mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 12:12:44 +00:00
Merge branch 'main' into factory-radius-layer
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface Config {
|
||||
infiniteTroops(): boolean;
|
||||
donateTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
disableNavMesh(): boolean;
|
||||
isRandomSpawn(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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