1
0
Fork 0

Add A* pathfinding

This commit is contained in:
Pabloader 2025-06-26 07:49:16 +00:00
parent 217d587863
commit 53930cbe23
2 changed files with 195 additions and 71 deletions

View File

@ -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';

View File

@ -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<Tile> = new Set([start]);
// For each node, the cost of getting from start to that node.
const gScore = new Map<Tile, number>();
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<Tile, number>();
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<Tile, Tile>();
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 weve 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 currents 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<Tile, Tile>,
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;