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