mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 10:12:14 +00:00
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:
committed by
GitHub
parent
9512e480d2
commit
b090f2f624
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user