130 lines
4.3 KiB
PHP
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)));
|
|
}
|
|
} |