Dijkstra maps
This commit is contained in:
parent
8b3da34d8c
commit
2ace096907
|
|
@ -25,118 +25,16 @@
|
||||||
* if (result) console.log(result.path, result.cost);
|
* if (result) console.log(result.path, result.cost);
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import type { Point } from "@common/geometry";
|
||||||
// Types
|
import {
|
||||||
// ---------------------------------------------------------------------------
|
buildAStarOptionsFrom2D,
|
||||||
|
MinHeap,
|
||||||
|
type AStar2DOptions,
|
||||||
|
type AStarOptions,
|
||||||
|
type AStarResult,
|
||||||
|
type Edge,
|
||||||
|
} from "@common/navigation/utils";
|
||||||
|
|
||||||
export interface Edge<N> {
|
|
||||||
node: N;
|
|
||||||
/** Traversal cost — must be > 0 for A* to be optimal. */
|
|
||||||
cost: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AStarOptions<N> {
|
|
||||||
/** Return the reachable neighbours of a node, with edge costs. */
|
|
||||||
neighbours: (node: N) => Iterable<Edge<N>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admissible heuristic — estimated cost from `a` to `b`.
|
|
||||||
* Must never exceed the true shortest cost (can under-estimate freely).
|
|
||||||
* Use `() => 0` for Dijkstra's algorithm.
|
|
||||||
*/
|
|
||||||
heuristic: (from: N, to: N) => number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique string key for a node.
|
|
||||||
* Default: String(node) — override whenever two distinct nodes could
|
|
||||||
* serialise the same way (objects, arrays, tuples…).
|
|
||||||
*/
|
|
||||||
key?: (node: N) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum number of nodes to expand before giving up.
|
|
||||||
* Useful as a safety valve in large or infinite graphs.
|
|
||||||
* Default: Infinity.
|
|
||||||
*/
|
|
||||||
maxExpansions?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AStarResult<N> {
|
|
||||||
/** Ordered list of nodes from start (inclusive) to goal (inclusive). */
|
|
||||||
path: N[];
|
|
||||||
/** Total accumulated edge cost along the path. */
|
|
||||||
cost: number;
|
|
||||||
/** Number of nodes popped from the open set during this search. */
|
|
||||||
expansions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Binary min-heap (open set)
|
|
||||||
// Keyed on f = g + h. Ties broken by higher g (closer to goal) — tends to
|
|
||||||
// reduce expansions on uniform grids and navmeshes.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface HeapItem<N> {
|
|
||||||
node: N;
|
|
||||||
nodeKey: string;
|
|
||||||
g: number; // cost from start
|
|
||||||
f: number; // g + h
|
|
||||||
}
|
|
||||||
|
|
||||||
class MinHeap<N> {
|
|
||||||
private data: HeapItem<N>[] = [];
|
|
||||||
|
|
||||||
get size(): number { return this.data.length; }
|
|
||||||
|
|
||||||
push(item: HeapItem<N>): void {
|
|
||||||
this.data.push(item);
|
|
||||||
this._bubbleUp(this.data.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(): HeapItem<N> | undefined {
|
|
||||||
if (this.data.length === 0) return undefined;
|
|
||||||
const top = this.data[0];
|
|
||||||
const last = this.data.pop()!;
|
|
||||||
if (this.data.length > 0) {
|
|
||||||
this.data[0] = last;
|
|
||||||
this._sinkDown(0);
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _bubbleUp(i: number): void {
|
|
||||||
while (i > 0) {
|
|
||||||
const parent = (i - 1) >> 1;
|
|
||||||
if (this._less(i, parent)) {
|
|
||||||
this._swap(i, parent);
|
|
||||||
i = parent;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sinkDown(i: number): void {
|
|
||||||
const n = this.data.length;
|
|
||||||
for (; ;) {
|
|
||||||
let best = i;
|
|
||||||
const l = 2 * i + 1, r = 2 * i + 2;
|
|
||||||
if (l < n && this._less(l, best)) best = l;
|
|
||||||
if (r < n && this._less(r, best)) best = r;
|
|
||||||
if (best === i) break;
|
|
||||||
this._swap(i, best);
|
|
||||||
i = best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lower f wins; equal f → higher g wins (prefer nodes closer to goal).
|
|
||||||
private _less(a: number, b: number): boolean {
|
|
||||||
const da = this.data[a], db = this.data[b];
|
|
||||||
return da.f < db.f || (da.f === db.f && da.g > db.g);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _swap(a: number, b: number): void {
|
|
||||||
[this.data[a], this.data[b]] = [this.data[b], this.data[a]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// A* class
|
// A* class
|
||||||
|
|
@ -351,64 +249,8 @@ export class AStar<N> {
|
||||||
// AStar2D — convenience subclass for 2-D grids
|
// AStar2D — convenience subclass for 2-D grids
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type Node2D = [number, number];
|
export class AStar2D extends AStar<Point> {
|
||||||
|
|
||||||
export interface AStar2DOptions {
|
|
||||||
/**
|
|
||||||
* Return true if the cell (toX, toY) can be entered from (fromX, fromY).
|
|
||||||
* The from-coordinates let you model direction-dependent rules such as
|
|
||||||
* one-way ramps, wall-hugging costs, or diagonal corner-cutting checks.
|
|
||||||
*/
|
|
||||||
passable: (toX: number, toY: number, fromX: number, fromY: number) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cost to move from one cell to a neighbour.
|
|
||||||
* Receives the same arguments as `passable` after the passability check.
|
|
||||||
* Default: 1 for cardinal moves, √2 for diagonal moves.
|
|
||||||
*/
|
|
||||||
cost?: (toX: number, toY: number, fromX: number, fromY: number) => number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which neighbour directions to consider.
|
|
||||||
* - 4-directional (N S E W, default)
|
|
||||||
* - 8-directional
|
|
||||||
*/
|
|
||||||
directions?: 4 | 8;
|
|
||||||
|
|
||||||
/** Forwarded to AStar. */
|
|
||||||
maxExpansions?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRS_4: Node2D[] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
|
||||||
const DIRS_8: Node2D[] = [...DIRS_4, [-1, -1], [-1, 1], [1, -1], [1, 1]];
|
|
||||||
|
|
||||||
export class AStar2D extends AStar<Node2D> {
|
|
||||||
constructor(options: AStar2DOptions) {
|
constructor(options: AStar2DOptions) {
|
||||||
const dirs = options.directions === 8 ? DIRS_8 : DIRS_4;
|
super(buildAStarOptionsFrom2D(options));
|
||||||
const costFn = options.cost
|
|
||||||
?? ((tx, ty, fx, fy) => (tx !== fx && ty !== fy ? Math.SQRT2 : 1));
|
|
||||||
|
|
||||||
super({
|
|
||||||
neighbours: ([x, y]) => {
|
|
||||||
const edges: Edge<Node2D>[] = [];
|
|
||||||
for (const [dx, dy] of dirs) {
|
|
||||||
const nx = x + dx, ny = y + dy;
|
|
||||||
if (options.passable(nx, ny, x, y)) {
|
|
||||||
edges.push({ node: [nx, ny], cost: costFn(nx, ny, x, y) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return edges;
|
|
||||||
},
|
|
||||||
// Octile heuristic — consistent for 8-directional grids;
|
|
||||||
// falls back to Manhattan for cardinal-only (diagonal term is 0).
|
|
||||||
heuristic: ([ax, ay], [bx, by]) => {
|
|
||||||
const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
|
|
||||||
return dirs === DIRS_8
|
|
||||||
? (dx + dy) + (Math.SQRT2 - 2) * Math.min(dx, dy) // octile
|
|
||||||
: dx + dy; // manhattan
|
|
||||||
},
|
|
||||||
key: ([x, y]) => `${x},${y}`,
|
|
||||||
maxExpansions: options.maxExpansions,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared types — keep Edge<N> in a shared types file if A* uses the same shape.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { Point } from "@common/geometry";
|
||||||
|
import { buildDijkstraOptionsFrom2D, MinHeap, type Dijkstra2DOptions, type DijkstraOptions, type Edge } from "@common/navigation/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DijkstraMap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class DijkstraMap<N> {
|
||||||
|
private readonly distances: Map<string, number> = new Map();
|
||||||
|
private readonly nodeRefs: Map<string, N> = new Map();
|
||||||
|
|
||||||
|
private readonly _key: (node: N) => string;
|
||||||
|
private readonly _neighbours: (node: N) => Iterable<Edge<N>>;
|
||||||
|
|
||||||
|
constructor(sources: Iterable<N>, opts: DijkstraOptions<N>) {
|
||||||
|
this._key = opts.key ?? ((n) => String(n));
|
||||||
|
this._neighbours = opts.neighbours;
|
||||||
|
this.build(sources, opts.maxExpansions ?? Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(sources: Iterable<N>, maxExpansions: number): void {
|
||||||
|
// HeapItem.f = HeapItem.g = distance — no heuristic, so f and g are
|
||||||
|
// always equal. The existing _less comparator (lower f wins, ties broken
|
||||||
|
// by higher g) reduces to a plain min-distance order, which is correct.
|
||||||
|
const heap = new MinHeap<N>();
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const nodeKey = this._key(source);
|
||||||
|
if (!this.distances.has(nodeKey)) {
|
||||||
|
this.distances.set(nodeKey, 0);
|
||||||
|
this.nodeRefs.set(nodeKey, source);
|
||||||
|
heap.push({ node: source, nodeKey, g: 0, f: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expansions = 0;
|
||||||
|
|
||||||
|
while (heap.size > 0 && expansions < maxExpansions) {
|
||||||
|
const { node, nodeKey, g: dist } = heap.pop()!;
|
||||||
|
|
||||||
|
// Discard stale entries produced by relaxation.
|
||||||
|
if (dist > this.distances.get(nodeKey)!) continue;
|
||||||
|
|
||||||
|
expansions++;
|
||||||
|
|
||||||
|
for (const edge of this._neighbours(node)) {
|
||||||
|
const neighbourKey = this._key(edge.node);
|
||||||
|
const candidate = dist + edge.cost;
|
||||||
|
const existing = this.distances.get(neighbourKey);
|
||||||
|
|
||||||
|
if (existing === undefined || candidate < existing) {
|
||||||
|
this.distances.set(neighbourKey, candidate);
|
||||||
|
this.nodeRefs.set(neighbourKey, edge.node);
|
||||||
|
// f = g = candidate: Dijkstra is A* with a zero heuristic.
|
||||||
|
heap.push({ node: edge.node, nodeKey: neighbourKey, g: candidate, f: candidate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Distance queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest-path cost from `node` to the nearest source.
|
||||||
|
* Returns `undefined` if the node was not reached (unreachable or beyond
|
||||||
|
* `maxExpansions`).
|
||||||
|
*/
|
||||||
|
distance(node: N): number | undefined {
|
||||||
|
return this.distances.get(this._key(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true` if `node` was reached during map construction. */
|
||||||
|
reachable(node: N): boolean {
|
||||||
|
return this.distances.has(this._key(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total number of nodes reached. */
|
||||||
|
get size(): number {
|
||||||
|
return this.distances.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gradient navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neighbour of `node` with the lowest map distance — the next step an
|
||||||
|
* entity should take to move *toward* the nearest source.
|
||||||
|
*
|
||||||
|
* Only reachable neighbours are considered. Returns `undefined` when `node`
|
||||||
|
* is isolated or all neighbours are unreachable.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const next = playerMap.approach(enemy.position);
|
||||||
|
* if (next) enemy.moveTo(next);
|
||||||
|
*/
|
||||||
|
approach(node: N): N | undefined {
|
||||||
|
return this.gradient(node, 'min');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neighbour of `node` with the highest map distance — the next step an
|
||||||
|
* entity should take to move *away from* all sources.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const next = playerMap.flee(enemy.position);
|
||||||
|
* if (next) enemy.moveTo(next);
|
||||||
|
*/
|
||||||
|
flee(node: N): N | undefined {
|
||||||
|
return this.gradient(node, 'max');
|
||||||
|
}
|
||||||
|
|
||||||
|
private gradient(node: N, mode: 'min' | 'max'): N | undefined {
|
||||||
|
let best: N | undefined;
|
||||||
|
let bestDist = mode === 'min' ? Infinity : -Infinity;
|
||||||
|
|
||||||
|
for (const edge of this._neighbours(node)) {
|
||||||
|
const d = this.distances.get(this._key(edge.node));
|
||||||
|
if (d === undefined) continue; // unreachable — never move there
|
||||||
|
if (mode === 'min' ? d < bestDist : d > bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
best = edge.node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Iteration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates all `[node, distance]` pairs in the map.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* for (const [node, dist] of playerMap.entries()) {
|
||||||
|
* renderer.tint(node, distToColor(dist));
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
*entries(): IterableIterator<[N, number]> {
|
||||||
|
for (const [k, dist] of this.distances) {
|
||||||
|
yield [this.nodeRefs.get(k)!, dist];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator]() {
|
||||||
|
return this.entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static factory: influence map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines multiple `DijkstraMap` layers into a single **influence map**.
|
||||||
|
*
|
||||||
|
* Each layer contributes `weight × distance(node)` to a total score.
|
||||||
|
* Use **negative weights** to attract toward a source, **positive** to repel.
|
||||||
|
*
|
||||||
|
* `approach()` moves toward the lowest combined score (most desirable cell).
|
||||||
|
* `flee()` moves toward the highest combined score (emergency escape).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const goblinAI = DijkstraMap.combine(
|
||||||
|
* [
|
||||||
|
* { map: playerMap, weight: -1.5 }, // chase
|
||||||
|
* { map: archerMap, weight: 2.0 }, // dodge archers
|
||||||
|
* { map: lootMap, weight: -0.5 }, // weakly drawn to loot
|
||||||
|
* ],
|
||||||
|
* { neighbours: grid.walkable, key: grid.key },
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* const next = goblinAI.approach(goblin.position);
|
||||||
|
*/
|
||||||
|
static combine<N>(
|
||||||
|
layers: ReadonlyArray<{ map: DijkstraMap<N>; weight: number }>,
|
||||||
|
opts: DijkstraOptions<N>,
|
||||||
|
): InfluenceMap<N> {
|
||||||
|
return new InfluenceMap(layers, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InfluenceMap — weighted sum of Dijkstra layers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Layers<N> = ReadonlyArray<{ map: DijkstraMap<N>; weight: number }>;
|
||||||
|
|
||||||
|
export class InfluenceMap<N> {
|
||||||
|
private readonly layers: Layers<N>;
|
||||||
|
private readonly _neighbours: (node: N) => Iterable<Edge<N>>;
|
||||||
|
|
||||||
|
/** @internal — use `DijkstraMap.combine()` instead. */
|
||||||
|
constructor(
|
||||||
|
layers: Layers<N>,
|
||||||
|
opts: DijkstraOptions<N>,
|
||||||
|
) {
|
||||||
|
this.layers = layers;
|
||||||
|
this._neighbours = opts.neighbours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined influence score for `node`: `Σ (weight_i × distance_i(node))`.
|
||||||
|
*
|
||||||
|
* Returns `undefined` if the node is unreachable in every layer.
|
||||||
|
* Layers that don't reach the node are omitted from the sum rather than
|
||||||
|
* treated as 0, so a node reachable by only some layers still gets a
|
||||||
|
* meaningful score.
|
||||||
|
*/
|
||||||
|
score(node: N): number | undefined {
|
||||||
|
let total = 0;
|
||||||
|
let hasAny = false;
|
||||||
|
|
||||||
|
for (const { map, weight } of this.layers) {
|
||||||
|
const d = map.distance(node);
|
||||||
|
if (d !== undefined) {
|
||||||
|
total += weight * d;
|
||||||
|
hasAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAny ? total : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step toward the lowest-scoring neighbour (most desirable position). */
|
||||||
|
approach(node: N): N | undefined {
|
||||||
|
return this.gradient(node, 'min');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step toward the highest-scoring neighbour (least desirable position). */
|
||||||
|
flee(node: N): N | undefined {
|
||||||
|
return this.gradient(node, 'max');
|
||||||
|
}
|
||||||
|
|
||||||
|
private gradient(node: N, mode: 'min' | 'max'): N | undefined {
|
||||||
|
let best: N | undefined;
|
||||||
|
let bestScore = mode === 'min' ? Infinity : -Infinity;
|
||||||
|
|
||||||
|
for (const edge of this._neighbours(node)) {
|
||||||
|
const s = this.score(edge.node);
|
||||||
|
if (s === undefined) continue;
|
||||||
|
if (mode === 'min' ? s < bestScore : s > bestScore) {
|
||||||
|
bestScore = s;
|
||||||
|
best = edge.node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DijkstraMap2D — convenience subclass for 2-D grids
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class DijkstraMap2D extends DijkstraMap<Point> {
|
||||||
|
constructor(sources: Iterable<Point>, options: Dijkstra2DOptions) {
|
||||||
|
super(sources, buildDijkstraOptionsFrom2D(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InfluenceMap2D — convenience subclass for 2-D grids
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InfluenceMap2D extends InfluenceMap<Point> {
|
||||||
|
constructor(layers: Layers<Point>, options: Dijkstra2DOptions) {
|
||||||
|
super(layers, buildDijkstraOptionsFrom2D(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MinHeap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { Point } from "@common/geometry";
|
||||||
|
|
||||||
|
export interface HeapItem<N> {
|
||||||
|
node: N;
|
||||||
|
nodeKey: string;
|
||||||
|
g: number; // cost from start
|
||||||
|
f: number; // g + h
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MinHeap<N> {
|
||||||
|
private data: HeapItem<N>[] = [];
|
||||||
|
|
||||||
|
get size(): number { return this.data.length; }
|
||||||
|
|
||||||
|
push(item: HeapItem<N>): void {
|
||||||
|
this.data.push(item);
|
||||||
|
this._bubbleUp(this.data.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pop(): HeapItem<N> | undefined {
|
||||||
|
if (this.data.length === 0) return undefined;
|
||||||
|
const top = this.data[0];
|
||||||
|
const last = this.data.pop()!;
|
||||||
|
if (this.data.length > 0) {
|
||||||
|
this.data[0] = last;
|
||||||
|
this._sinkDown(0);
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bubbleUp(i: number): void {
|
||||||
|
while (i > 0) {
|
||||||
|
const parent = (i - 1) >> 1;
|
||||||
|
if (this._less(i, parent)) {
|
||||||
|
this._swap(i, parent);
|
||||||
|
i = parent;
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sinkDown(i: number): void {
|
||||||
|
const n = this.data.length;
|
||||||
|
for (; ;) {
|
||||||
|
let best = i;
|
||||||
|
const l = 2 * i + 1, r = 2 * i + 2;
|
||||||
|
if (l < n && this._less(l, best)) best = l;
|
||||||
|
if (r < n && this._less(r, best)) best = r;
|
||||||
|
if (best === i) break;
|
||||||
|
this._swap(i, best);
|
||||||
|
i = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower f wins; equal f → higher g wins (prefer nodes closer to goal).
|
||||||
|
private _less(a: number, b: number): boolean {
|
||||||
|
const da = this.data[a], db = this.data[b];
|
||||||
|
return da.f < db.f || (da.f === db.f && da.g > db.g);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _swap(a: number, b: number): void {
|
||||||
|
[this.data[a], this.data[b]] = [this.data[b], this.data[a]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Edge<N> {
|
||||||
|
node: N;
|
||||||
|
/** Traversal cost — must be > 0 for A* to be optimal. */
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DijkstraOptions<N> {
|
||||||
|
/**
|
||||||
|
* Return the reachable neighbours of a node, with edge costs.
|
||||||
|
* Costs must be non-negative.
|
||||||
|
*/
|
||||||
|
neighbours: (node: N) => Iterable<Edge<N>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique string key for a node.
|
||||||
|
* Default: `String(node)` — override whenever two distinct nodes could
|
||||||
|
* serialise the same way (objects, arrays, coordinate tuples…).
|
||||||
|
*/
|
||||||
|
key?: (node: N) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of nodes to expand before stopping.
|
||||||
|
* Nodes beyond the frontier are left unreachable (`distance` → `undefined`).
|
||||||
|
* Default: `Infinity`.
|
||||||
|
*/
|
||||||
|
maxExpansions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AStarOptions<N> extends DijkstraOptions<N> {
|
||||||
|
/**
|
||||||
|
* Admissible heuristic — estimated cost from `a` to `b`.
|
||||||
|
* Must never exceed the true shortest cost (can under-estimate freely).
|
||||||
|
* Use `() => 0` for Dijkstra's algorithm.
|
||||||
|
*/
|
||||||
|
heuristic: (from: N, to: N) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AStarResult<N> {
|
||||||
|
/** Ordered list of nodes from start (inclusive) to goal (inclusive). */
|
||||||
|
path: N[];
|
||||||
|
/** Total accumulated edge cost along the path. */
|
||||||
|
cost: number;
|
||||||
|
/** Number of nodes popped from the open set during this search. */
|
||||||
|
expansions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dijkstra2DOptions {
|
||||||
|
/**
|
||||||
|
* Return true if the cell (toX, toY) can be entered from (fromX, fromY).
|
||||||
|
* The from-coordinates let you model direction-dependent rules such as
|
||||||
|
* one-way ramps, wall-hugging costs, or diagonal corner-cutting checks.
|
||||||
|
*/
|
||||||
|
passable: (toX: number, toY: number, fromX: number, fromY: number) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost to move from one cell to a neighbour.
|
||||||
|
* Receives the same arguments as `passable` after the passability check.
|
||||||
|
* Default: 1 for cardinal moves, √2 for diagonal moves.
|
||||||
|
*/
|
||||||
|
cost?: (toX: number, toY: number, fromX: number, fromY: number) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which neighbour directions to consider.
|
||||||
|
* - 4-directional (N S E W, default)
|
||||||
|
* - 8-directional
|
||||||
|
*/
|
||||||
|
directions?: 4 | 8;
|
||||||
|
|
||||||
|
/** Forwarded to AStar. */
|
||||||
|
maxExpansions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AStar2DOptions extends Dijkstra2DOptions { }
|
||||||
|
|
||||||
|
const DIRS_4: Point[] = [{ x: -1, y: 0 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: 0, y: 1 }];
|
||||||
|
const DIRS_8: Point[] = [...DIRS_4, { x: -1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: -1 }, { x: 1, y: 1 }];
|
||||||
|
|
||||||
|
export const buildDijkstraOptionsFrom2D = (options: Dijkstra2DOptions): DijkstraOptions<Point> => {
|
||||||
|
const dirs = options.directions === 8 ? DIRS_8 : DIRS_4;
|
||||||
|
const costFn = options.cost
|
||||||
|
?? ((tx, ty, fx, fy) => (tx !== fx && ty !== fy ? Math.SQRT2 : 1));
|
||||||
|
|
||||||
|
return {
|
||||||
|
neighbours: ({ x, y }) => {
|
||||||
|
const edges: Edge<Point>[] = [];
|
||||||
|
for (const { x: dx, y: dy } of dirs) {
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (options.passable(nx, ny, x, y)) {
|
||||||
|
edges.push({ node: { x: nx, y: ny }, cost: costFn(nx, ny, x, y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
},
|
||||||
|
key: ({ x, y }) => `${x},${y}`,
|
||||||
|
maxExpansions: options.maxExpansions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildAStarOptionsFrom2D = (options: AStar2DOptions): AStarOptions<Point> => {
|
||||||
|
const dirs = options.directions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildDijkstraOptionsFrom2D(options),
|
||||||
|
// Octile heuristic — consistent for 8-directional grids;
|
||||||
|
// falls back to Manhattan for cardinal-only (diagonal term is 0).
|
||||||
|
heuristic: ({ x: ax, y: ay }, { x: bx, y: by }) => {
|
||||||
|
const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
|
||||||
|
return dirs === 8
|
||||||
|
? (dx + dy) + (Math.SQRT2 - 2) * Math.min(dx, dy) // octile
|
||||||
|
: dx + dy; // manhattan
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue