initial commit
This commit is contained in:
commit
e8c3f9862a
193 files changed
+29795
No files matched your search
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user