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

+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Zombie;
use App\Game\EntityList;
use App\Game\Room;
use App\Game\Vector2;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EntityListTest extends TestCase
{
#[Test]
public function appendAndCount(): void
{
$room = new Room('r');
$list = new EntityList();
$zombie = new Zombie(0.0, 0.0, $list, $room);
$list->append($zombie);
$this->assertCount(1, $list);
}
#[Test]
public function removeRemovesInstance(): void
{
$room = new Room('r');
$list = new EntityList();
$a = new Zombie(0.0, 0.0, $list, $room);
$b = new Zombie(10.0, 0.0, $list, $room);
$list->append($a);
$list->append($b);
$list->remove($a);
$this->assertCount(1, $list);
$this->assertSame($b, $list[1]);
}
#[Test]
public function removeLeavesNoGapsAfterManyRemovals(): void
{
$room = new Room('r');
$list = new EntityList();
$items = [];
for ($i = 0; $i < 20; $i++) {
$items[] = new Zombie((float) $i, 0.0, $list, $room);
}
foreach ($items as $item) {
$list->append($item);
}
foreach ([10, 3, 17, 0] as $index) {
$list->remove($items[$index]);
}
$this->assertSame(16, $list->count());
$this->assertFalse($list->offsetExists(10));
$this->assertTrue($list->offsetExists(2));
}
#[Test]
public function findByKindFilters(): void
{
$room = new Room('r');
$list = new EntityList();
$list->append(new Zombie(0.0, 0.0, $list, $room));
$list->append(new Zombie(10.0, 0.0, $list, $room));
$found = $list->findByKind('zombie');
$this->assertCount(2, $found);
$this->assertSame([], $list->findByKind('player'));
}
#[Test]
public function copyReturnsDetachedArray(): void
{
$room = new Room('r');
$list = new EntityList();
$list->append(new Zombie(0.0, 0.0, $list, $room));
$snapshot = $list->copy();
$list->append(new Zombie(1.0, 1.0, $list, $room));
$this->assertCount(1, $snapshot);
$this->assertCount(2, $list);
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Boss;
use App\Game\Entities\Bullet;
use App\Game\Entities\Zombie;
use App\Game\GameEngine;
use App\Game\InputState;
use App\Game\Player;
use App\Game\Room;
use App\Game\Spawner;
use App\Game\Vector2;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GameEngineTest extends TestCase
{
#[Test]
public function tick_does_nothing_when_not_playing(): void
{
$room = new Room('test');
$engine = new GameEngine($room, new Spawner);
$engine->tick(1.0 / 60.0);
$this->assertSame(Room::STATE_LOBBY, $room->state);
}
#[Test]
public function zombies_spawn_during_play(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$room->addPlayer(new Player('p1', 'Tester', new InputState(fire: true), $room->entities, $room, ['x' => 960.0, 'y' => 540.0]));
$sawZombie = false;
for ($i = 0; $i < 600; $i++) {
$engine->tick(1.0 / 60.0);
if ($room->entities->findByKind('zombie') !== []) {
$sawZombie = true;
}
if ($room->state === Room::STATE_OVER) {
break;
}
}
$this->assertTrue($sawZombie, 'zombies should spawn during play');
}
#[Test]
public function game_ends_when_all_players_die(): void
{
$room = new Room('test', 1);
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$player = new Player('p1', 'Tester', new InputState, $room->entities, $room, [
'x' => 960.0,
'y' => 540.0,
'health' => 1,
]);
$room->addPlayer($player);
$zombie = new Zombie(944.4, 540.0, $room->entities, $room, ['emergeTime' => 0.0]);
$zombie->target = $player;
$room->entities->append($zombie);
$engine->tick(1.0 / 60.0);
$this->assertSame(Room::STATE_OVER, $room->state);
$this->assertTrue($player->dead);
}
#[Test]
public function bullets_damage_zombies(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$input = new InputState(fire: true);
$player = new Player('p1', 'T', $input, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]);
$room->addPlayer($player);
$zombie = new Zombie(1000.0, 500.0, $room->entities, $room, ['emergeTime' => 0.0]);
$zombie->target = $player;
$room->entities->append($zombie);
$engine->tick(1.0 / 60.0);
$bullets = $room->entities->findByKind('bullet');
$this->assertNotEmpty($bullets, 'player shot a bullet');
}
#[Test]
public function boss_spawns_at_score_threshold(): void
{
$room = new Room('test', 1);
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]);
$room->addPlayer($player);
$zombie = new Zombie(510.0, 500.0, $room->entities, $room, ['health' => 1, 'emergeTime' => 0.0, 'scoreValue' => 55]);
$zombie->target = $player;
$room->entities->append($zombie);
$bullet = new Bullet(509.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 1);
$room->entities->append($bullet);
$engine->tick(1.0 / 60.0);
$this->assertGreaterThan(0, $player->score, 'player killed zombie at score threshold');
$this->assertTrue($room->hasBoss(), 'boss should spawn when score reaches threshold');
}
#[Test]
public function kills_increment_score(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]);
$room->addPlayer($player);
$zombie = new Zombie(510.0, 500.0, $room->entities, $room, ['health' => 1, 'emergeTime' => 0.0]);
$zombie->target = $player;
$room->entities->append($zombie);
$bullet = new Bullet(509.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 10);
$room->entities->append($bullet);
$engine->tick(1.0 / 60.0);
$this->assertGreaterThan(0, $player->score, 'player scored from killing zombie');
}
#[Test]
public function zombies_appear_when_megatank_dies(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$room->elapsed = 400.0;
$spawner = new Spawner;
$engine = new GameEngine($room, $spawner);
$player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 960.0, 'y' => 540.0]);
$room->addPlayer($player);
$mega = null;
for ($i = 0; $i < 100 && $mega === null; $i++) {
$spawner->spawnZombie($room);
$megas = $room->entities->findByKind('megatank');
if ($megas !== []) {
$mega = $megas[0];
}
}
$this->assertNotNull($mega, 'a megatank should spawn when elapsed is high');
$mega->pos = new Vector2(960.0, 250.0);
$zombiesBefore = count($room->entities->findByKind('zombie'));
$portalsBefore = $this->portalEventCount($room);
$room->entities->append(new Bullet(960.0, 250.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 100));
$engine->tick(1.0 / 60.0);
$this->assertSame([], $room->entities->findByKind('megatank'), 'megatank should be removed on death');
$this->assertSame($zombiesBefore + 3, count($room->entities->findByKind('zombie')), 'megatank death should spawn three zombies');
$this->assertSame($portalsBefore + 3, $this->portalEventCount($room), 'each spawned zombie should get a portal effect');
$this->assertContains('kill', array_map(fn (array $ev) => $ev['k'], $room->events));
}
#[Test]
public function witches_appear_when_mega_witch_dies(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$spawner = new Spawner;
$engine = new GameEngine($room, $spawner);
$player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 960.0, 'y' => 540.0]);
$room->addPlayer($player);
$spawner->spawnMegaWitch($room, 960.0, 250.0, 1.0, 1.0);
$this->assertSame(1, count($room->entities->findByKind('megawitch')));
$portalsBefore = $this->portalEventCount($room);
$mega = $room->entities->findByKind('megawitch')[0];
$room->entities->append(new Bullet($mega->pos->x, $mega->pos->y, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 100));
$engine->tick(1.0 / 60.0);
$this->assertSame([], $room->entities->findByKind('megawitch'));
$this->assertCount(3, $room->entities->findByKind('witch'));
$this->assertSame($portalsBefore + 3, $this->portalEventCount($room));
}
#[Test]
public function emerging_minions_report_emerge_progress_in_snapshot(): void
{
$room = new Room('test');
$zombie = new Zombie(10.0, 10.0, $room->entities, $room, ['emergeTime' => 0.8]);
$snap = $zombie->toSnapshot();
$this->assertSame(0.8, $snap['er']);
$this->assertSame(0.8, $snap['et']);
$zombie->update(0.3);
$this->assertSame(0.5, $zombie->toSnapshot()['er'], 'emerge remaining should shrink as the zombie emerges');
$plain = new Zombie(30.0, 30.0, $room->entities, $room);
$this->assertArrayNotHasKey('er', $plain->toSnapshot(), 'non-emerging entities need no emerge data');
}
private function portalEventCount(Room $room): int
{
$count = 0;
foreach ($room->events as $event) {
if ($event['k'] === 'portal') {
$count++;
}
}
return $count;
}
#[Test]
public function boss_defeated_event_emitted_on_boss_death(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$engine = new GameEngine($room, new Spawner);
$player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]);
$room->addPlayer($player);
$boss = new Boss(505.0, 500.0, $room->entities, $room, $player, [
'health' => 1,
'emergeTime' => 0.0,
]);
$boss->target = $player;
$room->entities->append($boss);
$bullet = new Bullet(503.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 10);
$room->entities->append($bullet);
$engine->tick(1.0 / 60.0);
$eventKinds = array_map(fn (array $ev) => $ev['k'], $room->events);
$this->assertContains('bossdefeated', $eventKinds);
}
}
+358
View File
@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Bullet;
use App\Game\Entities\Zombie;
use App\Game\GameSimulator;
use App\Game\Room;
use App\Game\Vector2;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GameSimulatorTest extends TestCase
{
/** @var array<int, array<string, mixed>> */
private array $broadcasts = [];
/** @var array<int, array{room: string, code: string}> */
private array $errors = [];
private function makeSimulator(): GameSimulator
{
$this->broadcasts = [];
$this->errors = [];
return new GameSimulator(
function (Room $room, array $players, array $entities, array $events): void {
$this->broadcasts[] = [
'room' => $room->id,
'state' => $room->state,
'players' => $players,
'entities' => $entities,
'events' => $events,
];
},
function (string $roomId, string $code): void {
$this->errors[] = ['room' => $roomId, 'code' => $code];
},
);
}
#[Test]
public function create_creates_room_and_broadcasts_lobby(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'Alice']);
$this->assertCount(1, $sim->roomIds());
$this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state);
$this->assertCount(1, $sim->room('room-a')->players);
$this->assertSame('Alice', $this->broadcasts[0]['players'][0]['name']);
$this->assertSame('p1', $sim->room('room-a')->hostId);
}
#[Test]
public function snapshot_marks_host(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$this->assertTrue($this->broadcasts[0]['players'][0]['host']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$last = $this->broadcasts[array_key_last($this->broadcasts)];
$hosts = [];
foreach ($last['players'] as $player) {
$hosts[$player['id']] = $player['host'];
}
$this->assertSame(['p1' => true, 'p2' => false], $hosts);
}
#[Test]
public function start_transitions_to_playing(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state);
}
#[Test]
public function guest_cannot_start_room(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']);
$this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state);
}
#[Test]
public function host_promoted_when_host_leaves(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']);
$this->assertSame('p2', $sim->room('room-a')->hostId);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']);
$this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state);
}
#[Test]
public function reset_rejects_non_host(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$room = $sim->room('room-a');
$room->state = Room::STATE_OVER;
$sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p2']);
$this->assertSame(Room::STATE_OVER, $room->state);
$sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']);
$this->assertSame(Room::STATE_LOBBY, $room->state);
}
#[Test]
public function reset_does_nothing_in_lobby(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$room = $sim->room('room-a');
$sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']);
$this->assertSame(Room::STATE_LOBBY, $room->state);
$this->assertCount(1, $this->broadcasts);
}
#[Test]
public function reset_clears_entities_and_restores_players(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$room = $sim->room('room-a');
$room->state = Room::STATE_OVER;
$room->entities->append(new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]));
$player = $room->players['p1'];
$player->health = 1;
$player->score = 99;
$player->pos = new Vector2(5.0, 5.0);
$player->upgrades['rapid'] = 1;
$sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']);
$this->assertSame(Room::STATE_LOBBY, $room->state);
$this->assertSame([], $room->entities->findByKind('zombie'));
$this->assertSame($player->maxHealth, $player->health);
$this->assertSame(0, $player->score);
$this->assertSame(0, $player->upgrades['rapid']);
$this->assertEqualsWithDelta(960.0, $player->pos->x, 1e-6);
$this->assertSame(Room::STATE_LOBBY, $this->broadcasts[array_key_last($this->broadcasts)]['state']);
}
#[Test]
public function start_does_nothing_with_empty_world(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('empty-room', ['type' => 'start']);
$this->assertSame([], $sim->roomIds());
}
#[Test]
public function apply_input_moves_player(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$room = $sim->room('room-a');
$x = $room->players['p1']->pos->x;
$y = $room->players['p1']->pos->y;
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$sim->applyInput('room-a', ['id' => 'p1', 'keys' => ['d'], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => false]);
$sim->tick(1.0 / 60.0);
$sim->tick(1.0 / 60.0);
$player = $room->players['p1'];
$this->assertGreaterThan($x, $player->pos->x, 'player moves right');
$this->assertEqualsWithDelta($y, $player->pos->y, 1e-6, 'player stays on same y');
}
#[Test]
public function tick_spawns_zombies_from_input(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => true]);
$sawZombie = false;
for ($i = 0; $i < 600; $i++) {
$sim->tick(1.0 / 60.0);
$room = $sim->room('room-a');
if ($room->entities->findByKind('zombie') !== []) {
$sawZombie = true;
}
if ($room->state === Room::STATE_OVER) {
break;
}
}
$this->assertTrue($sawZombie, 'zombies spawn during play');
}
#[Test]
public function leave_removes_player_and_destroys_room_when_empty(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']);
$this->assertSame([], $sim->roomIds());
}
#[Test]
public function leave_keeps_room_when_other_players_remain(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']);
$this->assertSame(['room-a'], $sim->roomIds());
$this->assertCount(1, $sim->room('room-a')->players);
}
#[Test]
public function decode_parses_json_frames(): void
{
$sim = $this->makeSimulator();
$frame = $sim->decode('{"type":"join","id":"p1"}');
$this->assertSame('join', $frame['type']);
$this->assertSame('p1', $frame['id']);
$this->assertNull($sim->decode('not json'));
}
#[Test]
public function snapshot_includes_entities_but_not_players(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$room = $sim->room('room-a');
$player = $room->players['p1'];
$zombie = new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]);
$room->entities->append($zombie);
$bullet = new Bullet(5.0, 5.0, new Vector2(1.0, 0.0), $room->entities, $room, $player);
$room->entities->append($bullet);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$sim->tick(1.0 / 60.0);
$sim->tick(1.0 / 60.0);
$snapshot = $this->broadcasts[array_key_last($this->broadcasts)];
$kinds = array_map(static fn (array $e) => $e['d'] ?? null, $snapshot['entities']);
$this->assertContains('zombie', $kinds);
$this->assertNotContains('player', $kinds);
$this->assertNotContains('player', $snapshot['entities']);
}
#[Test]
public function join_is_rejected_after_start(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$this->assertCount(1, $sim->room('room-a')->players, 'no new join after start');
$this->assertSame('room_not_open', $this->errors[0]['code']);
}
#[Test]
public function join_rejects_missing_room_without_creating_it(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('nope', ['type' => 'join', 'id' => 'p1', 'name' => 'A']);
$this->assertSame([], $sim->roomIds(), 'no room is created for an unknown code');
$this->assertSame('room_not_found', $this->errors[0]['code']);
$this->assertSame('nope', $this->errors[0]['room']);
$this->assertSame([], $this->broadcasts, 'nothing is broadcast for an unknown code');
}
#[Test]
public function create_rejects_room_owned_by_another_host(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p2', 'name' => 'B']);
$this->assertSame('p1', $sim->room('room-a')->hostId, 'creator keeps host');
$this->assertCount(1, $sim->room('room-a')->players, 'second host is not added');
$this->assertSame('room_exists', $this->errors[0]['code']);
}
#[Test]
public function create_is_idempotent_rejoin_for_the_host(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$this->assertCount(1, $sim->room('room-a')->players);
$this->assertSame('p1', $sim->room('room-a')->hostId);
$this->assertSame([], $this->errors);
}
#[Test]
public function guest_joins_existing_room_as_non_host(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']);
$this->assertCount(2, $sim->room('room-a')->players);
$this->assertSame([], $this->errors, 'joining an existing open room succeeds');
}
#[Test]
public function join_rejects_when_room_is_full(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
for ($i = 2; $i <= 8; $i++) {
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p'.$i, 'name' => 'P'.$i]);
}
$sim->applyControl('room-a', ['type' => 'join', 'id' => 'p9', 'name' => 'P9']);
$this->assertCount(8, $sim->room('room-a')->players);
$this->assertSame('room_full', $this->errors[0]['code']);
}
#[Test]
public function bullet_impact_emits_hit_event(): void
{
$sim = $this->makeSimulator();
$sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']);
$room = $sim->room('room-a');
$room->state = Room::STATE_PLAYING;
$player = $room->players['p1'];
$zombie = new Zombie($player->pos->x + 40.0, $player->pos->y, $room->entities, $room, ['emergeTime' => 0.0]);
$room->entities->append($zombie);
$sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => false]);
$hit = false;
$kills = false;
for ($i = 0; $i < 30; $i++) {
$sim->tick(1.0 / 60.0);
foreach ($this->broadcasts[array_key_last($this->broadcasts)]['events'] as $event) {
$hit = $hit || $event['k'] === 'bullethit';
$kills = $kills || $event['k'] === 'kill';
}
if ($hit && $kills) {
break;
}
}
$this->assertTrue($hit, 'a bullet impact emits a bullethit event');
$this->assertTrue($kills, 'the zombie dies and emits a kill event');
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Zombie;
use App\Game\InputState;
use App\Game\Player;
use App\Game\Room;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PlayerTest extends TestCase
{
private function makePlayer(?Room $room = null): Player
{
$room = $room ?? new Room('test');
$input = new InputState;
return new Player('p1', 'Tester', $input, $room->entities, $room, [
'x' => 100.0,
'y' => 100.0,
'health' => 3,
]);
}
#[Test]
public function movement_keys_affect_velocity(): void
{
$room = new Room('test');
$input = new InputState(['d']);
$player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$player->update(1.0 / 60.0);
$this->assertGreaterThan(100.0, $player->pos->x);
}
#[Test]
public function fire_spawns_bullets(): void
{
$room = new Room('test');
$input = new InputState(fire: true);
$player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$room->addPlayer($player);
$player->update(1.0 / 60.0);
$bullets = $room->entities->findByKind('bullet');
$this->assertNotEmpty($bullets);
}
#[Test]
public function auto_aim_fires_at_nearest_zombie_without_mouse(): void
{
$room = new Room('test');
$room->state = Room::STATE_PLAYING;
$input = new InputState(fire: false, autoAim: true);
$player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$room->addPlayer($player);
$zombie = new Zombie(300.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]);
$zombie->target = $player;
$room->entities->append($zombie);
$player->update(1.0 / 60.0);
$bullets = $room->entities->findByKind('bullet');
$this->assertNotEmpty($bullets, 'auto-aim should fire at a zombie without the mouse held');
$direction = $bullets[0]->velocity->normalize();
$this->assertGreaterThan(0.9, $direction->x, 'bullet should fly toward the zombie to the right');
}
#[Test]
public function auto_aim_does_not_fire_without_target(): void
{
$room = new Room('test');
$input = new InputState(fire: false, autoAim: true);
$player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$room->addPlayer($player);
$player->update(1.0 / 60.0);
$this->assertEmpty($room->entities->findByKind('bullet'), 'no auto-fire when no zombie is in range');
}
#[Test]
public function take_damage_decreases_health_and_cooldown_prevents_double_hit(): void
{
$room = new Room('test');
$player = $this->makePlayer($room);
$player->takeDamage();
$this->assertSame(2, $player->health);
$this->assertFalse($player->dead);
$player->takeDamage();
$this->assertSame(2, $player->health, 'damage cooldown prevented second hit');
}
#[Test]
public function take_damage_kills_at_zero_health(): void
{
$room = new Room('test');
$player = $this->makePlayer($room);
$player->takeDamage();
$player->update(0.6);
$player->takeDamage();
$player->update(0.6);
$player->takeDamage();
$this->assertSame(0, $player->health);
$this->assertTrue($player->dead);
}
#[Test]
public function apply_upgrade_increases_level_and_effects(): void
{
$room = new Room('test');
$player = $this->makePlayer($room);
$originalFireRate = $player->fireRate;
$this->assertTrue($player->applyUpgrade('rapid'));
$this->assertSame(1, $player->upgrades['rapid']);
$this->assertLessThan($originalFireRate, $player->fireRate);
}
#[Test]
public function apply_upgrade_returns_false_when_maxed(): void
{
$room = new Room('test');
$player = $this->makePlayer($room);
for ($i = 0; $i < $player->maxUpgradeLevel; $i++) {
$this->assertTrue($player->applyUpgrade('damage'));
}
$this->assertFalse($player->applyUpgrade('damage'));
$this->assertSame($player->maxUpgradeLevel, $player->upgrades['damage']);
}
#[Test]
public function apply_upgrade_unknown_type_returns_false(): void
{
$player = $this->makePlayer();
$this->assertFalse($player->applyUpgrade('nonexistent'));
}
#[Test]
public function reset_for_round_restores_derived_stats(): void
{
$room = new Room('test');
$input = new InputState;
$player = new Player('p1', 'Tester', $input, $room->entities, $room, [
'x' => 100.0,
'y' => 100.0,
'fireRate' => 0.5,
'damage' => 2,
'bulletCount' => 3,
'bulletSpeed' => 900.0,
]);
$player->applyUpgrade('rapid');
$player->applyUpgrade('damage');
$player->applyUpgrade('multishot');
$player->applyUpgrade('speed');
$player->resetForRound();
$this->assertSame(['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0], $player->upgrades);
$this->assertSame(0.5, $player->fireRate);
$this->assertSame(2, $player->damage);
$this->assertSame(3, $player->bulletCount);
$this->assertSame(900.0, $player->bulletSpeed);
}
#[Test]
public function auto_aim_tracks_nearest_zombie(): void
{
$room = new Room('test');
$input = new InputState(fire: true, autoAim: true);
$player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$room->addPlayer($player);
$zombie = new Zombie(200.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]);
$room->entities->append($zombie);
$player->update(1.0 / 60.0);
$bullets = $room->entities->findByKind('bullet');
$this->assertNotEmpty($bullets);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\EntityList;
use App\Game\InputState;
use App\Game\Player;
use App\Game\Room;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RoomTest extends TestCase
{
#[Test]
public function initialStateIsLobby(): void
{
$room = new Room('test');
$this->assertSame(Room::STATE_LOBBY, $room->state);
}
#[Test]
public function addPlayerIncrementsCount(): void
{
$room = new Room('test');
$player = new Player('p1', 'Alice', new InputState(), $room->entities, $room);
$room->addPlayer($player);
$this->assertCount(1, $room->players);
$this->assertArrayHasKey('p1', $room->players);
}
#[Test]
public function alivePlayersReturnsNonDead(): void
{
$room = new Room('test');
$p1 = new Player('p1', 'A', new InputState(), $room->entities, $room);
$p2 = new Player('p2', 'B', new InputState(), $room->entities, $room);
$room->addPlayer($p1);
$room->addPlayer($p2);
$p1->dead = true;
$this->assertCount(1, $room->alivePlayers());
$this->assertSame('p2', $room->alivePlayers()[0]->id);
}
#[Test]
public function highestScore(): void
{
$room = new Room('test');
$p1 = new Player('p1', 'A', new InputState(), $room->entities, $room);
$p2 = new Player('p2', 'B', new InputState(), $room->entities, $room);
$room->addPlayer($p1);
$room->addPlayer($p2);
$p2->score = 42;
$this->assertSame(42, $room->highestScore());
}
#[Test]
public function pushEventAndFlush(): void
{
$room = new Room('test');
$room->pushEvent('kill', ['x' => 1.0, 'y' => 2.0, 'color' => [1.0, 0.0, 0.0, 1.0]]);
$this->assertCount(1, $room->events);
$this->assertSame('kill', $room->events[0]['k']);
$flushed = $room->flushEvents();
$this->assertCount(1, $flushed);
$this->assertCount(0, $room->events);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Vector2;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
final class Vector2Test extends TestCase
{
#[Test]
public function addV(): void
{
$a = new Vector2(1.0, 2.0);
$b = new Vector2(3.0, -1.0);
$c = $a->addV($b);
$this->assertSame(4.0, $c->x);
$this->assertSame(1.0, $c->y);
}
#[Test]
public function subV(): void
{
$a = new Vector2(5.0, 7.0);
$b = new Vector2(2.0, 3.0);
$c = $a->subV($b);
$this->assertSame(3.0, $c->x);
$this->assertSame(4.0, $c->y);
}
#[Test]
public function mulS(): void
{
$v = new Vector2(2.0, -3.0);
$r = $v->mulS(4.0);
$this->assertSame(8.0, $r->x);
$this->assertSame(-12.0, $r->y);
}
#[Test]
public function addS(): void
{
$v = new Vector2(2.0, 3.0);
$r = $v->addS(5.0);
$this->assertSame(7.0, $r->x);
$this->assertSame(8.0, $r->y);
}
#[Test]
public function divS(): void
{
$v = new Vector2(6.0, -4.0);
$r = $v->divS(2.0);
$this->assertSame(3.0, $r->x);
$this->assertSame(-2.0, $r->y);
}
#[Test]
public function lengthSquared(): void
{
$v = new Vector2(3.0, 4.0);
$this->assertSame(25.0, $v->lengthSquared());
}
#[Test]
public function length(): void
{
$v = new Vector2(3.0, 4.0);
$this->assertSame(5.0, $v->length());
}
#[Test]
public function normalizeUnitVectorUnchanged(): void
{
$v = new Vector2(3.0, 0.0);
$n = $v->normalize();
$this->assertSame(1.0, $n->x);
$this->assertSame(0.0, $n->y);
}
#[Test]
public function normalizeNormalizesVector(): void
{
$v = new Vector2(3.0, 4.0);
$n = $v->normalize();
$this->assertEqualsWithDelta(0.6, $n->x, 1e-9);
$this->assertEqualsWithDelta(0.8, $n->y, 1e-9);
}
#[Test]
public function normalizeZeroVectorReturnsZero(): void
{
$v = new Vector2(0.0, 0.0);
$n = $v->normalize();
$this->assertSame(0.0, $n->x);
$this->assertSame(0.0, $n->y);
}
#[Test]
public function zeroReturnsOrigin(): void
{
$v = Vector2::zero();
$this->assertSame(0.0, $v->x);
$this->assertSame(0.0, $v->y);
}
#[Test]
public function rotate90DegreesYAxis(): void
{
$v = new Vector2(1.0, 0.0);
$r = $v->rotate(M_PI / 2.0);
$this->assertEqualsWithDelta(0.0, $r->x, 1e-6);
$this->assertEqualsWithDelta(1.0, $r->y, 1e-6);
}
#[Test]
public function rotate360ReturnsSameDirection(): void
{
$v = new Vector2(2.0, -1.0);
$r = $v->rotate(2.0 * M_PI);
$this->assertEqualsWithDelta($v->x, $r->x, 1e-6);
$this->assertEqualsWithDelta($v->y, $r->y, 1e-6);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Zombie;
use App\Game\EntityList;
use App\Game\InputState;
use App\Game\Player;
use App\Game\Room;
use App\Game\Vector2;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ZombieTest extends TestCase
{
#[Test]
public function zombieChasesTarget(): void
{
$room = new Room('test');
$list = $room->entities;
$player = new Player('p1', 'Tester', new InputState(), $list, $room, ['x' => 100.0, 'y' => 0.0]);
$room->addPlayer($player);
$zombie = new Zombie(0.0, 0.0, $list, $room, ['emergeTime' => 0.0]);
$list->append($zombie);
$zombie->target = $player;
$zombie->update(0.1);
$this->assertGreaterThan(0.0, $zombie->pos->x, 'zombie moves toward player x');
$this->assertEqualsWithDelta(0.0, $zombie->pos->y, 1e-6, 'zombie stays on same y');
}
#[Test]
public function hitReducesHealthAndKnocksBack(): void
{
$room = new Room('test');
$zombie = new Zombie(10.0, 0.0, $room->entities, $room);
$impulse = new Vector2(1.0, 0.0);
$dead = $zombie->hit($impulse, 1);
$this->assertTrue($dead);
$this->assertSame(0, $zombie->health);
}
#[Test]
public function hitDoesNotKillAtHighHealth(): void
{
$room = new Room('test');
$zombie = new Zombie(10.0, 0.0, $room->entities, $room, ['health' => 10]);
$dead = $zombie->hit(new Vector2(1.0, 0.0), 1);
$this->assertFalse($dead);
$this->assertSame(9, $zombie->health);
}
#[Test]
public function dieRemovesFromContainer(): void
{
$room = new Room('test');
$zombie = new Zombie(5.0, 5.0, $room->entities, $room);
$room->entities->append($zombie);
$zombie->die();
$this->assertCount(0, $room->entities);
}
#[Test]
public function emergeBlocksMovement(): void
{
$room = new Room('test');
$zombie = new Zombie(0.0, 0.0, $room->entities, $room, ['emergeTime' => 1.0]);
$room->entities->append($zombie);
$zombie->target = new Player('p1', 'T', new InputState(), $room->entities, $room, ['x' => 100.0, 'y' => 100.0]);
$zombie->update(0.1);
$this->assertSame(0.0, $zombie->pos->x, 'zombie blocked during emerge');
}
}