From 53930cbe23a4db7491e3aa913bfa3162021d4dc3 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 26 Jun 2025 07:49:16 +0000 Subject: [PATCH] Add A* pathfinding --- src/games/zombies/index.ts | 57 ++-------- src/games/zombies/tile.ts | 209 +++++++++++++++++++++++++++++++++---- 2 files changed, 195 insertions(+), 71 deletions(-) diff --git a/src/games/zombies/index.ts b/src/games/zombies/index.ts index cd93ddb..a6df502 100644 --- a/src/games/zombies/index.ts +++ b/src/games/zombies/index.ts @@ -2,20 +2,21 @@ import { createCanvas } from "@common/display/canvas"; import Spinner from "./spinner"; import type Entity from "./entity"; import { getRealPoint } from "@common/dom"; -import { nextFrame, range } from "@common/utils"; -import Tile from "./tile"; +import { nextFrame } from "@common/utils"; +import { TileMap } from "./tile"; const MAP_SIZE = 12; const MAP_PIXEL_SIZE = 1000; -const TILE_SIZE = MAP_PIXEL_SIZE / (MAP_SIZE + 4); +const MAP_PADDING = 100; +const TILE_SIZE = (MAP_PIXEL_SIZE - MAP_PADDING * 2) / MAP_SIZE; const SPINNER_SIZE = 200; const canvas = createCanvas(MAP_PIXEL_SIZE + SPINNER_SIZE, MAP_PIXEL_SIZE); const spinner = new Spinner([MAP_PIXEL_SIZE, 0], [SPINNER_SIZE, SPINNER_SIZE]); -const map = createMap(); +const map = new TileMap([MAP_PADDING, MAP_PADDING], MAP_SIZE, TILE_SIZE); const entities: Entity[] = [ spinner, - ...map.flat(), + map, ]; async function update(dt: number) { @@ -41,52 +42,6 @@ async function onMouseMove(e: MouseEvent) { entities.forEach(entity => entity.handleMouseMove(point.x, point.y)); } -function createMap() { - const map = range(MAP_SIZE) - .map(x => - range(MAP_SIZE) - .map(y => - new Tile([x + 2, y + 2], TILE_SIZE) - ) - ); - - const startTile = new Tile([1, (MAP_SIZE / 2)], TILE_SIZE * 2); - - delete map[0][MAP_SIZE - 2]; - delete map[1][MAP_SIZE - 2]; - delete map[1][MAP_SIZE - 1]; - - map[0][MAP_SIZE - 1] = startTile; - - // TODO walls - - for (let x = 0; x < map.length; x++) { - const column = map[x]; - if (!column) continue; - for (let y = 0; y < column.length; y++) { - const tile = column[y]; - if (!tile) continue; - tile.connections = [ - map[x - 1]?.[y], - map[x + 1]?.[y], - map[x]?.[y - 1], - map[x]?.[y + 1], - ].filter(t => t); - } - } - - startTile.connections = [ - map[0][MAP_SIZE - 3], - map[1][MAP_SIZE - 3], - map[2][MAP_SIZE - 2], - map[2][MAP_SIZE - 1], - ].filter(t => t); - - startTile.connections.forEach(t => t.connections.push(startTile)); - - return map; -} - export default async function main() { canvas.style.imageRendering = 'auto'; diff --git a/src/games/zombies/tile.ts b/src/games/zombies/tile.ts index 5cb6ce4..5e19855 100644 --- a/src/games/zombies/tile.ts +++ b/src/games/zombies/tile.ts @@ -1,35 +1,204 @@ +import { range } from "@common/utils"; import Entity from "./entity"; +export enum TileType { + EMPTY, + START, + END, +} + +export namespace AStar { + /** + * A* search from start to end Tile. + * @returns array of Tiles from start to end (inclusive), or [] if no path. + */ + export function findPath(start: Tile, end: Tile): Tile[] { + // The set of discovered nodes to be evaluated + const openSet: Set = new Set([start]); + + // For each node, the cost of getting from start to that node. + const gScore = new Map(); + gScore.set(start, 0); + + // For each node, the total cost of getting from start to goal + // by passing by that node: gScore + heuristic estimate to goal. + const fScore = new Map(); + fScore.set(start, heuristic(start, end)); + + // For path reconstruction: map each node to the node it can most efficiently be reached from. + const cameFrom = new Map(); + + while (openSet.size > 0) { + // Get the node in openSet having the lowest fScore + let current: Tile | null = null; + let currentF = Infinity; + for (const tile of openSet) { + const f = fScore.get(tile) ?? Infinity; + if (f < currentF) { + currentF = f; + current = tile; + } + } + if (!current) break; + + // If we’ve reached the goal, reconstruct and return the path. + if (current === end) { + return reconstructPath(cameFrom, current); + } + + openSet.delete(current); + + for (const neighbor of current.connections) { + // tentative gScore is current’s gScore plus cost to move to neighbor + const tentativeG = (gScore.get(current) ?? Infinity) + distance(current, neighbor); + + if (tentativeG < (gScore.get(neighbor) ?? Infinity)) { + // This path to neighbor is better than any previous one. Record it! + cameFrom.set(neighbor, current); + gScore.set(neighbor, tentativeG); + fScore.set(neighbor, tentativeG + heuristic(neighbor, end)); + if (!openSet.has(neighbor)) { + openSet.add(neighbor); + } + } + } + } + + // Open set is empty but goal was never reached + return []; + } + + /** Heuristic: Manhattan distance between tiles. */ + function heuristic(a: Tile, b: Tile): number { + return Math.abs(a.centerX - b.centerX) + Math.abs(a.centerY - b.centerY); + } + + /** Actual cost between two connected tiles (Euclidean). */ + function distance(a: Tile, b: Tile): number { + const dx = a.centerX - b.centerX; + const dy = a.centerY - b.centerY; + return Math.hypot(dx, dy); + } + + /** Reconstructs path from cameFrom map, ending at `current`. */ + function reconstructPath( + cameFrom: Map, + current: Tile + ): Tile[] { + const path: Tile[] = [current]; + while (cameFrom.has(current)) { + current = cameFrom.get(current)!; + path.push(current); + } + return path.reverse(); + } + +} + +export class TileMap extends Entity { + private tiles: Tile[] = []; + private pathTiles: Tile[] = []; + public startTile: Tile; + + constructor(position: [number, number], private mapSize: number, private tileSize: number) { + super(position, [mapSize * tileSize, mapSize * tileSize]); + this.startTile = this.createMap(); + } + + public createMap() { + const map = range(this.mapSize) + .map(x => + range(this.mapSize) + .map(y => + new Tile([x, y], this.tileSize) + ) + ); + + const startTile = new Tile([0, (this.mapSize / 2) - 1], this.tileSize * 2, TileType.START); + + delete map[0][this.mapSize - 2]; + delete map[1][this.mapSize - 2]; + delete map[1][this.mapSize - 1]; + + map[0][this.mapSize - 1] = startTile; + + // TODO walls + + for (let x = 0; x < map.length; x++) { + const column = map[x]; + if (!column) continue; + for (let y = 0; y < column.length; y++) { + const tile = column[y]; + if (!tile) continue; + tile.connections = [ + map[x - 1]?.[y], + map[x + 1]?.[y], + map[x]?.[y - 1], + map[x]?.[y + 1], + ].filter(t => t); + } + } + + startTile.connections = [ + map[0][this.mapSize - 3], + map[1][this.mapSize - 3], + map[2][this.mapSize - 2], + map[2][this.mapSize - 1], + ].filter(t => t); + + startTile.connections.forEach(t => t.connections.push(startTile)); + + this.tiles = map.flat(); + + return startTile; + } + + public override handleMouseMove(x: number, y: number): void { + this.tiles.forEach(tile => tile.handleMouseMove(x - this.left, y - this.top)); + } + + public override handleClick(x: number, y: number): void { + for (const tile of this.tiles) { + if (tile.isPointInBounds(x - this.left, y - this.top)) { + this.pathTiles = AStar.findPath(this.startTile, tile); + break; + } + } + } + + protected draw(ctx: CanvasRenderingContext2D): void { + ctx.scale(1 / this.width, 1 / this.height); + this.tiles.forEach(t => t.render(ctx)); + + if (this.pathTiles.length > 0) { + ctx.lineWidth = 1; + ctx.strokeStyle = 'yellow'; + ctx.beginPath(); + + const [start, ...path] = this.pathTiles; + ctx.moveTo(start.centerX, start.centerY); + path.forEach(t => ctx.lineTo(t.centerX, t.centerY)); + + ctx.stroke(); + } + } + + public update(dt: number): void { + this.tiles.forEach(t => t.update(dt)); + } +} + export default class Tile extends Entity { public connections: Tile[] = []; - constructor(position: [number, number], size: number, public image: HTMLImageElement | null = null) { + constructor(position: [number, number], size: number, public type: TileType = TileType.EMPTY) { super([position[0] * size, position[1] * size], [size, size]); } protected draw(ctx: CanvasRenderingContext2D) { - if (this.image) { - ctx.drawImage(this.image, 0, 0, 1, 1); - } - if (this.hovered) { ctx.fillStyle = `rgba(255, 255, 255, 0.1)`; ctx.fillRect(0, 0, 1, 1); - - ctx.lineWidth = 1 / this.width; - ctx.strokeStyle = 'red'; - - for (const tile of this.connections) { - const center = [ - 0.5 + (tile.centerX - this.centerX) / this.width, - 0.5 + (tile.centerY - this.centerY) / this.height, - ]; - - ctx.beginPath(); - ctx.moveTo(0.5, 0.5); - ctx.lineTo(center[0], center[1]); - ctx.stroke(); - } } ctx.lineWidth = 1 / this.width;