feat: model battlefield terrain and movement costs
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use SplQueue;
|
||||
|
||||
final readonly class Battlefield
|
||||
{
|
||||
/**
|
||||
* @param array<string, Terrain> $terrain
|
||||
*/
|
||||
public function __construct(
|
||||
public int $width,
|
||||
public int $height,
|
||||
private array $terrain = [],
|
||||
) {
|
||||
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
|
||||
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
|
||||
}
|
||||
|
||||
foreach (array_keys($terrain) as $key) {
|
||||
if (preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
|
||||
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
|
||||
}
|
||||
|
||||
$position = new Position((int) $matches[1], (int) $matches[2]);
|
||||
|
||||
if (!$this->contains($position)) {
|
||||
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function contains(Position $position): bool
|
||||
{
|
||||
return $position->x >= 0
|
||||
&& $position->x < $this->width
|
||||
&& $position->y >= 0
|
||||
&& $position->y < $this->height;
|
||||
}
|
||||
|
||||
public function terrainAt(Position $position): Terrain
|
||||
{
|
||||
return $this->terrain[$position->key()] ?? Terrain::Open;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Position> $occupied
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function reachable(Position $start, int $budget, array $occupied): array
|
||||
{
|
||||
$occupiedKeys = [];
|
||||
|
||||
foreach ($occupied as $position) {
|
||||
$occupiedKeys[$position->key()] = true;
|
||||
}
|
||||
|
||||
$costs = [$start->key() => 0];
|
||||
/** @var SplQueue<Position> $queue */
|
||||
$queue = new SplQueue();
|
||||
$queue->enqueue($start);
|
||||
|
||||
while (!$queue->isEmpty()) {
|
||||
$current = $queue->dequeue();
|
||||
$currentCost = $costs[$current->key()];
|
||||
|
||||
foreach ($this->neighbors($current) as $neighbor) {
|
||||
$key = $neighbor->key();
|
||||
$movementCost = $this->terrainAt($neighbor)->movementCost();
|
||||
|
||||
if ($movementCost === null || isset($occupiedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cost = $currentCost + $movementCost;
|
||||
|
||||
if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$costs[$key] = $cost;
|
||||
$queue->enqueue($neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
return $costs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Position>
|
||||
*/
|
||||
private function neighbors(Position $position): array
|
||||
{
|
||||
$neighbors = [
|
||||
new Position($position->x + 1, $position->y),
|
||||
new Position($position->x - 1, $position->y),
|
||||
new Position($position->x, $position->y + 1),
|
||||
new Position($position->x, $position->y - 1),
|
||||
];
|
||||
|
||||
return array_values(array_filter($neighbors, $this->contains(...)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final readonly class Position
|
||||
{
|
||||
public function __construct(
|
||||
public int $x,
|
||||
public int $y,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->x . ':' . $this->y;
|
||||
}
|
||||
|
||||
public function distanceTo(self $position): int
|
||||
{
|
||||
return abs($this->x - $position->x) + abs($this->y - $position->y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum Terrain: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Forest = 'forest';
|
||||
case Rough = 'rough';
|
||||
case Water = 'water';
|
||||
case Blocking = 'blocking';
|
||||
|
||||
public function movementCost(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 1,
|
||||
self::Forest, self::Rough => 2,
|
||||
self::Water, self::Blocking => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function defenseBonus(): int
|
||||
{
|
||||
return $this === self::Forest ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BattlefieldTest extends TestCase
|
||||
{
|
||||
public function testItRejectsDimensionsOutsideTheSupportedRange(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Battlefield(7, 16);
|
||||
}
|
||||
|
||||
public function testItFindsReachableTilesUsingTerrainCostsAndObstacles(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8, [
|
||||
'1:0' => Terrain::Forest,
|
||||
'0:1' => Terrain::Rough,
|
||||
'2:0' => Terrain::Water,
|
||||
'1:1' => Terrain::Blocking,
|
||||
]);
|
||||
|
||||
$reachable = $battlefield->reachable(
|
||||
new Position(0, 0),
|
||||
2,
|
||||
[new Position(0, 2)],
|
||||
);
|
||||
|
||||
self::assertSame(0, $reachable['0:0']);
|
||||
self::assertSame(2, $reachable['1:0']);
|
||||
self::assertSame(2, $reachable['0:1']);
|
||||
self::assertArrayNotHasKey('2:0', $reachable);
|
||||
self::assertArrayNotHasKey('1:1', $reachable);
|
||||
self::assertArrayNotHasKey('0:2', $reachable);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user