HPA* Pathfinding (#2815)

## Pathfinding with HPA*

Hi! The primary objective of this PR is to replace per-tile A* with
hierarchical pathfinding - HPA*. In practice, this means we create an
abstract graph on top of the actual map with far fewer points and use it
to decide on general path structure. Only then we go back to tile-level
and build path between selected waypoints. This speeds up long distance
pathfinding by over 1000x in some cases. To make the review easier, it
comes with a benchmark and visual playground.

## PREPROCESSING

H part of HPA* means "hierarchical" and requires preprocessing.

This PR includes pre-processing as part inside `new Game()` constructor.
It takes about 135ms for `giantworldmap` on my machine, which increases
the effective initialization from ~95ms to ~230ms. This time could be
reduced in different ways, which are **out of scope** for this PR.

After confirming the initialization time is bearable on low-end devices,
I argue merging this PR as-is is acceptable tradeoff. It creates small
lag at the beginning of a round but pays for itself in the first minute
of the match.

## Nerdy details

**Architecture**
- HPA*-style hierarchical pathfinding
- 32×32 sectors on minimap with gateway nodes on borders
- Gateway graph built via BFS during preprocessing
- Water component optimization skips unreachable gateway pairs
- A* on gateway graph → local A* within sectors → Bresenham path
smoothing
- Minimap upscaling identical to currently used in MiniAStar

**Key Optimizations**
- Typed arrays instead of high-level primitives
- Stamp-based visited tracking (no need to recreate buffers, O(1)
clearing)
- Optional - enabled by default - caching of tile paths between gateways
- Line of sight smoothing for the final path

## Review Focus

Play with included tools, benchmark and visualization. Pathfinding
should be safe to merge as a black box - you do not need to understand
the details. Outcomes can be tested empirically in-game. Visualize (and
share!) edge cases with included playground. Confirm the 100x speedup is
real with benchmark.

If you plan to dive into the code, I suggest the following order:
- Pathfinding abstraction in `src/core/pathfinding/`
- Pathfinding tests in `tests/core/pathfinding/`
- NavMesh in `src/core/pathfinding/navmesh/` + integration with
`Game.ts`
- Benchmark in `tests/pathfinding/benchmark/`

Do not look at playground's code, it has been created with a clanker.
The design is 100% mine and I spent way too long polishing it, but I
haven't even once edited the code manually. There is probably no
abstraction whatsoever, just do not look at the code, let it play.

## Core Changes

#### Pathfinding (`src/core/pathfinding/navmesh/`)
- HPA* + refinement -> three phased pathfinding: A* over the graph ->
naive path -> refinement
- comes with A* and BFS optimized for for specific needs

#### Pre-Processing (`src/core/pathfinding/navmesh/`)
- identify water bodies to avoid pathfinding between disconnected nodes
- create high-level graph of gateways on top of tile map

#### Abstraction (`src/core/pathfinding/`)

- common `PathFinder` interface that can return full path and also act
as state machine (`.next()`)
- adapters for both new and legacy algorithm with fallback to legacy if
navigation mesh not available

#### Benchmark (`tests/pathfinding/benchmark/`)

- `npx tsx tests/pathfinding/benchmark/run.ts` - no guesswork, numbers
- `npx tsx tests/pathfinding/benchmark/run.ts --synthetic` - 1000s of
synthetic paths
- `npx tsc tests/pathfinding/benchmark/generate.ts` - generate more as
needed, test new maps
- includes ONE synthetic scenario to avoid PR bloat, generate more
locally / later

#### Playground (`tests/pathfinding/playground/`)

- `npx tsx tests/pathfinding/playground/server.ts` - visualize paths
with both new and legacy algorithm

## Benchmarks

### Compared with legacy in default - hand picked - scenario:
```
Initialization: 95.95ms -> 227.29ms
Pathfinding: 3038.43ms -> 6.45ms
Distance: 26972 -> 26810 tiles
```

### 42,000 synthetic routes across all maps
```
Running 42 synthetic scenarios with hpa.cached adapter...

 synthetic/achiran                   | Init:    93.42ms | Path:    139.07ms | Dist: 1481630 tiles | Routes: 1000/1000
 synthetic/africa                    | Init:    87.14ms | Path:    155.08ms | Dist: 1829414 tiles | Routes: 1000/1000
 synthetic/asia                      | Init:    57.60ms | Path:    112.55ms | Dist: 1204082 tiles | Routes: 1000/1000
 synthetic/australia                 | Init:    78.18ms | Path:     77.12ms | Dist:  978375 tiles | Routes: 1000/1000
 synthetic/baikal                    | Init:    78.26ms | Path:    152.14ms | Dist: 1600016 tiles | Routes: 1000/1000
 synthetic/baikalnukewars            | Init:    81.44ms | Path:    165.90ms | Dist: 1699283 tiles | Routes: 1000/1000
 synthetic/betweentwoseas            | Init:    29.29ms | Path:    114.99ms | Dist: 1338075 tiles | Routes: 1000/1000
 synthetic/blacksea                  | Init:    30.66ms | Path:     93.14ms | Dist:  949217 tiles | Routes: 1000/1000
 synthetic/britannia                 | Init:    74.12ms | Path:     85.62ms | Dist:  866752 tiles | Routes: 1000/1000
 synthetic/deglaciatedantarctica     | Init:   105.49ms | Path:    192.93ms | Dist: 1574684 tiles | Routes: 1000/1000
 synthetic/didier                    | Init:    81.51ms | Path:    153.70ms | Dist: 1734876 tiles | Routes: 1000/1000
 synthetic/eastasia                  | Init:    49.29ms | Path:    128.63ms | Dist: 1410270 tiles | Routes: 1000/1000
 synthetic/europe                    | Init:    92.55ms | Path:    178.35ms | Dist: 1525216 tiles | Routes: 1000/1000
 synthetic/europeclassic             | Init:    33.50ms | Path:    104.40ms | Dist: 1209759 tiles | Routes: 1000/1000
 synthetic/falklandislands           | Init:    63.00ms | Path:    107.41ms | Dist: 1080251 tiles | Routes: 1000/1000
 synthetic/faroeislands              | Init:    71.91ms | Path:     49.52ms | Dist:  604613 tiles | Routes: 1000/1000
 synthetic/fourislands               | Init:    45.75ms | Path:     78.91ms | Dist:  937439 tiles | Routes: 1000/1000
 synthetic/gatewaytotheatlantic      | Init:    81.00ms | Path:    257.06ms | Dist: 2555551 tiles | Routes: 1000/1000
 synthetic/giantworldmap             | Init:   214.25ms | Path:    220.42ms | Dist: 1976693 tiles | Routes: 1000/1000
 synthetic/gulfofstlawrence          | Init:    45.16ms | Path:     96.05ms | Dist: 1014604 tiles | Routes: 1000/1000
 synthetic/halkidiki                 | Init:    74.68ms | Path:    149.39ms | Dist: 1546781 tiles | Routes: 1000/1000
 synthetic/iceland                   | Init:    58.72ms | Path:     78.16ms | Dist: 1001554 tiles | Routes: 1000/1000
 synthetic/italia                    | Init:    29.78ms | Path:    139.93ms | Dist: 1412024 tiles | Routes: 1000/1000
 synthetic/japan                     | Init:   161.07ms | Path:    118.65ms | Dist: 1154393 tiles | Routes: 1000/1000
 synthetic/lemnos                    | Init:    52.59ms | Path:    136.69ms | Dist: 1481101 tiles | Routes: 1000/1000
 synthetic/lisbon                    | Init:    49.27ms | Path:     86.53ms | Dist: 1032011 tiles | Routes: 1000/1000
 synthetic/manicouagan               | Init:    53.74ms | Path:    110.52ms | Dist: 1307630 tiles | Routes: 1000/1000
 synthetic/mars                      | Init:    29.39ms | Path:     80.55ms | Dist: 1091702 tiles | Routes: 1000/1000
 synthetic/mena                      | Init:    26.37ms | Path:    120.09ms | Dist: 1272751 tiles | Routes: 1000/1000
 synthetic/montreal                  | Init:    26.08ms | Path:    106.77ms | Dist: 1187736 tiles | Routes: 1000/1000
 synthetic/newyorkcity               | Init:    56.60ms | Path:    181.19ms | Dist: 1753875 tiles | Routes: 1000/1000
 synthetic/northamerica              | Init:    96.29ms | Path:    123.02ms | Dist: 1217221 tiles | Routes: 1000/1000
 synthetic/oceania                   | Init:    52.81ms | Path:     51.96ms | Dist:  482373 tiles | Routes: 1000/1000
 synthetic/pangaea                   | Init:    21.29ms | Path:     56.58ms | Dist:  716189 tiles | Routes: 1000/1000
 synthetic/pluto                     | Init:    53.89ms | Path:    141.62ms | Dist: 1304362 tiles | Routes: 1000/1000
 synthetic/southamerica              | Init:    85.19ms | Path:    123.03ms | Dist: 1301403 tiles | Routes: 1000/1000
 synthetic/straitofgibraltar         | Init:    76.68ms | Path:    108.30ms | Dist: 1304592 tiles | Routes: 1000/1000
 synthetic/straitofhormuz            | Init:    38.97ms | Path:     67.78ms | Dist:  754920 tiles | Routes: 1000/1000
 synthetic/surrounded                | Init:    95.35ms | Path:     90.18ms | Dist: 1017142 tiles | Routes: 1000/1000
 synthetic/svalmel                   | Init:    60.58ms | Path:    104.75ms | Dist: 1235501 tiles | Routes: 1000/1000
 synthetic/twolakes                  | Init:    62.05ms | Path:     94.54ms | Dist: 1140807 tiles | Routes: 1000/1000
 synthetic/world                     | Init:    41.43ms | Path:     93.42ms | Dist:  873406 tiles | Routes: 1000/1000

Completed 42 scenarios
Total Initialization Time: 2796.32ms
Total Pathfinding Time: 5026.64ms
Total Distance: 53160274 tiles
```

## Playground

**That's the fun part**. Watch NavMesh running circles around legacy
`PathFinder.Mini` in real time. Debug inner workings, test edge cases,
share URLs for debugging.


https://github.com/user-attachments/assets/34e2e3f5-fbc1-4b1f-917d-820766e98d5d

## Discord Tag
`moleole`
This commit is contained in:
Arkadiusz Sygulski
2026-01-08 22:34:18 +01:00
committed by GitHub
parent 9512e480d2
commit b090f2f624
45 changed files with 9032 additions and 61 deletions
+43
View File
@@ -0,0 +1,43 @@
import { Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
export enum PathStatus {
NEXT,
PENDING,
COMPLETE,
NOT_FOUND,
}
export type PathResult =
| { status: PathStatus.PENDING }
| { status: PathStatus.NEXT; node: TileRef }
| { status: PathStatus.COMPLETE; node: TileRef }
| { status: PathStatus.NOT_FOUND };
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);
}
return new NavMeshAdapter(game);
}
static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder {
return new MiniAStarAdapter(game, options);
}
}
+16 -21
View File
@@ -114,7 +114,7 @@ export class AirPathFinder {
}
}
export class PathFinder {
export class MiniPathFinder {
private curr: TileRef | null = null;
private dst: TileRef | null = null;
private path: TileRef[] | null = null;
@@ -122,28 +122,23 @@ export class PathFinder {
private aStar: AStar<TileRef>;
private computeFinished = true;
private constructor(
constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
private iterations: number,
private waterPath: boolean,
private maxTries: number,
) {}
public static Mini(
game: Game,
iterations: number,
waterPath: boolean = true,
maxTries: number = 20,
) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
iterations,
maxTries,
waterPath,
);
});
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(
@@ -171,7 +166,7 @@ export class PathFinder {
this.dst = dst;
this.path = null;
this.path_idx = 0;
this.aStar = this.newAStar(curr, dst);
this.aStar = this.createAStar(curr, dst);
this.computeFinished = false;
return this.nextTile(curr, dst);
} else {
@@ -0,0 +1,66 @@
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;
}
}
@@ -0,0 +1,99 @@
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;
}
}
+202
View File
@@ -0,0 +1,202 @@
// 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;
}
}
@@ -0,0 +1,120 @@
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
}
}
+118
View File
@@ -0,0 +1,118 @@
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 {
private stamp = 1;
private readonly visitedStamp: Uint32Array;
private readonly queue: Int32Array;
private readonly dist: Uint16Array;
constructor(numNodes: number) {
this.visitedStamp = new Uint32Array(numNodes);
this.queue = new Int32Array(numNodes);
this.dist = new Uint16Array(numNodes);
}
search<T>(
width: number,
height: number,
start: number,
maxDistance: number,
isValidNode: FastBFSAdapter<T>["isValidNode"],
visitor: FastBFSAdapter<T>["visitor"],
): T | null {
const stamp = this.nextStamp();
const lastRowStart = (height - 1) * width;
let head = 0;
let tail = 0;
this.visitedStamp[start] = stamp;
this.dist[start] = 0;
this.queue[tail++] = start;
while (head < tail) {
const node = this.queue[head++];
const currentDist = 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);
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 x = node % width;
// North
if (node >= width) {
const n = node - width;
if (this.visitedStamp[n] !== stamp && isValidNode(n)) {
this.visitedStamp[n] = stamp;
this.dist[n] = nextDist;
this.queue[tail++] = n;
}
}
// South
if (node < lastRowStart) {
const s = node + width;
if (this.visitedStamp[s] !== stamp && isValidNode(s)) {
this.visitedStamp[s] = stamp;
this.dist[s] = nextDist;
this.queue[tail++] = s;
}
}
// West
if (x !== 0) {
const wv = node - 1;
if (this.visitedStamp[wv] !== stamp && isValidNode(wv)) {
this.visitedStamp[wv] = stamp;
this.dist[wv] = nextDist;
this.queue[tail++] = wv;
}
}
// East
if (x !== width - 1) {
const ev = node + 1;
if (this.visitedStamp[ev] !== stamp && isValidNode(ev)) {
this.visitedStamp[ev] = stamp;
this.dist[ev] = nextDist;
this.queue[tail++] = ev;
}
}
}
return null;
}
private nextStamp(): number {
const stamp = this.stamp++;
if (this.stamp === 0) {
// Overflow - reset (extremely rare)
this.visitedStamp.fill(0);
this.stamp = 1;
}
return stamp;
}
}
@@ -0,0 +1,587 @@
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
@@ -0,0 +1,819 @@
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,200 @@
import { GameMap, TileRef } from "../../game/GameMap";
const LAND_MARKER = 0xff; // Must fit in Uint8Array
/**
* Manages water component identification using flood-fill.
* Pre-allocates buffers and provides explicit initialization.
*/
export class WaterComponents {
private readonly width: number;
private readonly height: number;
private readonly numTiles: number;
private readonly lastRowStart: number;
private readonly queue: Int32Array;
private componentIds: Uint8Array | Uint16Array | null = null;
constructor(
private readonly map: GameMap,
private readonly accessTerrainDirectly: boolean = true,
) {
this.width = map.width();
this.height = map.height();
this.numTiles = this.width * this.height;
this.lastRowStart = (this.height - 1) * this.width;
this.queue = new Int32Array(this.numTiles);
}
initialize(): void {
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
let nextId = 0;
// Scan all tiles and flood-fill each unvisited water component
for (let start = 0; start < this.numTiles; start++) {
const value = ids[start];
// Skip if already visited (land=0xFF or water component >0)
if (value === LAND_MARKER || value > 0) {
continue;
}
nextId++;
// Dynamically upgrade to Uint16Array when we hit component 254
if (nextId === 254 && ids instanceof Uint8Array) {
ids = this.upgradeToUint16Array(ids);
}
this.floodFillComponent(ids, start, nextId);
}
this.componentIds = ids;
}
/**
* Create and prefill a Uint8Array with land markers.
* Uses direct terrain access for performance.
*/
private createPrefilledIds(): Uint8Array {
const ids = new Uint8Array(this.numTiles);
if (this.accessTerrainDirectly) {
this.premarkLandTilesDirect(ids);
} else {
this.premarkLandTiles(ids);
}
return ids;
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*/
private premarkLandTiles(ids: Uint8Array): void {
for (let i = 0; i < this.numTiles; i++) {
ids[i] = this.map.isWater(i) ? 0 : LAND_MARKER;
}
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*
* This implementation accesses the terrain data **directly** without GameMap abstraction.
* In tests it is 30% to 50% faster than using isWater() method calls.
* As of 2026-01-05 it reduces avg. time for GWM from 15ms to 10ms.
*/
private premarkLandTilesDirect(ids: Uint8Array): void {
const terrain = (this.map as any).terrain as Uint8Array;
// Write 4 bytes at once using Uint32Array view for better performance
const numChunks = Math.floor(this.numTiles / 4);
const terrain32 = new Uint32Array(
terrain.buffer,
terrain.byteOffset,
numChunks,
);
const ids32 = new Uint32Array(ids.buffer, ids.byteOffset, numChunks);
for (let i = 0; i < numChunks; i++) {
const chunk = terrain32[i];
// Extract bit 7 from each byte, negate, and combine into single 32-bit write
// bit 7 = 0 (water) → -(0) = 0x00
// bit 7 = 1 (land) → -(1) = 0xFF (truncated to 8 bits)
const b0 = -((chunk >> 7) & 1) & 0xff;
const b1 = -((chunk >> 15) & 1) & 0xff;
const b2 = -((chunk >> 23) & 1) & 0xff;
const b3 = -((chunk >> 31) & 1); // Upper byte, no mask needed
ids32[i] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
}
// Handle remaining tiles (when numTiles not divisible by 4)
for (let i = numChunks * 4; i < this.numTiles; i++) {
ids[i] = -(terrain[i] >> 7);
}
}
/**
* Upgrade from Uint8Array to Uint16Array when we exceed 254 components.
* Direct copy works because both use 0xFF for land marker.
*/
private upgradeToUint16Array(ids: Uint8Array): Uint16Array {
const newIds = new Uint16Array(this.numTiles);
for (let i = 0; i < this.numTiles; i++) {
newIds[i] = ids[i];
}
return newIds;
}
/**
* Flood-fill a single connected water component using scan-line algorithm.
* Processes horizontal spans of tiles for better memory locality and cache performance.
*
* Note: Land tiles are pre-marked, so ids[x] === 0 guarantees water tile.
*/
private floodFillComponent(
ids: Uint8Array | Uint16Array,
start: number,
componentId: number,
): void {
let head = 0;
let tail = 0;
this.queue[tail++] = start;
while (head < tail) {
const seed = this.queue[head++]!;
// Skip if already processed
if (ids[seed] !== 0) continue;
// Scan left to find the start of this horizontal water span
// No isWaterFast check needed - ids[x] === 0 guarantees water
let left = seed;
const rowStart = seed - (seed % this.width);
while (left > rowStart && ids[left - 1] === 0) {
left--;
}
// Scan right to find the end of this horizontal water span
let right = seed;
const rowEnd = rowStart + this.width - 1;
while (right < rowEnd && ids[right + 1] === 0) {
right++;
}
// Fill the entire horizontal span and check above/below for new spans
for (let x = left; x <= right; x++) {
ids[x] = componentId;
// Check tile above (if not in first row)
if (x >= this.width) {
const above = x - this.width;
if (ids[above] === 0) {
this.queue[tail++] = above;
}
}
// Check tile below (if not in last row)
if (x < this.lastRowStart) {
const below = x + this.width;
if (ids[below] === 0) {
this.queue[tail++] = below;
}
}
}
}
}
/**
* Get the component ID for a tile.
* Returns 0 for land tiles or if not initialized.
*/
getComponentId(tile: TileRef): number {
if (!this.componentIds) return 0;
return this.componentIds[tile] ?? 0;
}
}