Files
Battle-for-Tismo/shared/hex_pathfinder.js
T
2026-09-17 22:01:30 +02:00

126 lines
4.0 KiB
JavaScript

// A* over a hex grid, independent of units and terrain. The caller describes
// the graph with callbacks so the algorithm stays pure and testable:
//
// neighbours(coords) -> [{x,y}, ...]
// canEnter(coords, isGoal) -> bool
// stepCost(from, to) -> number
// heuristic(from, to) -> number
//
// canEnter is told whether the tile is the destination, so a caller can allow
// entering a tile it would otherwise route around (a hostile unit or city) only
// as the very goal. findPath returns the cells from start to goal inclusive, or
// [] when the goal cannot be entered or is unreachable.
import { key } from "./hex.js";
class MinHeap {
constructor() {
this.keys = [];
this.values = [];
}
isEmpty() {
return this.keys.length === 0;
}
push(keyValue, value) {
this.keys.push(keyValue);
this.values.push(value);
let index = this.keys.length - 1;
while (index > 0) {
const parent = (index - 1) >> 1;
if (this.keys[parent] <= this.keys[index]) break;
this._swap(parent, index);
index = parent;
}
}
pop() {
const value = this.values[0];
const last = this.keys.length - 1;
this.keys[0] = this.keys[last];
this.values[0] = this.values[last];
this.keys.pop();
this.values.pop();
let index = 0;
const count = this.keys.length;
while (true) {
let smallest = index;
const left = (index << 1) + 1;
const right = left + 1;
if (left < count && this.keys[left] < this.keys[smallest]) smallest = left;
if (right < count && this.keys[right] < this.keys[smallest]) smallest = right;
if (smallest === index) break;
this._swap(index, smallest);
index = smallest;
}
return value;
}
_swap(a, b) {
const k = this.keys[a];
this.keys[a] = this.keys[b];
this.keys[b] = k;
const v = this.values[a];
this.values[a] = this.values[b];
this.values[b] = v;
}
}
export class HexPathfinder {
findPath(start, goal, neighbours, canEnter, stepCost, heuristic) {
const search = this.findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic);
let step = search.next();
while (!step.done) step = search.next();
return step.value;
}
// The same search exposed as an iterator, suspending after every expanded
// tile. A caller on the main thread can drive it a few tiles at a time and
// stay responsive instead of freezing on one long search.
*findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic) {
const startKey = key(start.x, start.y);
const goalKey = key(goal.x, goal.y);
if (startKey === goalKey || !canEnter(goal, true)) return [];
const open = new MinHeap();
const cameFrom = new Map();
const gScore = new Map();
const closed = new Set();
gScore.set(startKey, 0);
open.push(heuristic(start, goal), start);
while (!open.isEmpty()) {
const current = open.pop();
const currentKey = key(current.x, current.y);
if (!closed.has(currentKey)) {
if (currentKey === goalKey) return reconstruct(cameFrom, current);
closed.add(currentKey);
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
cameFrom.set(neighbourKey, current);
gScore.set(neighbourKey, tentative);
open.push(tentative + heuristic(neighbour, goal), neighbour);
}
}
}
yield;
}
return [];
}
}
function reconstruct(cameFrom, current) {
const path = [current];
let cursor = key(current.x, current.y);
while (cameFrom.has(cursor)) {
current = cameFrom.get(cursor);
path.unshift(current);
cursor = key(current.x, current.y);
}
return path;
}