mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 09:32:28 +00:00
Pathfinding Refactor pt. 2 (#2866)
## Playtest https://pf-pt-2.openfront.dev/ ## Pathfinding Refactor pt. 2 <img width="1536" height="1024" alt="image" src="https://github.com/user-attachments/assets/9477958e-54b7-4c83-b317-ba789e809e9e" /> This is a follow-up to a previous PR introducing pathfinding changes. This time, it introduces a complete refactor of `pathfinding` directory and breakdown into composable pieces. ### Unified PathFinder interface `PathFinder<T>` and `SteppingPathFinder<T>` are introduced to unify **all** pathfinding across the application. First one exposes complete path, while stepping variant allows the callee to iterate over the path by calling `.next`. All pathfinders share this one common interface, which makes them easy to use in any scenario - `PathFinding.Water(game).search(from, to)`. `SteppingPathFinder<T>` extends `PathFinder<T>` with an ability to iterate over the path. It handles caching, storing current index and invalidation. This allows the units to not care about the inner workings of the pathfinder and just call `pf.next(current, target)` and receive instructions on what to do next. ### Common entry point All pathfinders are now exposed from common `PathFinding` entrypoint: - `PathFinding.Water` - `PathFinding.Rail` - `PathFinding.Stations` - `PathFinding.Rail` Additional entry point is introduced for pathfinders which need to work both in the worker, but also on the frontend, which lacks `Game` interface. Currently only `UniversalPathFinding.Parabola` is available. ### Spatial Query New module has been introduced close to `pathfinding` - `SpatialQuery`. It aims to resolve any questions game may have about finding tiles meeting criteria. Currently `SpatialQuery.closestShore(player, target)` and `SpatialQuery.closestShoreByWater(player, target)` are available - they help answering questions about naval invasion: "What is the best landing location from user's click?" and "Which our tile should be used to launch the transport ship?". Under the hood they use very similar mechanics to pathfinding, so it felt right to put them close by. ### Modular architecture Pathfinders now support transformers: `MiniMapTransformer`, `ShoreCoercingTransformer`, `ComponentCheckTransformer`, `SmoothingTransformer`. Transformers functions like a middleware in the pathfinding chain. They wrap around the pathfinder and provide additional functionality. This allows the pathfinder to focus on actually finding the path instead of doing unrelated things. Example chain for simple (A*) water pathfinding: ```ts static WaterSimple(game: Game): SteppingPathFinder<TileRef> { const miniMap = game.miniMap(); const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) .buildWithStepper(tileStepperConfig(game)); } ``` The Pathfinder - here `AStarWater` - does not care about the conversion between minimap and main map tiles. It also does not care if the source or destination is a land tile. The transformers take care of that. The pathfinder gets a set of valid coordinates and produces the path - that's it. Modular approach makes working on a particular set of utilities much easier - for example map upscaling is handled consistently across all pathfinders. Additionally, the pathfinders are not tied to the particular map resolution used. Pass them a different map and they will work the same. ### Algorithms Algorithms used are neatly organized inside `src/core/pathfinding/algorithms`. They are prefixed with the algorithm name and suffixed with the use case. File without suffix exposes generic version ready to traverse any graph with adapters. Specialized versions either use an adapter or inline logic when performance is critical - using adapters leads to 20-30% performance loss. The directory includes `A*` and `BFS` but also other useful utils, such as `AbstractGraph` used to generate... an abstract graph on top of the tile map and `ConnectedComponents` helping to identify whether two tiles are connected by a path without actually computing the path. ### Playground The playground have been updated with new algorithms, including tweaked very greedy `A*`. <img width="2175" height="1424" alt="image" src="https://github.com/user-attachments/assets/1f833651-0024-4299-bf86-882f5368358c" /> ### Tests Yeah, there are some, a little too many if I say so myself. But there are no useless tests. I had to ensure refactored code works somehow reliably. This PR comes with trust me bro guarantee, but I would appreciate someone confirming **naval invasions, nukes (esp. MIRV) and warships**. ### Discord `moleole` GL & HF
This commit is contained in:
committed by
GitHub
parent
bcec4ad758
commit
0e3ced3bfa
@@ -1,31 +0,0 @@
|
||||
export interface AStar<NodeType> {
|
||||
compute(): PathFindResultType;
|
||||
reconstructPath(): NodeType[];
|
||||
}
|
||||
|
||||
export enum PathFindResultType {
|
||||
NextTile,
|
||||
Pending,
|
||||
Completed,
|
||||
PathNotFound,
|
||||
}
|
||||
export type AStarResult<NodeType> =
|
||||
| {
|
||||
type: PathFindResultType.NextTile;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Pending;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Completed;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.PathNotFound;
|
||||
};
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Cell } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
import { GraphAdapter, SerialAStar } from "./SerialAStar";
|
||||
|
||||
export class GameMapAdapter implements GraphAdapter<TileRef> {
|
||||
private readonly waterPenalty = 3;
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private waterPath: boolean,
|
||||
) {}
|
||||
|
||||
neighbors(node: TileRef): TileRef[] {
|
||||
return this.gameMap.neighbors(node);
|
||||
}
|
||||
|
||||
cost(node: TileRef): number {
|
||||
let base = this.gameMap.cost(node);
|
||||
// Avoid crossing water when possible
|
||||
if (!this.waterPath && this.gameMap.isWater(node)) {
|
||||
base += this.waterPenalty;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
position(node: TileRef): { x: number; y: number } {
|
||||
return { x: this.gameMap.x(node), y: this.gameMap.y(node) };
|
||||
}
|
||||
|
||||
isTraversable(from: TileRef, to: TileRef): boolean {
|
||||
const toWater = this.gameMap.isWater(to);
|
||||
if (this.waterPath) {
|
||||
return toWater;
|
||||
}
|
||||
// Allow water access from/to shore
|
||||
const fromShore = this.gameMap.isShoreline(from);
|
||||
const toShore = this.gameMap.isShoreline(to);
|
||||
return !toWater || fromShore || toShore;
|
||||
}
|
||||
}
|
||||
export class MiniAStar implements AStar<TileRef> {
|
||||
private aStar: AStar<TileRef>;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private miniMap: GameMap,
|
||||
private src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
iterations: number,
|
||||
maxTries: number,
|
||||
waterPath: boolean = true,
|
||||
directionChangePenalty: number = 0,
|
||||
) {
|
||||
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
|
||||
const miniSrc = srcArray.map((srcPoint) =>
|
||||
this.miniMap.ref(
|
||||
Math.floor(gameMap.x(srcPoint) / 2),
|
||||
Math.floor(gameMap.y(srcPoint) / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const miniDst = this.miniMap.ref(
|
||||
Math.floor(gameMap.x(dst) / 2),
|
||||
Math.floor(gameMap.y(dst) / 2),
|
||||
);
|
||||
|
||||
this.aStar = new SerialAStar(
|
||||
miniSrc,
|
||||
miniDst,
|
||||
iterations,
|
||||
maxTries,
|
||||
new GameMapAdapter(miniMap, waterPath),
|
||||
directionChangePenalty,
|
||||
);
|
||||
}
|
||||
|
||||
compute(): PathFindResultType {
|
||||
return this.aStar.compute();
|
||||
}
|
||||
|
||||
reconstructPath(): TileRef[] {
|
||||
let cellSrc: Cell | undefined;
|
||||
if (!Array.isArray(this.src)) {
|
||||
cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src));
|
||||
}
|
||||
const cellDst = new Cell(
|
||||
this.gameMap.x(this.dst),
|
||||
this.gameMap.y(this.dst),
|
||||
);
|
||||
const upscaled = fixExtremes(
|
||||
upscalePath(
|
||||
this.aStar
|
||||
.reconstructPath()
|
||||
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
|
||||
),
|
||||
cellDst,
|
||||
cellSrc,
|
||||
);
|
||||
return upscaled.map((c) => this.gameMap.ref(c.x, c.y));
|
||||
}
|
||||
}
|
||||
|
||||
function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
// didn't find the start tile in the path
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
// found start tile but not at the start
|
||||
// remove all tiles before the start tile
|
||||
upscaled = upscaled.slice(srcIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const dstIndex = findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
// didn't find the dst tile in the path
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
// found dst tile but not at the end
|
||||
// remove all tiles after the dst tile
|
||||
upscaled = upscaled.slice(0, dstIndex + 1);
|
||||
}
|
||||
return upscaled;
|
||||
}
|
||||
|
||||
function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
// Scale up each point
|
||||
const scaledPath = path.map(
|
||||
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
|
||||
);
|
||||
|
||||
const smoothPath: Cell[] = [];
|
||||
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
// Add the current point
|
||||
smoothPath.push(current);
|
||||
|
||||
// Always interpolate between scaled points
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
|
||||
// Calculate number of steps needed
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
// Add intermediate points
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push(
|
||||
new Cell(
|
||||
Math.round(current.x + (dx * step) / steps),
|
||||
Math.round(current.y + (dy * step) / steps),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last point
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
return smoothPath;
|
||||
}
|
||||
|
||||
function findCell(upscaled: Cell[], cellDst: Cell): number {
|
||||
for (let i = 0; i < upscaled.length; i++) {
|
||||
if (upscaled[i].x === cellDst.x && upscaled[i].y === cellDst.y) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PathFinder } from "./types";
|
||||
|
||||
export class AirPathFinder implements PathFinder<TileRef> {
|
||||
private seed: number;
|
||||
|
||||
constructor(private game: Game) {
|
||||
this.seed = game.ticks();
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
throw new Error("AirPathFinder does not support multiple start points");
|
||||
}
|
||||
|
||||
const random = new PseudoRandom(this.seed);
|
||||
const path: TileRef[] = [from];
|
||||
let current = from;
|
||||
|
||||
while (current !== to) {
|
||||
const next = this.computeNext(current, to, random);
|
||||
if (next === current) break; // Prevent infinite loop if something breaks
|
||||
current = next;
|
||||
path.push(current);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private computeNext(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
random: PseudoRandom,
|
||||
): TileRef {
|
||||
const x = this.game.x(from);
|
||||
const y = this.game.y(from);
|
||||
const dstX = this.game.x(to);
|
||||
const dstY = this.game.y(to);
|
||||
|
||||
if (x === dstX && y === dstY) {
|
||||
return to;
|
||||
}
|
||||
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
|
||||
|
||||
if (x === dstX) {
|
||||
// Can only move in Y
|
||||
nextY += y < dstY ? 1 : -1;
|
||||
} else if (y === dstY) {
|
||||
// Can only move in X
|
||||
nextX += x < dstX ? 1 : -1;
|
||||
} else {
|
||||
if (random.chance(ratio)) {
|
||||
nextX += x < dstX ? 1 : -1;
|
||||
} else {
|
||||
nextY += y < dstY ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return this.game.ref(nextX, nextY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { within } from "../Util";
|
||||
import { DistanceBasedBezierCurve } from "../utilities/Line";
|
||||
import { PathResult, PathStatus, SteppingPathFinder } from "./types";
|
||||
|
||||
export interface ParabolaOptions {
|
||||
increment?: number;
|
||||
distanceBasedHeight?: boolean;
|
||||
directionUp?: boolean;
|
||||
}
|
||||
|
||||
const PARABOLA_MIN_HEIGHT = 50;
|
||||
|
||||
export class ParabolaUniversalPathFinder
|
||||
implements SteppingPathFinder<TileRef>
|
||||
{
|
||||
private curve: DistanceBasedBezierCurve | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private options?: ParabolaOptions,
|
||||
) {}
|
||||
|
||||
private createCurve(from: TileRef, to: TileRef): DistanceBasedBezierCurve {
|
||||
const increment = this.options?.increment ?? 3;
|
||||
const distanceBasedHeight = this.options?.distanceBasedHeight ?? true;
|
||||
const directionUp = this.options?.directionUp ?? true;
|
||||
|
||||
const p0 = { x: this.gameMap.x(from), y: this.gameMap.y(from) };
|
||||
const p3 = { x: this.gameMap.x(to), y: this.gameMap.y(to) };
|
||||
const dx = p3.x - p0.x;
|
||||
const dy = p3.y - p0.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxHeight = distanceBasedHeight
|
||||
? Math.max(distance / 3, PARABOLA_MIN_HEIGHT)
|
||||
: 0;
|
||||
const heightMult = directionUp ? -1 : 1;
|
||||
const mapHeight = this.gameMap.height();
|
||||
|
||||
const p1 = {
|
||||
x: p0.x + dx / 4,
|
||||
y: within(p0.y + dy / 4 + heightMult * maxHeight, 0, mapHeight - 1),
|
||||
};
|
||||
const p2 = {
|
||||
x: p0.x + (dx * 3) / 4,
|
||||
y: within(p0.y + (dy * 3) / 4 + heightMult * maxHeight, 0, mapHeight - 1),
|
||||
};
|
||||
|
||||
return new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
throw new Error(
|
||||
"ParabolaUniversalPathFinder does not support multiple start points",
|
||||
);
|
||||
}
|
||||
const curve = this.createCurve(from, to);
|
||||
return curve
|
||||
.getAllPoints()
|
||||
.map((p) => this.gameMap.ref(Math.floor(p.x), Math.floor(p.y)));
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, speed?: number): PathResult<TileRef> {
|
||||
if (this.lastTo !== to) {
|
||||
this.curve = this.createCurve(from, to);
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
const nextPoint = this.curve!.increment(speed ?? 1);
|
||||
if (!nextPoint) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
const tile = this.gameMap.ref(
|
||||
Math.floor(nextPoint.x),
|
||||
Math.floor(nextPoint.y),
|
||||
);
|
||||
return { status: PathStatus.NEXT, node: tile };
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.curve = null;
|
||||
this.lastTo = null;
|
||||
}
|
||||
|
||||
currentIndex(): number {
|
||||
return this.curve?.getCurrentIndex() ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { StationManager } from "../game/RailNetworkImpl";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
import { AStar, AStarAdapter } from "./algorithms/AStar";
|
||||
import { PathFinder } from "./types";
|
||||
|
||||
export class StationPathFinder implements PathFinder<TrainStation> {
|
||||
private manager: StationManager;
|
||||
private aStar: AStar;
|
||||
|
||||
constructor(game: Game) {
|
||||
this.manager = game.railNetwork().stationManager();
|
||||
const adapter = new StationGraphAdapter(game, this.manager);
|
||||
this.aStar = new AStar({ adapter });
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: TrainStation | TrainStation[],
|
||||
to: TrainStation,
|
||||
): TrainStation[] | null {
|
||||
const toCluster = to.getCluster();
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const sameCluster = fromArray.filter((s) => s.getCluster() === toCluster);
|
||||
if (sameCluster.length === 0) return null;
|
||||
|
||||
const fromIds = sameCluster.map((s) => s.id);
|
||||
const path = this.aStar.findPath(fromIds, to.id);
|
||||
|
||||
if (!path) return null;
|
||||
return path.map((id) => this.manager.getById(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
class StationGraphAdapter implements AStarAdapter {
|
||||
constructor(
|
||||
private game: Game,
|
||||
private manager: StationManager,
|
||||
) {}
|
||||
|
||||
numNodes(): number {
|
||||
return this.manager.count();
|
||||
}
|
||||
|
||||
maxNeighbors(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
maxPriority(): number {
|
||||
return this.game.map().width() + this.game.map().height();
|
||||
}
|
||||
|
||||
neighbors(node: number, buffer: Int32Array): number {
|
||||
const station = this.manager.getById(node);
|
||||
if (!station) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (const n of station.neighbors()) {
|
||||
buffer[count++] = n.id;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
cost(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const a = this.manager.getById(node);
|
||||
const b = this.manager.getById(goal);
|
||||
if (!a || !b) return 0;
|
||||
|
||||
const ax = this.game.x(a.tile());
|
||||
const ay = this.game.y(a.tile());
|
||||
const bx = this.game.x(b.tile());
|
||||
const by = this.game.y(b.tile());
|
||||
return Math.abs(ax - bx) + Math.abs(ay - by);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,104 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
|
||||
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
import { AStarRail } from "./algorithms/AStar.Rail";
|
||||
import { AStarWater } from "./algorithms/AStar.Water";
|
||||
import { AirPathFinder } from "./PathFinder.Air";
|
||||
import {
|
||||
ParabolaOptions,
|
||||
ParabolaUniversalPathFinder,
|
||||
} from "./PathFinder.Parabola";
|
||||
import { StationPathFinder } from "./PathFinder.Station";
|
||||
import { PathFinderBuilder } from "./PathFinderBuilder";
|
||||
import { StepperConfig } from "./PathFinderStepper";
|
||||
import { BresenhamSmoothingTransformer } from "./smoothing/BresenhamPathSmoother";
|
||||
import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer";
|
||||
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
|
||||
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
|
||||
import { PathStatus, SteppingPathFinder } from "./types";
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
/**
|
||||
* Pathfinders that work with GameMap - usable in both simulation and UI layers
|
||||
*/
|
||||
export class UniversalPathFinding {
|
||||
static Parabola(
|
||||
gameMap: GameMap,
|
||||
options?: ParabolaOptions,
|
||||
): ParabolaUniversalPathFinder {
|
||||
return new ParabolaUniversalPathFinder(gameMap, options);
|
||||
}
|
||||
}
|
||||
|
||||
export type PathResult =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: TileRef }
|
||||
| { status: PathStatus.COMPLETE; node: TileRef }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
/**
|
||||
* Pathfinders that require Game - simulation layer only
|
||||
*/
|
||||
export class PathFinding {
|
||||
static Water(game: Game): SteppingPathFinder<TileRef> {
|
||||
const pf = game.miniWaterHPA();
|
||||
const graph = game.miniWaterGraph();
|
||||
|
||||
export interface PathFinder {
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult;
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null;
|
||||
}
|
||||
|
||||
export interface MiniAStarOptions {
|
||||
waterPath?: boolean;
|
||||
iterations?: number;
|
||||
maxTries?: number;
|
||||
}
|
||||
|
||||
export class PathFinders {
|
||||
static Water(game: Game): PathFinder {
|
||||
if (!game.navMesh()) {
|
||||
// Fall back to old water pathfinder if navmesh is not available
|
||||
return PathFinders.WaterLegacy(game);
|
||||
if (!pf || !graph || graph.nodeCount < 100) {
|
||||
return PathFinding.WaterSimple(game);
|
||||
}
|
||||
|
||||
return new NavMeshAdapter(game);
|
||||
const miniMap = game.miniMap();
|
||||
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
|
||||
.wrap((pf) => new BresenhamSmoothingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder {
|
||||
return new MiniAStarAdapter(game, options);
|
||||
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const pf = new AStarWater(miniMap);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static Rail(game: Game): SteppingPathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const pf = new AStarRail(miniMap);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static Stations(game: Game): SteppingPathFinder<TrainStation> {
|
||||
const pf = new StationPathFinder(game);
|
||||
|
||||
return PathFinderBuilder.create(pf).buildWithStepper({
|
||||
equals: (a, b) => a.id === b.id,
|
||||
distance: (a, b) => game.manhattanDist(a.tile(), b.tile()),
|
||||
});
|
||||
}
|
||||
|
||||
static Air(game: Game): SteppingPathFinder<TileRef> {
|
||||
const pf = new AirPathFinder(game);
|
||||
|
||||
return PathFinderBuilder.create(pf).buildWithStepper({
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
|
||||
return {
|
||||
equals: (a, b) => a === b,
|
||||
distance: (a, b) => game.manhattanDist(a, b),
|
||||
preCheck: (from, to) =>
|
||||
typeof from !== "number" ||
|
||||
typeof to !== "number" ||
|
||||
!game.isValidRef(from) ||
|
||||
!game.isValidRef(to)
|
||||
? { status: PathStatus.NOT_FOUND }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PathFinderStepper, StepperConfig } from "./PathFinderStepper";
|
||||
import { PathFinder, SteppingPathFinder } from "./types";
|
||||
|
||||
type WrapFactory<T> = (pf: PathFinder<T>) => PathFinder<T>;
|
||||
|
||||
/**
|
||||
* PathFinderBuilder - fluent builder for composing PathFinder transformers.
|
||||
*
|
||||
* Usage:
|
||||
* const finder = PathFinderBuilder.create(corePathFinder)
|
||||
* .wrap((pf) => new SomeTransformer(pf, deps))
|
||||
* .wrap((pf) => new AnotherTransformer(pf, deps))
|
||||
* .build();
|
||||
*/
|
||||
export class PathFinderBuilder<T> {
|
||||
private wrappers: WrapFactory<T>[] = [];
|
||||
|
||||
private constructor(private core: PathFinder<T>) {}
|
||||
|
||||
static create<T>(core: PathFinder<T>): PathFinderBuilder<T> {
|
||||
return new PathFinderBuilder(core);
|
||||
}
|
||||
|
||||
wrap(factory: WrapFactory<T>): this {
|
||||
this.wrappers.push(factory);
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): PathFinder<T> {
|
||||
return this.wrappers.reduce(
|
||||
(pf, wrapper) => wrapper(pf),
|
||||
this.core as PathFinder<T>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and wrap with PathFinderStepper for step-by-step traversal.
|
||||
*/
|
||||
buildWithStepper(config: StepperConfig<T>): SteppingPathFinder<T> {
|
||||
return new PathFinderStepper(this.build(), config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
SteppingPathFinder,
|
||||
} from "./types";
|
||||
|
||||
export interface StepperConfig<T> {
|
||||
equals: (a: T, b: T) => boolean;
|
||||
distance?: (a: T, b: T) => number;
|
||||
preCheck?: (from: T, to: T) => PathResult<T> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PathFinderStepper - wraps a PathFinder and provides step-by-step traversal
|
||||
*
|
||||
* Handles path caching, invalidation, and incremental movement.
|
||||
* Generic over any PathFinder<T> implementation.
|
||||
*/
|
||||
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
private path: T[] | null = null;
|
||||
private pathIndex = 0;
|
||||
private lastTo: T | null = null;
|
||||
|
||||
constructor(
|
||||
private finder: PathFinder<T>,
|
||||
private config: StepperConfig<T> = { equals: (a, b) => a === b },
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the next step on the path from `from` to `to`.
|
||||
* Returns PathResult with status and optional next node.
|
||||
*/
|
||||
next(from: T, to: T, dist?: number): PathResult<T> {
|
||||
// Domain-specific pre-check (validation, cluster, etc.)
|
||||
if (this.config.preCheck) {
|
||||
const result = this.config.preCheck(from, to);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (this.config.equals(from, to)) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
// Distance-based early exit
|
||||
if (dist !== undefined && dist > 0 && this.config.distance) {
|
||||
if (this.config.distance(from, to) <= dist) {
|
||||
return { status: PathStatus.COMPLETE, node: from };
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache if destination changed
|
||||
if (this.lastTo === null || !this.config.equals(this.lastTo, to)) {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
// Compute path if not cached
|
||||
if (this.path === null) {
|
||||
try {
|
||||
this.path = this.finder.findPath(from, to);
|
||||
} catch (err) {
|
||||
console.error("PathFinder threw an error during findPath", err);
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
this.pathIndex = 0;
|
||||
if (this.path.length > 0 && this.config.equals(this.path[0], from)) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) {
|
||||
this.invalidate();
|
||||
this.lastTo = to;
|
||||
return this.next(from, to, dist);
|
||||
}
|
||||
|
||||
// Check if we've reached the end
|
||||
if (this.pathIndex >= this.path.length) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
// Return next step
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = null;
|
||||
}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
if (this.config.preCheck) {
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
|
||||
const allFailed = fromArray.every((f) => {
|
||||
const result = this.config.preCheck!(f, to);
|
||||
return result?.status === PathStatus.NOT_FOUND;
|
||||
});
|
||||
|
||||
if (allFailed) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.finder.findPath(from, to);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { within } from "../Util";
|
||||
import { DistanceBasedBezierCurve } from "../utilities/Line";
|
||||
import { AStar, AStarResult, PathFindResultType } from "./AStar";
|
||||
import { MiniAStar } from "./MiniAStar";
|
||||
|
||||
const parabolaMinHeight = 50;
|
||||
|
||||
export class ParabolaPathFinder {
|
||||
constructor(private mg: GameMap) {}
|
||||
private curve: DistanceBasedBezierCurve | undefined;
|
||||
|
||||
computeControlPoints(
|
||||
orig: TileRef,
|
||||
dst: TileRef,
|
||||
increment: number = 3,
|
||||
distanceBasedHeight = true,
|
||||
directionUp = true,
|
||||
) {
|
||||
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
|
||||
const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) };
|
||||
const dx = p3.x - p0.x;
|
||||
const dy = p3.y - p0.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxHeight = distanceBasedHeight
|
||||
? Math.max(distance / 3, parabolaMinHeight)
|
||||
: 0;
|
||||
// Use a bezier curve pointing up or down based on directionUp parameter
|
||||
const heightMultiplier = directionUp ? -1 : 1;
|
||||
const mapHeight = this.mg.height();
|
||||
const p1 = {
|
||||
x: p0.x + (p3.x - p0.x) / 4,
|
||||
y: within(
|
||||
p0.y + (p3.y - p0.y) / 4 + heightMultiplier * maxHeight,
|
||||
0,
|
||||
mapHeight - 1,
|
||||
),
|
||||
};
|
||||
const p2 = {
|
||||
x: p0.x + ((p3.x - p0.x) * 3) / 4,
|
||||
y: within(
|
||||
p0.y + ((p3.y - p0.y) * 3) / 4 + heightMultiplier * maxHeight,
|
||||
0,
|
||||
mapHeight - 1,
|
||||
),
|
||||
};
|
||||
|
||||
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
|
||||
}
|
||||
|
||||
nextTile(speed: number): TileRef | true {
|
||||
if (!this.curve) {
|
||||
throw new Error("ParabolaPathFinder not initialized");
|
||||
}
|
||||
const nextPoint = this.curve.increment(speed);
|
||||
if (!nextPoint) {
|
||||
return true;
|
||||
}
|
||||
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
|
||||
}
|
||||
|
||||
currentIndex(): number {
|
||||
if (!this.curve) {
|
||||
return 0;
|
||||
}
|
||||
return this.curve.getCurrentIndex();
|
||||
}
|
||||
|
||||
allTiles(): TileRef[] {
|
||||
if (!this.curve) {
|
||||
return [];
|
||||
}
|
||||
return this.curve
|
||||
.getAllPoints()
|
||||
.map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y)));
|
||||
}
|
||||
}
|
||||
|
||||
export class AirPathFinder {
|
||||
constructor(
|
||||
private mg: GameMap,
|
||||
private random: PseudoRandom,
|
||||
) {}
|
||||
|
||||
nextTile(tile: TileRef, dst: TileRef): TileRef | true {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const dstX = this.mg.x(dst);
|
||||
const dstY = this.mg.y(dst);
|
||||
|
||||
if (x === dstX && y === dstY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate next position
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
|
||||
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
|
||||
|
||||
if (this.random.chance(ratio) && x !== dstX) {
|
||||
if (x < dstX) nextX++;
|
||||
else if (x > dstX) nextX--;
|
||||
} else {
|
||||
if (y < dstY) nextY++;
|
||||
else if (y > dstY) nextY--;
|
||||
}
|
||||
if (nextX === x && nextY === y) {
|
||||
return true;
|
||||
}
|
||||
return this.mg.ref(nextX, nextY);
|
||||
}
|
||||
}
|
||||
|
||||
export class MiniPathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
private path: TileRef[] | null = null;
|
||||
private path_idx: number = 0;
|
||||
private aStar: AStar<TileRef>;
|
||||
private computeFinished = true;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private iterations: number,
|
||||
private waterPath: boolean,
|
||||
private maxTries: number,
|
||||
) {}
|
||||
|
||||
private createAStar(curr: TileRef, dst: TileRef): AStar<TileRef> {
|
||||
return new MiniAStar(
|
||||
this.game.map(),
|
||||
this.game.miniMap(),
|
||||
curr,
|
||||
dst,
|
||||
this.iterations,
|
||||
this.maxTries,
|
||||
this.waterPath,
|
||||
);
|
||||
}
|
||||
|
||||
nextTile(
|
||||
curr: TileRef | null,
|
||||
dst: TileRef | null,
|
||||
dist: number = 1,
|
||||
): AStarResult<TileRef> {
|
||||
if (curr === null) {
|
||||
console.error("curr is null");
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
}
|
||||
if (dst === null) {
|
||||
console.error("dst is null");
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
}
|
||||
|
||||
if (this.game.manhattanDist(curr, dst) < dist) {
|
||||
this.path = null;
|
||||
return { type: PathFindResultType.Completed, node: curr };
|
||||
}
|
||||
|
||||
if (this.computeFinished) {
|
||||
if (this.shouldRecompute(curr, dst)) {
|
||||
this.curr = curr;
|
||||
this.dst = dst;
|
||||
this.path = null;
|
||||
this.path_idx = 0;
|
||||
this.aStar = this.createAStar(curr, dst);
|
||||
this.computeFinished = false;
|
||||
return this.nextTile(curr, dst);
|
||||
} else {
|
||||
const tile = this.path?.[this.path_idx++];
|
||||
if (tile === undefined) {
|
||||
throw new Error("missing tile");
|
||||
}
|
||||
return { type: PathFindResultType.NextTile, node: tile };
|
||||
}
|
||||
}
|
||||
|
||||
switch (this.aStar.compute()) {
|
||||
case PathFindResultType.Completed:
|
||||
this.computeFinished = true;
|
||||
this.path = this.aStar.reconstructPath();
|
||||
|
||||
// exclude first tile
|
||||
this.path_idx = 1;
|
||||
|
||||
return this.nextTile(curr, dst);
|
||||
case PathFindResultType.Pending:
|
||||
return { type: PathFindResultType.Pending };
|
||||
case PathFindResultType.PathNotFound:
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
default:
|
||||
throw new Error("unexpected compute result");
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRecompute(curr: TileRef, dst: TileRef) {
|
||||
if (this.path === null || this.curr === null || this.dst === null) {
|
||||
return true;
|
||||
}
|
||||
const dist = this.game.manhattanDist(curr, dst);
|
||||
let tolerance = 10;
|
||||
if (dist > 50) {
|
||||
tolerance = 10;
|
||||
} else if (dist > 25) {
|
||||
tolerance = 5;
|
||||
} else {
|
||||
tolerance = 0;
|
||||
}
|
||||
if (this.game.manhattanDist(this.dst, dst) > tolerance) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import FastPriorityQueue from "fastpriorityqueue";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
|
||||
/**
|
||||
* Implement this interface with your graph to find paths with A*
|
||||
*/
|
||||
export interface GraphAdapter<NodeType> {
|
||||
neighbors(node: NodeType): NodeType[];
|
||||
cost(node: NodeType): number;
|
||||
position(node: NodeType): { x: number; y: number };
|
||||
isTraversable(from: NodeType, to: NodeType): boolean;
|
||||
}
|
||||
|
||||
export class SerialAStar<NodeType> implements AStar<NodeType> {
|
||||
private fwdOpenSet: FastPriorityQueue<{
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
private bwdOpenSet: FastPriorityQueue<{
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private fwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private bwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private fwdGScore = new Map<NodeType, number>();
|
||||
private bwdGScore = new Map<NodeType, number>();
|
||||
|
||||
private meetingPoint: NodeType | null = null;
|
||||
public completed = false;
|
||||
private sources: NodeType[];
|
||||
private closestSource: NodeType;
|
||||
|
||||
constructor(
|
||||
src: NodeType | NodeType[],
|
||||
private dst: NodeType,
|
||||
private iterations: number,
|
||||
private maxTries: number,
|
||||
private graph: GraphAdapter<NodeType>,
|
||||
private directionChangePenalty: number = 0,
|
||||
) {
|
||||
this.fwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
|
||||
this.bwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
|
||||
this.sources = Array.isArray(src) ? src : [src];
|
||||
this.closestSource = this.findClosestSource(dst);
|
||||
|
||||
// Initialize forward search with source point(s)
|
||||
this.sources.forEach((startPoint) => {
|
||||
this.fwdGScore.set(startPoint, 0);
|
||||
this.fwdOpenSet.add({
|
||||
tile: startPoint,
|
||||
fScore: this.heuristic(startPoint, dst),
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize backward search from destination
|
||||
this.bwdGScore.set(dst, 0);
|
||||
this.bwdOpenSet.add({
|
||||
tile: dst,
|
||||
fScore: this.heuristic(dst, this.findClosestSource(dst)),
|
||||
});
|
||||
}
|
||||
|
||||
private findClosestSource(tile: NodeType): NodeType {
|
||||
return this.sources.reduce((closest, source) =>
|
||||
this.heuristic(tile, source) < this.heuristic(tile, closest)
|
||||
? source
|
||||
: closest,
|
||||
);
|
||||
}
|
||||
|
||||
compute(): PathFindResultType {
|
||||
if (this.completed) return PathFindResultType.Completed;
|
||||
|
||||
this.maxTries -= 1;
|
||||
let iterations = this.iterations;
|
||||
|
||||
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
|
||||
iterations--;
|
||||
if (iterations <= 0) {
|
||||
if (this.maxTries <= 0) {
|
||||
return PathFindResultType.PathNotFound;
|
||||
}
|
||||
return PathFindResultType.Pending;
|
||||
}
|
||||
|
||||
// Process forward search
|
||||
const fwdCurrent = this.fwdOpenSet.poll()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.bwdGScore.has(fwdCurrent)) {
|
||||
this.meetingPoint = fwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
this.expandNode(fwdCurrent, true);
|
||||
|
||||
// Process backward search
|
||||
const bwdCurrent = this.bwdOpenSet.poll()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.fwdGScore.has(bwdCurrent)) {
|
||||
this.meetingPoint = bwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
this.expandNode(bwdCurrent, false);
|
||||
}
|
||||
|
||||
return this.completed
|
||||
? PathFindResultType.Completed
|
||||
: PathFindResultType.PathNotFound;
|
||||
}
|
||||
|
||||
private expandNode(current: NodeType, isForward: boolean) {
|
||||
for (const neighbor of this.graph.neighbors(current)) {
|
||||
if (
|
||||
neighbor !== (isForward ? this.dst : this.closestSource) &&
|
||||
!this.graph.isTraversable(current, neighbor)
|
||||
)
|
||||
continue;
|
||||
|
||||
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
|
||||
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
|
||||
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
|
||||
|
||||
const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor);
|
||||
let penalty = 0;
|
||||
// With a direction change penalty, the path will get as straight as possible
|
||||
if (this.directionChangePenalty > 0) {
|
||||
const prev = cameFrom.get(current);
|
||||
if (prev) {
|
||||
const prevDir = this.getDirection(prev, current);
|
||||
const newDir = this.getDirection(current, neighbor);
|
||||
if (prevDir !== newDir) {
|
||||
penalty = this.directionChangePenalty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalG = tentativeGScore + penalty;
|
||||
if (!gScore.has(neighbor) || totalG < gScore.get(neighbor)!) {
|
||||
cameFrom.set(neighbor, current);
|
||||
gScore.set(neighbor, totalG);
|
||||
const fScore =
|
||||
totalG +
|
||||
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
|
||||
openSet.add({ tile: neighbor, fScore: fScore });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private heuristic(a: NodeType, b: NodeType): number {
|
||||
const posA = this.graph.position(a);
|
||||
const posB = this.graph.position(b);
|
||||
return 2 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
|
||||
}
|
||||
|
||||
private getDirection(from: NodeType, to: NodeType): string {
|
||||
const fromPos = this.graph.position(from);
|
||||
const toPos = this.graph.position(to);
|
||||
const dx = toPos.x - fromPos.x;
|
||||
const dy = toPos.y - fromPos.y;
|
||||
return `${Math.sign(dx)},${Math.sign(dy)}`;
|
||||
}
|
||||
|
||||
public reconstructPath(): NodeType[] {
|
||||
if (!this.meetingPoint) return [];
|
||||
|
||||
// Reconstruct path from start to meeting point
|
||||
const fwdPath: NodeType[] = [this.meetingPoint];
|
||||
let current = this.meetingPoint;
|
||||
|
||||
while (this.fwdCameFrom.has(current)) {
|
||||
current = this.fwdCameFrom.get(current)!;
|
||||
fwdPath.unshift(current);
|
||||
}
|
||||
|
||||
// Reconstruct path from meeting point to goal
|
||||
current = this.meetingPoint;
|
||||
|
||||
while (this.bwdCameFrom.has(current)) {
|
||||
current = this.bwdCameFrom.get(current)!;
|
||||
fwdPath.push(current);
|
||||
}
|
||||
|
||||
return fwdPath;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PathFindResultType } from "../AStar";
|
||||
import {
|
||||
MiniAStarOptions,
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
} from "../PathFinder";
|
||||
import { MiniPathFinder } from "../PathFinding";
|
||||
|
||||
const DEFAULT_ITERATIONS = 10_000;
|
||||
const DEFAULT_MAX_TRIES = 100;
|
||||
|
||||
export class MiniAStarAdapter implements PathFinder {
|
||||
private miniPathFinder: MiniPathFinder;
|
||||
|
||||
constructor(game: Game, options?: MiniAStarOptions) {
|
||||
this.miniPathFinder = new MiniPathFinder(
|
||||
game,
|
||||
options?.iterations ?? DEFAULT_ITERATIONS,
|
||||
options?.waterPath ?? true,
|
||||
options?.maxTries ?? DEFAULT_MAX_TRIES,
|
||||
);
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
const result = this.miniPathFinder.nextTile(from, to, dist);
|
||||
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Pending:
|
||||
return { status: PathStatus.PENDING };
|
||||
case PathFindResultType.NextTile:
|
||||
return { status: PathStatus.NEXT, node: result.node };
|
||||
case PathFindResultType.Completed:
|
||||
return { status: PathStatus.COMPLETE, node: result.node };
|
||||
case PathFindResultType.PathNotFound:
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const path: TileRef[] = [from];
|
||||
let current = from;
|
||||
const maxSteps = 100_000;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
const result = this.next(current, to);
|
||||
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NEXT) {
|
||||
current = result.node;
|
||||
path.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { NavMesh } from "../navmesh/NavMesh";
|
||||
import { PathFinder, PathResult, PathStatus } from "../PathFinder";
|
||||
|
||||
export class NavMeshAdapter implements PathFinder {
|
||||
private navMesh: NavMesh;
|
||||
private pathIndex = 0;
|
||||
private path: TileRef[] | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
constructor(private game: Game) {
|
||||
const navMesh = game.navMesh();
|
||||
if (!navMesh) {
|
||||
throw new Error("NavMeshAdapter requires game.navMesh() to be available");
|
||||
}
|
||||
this.navMesh = navMesh;
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
if (typeof from !== "number" || typeof to !== "number") {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (!this.game.isValidRef(from) || !this.game.isValidRef(to)) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
if (dist !== undefined && dist > 0) {
|
||||
const distance = this.game.manhattanDist(from, to);
|
||||
|
||||
if (distance <= dist) {
|
||||
return { status: PathStatus.COMPLETE, node: from };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastTo !== to) {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute if deviated from planned path
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
if (this.pathIndex > 0 && from !== expectedPos) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pathIndex >= this.path.length) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
return this.navMesh.findPath(from, to);
|
||||
}
|
||||
|
||||
private cachePath(from: TileRef, to: TileRef): boolean {
|
||||
try {
|
||||
this.path = this.navMesh.findPath(from, to);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pathIndex = 0;
|
||||
|
||||
// Path starts with 'from', skip to next tile
|
||||
if (this.path.length > 0 && this.path[0] === from) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { PathFinder } from "../types";
|
||||
import { AbstractGraph } from "./AbstractGraph";
|
||||
import { BucketQueue, MinHeap, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
export interface AbstractGraphAStarConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
useMinHeap?: boolean; // Use MinHeap instead of BucketQueue (better for variable costs)
|
||||
}
|
||||
|
||||
export class AbstractGraphAStar implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Float32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly startNode: Int32Array; // tracks which start each node came from
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly graph: AbstractGraph;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(graph: AbstractGraph, config?: AbstractGraphAStarConfig) {
|
||||
this.graph = graph;
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 1;
|
||||
this.maxIterations = config?.maxIterations ?? 100_000;
|
||||
|
||||
const numNodes = graph.nodeCount;
|
||||
|
||||
this.closedStamp = new Uint32Array(numNodes);
|
||||
this.gScoreStamp = new Uint32Array(numNodes);
|
||||
this.gScore = new Float32Array(numNodes);
|
||||
this.cameFrom = new Int32Array(numNodes);
|
||||
this.startNode = new Int32Array(numNodes);
|
||||
|
||||
// For abstract graphs with variable costs, MinHeap may be better
|
||||
// BucketQueue is O(1) but requires integer priorities
|
||||
if (config?.useMinHeap) {
|
||||
this.queue = new MinHeap(numNodes);
|
||||
} else {
|
||||
// Estimate max priority: weight * (mapWidth + mapHeight)
|
||||
// Use cluster size * clusters as approximation
|
||||
const maxDist = graph.clusterSize * Math.max(graph.clustersX, 10) * 2;
|
||||
const maxF = this.heuristicWeight * maxDist;
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
if (Array.isArray(start)) {
|
||||
return this.findPathMultiSource(start, goal);
|
||||
}
|
||||
return this.findPathSingle(start, goal);
|
||||
}
|
||||
|
||||
private findPathSingle(startId: number, goalId: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const graph = this.graph;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
|
||||
// Get goal node for heuristic
|
||||
const goalNode = graph.getNode(goalId);
|
||||
if (!goalNode) return null;
|
||||
const goalX = goalNode.x;
|
||||
const goalY = goalNode.y;
|
||||
|
||||
// Get start node for initial heuristic
|
||||
const startNode = graph.getNode(startId);
|
||||
if (!startNode) return null;
|
||||
|
||||
// Initialize
|
||||
queue.clear();
|
||||
gScore[startId] = 0;
|
||||
gScoreStamp[startId] = stamp;
|
||||
cameFrom[startId] = -1;
|
||||
|
||||
const startH =
|
||||
weight * (Math.abs(startNode.x - goalX) + Math.abs(startNode.y - goalY));
|
||||
queue.push(startId, startH);
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goalId) {
|
||||
return this.buildPathFromGoal(goalId);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const edges = graph.getNodeEdges(current);
|
||||
|
||||
// Inline neighbor iteration
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const neighbor = graph.getOtherNode(edge, current);
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG = currentG + edge.cost;
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
|
||||
// Inline heuristic calculation
|
||||
const neighborNode = graph.getNode(neighbor);
|
||||
if (neighborNode) {
|
||||
const h =
|
||||
weight *
|
||||
(Math.abs(neighborNode.x - goalX) +
|
||||
Math.abs(neighborNode.y - goalY));
|
||||
queue.push(neighbor, tentativeG + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private findPathMultiSource(
|
||||
startIds: number[],
|
||||
goalId: number,
|
||||
): number[] | null {
|
||||
if (startIds.length === 0) return null;
|
||||
if (startIds.length === 1) return this.findPathSingle(startIds[0], goalId);
|
||||
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const graph = this.graph;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const startNode = this.startNode;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
|
||||
// Get goal node for heuristic
|
||||
const goalNode = graph.getNode(goalId);
|
||||
if (!goalNode) return null;
|
||||
const goalX = goalNode.x;
|
||||
const goalY = goalNode.y;
|
||||
|
||||
// Initialize all start nodes
|
||||
queue.clear();
|
||||
for (const startId of startIds) {
|
||||
const node = graph.getNode(startId);
|
||||
if (!node) continue;
|
||||
|
||||
gScore[startId] = 0;
|
||||
gScoreStamp[startId] = stamp;
|
||||
cameFrom[startId] = -1;
|
||||
startNode[startId] = startId; // each start is its own origin
|
||||
|
||||
const h = weight * (Math.abs(node.x - goalX) + Math.abs(node.y - goalY));
|
||||
queue.push(startId, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goalId) {
|
||||
return this.buildPathFromGoal(goalId);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const currentStart = startNode[current];
|
||||
const edges = graph.getNodeEdges(current);
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const neighbor = graph.getOtherNode(edge, current);
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG = currentG + edge.cost;
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
startNode[neighbor] = currentStart; // propagate origin
|
||||
|
||||
const neighborNode = graph.getNode(neighbor);
|
||||
if (neighborNode) {
|
||||
const h =
|
||||
weight *
|
||||
(Math.abs(neighborNode.x - goalX) +
|
||||
Math.abs(neighborNode.y - goalY));
|
||||
queue.push(neighbor, tentativeG + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPathFromGoal(goalId: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goalId;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue } from "./PriorityQueue";
|
||||
|
||||
const LAND_BIT = 7;
|
||||
|
||||
export interface BoundedAStarConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export interface SearchBounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export class AStarBounded implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: BucketQueue;
|
||||
private readonly terrain: Uint8Array;
|
||||
private readonly mapWidth: number;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(
|
||||
map: GameMap,
|
||||
maxSearchArea: number,
|
||||
config?: BoundedAStarConfig,
|
||||
) {
|
||||
this.terrain = (map as any).terrain as Uint8Array;
|
||||
this.mapWidth = map.width();
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 1;
|
||||
this.maxIterations = config?.maxIterations ?? 100_000;
|
||||
|
||||
this.closedStamp = new Uint32Array(maxSearchArea);
|
||||
this.gScoreStamp = new Uint32Array(maxSearchArea);
|
||||
this.gScore = new Uint32Array(maxSearchArea);
|
||||
this.cameFrom = new Int32Array(maxSearchArea);
|
||||
|
||||
const maxDim = Math.ceil(Math.sqrt(maxSearchArea));
|
||||
const maxF = this.heuristicWeight * maxDim * 2;
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
const goalX = goal % this.mapWidth;
|
||||
const goalY = (goal / this.mapWidth) | 0;
|
||||
|
||||
let minX = goalX;
|
||||
let maxX = goalX;
|
||||
let minY = goalY;
|
||||
let maxY = goalY;
|
||||
|
||||
for (const s of starts) {
|
||||
const sx = s % this.mapWidth;
|
||||
const sy = (s / this.mapWidth) | 0;
|
||||
minX = Math.min(minX, sx);
|
||||
maxX = Math.max(maxX, sx);
|
||||
minY = Math.min(minY, sy);
|
||||
maxY = Math.max(maxY, sy);
|
||||
}
|
||||
|
||||
return this.searchBounded(starts as TileRef[], goal as TileRef, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
}
|
||||
|
||||
searchBounded(
|
||||
start: TileRef | TileRef[],
|
||||
goal: TileRef,
|
||||
bounds: SearchBounds,
|
||||
): TileRef[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const mapWidth = this.mapWidth;
|
||||
const terrain = this.terrain;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
const landMask = 1 << LAND_BIT;
|
||||
|
||||
const { minX, maxX, minY, maxY } = bounds;
|
||||
const boundsWidth = maxX - minX + 1;
|
||||
const goalX = goal % mapWidth;
|
||||
const goalY = (goal / mapWidth) | 0;
|
||||
const boundsHeight = maxY - minY + 1;
|
||||
const numLocalNodes = boundsWidth * boundsHeight;
|
||||
|
||||
if (numLocalNodes > this.closedStamp.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toLocal = (tile: TileRef, clamp: boolean = false): number => {
|
||||
let x = tile % mapWidth;
|
||||
let y = (tile / mapWidth) | 0;
|
||||
if (clamp) {
|
||||
x = Math.max(minX, Math.min(maxX, x));
|
||||
y = Math.max(minY, Math.min(maxY, y));
|
||||
}
|
||||
return (y - minY) * boundsWidth + (x - minX);
|
||||
};
|
||||
|
||||
const toGlobal = (local: number): TileRef => {
|
||||
const localX = local % boundsWidth;
|
||||
const localY = (local / boundsWidth) | 0;
|
||||
return ((localY + minY) * mapWidth + (localX + minX)) as TileRef;
|
||||
};
|
||||
|
||||
const goalLocal = toLocal(goal, true);
|
||||
if (goalLocal < 0 || goalLocal >= numLocalNodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
const startLocal = toLocal(s, true);
|
||||
if (startLocal < 0 || startLocal >= numLocalNodes) {
|
||||
continue;
|
||||
}
|
||||
gScore[startLocal] = 0;
|
||||
gScoreStamp[startLocal] = stamp;
|
||||
cameFrom[startLocal] = -1;
|
||||
const sx = s % mapWidth;
|
||||
const sy = (s / mapWidth) | 0;
|
||||
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
|
||||
queue.push(startLocal, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentLocal = queue.pop();
|
||||
|
||||
if (closedStamp[currentLocal] === stamp) continue;
|
||||
closedStamp[currentLocal] = stamp;
|
||||
|
||||
if (currentLocal === goalLocal) {
|
||||
return this.buildPath(goalLocal, toGlobal, numLocalNodes);
|
||||
}
|
||||
|
||||
const currentG = gScore[currentLocal];
|
||||
const tentativeG = currentG + 1;
|
||||
|
||||
// Convert to global coords for neighbor calculation
|
||||
const current = toGlobal(currentLocal);
|
||||
const currentX = current % mapWidth;
|
||||
const currentY = (current / mapWidth) | 0;
|
||||
|
||||
if (currentY > minY) {
|
||||
const neighbor = current - mapWidth;
|
||||
const neighborLocal = currentLocal - boundsWidth;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - goalX) + Math.abs(currentY - 1 - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentY < maxY) {
|
||||
const neighbor = current + mapWidth;
|
||||
const neighborLocal = currentLocal + boundsWidth;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - goalX) + Math.abs(currentY + 1 - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX > minX) {
|
||||
const neighbor = current - 1;
|
||||
const neighborLocal = currentLocal - 1;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - 1 - goalX) + Math.abs(currentY - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX < maxX) {
|
||||
const neighbor = current + 1;
|
||||
const neighborLocal = currentLocal + 1;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX + 1 - goalX) + Math.abs(currentY - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(
|
||||
goalLocal: number,
|
||||
toGlobal: (local: number) => TileRef,
|
||||
maxPathLength: number,
|
||||
): TileRef[] {
|
||||
const path: TileRef[] = [];
|
||||
let current = goalLocal;
|
||||
|
||||
// Safety check to prevent infinite loops
|
||||
let iterations = 0;
|
||||
while (current !== -1 && iterations < maxPathLength) {
|
||||
path.push(toGlobal(current));
|
||||
current = this.cameFrom[current];
|
||||
iterations++;
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { GameMap } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { AStar, AStarAdapter } from "./AStar";
|
||||
|
||||
export class AStarRail implements PathFinder<number> {
|
||||
private readonly aStar: AStar;
|
||||
|
||||
constructor(gameMap: GameMap) {
|
||||
const adapter = new RailAdapter(gameMap);
|
||||
this.aStar = new AStar({ adapter });
|
||||
}
|
||||
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
return this.aStar.findPath(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal adapter
|
||||
class RailAdapter implements AStarAdapter {
|
||||
private readonly gameMap: GameMap;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly _numNodes: number;
|
||||
private readonly waterPenalty = 5;
|
||||
private readonly heuristicWeight = 2;
|
||||
private readonly directionChangePenalty = 3;
|
||||
|
||||
constructor(gameMap: GameMap) {
|
||||
this.gameMap = gameMap;
|
||||
this.width = gameMap.width();
|
||||
this.height = gameMap.height();
|
||||
this._numNodes = this.width * this.height;
|
||||
}
|
||||
|
||||
numNodes(): number {
|
||||
return this._numNodes;
|
||||
}
|
||||
|
||||
maxNeighbors(): number {
|
||||
return 4;
|
||||
}
|
||||
|
||||
maxPriority(): number {
|
||||
const maxCost = 1 + this.waterPenalty + this.directionChangePenalty;
|
||||
return this.heuristicWeight * (this.width + this.height) * maxCost;
|
||||
}
|
||||
|
||||
neighbors(node: number, buffer: Int32Array): number {
|
||||
let count = 0;
|
||||
const x = node % this.width;
|
||||
const fromShoreline = this.gameMap.isShoreline(node);
|
||||
|
||||
if (node >= this.width) {
|
||||
const n = node - this.width;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (node < this._numNodes - this.width) {
|
||||
const n = node + this.width;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (x !== 0) {
|
||||
const n = node - 1;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (x !== this.width - 1) {
|
||||
const n = node + 1;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private isTraversable(to: number, fromShoreline: boolean): boolean {
|
||||
const toWater = this.gameMap.isWater(to);
|
||||
if (!toWater) return true;
|
||||
return fromShoreline || this.gameMap.isShoreline(to);
|
||||
}
|
||||
|
||||
cost(from: number, to: number, prev?: number): number {
|
||||
const penalized = this.gameMap.isWater(to) || this.gameMap.isShoreline(to);
|
||||
let c = penalized ? 1 + this.waterPenalty : 1;
|
||||
|
||||
if (prev !== undefined) {
|
||||
const d1 = from - prev;
|
||||
const d2 = to - from;
|
||||
if (d1 !== d2) {
|
||||
c += this.directionChangePenalty;
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nx = node % this.width;
|
||||
const ny = (node / this.width) | 0;
|
||||
const gx = goal % this.width;
|
||||
const gy = (goal / this.width) | 0;
|
||||
return this.heuristicWeight * (Math.abs(nx - gx) + Math.abs(ny - gy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
const LAND_BIT = 7; // Bit 7 in terrain indicates land
|
||||
|
||||
export interface AStarWaterConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export class AStarWater implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly terrain: Uint8Array;
|
||||
private readonly width: number;
|
||||
private readonly numNodes: number;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(map: GameMap, config?: AStarWaterConfig) {
|
||||
this.terrain = (map as any).terrain as Uint8Array;
|
||||
this.width = map.width();
|
||||
this.numNodes = map.width() * map.height();
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 15;
|
||||
this.maxIterations = config?.maxIterations ?? 1_000_000;
|
||||
|
||||
this.closedStamp = new Uint32Array(this.numNodes);
|
||||
this.gScoreStamp = new Uint32Array(this.numNodes);
|
||||
this.gScore = new Uint32Array(this.numNodes);
|
||||
this.cameFrom = new Int32Array(this.numNodes);
|
||||
|
||||
const maxF = this.heuristicWeight * (map.width() + map.height());
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const width = this.width;
|
||||
const numNodes = this.numNodes;
|
||||
const terrain = this.terrain;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
const landMask = 1 << LAND_BIT;
|
||||
|
||||
const goalX = goal % width;
|
||||
const goalY = (goal / width) | 0;
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
gScore[s] = 0;
|
||||
gScoreStamp[s] = stamp;
|
||||
cameFrom[s] = -1;
|
||||
const sx = s % width;
|
||||
const sy = (s / width) | 0;
|
||||
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
|
||||
queue.push(s, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goal) {
|
||||
return this.buildPath(goal);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const tentativeG = currentG + 1;
|
||||
const currentX = current % width;
|
||||
|
||||
if (current >= width) {
|
||||
const neighbor = current - width;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const nx = neighbor % width;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current < numNodes - width) {
|
||||
const neighbor = current + width;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const nx = neighbor % width;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX !== 0) {
|
||||
const neighbor = current - 1;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(currentX - 1 - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX !== width - 1) {
|
||||
const neighbor = current + 1;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(currentX + 1 - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(goal: number): TileRef[] {
|
||||
const path: TileRef[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current as TileRef);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { AbstractGraphAStar } from "./AStar.AbstractGraph";
|
||||
import { AStarBounded } from "./AStar.Bounded";
|
||||
import { AbstractGraph, AbstractNode } from "./AbstractGraph";
|
||||
import { BFSGrid } from "./BFS.Grid";
|
||||
import { LAND_MARKER } from "./ConnectedComponents";
|
||||
|
||||
type PathDebugInfo = {
|
||||
nodePath: TileRef[] | null;
|
||||
initialPath: TileRef[] | null;
|
||||
graph: {
|
||||
clusterSize: number;
|
||||
nodes: Array<{ id: number; tile: TileRef }>;
|
||||
edges: Array<{
|
||||
id: number;
|
||||
nodeA: number;
|
||||
nodeB: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
}>;
|
||||
};
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class AStarWaterHierarchical implements PathFinder<number> {
|
||||
private tileBFS: BFSGrid;
|
||||
private abstractAStar: AbstractGraphAStar;
|
||||
private localAStar: AStarBounded;
|
||||
private localAStarMultiCluster: AStarBounded;
|
||||
private sourceResolver: SourceResolver;
|
||||
|
||||
public debugInfo: PathDebugInfo | null = null;
|
||||
public debugMode: boolean = false;
|
||||
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private graph: AbstractGraph,
|
||||
private options: {
|
||||
cachePaths?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
// BFS for nearest node search
|
||||
this.tileBFS = new BFSGrid(map.width() * map.height());
|
||||
|
||||
const clusterSize = graph.clusterSize;
|
||||
|
||||
// AbstractGraphAStar for abstract graph routing
|
||||
this.abstractAStar = new AbstractGraphAStar(this.graph);
|
||||
|
||||
// BoundedAStar for cluster-bounded local pathfinding
|
||||
const maxLocalNodes = clusterSize * clusterSize;
|
||||
this.localAStar = new AStarBounded(map, maxLocalNodes);
|
||||
|
||||
// BoundedAStar for multi-cluster (3x3) local pathfinding
|
||||
const multiClusterSize = clusterSize * 3;
|
||||
const maxMultiClusterNodes = multiClusterSize * multiClusterSize;
|
||||
this.localAStarMultiCluster = new AStarBounded(map, maxMultiClusterNodes);
|
||||
|
||||
// SourceResolver for multi-source search
|
||||
this.sourceResolver = new SourceResolver(this.map, this.graph);
|
||||
}
|
||||
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
return this.findPathMultiSource(from as TileRef[], to as TileRef);
|
||||
}
|
||||
|
||||
return this.findPathSingle(from as TileRef, to as TileRef, this.debugMode);
|
||||
}
|
||||
|
||||
private findPathMultiSource(
|
||||
sources: TileRef[],
|
||||
target: TileRef,
|
||||
): TileRef[] | null {
|
||||
// 1. Resolve target to abstract node
|
||||
const targetNode = this.sourceResolver.resolveTarget(target);
|
||||
if (!targetNode) return null;
|
||||
|
||||
// 2. Map sources → abstract nodes (cheap O(1) cluster lookup per source)
|
||||
const nodeToSource = this.sourceResolver.resolveSourcesToNodes(sources);
|
||||
if (nodeToSource.size === 0) return null;
|
||||
|
||||
// 3. Run multi-source A* on abstract graph
|
||||
const nodeIds = [...nodeToSource.keys()];
|
||||
const nodePath = this.abstractAStar.findPath(nodeIds, targetNode.id);
|
||||
if (!nodePath) return null;
|
||||
|
||||
// 4. Get winning source tile (nodePath[0] is winning start node)
|
||||
const winningSource = nodeToSource.get(nodePath[0])!;
|
||||
|
||||
// 5. Run full single-source from winner
|
||||
return this.findPathSingle(winningSource, target);
|
||||
}
|
||||
|
||||
findPathSingle(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
debug: boolean = false,
|
||||
): TileRef[] | null {
|
||||
if (debug) {
|
||||
const allEdges: Array<{
|
||||
id: number;
|
||||
nodeA: number;
|
||||
nodeB: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
}> = [];
|
||||
|
||||
for (let edgeId = 0; edgeId < this.graph.edgeCount; edgeId++) {
|
||||
const edge = this.graph.getEdge(edgeId);
|
||||
if (!edge) continue;
|
||||
|
||||
const nodeA = this.graph.getNode(edge.nodeA);
|
||||
const nodeB = this.graph.getNode(edge.nodeB);
|
||||
if (!nodeA || !nodeB) continue;
|
||||
|
||||
allEdges.push({
|
||||
id: edge.id,
|
||||
nodeA: edge.nodeA,
|
||||
nodeB: edge.nodeB,
|
||||
from: nodeA.tile,
|
||||
to: nodeB.tile,
|
||||
cost: edge.cost,
|
||||
});
|
||||
}
|
||||
|
||||
this.debugInfo = {
|
||||
nodePath: null,
|
||||
initialPath: null,
|
||||
graph: {
|
||||
clusterSize: this.graph.clusterSize,
|
||||
nodes: this.graph
|
||||
.getAllNodes()
|
||||
.map((node) => ({ id: node.id, tile: node.tile })),
|
||||
edges: allEdges,
|
||||
},
|
||||
timings: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dist = this.map.manhattanDist(from, to);
|
||||
|
||||
// Early exit for very short distances
|
||||
if (dist <= this.graph.clusterSize) {
|
||||
performance.mark("hpa:findPath:earlyExitLocalPath:start");
|
||||
const startX = this.map.x(from);
|
||||
const startY = this.map.y(from);
|
||||
const clusterX = Math.floor(startX / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(startY / this.graph.clusterSize);
|
||||
const localPath = this.findLocalPath(from, to, clusterX, clusterY, true);
|
||||
performance.mark("hpa:findPath:earlyExitLocalPath:end");
|
||||
const measure = performance.measure(
|
||||
"hpa:findPath:earlyExitLocalPath",
|
||||
"hpa:findPath:earlyExitLocalPath:start",
|
||||
"hpa:findPath:earlyExitLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
|
||||
this.debugInfo!.timings.total += measure.duration;
|
||||
}
|
||||
|
||||
if (localPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
|
||||
);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct path failed for dist=${dist}, falling back to abstract graph`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:findNodes:start");
|
||||
const startNode = this.findNearestNode(from);
|
||||
const endNode = this.findNearestNode(to);
|
||||
performance.mark("hpa:findPath:findNodes:end");
|
||||
const findNodesMeasure = performance.measure(
|
||||
"hpa:findPath:findNodes",
|
||||
"hpa:findPath:findNodes:start",
|
||||
"hpa:findPath:findNodes:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findNodes = findNodesMeasure.duration;
|
||||
this.debugInfo!.timings.total += findNodesMeasure.duration;
|
||||
}
|
||||
|
||||
if (!startNode) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find start node for (${this.map.x(from)}, ${this.map.y(from)})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!endNode) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find end node for (${this.map.x(to)}, ${this.map.y(to)})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startNode.id === endNode.id) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Start and end nodes are the same (ID=${startNode.id}), finding local path with multi-cluster search`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:sameNodeLocalPath:start");
|
||||
const clusterX = Math.floor(startNode.x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(startNode.y / this.graph.clusterSize);
|
||||
const path = this.findLocalPath(from, to, clusterX, clusterY, true);
|
||||
performance.mark("hpa:findPath:sameNodeLocalPath:end");
|
||||
const sameNodeMeasure = performance.measure(
|
||||
"hpa:findPath:sameNodeLocalPath",
|
||||
"hpa:findPath:sameNodeLocalPath:start",
|
||||
"hpa:findPath:sameNodeLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.sameNodeLocalPath = sameNodeMeasure.duration;
|
||||
this.debugInfo!.timings.total += sameNodeMeasure.duration;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:findAbstractPath:start");
|
||||
const nodePath = this.findAbstractPath(startNode.id, endNode.id);
|
||||
performance.mark("hpa:findPath:findAbstractPath:end");
|
||||
const findAbstractPathMeasure = performance.measure(
|
||||
"hpa:findPath:findAbstractPath",
|
||||
"hpa:findPath:findAbstractPath:start",
|
||||
"hpa:findPath:findAbstractPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findAbstractPath =
|
||||
findAbstractPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += findAbstractPathMeasure.duration;
|
||||
|
||||
this.debugInfo!.nodePath = nodePath
|
||||
? nodePath
|
||||
.map((nodeId) => {
|
||||
const node = this.graph.getNode(nodeId);
|
||||
return node ? node.tile : -1;
|
||||
})
|
||||
.filter((tile) => tile !== -1)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!nodePath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] No abstract path between nodes ${startNode.id} and ${endNode.id}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Abstract path found: ${nodePath.length} waypoints`);
|
||||
}
|
||||
|
||||
const initialPath: TileRef[] = [];
|
||||
|
||||
performance.mark("hpa:findPath:buildInitialPath:start");
|
||||
|
||||
// 1. Find path from start to first node
|
||||
const firstNode = this.graph.getNode(nodePath[0])!;
|
||||
const firstNodeTile = firstNode.tile;
|
||||
|
||||
const startX = this.map.x(from);
|
||||
const startY = this.map.y(from);
|
||||
const startClusterX = Math.floor(startX / this.graph.clusterSize);
|
||||
const startClusterY = Math.floor(startY / this.graph.clusterSize);
|
||||
const startSegment = this.findLocalPath(
|
||||
from,
|
||||
firstNodeTile,
|
||||
startClusterX,
|
||||
startClusterY,
|
||||
);
|
||||
|
||||
if (!startSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...startSegment);
|
||||
|
||||
// 2. Build path through abstract nodes
|
||||
for (let i = 0; i < nodePath.length - 1; i++) {
|
||||
const fromNodeId = nodePath[i];
|
||||
const toNodeId = nodePath[i + 1];
|
||||
|
||||
const edge = this.graph.getEdgeBetween(fromNodeId, toNodeId);
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromNode = this.graph.getNode(fromNodeId)!;
|
||||
const toNode = this.graph.getNode(toNodeId)!;
|
||||
const fromTile = fromNode.tile;
|
||||
const toTile = toNode.tile;
|
||||
|
||||
// Check path cache (stored on graph, shared across all instances)
|
||||
// Cache is direction-aware: A→B and B→A are cached separately
|
||||
if (this.options.cachePaths) {
|
||||
const cachedPath = this.graph.getCachedPath(edge.id, fromNodeId);
|
||||
if (cachedPath && cachedPath.length > 0) {
|
||||
// Path is cached for this exact direction, use as-is
|
||||
initialPath.push(...cachedPath.slice(1));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const segmentPath = this.findLocalPath(
|
||||
fromTile,
|
||||
toTile,
|
||||
edge.clusterX,
|
||||
edge.clusterY,
|
||||
);
|
||||
|
||||
if (!segmentPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...segmentPath.slice(1));
|
||||
|
||||
// Cache the path for this direction
|
||||
if (this.options.cachePaths) {
|
||||
this.graph.setCachedPath(edge.id, fromNodeId, segmentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find path from last node to end
|
||||
const lastNode = this.graph.getNode(nodePath[nodePath.length - 1])!;
|
||||
const lastNodeTile = lastNode.tile;
|
||||
|
||||
const endX = this.map.x(to);
|
||||
const endY = this.map.y(to);
|
||||
const endClusterX = Math.floor(endX / this.graph.clusterSize);
|
||||
const endClusterY = Math.floor(endY / this.graph.clusterSize);
|
||||
const endSegment = this.findLocalPath(
|
||||
lastNodeTile,
|
||||
to,
|
||||
endClusterX,
|
||||
endClusterY,
|
||||
);
|
||||
|
||||
if (!endSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...endSegment.slice(1));
|
||||
|
||||
performance.mark("hpa:findPath:buildInitialPath:end");
|
||||
const buildInitialPathMeasure = performance.measure(
|
||||
"hpa:findPath:buildInitialPath",
|
||||
"hpa:findPath:buildInitialPath:start",
|
||||
"hpa:findPath:buildInitialPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildInitialPath =
|
||||
buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.initialPath = initialPath;
|
||||
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
|
||||
}
|
||||
|
||||
// Smoothing moved to SmoothingTransformer - return raw path
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
private findNearestNode(tile: TileRef): AbstractNode | null {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
|
||||
const clusterX = Math.floor(x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(y / this.graph.clusterSize);
|
||||
|
||||
const clusterSize = this.graph.clusterSize;
|
||||
const minX = clusterX * clusterSize;
|
||||
const minY = clusterY * clusterSize;
|
||||
const maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1);
|
||||
const maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1);
|
||||
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster || cluster.nodeIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateNodes = cluster.nodeIds.map((id) => this.graph.getNode(id)!);
|
||||
const maxDistance = clusterSize * clusterSize;
|
||||
|
||||
return this.tileBFS.search(
|
||||
this.map.width(),
|
||||
this.map.height(),
|
||||
tile,
|
||||
maxDistance,
|
||||
(t: TileRef) => this.graph.getComponentId(t) !== LAND_MARKER,
|
||||
(t: TileRef, _dist: number) => {
|
||||
const tileX = this.map.x(t);
|
||||
const tileY = this.map.y(t);
|
||||
|
||||
for (const node of candidateNodes) {
|
||||
if (node.x === tileX && node.y === tileY) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findAbstractPath(
|
||||
fromNodeId: number,
|
||||
toNodeId: number,
|
||||
): number[] | null {
|
||||
return this.abstractAStar.findPath(fromNodeId, toNodeId);
|
||||
}
|
||||
|
||||
private findLocalPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
multiCluster: boolean = false,
|
||||
): TileRef[] | null {
|
||||
// Calculate cluster bounds
|
||||
const clusterSize = this.graph.clusterSize;
|
||||
|
||||
let minX: number;
|
||||
let minY: number;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
|
||||
if (multiCluster) {
|
||||
// 3×3 clusters centered on the starting cluster
|
||||
minX = Math.max(0, (clusterX - 1) * clusterSize);
|
||||
minY = Math.max(0, (clusterY - 1) * clusterSize);
|
||||
maxX = Math.min(this.map.width() - 1, (clusterX + 2) * clusterSize - 1);
|
||||
maxY = Math.min(this.map.height() - 1, (clusterY + 2) * clusterSize - 1);
|
||||
} else {
|
||||
minX = clusterX * clusterSize;
|
||||
minY = clusterY * clusterSize;
|
||||
maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1);
|
||||
maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1);
|
||||
}
|
||||
|
||||
// Choose the appropriate BoundedAStar based on search area
|
||||
const selectedAStar = multiCluster
|
||||
? this.localAStarMultiCluster
|
||||
: this.localAStar;
|
||||
|
||||
// Run BoundedAStar on bounded region - works directly on map coords
|
||||
const path = selectedAStar.searchBounded(from, to, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fix endpoints: BoundedAStar clamps tiles to bounds, but node tiles may be
|
||||
// just outside cluster bounds. Ensure path starts/ends at exact requested tiles.
|
||||
if (path[0] !== from) {
|
||||
path.unshift(from);
|
||||
}
|
||||
if (path[path.length - 1] !== to) {
|
||||
path.push(to);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for resolving tiles to abstract nodes
|
||||
// Assumes tiles are already water and component-filtered (by transformer pipeline)
|
||||
class SourceResolver {
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private graph: AbstractGraph,
|
||||
) {}
|
||||
|
||||
// Resolves target to its abstract node
|
||||
resolveTarget(target: TileRef): AbstractNode | null {
|
||||
return this.getClusterNode(target);
|
||||
}
|
||||
|
||||
// Maps sources → abstract nodes, returns Map<nodeId, sourceTile>
|
||||
resolveSourcesToNodes(sources: TileRef[]): Map<number, TileRef> {
|
||||
const nodeToSource = new Map<number, TileRef>();
|
||||
const nodeToDist = new Map<number, number>();
|
||||
|
||||
for (const source of sources) {
|
||||
const node = this.getClusterNode(source);
|
||||
if (node === null) continue;
|
||||
|
||||
const x = this.map.x(source);
|
||||
const y = this.map.y(source);
|
||||
const dist = Math.abs(node.x - x) + Math.abs(node.y - y);
|
||||
|
||||
// Keep closest source per node
|
||||
const prevDist = nodeToDist.get(node.id);
|
||||
if (prevDist === undefined || dist < prevDist) {
|
||||
nodeToSource.set(node.id, source);
|
||||
nodeToDist.set(node.id, dist);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeToSource;
|
||||
}
|
||||
|
||||
private getClusterNode(tile: TileRef): AbstractNode | null {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
const clusterX = Math.floor(x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(y / this.graph.clusterSize);
|
||||
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster || cluster.nodeIds.length === 0) return null;
|
||||
|
||||
// Return closest node to tile
|
||||
let bestNode: AbstractNode | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const nodeId of cluster.nodeIds) {
|
||||
const node = this.graph.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const dist = Math.abs(node.x - x) + Math.abs(node.y - y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
return bestNode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Generic A* implementation with adapter interface
|
||||
// See AStar.Rail.ts for adapter version where performance is not critical
|
||||
// See AStar.Water.ts for inlined version for performance-critical use
|
||||
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
export interface AStarAdapter {
|
||||
// Important optimization: write to the buffer and return the count
|
||||
// You can do this and it will be much faster :)
|
||||
neighbors(node: number, buffer: Int32Array): number;
|
||||
|
||||
cost(from: number, to: number, prev?: number): number;
|
||||
heuristic(node: number, goal: number): number;
|
||||
numNodes(): number;
|
||||
maxPriority(): number;
|
||||
maxNeighbors(): number;
|
||||
}
|
||||
|
||||
export interface AStarConfig {
|
||||
adapter: AStarAdapter;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export class AStar implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly adapter: AStarAdapter;
|
||||
private readonly neighborBuffer: Int32Array;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(config: AStarConfig) {
|
||||
this.adapter = config.adapter;
|
||||
this.maxIterations = config.maxIterations ?? 500_000;
|
||||
this.neighborBuffer = new Int32Array(this.adapter.maxNeighbors());
|
||||
this.closedStamp = new Uint32Array(this.adapter.numNodes());
|
||||
this.gScoreStamp = new Uint32Array(this.adapter.numNodes());
|
||||
this.gScore = new Uint32Array(this.adapter.numNodes());
|
||||
this.cameFrom = new Int32Array(this.adapter.numNodes());
|
||||
this.queue = new BucketQueue(this.adapter.maxPriority());
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const adapter = this.adapter;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const buffer = this.neighborBuffer;
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
gScore[s] = 0;
|
||||
gScoreStamp[s] = stamp;
|
||||
cameFrom[s] = -1;
|
||||
queue.push(s, adapter.heuristic(s, goal));
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goal) {
|
||||
return this.buildPath(goal);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const prev = cameFrom[current];
|
||||
const count = adapter.neighbors(current, buffer);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const neighbor = buffer[i];
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG =
|
||||
currentG +
|
||||
adapter.cost(current, neighbor, prev === -1 ? undefined : prev);
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
queue.push(neighbor, tentativeG + adapter.heuristic(neighbor, goal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(goal: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { BFSGrid } from "./BFS.Grid";
|
||||
import { ConnectedComponents } from "./ConnectedComponents";
|
||||
|
||||
export interface AbstractNode {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tile: TileRef;
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface AbstractEdge {
|
||||
id: number;
|
||||
nodeA: number; // Lower node ID (canonical order: nodeA < nodeB)
|
||||
nodeB: number; // Higher node ID
|
||||
cost: number;
|
||||
clusterX: number;
|
||||
clusterY: number;
|
||||
}
|
||||
|
||||
export interface Cluster {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeIds: number[];
|
||||
}
|
||||
|
||||
export type BuildDebugInfo = {
|
||||
clusters: number | null;
|
||||
nodes: number | null;
|
||||
edges: number | null;
|
||||
actualBFSCalls: number | null;
|
||||
potentialBFSCalls: number | null;
|
||||
skippedByComponentFilter: number | null;
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class AbstractGraph {
|
||||
// Nodes (array indexed by id)
|
||||
private readonly _nodes: AbstractNode[] = [];
|
||||
|
||||
// Edges (bidirectional, stored once)
|
||||
private readonly _edges: AbstractEdge[] = [];
|
||||
private readonly _nodeEdgeIds: number[][] = []; // nodeId → edge IDs
|
||||
|
||||
// Clusters (array indexed by clusterKey)
|
||||
private readonly _clusters: Cluster[] = [];
|
||||
|
||||
// Path cache indexed by edge.id (shared across all users)
|
||||
private _pathCache: (TileRef[] | null)[] = [];
|
||||
|
||||
// Water components for componentId lookup
|
||||
private _waterComponents: ConnectedComponents | null = null;
|
||||
|
||||
constructor(
|
||||
readonly clusterSize: number,
|
||||
readonly clustersX: number,
|
||||
readonly clustersY: number,
|
||||
) {}
|
||||
|
||||
getNode(id: number): AbstractNode | undefined {
|
||||
return this._nodes[id];
|
||||
}
|
||||
|
||||
getAllNodes(): readonly AbstractNode[] {
|
||||
return this._nodes;
|
||||
}
|
||||
|
||||
get nodeCount(): number {
|
||||
return this._nodes.length;
|
||||
}
|
||||
|
||||
getEdge(id: number): AbstractEdge | undefined {
|
||||
return this._edges[id];
|
||||
}
|
||||
|
||||
getNodeEdges(nodeId: number): AbstractEdge[] {
|
||||
const edgeIds = this._nodeEdgeIds[nodeId];
|
||||
if (!edgeIds) return [];
|
||||
return edgeIds.map((id) => this._edges[id]);
|
||||
}
|
||||
|
||||
getEdgeBetween(nodeA: number, nodeB: number): AbstractEdge | undefined {
|
||||
const edgeIds = this._nodeEdgeIds[nodeA];
|
||||
if (!edgeIds) return undefined;
|
||||
|
||||
for (const edgeId of edgeIds) {
|
||||
const edge = this._edges[edgeId];
|
||||
if (edge.nodeA === nodeB || edge.nodeB === nodeB) {
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOtherNode(edge: AbstractEdge, nodeId: number): number {
|
||||
return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA;
|
||||
}
|
||||
|
||||
get edgeCount(): number {
|
||||
return this._edges.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached path for edge in specific direction
|
||||
* @param edgeId Edge ID
|
||||
* @param fromNodeId The starting node of the traversal (determines direction)
|
||||
*/
|
||||
getCachedPath(edgeId: number, fromNodeId: number): TileRef[] | null {
|
||||
const edge = this._edges[edgeId];
|
||||
if (!edge) return null;
|
||||
// Direction: 0 if traversing A→B, 1 if traversing B→A
|
||||
const direction = fromNodeId === edge.nodeA ? 0 : 1;
|
||||
const cacheIndex = edgeId * 2 + direction;
|
||||
return this._pathCache[cacheIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache path for edge in specific direction
|
||||
* @param edgeId Edge ID
|
||||
* @param fromNodeId The starting node of the traversal (determines direction)
|
||||
* @param path The path tiles
|
||||
*/
|
||||
setCachedPath(edgeId: number, fromNodeId: number, path: TileRef[]): void {
|
||||
const edge = this._edges[edgeId];
|
||||
if (!edge) return;
|
||||
// Direction: 0 if traversing A→B, 1 if traversing B→A
|
||||
const direction = fromNodeId === edge.nodeA ? 0 : 1;
|
||||
const cacheIndex = edgeId * 2 + direction;
|
||||
this._pathCache[cacheIndex] = path;
|
||||
}
|
||||
|
||||
_initPathCache(): void {
|
||||
// Double the cache size to store both directions
|
||||
this._pathCache = new Array(this._edges.length * 2).fill(null);
|
||||
}
|
||||
|
||||
setWaterComponents(wc: ConnectedComponents): void {
|
||||
this._waterComponents = wc;
|
||||
}
|
||||
|
||||
getComponentId(tile: TileRef): number {
|
||||
return this._waterComponents?.getComponentId(tile) ?? 0;
|
||||
}
|
||||
|
||||
getClusterKey(clusterX: number, clusterY: number): number {
|
||||
return clusterY * this.clustersX + clusterX;
|
||||
}
|
||||
|
||||
getCluster(clusterX: number, clusterY: number): Cluster | undefined {
|
||||
return this._clusters[this.getClusterKey(clusterX, clusterY)];
|
||||
}
|
||||
|
||||
getClusterNodes(clusterX: number, clusterY: number): AbstractNode[] {
|
||||
const cluster = this.getCluster(clusterX, clusterY);
|
||||
if (!cluster) return [];
|
||||
return cluster.nodeIds.map((id) => this._nodes[id]);
|
||||
}
|
||||
|
||||
getNearbyClusterNodes(clusterX: number, clusterY: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const cluster = this.getCluster(clusterX + dx, clusterY + dy);
|
||||
if (cluster) {
|
||||
for (const nodeId of cluster.nodeIds) {
|
||||
nodes.push(this._nodes[nodeId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_addNode(node: AbstractNode): void {
|
||||
this._nodes[node.id] = node;
|
||||
this._nodeEdgeIds[node.id] = [];
|
||||
}
|
||||
|
||||
_addEdge(edge: AbstractEdge): void {
|
||||
this._edges[edge.id] = edge;
|
||||
this._nodeEdgeIds[edge.nodeA].push(edge.id);
|
||||
this._nodeEdgeIds[edge.nodeB].push(edge.id);
|
||||
}
|
||||
|
||||
_setCluster(key: number, cluster: Cluster): void {
|
||||
this._clusters[key] = cluster;
|
||||
}
|
||||
|
||||
_addNodeToCluster(clusterKey: number, nodeId: number): void {
|
||||
if (!this._clusters[clusterKey]) {
|
||||
// This shouldn't happen if clusters are pre-created
|
||||
return;
|
||||
}
|
||||
|
||||
this._clusters[clusterKey].nodeIds.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
export class AbstractGraphBuilder {
|
||||
static readonly CLUSTER_SIZE = 32;
|
||||
|
||||
// Derived immutable state
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly clustersX: number;
|
||||
private readonly clustersY: number;
|
||||
private readonly tileBFS: BFSGrid;
|
||||
private readonly waterComponents: ConnectedComponents;
|
||||
|
||||
// Build state
|
||||
private graph!: AbstractGraph;
|
||||
private tileToNode = new Map<TileRef, AbstractNode>();
|
||||
private nextNodeId = 0;
|
||||
private nextEdgeId = 0;
|
||||
private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
|
||||
|
||||
public debugInfo: BuildDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly map: GameMap,
|
||||
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
|
||||
) {
|
||||
this.width = map.width();
|
||||
this.height = map.height();
|
||||
this.clustersX = Math.ceil(this.width / clusterSize);
|
||||
this.clustersY = Math.ceil(this.height / clusterSize);
|
||||
this.tileBFS = new BFSGrid(this.width * this.height);
|
||||
this.waterComponents = new ConnectedComponents(map);
|
||||
}
|
||||
|
||||
build(debug: boolean = false): AbstractGraph {
|
||||
performance.mark("abstractgraph:build:start");
|
||||
|
||||
this.graph = new AbstractGraph(
|
||||
this.clusterSize,
|
||||
this.clustersX,
|
||||
this.clustersY,
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Building abstract graph with cluster size ${this.clusterSize} (${this.clustersX}x${this.clustersY} clusters)`,
|
||||
);
|
||||
|
||||
this.debugInfo = {
|
||||
clusters: null,
|
||||
nodes: null,
|
||||
edges: null,
|
||||
actualBFSCalls: null,
|
||||
potentialBFSCalls: null,
|
||||
skippedByComponentFilter: null,
|
||||
timings: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize water components
|
||||
performance.mark("abstractgraph:build:water-component:start");
|
||||
this.waterComponents.initialize();
|
||||
performance.mark("abstractgraph:build:water-component:end");
|
||||
const wcMeasure = performance.measure(
|
||||
"abstractgraph:build:water-component",
|
||||
"abstractgraph:build:water-component:start",
|
||||
"abstractgraph:build:water-component:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Water Component Identification: ${wcMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-create all clusters
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
const key = this.graph.getClusterKey(cx, cy);
|
||||
this.graph._setCluster(key, { x: cx, y: cy, nodeIds: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Find nodes (gateways) at cluster boundaries
|
||||
performance.mark("abstractgraph:build:nodes:start");
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
this.processCluster(cx, cy);
|
||||
}
|
||||
}
|
||||
performance.mark("abstractgraph:build:nodes:end");
|
||||
const nodesMeasure = performance.measure(
|
||||
"abstractgraph:build:nodes",
|
||||
"abstractgraph:build:nodes:start",
|
||||
"abstractgraph:build:nodes:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Node identification: ${nodesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
this.debugInfo!.potentialBFSCalls = 0;
|
||||
this.debugInfo!.skippedByComponentFilter = 0;
|
||||
}
|
||||
|
||||
// Build edges between nodes in same cluster
|
||||
performance.mark("abstractgraph:build:edges:start");
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
const cluster = this.graph.getCluster(cx, cy);
|
||||
if (!cluster || cluster.nodeIds.length === 0) continue;
|
||||
|
||||
if (debug) {
|
||||
const n = cluster.nodeIds.length;
|
||||
this.debugInfo!.potentialBFSCalls! += (n * (n - 1)) / 2;
|
||||
|
||||
// Count skipped by component filter
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const nodeI = this.graph.getNode(cluster.nodeIds[i])!;
|
||||
const nodeJ = this.graph.getNode(cluster.nodeIds[j])!;
|
||||
if (nodeI.componentId !== nodeJ.componentId) {
|
||||
this.debugInfo!.skippedByComponentFilter!++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.buildClusterConnections(cx, cy);
|
||||
}
|
||||
}
|
||||
performance.mark("abstractgraph:build:edges:end");
|
||||
const edgesMeasure = performance.measure(
|
||||
"abstractgraph:build:edges",
|
||||
"abstractgraph:build:edges:start",
|
||||
"abstractgraph:build:edges:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.actualBFSCalls =
|
||||
this.debugInfo!.potentialBFSCalls! -
|
||||
this.debugInfo!.skippedByComponentFilter!;
|
||||
|
||||
console.log(
|
||||
`[DEBUG] Edge identification: ${edgesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("abstractgraph:build:end");
|
||||
const totalMeasure = performance.measure(
|
||||
"abstractgraph:build:total",
|
||||
"abstractgraph:build:start",
|
||||
"abstractgraph:build:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Abstract graph built in ${totalMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`[DEBUG] Nodes: ${this.graph.nodeCount}`);
|
||||
console.log(`[DEBUG] Edges: ${this.graph.edgeCount}`);
|
||||
console.log(`[DEBUG] Clusters: ${this.clustersX * this.clustersY}`);
|
||||
|
||||
this.debugInfo!.clusters = this.clustersX * this.clustersY;
|
||||
this.debugInfo!.nodes = this.graph.nodeCount;
|
||||
this.debugInfo!.edges = this.graph.edgeCount;
|
||||
}
|
||||
|
||||
// Initialize path cache after all edges are built
|
||||
this.graph._initPathCache();
|
||||
|
||||
// Store water components for componentId lookups
|
||||
this.graph.setWaterComponents(this.waterComponents);
|
||||
|
||||
return this.graph;
|
||||
}
|
||||
|
||||
private getOrCreateNode(x: number, y: number): AbstractNode {
|
||||
const tile = this.map.ref(x, y);
|
||||
|
||||
const existing = this.tileToNode.get(tile);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const node: AbstractNode = {
|
||||
id: this.nextNodeId++,
|
||||
x,
|
||||
y,
|
||||
tile,
|
||||
componentId: this.waterComponents.getComponentId(tile),
|
||||
};
|
||||
|
||||
this.graph._addNode(node);
|
||||
this.tileToNode.set(tile, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private addNodeToCluster(
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
node: AbstractNode,
|
||||
): void {
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster) return;
|
||||
|
||||
// Check for duplicates (node at cluster corner can be found by both edge scans)
|
||||
if (!cluster.nodeIds.includes(node.id)) {
|
||||
cluster.nodeIds.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
private processCluster(cx: number, cy: number): void {
|
||||
const baseX = cx * this.clusterSize;
|
||||
const baseY = cy * this.clusterSize;
|
||||
|
||||
// Right edge (vertical boundary to next cluster)
|
||||
if (cx < this.clustersX - 1) {
|
||||
const edgeX = Math.min(baseX + this.clusterSize - 1, this.width - 1);
|
||||
const nodes = this.findNodesOnVerticalEdge(edgeX, baseY);
|
||||
|
||||
for (const node of nodes) {
|
||||
this.addNodeToCluster(cx, cy, node);
|
||||
this.addNodeToCluster(cx + 1, cy, node);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom edge (horizontal boundary to next cluster)
|
||||
if (cy < this.clustersY - 1) {
|
||||
const edgeY = Math.min(baseY + this.clusterSize - 1, this.height - 1);
|
||||
const nodes = this.findNodesOnHorizontalEdge(edgeY, baseX);
|
||||
|
||||
for (const node of nodes) {
|
||||
this.addNodeToCluster(cx, cy, node);
|
||||
this.addNodeToCluster(cx, cy + 1, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findNodesOnVerticalEdge(x: number, baseY: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
const maxY = Math.min(baseY + this.clusterSize, this.height);
|
||||
|
||||
let spanStart = -1;
|
||||
|
||||
const tryAddNode = (y: number) => {
|
||||
if (spanStart === -1) return;
|
||||
|
||||
const spanLength = y - spanStart;
|
||||
const midY = spanStart + Math.floor(spanLength / 2);
|
||||
spanStart = -1;
|
||||
|
||||
const node = this.getOrCreateNode(x, midY);
|
||||
nodes.push(node);
|
||||
};
|
||||
|
||||
for (let y = baseY; y < maxY; y++) {
|
||||
const tile = this.map.ref(x, y);
|
||||
const nextTile = x + 1 < this.map.width() ? this.map.ref(x + 1, y) : -1;
|
||||
const isEntrance =
|
||||
this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile);
|
||||
|
||||
if (isEntrance) {
|
||||
if (spanStart === -1) {
|
||||
spanStart = y;
|
||||
}
|
||||
} else {
|
||||
tryAddNode(y);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddNode(maxY);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private findNodesOnHorizontalEdge(y: number, baseX: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
const maxX = Math.min(baseX + this.clusterSize, this.width);
|
||||
|
||||
let spanStart = -1;
|
||||
|
||||
const tryAddNode = (x: number) => {
|
||||
if (spanStart === -1) return;
|
||||
|
||||
const spanLength = x - spanStart;
|
||||
const midX = spanStart + Math.floor(spanLength / 2);
|
||||
spanStart = -1;
|
||||
|
||||
const node = this.getOrCreateNode(midX, y);
|
||||
nodes.push(node);
|
||||
};
|
||||
|
||||
for (let x = baseX; x < maxX; x++) {
|
||||
const tile = this.map.ref(x, y);
|
||||
const nextTile = y + 1 < this.map.height() ? this.map.ref(x, y + 1) : -1;
|
||||
const isEntrance =
|
||||
this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile);
|
||||
|
||||
if (isEntrance) {
|
||||
if (spanStart === -1) {
|
||||
spanStart = x;
|
||||
}
|
||||
} else {
|
||||
tryAddNode(x);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddNode(maxX);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private buildClusterConnections(cx: number, cy: number): void {
|
||||
const cluster = this.graph.getCluster(cx, cy);
|
||||
if (!cluster) return;
|
||||
|
||||
const nodeIds = cluster.nodeIds;
|
||||
const nodes = nodeIds.map((id) => this.graph.getNode(id)!);
|
||||
|
||||
// Calculate cluster bounds
|
||||
const clusterMinX = cx * this.clusterSize;
|
||||
const clusterMinY = cy * this.clusterSize;
|
||||
const clusterMaxX = Math.min(
|
||||
this.width - 1,
|
||||
clusterMinX + this.clusterSize - 1,
|
||||
);
|
||||
const clusterMaxY = Math.min(
|
||||
this.height - 1,
|
||||
clusterMinY + this.clusterSize - 1,
|
||||
);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const fromNode = nodes[i];
|
||||
|
||||
// Build list of target nodes (only those we haven't processed with this node)
|
||||
const targetNodes: AbstractNode[] = [];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
// Skip if nodes are in different water components
|
||||
if (nodes[i].componentId !== nodes[j].componentId) {
|
||||
continue;
|
||||
}
|
||||
targetNodes.push(nodes[j]);
|
||||
}
|
||||
|
||||
if (targetNodes.length === 0) continue;
|
||||
|
||||
// Single BFS to find all reachable target nodes
|
||||
const reachable = this.findAllReachableNodesInBounds(
|
||||
fromNode.tile,
|
||||
targetNodes,
|
||||
clusterMinX,
|
||||
clusterMaxX,
|
||||
clusterMinY,
|
||||
clusterMaxY,
|
||||
);
|
||||
|
||||
// Create edges for all reachable nodes
|
||||
for (const [targetId, cost] of reachable.entries()) {
|
||||
this.addOrUpdateEdge(fromNode.id, targetId, cost, cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update edge between two nodes.
|
||||
* Edges are bidirectional and stored once with canonical order (nodeA < nodeB).
|
||||
* If edge exists with higher cost, update it.
|
||||
*/
|
||||
private addOrUpdateEdge(
|
||||
nodeIdA: number,
|
||||
nodeIdB: number,
|
||||
cost: number,
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
): void {
|
||||
// Canonical order: lower ID first
|
||||
const [lo, hi] =
|
||||
nodeIdA < nodeIdB ? [nodeIdA, nodeIdB] : [nodeIdB, nodeIdA];
|
||||
|
||||
// Check for existing edge
|
||||
let nodeMap = this.edgeBetween.get(lo);
|
||||
if (!nodeMap) {
|
||||
nodeMap = new Map();
|
||||
this.edgeBetween.set(lo, nodeMap);
|
||||
}
|
||||
|
||||
const existingEdge = nodeMap.get(hi);
|
||||
|
||||
if (existingEdge) {
|
||||
// Update if new cost is cheaper
|
||||
if (cost < existingEdge.cost) {
|
||||
existingEdge.cost = cost;
|
||||
existingEdge.clusterX = clusterX;
|
||||
existingEdge.clusterY = clusterY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new edge
|
||||
const edge: AbstractEdge = {
|
||||
id: this.nextEdgeId++,
|
||||
nodeA: lo,
|
||||
nodeB: hi,
|
||||
cost,
|
||||
clusterX,
|
||||
clusterY,
|
||||
};
|
||||
|
||||
nodeMap.set(hi, edge);
|
||||
this.graph._addEdge(edge);
|
||||
}
|
||||
|
||||
private findAllReachableNodesInBounds(
|
||||
from: TileRef,
|
||||
targetNodes: AbstractNode[],
|
||||
minX: number,
|
||||
maxX: number,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
): Map<number, number> {
|
||||
const fromX = this.map.x(from);
|
||||
const fromY = this.map.y(from);
|
||||
|
||||
// Create a map of tile positions to node IDs for fast lookup
|
||||
const tileToNodeId = new Map<TileRef, number>();
|
||||
let maxManhattanDist = 0;
|
||||
|
||||
for (const node of targetNodes) {
|
||||
tileToNodeId.set(node.tile, node.id);
|
||||
const dx = Math.abs(node.x - fromX);
|
||||
const dy = Math.abs(node.y - fromY);
|
||||
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
|
||||
}
|
||||
|
||||
const maxDistance = maxManhattanDist * 4; // Allow path deviation
|
||||
const reachable = new Map<number, number>();
|
||||
let foundCount = 0;
|
||||
|
||||
this.tileBFS.search(
|
||||
this.map.width(),
|
||||
this.map.height(),
|
||||
from,
|
||||
maxDistance,
|
||||
(tile: number) => this.map.isWater(tile),
|
||||
(tile: number, dist: number) => {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
|
||||
// Reject if outside of bounding box (except start/target)
|
||||
const isStartOrTarget = tile === from || tileToNodeId.has(tile);
|
||||
if (
|
||||
!isStartOrTarget &&
|
||||
(x < minX || x > maxX || y < minY || y > maxY)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this tile is one of our target nodes
|
||||
const nodeId = tileToNodeId.get(tile);
|
||||
|
||||
if (nodeId !== undefined) {
|
||||
reachable.set(nodeId, dist);
|
||||
foundCount++;
|
||||
|
||||
// Early exit if we've found all target nodes
|
||||
if (foundCount === targetNodes.length) {
|
||||
return dist; // Return to stop BFS
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return reachable;
|
||||
}
|
||||
}
|
||||
+33
-29
@@ -1,11 +1,7 @@
|
||||
export interface FastBFSAdapter<T> {
|
||||
visitor(node: number, dist: number): T | null | undefined;
|
||||
isValidNode(node: number): boolean;
|
||||
}
|
||||
|
||||
// Optimized BFS using stamp-based visited tracking and typed array queue
|
||||
export class FastBFS {
|
||||
// 4-direction grid BFS with stamp-based visited tracking
|
||||
export class BFSGrid {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly visitedStamp: Uint32Array;
|
||||
private readonly queue: Int32Array;
|
||||
private readonly dist: Uint16Array;
|
||||
@@ -16,48 +12,57 @@ export class FastBFS {
|
||||
this.dist = new Uint16Array(numNodes);
|
||||
}
|
||||
|
||||
search<T>(
|
||||
/**
|
||||
* Grid BFS search with visitor pattern.
|
||||
* @param start - Starting node(s)
|
||||
* @param maxDistance - Maximum distance to search
|
||||
* @param isValidNode - Filter for traversable nodes
|
||||
* @param visitor - Called for each node:
|
||||
* - Returns R: Found target, return immediately
|
||||
* - Returns undefined: Valid node, explore neighbors
|
||||
* - Returns null: Reject node, don't explore neighbors
|
||||
*/
|
||||
search<R>(
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
start: number | number[],
|
||||
maxDistance: number,
|
||||
isValidNode: FastBFSAdapter<T>["isValidNode"],
|
||||
visitor: FastBFSAdapter<T>["visitor"],
|
||||
): T | null {
|
||||
isValidNode: (node: number) => boolean,
|
||||
visitor: (node: number, dist: number) => R | null | undefined,
|
||||
): R | null {
|
||||
const stamp = this.nextStamp();
|
||||
const lastRowStart = (height - 1) * width;
|
||||
const starts = typeof start === "number" ? [start] : start;
|
||||
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
this.visitedStamp[start] = stamp;
|
||||
this.dist[start] = 0;
|
||||
this.queue[tail++] = start;
|
||||
for (const s of starts) {
|
||||
this.visitedStamp[s] = stamp;
|
||||
this.dist[s] = 0;
|
||||
this.queue[tail++] = s;
|
||||
}
|
||||
|
||||
while (head < tail) {
|
||||
const node = this.queue[head++];
|
||||
const currentDist = this.dist[node];
|
||||
const dist = this.dist[node];
|
||||
|
||||
if (currentDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call visitor:
|
||||
// - Returns T: Found target, return immediately
|
||||
// - Returns null: Reject tile, don't explore neighbors
|
||||
// - Returns undefined: Valid tile, explore neighbors
|
||||
const result = visitor(node, currentDist);
|
||||
const result = visitor(node, dist);
|
||||
|
||||
if (result !== null && result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If visitor returned null, reject this tile and don't explore neighbors
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDist = currentDist + 1;
|
||||
const nextDist = dist + 1;
|
||||
|
||||
if (nextDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = node % width;
|
||||
|
||||
// North
|
||||
@@ -107,8 +112,7 @@ export class FastBFS {
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.visitedStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Generic BFS implementation with adapter interface
|
||||
|
||||
export interface BFSAdapter<T> {
|
||||
neighbors(node: T): T[];
|
||||
}
|
||||
|
||||
export class BFS<T> {
|
||||
constructor(private adapter: BFSAdapter<T>) {}
|
||||
|
||||
/**
|
||||
* BFS search with visitor pattern.
|
||||
* @param start - Starting node(s)
|
||||
* @param maxDistance - Maximum distance to search (Infinity for unlimited)
|
||||
* @param visitor - Called for each node:
|
||||
* - Returns R: Found target, return immediately
|
||||
* - Returns undefined: Valid node, explore neighbors
|
||||
* - Returns null: Reject node, don't explore neighbors
|
||||
*/
|
||||
search<R>(
|
||||
start: T | T[],
|
||||
maxDistance: number,
|
||||
visitor: (node: T, dist: number) => R | null | undefined,
|
||||
): R | null {
|
||||
const visited = new Set<T>();
|
||||
const queue: { node: T; dist: number }[] = [];
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
|
||||
for (const s of starts) {
|
||||
visited.add(s);
|
||||
queue.push({ node: s, dist: 0 });
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, dist } = queue.shift()!;
|
||||
|
||||
const result = visitor(node, dist);
|
||||
|
||||
if (result !== null && result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDist = dist + 1;
|
||||
|
||||
if (nextDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const neighbor of this.adapter.neighbors(node)) {
|
||||
if (visited.has(neighbor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(neighbor);
|
||||
queue.push({ node: neighbor, dist: nextDist });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -1,12 +1,14 @@
|
||||
// Connected Component Labeling using flood-fill
|
||||
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
|
||||
const LAND_MARKER = 0xff; // Must fit in Uint8Array
|
||||
export const LAND_MARKER = 0xff; // Must fit in Uint8Array
|
||||
|
||||
/**
|
||||
* Manages water component identification using flood-fill.
|
||||
* Pre-allocates buffers and provides explicit initialization.
|
||||
* Connected component labeling for grid-based maps.
|
||||
* Identifies isolated regions using scan-line flood-fill.
|
||||
*/
|
||||
export class WaterComponents {
|
||||
export class ConnectedComponents {
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly numTiles: number;
|
||||
@@ -0,0 +1,154 @@
|
||||
export interface PriorityQueue {
|
||||
push(node: number, priority: number): void;
|
||||
pop(): number;
|
||||
isEmpty(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
// Binary min-heap: O(log n) push/pop, works with any priority values
|
||||
export class MinHeap implements PriorityQueue {
|
||||
private heap: Int32Array;
|
||||
private priorities: Float32Array;
|
||||
private size = 0;
|
||||
|
||||
constructor(private capacity: number) {
|
||||
this.heap = new Int32Array(capacity);
|
||||
this.priorities = new Float32Array(capacity);
|
||||
}
|
||||
|
||||
push(node: number, priority: number): void {
|
||||
if (this.size >= this.capacity) {
|
||||
throw new Error(`MinHeap capacity exceeded: ${this.capacity}`);
|
||||
}
|
||||
|
||||
let i = this.size++;
|
||||
this.heap[i] = node;
|
||||
this.priorities[i] = priority;
|
||||
|
||||
// Bubble up
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.priorities[parent] <= this.priorities[i]) break;
|
||||
// Swap
|
||||
const tmpNode = this.heap[parent];
|
||||
const tmpPri = this.priorities[parent];
|
||||
this.heap[parent] = this.heap[i];
|
||||
this.priorities[parent] = this.priorities[i];
|
||||
this.heap[i] = tmpNode;
|
||||
this.priorities[i] = tmpPri;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
const result = this.heap[0];
|
||||
this.size--;
|
||||
if (this.size > 0) {
|
||||
this.heap[0] = this.heap[this.size];
|
||||
this.priorities[0] = this.priorities[this.size];
|
||||
|
||||
// Bubble down
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = (i << 1) + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
|
||||
if (
|
||||
left < this.size &&
|
||||
this.priorities[left] < this.priorities[smallest]
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
if (
|
||||
right < this.size &&
|
||||
this.priorities[right] < this.priorities[smallest]
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
if (smallest === i) break;
|
||||
|
||||
// Swap
|
||||
const tmpNode = this.heap[smallest];
|
||||
const tmpPri = this.priorities[smallest];
|
||||
this.heap[smallest] = this.heap[i];
|
||||
this.priorities[smallest] = this.priorities[i];
|
||||
this.heap[i] = tmpNode;
|
||||
this.priorities[i] = tmpPri;
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket queue: O(1) push/pop when priorities are integers
|
||||
export class BucketQueue implements PriorityQueue {
|
||||
private buckets: Int32Array[];
|
||||
private bucketSizes: Int32Array;
|
||||
private minBucket: number;
|
||||
private maxBucket: number;
|
||||
private size: number;
|
||||
|
||||
constructor(maxPriority: number) {
|
||||
this.maxBucket = maxPriority + 1;
|
||||
this.buckets = new Array(this.maxBucket);
|
||||
this.bucketSizes = new Int32Array(this.maxBucket);
|
||||
this.minBucket = this.maxBucket;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
push(node: number, priority: number): void {
|
||||
const bucket = Math.min(priority | 0, this.maxBucket - 1);
|
||||
|
||||
if (!this.buckets[bucket]) {
|
||||
this.buckets[bucket] = new Int32Array(64);
|
||||
}
|
||||
|
||||
const size = this.bucketSizes[bucket];
|
||||
if (size >= this.buckets[bucket].length) {
|
||||
const newBucket = new Int32Array(this.buckets[bucket].length * 2);
|
||||
newBucket.set(this.buckets[bucket]);
|
||||
this.buckets[bucket] = newBucket;
|
||||
}
|
||||
|
||||
this.buckets[bucket][size] = node;
|
||||
this.bucketSizes[bucket]++;
|
||||
this.size++;
|
||||
|
||||
if (bucket < this.minBucket) {
|
||||
this.minBucket = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
while (this.minBucket < this.maxBucket) {
|
||||
const size = this.bucketSizes[this.minBucket];
|
||||
if (size > 0) {
|
||||
this.bucketSizes[this.minBucket]--;
|
||||
this.size--;
|
||||
return this.buckets[this.minBucket][size - 1];
|
||||
}
|
||||
this.minBucket++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.bucketSizes.fill(0);
|
||||
this.minBucket = this.maxBucket;
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// A* optimized for performance for small to medium graphs.
|
||||
// Works with node IDs represented as integers (0 to numNodes-1)
|
||||
|
||||
export interface FastAStarAdapter {
|
||||
getNeighbors(node: number): number[];
|
||||
getCost(from: number, to: number): number;
|
||||
heuristic(node: number, goal: number): number;
|
||||
}
|
||||
|
||||
// Simple binary min-heap for open set using typed arrays
|
||||
class MinHeap {
|
||||
private heap: Int32Array;
|
||||
private scores: Float32Array;
|
||||
private size = 0;
|
||||
|
||||
constructor(capacity: number, scores: Float32Array) {
|
||||
this.heap = new Int32Array(capacity);
|
||||
this.scores = scores;
|
||||
}
|
||||
|
||||
push(node: number): void {
|
||||
let i = this.size++;
|
||||
this.heap[i] = node;
|
||||
|
||||
// Bubble up
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.scores[this.heap[parent]] <= this.scores[this.heap[i]]) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[parent];
|
||||
this.heap[parent] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
const result = this.heap[0];
|
||||
this.heap[0] = this.heap[--this.size];
|
||||
|
||||
// Bubble down
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = (i << 1) + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
|
||||
if (
|
||||
left < this.size &&
|
||||
this.scores[this.heap[left]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
|
||||
if (
|
||||
right < this.size &&
|
||||
this.scores[this.heap[right]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
|
||||
if (smallest === i) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[smallest];
|
||||
this.heap[smallest] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = smallest;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class FastAStar {
|
||||
private stamp = 1;
|
||||
private readonly closedStamp: Uint32Array; // Tracks fully processed nodes
|
||||
private readonly gScoreStamp: Uint32Array; // Tracks valid gScores
|
||||
private readonly gScore: Float32Array;
|
||||
private readonly fScore: Float32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly openHeap: MinHeap;
|
||||
|
||||
constructor(numNodes: number) {
|
||||
this.closedStamp = new Uint32Array(numNodes);
|
||||
this.gScoreStamp = new Uint32Array(numNodes);
|
||||
this.gScore = new Float32Array(numNodes);
|
||||
this.fScore = new Float32Array(numNodes);
|
||||
this.cameFrom = new Int32Array(numNodes);
|
||||
this.openHeap = new MinHeap(numNodes, this.fScore);
|
||||
}
|
||||
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
|
||||
search(
|
||||
start: number,
|
||||
goal: number,
|
||||
adapter: FastAStarAdapter,
|
||||
maxIterations: number = 100000,
|
||||
): number[] | null {
|
||||
const stamp = this.nextStamp();
|
||||
|
||||
this.openHeap.clear();
|
||||
this.gScore[start] = 0;
|
||||
this.gScoreStamp[start] = stamp;
|
||||
this.fScore[start] = adapter.heuristic(start, goal);
|
||||
this.cameFrom[start] = -1;
|
||||
this.openHeap.push(start);
|
||||
|
||||
let iterations = 0;
|
||||
|
||||
while (!this.openHeap.isEmpty() && iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
const current = this.openHeap.pop();
|
||||
|
||||
// Skip if already processed (duplicate from heap)
|
||||
if (this.closedStamp[current] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
this.closedStamp[current] = stamp;
|
||||
|
||||
// Found goal
|
||||
if (current === goal) {
|
||||
return this.reconstructPath(start, goal);
|
||||
}
|
||||
|
||||
const neighbors = adapter.getNeighbors(current);
|
||||
const currentGScore = this.gScore[current];
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
// Skip already processed neighbors
|
||||
if (this.closedStamp[neighbor] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tentativeGScore =
|
||||
currentGScore + adapter.getCost(current, neighbor);
|
||||
|
||||
// If we haven't visited this neighbor yet, or found a better path
|
||||
const hasValidGScore = this.gScoreStamp[neighbor] === stamp;
|
||||
if (!hasValidGScore || tentativeGScore < this.gScore[neighbor]) {
|
||||
this.cameFrom[neighbor] = current;
|
||||
this.gScore[neighbor] = tentativeGScore;
|
||||
this.gScoreStamp[neighbor] = stamp;
|
||||
this.fScore[neighbor] =
|
||||
tentativeGScore + adapter.heuristic(neighbor, goal);
|
||||
|
||||
// Add to heap (allow duplicates for better paths)
|
||||
this.openHeap.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private reconstructPath(start: number, goal: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== start) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
|
||||
// Safety check
|
||||
if (current === -1) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
path.push(start);
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastAStarAdapter } from "./FastAStar";
|
||||
import { GatewayGraph } from "./GatewayGraph";
|
||||
|
||||
export class GatewayGraphAdapter implements FastAStarAdapter {
|
||||
constructor(private graph: GatewayGraph) {}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const edges = this.graph.getEdges(node);
|
||||
return edges.map((edge) => edge.to);
|
||||
}
|
||||
|
||||
getCost(from: number, to: number): number {
|
||||
const edges = this.graph.getEdges(from);
|
||||
const edge = edges.find((edge) => edge.to === to);
|
||||
return edge?.cost ?? 1;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeGw = this.graph.getGateway(node);
|
||||
const goalGw = this.graph.getGateway(goal);
|
||||
|
||||
if (!nodeGw || !goalGw) {
|
||||
throw new Error(
|
||||
`Invalid gateway ID in heuristic: node=${node} (${nodeGw ? "exists" : "missing"}), goal=${goal} (${goalGw ? "exists" : "missing"})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Manhattan distance heuristic
|
||||
const dx = Math.abs(nodeGw.x - goalGw.x);
|
||||
const dy = Math.abs(nodeGw.y - goalGw.y);
|
||||
return dx + dy;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedGameMapAdapter implements FastAStarAdapter {
|
||||
private readonly minX: number;
|
||||
private readonly minY: number;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly startTile: TileRef;
|
||||
private readonly goalTile: TileRef;
|
||||
|
||||
readonly numNodes: number;
|
||||
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
startTile: TileRef,
|
||||
goalTile: TileRef,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
) {
|
||||
this.startTile = startTile;
|
||||
this.goalTile = goalTile;
|
||||
|
||||
this.minX = bounds.minX;
|
||||
this.minY = bounds.minY;
|
||||
this.width = bounds.maxX - bounds.minX + 1;
|
||||
this.height = bounds.maxY - bounds.minY + 1;
|
||||
|
||||
this.numNodes = this.width * this.height;
|
||||
}
|
||||
|
||||
// Convert global TileRef to local node ID
|
||||
tileToNode(tile: TileRef): number {
|
||||
const x = this.map.x(tile) - this.minX;
|
||||
const y = this.map.y(tile) - this.minY;
|
||||
|
||||
// Allow start and goal tiles to be outside bounds (matching graph building behavior)
|
||||
const isOutsideBounds =
|
||||
x < 0 || x >= this.width || y < 0 || y >= this.height;
|
||||
const isStartOrGoal = tile === this.startTile || tile === this.goalTile;
|
||||
if (isOutsideBounds && !isStartOrGoal) {
|
||||
return -1; // Outside bounds
|
||||
}
|
||||
|
||||
// Clamp coordinates for start/goal tiles that are outside bounds
|
||||
const clampedX = Math.max(0, Math.min(this.width - 1, x));
|
||||
const clampedY = Math.max(0, Math.min(this.height - 1, y));
|
||||
|
||||
return clampedY * this.width + clampedX;
|
||||
}
|
||||
|
||||
// Convert local node ID to global TileRef
|
||||
nodeToTile(node: number): TileRef {
|
||||
const localX = node % this.width;
|
||||
const localY = Math.floor(node / this.width);
|
||||
return this.map.ref(localX + this.minX, localY + this.minY);
|
||||
}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const tile = this.nodeToTile(node);
|
||||
const neighbors = this.map.neighbors(tile);
|
||||
const result: number[] = [];
|
||||
|
||||
for (const neighborTile of neighbors) {
|
||||
if (!this.map.isWater(neighborTile)) continue;
|
||||
|
||||
const neighborNode = this.tileToNode(neighborTile);
|
||||
if (neighborNode !== -1) {
|
||||
result.push(neighborNode);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCost(_from: number, _to: number): number {
|
||||
return 1; // Uniform cost for water tiles
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeTile = this.nodeToTile(node);
|
||||
const goalTile = this.nodeToTile(goal);
|
||||
|
||||
const dx = Math.abs(this.map.x(nodeTile) - this.map.x(goalTile));
|
||||
const dy = Math.abs(this.map.y(nodeTile) - this.map.y(goalTile));
|
||||
|
||||
return dx + dy; // Manhattan distance
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { WaterComponents } from "./WaterComponents";
|
||||
|
||||
export interface Gateway {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tile: TileRef;
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
from: number;
|
||||
to: number;
|
||||
cost: number;
|
||||
path?: TileRef[];
|
||||
sectorX: number;
|
||||
sectorY: number;
|
||||
}
|
||||
|
||||
export interface Sector {
|
||||
x: number;
|
||||
y: number;
|
||||
gateways: Gateway[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export type BuildDebugInfo = {
|
||||
sectors: number | null;
|
||||
gateways: number | null;
|
||||
edges: number | null;
|
||||
actualBFSCalls: number | null;
|
||||
potentialBFSCalls: number | null;
|
||||
skippedByComponentFilter: number | null;
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class GatewayGraph {
|
||||
constructor(
|
||||
readonly sectors: ReadonlyMap<number, Sector>,
|
||||
readonly gateways: ReadonlyMap<number, Gateway>,
|
||||
readonly edges: ReadonlyMap<number, Edge[]>,
|
||||
readonly sectorSize: number,
|
||||
readonly sectorsX: number,
|
||||
) {}
|
||||
|
||||
getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
getSector(sectorX: number, sectorY: number): Sector | undefined {
|
||||
return this.sectors.get(this.getSectorKey(sectorX, sectorY));
|
||||
}
|
||||
|
||||
getGateway(id: number): Gateway | undefined {
|
||||
return this.gateways.get(id);
|
||||
}
|
||||
|
||||
getEdges(gatewayId: number): Edge[] {
|
||||
return this.edges.get(gatewayId) ?? [];
|
||||
}
|
||||
|
||||
getNearbySectorGateways(sectorX: number, sectorY: number): Gateway[] {
|
||||
const nearby: Gateway[] = [];
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sector = this.getSector(sectorX + dx, sectorY + dy);
|
||||
if (sector) {
|
||||
nearby.push(...sector.gateways);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearby;
|
||||
}
|
||||
|
||||
getAllGateways(): Gateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayGraphBuilder {
|
||||
static readonly SECTOR_SIZE = 32;
|
||||
|
||||
// Derived immutable state
|
||||
private readonly miniMap: GameMap;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly sectorsX: number;
|
||||
private readonly sectorsY: number;
|
||||
private readonly fastBFS: FastBFS;
|
||||
private readonly waterComponents: WaterComponents;
|
||||
|
||||
// Mutable build state
|
||||
private sectors = new Map<number, Sector>();
|
||||
private gateways = new Map<number, Gateway>();
|
||||
private tileToGateway = new Map<TileRef, Gateway>();
|
||||
private edges = new Map<number, Edge[]>();
|
||||
private nextGatewayId = 0;
|
||||
|
||||
// Programatically accessible debug info
|
||||
public debugInfo: BuildDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly game: Game,
|
||||
private readonly sectorSize: number,
|
||||
) {
|
||||
this.miniMap = game.miniMap();
|
||||
this.width = this.miniMap.width();
|
||||
this.height = this.miniMap.height();
|
||||
this.sectorsX = Math.ceil(this.width / sectorSize);
|
||||
this.sectorsY = Math.ceil(this.height / sectorSize);
|
||||
this.fastBFS = new FastBFS(this.width * this.height);
|
||||
this.waterComponents = new WaterComponents(this.miniMap);
|
||||
}
|
||||
|
||||
build(debug: boolean): GatewayGraph {
|
||||
performance.mark("navsat:build:start");
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Building gateway graph with sector size ${this.sectorSize} (${this.sectorsX}x${this.sectorsY} sectors)`,
|
||||
);
|
||||
|
||||
this.debugInfo = {
|
||||
sectors: null,
|
||||
gateways: null,
|
||||
edges: null,
|
||||
actualBFSCalls: null,
|
||||
potentialBFSCalls: null,
|
||||
skippedByComponentFilter: null,
|
||||
timings: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize water components before building gateway graph
|
||||
performance.mark("navsat:build:water-component:start");
|
||||
this.waterComponents.initialize();
|
||||
performance.mark("navsat:build:water-component:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:build:water-component",
|
||||
"navsat:build:water-component:start",
|
||||
"navsat:build:water-component:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Water Component Identification: ${measure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:gateways:start");
|
||||
for (let sy = 0; sy < this.sectorsY; sy++) {
|
||||
for (let sx = 0; sx < this.sectorsX; sx++) {
|
||||
this.processSector(sx, sy);
|
||||
}
|
||||
}
|
||||
performance.mark("navsat:build:gateways:end");
|
||||
const gatewaysMeasure = performance.measure(
|
||||
"navsat:build:gateways",
|
||||
"navsat:build:gateways:start",
|
||||
"navsat:build:gateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway identification: ${gatewaysMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
this.debugInfo!.edges = 0;
|
||||
this.debugInfo!.potentialBFSCalls = 0;
|
||||
this.debugInfo!.skippedByComponentFilter = 0;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:start");
|
||||
for (const sector of this.sectors.values()) {
|
||||
const gws = sector.gateways;
|
||||
const numGateways = gws.length;
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.potentialBFSCalls! +=
|
||||
(numGateways * (numGateways - 1)) / 2;
|
||||
|
||||
for (let i = 0; i < gws.length; i++) {
|
||||
for (let j = i + 1; j < gws.length; j++) {
|
||||
if (gws[i].componentId !== gws[j].componentId) {
|
||||
this.debugInfo!.skippedByComponentFilter!++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.buildSectorConnections(sector);
|
||||
|
||||
if (debug) {
|
||||
// Divide by 2 because bidirectional
|
||||
this.debugInfo!.edges! += sector.edges.length / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.actualBFSCalls =
|
||||
this.debugInfo!.potentialBFSCalls! -
|
||||
this.debugInfo!.skippedByComponentFilter!;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:end");
|
||||
const edgesMeasure = performance.measure(
|
||||
"navsat:build:edges",
|
||||
"navsat:build:edges:start",
|
||||
"navsat:build:edges:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Edges Identification: ${edgesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Edges Found: ${this.debugInfo!.edges} (${((this.debugInfo!.edges! / this.debugInfo!.actualBFSCalls!) * 100).toFixed(1)}% success rate)`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:end");
|
||||
const totalMeasure = performance.measure(
|
||||
"navsat:build:total",
|
||||
"navsat:build:start",
|
||||
"navsat:build:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway graph built in ${totalMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`[DEBUG] Gateways: ${this.gateways.size}`);
|
||||
console.log(`[DEBUG] Sectors: ${this.sectors.size}`);
|
||||
}
|
||||
|
||||
return new GatewayGraph(
|
||||
this.sectors,
|
||||
this.gateways,
|
||||
this.edges,
|
||||
this.sectorSize,
|
||||
this.sectorsX,
|
||||
);
|
||||
}
|
||||
|
||||
private getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
private getOrCreateGateway(x: number, y: number): Gateway {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
|
||||
// O(1) lookup using tile reference
|
||||
const existing = this.tileToGateway.get(tile);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const gateway: Gateway = {
|
||||
id: this.nextGatewayId++,
|
||||
x: x,
|
||||
y: y,
|
||||
tile: tile,
|
||||
componentId: this.waterComponents.getComponentId(tile),
|
||||
};
|
||||
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
this.tileToGateway.set(tile, gateway);
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private addGatewayToSector(sector: Sector, gateway: Gateway): void {
|
||||
// Check for duplicates: a gateway at a sector corner can be
|
||||
// detected by both horizontal and vertical edge scans
|
||||
for (const existingGw of sector.gateways) {
|
||||
if (existingGw.x === gateway.x && existingGw.y === gateway.y) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway doesn't exist in sector yet, add it
|
||||
sector.gateways.push(gateway);
|
||||
}
|
||||
|
||||
private processSector(sx: number, sy: number): void {
|
||||
const sectorKey = this.getSectorKey(sx, sy);
|
||||
let sector = this.sectors.get(sectorKey);
|
||||
|
||||
if (!sector) {
|
||||
sector = { x: sx, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(sectorKey, sector);
|
||||
}
|
||||
|
||||
const baseX = sx * this.sectorSize;
|
||||
const baseY = sy * this.sectorSize;
|
||||
|
||||
if (sx < this.sectorsX - 1) {
|
||||
const edgeX = Math.min(baseX + this.sectorSize - 1, this.width - 1);
|
||||
const newGateways = this.findGatewaysOnVerticalEdge(edgeX, baseY);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const rightSectorKey = this.getSectorKey(sx + 1, sy);
|
||||
let rightSector = this.sectors.get(rightSectorKey);
|
||||
|
||||
if (!rightSector) {
|
||||
rightSector = { x: sx + 1, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(rightSectorKey, rightSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(rightSector, gateway);
|
||||
}
|
||||
}
|
||||
|
||||
if (sy < this.sectorsY - 1) {
|
||||
const edgeY = Math.min(baseY + this.sectorSize - 1, this.height - 1);
|
||||
const newGateways = this.findGatewaysOnHorizontalEdge(edgeY, baseX);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const bottomSectorKey = this.getSectorKey(sx, sy + 1);
|
||||
let bottomSector = this.sectors.get(bottomSectorKey);
|
||||
|
||||
if (!bottomSector) {
|
||||
bottomSector = { x: sx, y: sy + 1, gateways: [], edges: [] };
|
||||
this.sectors.set(bottomSectorKey, bottomSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(bottomSector, gateway);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findGatewaysOnVerticalEdge(x: number, baseY: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxY = Math.min(baseY + this.sectorSize, this.height);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (y: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = y - gatewayStart;
|
||||
const midY = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(x, midY);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let y = baseY; y < maxY; y++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
x + 1 < this.miniMap.width() ? this.miniMap.ref(x + 1, y) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = y;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(y);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxY);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private findGatewaysOnHorizontalEdge(y: number, baseX: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxX = Math.min(baseX + this.sectorSize, this.width);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (x: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = x - gatewayStart;
|
||||
const midX = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(midX, y);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let x = baseX; x < maxX; x++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
y + 1 < this.miniMap.height() ? this.miniMap.ref(x, y + 1) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = x;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(x);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxX);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private buildSectorConnections(sector: Sector): void {
|
||||
const gateways = sector.gateways;
|
||||
|
||||
// Calculate bounding box once for this sector
|
||||
const sectorMinX = sector.x * this.sectorSize;
|
||||
const sectorMinY = sector.y * this.sectorSize;
|
||||
const sectorMaxX = Math.min(
|
||||
this.width - 1,
|
||||
sectorMinX + this.sectorSize - 1,
|
||||
);
|
||||
const sectorMaxY = Math.min(
|
||||
this.height - 1,
|
||||
sectorMinY + this.sectorSize - 1,
|
||||
);
|
||||
|
||||
for (let i = 0; i < gateways.length; i++) {
|
||||
const fromGateway = gateways[i];
|
||||
|
||||
// Build list of target gateways (only those we haven't processed yet)
|
||||
const targetGateways: Gateway[] = [];
|
||||
for (let j = i + 1; j < gateways.length; j++) {
|
||||
// Skip if gateways are in different water components
|
||||
if (gateways[i].componentId !== gateways[j].componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targetGateways.push(gateways[j]);
|
||||
}
|
||||
|
||||
if (targetGateways.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single BFS to find all reachable target gateways
|
||||
const reachableGateways = this.findAllReachableGatewaysInBounds(
|
||||
fromGateway.tile,
|
||||
targetGateways,
|
||||
sectorMinX,
|
||||
sectorMaxX,
|
||||
sectorMinY,
|
||||
sectorMaxY,
|
||||
);
|
||||
|
||||
// Create edges for all reachable gateways
|
||||
for (const [targetId, cost] of reachableGateways.entries()) {
|
||||
if (!this.edges.has(fromGateway.id)) {
|
||||
this.edges.set(fromGateway.id, []);
|
||||
}
|
||||
|
||||
if (!this.edges.has(targetId)) {
|
||||
this.edges.set(targetId, []);
|
||||
}
|
||||
|
||||
// Check for existing edges - gateways may live in 2 sectors, keep only cheaper connection
|
||||
const existingEdgeFromI = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.find((e) => e.to === targetId);
|
||||
const existingEdgeFromJ = this.edges
|
||||
.get(targetId)!
|
||||
.find((e) => e.to === fromGateway.id);
|
||||
|
||||
// If edge doesn't exist or new cost is cheaper, update it
|
||||
if (!existingEdgeFromI || cost < existingEdgeFromI.cost) {
|
||||
const edge1: Edge = {
|
||||
from: fromGateway.id,
|
||||
to: targetId,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
const edge2: Edge = {
|
||||
from: targetId,
|
||||
to: fromGateway.id,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
// Add to sector edges for tracking
|
||||
sector.edges.push(edge1, edge2);
|
||||
|
||||
if (existingEdgeFromI) {
|
||||
const idx1 = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.indexOf(existingEdgeFromI);
|
||||
this.edges.get(fromGateway.id)![idx1] = edge1;
|
||||
|
||||
const idx2 = this.edges.get(targetId)!.indexOf(existingEdgeFromJ!);
|
||||
this.edges.get(targetId)![idx2] = edge2;
|
||||
} else {
|
||||
this.edges.get(fromGateway.id)!.push(edge1);
|
||||
this.edges.get(targetId)!.push(edge2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findAllReachableGatewaysInBounds(
|
||||
from: TileRef,
|
||||
targetGateways: Gateway[],
|
||||
minX: number,
|
||||
maxX: number,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
): Map<number, number> {
|
||||
const fromX = this.miniMap.x(from);
|
||||
const fromY = this.miniMap.y(from);
|
||||
|
||||
// Create a map of tile positions to gateway IDs for fast lookup
|
||||
const tileToGateway = new Map<TileRef, number>();
|
||||
let maxManhattanDist = 0;
|
||||
|
||||
for (const gateway of targetGateways) {
|
||||
tileToGateway.set(gateway.tile, gateway.id);
|
||||
const dx = Math.abs(gateway.x - fromX);
|
||||
const dy = Math.abs(gateway.y - fromY);
|
||||
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
|
||||
}
|
||||
|
||||
const maxDistance = maxManhattanDist * 4; // Allow path deviation
|
||||
const reachable = new Map<number, number>();
|
||||
let foundCount = 0;
|
||||
|
||||
this.fastBFS.search(
|
||||
this.miniMap.width(),
|
||||
this.miniMap.height(),
|
||||
from,
|
||||
maxDistance,
|
||||
(tile: number) => this.miniMap.isWater(tile),
|
||||
(tile: number, dist: number) => {
|
||||
const x = this.miniMap.x(tile);
|
||||
const y = this.miniMap.y(tile);
|
||||
|
||||
// Reject if outside of bounding box
|
||||
const isStartOrEnd = tile === from || tileToGateway.has(tile);
|
||||
if (!isStartOrEnd && (x < minX || x > maxX || y < minY || y > maxY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this tile is one of our target gateways
|
||||
const gatewayId = tileToGateway.get(tile);
|
||||
|
||||
if (gatewayId !== undefined) {
|
||||
reachable.set(gatewayId, dist);
|
||||
foundCount++;
|
||||
|
||||
// Early exit if we've found all target gateways
|
||||
if (foundCount === targetGateways.length) {
|
||||
return dist; // Return to stop BFS
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return reachable;
|
||||
}
|
||||
}
|
||||
@@ -1,819 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { FastAStar } from "./FastAStar";
|
||||
import { BoundedGameMapAdapter, GatewayGraphAdapter } from "./FastAStarAdapter";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { Gateway, GatewayGraph, GatewayGraphBuilder } from "./GatewayGraph";
|
||||
|
||||
type PathDebugInfo = {
|
||||
gatewayPath: TileRef[] | null;
|
||||
initialPath: TileRef[] | null;
|
||||
smoothPath: TileRef[] | null;
|
||||
graph: {
|
||||
sectorSize: number;
|
||||
gateways: Array<{ id: number; tile: TileRef }>;
|
||||
edges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}>;
|
||||
};
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class NavMesh {
|
||||
private graph!: GatewayGraph;
|
||||
private initialized = false;
|
||||
private fastBFS!: FastBFS;
|
||||
private gatewayAStar!: FastAStar;
|
||||
private localAStar!: FastAStar;
|
||||
private localAStarMultiSector!: FastAStar;
|
||||
|
||||
public debugInfo: PathDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private options: {
|
||||
cachePaths?: boolean;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
initialize(debug: boolean = false) {
|
||||
const gatewayGraphBuilder = new GatewayGraphBuilder(
|
||||
this.game,
|
||||
GatewayGraphBuilder.SECTOR_SIZE,
|
||||
);
|
||||
this.graph = gatewayGraphBuilder.build(debug);
|
||||
|
||||
const miniMap = this.game.miniMap();
|
||||
this.fastBFS = new FastBFS(miniMap.width() * miniMap.height());
|
||||
|
||||
const gatewayCount = this.graph.getAllGateways().length;
|
||||
this.gatewayAStar = new FastAStar(gatewayCount);
|
||||
|
||||
// Fixed-size FastAStar for sector-bounded local pathfinding
|
||||
// Single sector: 32×32 = 1,024 nodes
|
||||
const sectorSize = GatewayGraphBuilder.SECTOR_SIZE;
|
||||
const maxLocalNodes = sectorSize * sectorSize; // 1,024 nodes
|
||||
this.localAStar = new FastAStar(maxLocalNodes);
|
||||
|
||||
// Multi-sector FastAStar for cross-sector pathfinding (same gateway, different sectors)
|
||||
// 3×3 sectors: 96×96 = 9,216 nodes
|
||||
const multiSectorSize = sectorSize * 3;
|
||||
const maxMultiSectorNodes = multiSectorSize * multiSectorSize;
|
||||
this.localAStarMultiSector = new FastAStar(maxMultiSectorNodes);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
debug: boolean = false,
|
||||
): TileRef[] | null {
|
||||
if (!this.initialized) {
|
||||
throw new Error(
|
||||
"NavMesh not initialized. Call initialize() before using findPath().",
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
// Collect all edges with their paths for visualization
|
||||
const allEdges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}> = [];
|
||||
|
||||
for (const [fromId, edges] of this.graph.edges.entries()) {
|
||||
const fromGw = this.graph.getGateway(fromId);
|
||||
if (!fromGw) continue;
|
||||
|
||||
for (const edge of edges) {
|
||||
const toGw = this.graph.getGateway(edge.to);
|
||||
if (!toGw) continue;
|
||||
|
||||
// Only add each edge once (not both directions)
|
||||
// Include self-loops (fromId === edge.to) for debugging
|
||||
if (fromId <= edge.to) {
|
||||
allEdges.push({
|
||||
fromId: fromId,
|
||||
toId: edge.to,
|
||||
from: fromGw.tile,
|
||||
to: toGw.tile,
|
||||
cost: edge.cost,
|
||||
path: edge.path ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.debugInfo = {
|
||||
gatewayPath: null,
|
||||
initialPath: null,
|
||||
smoothPath: null,
|
||||
graph: {
|
||||
sectorSize: this.graph.sectorSize,
|
||||
gateways: this.graph
|
||||
.getAllGateways()
|
||||
.map((gw) => ({ id: gw.id, tile: gw.tile })),
|
||||
edges: allEdges,
|
||||
},
|
||||
timings: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dist = this.game.manhattanDist(from, to);
|
||||
|
||||
// Early exit for very short distances that fit within multi-sector range
|
||||
if (dist <= this.graph.sectorSize) {
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:start");
|
||||
const map = this.game.map();
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const sectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const localPath = this.findLocalPath(
|
||||
from,
|
||||
to,
|
||||
sectorX,
|
||||
sectorY,
|
||||
2000,
|
||||
true,
|
||||
);
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:findPath:earlyExitLocalPath",
|
||||
"navsat:findPath:earlyExitLocalPath:start",
|
||||
"navsat:findPath:earlyExitLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
|
||||
this.debugInfo!.timings.total += measure.duration;
|
||||
}
|
||||
|
||||
if (localPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct path failed for dist=${dist}, falling back to gateway graph`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGateways:start");
|
||||
const startGateway = this.findNearestGateway(from);
|
||||
const endGateway = this.findNearestGateway(to);
|
||||
performance.mark("navsat:findPath:findGateways:end");
|
||||
const findGatewaysMeasure = performance.measure(
|
||||
"navsat:findPath:findGateways",
|
||||
"navsat:findPath:findGateways:start",
|
||||
"navsat:findPath:findGateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGateways = findGatewaysMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewaysMeasure.duration;
|
||||
}
|
||||
|
||||
if (!startGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find start gateway for (${this.game.x(from)}, ${this.game.y(from)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!endGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find end gateway for (${this.game.x(to)}, ${this.game.y(to)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startGateway.id === endGateway.id) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Start and end gateways are the same (ID=${startGateway.id}), finding local path with multi-sector search`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:start");
|
||||
const sectorX = Math.floor(startGateway.x / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startGateway.y / this.graph.sectorSize);
|
||||
const path = this.findLocalPath(from, to, sectorX, sectorY, 10000, true);
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:end");
|
||||
const sameGatewayMeasure = performance.measure(
|
||||
"navsat:findPath:sameGatewayLocalPath",
|
||||
"navsat:findPath:sameGatewayLocalPath:start",
|
||||
"navsat:findPath:sameGatewayLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.sameGatewayLocalPath =
|
||||
sameGatewayMeasure.duration;
|
||||
this.debugInfo!.timings.total += sameGatewayMeasure.duration;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGatewayPath:start");
|
||||
const gatewayPath = this.findGatewayPath(startGateway.id, endGateway.id);
|
||||
performance.mark("navsat:findPath:findGatewayPath:end");
|
||||
const findGatewayPathMeasure = performance.measure(
|
||||
"navsat:findPath:findGatewayPath",
|
||||
"navsat:findPath:findGatewayPath:start",
|
||||
"navsat:findPath:findGatewayPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGatewayPath = findGatewayPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewayPathMeasure.duration;
|
||||
|
||||
this.debugInfo!.gatewayPath = gatewayPath
|
||||
? gatewayPath
|
||||
.map((gwId) => {
|
||||
const gw = this.graph.getGateway(gwId);
|
||||
return gw ? gw.tile : -1;
|
||||
})
|
||||
.filter((tile) => tile !== -1)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!gatewayPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] No gateway path between gateways ${startGateway.id} and ${endGateway.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway path found: ${gatewayPath.length} waypoints`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialPath: TileRef[] = [];
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:start");
|
||||
|
||||
// 1. Find path from start to first gateway
|
||||
const firstGateway = this.graph.getGateway(gatewayPath[0])!;
|
||||
const firstGatewayTile = map.ref(
|
||||
miniMap.x(firstGateway.tile) * 2,
|
||||
miniMap.y(firstGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use start position's sector with multi-sector search (gateway may be on border)
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const startSectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const startSectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const startSegment = this.findLocalPath(
|
||||
from,
|
||||
firstGatewayTile,
|
||||
startSectorX,
|
||||
startSectorY,
|
||||
);
|
||||
|
||||
if (!startSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...startSegment);
|
||||
|
||||
// 2. Build path through gateways
|
||||
for (let i = 0; i < gatewayPath.length - 1; i++) {
|
||||
const fromGwId = gatewayPath[i];
|
||||
const toGwId = gatewayPath[i + 1];
|
||||
|
||||
const edges = this.graph.getEdges(fromGwId);
|
||||
const edge = edges.find((edge) => edge.to === toGwId);
|
||||
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (edge.path) {
|
||||
// Use cached path if available
|
||||
initialPath.push(...edge.path.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromGw = this.graph.getGateway(fromGwId)!;
|
||||
const toGw = this.graph.getGateway(toGwId)!;
|
||||
const fromTile = map.ref(
|
||||
miniMap.x(fromGw.tile) * 2,
|
||||
miniMap.y(fromGw.tile) * 2,
|
||||
);
|
||||
const toTile = map.ref(
|
||||
miniMap.x(toGw.tile) * 2,
|
||||
miniMap.y(toGw.tile) * 2,
|
||||
);
|
||||
|
||||
const segmentPath = this.findLocalPath(
|
||||
fromTile,
|
||||
toTile,
|
||||
edge.sectorX,
|
||||
edge.sectorY,
|
||||
);
|
||||
|
||||
if (!segmentPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...segmentPath.slice(1));
|
||||
|
||||
if (this.options.cachePaths) {
|
||||
// Cache the path for future reuse on both directional edges
|
||||
edge.path = segmentPath;
|
||||
|
||||
// Also cache the reversed path on the opposite direction edge
|
||||
const reverseEdges = this.graph.getEdges(toGwId);
|
||||
const reverseEdge = reverseEdges.find((e) => e.to === fromGwId);
|
||||
if (reverseEdge) {
|
||||
reverseEdge.path = segmentPath.slice().reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find path from last gateway to end
|
||||
const lastGateway = this.graph.getGateway(
|
||||
gatewayPath[gatewayPath.length - 1],
|
||||
)!;
|
||||
const lastGatewayTile = map.ref(
|
||||
miniMap.x(lastGateway.tile) * 2,
|
||||
miniMap.y(lastGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use end position's sector with multi-sector search (gateway may be on border)
|
||||
const endMiniX = Math.floor(map.x(to) / 2);
|
||||
const endMiniY = Math.floor(map.y(to) / 2);
|
||||
const endSectorX = Math.floor(endMiniX / this.graph.sectorSize);
|
||||
const endSectorY = Math.floor(endMiniY / this.graph.sectorSize);
|
||||
const endSegment = this.findLocalPath(
|
||||
lastGatewayTile,
|
||||
to,
|
||||
endSectorX,
|
||||
endSectorY,
|
||||
);
|
||||
|
||||
if (!endSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...endSegment.slice(1));
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:end");
|
||||
const buildInitialPathMeasure = performance.measure(
|
||||
"navsat:findPath:buildInitialPath",
|
||||
"navsat:findPath:buildInitialPath:start",
|
||||
"navsat:findPath:buildInitialPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildInitialPath =
|
||||
buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.initialPath = initialPath;
|
||||
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:smoothPath:start");
|
||||
const smoothedPath = this.smoothPath(initialPath);
|
||||
performance.mark("navsat:findPath:smoothPath:end");
|
||||
const smoothPathMeasure = performance.measure(
|
||||
"navsat:findPath:smoothPath",
|
||||
"navsat:findPath:smoothPath:start",
|
||||
"navsat:findPath:smoothPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildSmoothPath = smoothPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += smoothPathMeasure.duration;
|
||||
this.debugInfo!.smoothPath = smoothedPath;
|
||||
console.log(
|
||||
`[DEBUG] Smoothed path: ${initialPath.length} → ${smoothedPath.length} tiles`,
|
||||
);
|
||||
}
|
||||
|
||||
return smoothedPath;
|
||||
}
|
||||
|
||||
private findNearestGateway(tile: TileRef): Gateway | null {
|
||||
const map = this.game.map();
|
||||
const x = map.x(tile);
|
||||
const y = map.y(tile);
|
||||
|
||||
// Convert to miniMap coordinates
|
||||
const miniMap = this.game.miniMap();
|
||||
const miniX = Math.floor(x / 2);
|
||||
const miniY = Math.floor(y / 2);
|
||||
const miniFrom = miniMap.ref(miniX, miniY);
|
||||
|
||||
// Check gateways in the tile's own sector (using miniMap coordinates)
|
||||
const sectorX = Math.floor(miniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(miniY / this.graph.sectorSize);
|
||||
|
||||
// Calculate single sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
const minX = sectorX * sectorSize;
|
||||
const minY = sectorY * sectorSize;
|
||||
const maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
const maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
|
||||
// Get gateways from the tile's own sector only (includes border gateways)
|
||||
const sector = this.graph.getSector(sectorX, sectorY);
|
||||
|
||||
if (!sector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateGateways = sector.gateways;
|
||||
if (candidateGateways.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use BFS to find the nearest reachable gateway (by water path distance)
|
||||
// Search space is bounded by sector bounds, so maxDistance can be large
|
||||
const maxDistance = sectorSize * sectorSize;
|
||||
|
||||
return this.fastBFS.search(
|
||||
miniMap.width(),
|
||||
miniMap.height(),
|
||||
miniFrom,
|
||||
maxDistance,
|
||||
(tile: TileRef) => miniMap.isWater(tile),
|
||||
(tile: TileRef, _dist: number) => {
|
||||
const tileX = miniMap.x(tile);
|
||||
const tileY = miniMap.y(tile);
|
||||
|
||||
// Check if any candidate gateway is at this position first
|
||||
for (const gateway of candidateGateways) {
|
||||
if (gateway.x === tileX && gateway.y === tileY) {
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject non-gateway tiles outside the sector bounds
|
||||
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findGatewayPath(
|
||||
fromGatewayId: number,
|
||||
toGatewayId: number,
|
||||
): number[] | null {
|
||||
const adapter = new GatewayGraphAdapter(this.graph);
|
||||
return this.gatewayAStar.search(
|
||||
fromGatewayId,
|
||||
toGatewayId,
|
||||
adapter,
|
||||
100000,
|
||||
);
|
||||
}
|
||||
|
||||
private findLocalPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
sectorX: number,
|
||||
sectorY: number,
|
||||
maxIterations: number = 10000,
|
||||
multiSector: boolean = false,
|
||||
): TileRef[] | null {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert full map coordinates to miniMap coordinates
|
||||
const miniFrom = miniMap.ref(
|
||||
Math.floor(map.x(from) / 2),
|
||||
Math.floor(map.y(from) / 2),
|
||||
);
|
||||
|
||||
const miniTo = miniMap.ref(
|
||||
Math.floor(map.x(to) / 2),
|
||||
Math.floor(map.y(to) / 2),
|
||||
);
|
||||
|
||||
// Calculate sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
|
||||
let minX: number;
|
||||
let minY: number;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
|
||||
if (multiSector) {
|
||||
// 3×3 sectors centered on the starting sector
|
||||
minX = Math.max(0, (sectorX - 1) * sectorSize);
|
||||
minY = Math.max(0, (sectorY - 1) * sectorSize);
|
||||
maxX = Math.min(miniMap.width() - 1, (sectorX + 2) * sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, (sectorY + 2) * sectorSize - 1);
|
||||
} else {
|
||||
// Single sector
|
||||
minX = sectorX * sectorSize;
|
||||
minY = sectorY * sectorSize;
|
||||
maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
}
|
||||
|
||||
const adapter = new BoundedGameMapAdapter(miniMap, miniFrom, miniTo, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
|
||||
// Convert to local node IDs
|
||||
const startNode = adapter.tileToNode(miniFrom);
|
||||
const goalNode = adapter.tileToNode(miniTo);
|
||||
|
||||
if (startNode === -1 || goalNode === -1) {
|
||||
return null; // Start or goal outside bounds
|
||||
}
|
||||
|
||||
// Choose the appropriate FastAStar buffer based on search area
|
||||
const selectedAStar = multiSector
|
||||
? this.localAStarMultiSector
|
||||
: this.localAStar;
|
||||
|
||||
// Run FastAStar on bounded region
|
||||
const path = selectedAStar.search(
|
||||
startNode,
|
||||
goalNode,
|
||||
adapter,
|
||||
maxIterations,
|
||||
);
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert path from local node IDs back to miniMap TileRefs
|
||||
const miniPath = path.map((node: number) => adapter.nodeToTile(node));
|
||||
|
||||
// Upscale from miniMap to full map (same logic as MiniAStar)
|
||||
const result = this.upscalePathToFullMap(miniPath, from, to);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private upscalePathToFullMap(
|
||||
miniPath: TileRef[],
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
): TileRef[] {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert miniMap path to cells
|
||||
const miniCells = miniPath.map((tile) => ({
|
||||
x: miniMap.x(tile),
|
||||
y: miniMap.y(tile),
|
||||
}));
|
||||
|
||||
// FIRST: Scale all points (2x)
|
||||
const scaledPath = miniCells.map((point) => ({
|
||||
x: point.x * 2,
|
||||
y: point.y * 2,
|
||||
}));
|
||||
|
||||
// SECOND: Interpolate between scaled points
|
||||
const smoothPath: Array<{ x: number; y: number }> = [];
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
// Add the current point
|
||||
smoothPath.push(current);
|
||||
|
||||
// Calculate dx/dy from SCALED coordinates
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
// Add intermediate points
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push({
|
||||
x: Math.round(current.x + (dx * step) / steps),
|
||||
y: Math.round(current.y + (dy * step) / steps),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add last point
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
const scaledCells = smoothPath;
|
||||
|
||||
// Fix extremes to ensure exact start/end
|
||||
const fromCell = { x: map.x(from), y: map.y(from) };
|
||||
const toCell = { x: map.x(to), y: map.y(to) };
|
||||
|
||||
// Ensure start is correct
|
||||
const startIdx = scaledCells.findIndex(
|
||||
(c) => c.x === fromCell.x && c.y === fromCell.y,
|
||||
);
|
||||
if (startIdx === -1) {
|
||||
scaledCells.unshift(fromCell);
|
||||
} else if (startIdx !== 0) {
|
||||
scaledCells.splice(0, startIdx);
|
||||
}
|
||||
|
||||
// Ensure end is correct
|
||||
const endIdx = scaledCells.findIndex(
|
||||
(c) => c.x === toCell.x && c.y === toCell.y,
|
||||
);
|
||||
if (endIdx === -1) {
|
||||
scaledCells.push(toCell);
|
||||
} else if (endIdx !== scaledCells.length - 1) {
|
||||
scaledCells.splice(endIdx + 1);
|
||||
}
|
||||
|
||||
// Convert back to TileRefs
|
||||
return scaledCells.map((cell) => map.ref(cell.x, cell.y));
|
||||
}
|
||||
|
||||
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const x0 = this.game.x(from);
|
||||
const y0 = this.game.y(from);
|
||||
const x1 = this.game.x(to);
|
||||
const y1 = this.game.y(to);
|
||||
|
||||
const tiles: TileRef[] = [];
|
||||
|
||||
// Bresenham's line algorithm - trace and collect all tiles
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
// Safety limit to prevent excessive memory allocation
|
||||
const maxTiles = 100000;
|
||||
let iterations = 0;
|
||||
|
||||
while (true) {
|
||||
if (iterations++ > maxTiles) {
|
||||
return null; // Path too long
|
||||
}
|
||||
const tile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(tile)) {
|
||||
return null; // Path blocked
|
||||
}
|
||||
|
||||
tiles.push(tile);
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const e2 = 2 * err;
|
||||
const shouldMoveX = e2 > -dy;
|
||||
const shouldMoveY = e2 < dx;
|
||||
|
||||
if (shouldMoveX && shouldMoveY) {
|
||||
// Diagonal move - need to expand into two 4-directional moves
|
||||
// Try moving X first, then Y
|
||||
x += sx;
|
||||
err -= dy;
|
||||
|
||||
const intermediateTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(intermediateTile)) {
|
||||
// X first doesn't work, try Y first instead
|
||||
x -= sx; // undo
|
||||
err += dy; // undo
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
|
||||
const altTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(altTile)) {
|
||||
return null; // Neither direction works
|
||||
}
|
||||
tiles.push(altTile);
|
||||
|
||||
// Now move X
|
||||
x += sx;
|
||||
err -= dy;
|
||||
} else {
|
||||
tiles.push(intermediateTile);
|
||||
|
||||
// Now move Y
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
} else {
|
||||
// Single-axis move
|
||||
if (shouldMoveX) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
}
|
||||
|
||||
if (shouldMoveY) {
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private smoothPath(path: TileRef[]): TileRef[] {
|
||||
if (path.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const smoothed: TileRef[] = [];
|
||||
let current = 0;
|
||||
|
||||
while (current < path.length - 1) {
|
||||
// Look as far ahead as possible while maintaining line of sight
|
||||
let farthest = current + 1;
|
||||
let bestTrace: TileRef[] | null = null;
|
||||
|
||||
for (
|
||||
let i = current + 2;
|
||||
i < path.length;
|
||||
i += Math.max(1, Math.floor(path.length / 20))
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[i]);
|
||||
|
||||
if (trace !== null) {
|
||||
farthest = i;
|
||||
bestTrace = trace;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try the final tile if we haven't already
|
||||
if (
|
||||
farthest < path.length - 1 &&
|
||||
(path.length - 1 - current) % 10 !== 0
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[path.length - 1]);
|
||||
if (trace !== null) {
|
||||
farthest = path.length - 1;
|
||||
bestTrace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the traced path (or just current tile if no improvement)
|
||||
if (bestTrace !== null && farthest > current + 1) {
|
||||
// Add all tiles from the trace except the last one (to avoid duplication)
|
||||
smoothed.push(...bestTrace.slice(0, -1));
|
||||
} else {
|
||||
// No LOS improvement, just add current tile
|
||||
smoothed.push(path[current]);
|
||||
}
|
||||
|
||||
current = farthest;
|
||||
}
|
||||
|
||||
// Add the final tile
|
||||
smoothed.push(path[path.length - 1]);
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { PathSmoother } from "./PathSmoother";
|
||||
|
||||
/**
|
||||
* Path smoother using Bresenham line-of-sight algorithm.
|
||||
* Greedily skips waypoints when direct traversal is possible.
|
||||
*/
|
||||
export class BresenhamPathSmoother implements PathSmoother<TileRef> {
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private isTraversable: (tile: TileRef) => boolean,
|
||||
) {}
|
||||
|
||||
smooth(path: TileRef[]): TileRef[] {
|
||||
if (path.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const smoothed: TileRef[] = [];
|
||||
let current = 0;
|
||||
|
||||
while (current < path.length - 1) {
|
||||
let farthest = current + 1;
|
||||
let bestTrace: TileRef[] | null = null;
|
||||
|
||||
for (
|
||||
let i = current + 2;
|
||||
i < path.length;
|
||||
i += Math.max(1, Math.floor(path.length / 20))
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[i]);
|
||||
|
||||
if (trace !== null) {
|
||||
farthest = i;
|
||||
bestTrace = trace;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
farthest < path.length - 1 &&
|
||||
(path.length - 1 - current) % 10 !== 0
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[path.length - 1]);
|
||||
if (trace !== null) {
|
||||
farthest = path.length - 1;
|
||||
bestTrace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestTrace !== null && farthest > current + 1) {
|
||||
smoothed.push(...bestTrace.slice(0, -1));
|
||||
} else {
|
||||
smoothed.push(path[current]);
|
||||
}
|
||||
|
||||
current = farthest;
|
||||
}
|
||||
|
||||
smoothed.push(path[path.length - 1]);
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const x0 = this.map.x(from);
|
||||
const y0 = this.map.y(from);
|
||||
const x1 = this.map.x(to);
|
||||
const y1 = this.map.y(to);
|
||||
|
||||
const tiles: TileRef[] = [];
|
||||
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
const maxTiles = 100000;
|
||||
let iterations = 0;
|
||||
|
||||
while (true) {
|
||||
if (iterations++ > maxTiles) {
|
||||
return null;
|
||||
}
|
||||
const tile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(tile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tiles.push(tile);
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const e2 = 2 * err;
|
||||
const shouldMoveX = e2 > -dy;
|
||||
const shouldMoveY = e2 < dx;
|
||||
|
||||
if (shouldMoveX && shouldMoveY) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
|
||||
const intermediateTile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(intermediateTile)) {
|
||||
x -= sx;
|
||||
err += dy;
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
|
||||
const altTile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(altTile)) {
|
||||
return null;
|
||||
}
|
||||
tiles.push(altTile);
|
||||
|
||||
x += sx;
|
||||
err -= dy;
|
||||
} else {
|
||||
tiles.push(intermediateTile);
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
} else {
|
||||
if (shouldMoveX) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
}
|
||||
|
||||
if (shouldMoveY) {
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready-to-use transformer that applies Bresenham smoothing.
|
||||
* Defaults to water traversability.
|
||||
*/
|
||||
export class BresenhamSmoothingTransformer implements PathFinder<TileRef> {
|
||||
private smoother: BresenhamPathSmoother;
|
||||
|
||||
constructor(
|
||||
private inner: PathFinder<TileRef>,
|
||||
map: GameMap,
|
||||
isTraversable: (tile: TileRef) => boolean = (t) => map.isWater(t),
|
||||
) {
|
||||
this.smoother = new BresenhamPathSmoother(map, isTraversable);
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
const path = this.inner.findPath(from, to);
|
||||
return path ? this.smoother.smooth(path) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* PathSmoother - interface for path smoothing algorithms.
|
||||
* Takes a path and returns a smoothed version.
|
||||
*/
|
||||
export interface PathSmoother<T> {
|
||||
smooth(path: T[]): T[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PathFinder } from "../types";
|
||||
import { PathSmoother } from "./PathSmoother";
|
||||
|
||||
/**
|
||||
* Transformer that applies path smoothing to any PathFinder.
|
||||
* Wraps an inner PathFinder and smooths its output.
|
||||
*/
|
||||
export class SmoothingTransformer<T> implements PathFinder<T> {
|
||||
constructor(
|
||||
private inner: PathFinder<T>,
|
||||
private smoother: PathSmoother<T>,
|
||||
) {}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
const path = this.inner.findPath(from, to);
|
||||
return path ? this.smoother.smooth(path) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Game, Player, TerraNullius } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PathFinding } from "../PathFinder";
|
||||
|
||||
type Owner = Player | TerraNullius;
|
||||
|
||||
export class SpatialQuery {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
/**
|
||||
* Find nearest tile matching predicate using BFS traversal.
|
||||
* Uses Manhattan distance filter, ignores terrain barriers.
|
||||
*/
|
||||
private bfsNearest(
|
||||
from: TileRef,
|
||||
maxDist: number,
|
||||
predicate: (t: TileRef) => boolean,
|
||||
): TileRef | null {
|
||||
const map = this.game.map();
|
||||
const candidates: TileRef[] = [];
|
||||
|
||||
for (const tile of map.bfs(
|
||||
from,
|
||||
(_, t) => map.manhattanDist(from, t) <= maxDist,
|
||||
)) {
|
||||
if (predicate(tile)) {
|
||||
candidates.push(tile);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Sort by Manhattan distance to find actual nearest
|
||||
candidates.sort(
|
||||
(a, b) => map.manhattanDist(from, a) - map.manhattanDist(from, b),
|
||||
);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest shore tile by land BFS.
|
||||
* Works for both players and terra nullius.
|
||||
*/
|
||||
closestShore(
|
||||
owner: Owner,
|
||||
tile: TileRef,
|
||||
maxDist: number = 50,
|
||||
): TileRef | null {
|
||||
const gm = this.game;
|
||||
const ownerId = owner.smallID();
|
||||
|
||||
const isValidTile = (t: TileRef) => {
|
||||
if (!gm.isShore(t) || !gm.isLand(t)) return false;
|
||||
const tOwner = gm.ownerID(t);
|
||||
return tOwner === ownerId;
|
||||
};
|
||||
|
||||
return this.bfsNearest(tile, maxDist, isValidTile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest shore tile by water pathfinding.
|
||||
* Returns null for terra nullius (no borderTiles).
|
||||
*/
|
||||
closestShoreByWater(owner: Owner, target: TileRef): TileRef | null {
|
||||
if (!owner.isPlayer()) return null;
|
||||
|
||||
const gm = this.game;
|
||||
const player = owner as Player;
|
||||
|
||||
// Target must be water or shore (land adjacent to water)
|
||||
if (!gm.isWater(target) && !gm.isShore(target)) return null;
|
||||
|
||||
const targetComponent = gm.getWaterComponent(target);
|
||||
if (targetComponent === null) return null;
|
||||
|
||||
const isValidTile = (t: TileRef) => {
|
||||
if (!gm.isShore(t) || !gm.isLand(t)) return false;
|
||||
const tComponent = gm.getWaterComponent(t);
|
||||
return tComponent === targetComponent;
|
||||
};
|
||||
|
||||
const shores = Array.from(player.borderTiles()).filter(isValidTile);
|
||||
if (shores.length === 0) return null;
|
||||
|
||||
const path = PathFinding.Water(gm).findPath(shores, target);
|
||||
return path?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Component check transformer - fail fast if src/dst in different components
|
||||
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
/**
|
||||
* Wraps a PathFinder to fail fast when source and destination
|
||||
* are in different components (e.g., disconnected water bodies).
|
||||
*
|
||||
* Avoids running expensive pathfinding when no path exists.
|
||||
*/
|
||||
export class ComponentCheckTransformer<T> implements PathFinder<T> {
|
||||
constructor(
|
||||
private inner: PathFinder<T>,
|
||||
private getComponent: (t: T) => number,
|
||||
) {}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
const toComponent = this.getComponent(to);
|
||||
|
||||
// Check all sources - at least one must match destination component
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const validSources = fromArray.filter(
|
||||
(f) => this.getComponent(f) === toComponent,
|
||||
);
|
||||
|
||||
if (validSources.length === 0) {
|
||||
return null; // No source in same component as destination
|
||||
}
|
||||
|
||||
// Delegate with only valid sources
|
||||
const delegateFrom =
|
||||
validSources.length === 1 ? validSources[0] : validSources;
|
||||
return this.inner.findPath(delegateFrom, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Cell } from "../../game/Game";
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
export class MiniMapTransformer implements PathFinder<number> {
|
||||
constructor(
|
||||
private inner: PathFinder<number>,
|
||||
private map: GameMap,
|
||||
private miniMap: GameMap,
|
||||
) {}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
// Convert game coords → minimap coords (supports multi-source)
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const miniFromArray = fromArray.map((f) =>
|
||||
this.miniMap.ref(
|
||||
Math.floor(this.map.x(f) / 2),
|
||||
Math.floor(this.map.y(f) / 2),
|
||||
),
|
||||
);
|
||||
const miniFrom =
|
||||
miniFromArray.length === 1 ? miniFromArray[0] : miniFromArray;
|
||||
|
||||
const miniTo = this.miniMap.ref(
|
||||
Math.floor(this.map.x(to) / 2),
|
||||
Math.floor(this.map.y(to) / 2),
|
||||
);
|
||||
|
||||
// Search on minimap
|
||||
const path = this.inner.findPath(miniFrom, miniTo);
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert minimap TileRefs → Cells
|
||||
const cellPath = path.map(
|
||||
(ref) => new Cell(this.miniMap.x(ref), this.miniMap.y(ref)),
|
||||
);
|
||||
|
||||
// For multi-source, find closest source to path start
|
||||
const upscaledPath = this.upscalePath(cellPath);
|
||||
let cellFrom: Cell | undefined;
|
||||
if (Array.isArray(from)) {
|
||||
if (upscaledPath.length > 0) {
|
||||
const pathStart = upscaledPath[0];
|
||||
let minDist = Infinity;
|
||||
for (const f of from) {
|
||||
const fx = this.map.x(f);
|
||||
const fy = this.map.y(f);
|
||||
const dist = Math.abs(fx - pathStart.x) + Math.abs(fy - pathStart.y);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
cellFrom = new Cell(fx, fy);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cellFrom = new Cell(this.map.x(from), this.map.y(from));
|
||||
}
|
||||
const cellTo = new Cell(this.map.x(to), this.map.y(to));
|
||||
const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom);
|
||||
|
||||
return upscaled.map((c) => this.map.ref(c.x, c.y));
|
||||
}
|
||||
|
||||
private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
const scaledPath = path.map(
|
||||
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
|
||||
);
|
||||
|
||||
const smoothPath: Cell[] = [];
|
||||
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
smoothPath.push(current);
|
||||
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push(
|
||||
new Cell(
|
||||
Math.round(current.x + (dx * step) / steps),
|
||||
Math.round(current.y + (dy * step) / steps),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
return smoothPath;
|
||||
}
|
||||
|
||||
private fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = this.findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
upscaled = upscaled.slice(srcIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const dstIndex = this.findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
upscaled = upscaled.slice(0, dstIndex + 1);
|
||||
}
|
||||
return upscaled;
|
||||
}
|
||||
|
||||
private findCell(cells: Cell[], target: Cell): number {
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].x === target.x && cells[i].y === target.y) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding
|
||||
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
/**
|
||||
* Wraps a PathFinder to handle shore tiles.
|
||||
* Coerces shore tiles to nearby water tiles before pathfinding,
|
||||
* then fixes the path extremes to include the original shore tiles.
|
||||
*
|
||||
* Works at whatever resolution the map provides - can be used with
|
||||
* full map or minimap-based pathfinders.
|
||||
*/
|
||||
export class ShoreCoercingTransformer implements PathFinder<number> {
|
||||
constructor(
|
||||
private inner: PathFinder<number>,
|
||||
private map: GameMap,
|
||||
) {}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const waterToOriginal = new Map<TileRef, TileRef | null>();
|
||||
const waterFrom: TileRef[] = [];
|
||||
|
||||
for (const f of fromArray) {
|
||||
const coerced = this.coerceToWater(f);
|
||||
if (coerced.water !== null) {
|
||||
waterFrom.push(coerced.water);
|
||||
waterToOriginal.set(coerced.water, coerced.original);
|
||||
}
|
||||
}
|
||||
|
||||
if (waterFrom.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Coerce to tile
|
||||
const coercedTo = this.coerceToWater(to);
|
||||
if (coercedTo.water === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search on water tiles
|
||||
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
|
||||
const path = this.inner.findPath(fromTiles, coercedTo.water);
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up the actual path start in the map
|
||||
const originalShore = waterToOriginal.get(path[0]);
|
||||
if (originalShore !== undefined && originalShore !== null) {
|
||||
path.unshift(originalShore);
|
||||
}
|
||||
|
||||
// Append original to if different
|
||||
if (
|
||||
coercedTo.original !== null &&
|
||||
path[path.length - 1] !== coercedTo.original
|
||||
) {
|
||||
path.push(coercedTo.original);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a tile to water for pathfinding.
|
||||
* If tile is already water, returns it unchanged.
|
||||
* If tile is shore (land with water neighbor), finds the nearest water neighbor.
|
||||
*/
|
||||
private coerceToWater(tile: TileRef): {
|
||||
water: TileRef | null;
|
||||
original: TileRef | null;
|
||||
} {
|
||||
// If already water, no coercion needed
|
||||
if (this.map.isWater(tile)) {
|
||||
return { water: tile, original: null };
|
||||
}
|
||||
|
||||
// Find adjacent water neighbor
|
||||
for (const n of this.map.neighbors(tile)) {
|
||||
if (this.map.isWater(n)) {
|
||||
return { water: n, original: tile };
|
||||
}
|
||||
}
|
||||
|
||||
// No water neighbor found - let HPA* handle at minimap level
|
||||
return { water: null, original: tile };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Core pathfinding types and interfaces.
|
||||
* No dependencies - safe to import from anywhere.
|
||||
*/
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
}
|
||||
|
||||
export type PathResult<T> =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: T }
|
||||
| { status: PathStatus.COMPLETE; node: T }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
|
||||
/**
|
||||
* PathFinder - core pathfinding interface.
|
||||
* Implementations find paths between nodes.
|
||||
*/
|
||||
export interface PathFinder<T> {
|
||||
findPath(from: T | T[], to: T): T[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SteppingPathFinder - PathFinder with stepping support.
|
||||
* Used by execution classes that need incremental path traversal.
|
||||
*/
|
||||
export interface SteppingPathFinder<T> extends PathFinder<T> {
|
||||
next(from: T, to: T, dist?: number): PathResult<T>;
|
||||
invalidate(): void;
|
||||
}
|
||||
Reference in New Issue
Block a user