Merge branch 'main' into factory-radius-layer

This commit is contained in:
bijx
2026-01-08 16:35:20 -05:00
committed by GitHub
45 changed files with 9032 additions and 61 deletions
+1
View File
@@ -182,6 +182,7 @@ export const GameConfigSchema = z.object({
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(),
+1
View File
@@ -83,6 +83,7 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
disableNavMesh(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
+3
View File
@@ -333,6 +333,9 @@ export class DefaultConfig implements Config {
instantBuild(): boolean {
return this._gameConfig.instantBuild;
}
disableNavMesh(): boolean {
return this._gameConfig.disableNavMesh ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
+8 -9
View File
@@ -8,8 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { distSortUnit } from "../Util";
export class TradeShipExecution implements Execution {
@@ -28,7 +27,7 @@ export class TradeShipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 2500);
this.pathFinder = PathFinders.Water(mg);
}
tick(ticks: number): void {
@@ -102,14 +101,14 @@ export class TradeShipExecution implements Execution {
return;
}
const result = this.pathFinder.nextTile(curTile, this._dstPort.tile());
const result = this.pathFinder.next(curTile, this._dstPort.tile());
switch (result.type) {
case PathFindResultType.Pending:
switch (result.status) {
case PathStatus.PENDING:
// Fire unit event to rerender.
this.tradeShip.move(curTile);
break;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
// Update safeFromPirates status
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
this.tradeShip.setSafeFromPirates();
@@ -117,10 +116,10 @@ export class TradeShipExecution implements Execution {
this.tradeShip.move(result.node);
this.tilesTraveled++;
break;
case PathFindResultType.Completed:
case PathStatus.COMPLETE:
this.complete();
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.warn("captured trade ship cannot find route");
if (this.tradeShip.isActive()) {
this.tradeShip.delete(false);
+8 -9
View File
@@ -11,8 +11,7 @@ import {
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -70,7 +69,7 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 100);
this.pathFinder = PathFinders.Water(mg);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
@@ -224,9 +223,9 @@ export class TransportShipExecution implements Execution {
}
}
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
const result = this.pathFinder.next(this.boat.tile(), this.dst);
switch (result.status) {
case PathStatus.COMPLETE:
if (this.mg.owner(this.dst) === this.attacker) {
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
@@ -269,12 +268,12 @@ export class TransportShipExecution implements Execution {
.stats()
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
return;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.boat.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
// TODO: add to poisoned port list
console.warn(`path not found to dst`);
this.attacker.addTroops(this.boat.troops());
+14 -15
View File
@@ -8,8 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { PseudoRandom } from "../PseudoRandom";
import { ShellExecution } from "./ShellExecution";
@@ -27,7 +26,7 @@ export class WarshipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
this.pathfinder = PathFinders.Water(mg);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
@@ -177,24 +176,24 @@ export class WarshipExecution implements Execution {
private huntDownTradeShip() {
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.nextTile(
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetUnit()!.tile(),
5,
);
switch (result.type) {
case PathFindResultType.Completed:
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.log(`path not found to target`);
break;
}
@@ -209,22 +208,22 @@ export class WarshipExecution implements Execution {
}
}
const result = this.pathfinder.nextTile(
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetTile()!,
);
switch (result.type) {
case PathFindResultType.Completed:
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.setTargetTile(undefined);
this.warship.move(result.node);
break;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.warn(`path not found to target tile`);
this.warship.setTargetTile(undefined);
break;
+2
View File
@@ -1,4 +1,5 @@
import { Config } from "../configuration/Config";
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { GameMap, TileRef } from "./GameMap";
@@ -795,6 +796,7 @@ export interface Game extends GameMap {
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
conquerPlayer(conqueror: Player, conquered: Player): void;
navMesh(): NavMesh | null;
}
export interface PlayerActions {
+10
View File
@@ -1,5 +1,6 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
@@ -86,6 +87,7 @@ export class GameImpl implements Game {
private nextAllianceID: number = 0;
private _isPaused: boolean = false;
private _navMesh: NavMesh | null = null;
constructor(
private _humans: PlayerInfo[],
@@ -104,6 +106,11 @@ export class GameImpl implements Game {
this.populateTeams();
}
this.addPlayers();
if (!_config.disableNavMesh()) {
this._navMesh = new NavMesh(this, { cachePaths: true });
this._navMesh.initialize();
}
}
private populateTeams() {
@@ -957,6 +964,9 @@ export class GameImpl implements Game {
railNetwork(): RailNetwork {
return this._railNetwork;
}
navMesh(): NavMesh | null {
return this._navMesh;
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered
+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;
}
}