feat: add GameMap and A* pathfinding with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user