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:
Arkadiusz Sygulski
2026-01-12 05:11:14 +01:00
committed by GitHub
parent bcec4ad758
commit 0e3ced3bfa
75 changed files with 6800 additions and 4200 deletions
-31
View File
@@ -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;
}
-177
View File
@@ -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;
}
+66
View File
@@ -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);
}
}
+93 -32
View File
@@ -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,
};
}
+42
View File
@@ -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);
}
}
+119
View File
@@ -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);
}
}
-217
View File
@@ -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;
}
}
-189
View File
@@ -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;
}
}
+127
View File
@@ -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;
}
}
@@ -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;
}
+64
View File
@@ -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;
}
}
@@ -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;
}
}
-202
View File
@@ -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;
}
}
-819
View File
@@ -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 };
}
}
+34
View File
@@ -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;
}