Files
zombie-game/app/Console/Commands/GameSimulate.php
T
2026-09-07 20:37:40 +02:00

130 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Game\GameSimulator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
/**
* Long-running server-authoritative simulation loop.
*
* Reads control + input frames pushed to Redis (by the HTTP/reverb process),
* applies them to the world owned by GameSimulator, advances the sim at a
* fixed 60 Hz step, and broadcasts 30 Hz snapshots to the private game.{room}
* channel through the configured broadcaster.
*/
final class GameSimulate extends Command
{
protected $signature = 'game:simulate';
protected $description = 'Run the authoritative game simulation worker';
private const TICK_DT = 1.0 / 60.0;
private const MAX_TICKS_PER_LOOP = 8;
private float $accumulator = 0.0;
private bool $running = true;
private int $loop = 0;
public function handle(GameSimulator $simulator): int
{
$this->registerSignals();
$last = hrtime(true) / 1e9;
while ($this->running) {
$now = hrtime(true) / 1e9;
$delta = min(0.25, max(0.0, $now - $last));
$last = $now;
$this->accumulator += $delta;
$ticks = 0;
while ($this->accumulator >= self::TICK_DT && $ticks < self::MAX_TICKS_PER_LOOP) {
$this->drainQueues($simulator);
$simulator->tick(self::TICK_DT);
$this->accumulator -= self::TICK_DT;
$ticks++;
}
if (++$this->loop % 600 === 0) {
$this->heartbeat($simulator);
}
usleep(1000);
}
$this->info('Simulation worker stopped.');
return self::SUCCESS;
}
private function registerSignals(): void
{
if (!function_exists('pcntl_signal')) {
$this->warn('pcntl unavailable; graceful shutdown disabled.');
return;
}
pcntl_async_signals(true);
pcntl_signal(SIGINT, function (): void {
$this->running = false;
});
pcntl_signal(SIGTERM, function (): void {
$this->running = false;
});
}
private function drainQueues(GameSimulator $simulator): void
{
$roomIds = $simulator->roomIds();
foreach (Redis::keys(sprintf(GameSimulator::CONTROL_QUEUE, '*')) as $key) {
if (preg_match('#^game:ctrl:(.+)$#', (string) $key, $matches) === 1) {
$roomIds[] = $matches[1];
}
}
$roomIds = array_values(array_unique($roomIds));
foreach ($roomIds as $roomId) {
while (($control = Redis::lpop(sprintf(GameSimulator::CONTROL_QUEUE, $roomId))) !== false && $control !== null) {
$frame = $simulator->decode($control);
if ($frame !== null) {
if ($this->getOutput()->isVerbose()) {
$this->line(sprintf(' ctrl %s <- %s', $roomId, (string) ($frame['type'] ?? '?')));
}
$simulator->applyControl($roomId, $frame);
}
}
if (!in_array($roomId, $simulator->roomIds(), true)) {
continue;
}
while (($input = Redis::lpop(sprintf(GameSimulator::INPUT_QUEUE, $roomId))) !== false && $input !== null) {
$frame = $simulator->decode($input);
if ($frame !== null) {
if ($this->getOutput()->isVerbose()) {
$this->line(sprintf(' in %s <- %s#%s', $roomId, (string) ($frame['id'] ?? '?'), implode('+', (array) ($frame['keys'] ?? []))));
}
$simulator->applyInput($roomId, $frame);
}
}
}
}
private function heartbeat(GameSimulator $simulator): void
{
if (!$this->getOutput()->isVerbose()) {
return;
}
$rooms = [];
foreach ($simulator->roomIds() as $roomId) {
$room = $simulator->room($roomId);
$rooms[] = sprintf('%s:%s(%d,%d)', $roomId, $room?->state ?? '?', count($room?->players ?? []), count($room?->entities ?? []));
}
$keys = Redis::keys('game:*') ?: [];
$this->line(sprintf('hb frame=%d loop=%d rooms=[%s] queueKeys=%d', $simulator->frame(), $this->loop, implode(',', $rooms), count($keys)));
}
}