}> */ private array $world = []; private int $frame = 0; /** @var callable(Room, array, array, array): 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 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|null */ public function decode(string $payload): ?array { $frame = json_decode($payload, true); return is_array($frame) ? $frame : null; } }