feat: add GameMap and A* pathfinding with tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:13:31 -05:00
parent 733d1eb795
commit 30ea2f42c2
3 changed files with 181 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { WORLD_WIDTH, WORLD_HEIGHT, type Position } from '@dflike/shared';
export interface PointOfInterest {
type: 'food' | 'rest';
position: Position;
}
export class GameMap {
readonly width: number;
readonly height: number;
private obstacles: Set<string> = new Set();
private pois: PointOfInterest[] = [];
constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) {
this.width = width;
this.height = height;
}
private key(x: number, y: number): string {
return `${x},${y}`;
}
setObstacle(x: number, y: number): void {
this.obstacles.add(this.key(x, y));
}
isWalkable(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false;
return !this.obstacles.has(this.key(x, y));
}
addPointOfInterest(poi: PointOfInterest): void {
this.pois.push(poi);
}
getPointsOfInterest(type?: 'food' | 'rest'): PointOfInterest[] {
if (type) return this.pois.filter(p => p.type === type);
return this.pois;
}
getObstacles(): Position[] {
return [...this.obstacles].map(k => {
const [x, y] = k.split(',').map(Number);
return { x, y };
});
}
getRandomWalkable(): Position {
let x: number, y: number;
do {
x = Math.floor(Math.random() * this.width);
y = Math.floor(Math.random() * this.height);
} while (!this.isWalkable(x, y));
return { x, y };
}
}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { findPath } from '../pathfinding.js';
import { GameMap } from '../GameMap.js';
describe('findPath (A*)', () => {
it('finds a straight path with no obstacles', () => {
const map = new GameMap(10, 10);
const path = findPath(map, { x: 0, y: 0 }, { x: 3, y: 0 });
expect(path).not.toBeNull();
expect(path!.length).toBeGreaterThan(0);
expect(path![path!.length - 1]).toEqual({ x: 3, y: 0 });
});
it('finds a path around an obstacle', () => {
const map = new GameMap(10, 10);
map.setObstacle(1, 0);
map.setObstacle(1, 1);
const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 });
expect(path).not.toBeNull();
expect(path!.some(p => p.x === 1 && p.y === 0)).toBe(false);
expect(path![path!.length - 1]).toEqual({ x: 2, y: 0 });
});
it('returns null if no path exists', () => {
const map = new GameMap(5, 5);
// Wall off the target
for (let y = 0; y < 5; y++) map.setObstacle(2, y);
const path = findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 });
expect(path).toBeNull();
});
it('returns empty path if start equals goal', () => {
const map = new GameMap(10, 10);
const path = findPath(map, { x: 3, y: 3 }, { x: 3, y: 3 });
expect(path).toEqual([]);
});
it('does not include the start position in the path', () => {
const map = new GameMap(10, 10);
const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 });
expect(path).not.toBeNull();
expect(path![0]).not.toEqual({ x: 0, y: 0 });
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { Position } from '@dflike/shared';
import type { GameMap } from './GameMap.js';
interface Node {
x: number;
y: number;
g: number;
h: number;
f: number;
parent: Node | null;
}
function heuristic(a: Position, b: Position): number {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
const NEIGHBORS = [
{ dx: 0, dy: -1 },
{ dx: 0, dy: 1 },
{ dx: -1, dy: 0 },
{ dx: 1, dy: 0 },
];
export function findPath(map: GameMap, start: Position, goal: Position): Position[] | null {
if (start.x === goal.x && start.y === goal.y) return [];
const open: Node[] = [];
const closed = new Set<string>();
const key = (x: number, y: number) => `${x},${y}`;
const startNode: Node = {
x: start.x, y: start.y,
g: 0, h: heuristic(start, goal), f: heuristic(start, goal),
parent: null,
};
open.push(startNode);
while (open.length > 0) {
// Find lowest f
let bestIdx = 0;
for (let i = 1; i < open.length; i++) {
if (open[i].f < open[bestIdx].f) bestIdx = i;
}
const current = open.splice(bestIdx, 1)[0];
if (current.x === goal.x && current.y === goal.y) {
// Reconstruct path (excluding start)
const path: Position[] = [];
let node: Node | null = current;
while (node && !(node.x === start.x && node.y === start.y)) {
path.push({ x: node.x, y: node.y });
node = node.parent;
}
return path.reverse();
}
closed.add(key(current.x, current.y));
for (const { dx, dy } of NEIGHBORS) {
const nx = current.x + dx;
const ny = current.y + dy;
if (!map.isWalkable(nx, ny) || closed.has(key(nx, ny))) continue;
const g = current.g + 1;
const h = heuristic({ x: nx, y: ny }, goal);
const existing = open.find(n => n.x === nx && n.y === ny);
if (existing) {
if (g < existing.g) {
existing.g = g;
existing.f = g + h;
existing.parent = current;
}
} else {
open.push({ x: nx, y: ny, g, h, f: g + h, parent: current });
}
}
}
return null; // No path found
}