initial commit

This commit is contained in:
armel committed 2026-09-07 20:37:40 +02:00
commit e8c3f9862a
193 files changed
+29795

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
<?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)));
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Tells a client why its room control frame was rejected (e.g. joining a
* room that does not exist). Broadcast to game.{room}, which the requesting
* client is already subscribed to.
*/
final class GameRoomError implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
public function __construct(
public readonly string $room,
public readonly string $code,
) {}
public function broadcastOn(): Channel
{
return new Channel('game.'.$this->room);
}
public function broadcastAs(): string
{
return 'room-error';
}
/** @return array{code: string, room: string} */
public function broadcastWith(): array
{
return [
'code' => $this->code,
'room' => $this->room,
];
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Game\Room;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final class GameSnapshot implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* @param array<string, mixed> $players
* @param array<int, mixed> $entities
* @param array<int, mixed> $events
*/
public function __construct(
public Room $room,
public array $players,
public array $entities,
public array $events,
) {
}
public function broadcastOn(): Channel
{
return new Channel('game.' . $this->room->id);
}
public function broadcastAs(): string
{
return 'snapshot';
}
/** @return array<string, mixed> */
public function broadcastWith(): array
{
return [
'state' => $this->room->state,
'elapsed' => round($this->room->elapsed, 2),
'seq' => $this->room->seq,
'players' => $this->players,
'entities' => $this->entities,
'events' => $this->events,
];
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\Player;
use App\Game\Room;
final class Boss extends Zombie
{
private float $spawnInterval;
private int $spawnCount;
private float $spawnTimer;
/** @var null|callable(float,float,EntityList,Room,?Player):void */
private $spawnZombie;
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
?Player $initialTarget,
array $config = [],
?callable $onDeath = null,
) {
parent::__construct($x, $y, $container, $room, $config, $onDeath);
if ($initialTarget !== null) {
$this->target = $initialTarget;
}
$this->spawnInterval = $config['spawnInterval'] ?? 5.0;
$this->spawnCount = $config['spawnCount'] ?? 1;
$this->spawnTimer = 3.0;
$this->spawnZombie = $config['spawnZombie'] ?? null;
}
public function kind(): string
{
return 'boss';
}
public function update(float $dt): void
{
parent::update($dt);
$this->spawnTimer -= $dt;
if ($this->spawnTimer > 0.0 || $this->spawnZombie === null) {
return;
}
$this->spawnTimer = $this->spawnInterval;
$minions = 0;
foreach ($this->container as $obj) {
if ($obj instanceof Zombie && ! ($obj instanceof self)) {
$minions++;
}
}
if ($minions >= 15) {
return;
}
for ($i = 0; $i < $this->spawnCount; $i++) {
$angle = ($i / $this->spawnCount) * M_PI * 2.0 + $this->rand01() * 0.6;
($this->spawnZombie)(
$this->pos->x + cos($angle) * 70.0,
$this->pos->y + sin($angle) * 70.0,
$this->container,
$this->room,
$this->target,
);
}
}
public function toSnapshot(): array
{
$data = parent::toSnapshot();
$data['type'] = 'boss';
return $data;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\GameObject;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
final class Bullet extends GameObject
{
private EntityList $container;
private Room $room;
public float $size;
public int $damage;
/** @var array<float> */
public array $color;
public Player $owner;
public function __construct(
float $x,
float $y,
Vector2 $direction,
EntityList $container,
Room $room,
Player $owner,
float $speed = 800.0,
float $size = 8.0,
int $damage = 1,
) {
parent::__construct($x, $y);
$this->container = $container;
$this->room = $room;
$this->owner = $owner;
$this->velocity = $direction->mulS($speed);
$this->size = $size;
$this->damage = $damage;
$this->color = [1.0, 0.2, 0.2, 1.0];
$this->width = $size;
$this->height = $size;
}
public function kind(): string
{
return 'bullet';
}
public function update(float $dt): void
{
parent::update($dt);
if (
$this->pos->x < -$this->size
|| $this->pos->x > $this->room->width + $this->size
|| $this->pos->y < -$this->size
|| $this->pos->y > $this->room->height + $this->size
) {
$this->container->remove($this);
}
}
public function toSnapshot(): array
{
return [
'eid' => $this->eid,
'd' => $this->kind(),
'x' => round($this->pos->x, 1),
'y' => round($this->pos->y, 1),
'vx' => round($this->velocity->x, 1),
'vy' => round($this->velocity->y, 1),
's' => $this->size,
];
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
final class Leaper extends Zombie
{
private float $dashRange;
private float $windupTime;
private float $dashTime;
private float $dashSpeed;
private float $dashCooldown;
private float $dashTimer = 0.0;
private float $windupTimer = 0.0;
private float $dashCooldownTimer = 0.0;
private Vector2 $dashDirection;
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
?Player $initialTarget,
array $config = [],
?callable $onDeath = null,
) {
parent::__construct($x, $y, $container, $room, $config, $onDeath);
if ($initialTarget !== null) {
$this->target = $initialTarget;
}
$this->dashRange = $config['dashRange'] ?? 300.0;
$this->windupTime = $config['windupTime'] ?? 0.35;
$this->dashTime = $config['dashTime'] ?? 0.28;
$this->dashSpeed = $config['dashSpeed'] ?? 680.0;
$this->dashCooldown = $config['dashCooldown'] ?? 1.8;
$this->dashDirection = new Vector2(1.0, 0.0);
}
public function kind(): string
{
return 'leaper';
}
public function update(float $dt): void
{
$this->time += $dt;
if ($this->updateEmerge($dt)) {
return;
}
$this->dashCooldownTimer = max(0.0, $this->dashCooldownTimer - $dt);
$target = $this->target;
$delta = $target !== null ? $target->pos->subV($this->pos) : Vector2::zero();
$distance = $delta->length();
$move = Vector2::zero();
if ($this->windupTimer > 0.0) {
$this->windupTimer -= $dt;
if ($this->windupTimer <= 0.0) {
$this->dashTimer = $this->dashTime;
$this->dashDirection = $distance > 1.0 ? $delta->divS($distance) : new Vector2(1.0, 0.0);
}
} elseif ($this->dashTimer > 0.0) {
$this->dashTimer -= $dt;
$move = $this->dashDirection->mulS($this->dashSpeed);
} elseif ($distance > 1.0) {
if ($distance < $this->dashRange && $this->dashCooldownTimer <= 0.0) {
$this->windupTimer = $this->windupTime;
} else {
$move = $delta->divS($distance)->mulS($this->speed);
}
}
$this->velocity = $move->addV($this->hitKnockback);
$this->move($dt);
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
$this->flash = max(0.0, $this->flash - $dt);
}
public function toSnapshot(): array
{
$data = parent::toSnapshot();
$data['dash'] = $this->dashTimer > 0.0;
$data['wind'] = $this->windupTimer > 0.0;
return $data;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\Player;
use App\Game\Room;
final class MegaTank extends Zombie
{
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
?Player $initialTarget,
array $config = [],
?callable $onDeath = null,
) {
$spawnCount = $config['spawnCount'] ?? 3;
$spawnOnDeath = $config['spawnOnDeath'] ?? null;
parent::__construct($x, $y, $container, $room, $config, $onDeath ?? function (Zombie $mega) use ($spawnCount, $spawnOnDeath): void {
if ($spawnOnDeath === null) {
return;
}
for ($i = 0; $i < $spawnCount; $i++) {
$angle = ($i / $spawnCount) * M_PI * 2.0;
$offset = $mega->size + 28.0;
$spawnOnDeath(
$mega->pos->x + cos($angle) * $offset,
$mega->pos->y + sin($angle) * $offset,
$mega->container,
$mega->room,
$mega->target,
);
}
});
if ($initialTarget !== null) {
$this->target = $initialTarget;
}
}
public function kind(): string
{
return 'megatank';
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
final class MegaWitch extends Witch
{
private float $novaInterval;
private int $novaCount;
private float $novaProjectileSpeed;
private float $novaTimer;
private float $novaRotation;
/** @var array<int, array{0:float, 1:\Closure}> */
private array $pendingNova = [];
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
?Player $initialTarget,
array $config = [],
?callable $onDeath = null,
) {
$spawnCount = $config['spawnCount'] ?? 3;
$spawnOnDeath = $config['spawnOnDeath'] ?? null;
parent::__construct($x, $y, $container, $room, $initialTarget, $config, $onDeath ?? function (Zombie $mega) use ($spawnCount, $spawnOnDeath): void {
if ($spawnOnDeath === null) {
return;
}
for ($i = 0; $i < $spawnCount; $i++) {
$angle = ($i / $spawnCount) * M_PI * 2.0;
$offset = $mega->size + 30.0;
$spawnOnDeath(
$mega->pos->x + cos($angle) * $offset,
$mega->pos->y + sin($angle) * $offset,
$mega->container,
$mega->room,
$mega->target,
);
}
});
if ($initialTarget !== null) {
$this->target = $initialTarget;
}
$this->novaInterval = $config['novaInterval'] ?? 5.0;
$this->novaCount = $config['novaCount'] ?? 16;
$this->novaProjectileSpeed = $config['novaProjectileSpeed'] ?? 250.0;
$this->novaTimer = $this->novaInterval * 0.5;
$this->novaRotation = $config['novaRotation'] ?? $this->rand01() * M_PI * 2.0;
}
public function kind(): string
{
return 'megawitch';
}
public function update(float $dt): void
{
parent::update($dt);
$target = $this->target;
if ($target !== null && $target->dead) {
return;
}
foreach (array_keys($this->pendingNova) as $i) {
$this->pendingNova[$i][0] -= $dt;
if ($this->pendingNova[$i][0] <= 0.0) {
$closure = $this->pendingNova[$i][1];
unset($this->pendingNova[$i]);
$closure();
}
}
$this->novaTimer -= $dt;
if ($this->novaTimer <= 0.0) {
$this->novaTimer = $this->novaInterval * (0.85 + $this->rand01() * 0.3);
$this->nova();
}
}
private function nova(): void
{
$lost = 1.0 - max(0.0, $this->health / max(1, $this->maxHealth));
$count = max(6, (int) round($this->novaCount * (0.75 + 0.25 * $lost)));
$speed = $this->novaProjectileSpeed * (1.0 + $lost * 0.45);
$size = max(8.0, $this->size * 0.3);
$color = [1.0, 0.5, 0.3, 1.0];
$this->novaRotation += 0.36;
$fireRing = function (float $offset) use ($count, $speed, $size, $color): void {
for ($i = 0; $i < $count; $i++) {
$angle = ($i / $count) * M_PI * 2.0 + $this->novaRotation + $offset;
$direction = new Vector2(cos($angle), sin($angle));
$this->container->append(new WitchProjectile(
$this->pos->x,
$this->pos->y,
$direction,
$this->container,
$this->room,
$speed,
$size,
$color,
));
}
};
$fireRing(0.0);
$this->pendingNova[] = [0.22, function () use ($fireRing, $count): void {
$fireRing(M_PI / $count);
}];
if ($lost > 0.34) {
$this->pendingNova[] = [0.44, function () use ($fireRing): void {
$fireRing(0.0);
}];
}
}
public function toSnapshot(): array
{
$data = parent::toSnapshot();
$data['charge'] = 1.0 - max(0.0, $this->novaTimer) / $this->novaInterval;
if ($data['hp'] < $data['mhp'] && $data['charge'] > 0.72) {
$data['nova'] = true;
}
return $data;
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\GameObject;
use App\Game\Player;
use App\Game\Room;
final class Pickup extends GameObject
{
public const TYPES = [
'rapid' => 'Rapid Fire',
'damage' => 'Damage +1',
'multishot' => 'Multishot',
'speed' => 'Bullet Speed',
];
private EntityList $container;
private Room $room;
public string $type;
public float $life;
private float $time = 0.0;
public float $collectRadius;
public float $size;
/** @var array<float> */
public array $color;
public function __construct(
float $x,
float $y,
string $type,
EntityList $container,
Room $room,
float $life = 10.0,
float $collectRadius = 46.0,
) {
parent::__construct($x, $y);
$this->container = $container;
$this->room = $room;
$this->type = $type;
$this->life = $life;
$this->collectRadius = $collectRadius;
$this->time = 0.0;
$this->size = 16.0;
$this->width = $this->size;
$this->height = $this->size;
$this->color = self::colorFor($type);
}
public function kind(): string
{
return 'pickup';
}
public static function colorFor(string $type): array
{
return match ($type) {
'rapid' => [1.0, 0.8, 0.2, 1.0],
'damage' => [1.0, 0.3, 0.3, 1.0],
'multishot' => [0.2, 0.8, 1.0, 1.0],
'speed' => [0.6, 0.9, 1.0, 1.0],
default => [1.0, 1.0, 1.0, 1.0],
};
}
public function collectableBy(Player $player): bool
{
if ($player instanceof Player && ! $player->dead) {
return $player->maxUpgradeLevel > ($player->upgrades[$this->type] ?? 0);
}
return false;
}
public function update(float $dt): void
{
$this->time += $dt;
if ($this->time >= $this->life) {
$this->container->remove($this);
}
}
public function remaining(): float
{
return max(0.0, $this->life - $this->time);
}
public function toSnapshot(): array
{
return [
'eid' => $this->eid,
'd' => $this->kind(),
'x' => round($this->pos->x, 1),
'y' => round($this->pos->y, 1),
't' => $this->type,
'life' => round($this->remaining(), 2),
];
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
class Witch extends Zombie
{
public float $preferredDistance;
public float $fireInterval;
public float $projectileSpeed;
public int $projectileCount;
public float $projectileSpread;
/** @var array<float> */
public array $projectileColor;
protected float $fireTimer = 0.0;
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
?Player $initialTarget,
array $config = [],
?callable $onDeath = null,
) {
parent::__construct($x, $y, $container, $room, $config, $onDeath);
if ($initialTarget !== null) {
$this->target = $initialTarget;
}
$this->preferredDistance = $config['preferredDistance'] ?? 220.0;
$this->fireInterval = $config['fireInterval'] ?? 2.2;
$this->projectileSpeed = $config['projectileSpeed'] ?? 220.0;
$this->projectileCount = $config['projectileCount'] ?? 1;
$this->projectileSpread = $config['projectileSpread'] ?? 0.08;
$this->projectileColor = $config['projectileColor'] ?? [0.7, 0.2, 1.0, 1.0];
}
public function kind(): string
{
return 'witch';
}
public function update(float $dt): void
{
$this->time += $dt;
if ($this->updateEmerge($dt)) {
return;
}
$this->fireTimer -= $dt;
$target = $this->target;
if ($target === null) {
return;
}
$delta = $target->pos->subV($this->pos);
$distance = $delta->length();
$move = Vector2::zero();
if ($distance > 1.0) {
$dir = $delta->divS($distance);
if ($distance < $this->preferredDistance) {
$move = $dir->mulS(-$this->speed);
} elseif ($distance > $this->preferredDistance + 40.0) {
$move = $dir->mulS($this->speed);
}
}
$this->velocity = $move->addV($this->hitKnockback);
$this->move($dt);
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
$this->flash = max(0.0, $this->flash - $dt);
if ($this->fireTimer <= 0.0 && ! $target->dead && $distance < 480.0) {
$this->fireTimer = $this->fireInterval * (0.8 + $this->rand01() * 0.4);
$this->fire();
}
}
protected function onEmergeComplete(): void
{
$this->fireTimer = $this->fireInterval * (0.4 + $this->rand01() * 0.4);
}
public function fire(): void
{
$target = $this->target;
if ($target === null) {
return;
}
$aim = $target->pos->subV($this->pos);
$aimAngle = $aim->lengthSquared() > 1.0 ? atan2($aim->y, $aim->x) : $this->time;
$offset = (($this->projectileCount - 1) / 2.0) * $this->projectileSpread;
for ($i = 0; $i < $this->projectileCount; $i++) {
$angle = $aimAngle - $offset + $i * $this->projectileSpread;
$direction = new Vector2(cos($angle), sin($angle));
$this->container->append(new WitchProjectile(
$this->pos->x,
$this->pos->y,
$direction,
$this->container,
$this->room,
$this->projectileSpeed,
$this->size * 0.4,
$this->projectileColor,
));
}
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\GameObject;
use App\Game\Room;
use App\Game\Vector2;
final class WitchProjectile extends GameObject
{
private EntityList $container;
private Room $room;
public float $size;
/** @var array<float> */
public array $color;
private float $time = 0.0;
public function __construct(
float $x,
float $y,
Vector2 $direction,
EntityList $container,
Room $room,
float $speed = 220.0,
float $size = 12.0,
array $color = [0.7, 0.2, 1.0, 1.0],
) {
parent::__construct($x, $y);
$this->container = $container;
$this->room = $room;
$this->velocity = $direction->mulS($speed);
$this->size = $size;
$this->color = $color;
$this->width = $size;
$this->height = $size;
}
public function kind(): string
{
return 'projectile';
}
public function update(float $dt): void
{
parent::update($dt);
$this->time += $dt;
if (
$this->pos->x < -$this->size
|| $this->pos->x > $this->room->width + $this->size
|| $this->pos->y < -$this->size
|| $this->pos->y > $this->room->height + $this->size
) {
$this->container->remove($this);
}
}
public function toSnapshot(): array
{
return [
'eid' => $this->eid,
'd' => $this->kind(),
'x' => round($this->pos->x, 1),
'y' => round($this->pos->y, 1),
'vx' => round($this->velocity->x, 1),
'vy' => round($this->velocity->y, 1),
's' => $this->size,
];
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Game\Entities;
use App\Game\EntityList;
use App\Game\GameObject;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
class Zombie extends GameObject
{
public ?Player $target = null;
protected EntityList $container;
protected Room $room;
public float $speed;
public float $size;
/** @var array<float> */
public array $baseColor;
public int $health;
public int $maxHealth;
public float $knockback;
public float $knockbackDamping;
public int $scoreValue;
/** @var null|callable(Zombie):void */
public $onDeath;
protected Vector2 $hitKnockback;
protected float $flash = 0.0;
protected float $time = 0.0;
public float $broken;
protected float $emergeTotal;
protected float $emerge;
public function __construct(
float $x,
float $y,
EntityList $container,
Room $room,
array $config = [],
?callable $onDeath = null,
) {
parent::__construct($x, $y);
$this->container = $container;
$this->room = $room;
$this->speed = $config['speed'] ?? 110.0;
$this->size = $config['size'] ?? 24.0;
$this->baseColor = $config['color'] ?? [0.15, 0.65, 0.2, 1.0];
$this->health = $config['health'] ?? 1;
$this->maxHealth = $this->health;
$this->knockback = $config['knockback'] ?? 260.0;
$this->knockbackDamping = $config['knockbackDamping'] ?? 10.0;
$this->scoreValue = $config['scoreValue'] ?? 1;
$emergeTime = $config['emergeTime'] ?? 0.0;
$this->emergeTotal = $emergeTime;
$this->emerge = $emergeTime;
$this->onDeath = $onDeath;
$this->broken = rand(0, 100000) / 100000.0 * M_PI * 2.0;
$this->hitKnockback = Vector2::zero();
$this->width = $this->size;
$this->height = $this->size;
}
public function kind(): string
{
return 'zombie';
}
public function die(): void
{
if ($this->onDeath !== null) {
($this->onDeath)($this);
}
$this->removeAllReferences();
$this->container->remove($this);
}
protected function removeAllReferences(): void {}
public function hit(Vector2 $impulse, int $damage = 1): bool
{
$this->health -= $damage;
if ($this->health < 0) {
$this->health = 0;
}
$this->hitKnockback = $impulse->mulS($this->knockback);
$this->flash = 0.08;
return $this->health <= 0;
}
public function update(float $dt): void
{
$this->time += $dt;
if ($this->updateEmerge($dt)) {
return;
}
if ($this->target === null) {
return;
}
$delta = $this->target->pos->subV($this->pos);
$distance = $delta->length();
$chaseVelocity = Vector2::zero();
if ($distance > 1.0) {
$chaseVelocity = $delta->divS($distance)->mulS($this->speed);
}
$this->velocity = $chaseVelocity->addV($this->hitKnockback);
parent::update($dt);
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
$this->flash = max(0.0, $this->flash - $dt);
}
public function updateEmerge(float $dt): bool
{
if ($this->emerge <= 0.0) {
return false;
}
$this->emerge -= $dt;
if ($this->emerge <= 0.0) {
$this->emerge = 0.0;
$this->onEmergeComplete();
}
return true;
}
protected function onEmergeComplete(): void {}
protected function rand01(): float
{
return random_int(0, 1000000) / 1000000.0;
}
public function toSnapshot(): array
{
$snapshot = [
'eid' => $this->eid,
'd' => $this->kind(),
'x' => round($this->pos->x, 1),
'y' => round($this->pos->y, 1),
's' => $this->size,
'hp' => $this->health,
'mhp' => $this->maxHealth,
'c' => array_slice($this->baseColor, 0, 3),
];
if ($this->emergeTotal > 0.0) {
$snapshot['et'] = round($this->emergeTotal, 2);
$snapshot['er'] = round($this->emerge, 2);
}
return $snapshot;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Game;
final class EntityList extends \ArrayObject
{
public function remove(GameObject $gameObject): void
{
foreach (array_keys($this->getArrayCopy()) as $key) {
if ($this[$key] === $gameObject) {
$this->offsetUnset($key);
break;
}
}
}
public function findByKind(string $kind): array
{
$found = [];
foreach ($this as $entity) {
if ($entity->kind() === $kind) {
$found[] = $entity;
}
}
return $found;
}
/** Snapshot copy so entities added mid-iteration are not touched. */
public function copy(): array
{
$copy = [];
foreach ($this as $entity) {
$copy[] = $entity;
}
return $copy;
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Game;
use App\Game\Entities\Boss;
use App\Game\Entities\Bullet;
use App\Game\Entities\Pickup;
use App\Game\Entities\Witch;
use App\Game\Entities\WitchProjectile;
use App\Game\Entities\Zombie;
final class GameEngine
{
private const PICKUP_INTERVAL = 10.0;
private const MAX_ZOMBIES = 30;
public function __construct(
private readonly Room $room,
private readonly Spawner $spawner,
) {}
public function tick(float $dt): void
{
if ($this->room->state !== Room::STATE_PLAYING) {
return;
}
$this->room->elapsed += $dt;
$this->room->bossCooldown = max(0.0, $this->room->bossCooldown - $dt);
$this->assignTargets();
$this->updateEntities($dt);
$this->updateSpawns($dt);
$this->updatePickupTidy($dt);
$this->resolveBulletHits();
$this->resolveProjectileHits();
$this->resolvePlayerContact();
$this->resolvePickups();
}
private function assignTargets(): void
{
$alive = $this->room->alivePlayers();
if ($alive === []) {
return;
}
foreach ($this->room->entities as $entity) {
if (! $entity instanceof Zombie) {
continue;
}
$entity->target = $this->nearestPlayer($entity, $alive);
}
}
/** @param array<int, Player> $alive */
private function nearestPlayer(Zombie $zombie, array $alive): ?Player
{
$best = null;
$bestDist = INF;
foreach ($alive as $player) {
$dist = $player->pos->subV($zombie->pos)->lengthSquared();
if ($dist < $bestDist) {
$bestDist = $dist;
$best = $player;
}
}
return $best;
}
private function updateEntities(float $dt): void
{
// Players first so bullets spawn before resolution this frame.
foreach ($this->room->players as $player) {
if (! $player->dead) {
$player->update($dt);
}
}
foreach ($this->room->entities->copy() as $entity) {
if ($entity instanceof Player) {
continue;
}
$entity->update($dt);
}
}
private function updateSpawns(float $dt): void
{
$this->room->spawnTimer += $dt;
$interval = $this->spawner->spawnInterval($this->room->elapsed);
if ($this->room->spawnTimer >= $interval) {
$this->room->spawnTimer = 0.0;
$zombieCount = 0;
foreach ($this->room->entities as $entity) {
if ($entity instanceof Zombie) {
$zombieCount++;
}
}
if ($zombieCount < self::MAX_ZOMBIES) {
$this->spawner->spawnZombie($this->room);
}
}
}
private function updatePickupTidy(float $dt): void
{
foreach ($this->room->entities->copy() as $pickup) {
if (! $pickup instanceof Pickup) {
continue;
}
$anyNeeds = false;
foreach ($this->room->alivePlayers() as $player) {
if (($player->upgrades[$pickup->type] ?? 0) < $player->maxUpgradeLevel) {
$anyNeeds = true;
break;
}
}
if (! $anyNeeds) {
$pickup->removeFrom($this->room->entities);
}
}
$this->room->pickupTimer -= $dt;
if ($this->room->pickupTimer <= 0.0) {
$this->room->pickupTimer = self::PICKUP_INTERVAL;
if ($this->room->alivePlayers() !== []) {
$this->spawner->spawnPickup($this->room);
}
}
}
private function resolveBulletHits(): void
{
foreach ($this->room->entities->copy() as $bullet) {
if (! $bullet instanceof Bullet) {
continue;
}
foreach ($this->room->entities->copy() as $zombie) {
if (! $zombie instanceof Zombie || ! $bullet->intersects($zombie)) {
continue;
}
$impulse = $zombie->pos->subV($bullet->pos);
$impulse = $impulse->lengthSquared() > 1.0
? $impulse->divS($impulse->length())
: new Vector2(1.0, 0.0);
$bullet->removeFrom($this->room->entities);
$this->room->pushEvent('bullethit', [
'x' => $bullet->pos->x,
'y' => $bullet->pos->y,
'color' => array_slice($zombie->baseColor, 0, 3),
]);
if (! $zombie->hit($impulse, $bullet->damage)) {
break;
}
$zombie->die();
$owner = $bullet->owner;
if (! $owner->dead) {
$owner->addScore($zombie->scoreValue);
}
$killColor = array_slice($zombie->baseColor, 0, 3);
$this->room->pushEvent('kill', [
'x' => $zombie->pos->x,
'y' => $zombie->pos->y,
'color' => $killColor,
'boss' => $zombie instanceof Boss,
'big' => $zombie->maxHealth > 1,
]);
if ($zombie instanceof Boss) {
$this->room->nextBossScore *= 2.0;
$this->room->bossCooldown = 60.0;
$this->room->pushEvent('bossdefeated', [
'x' => $zombie->pos->x,
'y' => $zombie->pos->y,
]);
} elseif (
$owner->score >= $this->room->nextBossScore
&& $this->room->bossCooldown <= 0.0
&& ! $this->room->hasBoss()
) {
$this->spawner->spawnBoss($this->room);
}
break;
}
}
}
private function resolveProjectileHits(): void
{
foreach ($this->room->entities->copy() as $projectile) {
if (! $projectile instanceof WitchProjectile) {
continue;
}
foreach ($this->room->alivePlayers() as $player) {
if (! $projectile->intersects($player)) {
continue;
}
$projectile->removeFrom($this->room->entities);
$player->takeDamage();
$this->room->pushEvent('playerdamage', [
'x' => $projectile->pos->x,
'y' => $projectile->pos->y,
'id' => $player->id,
]);
if ($player->dead) {
$this->room->pushEvent('playerdeath', [
'x' => $player->pos->x,
'y' => $player->pos->y,
'id' => $player->id,
]);
$player->removeFrom($this->room->entities);
$this->checkGameOver();
}
}
}
}
private function resolvePlayerContact(): void
{
foreach ($this->room->entities->copy() as $zombie) {
if (! $zombie instanceof Zombie) {
continue;
}
foreach ($this->room->alivePlayers() as $player) {
if (! $zombie->intersects($player)) {
continue;
}
if (! $zombie instanceof Boss && ! $zombie instanceof Witch) {
$zombie->removeFrom($this->room->entities);
$this->room->pushEvent('bloodpool', [
'x' => $zombie->pos->x,
'y' => $zombie->pos->y,
's' => $zombie->size,
]);
}
$player->takeDamage();
$this->room->pushEvent('playerdamage', [
'x' => $player->pos->x,
'y' => $player->pos->y,
'id' => $player->id,
]);
if ($player->dead) {
$this->room->pushEvent('playerdeath', [
'x' => $player->pos->x,
'y' => $player->pos->y,
'id' => $player->id,
]);
$player->removeFrom($this->room->entities);
$this->checkGameOver();
}
}
}
}
private function resolvePickups(): void
{
foreach ($this->room->entities->copy() as $pickup) {
if (! $pickup instanceof Pickup) {
continue;
}
foreach ($this->room->alivePlayers() as $player) {
if (! $this->pickupIntersects($pickup, $player)) {
continue;
}
$maxed = ($player->upgrades[$pickup->type] ?? 0) >= $player->maxUpgradeLevel;
if ($maxed) {
$this->room->pushEvent('pickupmax', [
'x' => $player->pos->x,
'y' => $player->pos->y,
'type' => $pickup->type,
'id' => $player->id,
]);
continue;
}
$pickup->removeFrom($this->room->entities);
$player->applyUpgrade($pickup->type);
$this->room->pushEvent('pickup', [
'x' => $pickup->pos->x,
'y' => $pickup->pos->y,
'type' => $pickup->type,
'id' => $player->id,
]);
break;
}
}
}
private function pickupIntersects(Pickup $pickup, Player $player): bool
{
$dx = $pickup->pos->x - $player->pos->x;
$dy = $pickup->pos->y - $player->pos->y;
return $dx * $dx + $dy * $dy <= $pickup->collectRadius * $pickup->collectRadius
|| $pickup->intersects($player);
}
private function checkGameOver(): void
{
if ($this->room->alivePlayers() !== []) {
return;
}
$this->room->state = Room::STATE_OVER;
foreach ($this->room->entities->copy() as $entity) {
if ($entity instanceof Zombie) {
$entity->removeFrom($this->room->entities);
}
}
$this->room->pushEvent('gameover');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Game;
abstract class GameObject
{
private static int $nextId = 0;
public readonly int $eid;
public Vector2 $pos;
public Vector2 $velocity;
public float $width;
public float $height;
public function __construct(float $x = 0.0, float $y = 0.0)
{
$this->eid = ++self::$nextId;
$this->pos = new Vector2($x, $y);
$this->velocity = Vector2::zero();
$this->width = 0.0;
$this->height = 0.0;
}
public function update(float $dt): void
{
$this->move($dt);
}
/** Integrate position from velocity; used directly by subclasses that override update(). */
public function move(float $dt): void
{
$this->pos = new Vector2(
$this->pos->x + $this->velocity->x * $dt,
$this->pos->y + $this->velocity->y * $dt,
);
}
public function intersects(GameObject $other): bool
{
return abs($this->pos->x - $other->pos->x) * 2.0 < $this->width + $other->width
&& abs($this->pos->y - $other->pos->y) * 2.0 < $this->height + $other->height;
}
public function removeFrom(EntityList $container): void
{
$container->remove($this);
}
abstract public function kind(): string;
}
+308
View File
@@ -0,0 +1,308 @@
<?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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Game;
final class InputState
{
/** @var array<string> Currently-held keys (lowercase: w/a/s/d/space/shift). */
public array $keys;
/** Normalized aim direction in world space, sent by the client. */
public Vector2 $aim;
public bool $fire;
public bool $autoAim;
public function __construct(
array $keys = [],
?Vector2 $aim = null,
bool $fire = false,
bool $autoAim = false,
) {
$this->keys = $keys;
$this->aim = $aim ?? new Vector2(1.0, 0.0);
$this->fire = $fire;
$this->autoAim = $autoAim;
}
public function reset(?Vector2 $aim = null): void
{
$this->keys = [];
$this->aim = $aim ?? new Vector2(1.0, 0.0);
$this->fire = false;
$this->autoAim = false;
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Game;
use App\Game\Entities\Bullet;
use App\Game\Entities\Zombie;
final class Player extends GameObject
{
public InputState $input;
private EntityList $container;
private Room $room;
public float $moveSpeed;
public float $fireRate;
public float $recoilSpeed;
public float $recoilDamping;
public float $bulletSpeed;
public float $bulletSize;
public int $bulletCount;
public float $bulletSpread;
public int $damage;
private float $baseFireRate;
private int $baseDamage;
private int $baseBulletCount;
private float $baseBulletSpeed;
public int $maxUpgradeLevel;
/** @var array<string, int> */
public array $upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0];
public float $size;
public string $name;
public string $id;
public int $health;
public int $maxHealth;
public int $score = 0;
public bool $dead = false;
public bool $autoAim = false;
private float $cooldown = 0.0;
private Vector2 $aimDirection;
private Vector2 $recoil;
private float $damageCooldown = 0.0;
private float $time = 0.0;
public function __construct(
string $playerId,
string $name,
InputState $input,
EntityList $container,
Room $room,
array $config = [],
) {
parent::__construct(
$config['x'] ?? $room->width / 2.0,
$config['y'] ?? $room->height / 2.0,
);
$this->input = $input;
$this->container = $container;
$this->room = $room;
$this->id = $playerId;
$this->name = $name;
$this->moveSpeed = $config['moveSpeed'] ?? 420.0;
$this->fireRate = $config['fireRate'] ?? 0.2;
$this->recoilSpeed = $config['recoilSpeed'] ?? 100.0;
$this->recoilDamping = $config['recoilDamping'] ?? 15.0;
$this->bulletSpeed = $config['bulletSpeed'] ?? 800.0;
$this->bulletSize = $config['bulletSize'] ?? 8.0;
$this->bulletCount = $config['bulletCount'] ?? 1;
$this->bulletSpread = $config['bulletSpread'] ?? 0.09;
$this->damage = $config['damage'] ?? 1;
$this->baseFireRate = $this->fireRate;
$this->baseDamage = $this->damage;
$this->baseBulletCount = $this->bulletCount;
$this->baseBulletSpeed = $this->bulletSpeed;
$this->maxUpgradeLevel = $config['maxUpgradeLevel'] ?? 3;
$this->health = $config['health'] ?? 3;
$this->maxHealth = $this->health;
$this->size = $config['size'] ?? 24.0;
$this->width = $this->size;
$this->height = $this->size;
$this->aimDirection = new Vector2(1.0, 0.0);
$this->recoil = Vector2::zero();
}
public function kind(): string
{
return 'player';
}
public function update(float $dt): void
{
$this->time += $dt;
$movement = new Vector2(0.0, 0.0);
$keys = $this->input->keys;
if (in_array('w', $keys, true) || in_array('arrowup', $keys, true)) {
$movement = new Vector2($movement->x, $movement->y - 1.0);
}
if (in_array('s', $keys, true) || in_array('arrowdown', $keys, true)) {
$movement = new Vector2($movement->x, $movement->y + 1.0);
}
if (in_array('a', $keys, true) || in_array('arrowleft', $keys, true)) {
$movement = new Vector2($movement->x - 1.0, $movement->y);
}
if (in_array('d', $keys, true) || in_array('arrowright', $keys, true)) {
$movement = new Vector2($movement->x + 1.0, $movement->y);
}
$moveVelocity = Vector2::zero();
if ($movement->lengthSquared() > 0.0) {
$moveVelocity = $movement->normalize()->mulS($this->moveSpeed);
}
$this->velocity = $this->recoil->lengthSquared() > 1.0
? $moveVelocity->addV($this->recoil)
: $moveVelocity;
parent::update($dt);
$maxX = max($this->width / 2.0, 0.0);
$maxY = max($this->height / 2.0, 0.0);
$this->pos = new Vector2(
min(max($this->pos->x, $maxX), $this->room->width - $maxX),
min(max($this->pos->y, $maxY), $this->room->height - $maxY),
);
$aim = $this->input->aim;
$autoAimActive = $this->input->autoAim || $this->autoAim;
$hasTarget = false;
if ($autoAimActive) {
$nearest = $this->nearestZombie();
if ($nearest !== null) {
$hasTarget = true;
$toEnemy = $nearest->pos->subV($this->pos);
if ($toEnemy->lengthSquared() > 1.0) {
$aim = $toEnemy;
}
}
}
if ($aim->lengthSquared() > 1.0) {
$this->aimDirection = $aim->divS($aim->length());
} elseif ($aim->lengthSquared() > 0.01 && $this->input->aim !== null && ! $autoAimActive) {
// Raw normalized direction from the client when not auto-aiming.
$this->aimDirection = $aim->normalize();
}
$this->recoil = $this->recoil->mulS(max(0.0, 1 - $dt * $this->recoilDamping));
$this->damageCooldown = max(0.0, $this->damageCooldown - $dt);
$this->cooldown -= $dt;
if ($this->cooldown <= 0.0 && ($this->input->fire || ($autoAimActive && $hasTarget))) {
$this->cooldown = $this->fireRate;
$this->shoot();
}
}
public function nearestZombie(): ?Zombie
{
$best = null;
$bestDistSq = INF;
foreach ($this->container as $obj) {
if (! $obj instanceof Zombie) {
continue;
}
$delta = $obj->pos->subV($this->pos);
$distSq = $delta->lengthSquared();
if ($distSq < $bestDistSq) {
$bestDistSq = $distSq;
$best = $obj;
}
}
return $best;
}
public function shoot(): void
{
$aimAngle = atan2($this->aimDirection->y, $this->aimDirection->x);
$this->recoil = $this->aimDirection->mulS(-$this->recoilSpeed);
$offset = (($this->bulletCount - 1) / 2.0) * $this->bulletSpread;
for ($i = 0; $i < $this->bulletCount; $i++) {
$angle = $aimAngle - $offset + $i * $this->bulletSpread;
$direction = new Vector2(cos($angle), sin($angle));
$bullet = new Bullet(
$this->pos->x,
$this->pos->y,
$direction,
$this->container,
$this->room,
$this,
$this->bulletSpeed,
$this->bulletSize,
$this->damage,
);
$this->container->append($bullet);
}
}
public function applyUpgrade(string $type): bool
{
if (! array_key_exists($type, $this->upgrades)) {
return false;
}
if ($this->upgrades[$type] >= $this->maxUpgradeLevel) {
return false;
}
$this->upgrades[$type] += 1;
switch ($type) {
case 'rapid':
$this->fireRate = max(0.06, $this->fireRate * 0.8);
break;
case 'damage':
$this->damage += 1;
break;
case 'multishot':
$this->bulletCount = min(7, $this->bulletCount + 1);
break;
case 'speed':
$this->bulletSpeed = min(1800.0, $this->bulletSpeed * 1.15);
break;
}
return true;
}
public function toSnapshot(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'x' => round($this->pos->x, 1),
'y' => round($this->pos->y, 1),
'aimX' => round($this->aimDirection->x, 3),
'aimY' => round($this->aimDirection->y, 3),
'hp' => $this->health,
'mhp' => $this->maxHealth,
'score' => $this->score,
'dead' => $this->dead,
'host' => $this->room->hostId === $this->id,
'up' => $this->upgrades,
];
}
/** Reset per-round state back to lobby defaults (keeps id/name/host). */
public function resetForRound(): void
{
$this->pos = new Vector2($this->room->width / 2.0, $this->room->height / 2.0);
$this->velocity = Vector2::zero();
$this->recoil = Vector2::zero();
$this->aimDirection = new Vector2(1.0, 0.0);
$this->health = $this->maxHealth;
$this->dead = false;
$this->score = 0;
$this->upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0];
$this->fireRate = $this->baseFireRate;
$this->bulletSpeed = $this->baseBulletSpeed;
$this->bulletCount = $this->baseBulletCount;
$this->damage = $this->baseDamage;
$this->cooldown = 0.0;
$this->damageCooldown = 0.0;
$this->time = 0.0;
}
public function takeDamage(): void
{
if ($this->damageCooldown > 0.0) {
return;
}
$this->damageCooldown = 0.5;
$this->health -= 1;
if ($this->health <= 0 && ! $this->dead) {
$this->dead = true;
}
}
public function addScore(int $points): void
{
$this->score += $points;
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Game;
final class Room
{
public const STATE_LOBBY = 'lobby';
public const STATE_PLAYING = 'playing';
public const STATE_OVER = 'over';
public float $width = 1920.0;
public float $height = 1080.0;
public EntityList $entities;
/** @var array<string, Player> playerId => Player */
public array $players = [];
public string $state = self::STATE_LOBBY;
/** Player id of the room's host (the first player to join). */
public string $hostId = '';
public float $elapsed = 0.0;
public float $nextBossScore = 50.0;
public int $bossCount = 0;
public float $bossCooldown = 0.0;
public float $spawnTimer = 0.0;
public float $pickupTimer = 8.0;
/** @var array<int, array<string, mixed>> Queue of one-shot event descriptors for clients. */
public array $events = [];
public int $seq = 0;
public function __construct(
public readonly string $id,
public readonly int $maxPlayers = 8,
) {
$this->entities = new EntityList;
}
public function addPlayer(Player $player): void
{
$this->players[$player->id] = $player;
$this->entities->append($player);
}
public function removePlayer(Player $player): void
{
unset($this->players[$player->id]);
$this->entities->remove($player);
}
/** Reset round state so the room can be played again (host-initiated). */
public function reset(): void
{
$this->state = self::STATE_LOBBY;
$this->elapsed = 0.0;
$this->nextBossScore = 50.0;
$this->bossCount = 0;
$this->bossCooldown = 0.0;
$this->spawnTimer = 0.0;
$this->pickupTimer = 8.0;
$this->events = [];
$this->seq++;
foreach ($this->entities->copy() as $entity) {
if ($entity instanceof Player) {
continue;
}
$this->entities->remove($entity);
}
foreach ($this->players as $player) {
$player->resetForRound();
}
}
/** @return array<int, Player> */
public function alivePlayers(): array
{
$alive = [];
foreach ($this->players as $player) {
if (! $player->dead) {
$alive[] = $player;
}
}
return $alive;
}
public function highestScore(): int
{
$best = 0;
foreach ($this->players as $player) {
$best = max($best, $player->score);
}
return $best;
}
public function hasBoss(): bool
{
foreach ($this->entities as $entity) {
if ($entity instanceof Entities\Boss) {
return true;
}
}
return false;
}
public function pushEvent(string $kind, array $data = []): void
{
$this->events[] = ['k' => $kind, 'x' => $data['x'] ?? 0.0, 'y' => $data['y'] ?? 0.0, 'd' => $data];
}
public function flushEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace App\Game;
use App\Game\Entities\Boss;
use App\Game\Entities\Leaper;
use App\Game\Entities\MegaTank;
use App\Game\Entities\MegaWitch;
use App\Game\Entities\Pickup;
use App\Game\Entities\Witch;
use App\Game\Entities\Zombie;
final class Spawner
{
public function spawnInterval(float $elapsed): float
{
return max(0.35, 1.2 - $elapsed * 0.008);
}
public function speedMultiplier(float $elapsed): float
{
return min(2.2, 1.0 + $elapsed * 0.004);
}
private function tankChance(float $elapsed): float
{
return min(0.5, 0.15 + $elapsed * 0.002);
}
private function megaTankChance(float $elapsed): float
{
return min(0.22, $elapsed * 0.0015);
}
private function runnerChance(float $elapsed): float
{
return min(0.4, 0.2 + $elapsed * 0.001);
}
private function leaperChance(float $elapsed): float
{
return min(0.12, 0.04 + $elapsed * 0.06);
}
private function witchChance(float $elapsed): float
{
return min(0.3, 0.1 + $elapsed * 0.002);
}
private function megaWitchChance(float $elapsed): float
{
return $elapsed < 45.0 ? 0.0 : min(0.12, ($elapsed - 45.0) * 0.0008);
}
public function spawnZombie(Room $room): void
{
$elapsed = $room->elapsed;
$width = $room->width;
$height = $room->height;
$speedMultiplier = $this->speedMultiplier($elapsed);
$isMegaTank = $this->chance($this->megaTankChance($elapsed));
$isMegaWitch = ! $isMegaTank && $this->chance($this->megaWitchChance($elapsed));
$isWitch = ! $isMegaTank && ! $isMegaWitch && $this->chance($this->witchChance($elapsed));
$isRunner = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && $this->chance($this->runnerChance($elapsed));
$isLeaper = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && $this->chance($this->leaperChance($elapsed));
$isTank = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && ! $isLeaper && $this->chance($this->tankChance($elapsed));
$size = $isMegaWitch ? 52.0 : ($isWitch ? 30.0 : ($isRunner ? 14.0 : ($isLeaper ? 28.0 : ($isTank ? 36.0 : 24.0))));
[$x, $y] = $this->edgePosition($room, $size);
if ($isMegaTank) {
$room->entities->append(new MegaTank($x, $y, $room->entities, $room, $this->initialTarget($room), [
'speed' => 28.0 + $this->rand() * 12.0,
'size' => 64.0,
'health' => 18,
'knockback' => 90.0,
'scoreValue' => 15,
'spawnCount' => 3,
'color' => [0.55, 0.28, 0.12, 1.0],
'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
$container->append(new Zombie($sx, $sy, $container, $sroom, [
'speed' => (70.0 + $this->rand() * 60.0) * $this->speedMultiplier($sroom->elapsed),
'size' => 24.0,
'health' => 1,
'knockback' => 260.0,
'scoreValue' => 1,
'color' => [0.15, 0.65, 0.2, 1.0],
'emergeTime' => 0.8,
], fn (Zombie $z) => $z->target = $starget));
$sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.15, 0.65, 0.2, 1.0], 'scale' => 0.8]);
},
], null));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.28, 0.12, 1.0], 'scale' => 2.2]);
return;
}
if ($isMegaWitch) {
$megaPower = min(1.0, max(0.0, ($elapsed - 45.0) / 90.0));
$this->spawnMegaWitch($room, $x, $y, $speedMultiplier, $megaPower);
return;
}
if ($isWitch) {
$this->spawnWitch($room, $x, $y, $speedMultiplier);
return;
}
if ($isRunner) {
$room->entities->append(new Zombie($x, $y, $room->entities, $room, [
'speed' => (180.0 + $this->rand() * 60.0) * $speedMultiplier,
'size' => $size,
'health' => 1,
'knockback' => 380.0,
'scoreValue' => 2,
'color' => [0.85, 0.1, 0.2, 1.0],
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.85, 0.1, 0.2, 1.0], 'scale' => 0.9]);
return;
}
if ($isLeaper) {
$this->spawnLeaper($room, $x, $y, $speedMultiplier);
return;
}
$zombieColor = $isTank ? [0.3, 0.35, 0.15, 1.0] : [0.15, 0.65, 0.2, 1.0];
$room->entities->append(new Zombie($x, $y, $room->entities, $room, [
'speed' => ($isTank ? 45.0 : 80.0 + $this->rand() * 60.0) * $speedMultiplier,
'size' => $size,
'health' => $isTank ? 4 : 1,
'knockback' => $isTank ? 160.0 : 260.0,
'scoreValue' => $isTank ? 3 : 1,
'color' => $zombieColor,
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => $zombieColor, 'scale' => $isTank ? 1.5 : 1.0]);
}
public function spawnWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void
{
$room->entities->append(new Witch($x, $y, $room->entities, $room, $this->initialTarget($room), [
'speed' => (70.0 + $this->rand() * 40.0) * $speedMultiplier,
'size' => 30.0,
'health' => 5,
'knockback' => 180.0,
'scoreValue' => 4,
'color' => [0.55, 0.2, 0.8, 1.0],
'preferredDistance' => 220.0,
'fireInterval' => 2.2,
'projectileSpeed' => 220.0,
'emergeTime' => $emergeTime,
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]);
}
public function spawnMegaWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, float $power = 1.0): void
{
$room->entities->append(new MegaWitch($x, $y, $room->entities, $room, $this->initialTarget($room), [
'speed' => 55.0 * $speedMultiplier,
'size' => 52.0,
'health' => (int) round(8.0 + 14.0 * $power),
'knockback' => 130.0,
'scoreValue' => 12,
'color' => [0.8, 0.15, 0.7, 1.0],
'preferredDistance' => 240.0,
'fireInterval' => 2.0,
'projectileSpeed' => 240.0,
'projectileCount' => 3,
'projectileSpread' => 0.35,
'projectileColor' => [1.0, 0.3, 0.3, 1.0],
'novaInterval' => 6.0 - $power,
'novaCount' => (int) round(10.0 + 6.0 * $power),
'novaProjectileSpeed' => 210.0 + 40.0 * $power,
'spawnCount' => 3,
'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
$container->append(new Witch($sx, $sy, $container, $sroom, $starget, [
'speed' => 70.0 + $this->rand() * 40.0,
'size' => 30.0,
'health' => 5,
'knockback' => 180.0,
'scoreValue' => 4,
'color' => [0.55, 0.2, 0.8, 1.0],
'preferredDistance' => 220.0,
'fireInterval' => 2.2,
'projectileSpeed' => 220.0,
'emergeTime' => 1.1,
], fn (Zombie $z) => $z->target = $starget));
$sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]);
},
], null));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.8, 0.15, 0.7, 1.0], 'scale' => 2.2]);
}
public function spawnLeaper(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void
{
$room->entities->append(new Leaper($x, $y, $room->entities, $room, $this->initialTarget($room), [
'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier,
'size' => 28.0,
'health' => 3,
'knockback' => 240.0,
'scoreValue' => 6,
'color' => [0.15, 0.25, 0.75, 1.0],
'dashRange' => 300.0,
'windupTime' => 0.35,
'dashTime' => 0.28,
'dashSpeed' => 680.0,
'dashCooldown' => 1.8,
'emergeTime' => $emergeTime,
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.15, 0.25, 0.75, 1.0], 'scale' => 1.2]);
}
public function spawnBoss(Room $room): void
{
$width = $room->width;
$witchCount = min(4, 2 ** $room->bossCount);
$room->bossCount += 1;
$room->entities->append(new Boss($width / 2.0, -80.0, $room->entities, $room, $this->initialTarget($room), [
'speed' => 36.0 + $this->rand() * 10.0,
'size' => 90.0,
'health' => 80,
'knockback' => 40.0,
'scoreValue' => 50,
'color' => [0.45, 0.05, 0.1, 1.0],
'spawnInterval' => 5.0,
'spawnCount' => $witchCount,
'spawnZombie' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
if ($this->chance(0.5)) {
$container->append(new Witch($sx, $sy, $container, $sroom, $starget, [
'speed' => (70.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed),
'size' => 30.0,
'health' => 5,
'knockback' => 180.0,
'scoreValue' => 4,
'color' => [0.55, 0.2, 0.8, 1.0],
'preferredDistance' => 220.0,
'fireInterval' => 2.2,
'projectileSpeed' => 220.0,
'emergeTime' => 0.9,
], fn (Zombie $z) => $z->target = $starget));
} else {
$container->append(new Leaper($sx, $sy, $container, $sroom, $starget, [
'speed' => (80.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed),
'size' => 28.0,
'health' => 3,
'knockback' => 240.0,
'scoreValue' => 6,
'color' => [0.15, 0.25, 0.75, 1.0],
'dashRange' => 300.0,
'windupTime' => 0.35,
'dashTime' => 0.28,
'dashSpeed' => 680.0,
'dashCooldown' => 1.8,
'emergeTime' => 0.9,
], fn (Zombie $z) => $z->target = $starget));
}
},
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
$room->pushEvent('portal', ['x' => $width / 2.0, 'y' => -80.0, 'color' => [0.45, 0.05, 0.1, 1.0], 'scale' => 3.5]);
$room->pushEvent('boss', [
'x' => $width / 2.0,
'y' => $room->height / 2.0,
'title' => 'BOSS APPROACHES',
]);
}
public function spawnPickup(Room $room): void
{
$available = [];
foreach (array_keys(Pickup::TYPES) as $type) {
$needed = false;
foreach ($room->alivePlayers() as $player) {
if (($player->upgrades[$type] ?? 0) < $player->maxUpgradeLevel) {
$needed = true;
break;
}
}
if ($needed) {
$available[] = $type;
}
}
if ($available === []) {
return;
}
$margin = 60.0;
$x = $margin + $this->rand() * ($room->width - $margin * 2.0);
$y = $margin + $this->rand() * ($room->height - $margin * 2.0);
$type = $available[(int) floor($this->rand() * count($available))];
$room->entities->append(new Pickup($x, $y, $type, $room->entities, $room));
}
/** @return array{0:float, 1:float} */
private function edgePosition(Room $room, float $size): array
{
$width = $room->width;
$height = $room->height;
$edge = (int) floor($this->rand() * 4.0);
return match ($edge) {
0 => [$this->rand() * $width, -$size],
1 => [$this->rand() * $width, $height + $size],
2 => [-$size, $this->rand() * $height],
default => [$width + $size, $this->rand() * $height],
};
}
private function initialTarget(Room $room): ?Player
{
$alive = $room->alivePlayers();
return $alive === [] ? null : $alive[array_rand($alive)];
}
private function chance(float $probability): bool
{
return $this->rand() < $probability;
}
private function rand(): float
{
return random_int(0, 1000000) / 1000000.0;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Game;
final class Vector2
{
public function __construct(
public float $x,
public float $y,
) {}
public static function zero(): self
{
return new self(0.0, 0.0);
}
public function mulS(float $scalar): self
{
return new self($this->x * $scalar, $this->y * $scalar);
}
public function mulV(self $v): self
{
return new self($this->x * $v->x, $this->y * $v->y);
}
public function divS(float $scalar): self
{
return new self($this->x / $scalar, $this->y / $scalar);
}
public function addS(float $scalar): self
{
return new self($this->x + $scalar, $this->y + $scalar);
}
public function addV(self $v): self
{
return new self($this->x + $v->x, $this->y + $v->y);
}
public function subS(float $scalar): self
{
return new self($this->x - $scalar, $this->y - $scalar);
}
public function subV(self $v): self
{
return new self($this->x - $v->x, $this->y - $v->y);
}
public function dot(self $v): float
{
return $this->x * $v->x + $this->y * $v->y;
}
public function abs(): self
{
return new self(abs($this->x), abs($this->y));
}
public function length(): float
{
return sqrt($this->dot($this));
}
public function lengthSquared(): float
{
return $this->dot($this);
}
public function normalize(): self
{
$length = $this->length();
if ($length < 1.0e-9) {
return new self(0.0, 0.0);
}
return new self($this->x / $length, $this->y / $length);
}
public function rotate(float $angle): self
{
$cos = cos($angle);
$sin = sin($angle);
return new self($this->x * $cos - $this->y * $sin, $this->x * $sin + $this->y * $cos);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
final class GameInputController extends Controller
{
public function __invoke(Request $request, string $room): JsonResponse
{
$playerId = (string) $request->input('id', '');
if ($playerId === '') {
return response()->json(['error' => 'missing id'], 422);
}
$keys = $request->input('keys');
$keys = is_array($keys)
? array_values(array_filter($keys, 'is_string'))
: [];
$aim = $request->input('aim');
$aim = is_array($aim)
? ['x' => (float) ($aim['x'] ?? 0.0), 'y' => (float) ($aim['y'] ?? 0.0)]
: ['x' => 1.0, 'y' => 0.0];
$frame = [
'id' => $playerId,
'keys' => $keys,
'aim' => $aim,
'fire' => (bool) $request->input('fire', false),
'autoAim' => (bool) $request->input('autoAim', false),
];
Redis::rpush(sprintf('game:in:%s', $room), json_encode($frame, JSON_THROW_ON_ERROR));
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use Illuminate\Support\Facades\Redis;
use Laravel\Reverb\Events\MessageReceived;
final class PushClientEventToInputQueue
{
private const INPUT_QUEUE = 'game:in:%s';
private const CONTROL_QUEUE = 'game:ctrl:%s';
private const CONTROL_TYPES = ['join', 'create', 'leave', 'start', 'reset'];
public function handle(MessageReceived $event): void
{
$payload = json_decode($event->message, true);
if (! is_array($payload)) {
return;
}
$clientEvent = $payload['event'] ?? null;
if (! is_string($clientEvent) || ! str_starts_with($clientEvent, 'client-')) {
return;
}
$channel = $payload['channel'] ?? null;
if (! is_string($channel)) {
return;
}
$roomId = $this->roomFromChannel($channel);
if ($roomId === null) {
return;
}
$data = $payload['data'] ?? [];
if (! is_array($data)) {
return;
}
$type = substr($clientEvent, strlen('client-'));
if ($type === 'input') {
$this->pushInput($roomId, $data);
return;
}
if (in_array($type, self::CONTROL_TYPES, true)) {
$frame = array_merge($data, ['type' => $type]);
Redis::rpush(sprintf(self::CONTROL_QUEUE, $roomId), json_encode($frame, JSON_THROW_ON_ERROR));
}
}
private function pushInput(string $roomId, array $data): void
{
if (! isset($data['id'])) {
return;
}
$frame = [
'id' => (string) $data['id'],
'keys' => is_array($data['keys'] ?? null) ? ($data['keys']) : [],
'aim' => is_array($data['aim'] ?? null)
? ['x' => (float) ($data['aim']['x'] ?? 0.0), 'y' => (float) ($data['aim']['y'] ?? 0.0)]
: ['x' => 1.0, 'y' => 0.0],
'fire' => (bool) ($data['fire'] ?? false),
'autoAim' => (bool) ($data['autoAim'] ?? false),
];
Redis::rpush(sprintf(self::INPUT_QUEUE, $roomId), json_encode($frame, JSON_THROW_ON_ERROR));
}
private function roomFromChannel(string $channel): ?string
{
// game.{room}.input
if (preg_match('#^game\.(.+?)\.input$#', $channel, $matches) !== 1) {
return null;
}
return $matches[1];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}