Add A* pathfinding
This commit is contained in:
parent
217d587863
commit
53930cbe23
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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<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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue