309 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|