Files
zombie-game/app/Game/GameSimulator.php
T
2026-09-07 20:37:40 +02:00

309 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Game;
use App\Events\GameRoomError;
use App\Events\GameSnapshot;
/**
* Owns the in-memory world (rooms, players, inputs) and advances it forward.
*
* Queue draining (Redis I/O) is the concern of the caller; this class only
* applies already-decoded control/input frames and steps the sim.
*/
final class GameSimulator
{
public const INPUT_QUEUE = 'game:in:%s';
public const CONTROL_QUEUE = 'game:ctrl:%s';
private const SNAPSHOT_EVERY = 2;
/** @var array<string, array{room: Room, engine: GameEngine, spawner: Spawner, inputs: array<string, InputState>}> */
private array $world = [];
private int $frame = 0;
/** @var callable(Room, array<int, mixed>, array<int, mixed>, array<int, mixed>): void */
private $broadcaster;
/** @var callable(string, string): void */
private $errorReporter;
public function __construct(?callable $broadcaster = null, ?callable $errorReporter = null)
{
$this->broadcaster = $broadcaster ?? function (Room $room, array $players, array $entities, array $events): void {
GameSnapshot::dispatch($room, $players, $entities, $events);
};
$this->errorReporter = $errorReporter ?? static function (string $roomId, string $code): void {
GameRoomError::dispatch($roomId, $code);
};
}
/** @return array<string, string> room id to Room id (for introspection/tests) */
public function roomIds(): array
{
return array_keys($this->world);
}
public function frame(): int
{
return $this->frame;
}
public function room(string $id): ?Room
{
return $this->world[$id]['room'] ?? null;
}
public function applyControl(string $roomId, array $frame): void
{
$type = $frame['type'] ?? null;
if ($type === 'create') {
$this->create($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player'));
} elseif ($type === 'join') {
$this->join($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player'));
} elseif ($type === 'leave') {
$this->leave($roomId, (string) ($frame['id'] ?? ''));
} elseif ($type === 'start') {
$entry = $this->world[$roomId] ?? null;
if ($entry !== null) {
$room = $entry['room'];
if (
$room->state === Room::STATE_LOBBY
&& $room->players !== []
&& (string) ($frame['id'] ?? '') === $room->hostId
) {
$room->state = Room::STATE_PLAYING;
$room->pickupTimer = 8.0;
$room->spawnTimer = 0.0;
}
}
} elseif ($type === 'reset') {
$this->reset($roomId, (string) ($frame['id'] ?? ''));
}
}
/** Host-only: create a brand-new room with this client as host. */
private function create(string $roomId, string $playerId, string $name): void
{
if ($playerId === '') {
return;
}
if (! isset($this->world[$roomId])) {
$this->createRoom($roomId, $playerId, $name);
return;
}
$room = $this->world[$roomId]['room'];
if ($room->hostId !== $playerId) {
$this->reject($roomId, 'room_exists');
return;
}
$this->addPlayer($roomId, $playerId, $name);
}
/** Guests may only join rooms that already exist and are open. */
private function join(string $roomId, string $playerId, string $name): void
{
if ($playerId === '') {
return;
}
if (! isset($this->world[$roomId])) {
$this->reject($roomId, 'room_not_found');
return;
}
$room = $this->world[$roomId]['room'];
if ($room->state !== Room::STATE_LOBBY) {
$this->broadcastRoom($room);
$this->reject($roomId, 'room_not_open');
return;
}
if (isset($room->players[$playerId])) {
return;
}
if (count($room->players) >= $room->maxPlayers) {
$this->broadcastRoom($room);
$this->reject($roomId, 'room_full');
return;
}
$this->addPlayer($roomId, $playerId, $name);
}
private function createRoom(string $roomId, string $playerId, string $name): void
{
$room = new Room($roomId);
$room->state = Room::STATE_LOBBY;
$room->hostId = $playerId;
$spawner = new Spawner;
$this->world[$roomId] = [
'room' => $room,
'engine' => new GameEngine($room, $spawner),
'spawner' => $spawner,
'inputs' => [],
];
$this->addPlayer($roomId, $playerId, $name);
}
private function addPlayer(string $roomId, string $playerId, string $name): void
{
$entry = $this->world[$roomId];
$room = $entry['room'];
if (isset($room->players[$playerId])) {
return;
}
if (count($room->players) >= $room->maxPlayers || $room->state !== Room::STATE_LOBBY) {
$this->broadcastRoom($room);
return;
}
$input = new InputState;
$player = new Player($playerId, $name, $input, $room->entities, $room);
$room->addPlayer($player);
$entry['inputs'][$playerId] = $input;
$this->world[$roomId] = $entry;
$this->broadcastRoom($room);
}
private function reject(string $roomId, string $code): void
{
($this->errorReporter)($roomId, $code);
}
private function leave(string $roomId, string $playerId): void
{
if (! isset($this->world[$roomId])) {
return;
}
$entry = $this->world[$roomId];
$room = $entry['room'];
$player = $room->players[$playerId] ?? null;
if ($player !== null) {
$room->removePlayer($player);
if ($room->hostId === $playerId && $room->players !== []) {
$room->hostId = (string) array_key_first($room->players);
}
}
unset($entry['inputs'][$playerId]);
$this->world[$roomId] = $entry;
$this->broadcastRoom($room);
if ($room->players === []) {
unset($this->world[$roomId]);
}
}
/** Host-only: send a finished room back to the lobby for another round. */
private function reset(string $roomId, string $playerId): void
{
$room = $this->world[$roomId]['room'] ?? null;
if ($room === null || $playerId === '' || $room->hostId !== $playerId) {
return;
}
if ($room->state !== Room::STATE_OVER) {
return;
}
$room->reset();
$this->broadcastRoom($room);
}
public function applyInput(string $roomId, array $frame): void
{
$entry = $this->world[$roomId] ?? null;
if ($entry === null) {
return;
}
$input = $entry['inputs'][(string) ($frame['id'] ?? '')] ?? null;
if ($input === null) {
return;
}
$keys = is_array($frame['keys'] ?? null) ? $frame['keys'] : [];
$input->keys = array_values(array_filter($keys, 'is_string'));
$input->fire = (bool) ($frame['fire'] ?? false);
$input->autoAim = (bool) ($frame['autoAim'] ?? false);
$aim = $frame['aim'] ?? null;
if (is_array($aim)) {
$input->aim = new Vector2(
(float) ($aim['x'] ?? 0.0),
(float) ($aim['y'] ?? 0.0),
);
} else {
$input->aim = new Vector2(1.0, 0.0);
}
}
public function tick(float $dt): void
{
$this->frame++;
foreach (array_keys($this->world) as $roomId) {
$entry = $this->world[$roomId];
$room = $entry['room'];
if ($room->state !== Room::STATE_PLAYING) {
continue;
}
$entry['engine']->tick($dt);
if ($room->state === Room::STATE_OVER) {
$room->seq++;
$this->broadcastRoom($room);
} elseif ($this->frame % self::SNAPSHOT_EVERY === 0) {
$room->seq++;
$this->broadcastRoom($room);
}
}
}
public function broadcastRoom(Room $room): void
{
if ($room->players === []) {
return;
}
$players = [];
foreach ($room->players as $player) {
$players[] = $player->toSnapshot();
}
$entities = [];
foreach ($room->entities as $entity) {
if ($entity instanceof Player) {
continue;
}
if ($entity instanceof GameObject) {
$entities[] = $entity->toSnapshot();
}
}
($this->broadcaster)($room, $players, $entities, $room->flushEvents());
}
/** @return array<string, mixed>|null */
public function decode(string $payload): ?array
{
$frame = json_decode($payload, true);
return is_array($frame) ? $frame : null;
}
}