mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 18:05:08 +00:00
Pathfinding Refactor
This commit is contained in:
@@ -2,7 +2,7 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding";
|
||||
import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder";
|
||||
import {
|
||||
GhostStructureChangedEvent,
|
||||
MouseMoveEvent,
|
||||
@@ -211,20 +211,16 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
|
||||
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
|
||||
|
||||
// Calculate trajectory using ParabolaPathFinder with cached spawn tile
|
||||
const pathFinder = new ParabolaPathFinder(this.game);
|
||||
// Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile
|
||||
const speed = this.game.config().defaultNukeSpeed();
|
||||
const distanceBasedHeight = true; // AtomBomb/HydrogenBomb use distance-based height
|
||||
const pathFinder = UniversalPathFinding.Parabola(this.game, {
|
||||
increment: speed,
|
||||
distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height
|
||||
directionUp: this.uiState.rocketDirectionUp,
|
||||
});
|
||||
|
||||
pathFinder.computeControlPoints(
|
||||
this.cachedSpawnTile,
|
||||
targetTile,
|
||||
speed,
|
||||
distanceBasedHeight,
|
||||
this.uiState.rocketDirectionUp,
|
||||
);
|
||||
|
||||
this.trajectoryPoints = pathFinder.allTiles();
|
||||
this.trajectoryPoints =
|
||||
pathFinder.findPath(this.cachedSpawnTile, targetTile) ?? [];
|
||||
|
||||
// NOTE: This is a lot to do in the rendering method, naive
|
||||
// But trajectory is already calculated here and needed for prediction.
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
|
||||
import { UniversalPathFinding } from "../pathfinding/PathFinder";
|
||||
import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola";
|
||||
import { PathStatus } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
@@ -30,11 +32,12 @@ export class MirvExecution implements Execution {
|
||||
|
||||
private random: PseudoRandom;
|
||||
|
||||
private pathFinder: ParabolaPathFinder;
|
||||
private pathFinder: ParabolaUniversalPathFinder;
|
||||
|
||||
private targetPlayer: Player | TerraNullius;
|
||||
|
||||
private separateDst: TileRef;
|
||||
private spawnTile: TileRef;
|
||||
|
||||
private speed: number = -1;
|
||||
|
||||
@@ -46,9 +49,11 @@ export class MirvExecution implements Execution {
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id()));
|
||||
this.mg = mg;
|
||||
this.pathFinder = new ParabolaPathFinder(mg);
|
||||
this.targetPlayer = this.mg.owner(this.dst);
|
||||
this.speed = this.mg.config().defaultNukeSpeed();
|
||||
this.pathFinder = UniversalPathFinding.Parabola(mg, {
|
||||
increment: this.speed,
|
||||
});
|
||||
|
||||
// Betrayal on launch
|
||||
if (this.targetPlayer.isPlayer()) {
|
||||
@@ -70,6 +75,7 @@ export class MirvExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.spawnTile = spawn;
|
||||
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {
|
||||
targetTile: this.dst,
|
||||
});
|
||||
@@ -79,7 +85,6 @@ export class MirvExecution implements Execution {
|
||||
);
|
||||
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
|
||||
this.separateDst = this.mg.ref(x, y);
|
||||
this.pathFinder.computeControlPoints(spawn, this.separateDst);
|
||||
|
||||
this.mg.displayIncomingUnit(
|
||||
this.nuke.id(),
|
||||
@@ -90,15 +95,19 @@ export class MirvExecution implements Execution {
|
||||
);
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(this.speed);
|
||||
if (result === true) {
|
||||
const result = this.pathFinder.next(
|
||||
this.spawnTile,
|
||||
this.separateDst,
|
||||
this.speed,
|
||||
);
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
this.separate();
|
||||
this.active = false;
|
||||
// Record stats
|
||||
this.mg.stats().bombLand(this.player, this.targetPlayer, UnitType.MIRV);
|
||||
return;
|
||||
} else {
|
||||
this.nuke.move(result);
|
||||
} else if (result.status === PathStatus.NEXT) {
|
||||
this.nuke.move(result.node);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
|
||||
import { UniversalPathFinding } from "../pathfinding/PathFinder";
|
||||
import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola";
|
||||
import { PathStatus } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { computeNukeBlastCounts } from "./Util";
|
||||
@@ -22,7 +24,7 @@ export class NukeExecution implements Execution {
|
||||
private mg: Game;
|
||||
private nuke: Unit | null = null;
|
||||
private tilesToDestroyCache: Set<TileRef> | undefined;
|
||||
private pathFinder: ParabolaPathFinder;
|
||||
private pathFinder: ParabolaUniversalPathFinder;
|
||||
|
||||
constructor(
|
||||
private nukeType: NukeType,
|
||||
@@ -39,7 +41,11 @@ export class NukeExecution implements Execution {
|
||||
if (this.speed === -1) {
|
||||
this.speed = this.mg.config().defaultNukeSpeed();
|
||||
}
|
||||
this.pathFinder = new ParabolaPathFinder(mg);
|
||||
this.pathFinder = UniversalPathFinding.Parabola(mg, {
|
||||
increment: this.speed,
|
||||
distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead,
|
||||
directionUp: this.rocketDirectionUp,
|
||||
});
|
||||
}
|
||||
|
||||
public target(): Player | TerraNullius {
|
||||
@@ -123,13 +129,6 @@ export class NukeExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.src = spawn;
|
||||
this.pathFinder.computeControlPoints(
|
||||
spawn,
|
||||
this.dst,
|
||||
this.speed,
|
||||
this.nukeType !== UnitType.MIRVWarhead,
|
||||
this.rocketDirectionUp,
|
||||
);
|
||||
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
|
||||
targetTile: this.dst,
|
||||
trajectory: this.getTrajectory(this.dst),
|
||||
@@ -186,13 +185,13 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
|
||||
// Move to next tile
|
||||
const nextTile = this.pathFinder.nextTile(this.speed);
|
||||
if (nextTile === true) {
|
||||
const result = this.pathFinder.next(this.src!, this.dst, this.speed);
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
this.detonate();
|
||||
return;
|
||||
} else {
|
||||
} else if (result.status === PathStatus.NEXT) {
|
||||
this.updateNukeTargetable();
|
||||
this.nuke.move(nextTile);
|
||||
this.nuke.move(result.node);
|
||||
// Update index so SAM can interpolate future position
|
||||
this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex());
|
||||
}
|
||||
@@ -206,7 +205,7 @@ export class NukeExecution implements Execution {
|
||||
const trajectoryTiles: TrajectoryTile[] = [];
|
||||
const targetRangeSquared =
|
||||
this.mg.config().defaultNukeTargetableRange() ** 2;
|
||||
const allTiles: TileRef[] = this.pathFinder.allTiles();
|
||||
const allTiles = this.pathFinder.findPath(this.src!, target) ?? [];
|
||||
for (const tile of allTiles) {
|
||||
trajectoryTiles.push({
|
||||
tile,
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
|
||||
export class SAMMissileExecution implements Execution {
|
||||
private active = true;
|
||||
private pathFinder: AirPathFinder;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
private SAMMissile: Unit | undefined;
|
||||
private mg: Game;
|
||||
private speed: number = 0;
|
||||
@@ -27,7 +27,7 @@ export class SAMMissileExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
||||
this.pathFinder = PathFinding.Air(mg);
|
||||
this.mg = mg;
|
||||
this.speed = this.mg.config().defaultSamMissileSpeed();
|
||||
}
|
||||
@@ -55,11 +55,11 @@ export class SAMMissileExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < this.speed; i++) {
|
||||
const result = this.pathFinder.nextTile(
|
||||
const result = this.pathFinder.next(
|
||||
this.SAMMissile.tile(),
|
||||
this.targetTile,
|
||||
);
|
||||
if (result === true) {
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
this.mg.displayMessage(
|
||||
"events_display.missile_intercepted",
|
||||
MessageType.SAM_HIT,
|
||||
@@ -76,8 +76,8 @@ export class SAMMissileExecution implements Execution {
|
||||
.stats()
|
||||
.bombIntercept(this._owner, this.target.type() as NukeType, 1);
|
||||
return;
|
||||
} else {
|
||||
this.SAMMissile.move(result);
|
||||
} else if (result.status === PathStatus.NEXT) {
|
||||
this.SAMMissile.move(result.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
|
||||
export class ShellExecution implements Execution {
|
||||
private active = true;
|
||||
private pathFinder: AirPathFinder;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
private shell: Unit | undefined;
|
||||
private mg: Game;
|
||||
private destroyAtTick: number = -1;
|
||||
@@ -19,7 +20,7 @@ export class ShellExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
||||
this.pathFinder = PathFinding.Air(mg);
|
||||
this.mg = mg;
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
}
|
||||
@@ -45,18 +46,18 @@ export class ShellExecution implements Execution {
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = this.pathFinder.nextTile(
|
||||
const result = this.pathFinder.next(
|
||||
this.shell.tile(),
|
||||
this.target.tile(),
|
||||
);
|
||||
if (result === true) {
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
|
||||
this.shell.setReachedTarget();
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
} else {
|
||||
this.shell.move(result);
|
||||
} else if (result.status === PathStatus.NEXT) {
|
||||
this.shell.move(result.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { distSortUnit } from "../Util";
|
||||
|
||||
export class TradeShipExecution implements Execution {
|
||||
@@ -16,7 +17,7 @@ export class TradeShipExecution implements Execution {
|
||||
private mg: Game;
|
||||
private tradeShip: Unit | undefined;
|
||||
private wasCaptured = false;
|
||||
private pathFinder: PathFinder;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
private tilesTraveled = 0;
|
||||
|
||||
constructor(
|
||||
@@ -27,7 +28,7 @@ export class TradeShipExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinders.Water(mg);
|
||||
this.pathFinder = PathFinding.Water(mg);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
@@ -29,11 +30,10 @@ export class TransportShipExecution implements Execution {
|
||||
// TODO make private
|
||||
public path: TileRef[];
|
||||
private dst: TileRef | null;
|
||||
private dstShore: TileRef | null;
|
||||
|
||||
private boat: Unit;
|
||||
|
||||
private pathFinder: PathFinder;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
|
||||
private originalOwner: Player;
|
||||
|
||||
@@ -70,7 +70,7 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinders.Water(mg);
|
||||
this.pathFinder = PathFinding.Water(mg);
|
||||
|
||||
if (
|
||||
this.attacker.unitCount(UnitType.TransportShip) >=
|
||||
@@ -106,8 +106,8 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
|
||||
|
||||
this.dstShore = targetTransportTile(this.mg, this.ref);
|
||||
if (this.dstShore === null) {
|
||||
this.dst = targetTransportTile(this.mg, this.ref);
|
||||
if (this.dst === null) {
|
||||
console.warn(
|
||||
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
|
||||
);
|
||||
@@ -115,18 +115,9 @@ export class TransportShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dst = this.adjacentWater(this.dstShore);
|
||||
if (this.dst === null) {
|
||||
console.warn(
|
||||
`${this.attacker} cannot find water tile adjacent to destination`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const closestTileSrc = this.attacker.canBuild(
|
||||
UnitType.TransportShip,
|
||||
this.dstShore,
|
||||
this.dst,
|
||||
);
|
||||
if (closestTileSrc === false) {
|
||||
console.warn(`can't build transport ship`);
|
||||
@@ -152,21 +143,10 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
|
||||
troops: this.startTroops,
|
||||
targetTile: this.dst ?? undefined,
|
||||
});
|
||||
|
||||
// Move boat from shore to adjacent water for pathfinding
|
||||
const spawnWater = this.adjacentWater(this.src);
|
||||
if (spawnWater === null) {
|
||||
console.warn(`No adjacent water for transport ship spawn`);
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.boat.move(spawnWater);
|
||||
|
||||
if (this.dstShore !== null) {
|
||||
this.boat.setTargetTile(this.dstShore);
|
||||
if (this.dst !== null) {
|
||||
this.boat.setTargetTile(this.dst);
|
||||
} else {
|
||||
this.boat.setTargetTile(undefined);
|
||||
}
|
||||
@@ -222,7 +202,6 @@ export class TransportShipExecution implements Execution {
|
||||
if (this.mg.owner(this.src!) !== this.attacker) {
|
||||
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
||||
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
||||
|
||||
if (newSrc === false) {
|
||||
this.src = null;
|
||||
} else {
|
||||
@@ -239,19 +218,10 @@ export class TransportShipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
} else {
|
||||
this.dstShore = this.src;
|
||||
const retreatWater = this.adjacentWater(this.src);
|
||||
if (retreatWater === null) {
|
||||
console.warn(`No adjacent water for retreat destination`);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.dst = retreatWater;
|
||||
this.dst = this.src;
|
||||
|
||||
if (this.boat.targetTile() !== this.dstShore) {
|
||||
this.boat.setTargetTile(this.dstShore!);
|
||||
if (this.boat.targetTile() !== this.dst) {
|
||||
this.boat.setTargetTile(this.dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,7 +229,7 @@ export class TransportShipExecution implements Execution {
|
||||
const result = this.pathFinder.next(this.boat.tile(), this.dst);
|
||||
switch (result.status) {
|
||||
case PathStatus.COMPLETE:
|
||||
if (this.mg.owner(this.dstShore!) === this.attacker) {
|
||||
if (this.mg.owner(this.dst) === this.attacker) {
|
||||
const deaths = this.boat.troops() * (malusForRetreat / 100);
|
||||
const survivors = this.boat.troops() - deaths;
|
||||
this.attacker.addTroops(survivors);
|
||||
@@ -281,7 +251,7 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.attacker.conquer(this.dstShore!);
|
||||
this.attacker.conquer(this.dst);
|
||||
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
} else {
|
||||
@@ -290,7 +260,7 @@ export class TransportShipExecution implements Execution {
|
||||
this.boat.troops(),
|
||||
this.attacker,
|
||||
this.targetID,
|
||||
this.dstShore!,
|
||||
this.dst,
|
||||
false,
|
||||
),
|
||||
);
|
||||
@@ -308,13 +278,18 @@ export class TransportShipExecution implements Execution {
|
||||
break;
|
||||
case PathStatus.PENDING:
|
||||
break;
|
||||
case PathStatus.NOT_FOUND:
|
||||
case PathStatus.NOT_FOUND: {
|
||||
// TODO: add to poisoned port list
|
||||
console.warn(`path not found to dst`);
|
||||
const map = this.mg.map();
|
||||
const boatTile = this.boat.tile();
|
||||
console.warn(
|
||||
`TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`,
|
||||
);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,17 +300,4 @@ export class TransportShipExecution implements Execution {
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private adjacentWater(tile: TileRef): TileRef | null {
|
||||
if (this.mg.isWater(tile)) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
for (const neighbor of this.mg.neighbors(tile)) {
|
||||
if (this.mg.isWater(neighbor)) {
|
||||
return neighbor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
@@ -16,7 +17,7 @@ export class WarshipExecution implements Execution {
|
||||
private random: PseudoRandom;
|
||||
private warship: Unit;
|
||||
private mg: Game;
|
||||
private pathfinder: PathFinder;
|
||||
private pathfinder: SteppingPathFinder<TileRef>;
|
||||
private lastShellAttack = 0;
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
@@ -26,7 +27,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.pathfinder = PathFinders.Water(mg);
|
||||
this.pathfinder = PathFinding.Water(mg);
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
if (isUnit(this.input)) {
|
||||
this.warship = this.input;
|
||||
@@ -193,9 +194,10 @@ export class WarshipExecution implements Execution {
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathStatus.NOT_FOUND:
|
||||
case PathStatus.NOT_FOUND: {
|
||||
console.log(`path not found to target`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,10 +225,10 @@ export class WarshipExecution implements Execution {
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathStatus.NOT_FOUND:
|
||||
console.warn(`path not found to target tile`);
|
||||
this.warship.setTargetTile(undefined);
|
||||
case PathStatus.NOT_FOUND: {
|
||||
console.log(`path not found to target`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +245,10 @@ export class WarshipExecution implements Execution {
|
||||
const maxAttemptBeforeExpand: number = 500;
|
||||
let attempts: number = 0;
|
||||
let expandCount: number = 0;
|
||||
|
||||
// Get warship's water component for connectivity check
|
||||
const warshipComponent = this.mg.getWaterComponent(this.warship.tile());
|
||||
|
||||
while (expandCount < 3) {
|
||||
const x =
|
||||
this.mg.x(this.warship.patrolTile()!) +
|
||||
@@ -267,6 +273,20 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Check water component connectivity
|
||||
if (
|
||||
warshipComponent !== null &&
|
||||
!this.mg.hasWaterComponent(tile, warshipComponent)
|
||||
) {
|
||||
attempts++;
|
||||
if (attempts === maxAttemptBeforeExpand) {
|
||||
expandCount++;
|
||||
attempts = 0;
|
||||
warshipPatrolRange =
|
||||
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
console.warn(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef, euclDistFN } from "../../game/GameMap";
|
||||
import { ParabolaPathFinder } from "../../pathfinding/PathFinding";
|
||||
import { UniversalPathFinding } from "../../pathfinding/PathFinder";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever, boundingBoxTiles } from "../../Util";
|
||||
import { NukeExecution } from "../NukeExecution";
|
||||
@@ -456,20 +456,14 @@ export class NationNukeBehavior {
|
||||
spawnTile: TileRef,
|
||||
targetTile: TileRef,
|
||||
): boolean {
|
||||
const pathFinder = new ParabolaPathFinder(this.game);
|
||||
const speed = this.game.config().defaultNukeSpeed();
|
||||
const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height
|
||||
const rocketDirectionUp = true; // AI nukes always go "up" for now
|
||||
const pathFinder = UniversalPathFinding.Parabola(this.game, {
|
||||
increment: speed,
|
||||
distanceBasedHeight: true, // Atom/Hydrogen bombs use distance-based height
|
||||
directionUp: true, // AI nukes always go "up" for now
|
||||
});
|
||||
|
||||
pathFinder.computeControlPoints(
|
||||
spawnTile,
|
||||
targetTile,
|
||||
speed,
|
||||
distanceBasedHeight,
|
||||
rocketDirectionUp,
|
||||
);
|
||||
|
||||
const trajectory = pathFinder.allTiles();
|
||||
const trajectory = pathFinder.findPath(spawnTile, targetTile) ?? [];
|
||||
if (trajectory.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Config } from "../configuration/Config";
|
||||
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
|
||||
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
|
||||
import { PathFinder } from "../pathfinding/types";
|
||||
import { AllPlayersStats, ClientID } from "../Schemas";
|
||||
import { getClanTag } from "../Util";
|
||||
import { GameMap, TileRef } from "./GameMap";
|
||||
@@ -802,7 +803,10 @@ export interface Game extends GameMap {
|
||||
addUpdate(update: GameUpdate): void;
|
||||
railNetwork(): RailNetwork;
|
||||
conquerPlayer(conqueror: Player, conquered: Player): void;
|
||||
navMesh(): NavMesh | null;
|
||||
miniWaterHPA(): PathFinder<number> | null;
|
||||
miniWaterGraph(): AbstractGraph | null;
|
||||
getWaterComponent(tile: TileRef): number | null;
|
||||
hasWaterComponent(tile: TileRef, component: number): boolean;
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { renderNumber } from "../../client/Utils";
|
||||
import { Config } from "../configuration/Config";
|
||||
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
|
||||
import {
|
||||
AbstractGraph,
|
||||
AbstractGraphBuilder,
|
||||
} from "../pathfinding/algorithms/AbstractGraph";
|
||||
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
|
||||
import { PathFinder } from "../pathfinding/types";
|
||||
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceImpl } from "./AllianceImpl";
|
||||
@@ -87,7 +92,8 @@ export class GameImpl implements Game {
|
||||
private nextAllianceID: number = 0;
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
private _navMesh: NavMesh | null = null;
|
||||
private _miniWaterGraph: AbstractGraph | null = null;
|
||||
private _miniWaterHPA: AStarWaterHierarchical | null = null;
|
||||
|
||||
constructor(
|
||||
private _humans: PlayerInfo[],
|
||||
@@ -108,8 +114,14 @@ export class GameImpl implements Game {
|
||||
this.addPlayers();
|
||||
|
||||
if (!_config.disableNavMesh()) {
|
||||
this._navMesh = new NavMesh(this, { cachePaths: true });
|
||||
this._navMesh.initialize();
|
||||
const graphBuilder = new AbstractGraphBuilder(this.miniGameMap);
|
||||
this._miniWaterGraph = graphBuilder.build();
|
||||
|
||||
this._miniWaterHPA = new AStarWaterHierarchical(
|
||||
this.miniGameMap,
|
||||
this._miniWaterGraph,
|
||||
{ cachePaths: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,8 +978,79 @@ export class GameImpl implements Game {
|
||||
railNetwork(): RailNetwork {
|
||||
return this._railNetwork;
|
||||
}
|
||||
navMesh(): NavMesh | null {
|
||||
return this._navMesh;
|
||||
miniWaterHPA(): PathFinder<number> | null {
|
||||
return this._miniWaterHPA;
|
||||
}
|
||||
miniWaterGraph(): AbstractGraph | null {
|
||||
return this._miniWaterGraph;
|
||||
}
|
||||
getWaterComponent(tile: TileRef): number | null {
|
||||
// Permissive fallback for tests with disableNavMesh
|
||||
if (!this._miniWaterGraph) return 0;
|
||||
|
||||
const miniX = Math.floor(this._map.x(tile) / 2);
|
||||
const miniY = Math.floor(this._map.y(tile) / 2);
|
||||
const miniTile = this.miniGameMap.ref(miniX, miniY);
|
||||
|
||||
if (this.miniGameMap.isWater(miniTile)) {
|
||||
return this._miniWaterGraph.getComponentId(miniTile);
|
||||
}
|
||||
|
||||
// Shore tile: find water neighbor (expand search for minimap resolution loss)
|
||||
for (const n of this.miniGameMap.neighbors(miniTile)) {
|
||||
if (this.miniGameMap.isWater(n)) {
|
||||
return this._miniWaterGraph.getComponentId(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Extended search: check 2-hop neighbors for narrow straits
|
||||
for (const n of this.miniGameMap.neighbors(miniTile)) {
|
||||
for (const n2 of this.miniGameMap.neighbors(n)) {
|
||||
if (this.miniGameMap.isWater(n2)) {
|
||||
return this._miniWaterGraph.getComponentId(n2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
hasWaterComponent(tile: TileRef, component: number): boolean {
|
||||
// Permissive fallback for tests with disableNavMesh
|
||||
if (!this._miniWaterGraph) return true;
|
||||
|
||||
const miniX = Math.floor(this._map.x(tile) / 2);
|
||||
const miniY = Math.floor(this._map.y(tile) / 2);
|
||||
const miniTile = this.miniGameMap.ref(miniX, miniY);
|
||||
|
||||
// Check miniTile itself (shore in full map may be water in minimap)
|
||||
if (
|
||||
this.miniGameMap.isWater(miniTile) &&
|
||||
this._miniWaterGraph.getComponentId(miniTile) === component
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check neighbors
|
||||
for (const n of this.miniGameMap.neighbors(miniTile)) {
|
||||
if (
|
||||
this.miniGameMap.isWater(n) &&
|
||||
this._miniWaterGraph.getComponentId(n) === component
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extended search: check 2-hop neighbors for narrow straits
|
||||
for (const n of this.miniGameMap.neighbors(miniTile)) {
|
||||
for (const n2 of this.miniGameMap.neighbors(n)) {
|
||||
if (
|
||||
this.miniGameMap.isWater(n2) &&
|
||||
this._miniWaterGraph.getComponentId(n2) === component
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
conquerPlayer(conqueror: Player, conquered: Player) {
|
||||
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
|
||||
|
||||
@@ -1259,6 +1259,6 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
return bestShoreDeploymentSource(this.mg, this, targetTile);
|
||||
return bestShoreDeploymentSource(this.mg, this, targetTile) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Unit } from "./Game";
|
||||
import { StationManager } from "./RailNetworkImpl";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export interface RailNetwork {
|
||||
connectStation(station: TrainStation): void;
|
||||
removeStation(unit: Unit): void;
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
stationManager(): StationManager;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { RailroadExecution } from "../execution/RailroadExecution";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
||||
import { SerialAStar } from "../pathfinding/SerialAStar";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { Game, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Railroad } from "./Railroad";
|
||||
import { Cluster, TrainStation, TrainStationMapAdapter } from "./TrainStation";
|
||||
import { Cluster, TrainStation } from "./TrainStation";
|
||||
|
||||
/**
|
||||
* The Stations handle their own neighbors so the graph is naturally traversable,
|
||||
@@ -18,16 +16,23 @@ export interface StationManager {
|
||||
removeStation(station: TrainStation): void;
|
||||
findStation(unit: Unit): TrainStation | null;
|
||||
getAll(): Set<TrainStation>;
|
||||
getById(id: number): TrainStation | undefined;
|
||||
count(): number;
|
||||
}
|
||||
|
||||
export class StationManagerImpl implements StationManager {
|
||||
private stations: Set<TrainStation> = new Set();
|
||||
private stationsById: (TrainStation | undefined)[] = [];
|
||||
private nextId = 0;
|
||||
|
||||
addStation(station: TrainStation) {
|
||||
station.id = this.nextId++;
|
||||
this.stationsById[station.id] = station;
|
||||
this.stations.add(station);
|
||||
}
|
||||
|
||||
removeStation(station: TrainStation) {
|
||||
this.stationsById[station.id] = undefined;
|
||||
this.stations.delete(station);
|
||||
}
|
||||
|
||||
@@ -41,6 +46,14 @@ export class StationManagerImpl implements StationManager {
|
||||
getAll(): Set<TrainStation> {
|
||||
return this.stations;
|
||||
}
|
||||
|
||||
getById(id: number): TrainStation | undefined {
|
||||
return this.stationsById[id];
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.nextId;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RailPathFinderService {
|
||||
@@ -52,32 +65,11 @@ class RailPathFinderServiceImpl implements RailPathFinderService {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
findTilePath(from: TileRef, to: TileRef): TileRef[] {
|
||||
const astar = new MiniAStar(
|
||||
this.game.map(),
|
||||
this.game.miniMap(),
|
||||
from,
|
||||
to,
|
||||
5000,
|
||||
20,
|
||||
false,
|
||||
3,
|
||||
);
|
||||
return astar.compute() === PathFindResultType.Completed
|
||||
? astar.reconstructPath()
|
||||
: [];
|
||||
return PathFinding.Rail(this.game).findPath(from, to) ?? [];
|
||||
}
|
||||
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
|
||||
const stationAStar = new SerialAStar(
|
||||
from,
|
||||
to,
|
||||
5000,
|
||||
20,
|
||||
new TrainStationMapAdapter(this.game),
|
||||
);
|
||||
return stationAStar.compute() === PathFindResultType.Completed
|
||||
? stationAStar.reconstructPath()
|
||||
: [];
|
||||
return PathFinding.Stations(this.game).findPath(from, to) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,22 +84,26 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private stationManager: StationManager,
|
||||
private _stationManager: StationManager,
|
||||
private pathService: RailPathFinderService,
|
||||
) {}
|
||||
|
||||
stationManager(): StationManager {
|
||||
return this._stationManager;
|
||||
}
|
||||
|
||||
connectStation(station: TrainStation) {
|
||||
this.stationManager.addStation(station);
|
||||
this._stationManager.addStation(station);
|
||||
this.connectToNearbyStations(station);
|
||||
}
|
||||
|
||||
removeStation(unit: Unit): void {
|
||||
const station = this.stationManager.findStation(unit);
|
||||
const station = this._stationManager.findStation(unit);
|
||||
if (!station) return;
|
||||
|
||||
const neighbors = station.neighbors();
|
||||
this.disconnectFromNetwork(station);
|
||||
this.stationManager.removeStation(station);
|
||||
this._stationManager.removeStation(station);
|
||||
|
||||
const cluster = station.getCluster();
|
||||
if (!cluster) return;
|
||||
@@ -142,7 +138,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
if (neighbor.unit === station.unit) continue;
|
||||
const neighborStation = this.stationManager.findStation(neighbor.unit);
|
||||
const neighborStation = this._stationManager.findStation(neighbor.unit);
|
||||
if (!neighborStation) continue;
|
||||
|
||||
const distanceToStation = this.distanceFrom(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TrainExecution } from "../execution/TrainExecution";
|
||||
import { GraphAdapter } from "../pathfinding/SerialAStar";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { Game, Player, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
@@ -72,6 +71,7 @@ export function createTrainStopHandlers(
|
||||
}
|
||||
|
||||
export class TrainStation {
|
||||
id: number = -1; // assigned by StationManager
|
||||
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
||||
{};
|
||||
private cluster: Cluster | null;
|
||||
@@ -171,29 +171,6 @@ export class TrainStation {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the trainstation usable with A*
|
||||
*/
|
||||
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
neighbors(node: TrainStation): TrainStation[] {
|
||||
return node.neighbors();
|
||||
}
|
||||
|
||||
cost(node: TrainStation): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
position(node: TrainStation): { x: number; y: number } {
|
||||
return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) };
|
||||
}
|
||||
|
||||
isTraversable(from: TrainStation, to: TrainStation): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster of connected stations
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
||||
import { SpatialQuery } from "../pathfinding/spatial/SpatialQuery";
|
||||
import { Game, Player, UnitType } from "./Game";
|
||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
export function canBuildTransportShip(
|
||||
game: Game,
|
||||
@@ -27,236 +26,20 @@ export function canBuildTransportShip(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (game.isOceanShore(dst)) {
|
||||
let myPlayerBordersOcean = false;
|
||||
for (const bt of player.borderTiles()) {
|
||||
if (game.isOceanShore(bt)) {
|
||||
myPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let otherPlayerBordersOcean = false;
|
||||
if (!game.hasOwner(tile)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
} else {
|
||||
for (const bt of (other as Player).borderTiles()) {
|
||||
if (game.isOceanShore(bt)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
|
||||
return transportShipSpawn(game, player, dst);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Now we are boating in a lake, so do a bfs from target until we find
|
||||
// a border tile owned by the player
|
||||
|
||||
const tiles = game.bfs(
|
||||
dst,
|
||||
andFN(
|
||||
manhattanDistFN(dst, 300),
|
||||
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
|
||||
),
|
||||
);
|
||||
|
||||
const sorted = Array.from(tiles).sort(
|
||||
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
|
||||
);
|
||||
|
||||
for (const t of sorted) {
|
||||
if (game.owner(t) === player) {
|
||||
return transportShipSpawn(game, player, t);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function transportShipSpawn(
|
||||
game: Game,
|
||||
player: Player,
|
||||
targetTile: TileRef,
|
||||
): TileRef | false {
|
||||
if (!game.isShore(targetTile)) {
|
||||
return false;
|
||||
}
|
||||
const spawn = closestShoreFromPlayer(game, player, targetTile);
|
||||
if (spawn === null) {
|
||||
return false;
|
||||
}
|
||||
return spawn;
|
||||
}
|
||||
|
||||
export function sourceDstOceanShore(
|
||||
gm: Game,
|
||||
src: Player,
|
||||
tile: TileRef,
|
||||
): [TileRef | null, TileRef | null] {
|
||||
const dst = gm.owner(tile);
|
||||
const srcTile = closestShoreFromPlayer(gm, src, tile);
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return [srcTile, dstTile];
|
||||
const spatial = new SpatialQuery(game);
|
||||
return spatial.closestShoreByWater(player, dst) ?? false;
|
||||
}
|
||||
|
||||
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
|
||||
const dst = gm.playerBySmallID(gm.ownerID(tile));
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return dstTile;
|
||||
}
|
||||
|
||||
export function closestShoreFromPlayer(
|
||||
gm: GameMap,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | null {
|
||||
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
|
||||
gm.isShore(t),
|
||||
);
|
||||
if (shoreTiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shoreTiles.reduce((closest, current) => {
|
||||
const closestDistance = gm.manhattanDist(target, closest);
|
||||
const currentDistance = gm.manhattanDist(target, current);
|
||||
return currentDistance < closestDistance ? current : closest;
|
||||
});
|
||||
const spatial = new SpatialQuery(gm);
|
||||
return spatial.closestShore(gm.owner(tile), tile);
|
||||
}
|
||||
|
||||
export function bestShoreDeploymentSource(
|
||||
gm: Game,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | false {
|
||||
const t = targetTransportTile(gm, target);
|
||||
if (t === null) return false;
|
||||
|
||||
const candidates = candidateShoreTiles(gm, player, t);
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
|
||||
const result = aStar.compute();
|
||||
if (result !== PathFindResultType.Completed) {
|
||||
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
|
||||
return false;
|
||||
}
|
||||
const path = aStar.reconstructPath();
|
||||
if (path.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const potential = path[0];
|
||||
// Since mini a* downscales the map, we need to check the neighbors
|
||||
// of the potential tile to find a valid deployment point
|
||||
const neighbors = gm
|
||||
.neighbors(potential)
|
||||
.filter((n) => gm.isShore(n) && gm.owner(n) === player);
|
||||
if (neighbors.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return neighbors[0];
|
||||
}
|
||||
|
||||
export function candidateShoreTiles(
|
||||
gm: Game,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef[] {
|
||||
let closestManhattanDistance = Infinity;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
let bestByManhattan: TileRef | null = null;
|
||||
const extremumTiles: Record<string, TileRef | null> = {
|
||||
minX: null,
|
||||
minY: null,
|
||||
maxX: null,
|
||||
maxY: null,
|
||||
};
|
||||
|
||||
const borderShoreTiles = Array.from(player.borderTiles()).filter((t) =>
|
||||
gm.isShore(t),
|
||||
);
|
||||
|
||||
for (const tile of borderShoreTiles) {
|
||||
const distance = gm.manhattanDist(tile, target);
|
||||
const cell = gm.cell(tile);
|
||||
|
||||
// Manhattan-closest tile
|
||||
if (distance < closestManhattanDistance) {
|
||||
closestManhattanDistance = distance;
|
||||
bestByManhattan = tile;
|
||||
}
|
||||
|
||||
// Extremum tiles
|
||||
if (cell.x < minX) {
|
||||
minX = cell.x;
|
||||
extremumTiles.minX = tile;
|
||||
} else if (cell.y < minY) {
|
||||
minY = cell.y;
|
||||
extremumTiles.minY = tile;
|
||||
} else if (cell.x > maxX) {
|
||||
maxX = cell.x;
|
||||
extremumTiles.maxX = tile;
|
||||
} else if (cell.y > maxY) {
|
||||
maxY = cell.y;
|
||||
extremumTiles.maxY = tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sampling interval to ensure we get at most 50 tiles
|
||||
const samplingInterval = Math.max(
|
||||
10,
|
||||
Math.ceil(borderShoreTiles.length / 50),
|
||||
);
|
||||
const sampledTiles = borderShoreTiles.filter(
|
||||
(_, index) => index % samplingInterval === 0,
|
||||
);
|
||||
|
||||
const candidates = [
|
||||
bestByManhattan,
|
||||
extremumTiles.minX,
|
||||
extremumTiles.minY,
|
||||
extremumTiles.maxX,
|
||||
extremumTiles.maxY,
|
||||
...sampledTiles,
|
||||
].filter(Boolean) as number[];
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function closestShoreTN(
|
||||
gm: GameMap,
|
||||
tile: TileRef,
|
||||
searchDist: number,
|
||||
dst: TileRef,
|
||||
): TileRef | null {
|
||||
const tn = Array.from(
|
||||
gm.bfs(
|
||||
tile,
|
||||
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
|
||||
),
|
||||
)
|
||||
.filter((t) => gm.isShore(t))
|
||||
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
|
||||
if (tn.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return tn[0];
|
||||
const spatial = new SpatialQuery(gm);
|
||||
return spatial.closestShoreByWater(player, dst);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface AStar<NodeType> {
|
||||
compute(): PathFindResultType;
|
||||
reconstructPath(): NodeType[];
|
||||
}
|
||||
|
||||
export enum PathFindResultType {
|
||||
NextTile,
|
||||
Pending,
|
||||
Completed,
|
||||
PathNotFound,
|
||||
}
|
||||
export type AStarResult<NodeType> =
|
||||
| {
|
||||
type: PathFindResultType.NextTile;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Pending;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Completed;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.PathNotFound;
|
||||
};
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Cell } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
import { GraphAdapter, SerialAStar } from "./SerialAStar";
|
||||
|
||||
export class GameMapAdapter implements GraphAdapter<TileRef> {
|
||||
private readonly waterPenalty = 3;
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private waterPath: boolean,
|
||||
) {}
|
||||
|
||||
neighbors(node: TileRef): TileRef[] {
|
||||
return this.gameMap.neighbors(node);
|
||||
}
|
||||
|
||||
cost(node: TileRef): number {
|
||||
let base = this.gameMap.cost(node);
|
||||
// Avoid crossing water when possible
|
||||
if (!this.waterPath && this.gameMap.isWater(node)) {
|
||||
base += this.waterPenalty;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
position(node: TileRef): { x: number; y: number } {
|
||||
return { x: this.gameMap.x(node), y: this.gameMap.y(node) };
|
||||
}
|
||||
|
||||
isTraversable(from: TileRef, to: TileRef): boolean {
|
||||
const toWater = this.gameMap.isWater(to);
|
||||
if (this.waterPath) {
|
||||
return toWater;
|
||||
}
|
||||
// Allow water access from/to shore
|
||||
const fromShore = this.gameMap.isShoreline(from);
|
||||
const toShore = this.gameMap.isShoreline(to);
|
||||
return !toWater || fromShore || toShore;
|
||||
}
|
||||
}
|
||||
export class MiniAStar implements AStar<TileRef> {
|
||||
private aStar: AStar<TileRef>;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private miniMap: GameMap,
|
||||
private src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
iterations: number,
|
||||
maxTries: number,
|
||||
waterPath: boolean = true,
|
||||
directionChangePenalty: number = 0,
|
||||
) {
|
||||
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
|
||||
const miniSrc = srcArray.map((srcPoint) =>
|
||||
this.miniMap.ref(
|
||||
Math.floor(gameMap.x(srcPoint) / 2),
|
||||
Math.floor(gameMap.y(srcPoint) / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const miniDst = this.miniMap.ref(
|
||||
Math.floor(gameMap.x(dst) / 2),
|
||||
Math.floor(gameMap.y(dst) / 2),
|
||||
);
|
||||
|
||||
this.aStar = new SerialAStar(
|
||||
miniSrc,
|
||||
miniDst,
|
||||
iterations,
|
||||
maxTries,
|
||||
new GameMapAdapter(miniMap, waterPath),
|
||||
directionChangePenalty,
|
||||
);
|
||||
}
|
||||
|
||||
compute(): PathFindResultType {
|
||||
return this.aStar.compute();
|
||||
}
|
||||
|
||||
reconstructPath(): TileRef[] {
|
||||
let cellSrc: Cell | undefined;
|
||||
if (!Array.isArray(this.src)) {
|
||||
cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src));
|
||||
}
|
||||
const cellDst = new Cell(
|
||||
this.gameMap.x(this.dst),
|
||||
this.gameMap.y(this.dst),
|
||||
);
|
||||
const upscaled = fixExtremes(
|
||||
upscalePath(
|
||||
this.aStar
|
||||
.reconstructPath()
|
||||
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
|
||||
),
|
||||
cellDst,
|
||||
cellSrc,
|
||||
);
|
||||
return upscaled.map((c) => this.gameMap.ref(c.x, c.y));
|
||||
}
|
||||
}
|
||||
|
||||
function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
// didn't find the start tile in the path
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
// found start tile but not at the start
|
||||
// remove all tiles before the start tile
|
||||
upscaled = upscaled.slice(srcIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const dstIndex = findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
// didn't find the dst tile in the path
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
// found dst tile but not at the end
|
||||
// remove all tiles after the dst tile
|
||||
upscaled = upscaled.slice(0, dstIndex + 1);
|
||||
}
|
||||
return upscaled;
|
||||
}
|
||||
|
||||
function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
// Scale up each point
|
||||
const scaledPath = path.map(
|
||||
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
|
||||
);
|
||||
|
||||
const smoothPath: Cell[] = [];
|
||||
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
// Add the current point
|
||||
smoothPath.push(current);
|
||||
|
||||
// Always interpolate between scaled points
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
|
||||
// Calculate number of steps needed
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
// Add intermediate points
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push(
|
||||
new Cell(
|
||||
Math.round(current.x + (dx * step) / steps),
|
||||
Math.round(current.y + (dy * step) / steps),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last point
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
return smoothPath;
|
||||
}
|
||||
|
||||
function findCell(upscaled: Cell[], cellDst: Cell): number {
|
||||
for (let i = 0; i < upscaled.length; i++) {
|
||||
if (upscaled[i].x === cellDst.x && upscaled[i].y === cellDst.y) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PathFinder } from "./types";
|
||||
|
||||
export class AirPathFinder implements PathFinder<TileRef> {
|
||||
private seed: number;
|
||||
|
||||
constructor(private game: Game) {
|
||||
this.seed = game.ticks();
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
throw new Error("AirPathFinder does not support multiple start points");
|
||||
}
|
||||
|
||||
const random = new PseudoRandom(this.seed);
|
||||
const path: TileRef[] = [from];
|
||||
let current = from;
|
||||
|
||||
while (current !== to) {
|
||||
const next = this.computeNext(current, to, random);
|
||||
if (next === current) break; // Prevent infinite loop if something breaks
|
||||
current = next;
|
||||
path.push(current);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private computeNext(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
random: PseudoRandom,
|
||||
): TileRef {
|
||||
const x = this.game.x(from);
|
||||
const y = this.game.y(from);
|
||||
const dstX = this.game.x(to);
|
||||
const dstY = this.game.y(to);
|
||||
|
||||
if (x === dstX && y === dstY) {
|
||||
return to;
|
||||
}
|
||||
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
|
||||
|
||||
if (random.chance(ratio) && x !== dstX) {
|
||||
nextX += x < dstX ? 1 : -1;
|
||||
} else {
|
||||
nextY += y < dstY ? 1 : -1;
|
||||
}
|
||||
|
||||
return this.game.ref(nextX, nextY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { within } from "../Util";
|
||||
import { DistanceBasedBezierCurve } from "../utilities/Line";
|
||||
import { PathResult, PathStatus, SteppingPathFinder } from "./types";
|
||||
|
||||
export interface ParabolaOptions {
|
||||
increment?: number;
|
||||
distanceBasedHeight?: boolean;
|
||||
directionUp?: boolean;
|
||||
}
|
||||
|
||||
const PARABOLA_MIN_HEIGHT = 50;
|
||||
|
||||
export class ParabolaUniversalPathFinder
|
||||
implements SteppingPathFinder<TileRef>
|
||||
{
|
||||
private curve: DistanceBasedBezierCurve | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private options?: ParabolaOptions,
|
||||
) {}
|
||||
|
||||
private createCurve(from: TileRef, to: TileRef): DistanceBasedBezierCurve {
|
||||
const increment = this.options?.increment ?? 3;
|
||||
const distanceBasedHeight = this.options?.distanceBasedHeight ?? true;
|
||||
const directionUp = this.options?.directionUp ?? true;
|
||||
|
||||
const p0 = { x: this.gameMap.x(from), y: this.gameMap.y(from) };
|
||||
const p3 = { x: this.gameMap.x(to), y: this.gameMap.y(to) };
|
||||
const dx = p3.x - p0.x;
|
||||
const dy = p3.y - p0.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxHeight = distanceBasedHeight
|
||||
? Math.max(distance / 3, PARABOLA_MIN_HEIGHT)
|
||||
: 0;
|
||||
const heightMult = directionUp ? -1 : 1;
|
||||
const mapHeight = this.gameMap.height();
|
||||
|
||||
const p1 = {
|
||||
x: p0.x + dx / 4,
|
||||
y: within(p0.y + dy / 4 + heightMult * maxHeight, 0, mapHeight - 1),
|
||||
};
|
||||
const p2 = {
|
||||
x: p0.x + (dx * 3) / 4,
|
||||
y: within(p0.y + (dy * 3) / 4 + heightMult * maxHeight, 0, mapHeight - 1),
|
||||
};
|
||||
|
||||
return new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
throw new Error(
|
||||
"ParabolaUniversalPathFinder does not support multiple start points",
|
||||
);
|
||||
}
|
||||
const curve = this.createCurve(from, to);
|
||||
return curve
|
||||
.getAllPoints()
|
||||
.map((p) => this.gameMap.ref(Math.floor(p.x), Math.floor(p.y)));
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, speed?: number): PathResult<TileRef> {
|
||||
if (this.lastTo !== to) {
|
||||
this.curve = this.createCurve(from, to);
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
const nextPoint = this.curve!.increment(speed ?? 1);
|
||||
if (!nextPoint) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
const tile = this.gameMap.ref(
|
||||
Math.floor(nextPoint.x),
|
||||
Math.floor(nextPoint.y),
|
||||
);
|
||||
return { status: PathStatus.NEXT, node: tile };
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.curve = null;
|
||||
this.lastTo = null;
|
||||
}
|
||||
|
||||
currentIndex(): number {
|
||||
return this.curve?.getCurrentIndex() ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { StationManager } from "../game/RailNetworkImpl";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
import { AStar, AStarAdapter } from "./algorithms/AStar";
|
||||
import { PathFinder } from "./types";
|
||||
|
||||
export class StationPathFinder implements PathFinder<TrainStation> {
|
||||
private manager: StationManager;
|
||||
private aStar: AStar;
|
||||
|
||||
constructor(game: Game) {
|
||||
this.manager = game.railNetwork().stationManager();
|
||||
const adapter = new StationGraphAdapter(game, this.manager);
|
||||
this.aStar = new AStar({ adapter });
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: TrainStation | TrainStation[],
|
||||
to: TrainStation,
|
||||
): TrainStation[] | null {
|
||||
const toCluster = to.getCluster();
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const sameCluster = fromArray.filter((s) => s.getCluster() === toCluster);
|
||||
if (sameCluster.length === 0) return null;
|
||||
|
||||
const fromIds = sameCluster.map((s) => s.id);
|
||||
const path = this.aStar.findPath(fromIds, to.id);
|
||||
|
||||
if (!path) return null;
|
||||
return path.map((id) => this.manager.getById(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
class StationGraphAdapter implements AStarAdapter {
|
||||
constructor(
|
||||
private game: Game,
|
||||
private manager: StationManager,
|
||||
) {}
|
||||
|
||||
numNodes(): number {
|
||||
return this.manager.count();
|
||||
}
|
||||
|
||||
maxNeighbors(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
maxPriority(): number {
|
||||
return this.game.map().width() + this.game.map().height();
|
||||
}
|
||||
|
||||
neighbors(node: number, buffer: Int32Array): number {
|
||||
const station = this.manager.getById(node);
|
||||
if (!station) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (const n of station.neighbors()) {
|
||||
buffer[count++] = n.id;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
cost(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const a = this.manager.getById(node);
|
||||
const b = this.manager.getById(goal);
|
||||
if (!a || !b) return 0;
|
||||
|
||||
const ax = this.game.x(a.tile());
|
||||
const ay = this.game.y(a.tile());
|
||||
const bx = this.game.x(b.tile());
|
||||
const by = this.game.y(b.tile());
|
||||
return Math.abs(ax - bx) + Math.abs(ay - by);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,104 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
|
||||
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
import { AStarRail } from "./algorithms/AStar.Rail";
|
||||
import { AStarWater } from "./algorithms/AStar.Water";
|
||||
import { AirPathFinder } from "./PathFinder.Air";
|
||||
import {
|
||||
ParabolaOptions,
|
||||
ParabolaUniversalPathFinder,
|
||||
} from "./PathFinder.Parabola";
|
||||
import { StationPathFinder } from "./PathFinder.Station";
|
||||
import { PathFinderBuilder } from "./PathFinderBuilder";
|
||||
import { StepperConfig } from "./PathFinderStepper";
|
||||
import { BresenhamSmoothingTransformer } from "./smoothing/BresenhamPathSmoother";
|
||||
import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer";
|
||||
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
|
||||
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
|
||||
import { PathStatus, SteppingPathFinder } from "./types";
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
/**
|
||||
* Pathfinders that work with GameMap - usable in both simulation and UI layers
|
||||
*/
|
||||
export class UniversalPathFinding {
|
||||
static Parabola(
|
||||
gameMap: GameMap,
|
||||
options?: ParabolaOptions,
|
||||
): ParabolaUniversalPathFinder {
|
||||
return new ParabolaUniversalPathFinder(gameMap, options);
|
||||
}
|
||||
}
|
||||
|
||||
export type PathResult =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: TileRef }
|
||||
| { status: PathStatus.COMPLETE; node: TileRef }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
/**
|
||||
* Pathfinders that require Game - simulation layer only
|
||||
*/
|
||||
export class PathFinding {
|
||||
static Water(game: Game): SteppingPathFinder<TileRef> {
|
||||
const pf = game.miniWaterHPA();
|
||||
const graph = game.miniWaterGraph();
|
||||
|
||||
export interface PathFinder {
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult;
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null;
|
||||
}
|
||||
|
||||
export interface MiniAStarOptions {
|
||||
waterPath?: boolean;
|
||||
iterations?: number;
|
||||
maxTries?: number;
|
||||
}
|
||||
|
||||
export class PathFinders {
|
||||
static Water(game: Game): PathFinder {
|
||||
if (!game.navMesh()) {
|
||||
// Fall back to old water pathfinder if navmesh is not available
|
||||
return PathFinders.WaterLegacy(game);
|
||||
if (!pf || !graph || graph.nodeCount < 100) {
|
||||
return PathFinding.WaterSimple(game);
|
||||
}
|
||||
|
||||
return new NavMeshAdapter(game);
|
||||
const miniMap = game.miniMap();
|
||||
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
|
||||
.wrap((pf) => new BresenhamSmoothingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder {
|
||||
return new MiniAStarAdapter(game, options);
|
||||
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const pf = new AStarWater(miniMap);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static Rail(game: Game): SteppingPathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const pf = new AStarRail(miniMap);
|
||||
|
||||
return PathFinderBuilder.create(pf)
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
|
||||
static Stations(game: Game): SteppingPathFinder<TrainStation> {
|
||||
const pf = new StationPathFinder(game);
|
||||
|
||||
return PathFinderBuilder.create(pf).buildWithStepper({
|
||||
equals: (a, b) => a.id === b.id,
|
||||
distance: (a, b) => game.manhattanDist(a.tile(), b.tile()),
|
||||
});
|
||||
}
|
||||
|
||||
static Air(game: Game): SteppingPathFinder<TileRef> {
|
||||
const pf = new AirPathFinder(game);
|
||||
|
||||
return PathFinderBuilder.create(pf).buildWithStepper({
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
|
||||
return {
|
||||
equals: (a, b) => a === b,
|
||||
distance: (a, b) => game.manhattanDist(a, b),
|
||||
preCheck: (from, to) =>
|
||||
typeof from !== "number" ||
|
||||
typeof to !== "number" ||
|
||||
!game.isValidRef(from) ||
|
||||
!game.isValidRef(to)
|
||||
? { status: PathStatus.NOT_FOUND }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PathFinderStepper, StepperConfig } from "./PathFinderStepper";
|
||||
import { PathFinder, SteppingPathFinder } from "./types";
|
||||
|
||||
type WrapFactory<T> = (pf: PathFinder<T>) => PathFinder<T>;
|
||||
|
||||
/**
|
||||
* PathFinderBuilder - fluent builder for composing PathFinder transformers.
|
||||
*
|
||||
* Usage:
|
||||
* const finder = PathFinderBuilder.create(corePathFinder)
|
||||
* .wrap((pf) => new SomeTransformer(pf, deps))
|
||||
* .wrap((pf) => new AnotherTransformer(pf, deps))
|
||||
* .build();
|
||||
*/
|
||||
export class PathFinderBuilder<T> {
|
||||
private wrappers: WrapFactory<T>[] = [];
|
||||
|
||||
private constructor(private core: PathFinder<T>) {}
|
||||
|
||||
static create<T>(core: PathFinder<T>): PathFinderBuilder<T> {
|
||||
return new PathFinderBuilder(core);
|
||||
}
|
||||
|
||||
wrap(factory: WrapFactory<T>): this {
|
||||
this.wrappers.push(factory);
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): PathFinder<T> {
|
||||
return this.wrappers.reduce(
|
||||
(pf, wrapper) => wrapper(pf),
|
||||
this.core as PathFinder<T>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and wrap with PathFinderStepper for step-by-step traversal.
|
||||
*/
|
||||
buildWithStepper(config: StepperConfig<T>): SteppingPathFinder<T> {
|
||||
return new PathFinderStepper(this.build(), config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
SteppingPathFinder,
|
||||
} from "./types";
|
||||
|
||||
export interface StepperConfig<T> {
|
||||
equals: (a: T, b: T) => boolean;
|
||||
distance?: (a: T, b: T) => number;
|
||||
preCheck?: (from: T, to: T) => PathResult<T> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PathFinderStepper - wraps a PathFinder and provides step-by-step traversal
|
||||
*
|
||||
* Handles path caching, invalidation, and incremental movement.
|
||||
* Generic over any PathFinder<T> implementation.
|
||||
*/
|
||||
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
private path: T[] | null = null;
|
||||
private pathIndex = 0;
|
||||
private lastTo: T | null = null;
|
||||
|
||||
constructor(
|
||||
private finder: PathFinder<T>,
|
||||
private config: StepperConfig<T> = { equals: (a, b) => a === b },
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the next step on the path from `from` to `to`.
|
||||
* Returns PathResult with status and optional next node.
|
||||
*/
|
||||
next(from: T, to: T, dist?: number): PathResult<T> {
|
||||
// Domain-specific pre-check (validation, cluster, etc.)
|
||||
if (this.config.preCheck) {
|
||||
const result = this.config.preCheck(from, to);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (this.config.equals(from, to)) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
// Distance-based early exit
|
||||
if (dist !== undefined && dist > 0 && this.config.distance) {
|
||||
if (this.config.distance(from, to) <= dist) {
|
||||
return { status: PathStatus.COMPLETE, node: from };
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache if destination changed
|
||||
if (this.lastTo === null || !this.config.equals(this.lastTo, to)) {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
// Compute path if not cached
|
||||
if (this.path === null) {
|
||||
try {
|
||||
this.path = this.finder.findPath(from, to);
|
||||
} catch (err) {
|
||||
console.error("PathFinder threw an error during findPath", err);
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
this.pathIndex = 0;
|
||||
if (this.path.length > 0 && this.config.equals(this.path[0], from)) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) {
|
||||
this.invalidate();
|
||||
this.lastTo = to;
|
||||
return this.next(from, to, dist);
|
||||
}
|
||||
|
||||
// Check if we've reached the end
|
||||
if (this.pathIndex >= this.path.length) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
// Return next step
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = null;
|
||||
}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
if (this.config.preCheck) {
|
||||
const first = Array.isArray(from) ? from[0] : from;
|
||||
const result = this.config.preCheck(first, to);
|
||||
if (result?.status === PathStatus.NOT_FOUND) return null;
|
||||
}
|
||||
|
||||
return this.finder.findPath(from, to);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { within } from "../Util";
|
||||
import { DistanceBasedBezierCurve } from "../utilities/Line";
|
||||
import { AStar, AStarResult, PathFindResultType } from "./AStar";
|
||||
import { MiniAStar } from "./MiniAStar";
|
||||
|
||||
const parabolaMinHeight = 50;
|
||||
|
||||
export class ParabolaPathFinder {
|
||||
constructor(private mg: GameMap) {}
|
||||
private curve: DistanceBasedBezierCurve | undefined;
|
||||
|
||||
computeControlPoints(
|
||||
orig: TileRef,
|
||||
dst: TileRef,
|
||||
increment: number = 3,
|
||||
distanceBasedHeight = true,
|
||||
directionUp = true,
|
||||
) {
|
||||
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
|
||||
const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) };
|
||||
const dx = p3.x - p0.x;
|
||||
const dy = p3.y - p0.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxHeight = distanceBasedHeight
|
||||
? Math.max(distance / 3, parabolaMinHeight)
|
||||
: 0;
|
||||
// Use a bezier curve pointing up or down based on directionUp parameter
|
||||
const heightMultiplier = directionUp ? -1 : 1;
|
||||
const mapHeight = this.mg.height();
|
||||
const p1 = {
|
||||
x: p0.x + (p3.x - p0.x) / 4,
|
||||
y: within(
|
||||
p0.y + (p3.y - p0.y) / 4 + heightMultiplier * maxHeight,
|
||||
0,
|
||||
mapHeight - 1,
|
||||
),
|
||||
};
|
||||
const p2 = {
|
||||
x: p0.x + ((p3.x - p0.x) * 3) / 4,
|
||||
y: within(
|
||||
p0.y + ((p3.y - p0.y) * 3) / 4 + heightMultiplier * maxHeight,
|
||||
0,
|
||||
mapHeight - 1,
|
||||
),
|
||||
};
|
||||
|
||||
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
|
||||
}
|
||||
|
||||
nextTile(speed: number): TileRef | true {
|
||||
if (!this.curve) {
|
||||
throw new Error("ParabolaPathFinder not initialized");
|
||||
}
|
||||
const nextPoint = this.curve.increment(speed);
|
||||
if (!nextPoint) {
|
||||
return true;
|
||||
}
|
||||
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
|
||||
}
|
||||
|
||||
currentIndex(): number {
|
||||
if (!this.curve) {
|
||||
return 0;
|
||||
}
|
||||
return this.curve.getCurrentIndex();
|
||||
}
|
||||
|
||||
allTiles(): TileRef[] {
|
||||
if (!this.curve) {
|
||||
return [];
|
||||
}
|
||||
return this.curve
|
||||
.getAllPoints()
|
||||
.map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y)));
|
||||
}
|
||||
}
|
||||
|
||||
export class AirPathFinder {
|
||||
constructor(
|
||||
private mg: GameMap,
|
||||
private random: PseudoRandom,
|
||||
) {}
|
||||
|
||||
nextTile(tile: TileRef, dst: TileRef): TileRef | true {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const dstX = this.mg.x(dst);
|
||||
const dstY = this.mg.y(dst);
|
||||
|
||||
if (x === dstX && y === dstY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate next position
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
|
||||
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
|
||||
|
||||
if (this.random.chance(ratio) && x !== dstX) {
|
||||
if (x < dstX) nextX++;
|
||||
else if (x > dstX) nextX--;
|
||||
} else {
|
||||
if (y < dstY) nextY++;
|
||||
else if (y > dstY) nextY--;
|
||||
}
|
||||
if (nextX === x && nextY === y) {
|
||||
return true;
|
||||
}
|
||||
return this.mg.ref(nextX, nextY);
|
||||
}
|
||||
}
|
||||
|
||||
export class MiniPathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
private path: TileRef[] | null = null;
|
||||
private path_idx: number = 0;
|
||||
private aStar: AStar<TileRef>;
|
||||
private computeFinished = true;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private iterations: number,
|
||||
private waterPath: boolean,
|
||||
private maxTries: number,
|
||||
) {}
|
||||
|
||||
private createAStar(curr: TileRef, dst: TileRef): AStar<TileRef> {
|
||||
return new MiniAStar(
|
||||
this.game.map(),
|
||||
this.game.miniMap(),
|
||||
curr,
|
||||
dst,
|
||||
this.iterations,
|
||||
this.maxTries,
|
||||
this.waterPath,
|
||||
);
|
||||
}
|
||||
|
||||
nextTile(
|
||||
curr: TileRef | null,
|
||||
dst: TileRef | null,
|
||||
dist: number = 1,
|
||||
): AStarResult<TileRef> {
|
||||
if (curr === null) {
|
||||
console.error("curr is null");
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
}
|
||||
if (dst === null) {
|
||||
console.error("dst is null");
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
}
|
||||
|
||||
if (this.game.manhattanDist(curr, dst) < dist) {
|
||||
this.path = null;
|
||||
return { type: PathFindResultType.Completed, node: curr };
|
||||
}
|
||||
|
||||
if (this.computeFinished) {
|
||||
if (this.shouldRecompute(curr, dst)) {
|
||||
this.curr = curr;
|
||||
this.dst = dst;
|
||||
this.path = null;
|
||||
this.path_idx = 0;
|
||||
this.aStar = this.createAStar(curr, dst);
|
||||
this.computeFinished = false;
|
||||
return this.nextTile(curr, dst);
|
||||
} else {
|
||||
const tile = this.path?.[this.path_idx++];
|
||||
if (tile === undefined) {
|
||||
throw new Error("missing tile");
|
||||
}
|
||||
return { type: PathFindResultType.NextTile, node: tile };
|
||||
}
|
||||
}
|
||||
|
||||
switch (this.aStar.compute()) {
|
||||
case PathFindResultType.Completed:
|
||||
this.computeFinished = true;
|
||||
this.path = this.aStar.reconstructPath();
|
||||
|
||||
// exclude first tile
|
||||
this.path_idx = 1;
|
||||
|
||||
return this.nextTile(curr, dst);
|
||||
case PathFindResultType.Pending:
|
||||
return { type: PathFindResultType.Pending };
|
||||
case PathFindResultType.PathNotFound:
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
default:
|
||||
throw new Error("unexpected compute result");
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRecompute(curr: TileRef, dst: TileRef) {
|
||||
if (this.path === null || this.curr === null || this.dst === null) {
|
||||
return true;
|
||||
}
|
||||
const dist = this.game.manhattanDist(curr, dst);
|
||||
let tolerance = 10;
|
||||
if (dist > 50) {
|
||||
tolerance = 10;
|
||||
} else if (dist > 25) {
|
||||
tolerance = 5;
|
||||
} else {
|
||||
tolerance = 0;
|
||||
}
|
||||
if (this.game.manhattanDist(this.dst, dst) > tolerance) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import FastPriorityQueue from "fastpriorityqueue";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
|
||||
/**
|
||||
* Implement this interface with your graph to find paths with A*
|
||||
*/
|
||||
export interface GraphAdapter<NodeType> {
|
||||
neighbors(node: NodeType): NodeType[];
|
||||
cost(node: NodeType): number;
|
||||
position(node: NodeType): { x: number; y: number };
|
||||
isTraversable(from: NodeType, to: NodeType): boolean;
|
||||
}
|
||||
|
||||
export class SerialAStar<NodeType> implements AStar<NodeType> {
|
||||
private fwdOpenSet: FastPriorityQueue<{
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
private bwdOpenSet: FastPriorityQueue<{
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private fwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private bwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private fwdGScore = new Map<NodeType, number>();
|
||||
private bwdGScore = new Map<NodeType, number>();
|
||||
|
||||
private meetingPoint: NodeType | null = null;
|
||||
public completed = false;
|
||||
private sources: NodeType[];
|
||||
private closestSource: NodeType;
|
||||
|
||||
constructor(
|
||||
src: NodeType | NodeType[],
|
||||
private dst: NodeType,
|
||||
private iterations: number,
|
||||
private maxTries: number,
|
||||
private graph: GraphAdapter<NodeType>,
|
||||
private directionChangePenalty: number = 0,
|
||||
) {
|
||||
this.fwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
|
||||
this.bwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
|
||||
this.sources = Array.isArray(src) ? src : [src];
|
||||
this.closestSource = this.findClosestSource(dst);
|
||||
|
||||
// Initialize forward search with source point(s)
|
||||
this.sources.forEach((startPoint) => {
|
||||
this.fwdGScore.set(startPoint, 0);
|
||||
this.fwdOpenSet.add({
|
||||
tile: startPoint,
|
||||
fScore: this.heuristic(startPoint, dst),
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize backward search from destination
|
||||
this.bwdGScore.set(dst, 0);
|
||||
this.bwdOpenSet.add({
|
||||
tile: dst,
|
||||
fScore: this.heuristic(dst, this.findClosestSource(dst)),
|
||||
});
|
||||
}
|
||||
|
||||
private findClosestSource(tile: NodeType): NodeType {
|
||||
return this.sources.reduce((closest, source) =>
|
||||
this.heuristic(tile, source) < this.heuristic(tile, closest)
|
||||
? source
|
||||
: closest,
|
||||
);
|
||||
}
|
||||
|
||||
compute(): PathFindResultType {
|
||||
if (this.completed) return PathFindResultType.Completed;
|
||||
|
||||
this.maxTries -= 1;
|
||||
let iterations = this.iterations;
|
||||
|
||||
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
|
||||
iterations--;
|
||||
if (iterations <= 0) {
|
||||
if (this.maxTries <= 0) {
|
||||
return PathFindResultType.PathNotFound;
|
||||
}
|
||||
return PathFindResultType.Pending;
|
||||
}
|
||||
|
||||
// Process forward search
|
||||
const fwdCurrent = this.fwdOpenSet.poll()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.bwdGScore.has(fwdCurrent)) {
|
||||
this.meetingPoint = fwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
this.expandNode(fwdCurrent, true);
|
||||
|
||||
// Process backward search
|
||||
const bwdCurrent = this.bwdOpenSet.poll()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.fwdGScore.has(bwdCurrent)) {
|
||||
this.meetingPoint = bwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
this.expandNode(bwdCurrent, false);
|
||||
}
|
||||
|
||||
return this.completed
|
||||
? PathFindResultType.Completed
|
||||
: PathFindResultType.PathNotFound;
|
||||
}
|
||||
|
||||
private expandNode(current: NodeType, isForward: boolean) {
|
||||
for (const neighbor of this.graph.neighbors(current)) {
|
||||
if (
|
||||
neighbor !== (isForward ? this.dst : this.closestSource) &&
|
||||
!this.graph.isTraversable(current, neighbor)
|
||||
)
|
||||
continue;
|
||||
|
||||
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
|
||||
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
|
||||
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
|
||||
|
||||
const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor);
|
||||
let penalty = 0;
|
||||
// With a direction change penalty, the path will get as straight as possible
|
||||
if (this.directionChangePenalty > 0) {
|
||||
const prev = cameFrom.get(current);
|
||||
if (prev) {
|
||||
const prevDir = this.getDirection(prev, current);
|
||||
const newDir = this.getDirection(current, neighbor);
|
||||
if (prevDir !== newDir) {
|
||||
penalty = this.directionChangePenalty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalG = tentativeGScore + penalty;
|
||||
if (!gScore.has(neighbor) || totalG < gScore.get(neighbor)!) {
|
||||
cameFrom.set(neighbor, current);
|
||||
gScore.set(neighbor, totalG);
|
||||
const fScore =
|
||||
totalG +
|
||||
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
|
||||
openSet.add({ tile: neighbor, fScore: fScore });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private heuristic(a: NodeType, b: NodeType): number {
|
||||
const posA = this.graph.position(a);
|
||||
const posB = this.graph.position(b);
|
||||
return 2 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
|
||||
}
|
||||
|
||||
private getDirection(from: NodeType, to: NodeType): string {
|
||||
const fromPos = this.graph.position(from);
|
||||
const toPos = this.graph.position(to);
|
||||
const dx = toPos.x - fromPos.x;
|
||||
const dy = toPos.y - fromPos.y;
|
||||
return `${Math.sign(dx)},${Math.sign(dy)}`;
|
||||
}
|
||||
|
||||
public reconstructPath(): NodeType[] {
|
||||
if (!this.meetingPoint) return [];
|
||||
|
||||
// Reconstruct path from start to meeting point
|
||||
const fwdPath: NodeType[] = [this.meetingPoint];
|
||||
let current = this.meetingPoint;
|
||||
|
||||
while (this.fwdCameFrom.has(current)) {
|
||||
current = this.fwdCameFrom.get(current)!;
|
||||
fwdPath.unshift(current);
|
||||
}
|
||||
|
||||
// Reconstruct path from meeting point to goal
|
||||
current = this.meetingPoint;
|
||||
|
||||
while (this.bwdCameFrom.has(current)) {
|
||||
current = this.bwdCameFrom.get(current)!;
|
||||
fwdPath.push(current);
|
||||
}
|
||||
|
||||
return fwdPath;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PathFindResultType } from "../AStar";
|
||||
import {
|
||||
MiniAStarOptions,
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
} from "../PathFinder";
|
||||
import { MiniPathFinder } from "../PathFinding";
|
||||
|
||||
const DEFAULT_ITERATIONS = 10_000;
|
||||
const DEFAULT_MAX_TRIES = 100;
|
||||
|
||||
export class MiniAStarAdapter implements PathFinder {
|
||||
private miniPathFinder: MiniPathFinder;
|
||||
|
||||
constructor(game: Game, options?: MiniAStarOptions) {
|
||||
this.miniPathFinder = new MiniPathFinder(
|
||||
game,
|
||||
options?.iterations ?? DEFAULT_ITERATIONS,
|
||||
options?.waterPath ?? true,
|
||||
options?.maxTries ?? DEFAULT_MAX_TRIES,
|
||||
);
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
const result = this.miniPathFinder.nextTile(from, to, dist);
|
||||
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Pending:
|
||||
return { status: PathStatus.PENDING };
|
||||
case PathFindResultType.NextTile:
|
||||
return { status: PathStatus.NEXT, node: result.node };
|
||||
case PathFindResultType.Completed:
|
||||
return { status: PathStatus.COMPLETE, node: result.node };
|
||||
case PathFindResultType.PathNotFound:
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const path: TileRef[] = [from];
|
||||
let current = from;
|
||||
const maxSteps = 100_000;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
const result = this.next(current, to);
|
||||
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NEXT) {
|
||||
current = result.node;
|
||||
path.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { NavMesh } from "../navmesh/NavMesh";
|
||||
import { PathFinder, PathResult, PathStatus } from "../PathFinder";
|
||||
|
||||
export class NavMeshAdapter implements PathFinder {
|
||||
private navMesh: NavMesh;
|
||||
private pathIndex = 0;
|
||||
private path: TileRef[] | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
constructor(private game: Game) {
|
||||
const navMesh = game.navMesh();
|
||||
if (!navMesh) {
|
||||
throw new Error("NavMeshAdapter requires game.navMesh() to be available");
|
||||
}
|
||||
this.navMesh = navMesh;
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
if (typeof from !== "number" || typeof to !== "number") {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (!this.game.isValidRef(from) || !this.game.isValidRef(to)) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
if (dist !== undefined && dist > 0) {
|
||||
const distance = this.game.manhattanDist(from, to);
|
||||
|
||||
if (distance <= dist) {
|
||||
return { status: PathStatus.COMPLETE, node: from };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastTo !== to) {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute if deviated from planned path
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
if (this.pathIndex > 0 && from !== expectedPos) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pathIndex >= this.path.length) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
return this.navMesh.findPath(from, to);
|
||||
}
|
||||
|
||||
private cachePath(from: TileRef, to: TileRef): boolean {
|
||||
try {
|
||||
this.path = this.navMesh.findPath(from, to);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pathIndex = 0;
|
||||
|
||||
// Path starts with 'from', skip to next tile
|
||||
if (this.path.length > 0 && this.path[0] === from) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { PathFinder } from "../types";
|
||||
import { AbstractGraph } from "./AbstractGraph";
|
||||
import { BucketQueue, MinHeap, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
export interface AbstractGraphAStarConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
useMinHeap?: boolean; // Use MinHeap instead of BucketQueue (better for variable costs)
|
||||
}
|
||||
|
||||
export class AbstractGraphAStar implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Float32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly startNode: Int32Array; // tracks which start each node came from
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly graph: AbstractGraph;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(graph: AbstractGraph, config?: AbstractGraphAStarConfig) {
|
||||
this.graph = graph;
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 1;
|
||||
this.maxIterations = config?.maxIterations ?? 100_000;
|
||||
|
||||
const numNodes = graph.nodeCount;
|
||||
|
||||
this.closedStamp = new Uint32Array(numNodes);
|
||||
this.gScoreStamp = new Uint32Array(numNodes);
|
||||
this.gScore = new Float32Array(numNodes);
|
||||
this.cameFrom = new Int32Array(numNodes);
|
||||
this.startNode = new Int32Array(numNodes);
|
||||
|
||||
// For abstract graphs with variable costs, MinHeap may be better
|
||||
// BucketQueue is O(1) but requires integer priorities
|
||||
if (config?.useMinHeap) {
|
||||
this.queue = new MinHeap(numNodes);
|
||||
} else {
|
||||
// Estimate max priority: weight * (mapWidth + mapHeight)
|
||||
// Use cluster size * clusters as approximation
|
||||
const maxDist = graph.clusterSize * Math.max(graph.clustersX, 10) * 2;
|
||||
const maxF = this.heuristicWeight * maxDist;
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
if (Array.isArray(start)) {
|
||||
return this.findPathMultiSource(start, goal);
|
||||
}
|
||||
return this.findPathSingle(start, goal);
|
||||
}
|
||||
|
||||
private findPathSingle(startId: number, goalId: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const graph = this.graph;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
|
||||
// Get goal node for heuristic
|
||||
const goalNode = graph.getNode(goalId);
|
||||
if (!goalNode) return null;
|
||||
const goalX = goalNode.x;
|
||||
const goalY = goalNode.y;
|
||||
|
||||
// Get start node for initial heuristic
|
||||
const startNode = graph.getNode(startId);
|
||||
if (!startNode) return null;
|
||||
|
||||
// Initialize
|
||||
queue.clear();
|
||||
gScore[startId] = 0;
|
||||
gScoreStamp[startId] = stamp;
|
||||
cameFrom[startId] = -1;
|
||||
|
||||
const startH =
|
||||
weight * (Math.abs(startNode.x - goalX) + Math.abs(startNode.y - goalY));
|
||||
queue.push(startId, startH);
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goalId) {
|
||||
return this.buildPathFromGoal(goalId);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const edges = graph.getNodeEdges(current);
|
||||
|
||||
// Inline neighbor iteration
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const neighbor = graph.getOtherNode(edge, current);
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG = currentG + edge.cost;
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
|
||||
// Inline heuristic calculation
|
||||
const neighborNode = graph.getNode(neighbor);
|
||||
if (neighborNode) {
|
||||
const h =
|
||||
weight *
|
||||
(Math.abs(neighborNode.x - goalX) +
|
||||
Math.abs(neighborNode.y - goalY));
|
||||
queue.push(neighbor, tentativeG + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private findPathMultiSource(
|
||||
startIds: number[],
|
||||
goalId: number,
|
||||
): number[] | null {
|
||||
if (startIds.length === 0) return null;
|
||||
if (startIds.length === 1) return this.findPathSingle(startIds[0], goalId);
|
||||
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const graph = this.graph;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const startNode = this.startNode;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
|
||||
// Get goal node for heuristic
|
||||
const goalNode = graph.getNode(goalId);
|
||||
if (!goalNode) return null;
|
||||
const goalX = goalNode.x;
|
||||
const goalY = goalNode.y;
|
||||
|
||||
// Initialize all start nodes
|
||||
queue.clear();
|
||||
for (const startId of startIds) {
|
||||
const node = graph.getNode(startId);
|
||||
if (!node) continue;
|
||||
|
||||
gScore[startId] = 0;
|
||||
gScoreStamp[startId] = stamp;
|
||||
cameFrom[startId] = -1;
|
||||
startNode[startId] = startId; // each start is its own origin
|
||||
|
||||
const h = weight * (Math.abs(node.x - goalX) + Math.abs(node.y - goalY));
|
||||
queue.push(startId, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goalId) {
|
||||
return this.buildPathFromGoal(goalId);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const currentStart = startNode[current];
|
||||
const edges = graph.getNodeEdges(current);
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const neighbor = graph.getOtherNode(edge, current);
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG = currentG + edge.cost;
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
startNode[neighbor] = currentStart; // propagate origin
|
||||
|
||||
const neighborNode = graph.getNode(neighbor);
|
||||
if (neighborNode) {
|
||||
const h =
|
||||
weight *
|
||||
(Math.abs(neighborNode.x - goalX) +
|
||||
Math.abs(neighborNode.y - goalY));
|
||||
queue.push(neighbor, tentativeG + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPathFromGoal(goalId: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goalId;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue } from "./PriorityQueue";
|
||||
|
||||
const LAND_BIT = 7;
|
||||
|
||||
export interface BoundedAStarConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export interface SearchBounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export class AStarBounded implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: BucketQueue;
|
||||
private readonly terrain: Uint8Array;
|
||||
private readonly mapWidth: number;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(
|
||||
map: GameMap,
|
||||
maxSearchArea: number,
|
||||
config?: BoundedAStarConfig,
|
||||
) {
|
||||
this.terrain = (map as any).terrain as Uint8Array;
|
||||
this.mapWidth = map.width();
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 1;
|
||||
this.maxIterations = config?.maxIterations ?? 100_000;
|
||||
|
||||
this.closedStamp = new Uint32Array(maxSearchArea);
|
||||
this.gScoreStamp = new Uint32Array(maxSearchArea);
|
||||
this.gScore = new Uint32Array(maxSearchArea);
|
||||
this.cameFrom = new Int32Array(maxSearchArea);
|
||||
|
||||
const maxDim = Math.ceil(Math.sqrt(maxSearchArea));
|
||||
const maxF = this.heuristicWeight * maxDim * 2;
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
const goalX = goal % this.mapWidth;
|
||||
const goalY = (goal / this.mapWidth) | 0;
|
||||
|
||||
let minX = goalX;
|
||||
let maxX = goalX;
|
||||
let minY = goalY;
|
||||
let maxY = goalY;
|
||||
|
||||
for (const s of starts) {
|
||||
const sx = s % this.mapWidth;
|
||||
const sy = (s / this.mapWidth) | 0;
|
||||
minX = Math.min(minX, sx);
|
||||
maxX = Math.max(maxX, sx);
|
||||
minY = Math.min(minY, sy);
|
||||
maxY = Math.max(maxY, sy);
|
||||
}
|
||||
|
||||
return this.searchBounded(starts as TileRef[], goal as TileRef, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
}
|
||||
|
||||
searchBounded(
|
||||
start: TileRef | TileRef[],
|
||||
goal: TileRef,
|
||||
bounds: SearchBounds,
|
||||
): TileRef[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const mapWidth = this.mapWidth;
|
||||
const terrain = this.terrain;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
const landMask = 1 << LAND_BIT;
|
||||
|
||||
const { minX, maxX, minY, maxY } = bounds;
|
||||
const boundsWidth = maxX - minX + 1;
|
||||
const goalX = goal % mapWidth;
|
||||
const goalY = (goal / mapWidth) | 0;
|
||||
const boundsHeight = maxY - minY + 1;
|
||||
const numLocalNodes = boundsWidth * boundsHeight;
|
||||
|
||||
if (numLocalNodes > this.closedStamp.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toLocal = (tile: TileRef, clamp: boolean = false): number => {
|
||||
let x = tile % mapWidth;
|
||||
let y = (tile / mapWidth) | 0;
|
||||
if (clamp) {
|
||||
x = Math.max(minX, Math.min(maxX, x));
|
||||
y = Math.max(minY, Math.min(maxY, y));
|
||||
}
|
||||
return (y - minY) * boundsWidth + (x - minX);
|
||||
};
|
||||
|
||||
const toGlobal = (local: number): TileRef => {
|
||||
const localX = local % boundsWidth;
|
||||
const localY = (local / boundsWidth) | 0;
|
||||
return ((localY + minY) * mapWidth + (localX + minX)) as TileRef;
|
||||
};
|
||||
|
||||
const goalLocal = toLocal(goal, true);
|
||||
if (goalLocal < 0 || goalLocal >= numLocalNodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
const startLocal = toLocal(s, true);
|
||||
if (startLocal < 0 || startLocal >= numLocalNodes) {
|
||||
continue;
|
||||
}
|
||||
gScore[startLocal] = 0;
|
||||
gScoreStamp[startLocal] = stamp;
|
||||
cameFrom[startLocal] = -1;
|
||||
const sx = s % mapWidth;
|
||||
const sy = (s / mapWidth) | 0;
|
||||
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
|
||||
queue.push(startLocal, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentLocal = queue.pop();
|
||||
|
||||
if (closedStamp[currentLocal] === stamp) continue;
|
||||
closedStamp[currentLocal] = stamp;
|
||||
|
||||
if (currentLocal === goalLocal) {
|
||||
return this.buildPath(goalLocal, toGlobal, numLocalNodes);
|
||||
}
|
||||
|
||||
const currentG = gScore[currentLocal];
|
||||
const tentativeG = currentG + 1;
|
||||
|
||||
// Convert to global coords for neighbor calculation
|
||||
const current = toGlobal(currentLocal);
|
||||
const currentX = current % mapWidth;
|
||||
const currentY = (current / mapWidth) | 0;
|
||||
|
||||
if (currentY > minY) {
|
||||
const neighbor = current - mapWidth;
|
||||
const neighborLocal = currentLocal - boundsWidth;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - goalX) + Math.abs(currentY - 1 - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentY < maxY) {
|
||||
const neighbor = current + mapWidth;
|
||||
const neighborLocal = currentLocal + boundsWidth;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - goalX) + Math.abs(currentY + 1 - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX > minX) {
|
||||
const neighbor = current - 1;
|
||||
const neighborLocal = currentLocal - 1;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX - 1 - goalX) + Math.abs(currentY - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX < maxX) {
|
||||
const neighbor = current + 1;
|
||||
const neighborLocal = currentLocal + 1;
|
||||
if (
|
||||
closedStamp[neighborLocal] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighborLocal] !== stamp ||
|
||||
tentativeG < gScore[neighborLocal]
|
||||
) {
|
||||
cameFrom[neighborLocal] = currentLocal;
|
||||
gScore[neighborLocal] = tentativeG;
|
||||
gScoreStamp[neighborLocal] = stamp;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight *
|
||||
(Math.abs(currentX + 1 - goalX) + Math.abs(currentY - goalY));
|
||||
queue.push(neighborLocal, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(
|
||||
goalLocal: number,
|
||||
toGlobal: (local: number) => TileRef,
|
||||
maxPathLength: number,
|
||||
): TileRef[] {
|
||||
const path: TileRef[] = [];
|
||||
let current = goalLocal;
|
||||
|
||||
// Safety check to prevent infinite loops
|
||||
let iterations = 0;
|
||||
while (current !== -1 && iterations < maxPathLength) {
|
||||
path.push(toGlobal(current));
|
||||
current = this.cameFrom[current];
|
||||
iterations++;
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { GameMap } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { AStar, AStarAdapter } from "./AStar";
|
||||
|
||||
export class AStarRail implements PathFinder<number> {
|
||||
private readonly aStar: AStar;
|
||||
|
||||
constructor(gameMap: GameMap) {
|
||||
const adapter = new RailAdapter(gameMap);
|
||||
this.aStar = new AStar({ adapter });
|
||||
}
|
||||
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
return this.aStar.findPath(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal adapter
|
||||
class RailAdapter implements AStarAdapter {
|
||||
private readonly gameMap: GameMap;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly _numNodes: number;
|
||||
private readonly waterPenalty = 5;
|
||||
private readonly heuristicWeight = 2;
|
||||
private readonly directionChangePenalty = 3;
|
||||
|
||||
constructor(gameMap: GameMap) {
|
||||
this.gameMap = gameMap;
|
||||
this.width = gameMap.width();
|
||||
this.height = gameMap.height();
|
||||
this._numNodes = this.width * this.height;
|
||||
}
|
||||
|
||||
numNodes(): number {
|
||||
return this._numNodes;
|
||||
}
|
||||
|
||||
maxNeighbors(): number {
|
||||
return 4;
|
||||
}
|
||||
|
||||
maxPriority(): number {
|
||||
const maxCost = 1 + this.waterPenalty + this.directionChangePenalty;
|
||||
return this.heuristicWeight * (this.width + this.height) * maxCost;
|
||||
}
|
||||
|
||||
neighbors(node: number, buffer: Int32Array): number {
|
||||
let count = 0;
|
||||
const x = node % this.width;
|
||||
const fromShoreline = this.gameMap.isShoreline(node);
|
||||
|
||||
if (node >= this.width) {
|
||||
const n = node - this.width;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (node < this._numNodes - this.width) {
|
||||
const n = node + this.width;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (x !== 0) {
|
||||
const n = node - 1;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
if (x !== this.width - 1) {
|
||||
const n = node + 1;
|
||||
if (this.isTraversable(n, fromShoreline)) buffer[count++] = n;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private isTraversable(to: number, fromShoreline: boolean): boolean {
|
||||
const toWater = this.gameMap.isWater(to);
|
||||
if (!toWater) return true;
|
||||
return fromShoreline || this.gameMap.isShoreline(to);
|
||||
}
|
||||
|
||||
cost(from: number, to: number, prev?: number): number {
|
||||
const penalized = this.gameMap.isWater(to) || this.gameMap.isShoreline(to);
|
||||
let c = penalized ? 1 + this.waterPenalty : 1;
|
||||
|
||||
if (prev !== undefined) {
|
||||
const d1 = from - prev;
|
||||
const d2 = to - from;
|
||||
if (d1 !== d2) {
|
||||
c += this.directionChangePenalty;
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nx = node % this.width;
|
||||
const ny = (node / this.width) | 0;
|
||||
const gx = goal % this.width;
|
||||
const gy = (goal / this.width) | 0;
|
||||
return this.heuristicWeight * (Math.abs(nx - gx) + Math.abs(ny - gy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
const LAND_BIT = 7; // Bit 7 in terrain indicates land
|
||||
|
||||
export interface AStarWaterConfig {
|
||||
heuristicWeight?: number;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export class AStarWater implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly terrain: Uint8Array;
|
||||
private readonly width: number;
|
||||
private readonly numNodes: number;
|
||||
private readonly heuristicWeight: number;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(map: GameMap, config?: AStarWaterConfig) {
|
||||
this.terrain = (map as any).terrain as Uint8Array;
|
||||
this.width = map.width();
|
||||
this.numNodes = map.width() * map.height();
|
||||
this.heuristicWeight = config?.heuristicWeight ?? 15;
|
||||
this.maxIterations = config?.maxIterations ?? 1_000_000;
|
||||
|
||||
this.closedStamp = new Uint32Array(this.numNodes);
|
||||
this.gScoreStamp = new Uint32Array(this.numNodes);
|
||||
this.gScore = new Uint32Array(this.numNodes);
|
||||
this.cameFrom = new Int32Array(this.numNodes);
|
||||
|
||||
const maxF = this.heuristicWeight * (map.width() + map.height());
|
||||
this.queue = new BucketQueue(maxF);
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const width = this.width;
|
||||
const numNodes = this.numNodes;
|
||||
const terrain = this.terrain;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const weight = this.heuristicWeight;
|
||||
const landMask = 1 << LAND_BIT;
|
||||
|
||||
const goalX = goal % width;
|
||||
const goalY = (goal / width) | 0;
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
gScore[s] = 0;
|
||||
gScoreStamp[s] = stamp;
|
||||
cameFrom[s] = -1;
|
||||
const sx = s % width;
|
||||
const sy = (s / width) | 0;
|
||||
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
|
||||
queue.push(s, h);
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goal) {
|
||||
return this.buildPath(goal);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const tentativeG = currentG + 1;
|
||||
const currentX = current % width;
|
||||
|
||||
if (current >= width) {
|
||||
const neighbor = current - width;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const nx = neighbor % width;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current < numNodes - width) {
|
||||
const neighbor = current + width;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const nx = neighbor % width;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX !== 0) {
|
||||
const neighbor = current - 1;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(currentX - 1 - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX !== width - 1) {
|
||||
const neighbor = current + 1;
|
||||
if (
|
||||
closedStamp[neighbor] !== stamp &&
|
||||
(neighbor === goal || (terrain[neighbor] & landMask) === 0)
|
||||
) {
|
||||
if (
|
||||
gScoreStamp[neighbor] !== stamp ||
|
||||
tentativeG < gScore[neighbor]
|
||||
) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
const ny = (neighbor / width) | 0;
|
||||
const f =
|
||||
tentativeG +
|
||||
weight * (Math.abs(currentX + 1 - goalX) + Math.abs(ny - goalY));
|
||||
queue.push(neighbor, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(goal: number): TileRef[] {
|
||||
const path: TileRef[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current as TileRef);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { AbstractGraphAStar } from "./AStar.AbstractGraph";
|
||||
import { AStarBounded } from "./AStar.Bounded";
|
||||
import { AbstractGraph, AbstractNode } from "./AbstractGraph";
|
||||
import { BFSGrid } from "./BFS.Grid";
|
||||
import { LAND_MARKER } from "./ConnectedComponents";
|
||||
|
||||
type PathDebugInfo = {
|
||||
nodePath: TileRef[] | null;
|
||||
initialPath: TileRef[] | null;
|
||||
graph: {
|
||||
clusterSize: number;
|
||||
nodes: Array<{ id: number; tile: TileRef }>;
|
||||
edges: Array<{
|
||||
id: number;
|
||||
nodeA: number;
|
||||
nodeB: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
}>;
|
||||
};
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class AStarWaterHierarchical implements PathFinder<number> {
|
||||
private tileBFS: BFSGrid;
|
||||
private abstractAStar: AbstractGraphAStar;
|
||||
private localAStar: AStarBounded;
|
||||
private localAStarMultiCluster: AStarBounded;
|
||||
private sourceResolver: SourceResolver;
|
||||
|
||||
public debugInfo: PathDebugInfo | null = null;
|
||||
public debugMode: boolean = false;
|
||||
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private graph: AbstractGraph,
|
||||
private options: {
|
||||
cachePaths?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
// BFS for nearest node search
|
||||
this.tileBFS = new BFSGrid(map.width() * map.height());
|
||||
|
||||
const clusterSize = graph.clusterSize;
|
||||
|
||||
// AbstractGraphAStar for abstract graph routing
|
||||
this.abstractAStar = new AbstractGraphAStar(this.graph);
|
||||
|
||||
// BoundedAStar for cluster-bounded local pathfinding
|
||||
const maxLocalNodes = clusterSize * clusterSize;
|
||||
this.localAStar = new AStarBounded(map, maxLocalNodes);
|
||||
|
||||
// BoundedAStar for multi-cluster (3x3) local pathfinding
|
||||
const multiClusterSize = clusterSize * 3;
|
||||
const maxMultiClusterNodes = multiClusterSize * multiClusterSize;
|
||||
this.localAStarMultiCluster = new AStarBounded(map, maxMultiClusterNodes);
|
||||
|
||||
// SourceResolver for multi-source search
|
||||
this.sourceResolver = new SourceResolver(this.map, this.graph);
|
||||
}
|
||||
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
if (Array.isArray(from)) {
|
||||
return this.findPathMultiSource(from as TileRef[], to as TileRef);
|
||||
}
|
||||
|
||||
return this.findPathSingle(from as TileRef, to as TileRef, this.debugMode);
|
||||
}
|
||||
|
||||
private findPathMultiSource(
|
||||
sources: TileRef[],
|
||||
target: TileRef,
|
||||
): TileRef[] | null {
|
||||
// 1. Resolve target to abstract node
|
||||
const targetNode = this.sourceResolver.resolveTarget(target);
|
||||
if (!targetNode) return null;
|
||||
|
||||
// 2. Map sources → abstract nodes (cheap O(1) cluster lookup per source)
|
||||
const nodeToSource = this.sourceResolver.resolveSourcesToNodes(sources);
|
||||
if (nodeToSource.size === 0) return null;
|
||||
|
||||
// 3. Run multi-source A* on abstract graph
|
||||
const nodeIds = [...nodeToSource.keys()];
|
||||
const nodePath = this.abstractAStar.findPath(nodeIds, targetNode.id);
|
||||
if (!nodePath) return null;
|
||||
|
||||
// 4. Get winning source tile (nodePath[0] is winning start node)
|
||||
const winningSource = nodeToSource.get(nodePath[0])!;
|
||||
|
||||
// 5. Run full single-source from winner
|
||||
return this.findPathSingle(winningSource, target);
|
||||
}
|
||||
|
||||
findPathSingle(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
debug: boolean = false,
|
||||
): TileRef[] | null {
|
||||
if (debug) {
|
||||
const allEdges: Array<{
|
||||
id: number;
|
||||
nodeA: number;
|
||||
nodeB: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
}> = [];
|
||||
|
||||
for (let edgeId = 0; edgeId < this.graph.edgeCount; edgeId++) {
|
||||
const edge = this.graph.getEdge(edgeId);
|
||||
if (!edge) continue;
|
||||
|
||||
const nodeA = this.graph.getNode(edge.nodeA);
|
||||
const nodeB = this.graph.getNode(edge.nodeB);
|
||||
if (!nodeA || !nodeB) continue;
|
||||
|
||||
allEdges.push({
|
||||
id: edge.id,
|
||||
nodeA: edge.nodeA,
|
||||
nodeB: edge.nodeB,
|
||||
from: nodeA.tile,
|
||||
to: nodeB.tile,
|
||||
cost: edge.cost,
|
||||
});
|
||||
}
|
||||
|
||||
this.debugInfo = {
|
||||
nodePath: null,
|
||||
initialPath: null,
|
||||
graph: {
|
||||
clusterSize: this.graph.clusterSize,
|
||||
nodes: this.graph
|
||||
.getAllNodes()
|
||||
.map((node) => ({ id: node.id, tile: node.tile })),
|
||||
edges: allEdges,
|
||||
},
|
||||
timings: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dist = this.map.manhattanDist(from, to);
|
||||
|
||||
// Early exit for very short distances
|
||||
if (dist <= this.graph.clusterSize) {
|
||||
performance.mark("hpa:findPath:earlyExitLocalPath:start");
|
||||
const startX = this.map.x(from);
|
||||
const startY = this.map.y(from);
|
||||
const clusterX = Math.floor(startX / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(startY / this.graph.clusterSize);
|
||||
const localPath = this.findLocalPath(from, to, clusterX, clusterY, true);
|
||||
performance.mark("hpa:findPath:earlyExitLocalPath:end");
|
||||
const measure = performance.measure(
|
||||
"hpa:findPath:earlyExitLocalPath",
|
||||
"hpa:findPath:earlyExitLocalPath:start",
|
||||
"hpa:findPath:earlyExitLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
|
||||
this.debugInfo!.timings.total += measure.duration;
|
||||
}
|
||||
|
||||
if (localPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
|
||||
);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct path failed for dist=${dist}, falling back to abstract graph`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:findNodes:start");
|
||||
const startNode = this.findNearestNode(from);
|
||||
const endNode = this.findNearestNode(to);
|
||||
performance.mark("hpa:findPath:findNodes:end");
|
||||
const findNodesMeasure = performance.measure(
|
||||
"hpa:findPath:findNodes",
|
||||
"hpa:findPath:findNodes:start",
|
||||
"hpa:findPath:findNodes:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findNodes = findNodesMeasure.duration;
|
||||
this.debugInfo!.timings.total += findNodesMeasure.duration;
|
||||
}
|
||||
|
||||
if (!startNode) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find start node for (${this.map.x(from)}, ${this.map.y(from)})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!endNode) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find end node for (${this.map.x(to)}, ${this.map.y(to)})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startNode.id === endNode.id) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Start and end nodes are the same (ID=${startNode.id}), finding local path with multi-cluster search`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:sameNodeLocalPath:start");
|
||||
const clusterX = Math.floor(startNode.x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(startNode.y / this.graph.clusterSize);
|
||||
const path = this.findLocalPath(from, to, clusterX, clusterY, true);
|
||||
performance.mark("hpa:findPath:sameNodeLocalPath:end");
|
||||
const sameNodeMeasure = performance.measure(
|
||||
"hpa:findPath:sameNodeLocalPath",
|
||||
"hpa:findPath:sameNodeLocalPath:start",
|
||||
"hpa:findPath:sameNodeLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.sameNodeLocalPath = sameNodeMeasure.duration;
|
||||
this.debugInfo!.timings.total += sameNodeMeasure.duration;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
performance.mark("hpa:findPath:findAbstractPath:start");
|
||||
const nodePath = this.findAbstractPath(startNode.id, endNode.id);
|
||||
performance.mark("hpa:findPath:findAbstractPath:end");
|
||||
const findAbstractPathMeasure = performance.measure(
|
||||
"hpa:findPath:findAbstractPath",
|
||||
"hpa:findPath:findAbstractPath:start",
|
||||
"hpa:findPath:findAbstractPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findAbstractPath =
|
||||
findAbstractPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += findAbstractPathMeasure.duration;
|
||||
|
||||
this.debugInfo!.nodePath = nodePath
|
||||
? nodePath
|
||||
.map((nodeId) => {
|
||||
const node = this.graph.getNode(nodeId);
|
||||
return node ? node.tile : -1;
|
||||
})
|
||||
.filter((tile) => tile !== -1)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!nodePath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] No abstract path between nodes ${startNode.id} and ${endNode.id}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Abstract path found: ${nodePath.length} waypoints`);
|
||||
}
|
||||
|
||||
const initialPath: TileRef[] = [];
|
||||
|
||||
performance.mark("hpa:findPath:buildInitialPath:start");
|
||||
|
||||
// 1. Find path from start to first node
|
||||
const firstNode = this.graph.getNode(nodePath[0])!;
|
||||
const firstNodeTile = firstNode.tile;
|
||||
|
||||
const startX = this.map.x(from);
|
||||
const startY = this.map.y(from);
|
||||
const startClusterX = Math.floor(startX / this.graph.clusterSize);
|
||||
const startClusterY = Math.floor(startY / this.graph.clusterSize);
|
||||
const startSegment = this.findLocalPath(
|
||||
from,
|
||||
firstNodeTile,
|
||||
startClusterX,
|
||||
startClusterY,
|
||||
);
|
||||
|
||||
if (!startSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...startSegment);
|
||||
|
||||
// 2. Build path through abstract nodes
|
||||
for (let i = 0; i < nodePath.length - 1; i++) {
|
||||
const fromNodeId = nodePath[i];
|
||||
const toNodeId = nodePath[i + 1];
|
||||
|
||||
const edge = this.graph.getEdgeBetween(fromNodeId, toNodeId);
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromNode = this.graph.getNode(fromNodeId)!;
|
||||
const toNode = this.graph.getNode(toNodeId)!;
|
||||
const fromTile = fromNode.tile;
|
||||
const toTile = toNode.tile;
|
||||
|
||||
// Check path cache (stored on graph, shared across all instances)
|
||||
// Cache is direction-aware: A→B and B→A are cached separately
|
||||
if (this.options.cachePaths) {
|
||||
const cachedPath = this.graph.getCachedPath(edge.id, fromNodeId);
|
||||
if (cachedPath && cachedPath.length > 0) {
|
||||
// Path is cached for this exact direction, use as-is
|
||||
initialPath.push(...cachedPath.slice(1));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const segmentPath = this.findLocalPath(
|
||||
fromTile,
|
||||
toTile,
|
||||
edge.clusterX,
|
||||
edge.clusterY,
|
||||
);
|
||||
|
||||
if (!segmentPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...segmentPath.slice(1));
|
||||
|
||||
// Cache the path for this direction
|
||||
if (this.options.cachePaths) {
|
||||
this.graph.setCachedPath(edge.id, fromNodeId, segmentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find path from last node to end
|
||||
const lastNode = this.graph.getNode(nodePath[nodePath.length - 1])!;
|
||||
const lastNodeTile = lastNode.tile;
|
||||
|
||||
const endX = this.map.x(to);
|
||||
const endY = this.map.y(to);
|
||||
const endClusterX = Math.floor(endX / this.graph.clusterSize);
|
||||
const endClusterY = Math.floor(endY / this.graph.clusterSize);
|
||||
const endSegment = this.findLocalPath(
|
||||
lastNodeTile,
|
||||
to,
|
||||
endClusterX,
|
||||
endClusterY,
|
||||
);
|
||||
|
||||
if (!endSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...endSegment.slice(1));
|
||||
|
||||
performance.mark("hpa:findPath:buildInitialPath:end");
|
||||
const buildInitialPathMeasure = performance.measure(
|
||||
"hpa:findPath:buildInitialPath",
|
||||
"hpa:findPath:buildInitialPath:start",
|
||||
"hpa:findPath:buildInitialPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildInitialPath =
|
||||
buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.initialPath = initialPath;
|
||||
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
|
||||
}
|
||||
|
||||
// Smoothing moved to SmoothingTransformer - return raw path
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
private findNearestNode(tile: TileRef): AbstractNode | null {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
|
||||
const clusterX = Math.floor(x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(y / this.graph.clusterSize);
|
||||
|
||||
const clusterSize = this.graph.clusterSize;
|
||||
const minX = clusterX * clusterSize;
|
||||
const minY = clusterY * clusterSize;
|
||||
const maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1);
|
||||
const maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1);
|
||||
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster || cluster.nodeIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateNodes = cluster.nodeIds.map((id) => this.graph.getNode(id)!);
|
||||
const maxDistance = clusterSize * clusterSize;
|
||||
|
||||
return this.tileBFS.search(
|
||||
this.map.width(),
|
||||
this.map.height(),
|
||||
tile,
|
||||
maxDistance,
|
||||
(t: TileRef) => this.graph.getComponentId(t) !== LAND_MARKER,
|
||||
(t: TileRef, _dist: number) => {
|
||||
const tileX = this.map.x(t);
|
||||
const tileY = this.map.y(t);
|
||||
|
||||
for (const node of candidateNodes) {
|
||||
if (node.x === tileX && node.y === tileY) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findAbstractPath(
|
||||
fromNodeId: number,
|
||||
toNodeId: number,
|
||||
): number[] | null {
|
||||
return this.abstractAStar.findPath(fromNodeId, toNodeId);
|
||||
}
|
||||
|
||||
private findLocalPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
multiCluster: boolean = false,
|
||||
): TileRef[] | null {
|
||||
// Calculate cluster bounds
|
||||
const clusterSize = this.graph.clusterSize;
|
||||
|
||||
let minX: number;
|
||||
let minY: number;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
|
||||
if (multiCluster) {
|
||||
// 3×3 clusters centered on the starting cluster
|
||||
minX = Math.max(0, (clusterX - 1) * clusterSize);
|
||||
minY = Math.max(0, (clusterY - 1) * clusterSize);
|
||||
maxX = Math.min(this.map.width() - 1, (clusterX + 2) * clusterSize - 1);
|
||||
maxY = Math.min(this.map.height() - 1, (clusterY + 2) * clusterSize - 1);
|
||||
} else {
|
||||
minX = clusterX * clusterSize;
|
||||
minY = clusterY * clusterSize;
|
||||
maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1);
|
||||
maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1);
|
||||
}
|
||||
|
||||
// Choose the appropriate BoundedAStar based on search area
|
||||
const selectedAStar = multiCluster
|
||||
? this.localAStarMultiCluster
|
||||
: this.localAStar;
|
||||
|
||||
// Run BoundedAStar on bounded region - works directly on map coords
|
||||
const path = selectedAStar.searchBounded(from, to, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fix endpoints: BoundedAStar clamps tiles to bounds, but node tiles may be
|
||||
// just outside cluster bounds. Ensure path starts/ends at exact requested tiles.
|
||||
if (path[0] !== from) {
|
||||
path.unshift(from);
|
||||
}
|
||||
if (path[path.length - 1] !== to) {
|
||||
path.push(to);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for resolving tiles to abstract nodes
|
||||
// Assumes tiles are already water and component-filtered (by transformer pipeline)
|
||||
class SourceResolver {
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private graph: AbstractGraph,
|
||||
) {}
|
||||
|
||||
// Resolves target to its abstract node
|
||||
resolveTarget(target: TileRef): AbstractNode | null {
|
||||
return this.getClusterNode(target);
|
||||
}
|
||||
|
||||
// Maps sources → abstract nodes, returns Map<nodeId, sourceTile>
|
||||
resolveSourcesToNodes(sources: TileRef[]): Map<number, TileRef> {
|
||||
const nodeToSource = new Map<number, TileRef>();
|
||||
const nodeToDist = new Map<number, number>();
|
||||
|
||||
for (const source of sources) {
|
||||
const node = this.getClusterNode(source);
|
||||
if (node === null) continue;
|
||||
|
||||
const x = this.map.x(source);
|
||||
const y = this.map.y(source);
|
||||
const dist = Math.abs(node.x - x) + Math.abs(node.y - y);
|
||||
|
||||
// Keep closest source per node
|
||||
const prevDist = nodeToDist.get(node.id);
|
||||
if (prevDist === undefined || dist < prevDist) {
|
||||
nodeToSource.set(node.id, source);
|
||||
nodeToDist.set(node.id, dist);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeToSource;
|
||||
}
|
||||
|
||||
private getClusterNode(tile: TileRef): AbstractNode | null {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
const clusterX = Math.floor(x / this.graph.clusterSize);
|
||||
const clusterY = Math.floor(y / this.graph.clusterSize);
|
||||
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster || cluster.nodeIds.length === 0) return null;
|
||||
|
||||
// Return closest node to tile
|
||||
let bestNode: AbstractNode | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const nodeId of cluster.nodeIds) {
|
||||
const node = this.graph.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const dist = Math.abs(node.x - x) + Math.abs(node.y - y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
return bestNode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Generic A* implementation with adapter interface
|
||||
// See AStar.Rail.ts for adapter version where performance is not critical
|
||||
// See AStar.Water.ts for inlined version for performance-critical use
|
||||
|
||||
import { PathFinder } from "../types";
|
||||
import { BucketQueue, PriorityQueue } from "./PriorityQueue";
|
||||
|
||||
export interface AStarAdapter {
|
||||
// Important optimization: write to the buffer and return the count
|
||||
// You can do this and it will be much faster :)
|
||||
neighbors(node: number, buffer: Int32Array): number;
|
||||
|
||||
cost(from: number, to: number, prev?: number): number;
|
||||
heuristic(node: number, goal: number): number;
|
||||
numNodes(): number;
|
||||
maxPriority(): number;
|
||||
maxNeighbors(): number;
|
||||
}
|
||||
|
||||
export interface AStarConfig {
|
||||
adapter: AStarAdapter;
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export class AStar implements PathFinder<number> {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly closedStamp: Uint32Array;
|
||||
private readonly gScoreStamp: Uint32Array;
|
||||
private readonly gScore: Uint32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly queue: PriorityQueue;
|
||||
private readonly adapter: AStarAdapter;
|
||||
private readonly neighborBuffer: Int32Array;
|
||||
private readonly maxIterations: number;
|
||||
|
||||
constructor(config: AStarConfig) {
|
||||
this.adapter = config.adapter;
|
||||
this.maxIterations = config.maxIterations ?? 500_000;
|
||||
this.neighborBuffer = new Int32Array(this.adapter.maxNeighbors());
|
||||
this.closedStamp = new Uint32Array(this.adapter.numNodes());
|
||||
this.gScoreStamp = new Uint32Array(this.adapter.numNodes());
|
||||
this.gScore = new Uint32Array(this.adapter.numNodes());
|
||||
this.cameFrom = new Int32Array(this.adapter.numNodes());
|
||||
this.queue = new BucketQueue(this.adapter.maxPriority());
|
||||
}
|
||||
|
||||
findPath(start: number | number[], goal: number): number[] | null {
|
||||
this.stamp++;
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
const stamp = this.stamp;
|
||||
const adapter = this.adapter;
|
||||
const closedStamp = this.closedStamp;
|
||||
const gScoreStamp = this.gScoreStamp;
|
||||
const gScore = this.gScore;
|
||||
const cameFrom = this.cameFrom;
|
||||
const queue = this.queue;
|
||||
const buffer = this.neighborBuffer;
|
||||
|
||||
queue.clear();
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
for (const s of starts) {
|
||||
gScore[s] = 0;
|
||||
gScoreStamp[s] = stamp;
|
||||
cameFrom[s] = -1;
|
||||
queue.push(s, adapter.heuristic(s, goal));
|
||||
}
|
||||
|
||||
let iterations = this.maxIterations;
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
if (--iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = queue.pop();
|
||||
|
||||
if (closedStamp[current] === stamp) continue;
|
||||
closedStamp[current] = stamp;
|
||||
|
||||
if (current === goal) {
|
||||
return this.buildPath(goal);
|
||||
}
|
||||
|
||||
const currentG = gScore[current];
|
||||
const prev = cameFrom[current];
|
||||
const count = adapter.neighbors(current, buffer);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const neighbor = buffer[i];
|
||||
|
||||
if (closedStamp[neighbor] === stamp) continue;
|
||||
|
||||
const tentativeG =
|
||||
currentG +
|
||||
adapter.cost(current, neighbor, prev === -1 ? undefined : prev);
|
||||
|
||||
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
gScoreStamp[neighbor] = stamp;
|
||||
queue.push(neighbor, tentativeG + adapter.heuristic(neighbor, goal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPath(goal: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== -1) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { BFSGrid } from "./BFS.Grid";
|
||||
import { ConnectedComponents } from "./ConnectedComponents";
|
||||
|
||||
export interface AbstractNode {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tile: TileRef;
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface AbstractEdge {
|
||||
id: number;
|
||||
nodeA: number; // Lower node ID (canonical order: nodeA < nodeB)
|
||||
nodeB: number; // Higher node ID
|
||||
cost: number;
|
||||
clusterX: number;
|
||||
clusterY: number;
|
||||
}
|
||||
|
||||
export interface Cluster {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeIds: number[];
|
||||
}
|
||||
|
||||
export type BuildDebugInfo = {
|
||||
clusters: number | null;
|
||||
nodes: number | null;
|
||||
edges: number | null;
|
||||
actualBFSCalls: number | null;
|
||||
potentialBFSCalls: number | null;
|
||||
skippedByComponentFilter: number | null;
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class AbstractGraph {
|
||||
// Nodes (array indexed by id)
|
||||
private readonly _nodes: AbstractNode[] = [];
|
||||
|
||||
// Edges (bidirectional, stored once)
|
||||
private readonly _edges: AbstractEdge[] = [];
|
||||
private readonly _nodeEdgeIds: number[][] = []; // nodeId → edge IDs
|
||||
|
||||
// Clusters (array indexed by clusterKey)
|
||||
private readonly _clusters: Cluster[] = [];
|
||||
|
||||
// Path cache indexed by edge.id (shared across all users)
|
||||
private _pathCache: (TileRef[] | null)[] = [];
|
||||
|
||||
// Water components for componentId lookup
|
||||
private _waterComponents: ConnectedComponents | null = null;
|
||||
|
||||
constructor(
|
||||
readonly clusterSize: number,
|
||||
readonly clustersX: number,
|
||||
readonly clustersY: number,
|
||||
) {}
|
||||
|
||||
getNode(id: number): AbstractNode | undefined {
|
||||
return this._nodes[id];
|
||||
}
|
||||
|
||||
getAllNodes(): readonly AbstractNode[] {
|
||||
return this._nodes;
|
||||
}
|
||||
|
||||
get nodeCount(): number {
|
||||
return this._nodes.length;
|
||||
}
|
||||
|
||||
getEdge(id: number): AbstractEdge | undefined {
|
||||
return this._edges[id];
|
||||
}
|
||||
|
||||
getNodeEdges(nodeId: number): AbstractEdge[] {
|
||||
const edgeIds = this._nodeEdgeIds[nodeId];
|
||||
if (!edgeIds) return [];
|
||||
return edgeIds.map((id) => this._edges[id]);
|
||||
}
|
||||
|
||||
getEdgeBetween(nodeA: number, nodeB: number): AbstractEdge | undefined {
|
||||
const edgeIds = this._nodeEdgeIds[nodeA];
|
||||
if (!edgeIds) return undefined;
|
||||
|
||||
for (const edgeId of edgeIds) {
|
||||
const edge = this._edges[edgeId];
|
||||
if (edge.nodeA === nodeB || edge.nodeB === nodeB) {
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOtherNode(edge: AbstractEdge, nodeId: number): number {
|
||||
return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA;
|
||||
}
|
||||
|
||||
get edgeCount(): number {
|
||||
return this._edges.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached path for edge in specific direction
|
||||
* @param edgeId Edge ID
|
||||
* @param fromNodeId The starting node of the traversal (determines direction)
|
||||
*/
|
||||
getCachedPath(edgeId: number, fromNodeId: number): TileRef[] | null {
|
||||
const edge = this._edges[edgeId];
|
||||
if (!edge) return null;
|
||||
// Direction: 0 if traversing A→B, 1 if traversing B→A
|
||||
const direction = fromNodeId === edge.nodeA ? 0 : 1;
|
||||
const cacheIndex = edgeId * 2 + direction;
|
||||
return this._pathCache[cacheIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache path for edge in specific direction
|
||||
* @param edgeId Edge ID
|
||||
* @param fromNodeId The starting node of the traversal (determines direction)
|
||||
* @param path The path tiles
|
||||
*/
|
||||
setCachedPath(edgeId: number, fromNodeId: number, path: TileRef[]): void {
|
||||
const edge = this._edges[edgeId];
|
||||
if (!edge) return;
|
||||
// Direction: 0 if traversing A→B, 1 if traversing B→A
|
||||
const direction = fromNodeId === edge.nodeA ? 0 : 1;
|
||||
const cacheIndex = edgeId * 2 + direction;
|
||||
this._pathCache[cacheIndex] = path;
|
||||
}
|
||||
|
||||
_initPathCache(): void {
|
||||
// Double the cache size to store both directions
|
||||
this._pathCache = new Array(this._edges.length * 2).fill(null);
|
||||
}
|
||||
|
||||
setWaterComponents(wc: ConnectedComponents): void {
|
||||
this._waterComponents = wc;
|
||||
}
|
||||
|
||||
getComponentId(tile: TileRef): number {
|
||||
return this._waterComponents?.getComponentId(tile) ?? 0;
|
||||
}
|
||||
|
||||
getClusterKey(clusterX: number, clusterY: number): number {
|
||||
return clusterY * this.clustersX + clusterX;
|
||||
}
|
||||
|
||||
getCluster(clusterX: number, clusterY: number): Cluster | undefined {
|
||||
return this._clusters[this.getClusterKey(clusterX, clusterY)];
|
||||
}
|
||||
|
||||
getClusterNodes(clusterX: number, clusterY: number): AbstractNode[] {
|
||||
const cluster = this.getCluster(clusterX, clusterY);
|
||||
if (!cluster) return [];
|
||||
return cluster.nodeIds.map((id) => this._nodes[id]);
|
||||
}
|
||||
|
||||
getNearbyClusterNodes(clusterX: number, clusterY: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const cluster = this.getCluster(clusterX + dx, clusterY + dy);
|
||||
if (cluster) {
|
||||
for (const nodeId of cluster.nodeIds) {
|
||||
nodes.push(this._nodes[nodeId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_addNode(node: AbstractNode): void {
|
||||
this._nodes[node.id] = node;
|
||||
this._nodeEdgeIds[node.id] = [];
|
||||
}
|
||||
|
||||
_addEdge(edge: AbstractEdge): void {
|
||||
this._edges[edge.id] = edge;
|
||||
this._nodeEdgeIds[edge.nodeA].push(edge.id);
|
||||
this._nodeEdgeIds[edge.nodeB].push(edge.id);
|
||||
}
|
||||
|
||||
_setCluster(key: number, cluster: Cluster): void {
|
||||
this._clusters[key] = cluster;
|
||||
}
|
||||
|
||||
_addNodeToCluster(clusterKey: number, nodeId: number): void {
|
||||
if (!this._clusters[clusterKey]) {
|
||||
// This shouldn't happen if clusters are pre-created
|
||||
return;
|
||||
}
|
||||
|
||||
this._clusters[clusterKey].nodeIds.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
export class AbstractGraphBuilder {
|
||||
static readonly CLUSTER_SIZE = 32;
|
||||
|
||||
// Derived immutable state
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly clustersX: number;
|
||||
private readonly clustersY: number;
|
||||
private readonly tileBFS: BFSGrid;
|
||||
private readonly waterComponents: ConnectedComponents;
|
||||
|
||||
// Build state
|
||||
private graph!: AbstractGraph;
|
||||
private tileToNode = new Map<TileRef, AbstractNode>();
|
||||
private nextNodeId = 0;
|
||||
private nextEdgeId = 0;
|
||||
private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
|
||||
|
||||
public debugInfo: BuildDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly map: GameMap,
|
||||
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
|
||||
) {
|
||||
this.width = map.width();
|
||||
this.height = map.height();
|
||||
this.clustersX = Math.ceil(this.width / clusterSize);
|
||||
this.clustersY = Math.ceil(this.height / clusterSize);
|
||||
this.tileBFS = new BFSGrid(this.width * this.height);
|
||||
this.waterComponents = new ConnectedComponents(map);
|
||||
}
|
||||
|
||||
build(debug: boolean = false): AbstractGraph {
|
||||
performance.mark("abstractgraph:build:start");
|
||||
|
||||
this.graph = new AbstractGraph(
|
||||
this.clusterSize,
|
||||
this.clustersX,
|
||||
this.clustersY,
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Building abstract graph with cluster size ${this.clusterSize} (${this.clustersX}x${this.clustersY} clusters)`,
|
||||
);
|
||||
|
||||
this.debugInfo = {
|
||||
clusters: null,
|
||||
nodes: null,
|
||||
edges: null,
|
||||
actualBFSCalls: null,
|
||||
potentialBFSCalls: null,
|
||||
skippedByComponentFilter: null,
|
||||
timings: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize water components
|
||||
performance.mark("abstractgraph:build:water-component:start");
|
||||
this.waterComponents.initialize();
|
||||
performance.mark("abstractgraph:build:water-component:end");
|
||||
const wcMeasure = performance.measure(
|
||||
"abstractgraph:build:water-component",
|
||||
"abstractgraph:build:water-component:start",
|
||||
"abstractgraph:build:water-component:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Water Component Identification: ${wcMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-create all clusters
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
const key = this.graph.getClusterKey(cx, cy);
|
||||
this.graph._setCluster(key, { x: cx, y: cy, nodeIds: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Find nodes (gateways) at cluster boundaries
|
||||
performance.mark("abstractgraph:build:nodes:start");
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
this.processCluster(cx, cy);
|
||||
}
|
||||
}
|
||||
performance.mark("abstractgraph:build:nodes:end");
|
||||
const nodesMeasure = performance.measure(
|
||||
"abstractgraph:build:nodes",
|
||||
"abstractgraph:build:nodes:start",
|
||||
"abstractgraph:build:nodes:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Node identification: ${nodesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
this.debugInfo!.potentialBFSCalls = 0;
|
||||
this.debugInfo!.skippedByComponentFilter = 0;
|
||||
}
|
||||
|
||||
// Build edges between nodes in same cluster
|
||||
performance.mark("abstractgraph:build:edges:start");
|
||||
for (let cy = 0; cy < this.clustersY; cy++) {
|
||||
for (let cx = 0; cx < this.clustersX; cx++) {
|
||||
const cluster = this.graph.getCluster(cx, cy);
|
||||
if (!cluster || cluster.nodeIds.length === 0) continue;
|
||||
|
||||
if (debug) {
|
||||
const n = cluster.nodeIds.length;
|
||||
this.debugInfo!.potentialBFSCalls! += (n * (n - 1)) / 2;
|
||||
|
||||
// Count skipped by component filter
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const nodeI = this.graph.getNode(cluster.nodeIds[i])!;
|
||||
const nodeJ = this.graph.getNode(cluster.nodeIds[j])!;
|
||||
if (nodeI.componentId !== nodeJ.componentId) {
|
||||
this.debugInfo!.skippedByComponentFilter!++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.buildClusterConnections(cx, cy);
|
||||
}
|
||||
}
|
||||
performance.mark("abstractgraph:build:edges:end");
|
||||
const edgesMeasure = performance.measure(
|
||||
"abstractgraph:build:edges",
|
||||
"abstractgraph:build:edges:start",
|
||||
"abstractgraph:build:edges:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.actualBFSCalls =
|
||||
this.debugInfo!.potentialBFSCalls! -
|
||||
this.debugInfo!.skippedByComponentFilter!;
|
||||
|
||||
console.log(
|
||||
`[DEBUG] Edge identification: ${edgesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("abstractgraph:build:end");
|
||||
const totalMeasure = performance.measure(
|
||||
"abstractgraph:build:total",
|
||||
"abstractgraph:build:start",
|
||||
"abstractgraph:build:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Abstract graph built in ${totalMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`[DEBUG] Nodes: ${this.graph.nodeCount}`);
|
||||
console.log(`[DEBUG] Edges: ${this.graph.edgeCount}`);
|
||||
console.log(`[DEBUG] Clusters: ${this.clustersX * this.clustersY}`);
|
||||
|
||||
this.debugInfo!.clusters = this.clustersX * this.clustersY;
|
||||
this.debugInfo!.nodes = this.graph.nodeCount;
|
||||
this.debugInfo!.edges = this.graph.edgeCount;
|
||||
}
|
||||
|
||||
// Initialize path cache after all edges are built
|
||||
this.graph._initPathCache();
|
||||
|
||||
// Store water components for componentId lookups
|
||||
this.graph.setWaterComponents(this.waterComponents);
|
||||
|
||||
return this.graph;
|
||||
}
|
||||
|
||||
private getOrCreateNode(x: number, y: number): AbstractNode {
|
||||
const tile = this.map.ref(x, y);
|
||||
|
||||
const existing = this.tileToNode.get(tile);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const node: AbstractNode = {
|
||||
id: this.nextNodeId++,
|
||||
x,
|
||||
y,
|
||||
tile,
|
||||
componentId: this.waterComponents.getComponentId(tile),
|
||||
};
|
||||
|
||||
this.graph._addNode(node);
|
||||
this.tileToNode.set(tile, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private addNodeToCluster(
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
node: AbstractNode,
|
||||
): void {
|
||||
const cluster = this.graph.getCluster(clusterX, clusterY);
|
||||
if (!cluster) return;
|
||||
|
||||
// Check for duplicates (node at cluster corner can be found by both edge scans)
|
||||
if (!cluster.nodeIds.includes(node.id)) {
|
||||
cluster.nodeIds.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
private processCluster(cx: number, cy: number): void {
|
||||
const baseX = cx * this.clusterSize;
|
||||
const baseY = cy * this.clusterSize;
|
||||
|
||||
// Right edge (vertical boundary to next cluster)
|
||||
if (cx < this.clustersX - 1) {
|
||||
const edgeX = Math.min(baseX + this.clusterSize - 1, this.width - 1);
|
||||
const nodes = this.findNodesOnVerticalEdge(edgeX, baseY);
|
||||
|
||||
for (const node of nodes) {
|
||||
this.addNodeToCluster(cx, cy, node);
|
||||
this.addNodeToCluster(cx + 1, cy, node);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom edge (horizontal boundary to next cluster)
|
||||
if (cy < this.clustersY - 1) {
|
||||
const edgeY = Math.min(baseY + this.clusterSize - 1, this.height - 1);
|
||||
const nodes = this.findNodesOnHorizontalEdge(edgeY, baseX);
|
||||
|
||||
for (const node of nodes) {
|
||||
this.addNodeToCluster(cx, cy, node);
|
||||
this.addNodeToCluster(cx, cy + 1, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findNodesOnVerticalEdge(x: number, baseY: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
const maxY = Math.min(baseY + this.clusterSize, this.height);
|
||||
|
||||
let spanStart = -1;
|
||||
|
||||
const tryAddNode = (y: number) => {
|
||||
if (spanStart === -1) return;
|
||||
|
||||
const spanLength = y - spanStart;
|
||||
const midY = spanStart + Math.floor(spanLength / 2);
|
||||
spanStart = -1;
|
||||
|
||||
const node = this.getOrCreateNode(x, midY);
|
||||
nodes.push(node);
|
||||
};
|
||||
|
||||
for (let y = baseY; y < maxY; y++) {
|
||||
const tile = this.map.ref(x, y);
|
||||
const nextTile = x + 1 < this.map.width() ? this.map.ref(x + 1, y) : -1;
|
||||
const isEntrance =
|
||||
this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile);
|
||||
|
||||
if (isEntrance) {
|
||||
if (spanStart === -1) {
|
||||
spanStart = y;
|
||||
}
|
||||
} else {
|
||||
tryAddNode(y);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddNode(maxY);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private findNodesOnHorizontalEdge(y: number, baseX: number): AbstractNode[] {
|
||||
const nodes: AbstractNode[] = [];
|
||||
const maxX = Math.min(baseX + this.clusterSize, this.width);
|
||||
|
||||
let spanStart = -1;
|
||||
|
||||
const tryAddNode = (x: number) => {
|
||||
if (spanStart === -1) return;
|
||||
|
||||
const spanLength = x - spanStart;
|
||||
const midX = spanStart + Math.floor(spanLength / 2);
|
||||
spanStart = -1;
|
||||
|
||||
const node = this.getOrCreateNode(midX, y);
|
||||
nodes.push(node);
|
||||
};
|
||||
|
||||
for (let x = baseX; x < maxX; x++) {
|
||||
const tile = this.map.ref(x, y);
|
||||
const nextTile = y + 1 < this.map.height() ? this.map.ref(x, y + 1) : -1;
|
||||
const isEntrance =
|
||||
this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile);
|
||||
|
||||
if (isEntrance) {
|
||||
if (spanStart === -1) {
|
||||
spanStart = x;
|
||||
}
|
||||
} else {
|
||||
tryAddNode(x);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddNode(maxX);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private buildClusterConnections(cx: number, cy: number): void {
|
||||
const cluster = this.graph.getCluster(cx, cy);
|
||||
if (!cluster) return;
|
||||
|
||||
const nodeIds = cluster.nodeIds;
|
||||
const nodes = nodeIds.map((id) => this.graph.getNode(id)!);
|
||||
|
||||
// Calculate cluster bounds
|
||||
const clusterMinX = cx * this.clusterSize;
|
||||
const clusterMinY = cy * this.clusterSize;
|
||||
const clusterMaxX = Math.min(
|
||||
this.width - 1,
|
||||
clusterMinX + this.clusterSize - 1,
|
||||
);
|
||||
const clusterMaxY = Math.min(
|
||||
this.height - 1,
|
||||
clusterMinY + this.clusterSize - 1,
|
||||
);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const fromNode = nodes[i];
|
||||
|
||||
// Build list of target nodes (only those we haven't processed with this node)
|
||||
const targetNodes: AbstractNode[] = [];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
// Skip if nodes are in different water components
|
||||
if (nodes[i].componentId !== nodes[j].componentId) {
|
||||
continue;
|
||||
}
|
||||
targetNodes.push(nodes[j]);
|
||||
}
|
||||
|
||||
if (targetNodes.length === 0) continue;
|
||||
|
||||
// Single BFS to find all reachable target nodes
|
||||
const reachable = this.findAllReachableNodesInBounds(
|
||||
fromNode.tile,
|
||||
targetNodes,
|
||||
clusterMinX,
|
||||
clusterMaxX,
|
||||
clusterMinY,
|
||||
clusterMaxY,
|
||||
);
|
||||
|
||||
// Create edges for all reachable nodes
|
||||
for (const [targetId, cost] of reachable.entries()) {
|
||||
this.addOrUpdateEdge(fromNode.id, targetId, cost, cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update edge between two nodes.
|
||||
* Edges are bidirectional and stored once with canonical order (nodeA < nodeB).
|
||||
* If edge exists with higher cost, update it.
|
||||
*/
|
||||
private addOrUpdateEdge(
|
||||
nodeIdA: number,
|
||||
nodeIdB: number,
|
||||
cost: number,
|
||||
clusterX: number,
|
||||
clusterY: number,
|
||||
): void {
|
||||
// Canonical order: lower ID first
|
||||
const [lo, hi] =
|
||||
nodeIdA < nodeIdB ? [nodeIdA, nodeIdB] : [nodeIdB, nodeIdA];
|
||||
|
||||
// Check for existing edge
|
||||
let nodeMap = this.edgeBetween.get(lo);
|
||||
if (!nodeMap) {
|
||||
nodeMap = new Map();
|
||||
this.edgeBetween.set(lo, nodeMap);
|
||||
}
|
||||
|
||||
const existingEdge = nodeMap.get(hi);
|
||||
|
||||
if (existingEdge) {
|
||||
// Update if new cost is cheaper
|
||||
if (cost < existingEdge.cost) {
|
||||
existingEdge.cost = cost;
|
||||
existingEdge.clusterX = clusterX;
|
||||
existingEdge.clusterY = clusterY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new edge
|
||||
const edge: AbstractEdge = {
|
||||
id: this.nextEdgeId++,
|
||||
nodeA: lo,
|
||||
nodeB: hi,
|
||||
cost,
|
||||
clusterX,
|
||||
clusterY,
|
||||
};
|
||||
|
||||
nodeMap.set(hi, edge);
|
||||
this.graph._addEdge(edge);
|
||||
}
|
||||
|
||||
private findAllReachableNodesInBounds(
|
||||
from: TileRef,
|
||||
targetNodes: AbstractNode[],
|
||||
minX: number,
|
||||
maxX: number,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
): Map<number, number> {
|
||||
const fromX = this.map.x(from);
|
||||
const fromY = this.map.y(from);
|
||||
|
||||
// Create a map of tile positions to node IDs for fast lookup
|
||||
const tileToNodeId = new Map<TileRef, number>();
|
||||
let maxManhattanDist = 0;
|
||||
|
||||
for (const node of targetNodes) {
|
||||
tileToNodeId.set(node.tile, node.id);
|
||||
const dx = Math.abs(node.x - fromX);
|
||||
const dy = Math.abs(node.y - fromY);
|
||||
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
|
||||
}
|
||||
|
||||
const maxDistance = maxManhattanDist * 4; // Allow path deviation
|
||||
const reachable = new Map<number, number>();
|
||||
let foundCount = 0;
|
||||
|
||||
this.tileBFS.search(
|
||||
this.map.width(),
|
||||
this.map.height(),
|
||||
from,
|
||||
maxDistance,
|
||||
(tile: number) => this.map.isWater(tile),
|
||||
(tile: number, dist: number) => {
|
||||
const x = this.map.x(tile);
|
||||
const y = this.map.y(tile);
|
||||
|
||||
// Reject if outside of bounding box (except start/target)
|
||||
const isStartOrTarget = tile === from || tileToNodeId.has(tile);
|
||||
if (
|
||||
!isStartOrTarget &&
|
||||
(x < minX || x > maxX || y < minY || y > maxY)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this tile is one of our target nodes
|
||||
const nodeId = tileToNodeId.get(tile);
|
||||
|
||||
if (nodeId !== undefined) {
|
||||
reachable.set(nodeId, dist);
|
||||
foundCount++;
|
||||
|
||||
// Early exit if we've found all target nodes
|
||||
if (foundCount === targetNodes.length) {
|
||||
return dist; // Return to stop BFS
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return reachable;
|
||||
}
|
||||
}
|
||||
+33
-29
@@ -1,11 +1,7 @@
|
||||
export interface FastBFSAdapter<T> {
|
||||
visitor(node: number, dist: number): T | null | undefined;
|
||||
isValidNode(node: number): boolean;
|
||||
}
|
||||
|
||||
// Optimized BFS using stamp-based visited tracking and typed array queue
|
||||
export class FastBFS {
|
||||
// 4-direction grid BFS with stamp-based visited tracking
|
||||
export class BFSGrid {
|
||||
private stamp = 1;
|
||||
|
||||
private readonly visitedStamp: Uint32Array;
|
||||
private readonly queue: Int32Array;
|
||||
private readonly dist: Uint16Array;
|
||||
@@ -16,48 +12,57 @@ export class FastBFS {
|
||||
this.dist = new Uint16Array(numNodes);
|
||||
}
|
||||
|
||||
search<T>(
|
||||
/**
|
||||
* Grid BFS search with visitor pattern.
|
||||
* @param start - Starting node(s)
|
||||
* @param maxDistance - Maximum distance to search
|
||||
* @param isValidNode - Filter for traversable nodes
|
||||
* @param visitor - Called for each node:
|
||||
* - Returns R: Found target, return immediately
|
||||
* - Returns undefined: Valid node, explore neighbors
|
||||
* - Returns null: Reject node, don't explore neighbors
|
||||
*/
|
||||
search<R>(
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
start: number | number[],
|
||||
maxDistance: number,
|
||||
isValidNode: FastBFSAdapter<T>["isValidNode"],
|
||||
visitor: FastBFSAdapter<T>["visitor"],
|
||||
): T | null {
|
||||
isValidNode: (node: number) => boolean,
|
||||
visitor: (node: number, dist: number) => R | null | undefined,
|
||||
): R | null {
|
||||
const stamp = this.nextStamp();
|
||||
const lastRowStart = (height - 1) * width;
|
||||
const starts = typeof start === "number" ? [start] : start;
|
||||
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
this.visitedStamp[start] = stamp;
|
||||
this.dist[start] = 0;
|
||||
this.queue[tail++] = start;
|
||||
for (const s of starts) {
|
||||
this.visitedStamp[s] = stamp;
|
||||
this.dist[s] = 0;
|
||||
this.queue[tail++] = s;
|
||||
}
|
||||
|
||||
while (head < tail) {
|
||||
const node = this.queue[head++];
|
||||
const currentDist = this.dist[node];
|
||||
const dist = this.dist[node];
|
||||
|
||||
if (currentDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call visitor:
|
||||
// - Returns T: Found target, return immediately
|
||||
// - Returns null: Reject tile, don't explore neighbors
|
||||
// - Returns undefined: Valid tile, explore neighbors
|
||||
const result = visitor(node, currentDist);
|
||||
const result = visitor(node, dist);
|
||||
|
||||
if (result !== null && result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If visitor returned null, reject this tile and don't explore neighbors
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDist = currentDist + 1;
|
||||
const nextDist = dist + 1;
|
||||
|
||||
if (nextDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = node % width;
|
||||
|
||||
// North
|
||||
@@ -107,8 +112,7 @@ export class FastBFS {
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
if (this.stamp > 0xffffffff) {
|
||||
this.visitedStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Generic BFS implementation with adapter interface
|
||||
|
||||
export interface BFSAdapter<T> {
|
||||
neighbors(node: T): T[];
|
||||
}
|
||||
|
||||
export class BFS<T> {
|
||||
constructor(private adapter: BFSAdapter<T>) {}
|
||||
|
||||
/**
|
||||
* BFS search with visitor pattern.
|
||||
* @param start - Starting node(s)
|
||||
* @param maxDistance - Maximum distance to search (Infinity for unlimited)
|
||||
* @param visitor - Called for each node:
|
||||
* - Returns R: Found target, return immediately
|
||||
* - Returns undefined: Valid node, explore neighbors
|
||||
* - Returns null: Reject node, don't explore neighbors
|
||||
*/
|
||||
search<R>(
|
||||
start: T | T[],
|
||||
maxDistance: number,
|
||||
visitor: (node: T, dist: number) => R | null | undefined,
|
||||
): R | null {
|
||||
const visited = new Set<T>();
|
||||
const queue: { node: T; dist: number }[] = [];
|
||||
const starts = Array.isArray(start) ? start : [start];
|
||||
|
||||
for (const s of starts) {
|
||||
visited.add(s);
|
||||
queue.push({ node: s, dist: 0 });
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, dist } = queue.shift()!;
|
||||
|
||||
const result = visitor(node, dist);
|
||||
|
||||
if (result !== null && result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDist = dist + 1;
|
||||
|
||||
if (nextDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const neighbor of this.adapter.neighbors(node)) {
|
||||
if (visited.has(neighbor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(neighbor);
|
||||
queue.push({ node: neighbor, dist: nextDist });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -1,12 +1,14 @@
|
||||
// Connected Component Labeling using flood-fill
|
||||
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
|
||||
const LAND_MARKER = 0xff; // Must fit in Uint8Array
|
||||
export const LAND_MARKER = 0xff; // Must fit in Uint8Array
|
||||
|
||||
/**
|
||||
* Manages water component identification using flood-fill.
|
||||
* Pre-allocates buffers and provides explicit initialization.
|
||||
* Connected component labeling for grid-based maps.
|
||||
* Identifies isolated regions using scan-line flood-fill.
|
||||
*/
|
||||
export class WaterComponents {
|
||||
export class ConnectedComponents {
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly numTiles: number;
|
||||
@@ -0,0 +1,150 @@
|
||||
export interface PriorityQueue {
|
||||
push(node: number, priority: number): void;
|
||||
pop(): number;
|
||||
isEmpty(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
// Binary min-heap: O(log n) push/pop, works with any priority values
|
||||
export class MinHeap implements PriorityQueue {
|
||||
private heap: Int32Array;
|
||||
private priorities: Float32Array;
|
||||
private size = 0;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.heap = new Int32Array(capacity);
|
||||
this.priorities = new Float32Array(capacity);
|
||||
}
|
||||
|
||||
push(node: number, priority: number): void {
|
||||
let i = this.size++;
|
||||
this.heap[i] = node;
|
||||
this.priorities[i] = priority;
|
||||
|
||||
// Bubble up
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.priorities[parent] <= this.priorities[i]) break;
|
||||
// Swap
|
||||
const tmpNode = this.heap[parent];
|
||||
const tmpPri = this.priorities[parent];
|
||||
this.heap[parent] = this.heap[i];
|
||||
this.priorities[parent] = this.priorities[i];
|
||||
this.heap[i] = tmpNode;
|
||||
this.priorities[i] = tmpPri;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
const result = this.heap[0];
|
||||
this.size--;
|
||||
if (this.size > 0) {
|
||||
this.heap[0] = this.heap[this.size];
|
||||
this.priorities[0] = this.priorities[this.size];
|
||||
|
||||
// Bubble down
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = (i << 1) + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
|
||||
if (
|
||||
left < this.size &&
|
||||
this.priorities[left] < this.priorities[smallest]
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
if (
|
||||
right < this.size &&
|
||||
this.priorities[right] < this.priorities[smallest]
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
if (smallest === i) break;
|
||||
|
||||
// Swap
|
||||
const tmpNode = this.heap[smallest];
|
||||
const tmpPri = this.priorities[smallest];
|
||||
this.heap[smallest] = this.heap[i];
|
||||
this.priorities[smallest] = this.priorities[i];
|
||||
this.heap[i] = tmpNode;
|
||||
this.priorities[i] = tmpPri;
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket queue: O(1) push/pop when priorities are integers
|
||||
export class BucketQueue implements PriorityQueue {
|
||||
private buckets: Int32Array[];
|
||||
private bucketSizes: Int32Array;
|
||||
private minBucket: number;
|
||||
private maxBucket: number;
|
||||
private size: number;
|
||||
|
||||
constructor(maxPriority: number) {
|
||||
this.maxBucket = maxPriority + 1;
|
||||
this.buckets = new Array(this.maxBucket);
|
||||
this.bucketSizes = new Int32Array(this.maxBucket);
|
||||
this.minBucket = this.maxBucket;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
push(node: number, priority: number): void {
|
||||
const bucket = Math.min(priority | 0, this.maxBucket - 1);
|
||||
|
||||
if (!this.buckets[bucket]) {
|
||||
this.buckets[bucket] = new Int32Array(64);
|
||||
}
|
||||
|
||||
const size = this.bucketSizes[bucket];
|
||||
if (size >= this.buckets[bucket].length) {
|
||||
const newBucket = new Int32Array(this.buckets[bucket].length * 2);
|
||||
newBucket.set(this.buckets[bucket]);
|
||||
this.buckets[bucket] = newBucket;
|
||||
}
|
||||
|
||||
this.buckets[bucket][size] = node;
|
||||
this.bucketSizes[bucket]++;
|
||||
this.size++;
|
||||
|
||||
if (bucket < this.minBucket) {
|
||||
this.minBucket = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
while (this.minBucket < this.maxBucket) {
|
||||
const size = this.bucketSizes[this.minBucket];
|
||||
if (size > 0) {
|
||||
this.bucketSizes[this.minBucket]--;
|
||||
this.size--;
|
||||
return this.buckets[this.minBucket][size - 1];
|
||||
}
|
||||
this.minBucket++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.bucketSizes.fill(0);
|
||||
this.minBucket = this.maxBucket;
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// A* optimized for performance for small to medium graphs.
|
||||
// Works with node IDs represented as integers (0 to numNodes-1)
|
||||
|
||||
export interface FastAStarAdapter {
|
||||
getNeighbors(node: number): number[];
|
||||
getCost(from: number, to: number): number;
|
||||
heuristic(node: number, goal: number): number;
|
||||
}
|
||||
|
||||
// Simple binary min-heap for open set using typed arrays
|
||||
class MinHeap {
|
||||
private heap: Int32Array;
|
||||
private scores: Float32Array;
|
||||
private size = 0;
|
||||
|
||||
constructor(capacity: number, scores: Float32Array) {
|
||||
this.heap = new Int32Array(capacity);
|
||||
this.scores = scores;
|
||||
}
|
||||
|
||||
push(node: number): void {
|
||||
let i = this.size++;
|
||||
this.heap[i] = node;
|
||||
|
||||
// Bubble up
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.scores[this.heap[parent]] <= this.scores[this.heap[i]]) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[parent];
|
||||
this.heap[parent] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
const result = this.heap[0];
|
||||
this.heap[0] = this.heap[--this.size];
|
||||
|
||||
// Bubble down
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = (i << 1) + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
|
||||
if (
|
||||
left < this.size &&
|
||||
this.scores[this.heap[left]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
|
||||
if (
|
||||
right < this.size &&
|
||||
this.scores[this.heap[right]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
|
||||
if (smallest === i) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[smallest];
|
||||
this.heap[smallest] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = smallest;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class FastAStar {
|
||||
private stamp = 1;
|
||||
private readonly closedStamp: Uint32Array; // Tracks fully processed nodes
|
||||
private readonly gScoreStamp: Uint32Array; // Tracks valid gScores
|
||||
private readonly gScore: Float32Array;
|
||||
private readonly fScore: Float32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly openHeap: MinHeap;
|
||||
|
||||
constructor(numNodes: number) {
|
||||
this.closedStamp = new Uint32Array(numNodes);
|
||||
this.gScoreStamp = new Uint32Array(numNodes);
|
||||
this.gScore = new Float32Array(numNodes);
|
||||
this.fScore = new Float32Array(numNodes);
|
||||
this.cameFrom = new Int32Array(numNodes);
|
||||
this.openHeap = new MinHeap(numNodes, this.fScore);
|
||||
}
|
||||
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
|
||||
search(
|
||||
start: number,
|
||||
goal: number,
|
||||
adapter: FastAStarAdapter,
|
||||
maxIterations: number = 100000,
|
||||
): number[] | null {
|
||||
const stamp = this.nextStamp();
|
||||
|
||||
this.openHeap.clear();
|
||||
this.gScore[start] = 0;
|
||||
this.gScoreStamp[start] = stamp;
|
||||
this.fScore[start] = adapter.heuristic(start, goal);
|
||||
this.cameFrom[start] = -1;
|
||||
this.openHeap.push(start);
|
||||
|
||||
let iterations = 0;
|
||||
|
||||
while (!this.openHeap.isEmpty() && iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
const current = this.openHeap.pop();
|
||||
|
||||
// Skip if already processed (duplicate from heap)
|
||||
if (this.closedStamp[current] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
this.closedStamp[current] = stamp;
|
||||
|
||||
// Found goal
|
||||
if (current === goal) {
|
||||
return this.reconstructPath(start, goal);
|
||||
}
|
||||
|
||||
const neighbors = adapter.getNeighbors(current);
|
||||
const currentGScore = this.gScore[current];
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
// Skip already processed neighbors
|
||||
if (this.closedStamp[neighbor] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tentativeGScore =
|
||||
currentGScore + adapter.getCost(current, neighbor);
|
||||
|
||||
// If we haven't visited this neighbor yet, or found a better path
|
||||
const hasValidGScore = this.gScoreStamp[neighbor] === stamp;
|
||||
if (!hasValidGScore || tentativeGScore < this.gScore[neighbor]) {
|
||||
this.cameFrom[neighbor] = current;
|
||||
this.gScore[neighbor] = tentativeGScore;
|
||||
this.gScoreStamp[neighbor] = stamp;
|
||||
this.fScore[neighbor] =
|
||||
tentativeGScore + adapter.heuristic(neighbor, goal);
|
||||
|
||||
// Add to heap (allow duplicates for better paths)
|
||||
this.openHeap.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private reconstructPath(start: number, goal: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== start) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
|
||||
// Safety check
|
||||
if (current === -1) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
path.push(start);
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastAStarAdapter } from "./FastAStar";
|
||||
import { GatewayGraph } from "./GatewayGraph";
|
||||
|
||||
export class GatewayGraphAdapter implements FastAStarAdapter {
|
||||
constructor(private graph: GatewayGraph) {}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const edges = this.graph.getEdges(node);
|
||||
return edges.map((edge) => edge.to);
|
||||
}
|
||||
|
||||
getCost(from: number, to: number): number {
|
||||
const edges = this.graph.getEdges(from);
|
||||
const edge = edges.find((edge) => edge.to === to);
|
||||
return edge?.cost ?? 1;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeGw = this.graph.getGateway(node);
|
||||
const goalGw = this.graph.getGateway(goal);
|
||||
|
||||
if (!nodeGw || !goalGw) {
|
||||
throw new Error(
|
||||
`Invalid gateway ID in heuristic: node=${node} (${nodeGw ? "exists" : "missing"}), goal=${goal} (${goalGw ? "exists" : "missing"})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Manhattan distance heuristic
|
||||
const dx = Math.abs(nodeGw.x - goalGw.x);
|
||||
const dy = Math.abs(nodeGw.y - goalGw.y);
|
||||
return dx + dy;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedGameMapAdapter implements FastAStarAdapter {
|
||||
private readonly minX: number;
|
||||
private readonly minY: number;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly startTile: TileRef;
|
||||
private readonly goalTile: TileRef;
|
||||
|
||||
readonly numNodes: number;
|
||||
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
startTile: TileRef,
|
||||
goalTile: TileRef,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
) {
|
||||
this.startTile = startTile;
|
||||
this.goalTile = goalTile;
|
||||
|
||||
this.minX = bounds.minX;
|
||||
this.minY = bounds.minY;
|
||||
this.width = bounds.maxX - bounds.minX + 1;
|
||||
this.height = bounds.maxY - bounds.minY + 1;
|
||||
|
||||
this.numNodes = this.width * this.height;
|
||||
}
|
||||
|
||||
// Convert global TileRef to local node ID
|
||||
tileToNode(tile: TileRef): number {
|
||||
const x = this.map.x(tile) - this.minX;
|
||||
const y = this.map.y(tile) - this.minY;
|
||||
|
||||
// Allow start and goal tiles to be outside bounds (matching graph building behavior)
|
||||
const isOutsideBounds =
|
||||
x < 0 || x >= this.width || y < 0 || y >= this.height;
|
||||
const isStartOrGoal = tile === this.startTile || tile === this.goalTile;
|
||||
if (isOutsideBounds && !isStartOrGoal) {
|
||||
return -1; // Outside bounds
|
||||
}
|
||||
|
||||
// Clamp coordinates for start/goal tiles that are outside bounds
|
||||
const clampedX = Math.max(0, Math.min(this.width - 1, x));
|
||||
const clampedY = Math.max(0, Math.min(this.height - 1, y));
|
||||
|
||||
return clampedY * this.width + clampedX;
|
||||
}
|
||||
|
||||
// Convert local node ID to global TileRef
|
||||
nodeToTile(node: number): TileRef {
|
||||
const localX = node % this.width;
|
||||
const localY = Math.floor(node / this.width);
|
||||
return this.map.ref(localX + this.minX, localY + this.minY);
|
||||
}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const tile = this.nodeToTile(node);
|
||||
const neighbors = this.map.neighbors(tile);
|
||||
const result: number[] = [];
|
||||
|
||||
for (const neighborTile of neighbors) {
|
||||
if (!this.map.isWater(neighborTile)) continue;
|
||||
|
||||
const neighborNode = this.tileToNode(neighborTile);
|
||||
if (neighborNode !== -1) {
|
||||
result.push(neighborNode);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCost(_from: number, _to: number): number {
|
||||
return 1; // Uniform cost for water tiles
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeTile = this.nodeToTile(node);
|
||||
const goalTile = this.nodeToTile(goal);
|
||||
|
||||
const dx = Math.abs(this.map.x(nodeTile) - this.map.x(goalTile));
|
||||
const dy = Math.abs(this.map.y(nodeTile) - this.map.y(goalTile));
|
||||
|
||||
return dx + dy; // Manhattan distance
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { WaterComponents } from "./WaterComponents";
|
||||
|
||||
export interface Gateway {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tile: TileRef;
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
from: number;
|
||||
to: number;
|
||||
cost: number;
|
||||
path?: TileRef[];
|
||||
sectorX: number;
|
||||
sectorY: number;
|
||||
}
|
||||
|
||||
export interface Sector {
|
||||
x: number;
|
||||
y: number;
|
||||
gateways: Gateway[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export type BuildDebugInfo = {
|
||||
sectors: number | null;
|
||||
gateways: number | null;
|
||||
edges: number | null;
|
||||
actualBFSCalls: number | null;
|
||||
potentialBFSCalls: number | null;
|
||||
skippedByComponentFilter: number | null;
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class GatewayGraph {
|
||||
constructor(
|
||||
readonly sectors: ReadonlyMap<number, Sector>,
|
||||
readonly gateways: ReadonlyMap<number, Gateway>,
|
||||
readonly edges: ReadonlyMap<number, Edge[]>,
|
||||
readonly sectorSize: number,
|
||||
readonly sectorsX: number,
|
||||
) {}
|
||||
|
||||
getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
getSector(sectorX: number, sectorY: number): Sector | undefined {
|
||||
return this.sectors.get(this.getSectorKey(sectorX, sectorY));
|
||||
}
|
||||
|
||||
getGateway(id: number): Gateway | undefined {
|
||||
return this.gateways.get(id);
|
||||
}
|
||||
|
||||
getEdges(gatewayId: number): Edge[] {
|
||||
return this.edges.get(gatewayId) ?? [];
|
||||
}
|
||||
|
||||
getNearbySectorGateways(sectorX: number, sectorY: number): Gateway[] {
|
||||
const nearby: Gateway[] = [];
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sector = this.getSector(sectorX + dx, sectorY + dy);
|
||||
if (sector) {
|
||||
nearby.push(...sector.gateways);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearby;
|
||||
}
|
||||
|
||||
getAllGateways(): Gateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayGraphBuilder {
|
||||
static readonly SECTOR_SIZE = 32;
|
||||
|
||||
// Derived immutable state
|
||||
private readonly miniMap: GameMap;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly sectorsX: number;
|
||||
private readonly sectorsY: number;
|
||||
private readonly fastBFS: FastBFS;
|
||||
private readonly waterComponents: WaterComponents;
|
||||
|
||||
// Mutable build state
|
||||
private sectors = new Map<number, Sector>();
|
||||
private gateways = new Map<number, Gateway>();
|
||||
private tileToGateway = new Map<TileRef, Gateway>();
|
||||
private edges = new Map<number, Edge[]>();
|
||||
private nextGatewayId = 0;
|
||||
|
||||
// Programatically accessible debug info
|
||||
public debugInfo: BuildDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly game: Game,
|
||||
private readonly sectorSize: number,
|
||||
) {
|
||||
this.miniMap = game.miniMap();
|
||||
this.width = this.miniMap.width();
|
||||
this.height = this.miniMap.height();
|
||||
this.sectorsX = Math.ceil(this.width / sectorSize);
|
||||
this.sectorsY = Math.ceil(this.height / sectorSize);
|
||||
this.fastBFS = new FastBFS(this.width * this.height);
|
||||
this.waterComponents = new WaterComponents(this.miniMap);
|
||||
}
|
||||
|
||||
build(debug: boolean): GatewayGraph {
|
||||
performance.mark("navsat:build:start");
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Building gateway graph with sector size ${this.sectorSize} (${this.sectorsX}x${this.sectorsY} sectors)`,
|
||||
);
|
||||
|
||||
this.debugInfo = {
|
||||
sectors: null,
|
||||
gateways: null,
|
||||
edges: null,
|
||||
actualBFSCalls: null,
|
||||
potentialBFSCalls: null,
|
||||
skippedByComponentFilter: null,
|
||||
timings: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize water components before building gateway graph
|
||||
performance.mark("navsat:build:water-component:start");
|
||||
this.waterComponents.initialize();
|
||||
performance.mark("navsat:build:water-component:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:build:water-component",
|
||||
"navsat:build:water-component:start",
|
||||
"navsat:build:water-component:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Water Component Identification: ${measure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:gateways:start");
|
||||
for (let sy = 0; sy < this.sectorsY; sy++) {
|
||||
for (let sx = 0; sx < this.sectorsX; sx++) {
|
||||
this.processSector(sx, sy);
|
||||
}
|
||||
}
|
||||
performance.mark("navsat:build:gateways:end");
|
||||
const gatewaysMeasure = performance.measure(
|
||||
"navsat:build:gateways",
|
||||
"navsat:build:gateways:start",
|
||||
"navsat:build:gateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway identification: ${gatewaysMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
this.debugInfo!.edges = 0;
|
||||
this.debugInfo!.potentialBFSCalls = 0;
|
||||
this.debugInfo!.skippedByComponentFilter = 0;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:start");
|
||||
for (const sector of this.sectors.values()) {
|
||||
const gws = sector.gateways;
|
||||
const numGateways = gws.length;
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.potentialBFSCalls! +=
|
||||
(numGateways * (numGateways - 1)) / 2;
|
||||
|
||||
for (let i = 0; i < gws.length; i++) {
|
||||
for (let j = i + 1; j < gws.length; j++) {
|
||||
if (gws[i].componentId !== gws[j].componentId) {
|
||||
this.debugInfo!.skippedByComponentFilter!++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.buildSectorConnections(sector);
|
||||
|
||||
if (debug) {
|
||||
// Divide by 2 because bidirectional
|
||||
this.debugInfo!.edges! += sector.edges.length / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.actualBFSCalls =
|
||||
this.debugInfo!.potentialBFSCalls! -
|
||||
this.debugInfo!.skippedByComponentFilter!;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:end");
|
||||
const edgesMeasure = performance.measure(
|
||||
"navsat:build:edges",
|
||||
"navsat:build:edges:start",
|
||||
"navsat:build:edges:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Edges Identification: ${edgesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Edges Found: ${this.debugInfo!.edges} (${((this.debugInfo!.edges! / this.debugInfo!.actualBFSCalls!) * 100).toFixed(1)}% success rate)`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:end");
|
||||
const totalMeasure = performance.measure(
|
||||
"navsat:build:total",
|
||||
"navsat:build:start",
|
||||
"navsat:build:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway graph built in ${totalMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`[DEBUG] Gateways: ${this.gateways.size}`);
|
||||
console.log(`[DEBUG] Sectors: ${this.sectors.size}`);
|
||||
}
|
||||
|
||||
return new GatewayGraph(
|
||||
this.sectors,
|
||||
this.gateways,
|
||||
this.edges,
|
||||
this.sectorSize,
|
||||
this.sectorsX,
|
||||
);
|
||||
}
|
||||
|
||||
private getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
private getOrCreateGateway(x: number, y: number): Gateway {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
|
||||
// O(1) lookup using tile reference
|
||||
const existing = this.tileToGateway.get(tile);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const gateway: Gateway = {
|
||||
id: this.nextGatewayId++,
|
||||
x: x,
|
||||
y: y,
|
||||
tile: tile,
|
||||
componentId: this.waterComponents.getComponentId(tile),
|
||||
};
|
||||
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
this.tileToGateway.set(tile, gateway);
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private addGatewayToSector(sector: Sector, gateway: Gateway): void {
|
||||
// Check for duplicates: a gateway at a sector corner can be
|
||||
// detected by both horizontal and vertical edge scans
|
||||
for (const existingGw of sector.gateways) {
|
||||
if (existingGw.x === gateway.x && existingGw.y === gateway.y) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway doesn't exist in sector yet, add it
|
||||
sector.gateways.push(gateway);
|
||||
}
|
||||
|
||||
private processSector(sx: number, sy: number): void {
|
||||
const sectorKey = this.getSectorKey(sx, sy);
|
||||
let sector = this.sectors.get(sectorKey);
|
||||
|
||||
if (!sector) {
|
||||
sector = { x: sx, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(sectorKey, sector);
|
||||
}
|
||||
|
||||
const baseX = sx * this.sectorSize;
|
||||
const baseY = sy * this.sectorSize;
|
||||
|
||||
if (sx < this.sectorsX - 1) {
|
||||
const edgeX = Math.min(baseX + this.sectorSize - 1, this.width - 1);
|
||||
const newGateways = this.findGatewaysOnVerticalEdge(edgeX, baseY);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const rightSectorKey = this.getSectorKey(sx + 1, sy);
|
||||
let rightSector = this.sectors.get(rightSectorKey);
|
||||
|
||||
if (!rightSector) {
|
||||
rightSector = { x: sx + 1, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(rightSectorKey, rightSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(rightSector, gateway);
|
||||
}
|
||||
}
|
||||
|
||||
if (sy < this.sectorsY - 1) {
|
||||
const edgeY = Math.min(baseY + this.sectorSize - 1, this.height - 1);
|
||||
const newGateways = this.findGatewaysOnHorizontalEdge(edgeY, baseX);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const bottomSectorKey = this.getSectorKey(sx, sy + 1);
|
||||
let bottomSector = this.sectors.get(bottomSectorKey);
|
||||
|
||||
if (!bottomSector) {
|
||||
bottomSector = { x: sx, y: sy + 1, gateways: [], edges: [] };
|
||||
this.sectors.set(bottomSectorKey, bottomSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(bottomSector, gateway);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findGatewaysOnVerticalEdge(x: number, baseY: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxY = Math.min(baseY + this.sectorSize, this.height);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (y: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = y - gatewayStart;
|
||||
const midY = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(x, midY);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let y = baseY; y < maxY; y++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
x + 1 < this.miniMap.width() ? this.miniMap.ref(x + 1, y) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = y;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(y);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxY);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private findGatewaysOnHorizontalEdge(y: number, baseX: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxX = Math.min(baseX + this.sectorSize, this.width);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (x: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = x - gatewayStart;
|
||||
const midX = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(midX, y);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let x = baseX; x < maxX; x++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
y + 1 < this.miniMap.height() ? this.miniMap.ref(x, y + 1) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = x;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(x);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxX);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private buildSectorConnections(sector: Sector): void {
|
||||
const gateways = sector.gateways;
|
||||
|
||||
// Calculate bounding box once for this sector
|
||||
const sectorMinX = sector.x * this.sectorSize;
|
||||
const sectorMinY = sector.y * this.sectorSize;
|
||||
const sectorMaxX = Math.min(
|
||||
this.width - 1,
|
||||
sectorMinX + this.sectorSize - 1,
|
||||
);
|
||||
const sectorMaxY = Math.min(
|
||||
this.height - 1,
|
||||
sectorMinY + this.sectorSize - 1,
|
||||
);
|
||||
|
||||
for (let i = 0; i < gateways.length; i++) {
|
||||
const fromGateway = gateways[i];
|
||||
|
||||
// Build list of target gateways (only those we haven't processed yet)
|
||||
const targetGateways: Gateway[] = [];
|
||||
for (let j = i + 1; j < gateways.length; j++) {
|
||||
// Skip if gateways are in different water components
|
||||
if (gateways[i].componentId !== gateways[j].componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targetGateways.push(gateways[j]);
|
||||
}
|
||||
|
||||
if (targetGateways.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single BFS to find all reachable target gateways
|
||||
const reachableGateways = this.findAllReachableGatewaysInBounds(
|
||||
fromGateway.tile,
|
||||
targetGateways,
|
||||
sectorMinX,
|
||||
sectorMaxX,
|
||||
sectorMinY,
|
||||
sectorMaxY,
|
||||
);
|
||||
|
||||
// Create edges for all reachable gateways
|
||||
for (const [targetId, cost] of reachableGateways.entries()) {
|
||||
if (!this.edges.has(fromGateway.id)) {
|
||||
this.edges.set(fromGateway.id, []);
|
||||
}
|
||||
|
||||
if (!this.edges.has(targetId)) {
|
||||
this.edges.set(targetId, []);
|
||||
}
|
||||
|
||||
// Check for existing edges - gateways may live in 2 sectors, keep only cheaper connection
|
||||
const existingEdgeFromI = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.find((e) => e.to === targetId);
|
||||
const existingEdgeFromJ = this.edges
|
||||
.get(targetId)!
|
||||
.find((e) => e.to === fromGateway.id);
|
||||
|
||||
// If edge doesn't exist or new cost is cheaper, update it
|
||||
if (!existingEdgeFromI || cost < existingEdgeFromI.cost) {
|
||||
const edge1: Edge = {
|
||||
from: fromGateway.id,
|
||||
to: targetId,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
const edge2: Edge = {
|
||||
from: targetId,
|
||||
to: fromGateway.id,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
// Add to sector edges for tracking
|
||||
sector.edges.push(edge1, edge2);
|
||||
|
||||
if (existingEdgeFromI) {
|
||||
const idx1 = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.indexOf(existingEdgeFromI);
|
||||
this.edges.get(fromGateway.id)![idx1] = edge1;
|
||||
|
||||
const idx2 = this.edges.get(targetId)!.indexOf(existingEdgeFromJ!);
|
||||
this.edges.get(targetId)![idx2] = edge2;
|
||||
} else {
|
||||
this.edges.get(fromGateway.id)!.push(edge1);
|
||||
this.edges.get(targetId)!.push(edge2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findAllReachableGatewaysInBounds(
|
||||
from: TileRef,
|
||||
targetGateways: Gateway[],
|
||||
minX: number,
|
||||
maxX: number,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
): Map<number, number> {
|
||||
const fromX = this.miniMap.x(from);
|
||||
const fromY = this.miniMap.y(from);
|
||||
|
||||
// Create a map of tile positions to gateway IDs for fast lookup
|
||||
const tileToGateway = new Map<TileRef, number>();
|
||||
let maxManhattanDist = 0;
|
||||
|
||||
for (const gateway of targetGateways) {
|
||||
tileToGateway.set(gateway.tile, gateway.id);
|
||||
const dx = Math.abs(gateway.x - fromX);
|
||||
const dy = Math.abs(gateway.y - fromY);
|
||||
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
|
||||
}
|
||||
|
||||
const maxDistance = maxManhattanDist * 4; // Allow path deviation
|
||||
const reachable = new Map<number, number>();
|
||||
let foundCount = 0;
|
||||
|
||||
this.fastBFS.search(
|
||||
this.miniMap.width(),
|
||||
this.miniMap.height(),
|
||||
from,
|
||||
maxDistance,
|
||||
(tile: number) => this.miniMap.isWater(tile),
|
||||
(tile: number, dist: number) => {
|
||||
const x = this.miniMap.x(tile);
|
||||
const y = this.miniMap.y(tile);
|
||||
|
||||
// Reject if outside of bounding box
|
||||
const isStartOrEnd = tile === from || tileToGateway.has(tile);
|
||||
if (!isStartOrEnd && (x < minX || x > maxX || y < minY || y > maxY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this tile is one of our target gateways
|
||||
const gatewayId = tileToGateway.get(tile);
|
||||
|
||||
if (gatewayId !== undefined) {
|
||||
reachable.set(gatewayId, dist);
|
||||
foundCount++;
|
||||
|
||||
// Early exit if we've found all target gateways
|
||||
if (foundCount === targetGateways.length) {
|
||||
return dist; // Return to stop BFS
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return reachable;
|
||||
}
|
||||
}
|
||||
@@ -1,819 +0,0 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { FastAStar } from "./FastAStar";
|
||||
import { BoundedGameMapAdapter, GatewayGraphAdapter } from "./FastAStarAdapter";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { Gateway, GatewayGraph, GatewayGraphBuilder } from "./GatewayGraph";
|
||||
|
||||
type PathDebugInfo = {
|
||||
gatewayPath: TileRef[] | null;
|
||||
initialPath: TileRef[] | null;
|
||||
smoothPath: TileRef[] | null;
|
||||
graph: {
|
||||
sectorSize: number;
|
||||
gateways: Array<{ id: number; tile: TileRef }>;
|
||||
edges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}>;
|
||||
};
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class NavMesh {
|
||||
private graph!: GatewayGraph;
|
||||
private initialized = false;
|
||||
private fastBFS!: FastBFS;
|
||||
private gatewayAStar!: FastAStar;
|
||||
private localAStar!: FastAStar;
|
||||
private localAStarMultiSector!: FastAStar;
|
||||
|
||||
public debugInfo: PathDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private options: {
|
||||
cachePaths?: boolean;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
initialize(debug: boolean = false) {
|
||||
const gatewayGraphBuilder = new GatewayGraphBuilder(
|
||||
this.game,
|
||||
GatewayGraphBuilder.SECTOR_SIZE,
|
||||
);
|
||||
this.graph = gatewayGraphBuilder.build(debug);
|
||||
|
||||
const miniMap = this.game.miniMap();
|
||||
this.fastBFS = new FastBFS(miniMap.width() * miniMap.height());
|
||||
|
||||
const gatewayCount = this.graph.getAllGateways().length;
|
||||
this.gatewayAStar = new FastAStar(gatewayCount);
|
||||
|
||||
// Fixed-size FastAStar for sector-bounded local pathfinding
|
||||
// Single sector: 32×32 = 1,024 nodes
|
||||
const sectorSize = GatewayGraphBuilder.SECTOR_SIZE;
|
||||
const maxLocalNodes = sectorSize * sectorSize; // 1,024 nodes
|
||||
this.localAStar = new FastAStar(maxLocalNodes);
|
||||
|
||||
// Multi-sector FastAStar for cross-sector pathfinding (same gateway, different sectors)
|
||||
// 3×3 sectors: 96×96 = 9,216 nodes
|
||||
const multiSectorSize = sectorSize * 3;
|
||||
const maxMultiSectorNodes = multiSectorSize * multiSectorSize;
|
||||
this.localAStarMultiSector = new FastAStar(maxMultiSectorNodes);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
debug: boolean = false,
|
||||
): TileRef[] | null {
|
||||
if (!this.initialized) {
|
||||
throw new Error(
|
||||
"NavMesh not initialized. Call initialize() before using findPath().",
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
// Collect all edges with their paths for visualization
|
||||
const allEdges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}> = [];
|
||||
|
||||
for (const [fromId, edges] of this.graph.edges.entries()) {
|
||||
const fromGw = this.graph.getGateway(fromId);
|
||||
if (!fromGw) continue;
|
||||
|
||||
for (const edge of edges) {
|
||||
const toGw = this.graph.getGateway(edge.to);
|
||||
if (!toGw) continue;
|
||||
|
||||
// Only add each edge once (not both directions)
|
||||
// Include self-loops (fromId === edge.to) for debugging
|
||||
if (fromId <= edge.to) {
|
||||
allEdges.push({
|
||||
fromId: fromId,
|
||||
toId: edge.to,
|
||||
from: fromGw.tile,
|
||||
to: toGw.tile,
|
||||
cost: edge.cost,
|
||||
path: edge.path ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.debugInfo = {
|
||||
gatewayPath: null,
|
||||
initialPath: null,
|
||||
smoothPath: null,
|
||||
graph: {
|
||||
sectorSize: this.graph.sectorSize,
|
||||
gateways: this.graph
|
||||
.getAllGateways()
|
||||
.map((gw) => ({ id: gw.id, tile: gw.tile })),
|
||||
edges: allEdges,
|
||||
},
|
||||
timings: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dist = this.game.manhattanDist(from, to);
|
||||
|
||||
// Early exit for very short distances that fit within multi-sector range
|
||||
if (dist <= this.graph.sectorSize) {
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:start");
|
||||
const map = this.game.map();
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const sectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const localPath = this.findLocalPath(
|
||||
from,
|
||||
to,
|
||||
sectorX,
|
||||
sectorY,
|
||||
2000,
|
||||
true,
|
||||
);
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:findPath:earlyExitLocalPath",
|
||||
"navsat:findPath:earlyExitLocalPath:start",
|
||||
"navsat:findPath:earlyExitLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
|
||||
this.debugInfo!.timings.total += measure.duration;
|
||||
}
|
||||
|
||||
if (localPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct path failed for dist=${dist}, falling back to gateway graph`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGateways:start");
|
||||
const startGateway = this.findNearestGateway(from);
|
||||
const endGateway = this.findNearestGateway(to);
|
||||
performance.mark("navsat:findPath:findGateways:end");
|
||||
const findGatewaysMeasure = performance.measure(
|
||||
"navsat:findPath:findGateways",
|
||||
"navsat:findPath:findGateways:start",
|
||||
"navsat:findPath:findGateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGateways = findGatewaysMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewaysMeasure.duration;
|
||||
}
|
||||
|
||||
if (!startGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find start gateway for (${this.game.x(from)}, ${this.game.y(from)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!endGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find end gateway for (${this.game.x(to)}, ${this.game.y(to)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startGateway.id === endGateway.id) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Start and end gateways are the same (ID=${startGateway.id}), finding local path with multi-sector search`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:start");
|
||||
const sectorX = Math.floor(startGateway.x / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startGateway.y / this.graph.sectorSize);
|
||||
const path = this.findLocalPath(from, to, sectorX, sectorY, 10000, true);
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:end");
|
||||
const sameGatewayMeasure = performance.measure(
|
||||
"navsat:findPath:sameGatewayLocalPath",
|
||||
"navsat:findPath:sameGatewayLocalPath:start",
|
||||
"navsat:findPath:sameGatewayLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.sameGatewayLocalPath =
|
||||
sameGatewayMeasure.duration;
|
||||
this.debugInfo!.timings.total += sameGatewayMeasure.duration;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGatewayPath:start");
|
||||
const gatewayPath = this.findGatewayPath(startGateway.id, endGateway.id);
|
||||
performance.mark("navsat:findPath:findGatewayPath:end");
|
||||
const findGatewayPathMeasure = performance.measure(
|
||||
"navsat:findPath:findGatewayPath",
|
||||
"navsat:findPath:findGatewayPath:start",
|
||||
"navsat:findPath:findGatewayPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGatewayPath = findGatewayPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewayPathMeasure.duration;
|
||||
|
||||
this.debugInfo!.gatewayPath = gatewayPath
|
||||
? gatewayPath
|
||||
.map((gwId) => {
|
||||
const gw = this.graph.getGateway(gwId);
|
||||
return gw ? gw.tile : -1;
|
||||
})
|
||||
.filter((tile) => tile !== -1)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!gatewayPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] No gateway path between gateways ${startGateway.id} and ${endGateway.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway path found: ${gatewayPath.length} waypoints`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialPath: TileRef[] = [];
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:start");
|
||||
|
||||
// 1. Find path from start to first gateway
|
||||
const firstGateway = this.graph.getGateway(gatewayPath[0])!;
|
||||
const firstGatewayTile = map.ref(
|
||||
miniMap.x(firstGateway.tile) * 2,
|
||||
miniMap.y(firstGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use start position's sector with multi-sector search (gateway may be on border)
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const startSectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const startSectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const startSegment = this.findLocalPath(
|
||||
from,
|
||||
firstGatewayTile,
|
||||
startSectorX,
|
||||
startSectorY,
|
||||
);
|
||||
|
||||
if (!startSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...startSegment);
|
||||
|
||||
// 2. Build path through gateways
|
||||
for (let i = 0; i < gatewayPath.length - 1; i++) {
|
||||
const fromGwId = gatewayPath[i];
|
||||
const toGwId = gatewayPath[i + 1];
|
||||
|
||||
const edges = this.graph.getEdges(fromGwId);
|
||||
const edge = edges.find((edge) => edge.to === toGwId);
|
||||
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (edge.path) {
|
||||
// Use cached path if available
|
||||
initialPath.push(...edge.path.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromGw = this.graph.getGateway(fromGwId)!;
|
||||
const toGw = this.graph.getGateway(toGwId)!;
|
||||
const fromTile = map.ref(
|
||||
miniMap.x(fromGw.tile) * 2,
|
||||
miniMap.y(fromGw.tile) * 2,
|
||||
);
|
||||
const toTile = map.ref(
|
||||
miniMap.x(toGw.tile) * 2,
|
||||
miniMap.y(toGw.tile) * 2,
|
||||
);
|
||||
|
||||
const segmentPath = this.findLocalPath(
|
||||
fromTile,
|
||||
toTile,
|
||||
edge.sectorX,
|
||||
edge.sectorY,
|
||||
);
|
||||
|
||||
if (!segmentPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...segmentPath.slice(1));
|
||||
|
||||
if (this.options.cachePaths) {
|
||||
// Cache the path for future reuse on both directional edges
|
||||
edge.path = segmentPath;
|
||||
|
||||
// Also cache the reversed path on the opposite direction edge
|
||||
const reverseEdges = this.graph.getEdges(toGwId);
|
||||
const reverseEdge = reverseEdges.find((e) => e.to === fromGwId);
|
||||
if (reverseEdge) {
|
||||
reverseEdge.path = segmentPath.slice().reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find path from last gateway to end
|
||||
const lastGateway = this.graph.getGateway(
|
||||
gatewayPath[gatewayPath.length - 1],
|
||||
)!;
|
||||
const lastGatewayTile = map.ref(
|
||||
miniMap.x(lastGateway.tile) * 2,
|
||||
miniMap.y(lastGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use end position's sector with multi-sector search (gateway may be on border)
|
||||
const endMiniX = Math.floor(map.x(to) / 2);
|
||||
const endMiniY = Math.floor(map.y(to) / 2);
|
||||
const endSectorX = Math.floor(endMiniX / this.graph.sectorSize);
|
||||
const endSectorY = Math.floor(endMiniY / this.graph.sectorSize);
|
||||
const endSegment = this.findLocalPath(
|
||||
lastGatewayTile,
|
||||
to,
|
||||
endSectorX,
|
||||
endSectorY,
|
||||
);
|
||||
|
||||
if (!endSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...endSegment.slice(1));
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:end");
|
||||
const buildInitialPathMeasure = performance.measure(
|
||||
"navsat:findPath:buildInitialPath",
|
||||
"navsat:findPath:buildInitialPath:start",
|
||||
"navsat:findPath:buildInitialPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildInitialPath =
|
||||
buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.initialPath = initialPath;
|
||||
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:smoothPath:start");
|
||||
const smoothedPath = this.smoothPath(initialPath);
|
||||
performance.mark("navsat:findPath:smoothPath:end");
|
||||
const smoothPathMeasure = performance.measure(
|
||||
"navsat:findPath:smoothPath",
|
||||
"navsat:findPath:smoothPath:start",
|
||||
"navsat:findPath:smoothPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildSmoothPath = smoothPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += smoothPathMeasure.duration;
|
||||
this.debugInfo!.smoothPath = smoothedPath;
|
||||
console.log(
|
||||
`[DEBUG] Smoothed path: ${initialPath.length} → ${smoothedPath.length} tiles`,
|
||||
);
|
||||
}
|
||||
|
||||
return smoothedPath;
|
||||
}
|
||||
|
||||
private findNearestGateway(tile: TileRef): Gateway | null {
|
||||
const map = this.game.map();
|
||||
const x = map.x(tile);
|
||||
const y = map.y(tile);
|
||||
|
||||
// Convert to miniMap coordinates
|
||||
const miniMap = this.game.miniMap();
|
||||
const miniX = Math.floor(x / 2);
|
||||
const miniY = Math.floor(y / 2);
|
||||
const miniFrom = miniMap.ref(miniX, miniY);
|
||||
|
||||
// Check gateways in the tile's own sector (using miniMap coordinates)
|
||||
const sectorX = Math.floor(miniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(miniY / this.graph.sectorSize);
|
||||
|
||||
// Calculate single sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
const minX = sectorX * sectorSize;
|
||||
const minY = sectorY * sectorSize;
|
||||
const maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
const maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
|
||||
// Get gateways from the tile's own sector only (includes border gateways)
|
||||
const sector = this.graph.getSector(sectorX, sectorY);
|
||||
|
||||
if (!sector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateGateways = sector.gateways;
|
||||
if (candidateGateways.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use BFS to find the nearest reachable gateway (by water path distance)
|
||||
// Search space is bounded by sector bounds, so maxDistance can be large
|
||||
const maxDistance = sectorSize * sectorSize;
|
||||
|
||||
return this.fastBFS.search(
|
||||
miniMap.width(),
|
||||
miniMap.height(),
|
||||
miniFrom,
|
||||
maxDistance,
|
||||
(tile: TileRef) => miniMap.isWater(tile),
|
||||
(tile: TileRef, _dist: number) => {
|
||||
const tileX = miniMap.x(tile);
|
||||
const tileY = miniMap.y(tile);
|
||||
|
||||
// Check if any candidate gateway is at this position first
|
||||
for (const gateway of candidateGateways) {
|
||||
if (gateway.x === tileX && gateway.y === tileY) {
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject non-gateway tiles outside the sector bounds
|
||||
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findGatewayPath(
|
||||
fromGatewayId: number,
|
||||
toGatewayId: number,
|
||||
): number[] | null {
|
||||
const adapter = new GatewayGraphAdapter(this.graph);
|
||||
return this.gatewayAStar.search(
|
||||
fromGatewayId,
|
||||
toGatewayId,
|
||||
adapter,
|
||||
100000,
|
||||
);
|
||||
}
|
||||
|
||||
private findLocalPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
sectorX: number,
|
||||
sectorY: number,
|
||||
maxIterations: number = 10000,
|
||||
multiSector: boolean = false,
|
||||
): TileRef[] | null {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert full map coordinates to miniMap coordinates
|
||||
const miniFrom = miniMap.ref(
|
||||
Math.floor(map.x(from) / 2),
|
||||
Math.floor(map.y(from) / 2),
|
||||
);
|
||||
|
||||
const miniTo = miniMap.ref(
|
||||
Math.floor(map.x(to) / 2),
|
||||
Math.floor(map.y(to) / 2),
|
||||
);
|
||||
|
||||
// Calculate sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
|
||||
let minX: number;
|
||||
let minY: number;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
|
||||
if (multiSector) {
|
||||
// 3×3 sectors centered on the starting sector
|
||||
minX = Math.max(0, (sectorX - 1) * sectorSize);
|
||||
minY = Math.max(0, (sectorY - 1) * sectorSize);
|
||||
maxX = Math.min(miniMap.width() - 1, (sectorX + 2) * sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, (sectorY + 2) * sectorSize - 1);
|
||||
} else {
|
||||
// Single sector
|
||||
minX = sectorX * sectorSize;
|
||||
minY = sectorY * sectorSize;
|
||||
maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
}
|
||||
|
||||
const adapter = new BoundedGameMapAdapter(miniMap, miniFrom, miniTo, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
|
||||
// Convert to local node IDs
|
||||
const startNode = adapter.tileToNode(miniFrom);
|
||||
const goalNode = adapter.tileToNode(miniTo);
|
||||
|
||||
if (startNode === -1 || goalNode === -1) {
|
||||
return null; // Start or goal outside bounds
|
||||
}
|
||||
|
||||
// Choose the appropriate FastAStar buffer based on search area
|
||||
const selectedAStar = multiSector
|
||||
? this.localAStarMultiSector
|
||||
: this.localAStar;
|
||||
|
||||
// Run FastAStar on bounded region
|
||||
const path = selectedAStar.search(
|
||||
startNode,
|
||||
goalNode,
|
||||
adapter,
|
||||
maxIterations,
|
||||
);
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert path from local node IDs back to miniMap TileRefs
|
||||
const miniPath = path.map((node: number) => adapter.nodeToTile(node));
|
||||
|
||||
// Upscale from miniMap to full map (same logic as MiniAStar)
|
||||
const result = this.upscalePathToFullMap(miniPath, from, to);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private upscalePathToFullMap(
|
||||
miniPath: TileRef[],
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
): TileRef[] {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert miniMap path to cells
|
||||
const miniCells = miniPath.map((tile) => ({
|
||||
x: miniMap.x(tile),
|
||||
y: miniMap.y(tile),
|
||||
}));
|
||||
|
||||
// FIRST: Scale all points (2x)
|
||||
const scaledPath = miniCells.map((point) => ({
|
||||
x: point.x * 2,
|
||||
y: point.y * 2,
|
||||
}));
|
||||
|
||||
// SECOND: Interpolate between scaled points
|
||||
const smoothPath: Array<{ x: number; y: number }> = [];
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
// Add the current point
|
||||
smoothPath.push(current);
|
||||
|
||||
// Calculate dx/dy from SCALED coordinates
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
// Add intermediate points
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push({
|
||||
x: Math.round(current.x + (dx * step) / steps),
|
||||
y: Math.round(current.y + (dy * step) / steps),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add last point
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
const scaledCells = smoothPath;
|
||||
|
||||
// Fix extremes to ensure exact start/end
|
||||
const fromCell = { x: map.x(from), y: map.y(from) };
|
||||
const toCell = { x: map.x(to), y: map.y(to) };
|
||||
|
||||
// Ensure start is correct
|
||||
const startIdx = scaledCells.findIndex(
|
||||
(c) => c.x === fromCell.x && c.y === fromCell.y,
|
||||
);
|
||||
if (startIdx === -1) {
|
||||
scaledCells.unshift(fromCell);
|
||||
} else if (startIdx !== 0) {
|
||||
scaledCells.splice(0, startIdx);
|
||||
}
|
||||
|
||||
// Ensure end is correct
|
||||
const endIdx = scaledCells.findIndex(
|
||||
(c) => c.x === toCell.x && c.y === toCell.y,
|
||||
);
|
||||
if (endIdx === -1) {
|
||||
scaledCells.push(toCell);
|
||||
} else if (endIdx !== scaledCells.length - 1) {
|
||||
scaledCells.splice(endIdx + 1);
|
||||
}
|
||||
|
||||
// Convert back to TileRefs
|
||||
return scaledCells.map((cell) => map.ref(cell.x, cell.y));
|
||||
}
|
||||
|
||||
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const x0 = this.game.x(from);
|
||||
const y0 = this.game.y(from);
|
||||
const x1 = this.game.x(to);
|
||||
const y1 = this.game.y(to);
|
||||
|
||||
const tiles: TileRef[] = [];
|
||||
|
||||
// Bresenham's line algorithm - trace and collect all tiles
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
// Safety limit to prevent excessive memory allocation
|
||||
const maxTiles = 100000;
|
||||
let iterations = 0;
|
||||
|
||||
while (true) {
|
||||
if (iterations++ > maxTiles) {
|
||||
return null; // Path too long
|
||||
}
|
||||
const tile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(tile)) {
|
||||
return null; // Path blocked
|
||||
}
|
||||
|
||||
tiles.push(tile);
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const e2 = 2 * err;
|
||||
const shouldMoveX = e2 > -dy;
|
||||
const shouldMoveY = e2 < dx;
|
||||
|
||||
if (shouldMoveX && shouldMoveY) {
|
||||
// Diagonal move - need to expand into two 4-directional moves
|
||||
// Try moving X first, then Y
|
||||
x += sx;
|
||||
err -= dy;
|
||||
|
||||
const intermediateTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(intermediateTile)) {
|
||||
// X first doesn't work, try Y first instead
|
||||
x -= sx; // undo
|
||||
err += dy; // undo
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
|
||||
const altTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(altTile)) {
|
||||
return null; // Neither direction works
|
||||
}
|
||||
tiles.push(altTile);
|
||||
|
||||
// Now move X
|
||||
x += sx;
|
||||
err -= dy;
|
||||
} else {
|
||||
tiles.push(intermediateTile);
|
||||
|
||||
// Now move Y
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
} else {
|
||||
// Single-axis move
|
||||
if (shouldMoveX) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
}
|
||||
|
||||
if (shouldMoveY) {
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private smoothPath(path: TileRef[]): TileRef[] {
|
||||
if (path.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const smoothed: TileRef[] = [];
|
||||
let current = 0;
|
||||
|
||||
while (current < path.length - 1) {
|
||||
// Look as far ahead as possible while maintaining line of sight
|
||||
let farthest = current + 1;
|
||||
let bestTrace: TileRef[] | null = null;
|
||||
|
||||
for (
|
||||
let i = current + 2;
|
||||
i < path.length;
|
||||
i += Math.max(1, Math.floor(path.length / 20))
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[i]);
|
||||
|
||||
if (trace !== null) {
|
||||
farthest = i;
|
||||
bestTrace = trace;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try the final tile if we haven't already
|
||||
if (
|
||||
farthest < path.length - 1 &&
|
||||
(path.length - 1 - current) % 10 !== 0
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[path.length - 1]);
|
||||
if (trace !== null) {
|
||||
farthest = path.length - 1;
|
||||
bestTrace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the traced path (or just current tile if no improvement)
|
||||
if (bestTrace !== null && farthest > current + 1) {
|
||||
// Add all tiles from the trace except the last one (to avoid duplication)
|
||||
smoothed.push(...bestTrace.slice(0, -1));
|
||||
} else {
|
||||
// No LOS improvement, just add current tile
|
||||
smoothed.push(path[current]);
|
||||
}
|
||||
|
||||
current = farthest;
|
||||
}
|
||||
|
||||
// Add the final tile
|
||||
smoothed.push(path[path.length - 1]);
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
import { PathSmoother } from "./PathSmoother";
|
||||
|
||||
/**
|
||||
* Path smoother using Bresenham line-of-sight algorithm.
|
||||
* Greedily skips waypoints when direct traversal is possible.
|
||||
*/
|
||||
export class BresenhamPathSmoother implements PathSmoother<TileRef> {
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
private isTraversable: (tile: TileRef) => boolean,
|
||||
) {}
|
||||
|
||||
smooth(path: TileRef[]): TileRef[] {
|
||||
if (path.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const smoothed: TileRef[] = [];
|
||||
let current = 0;
|
||||
|
||||
while (current < path.length - 1) {
|
||||
let farthest = current + 1;
|
||||
let bestTrace: TileRef[] | null = null;
|
||||
|
||||
for (
|
||||
let i = current + 2;
|
||||
i < path.length;
|
||||
i += Math.max(1, Math.floor(path.length / 20))
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[i]);
|
||||
|
||||
if (trace !== null) {
|
||||
farthest = i;
|
||||
bestTrace = trace;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
farthest < path.length - 1 &&
|
||||
(path.length - 1 - current) % 10 !== 0
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[path.length - 1]);
|
||||
if (trace !== null) {
|
||||
farthest = path.length - 1;
|
||||
bestTrace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestTrace !== null && farthest > current + 1) {
|
||||
smoothed.push(...bestTrace.slice(0, -1));
|
||||
} else {
|
||||
smoothed.push(path[current]);
|
||||
}
|
||||
|
||||
current = farthest;
|
||||
}
|
||||
|
||||
smoothed.push(path[path.length - 1]);
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const x0 = this.map.x(from);
|
||||
const y0 = this.map.y(from);
|
||||
const x1 = this.map.x(to);
|
||||
const y1 = this.map.y(to);
|
||||
|
||||
const tiles: TileRef[] = [];
|
||||
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
const maxTiles = 100000;
|
||||
let iterations = 0;
|
||||
|
||||
while (true) {
|
||||
if (iterations++ > maxTiles) {
|
||||
return null;
|
||||
}
|
||||
const tile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(tile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tiles.push(tile);
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const e2 = 2 * err;
|
||||
const shouldMoveX = e2 > -dy;
|
||||
const shouldMoveY = e2 < dx;
|
||||
|
||||
if (shouldMoveX && shouldMoveY) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
|
||||
const intermediateTile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(intermediateTile)) {
|
||||
x -= sx;
|
||||
err += dy;
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
|
||||
const altTile = this.map.ref(x, y);
|
||||
if (!this.isTraversable(altTile)) {
|
||||
return null;
|
||||
}
|
||||
tiles.push(altTile);
|
||||
|
||||
x += sx;
|
||||
err -= dy;
|
||||
} else {
|
||||
tiles.push(intermediateTile);
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
} else {
|
||||
if (shouldMoveX) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
}
|
||||
|
||||
if (shouldMoveY) {
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready-to-use transformer that applies Bresenham smoothing.
|
||||
* Defaults to water traversability.
|
||||
*/
|
||||
export class BresenhamSmoothingTransformer implements PathFinder<TileRef> {
|
||||
private smoother: BresenhamPathSmoother;
|
||||
|
||||
constructor(
|
||||
private inner: PathFinder<TileRef>,
|
||||
map: GameMap,
|
||||
isTraversable: (tile: TileRef) => boolean = (t) => map.isWater(t),
|
||||
) {
|
||||
this.smoother = new BresenhamPathSmoother(map, isTraversable);
|
||||
}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
const path = this.inner.findPath(from, to);
|
||||
return path ? this.smoother.smooth(path) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* PathSmoother - interface for path smoothing algorithms.
|
||||
* Takes a path and returns a smoothed version.
|
||||
*/
|
||||
export interface PathSmoother<T> {
|
||||
smooth(path: T[]): T[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PathFinder } from "../types";
|
||||
import { PathSmoother } from "./PathSmoother";
|
||||
|
||||
/**
|
||||
* Transformer that applies path smoothing to any PathFinder.
|
||||
* Wraps an inner PathFinder and smooths its output.
|
||||
*/
|
||||
export class SmoothingTransformer<T> implements PathFinder<T> {
|
||||
constructor(
|
||||
private inner: PathFinder<T>,
|
||||
private smoother: PathSmoother<T>,
|
||||
) {}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
const path = this.inner.findPath(from, to);
|
||||
return path ? this.smoother.smooth(path) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Game, Player, TerraNullius } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PathFinding } from "../PathFinder";
|
||||
|
||||
type Owner = Player | TerraNullius;
|
||||
|
||||
export class SpatialQuery {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
/**
|
||||
* Find nearest tile matching predicate using BFS traversal.
|
||||
* Uses Manhattan distance filter, ignores terrain barriers.
|
||||
*/
|
||||
private bfsNearest(
|
||||
from: TileRef,
|
||||
maxDist: number,
|
||||
predicate: (t: TileRef) => boolean,
|
||||
): TileRef | null {
|
||||
const map = this.game.map();
|
||||
const candidates: TileRef[] = [];
|
||||
|
||||
for (const tile of map.bfs(
|
||||
from,
|
||||
(_, t) => map.manhattanDist(from, t) <= maxDist,
|
||||
)) {
|
||||
if (predicate(tile)) {
|
||||
candidates.push(tile);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Sort by Manhattan distance to find actual nearest
|
||||
candidates.sort(
|
||||
(a, b) => map.manhattanDist(from, a) - map.manhattanDist(from, b),
|
||||
);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest shore tile by land BFS.
|
||||
* Works for both players and terra nullius.
|
||||
*/
|
||||
closestShore(
|
||||
owner: Owner,
|
||||
tile: TileRef,
|
||||
maxDist: number = 50,
|
||||
): TileRef | null {
|
||||
const gm = this.game;
|
||||
const ownerId = owner.smallID();
|
||||
|
||||
const isValidTile = (t: TileRef) => {
|
||||
if (!gm.isShore(t) || !gm.isLand(t)) return false;
|
||||
const tOwner = gm.ownerID(t);
|
||||
return tOwner === ownerId;
|
||||
};
|
||||
|
||||
return this.bfsNearest(tile, maxDist, isValidTile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest shore tile by water pathfinding.
|
||||
* Returns null for terra nullius (no borderTiles).
|
||||
*/
|
||||
closestShoreByWater(owner: Owner, target: TileRef): TileRef | null {
|
||||
if (!owner.isPlayer()) return null;
|
||||
|
||||
const gm = this.game;
|
||||
const player = owner as Player;
|
||||
|
||||
// Target must be water or shore (land adjacent to water)
|
||||
if (!gm.isWater(target) && !gm.isShore(target)) return null;
|
||||
|
||||
const targetComponent = gm.getWaterComponent(target);
|
||||
if (targetComponent === null) return null;
|
||||
|
||||
const isValidTile = (t: TileRef) => {
|
||||
if (!gm.isShore(t) || !gm.isLand(t)) return false;
|
||||
const tComponent = gm.getWaterComponent(t);
|
||||
return tComponent === targetComponent;
|
||||
};
|
||||
|
||||
const shores = Array.from(player.borderTiles()).filter(isValidTile);
|
||||
if (shores.length === 0) return null;
|
||||
|
||||
const path = PathFinding.Water(gm).findPath(shores, target);
|
||||
return path?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Component check transformer - fail fast if src/dst in different components
|
||||
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
/**
|
||||
* Wraps a PathFinder to fail fast when source and destination
|
||||
* are in different components (e.g., disconnected water bodies).
|
||||
*
|
||||
* Avoids running expensive pathfinding when no path exists.
|
||||
*/
|
||||
export class ComponentCheckTransformer<T> implements PathFinder<T> {
|
||||
constructor(
|
||||
private inner: PathFinder<T>,
|
||||
private getComponent: (t: T) => number,
|
||||
) {}
|
||||
|
||||
findPath(from: T | T[], to: T): T[] | null {
|
||||
const toComponent = this.getComponent(to);
|
||||
|
||||
// Check all sources - at least one must match destination component
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const validSources = fromArray.filter(
|
||||
(f) => this.getComponent(f) === toComponent,
|
||||
);
|
||||
|
||||
if (validSources.length === 0) {
|
||||
return null; // No source in same component as destination
|
||||
}
|
||||
|
||||
// Delegate with only valid sources
|
||||
const delegateFrom =
|
||||
validSources.length === 1 ? validSources[0] : validSources;
|
||||
return this.inner.findPath(delegateFrom, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Cell } from "../../game/Game";
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
export class MiniMapTransformer implements PathFinder<number> {
|
||||
constructor(
|
||||
private inner: PathFinder<number>,
|
||||
private map: GameMap,
|
||||
private miniMap: GameMap,
|
||||
) {}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
// Convert game coords → minimap coords (supports multi-source)
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const miniFromArray = fromArray.map((f) =>
|
||||
this.miniMap.ref(
|
||||
Math.floor(this.map.x(f) / 2),
|
||||
Math.floor(this.map.y(f) / 2),
|
||||
),
|
||||
);
|
||||
const miniFrom =
|
||||
miniFromArray.length === 1 ? miniFromArray[0] : miniFromArray;
|
||||
|
||||
const miniTo = this.miniMap.ref(
|
||||
Math.floor(this.map.x(to) / 2),
|
||||
Math.floor(this.map.y(to) / 2),
|
||||
);
|
||||
|
||||
// Search on minimap
|
||||
const path = this.inner.findPath(miniFrom, miniTo);
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert minimap TileRefs → Cells
|
||||
const cellPath = path.map(
|
||||
(ref) => new Cell(this.miniMap.x(ref), this.miniMap.y(ref)),
|
||||
);
|
||||
|
||||
// For multi-source, find closest source to path start
|
||||
const upscaledPath = this.upscalePath(cellPath);
|
||||
let cellFrom: Cell | undefined;
|
||||
if (Array.isArray(from)) {
|
||||
if (upscaledPath.length > 0) {
|
||||
const pathStart = upscaledPath[0];
|
||||
let minDist = Infinity;
|
||||
for (const f of from) {
|
||||
const fx = this.map.x(f);
|
||||
const fy = this.map.y(f);
|
||||
const dist = Math.abs(fx - pathStart.x) + Math.abs(fy - pathStart.y);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
cellFrom = new Cell(fx, fy);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cellFrom = new Cell(this.map.x(from), this.map.y(from));
|
||||
}
|
||||
const cellTo = new Cell(this.map.x(to), this.map.y(to));
|
||||
const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom);
|
||||
|
||||
return upscaled.map((c) => this.map.ref(c.x, c.y));
|
||||
}
|
||||
|
||||
private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
const scaledPath = path.map(
|
||||
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
|
||||
);
|
||||
|
||||
const smoothPath: Cell[] = [];
|
||||
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
smoothPath.push(current);
|
||||
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push(
|
||||
new Cell(
|
||||
Math.round(current.x + (dx * step) / steps),
|
||||
Math.round(current.y + (dy * step) / steps),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
return smoothPath;
|
||||
}
|
||||
|
||||
private fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = this.findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
upscaled = upscaled.slice(srcIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const dstIndex = this.findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
upscaled = upscaled.slice(0, dstIndex + 1);
|
||||
}
|
||||
return upscaled;
|
||||
}
|
||||
|
||||
private findCell(cells: Cell[], target: Cell): number {
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].x === target.x && cells[i].y === target.y) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding
|
||||
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { PathFinder } from "../types";
|
||||
|
||||
/**
|
||||
* Wraps a PathFinder to handle shore tiles.
|
||||
* Coerces shore tiles to nearby water tiles before pathfinding,
|
||||
* then fixes the path extremes to include the original shore tiles.
|
||||
*
|
||||
* Works at whatever resolution the map provides - can be used with
|
||||
* full map or minimap-based pathfinders.
|
||||
*/
|
||||
export class ShoreCoercingTransformer implements PathFinder<number> {
|
||||
constructor(
|
||||
private inner: PathFinder<number>,
|
||||
private map: GameMap,
|
||||
) {}
|
||||
|
||||
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
|
||||
// Coerce from tiles
|
||||
const fromArray = Array.isArray(from) ? from : [from];
|
||||
const coercedFromArray: Array<{
|
||||
water: TileRef;
|
||||
original: TileRef | null;
|
||||
}> = [];
|
||||
|
||||
for (const f of fromArray) {
|
||||
const coerced = this.coerceToWater(f);
|
||||
if (coerced.water !== null) {
|
||||
coercedFromArray.push({
|
||||
water: coerced.water,
|
||||
original: coerced.original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (coercedFromArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Coerce to tile
|
||||
const coercedTo = this.coerceToWater(to);
|
||||
if (coercedTo.water === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build water-only from array
|
||||
const waterFrom =
|
||||
coercedFromArray.length === 1
|
||||
? coercedFromArray[0].water
|
||||
: coercedFromArray.map((c) => c.water);
|
||||
|
||||
// Search on water tiles
|
||||
const path = this.inner.findPath(waterFrom, coercedTo.water);
|
||||
if (!path || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fix extremes: find which source was used and prepend/append originals
|
||||
const result = [...path];
|
||||
|
||||
// Find the original for the source that was used (closest to path start)
|
||||
if (coercedFromArray.length > 0) {
|
||||
const pathStart = result[0];
|
||||
let bestOriginal: TileRef | null = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
for (const { water, original } of coercedFromArray) {
|
||||
if (original !== null) {
|
||||
const dist = this.map.manhattanDist(pathStart, water);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
bestOriginal = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend original if we have one and it's not already at start
|
||||
if (bestOriginal !== null && result[0] !== bestOriginal) {
|
||||
result.unshift(bestOriginal);
|
||||
}
|
||||
}
|
||||
|
||||
// Append original to if different
|
||||
if (
|
||||
coercedTo.original !== null &&
|
||||
result[result.length - 1] !== coercedTo.original
|
||||
) {
|
||||
result.push(coercedTo.original);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a tile to water for pathfinding.
|
||||
* If tile is already water, returns it unchanged.
|
||||
* If tile is shore (land with water neighbor), finds the nearest water neighbor.
|
||||
*/
|
||||
private coerceToWater(tile: TileRef): {
|
||||
water: TileRef | null;
|
||||
original: TileRef | null;
|
||||
} {
|
||||
// If already water, no coercion needed
|
||||
if (this.map.isWater(tile)) {
|
||||
return { water: tile, original: null };
|
||||
}
|
||||
|
||||
// Find adjacent water neighbor
|
||||
for (const n of this.map.neighbors(tile)) {
|
||||
if (this.map.isWater(n)) {
|
||||
return { water: n, original: tile };
|
||||
}
|
||||
}
|
||||
|
||||
// No water neighbor found - let HPA* handle at minimap level
|
||||
return { water: null, original: tile };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Core pathfinding types and interfaces.
|
||||
* No dependencies - safe to import from anywhere.
|
||||
*/
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
}
|
||||
|
||||
export type PathResult<T> =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: T }
|
||||
| { status: PathStatus.COMPLETE; node: T }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
|
||||
/**
|
||||
* PathFinder - core pathfinding interface.
|
||||
* Implementations find paths between nodes.
|
||||
*/
|
||||
export interface PathFinder<T> {
|
||||
findPath(from: T | T[], to: T): T[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SteppingPathFinder - PathFinder with stepping support.
|
||||
* Used by execution classes that need incremental path traversal.
|
||||
*/
|
||||
export interface SteppingPathFinder<T> extends PathFinder<T> {
|
||||
next(from: T, to: T, dist?: number): PathResult<T>;
|
||||
invalidate(): void;
|
||||
}
|
||||
@@ -383,6 +383,7 @@ describe("Disconnected", () => {
|
||||
player1.conquer(game.map().ref(coastX, 4));
|
||||
player2.conquer(game.map().ref(coastX, 1));
|
||||
|
||||
// Use a far destination so boat is still in transit after attack completes
|
||||
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||
|
||||
game.addExecution(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
|
||||
import { Game, Player, Unit } from "../../../src/core/game/Game";
|
||||
import { PathStatus } from "../../../src/core/pathfinding/PathFinder";
|
||||
import { PathStatus } from "../../../src/core/pathfinding/types";
|
||||
import { setup } from "../../util/Setup";
|
||||
|
||||
describe("TradeShipExecution", () => {
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { Game } from "../../../src/core/game/Game";
|
||||
import { TileRef } from "../../../src/core/game/GameMap";
|
||||
import { MiniAStarAdapter } from "../../../src/core/pathfinding/adapters/MiniAStarAdapter";
|
||||
import { NavMeshAdapter } from "../../../src/core/pathfinding/adapters/NavMeshAdapter";
|
||||
import {
|
||||
PathFinder,
|
||||
PathStatus,
|
||||
} from "../../../src/core/pathfinding/PathFinder";
|
||||
import { setup } from "../../util/Setup";
|
||||
import { gameFromString } from "./utils";
|
||||
|
||||
type AdapterFactory = {
|
||||
name: string;
|
||||
create: (game: Game) => PathFinder;
|
||||
};
|
||||
|
||||
const adapters: AdapterFactory[] = [
|
||||
{
|
||||
name: "MiniAStarAdapter",
|
||||
create: (game) => new MiniAStarAdapter(game, { waterPath: true }),
|
||||
},
|
||||
{
|
||||
name: "NavMeshAdapter",
|
||||
create: (game) => new NavMeshAdapter(game),
|
||||
},
|
||||
];
|
||||
|
||||
// Shared world game instance
|
||||
let worldGame: Game;
|
||||
|
||||
beforeAll(async () => {
|
||||
worldGame = await setup("world", { disableNavMesh: false });
|
||||
});
|
||||
|
||||
describe.each(adapters)("$name", ({ create }) => {
|
||||
describe("findPath()", () => {
|
||||
test("finds path between adjacent tiles", async () => {
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(1, 0);
|
||||
|
||||
const path = adapter.findPath(src, dst);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![0]).toBe(src);
|
||||
expect(path![path!.length - 1]).toBe(dst);
|
||||
});
|
||||
|
||||
test("finds path across multiple tiles", async () => {
|
||||
const game = await gameFromString(["WWWWWW", "WWWWWW", "WWWWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(5, 2);
|
||||
|
||||
const path = adapter.findPath(src, dst);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![0]).toBe(src);
|
||||
expect(path![path!.length - 1]).toBe(dst);
|
||||
});
|
||||
|
||||
test("returns single-element path for same tile", async () => {
|
||||
// Old quirk of MiniAStar, we return dst tile twice
|
||||
// Should probably be fixed to return [] instead
|
||||
|
||||
const game = await gameFromString(["WW"]);
|
||||
const adapter = create(game);
|
||||
const tile = game.ref(0, 0);
|
||||
|
||||
const path = adapter.findPath(tile, tile);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(1);
|
||||
expect(path![0]).toBe(tile);
|
||||
});
|
||||
|
||||
test("returns null for blocked path", async () => {
|
||||
const game = await gameFromString(["WWLLWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(5, 0);
|
||||
|
||||
const path = adapter.findPath(src, dst);
|
||||
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for water to land", () => {
|
||||
const adapter = create(worldGame);
|
||||
const src = worldGame.ref(926, 283); // water
|
||||
const dst = worldGame.ref(950, 230); // land
|
||||
|
||||
const path = adapter.findPath(src, dst);
|
||||
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
|
||||
test("traverses 3-tile path in 3 tiles", async () => {
|
||||
// Expected: [1, 2, 3]
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(3, 0);
|
||||
|
||||
const path = adapter.findPath(src, dst);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path).toEqual([
|
||||
game.ref(0, 0),
|
||||
game.ref(1, 0),
|
||||
game.ref(2, 0),
|
||||
game.ref(3, 0),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next() state machine", () => {
|
||||
test("returns NEXT on first call", async () => {
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(3, 0);
|
||||
|
||||
const result = adapter.next(src, dst);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NEXT);
|
||||
});
|
||||
|
||||
test("returns COMPLETE when at destination", async () => {
|
||||
const game = await gameFromString(["WW"]);
|
||||
const adapter = create(game);
|
||||
const tile = game.ref(0, 0);
|
||||
|
||||
const result = adapter.next(tile, tile);
|
||||
|
||||
expect(result.status).toBe(PathStatus.COMPLETE);
|
||||
});
|
||||
|
||||
test("returns NOT_FOUND for blocked path", async () => {
|
||||
const game = await gameFromString(["WWLLWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(5, 0);
|
||||
|
||||
const result = adapter.next(src, dst);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
});
|
||||
|
||||
test("traverses 3-tile path in 4 calls", async () => {
|
||||
// Expected: NEXT(1) -> NEXT(2) -> NEXT(3) -> COMPLETE(4)
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst = game.ref(3, 0);
|
||||
|
||||
let current = src;
|
||||
const steps: string[] = [];
|
||||
|
||||
// 3 NEXT calls to reach destination
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const result = adapter.next(current, dst);
|
||||
expect([PathStatus.NEXT, PathStatus.COMPLETE]).toContain(result.status);
|
||||
|
||||
current = (result as { node: TileRef }).node;
|
||||
steps.push(`${PathStatus[result.status]}(${current})`);
|
||||
}
|
||||
|
||||
expect(steps).toEqual(["NEXT(1)", "NEXT(2)", "NEXT(3)", "COMPLETE(3)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Destination changes", () => {
|
||||
test("reaches new destination when dest changes", async () => {
|
||||
const game = await gameFromString(["WWWWWWWW"]); // 8 wide
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst1 = game.ref(4, 0);
|
||||
const dst2 = game.ref(7, 0);
|
||||
|
||||
// First path exists
|
||||
expect(adapter.findPath(src, dst1)).not.toBeNull();
|
||||
|
||||
// Can still find path to new destination
|
||||
expect(adapter.findPath(dst1, dst2)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("recomputes when destination changes mid-path", async () => {
|
||||
const game = await gameFromString(["WWWWWWWWWWWWWWWWWWWW"]); // 20 wide
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
const dst1 = game.ref(10, 0);
|
||||
const dst2 = game.ref(19, 0);
|
||||
|
||||
// Start pathing to dst1, take one step
|
||||
const result1 = adapter.next(src, dst1);
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
|
||||
// Change destination mid-path, continue from current position
|
||||
let current = (result1 as { node: TileRef }).node;
|
||||
let result = adapter.next(current, dst2);
|
||||
for (let i = 0; i < 100 && result.status === PathStatus.NEXT; i++) {
|
||||
current = (result as { node: TileRef }).node;
|
||||
result = adapter.next(current, dst2);
|
||||
}
|
||||
|
||||
expect(result.status).toBe(PathStatus.COMPLETE);
|
||||
expect(current).toBe(dst2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
// MiniAStar logs console error when nulls passed, muted in test
|
||||
|
||||
test("returns NOT_FOUND for null source", async () => {
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const dst = game.ref(0, 0);
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const result = adapter.next(null as unknown as TileRef, dst);
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns NOT_FOUND for null destination", async () => {
|
||||
const game = await gameFromString(["WWWW"]);
|
||||
const adapter = create(game);
|
||||
const src = game.ref(0, 0);
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const result = adapter.next(src, null as unknown as TileRef);
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dist parameter", () => {
|
||||
test("returns COMPLETE when within dist", () => {
|
||||
const adapter = create(worldGame);
|
||||
const src = worldGame.ref(926, 283);
|
||||
const dst = worldGame.ref(928, 283); // 2 tiles away
|
||||
|
||||
const result = adapter.next(src, dst, 5);
|
||||
|
||||
expect(result.status).toBe(PathStatus.COMPLETE);
|
||||
});
|
||||
|
||||
test("returns NEXT when beyond dist", () => {
|
||||
const adapter = create(worldGame);
|
||||
const src = worldGame.ref(926, 283);
|
||||
const dst = worldGame.ref(950, 257);
|
||||
|
||||
// Adapter may need a few ticks to compute path
|
||||
let result = adapter.next(src, dst, 5);
|
||||
for (let i = 0; i < 100 && result.status === PathStatus.PENDING; i++) {
|
||||
result = adapter.next(src, dst, 5);
|
||||
}
|
||||
|
||||
expect(result.status).toBe(PathStatus.NEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("World map routes", () => {
|
||||
test("Spain to France (Mediterranean)", () => {
|
||||
const adapter = create(worldGame);
|
||||
const path = adapter.findPath(
|
||||
worldGame.ref(926, 283),
|
||||
worldGame.ref(950, 257),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Miami to Rio (Atlantic)", () => {
|
||||
const adapter = create(worldGame);
|
||||
const path = adapter.findPath(
|
||||
worldGame.ref(488, 355),
|
||||
worldGame.ref(680, 658),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("France to Poland (around Europe)", () => {
|
||||
const adapter = create(worldGame);
|
||||
const path = adapter.findPath(
|
||||
worldGame.ref(950, 257),
|
||||
worldGame.ref(1033, 175),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Miami to Spain (transatlantic)", () => {
|
||||
const adapter = create(worldGame);
|
||||
const path = adapter.findPath(
|
||||
worldGame.ref(488, 355),
|
||||
worldGame.ref(926, 283),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Rio to Poland (South Atlantic to Baltic)", () => {
|
||||
const adapter = create(worldGame);
|
||||
const path = adapter.findPath(
|
||||
worldGame.ref(680, 658),
|
||||
worldGame.ref(1033, 175),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Known bugs", () => {
|
||||
test("path can cross 1-tile land barrier", async () => {
|
||||
const game = await gameFromString(["WLLWLWWLLW"]);
|
||||
const adapter = create(game);
|
||||
const path = adapter.findPath(game.ref(0, 0), game.ref(9, 0));
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
test("path can cross diagonal land barrier", async () => {
|
||||
const game = await gameFromString(["WL", "LW"]);
|
||||
const adapter = create(game);
|
||||
const path = adapter.findPath(game.ref(0, 0), game.ref(1, 1));
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PathFinderStepper } from "../../../src/core/pathfinding/PathFinderStepper";
|
||||
import { PathFinder, PathStatus } from "../../../src/core/pathfinding/types";
|
||||
|
||||
describe("PathFinderStepper", () => {
|
||||
function createMockFinder(
|
||||
pathMap: Map<string, number[]>,
|
||||
): PathFinder<number> {
|
||||
return {
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
const fromTile = Array.isArray(from) ? from[0] : from;
|
||||
const key = `${fromTile}->${to}`;
|
||||
return pathMap.get(key) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("next", () => {
|
||||
it("returns COMPLETE when at destination", () => {
|
||||
const pathMap = new Map<string, number[]>();
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
const result = stepper.next(5, 5);
|
||||
|
||||
expect(result.status).toBe(PathStatus.COMPLETE);
|
||||
expect((result as { node: number }).node).toBe(5);
|
||||
});
|
||||
|
||||
it("returns NEXT with path nodes sequentially", () => {
|
||||
const pathMap = new Map<string, number[]>([["1->4", [1, 2, 3, 4]]]);
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
// First step: 1 -> 4, returns 2
|
||||
const result1 = stepper.next(1, 4);
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
expect((result1 as { node: number }).node).toBe(2);
|
||||
|
||||
// Second step: from 2, returns 3
|
||||
const result2 = stepper.next(2, 4);
|
||||
expect(result2.status).toBe(PathStatus.NEXT);
|
||||
expect((result2 as { node: number }).node).toBe(3);
|
||||
|
||||
// Third step: from 3, returns 4
|
||||
const result3 = stepper.next(3, 4);
|
||||
expect(result3.status).toBe(PathStatus.NEXT);
|
||||
expect((result3 as { node: number }).node).toBe(4);
|
||||
|
||||
// Fourth step: at destination
|
||||
const result4 = stepper.next(4, 4);
|
||||
expect(result4.status).toBe(PathStatus.COMPLETE);
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when no path exists", () => {
|
||||
const pathMap = new Map<string, number[]>();
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
const result = stepper.next(1, 99);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("recomputes path when moved off-path", () => {
|
||||
// Path from 1->5 goes through 2,3,4
|
||||
// Path from 10->5 goes through 9,8,7,6
|
||||
const pathMap = new Map<string, number[]>([
|
||||
["1->5", [1, 2, 3, 4, 5]],
|
||||
["10->5", [10, 9, 8, 7, 6, 5]],
|
||||
]);
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
// Start on path 1->5
|
||||
const result1 = stepper.next(1, 5);
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
expect((result1 as { node: number }).node).toBe(2);
|
||||
|
||||
// Move off-path to tile 10 (not on original path)
|
||||
// Should recompute using path from 10->5
|
||||
const result2 = stepper.next(10, 5);
|
||||
expect(result2.status).toBe(PathStatus.NEXT);
|
||||
expect((result2 as { node: number }).node).toBe(9);
|
||||
});
|
||||
|
||||
it("recomputes path when destination changes", () => {
|
||||
const pathMap = new Map<string, number[]>([
|
||||
["1->5", [1, 2, 3, 4, 5]],
|
||||
["2->9", [2, 6, 7, 8, 9]],
|
||||
]);
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
// Start on path 1->5
|
||||
const result1 = stepper.next(1, 5);
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
expect((result1 as { node: number }).node).toBe(2);
|
||||
|
||||
// Change destination to 9 (from current position 2)
|
||||
const result2 = stepper.next(2, 9);
|
||||
expect(result2.status).toBe(PathStatus.NEXT);
|
||||
expect((result2 as { node: number }).node).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidate", () => {
|
||||
it("clears cached path so next recomputes", () => {
|
||||
let callCount = 0;
|
||||
const finder: PathFinder<number> = {
|
||||
findPath(from, to): number[] | null {
|
||||
callCount++;
|
||||
const fromTile = Array.isArray(from) ? from[0] : from;
|
||||
return [fromTile, to];
|
||||
},
|
||||
};
|
||||
const stepper = new PathFinderStepper(finder);
|
||||
|
||||
stepper.next(1, 5);
|
||||
stepper.next(5, 5);
|
||||
|
||||
// Second call follows path without recomputing
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
stepper.invalidate();
|
||||
stepper.next(1, 5);
|
||||
|
||||
// Recomputed path after invalidation
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPath", () => {
|
||||
it("delegates to inner finder", () => {
|
||||
const pathMap = new Map<string, number[]>([["1->5", [1, 2, 3, 4, 5]]]);
|
||||
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
||||
|
||||
const path = stepper.findPath(1, 5);
|
||||
|
||||
expect(path).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("supports multi-source", () => {
|
||||
const finder: PathFinder<number> = {
|
||||
findPath(from, to): number[] | null {
|
||||
const firstFrom = Array.isArray(from) ? from[0] : from;
|
||||
return [firstFrom, to];
|
||||
},
|
||||
};
|
||||
const stepper = new PathFinderStepper(finder);
|
||||
|
||||
const path = stepper.findPath([1, 2, 3], 5);
|
||||
|
||||
expect(path).toEqual([1, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom equals", () => {
|
||||
it("uses custom equals function for position comparison", () => {
|
||||
type Pos = { x: number; y: number };
|
||||
const posEquals = (a: Pos, b: Pos) => a.x === b.x && a.y === b.y;
|
||||
|
||||
const finder: PathFinder<Pos> = {
|
||||
findPath(from, to): Pos[] | null {
|
||||
const f = Array.isArray(from) ? from[0] : from;
|
||||
return [f, { x: 2, y: 0 }, to];
|
||||
},
|
||||
};
|
||||
|
||||
const stepper = new PathFinderStepper(finder, { equals: posEquals });
|
||||
|
||||
const from1 = { x: 1, y: 0 };
|
||||
const to = { x: 3, y: 0 };
|
||||
|
||||
const result1 = stepper.next(from1, to);
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
|
||||
// Use equivalent but different object (a !== b), still on track
|
||||
const result2 = stepper.next({ x: 2, y: 0 }, to);
|
||||
expect(result2.status).toBe(PathStatus.NEXT);
|
||||
expect((result2 as { node: Pos }).node).toEqual({ x: 3, y: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { Game } from "../../../src/core/game/Game";
|
||||
import { TileRef } from "../../../src/core/game/GameMap";
|
||||
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
|
||||
import { SteppingPathFinder } from "../../../src/core/pathfinding/types";
|
||||
import { setup } from "../../util/Setup";
|
||||
|
||||
describe("PathFinding.Air", () => {
|
||||
let game: Game;
|
||||
|
||||
function createPathFinder(): SteppingPathFinder<TileRef> {
|
||||
return PathFinding.Air(game);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
game = await setup("ocean_and_land");
|
||||
});
|
||||
|
||||
describe("findPath", () => {
|
||||
it("returns path between any two points (ignores terrain)", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Air pathfinder ignores terrain, so can go anywhere
|
||||
// (2,2) → (14,14): manhattan = 24, path length = 25
|
||||
const from = map.ref(2, 2);
|
||||
const to = map.ref(14, 14);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(25);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
|
||||
it("throws error for multiple start points", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const from = [map.ref(2, 2), map.ref(4, 4)];
|
||||
const to = map.ref(14, 14);
|
||||
|
||||
expect(() => pathFinder.findPath(from, to)).toThrow(
|
||||
"does not support multiple start points",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns single-tile path when from equals to", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
const tile = map.ref(8, 8);
|
||||
|
||||
const path = pathFinder.findPath(tile, tile);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![0]).toBe(tile);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path validity", () => {
|
||||
it("all consecutive tiles in path are adjacent (Manhattan distance 1)", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// (2,2) → (14,14): manhattan = 24, path length = 25
|
||||
const from = map.ref(2, 2);
|
||||
const to = map.ref(14, 14);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(25);
|
||||
|
||||
// Verify every consecutive pair is adjacent
|
||||
for (let i = 1; i < path!.length; i++) {
|
||||
const dist = map.manhattanDist(path![i - 1], path![i]);
|
||||
expect(dist).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("path ends at exact destination", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const from = map.ref(5, 5);
|
||||
const to = map.ref(10, 12);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path shapes", () => {
|
||||
it("diagonal path has equal X and Y movement", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Equal X and Y offset: (0,0) → (10,10)
|
||||
const from = map.ref(0, 0);
|
||||
const to = map.ref(10, 10);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
let xMoves = 0;
|
||||
let yMoves = 0;
|
||||
for (let i = 1; i < path!.length; i++) {
|
||||
const dx = map.x(path![i]) - map.x(path![i - 1]);
|
||||
const dy = map.y(path![i]) - map.y(path![i - 1]);
|
||||
if (dx !== 0) xMoves++;
|
||||
if (dy !== 0) yMoves++;
|
||||
}
|
||||
|
||||
expect(xMoves).toBe(10);
|
||||
expect(yMoves).toBe(10);
|
||||
});
|
||||
|
||||
it("horizontal path has only X movement", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Pure horizontal: (0,5) → (15,5)
|
||||
const from = map.ref(0, 5);
|
||||
const to = map.ref(15, 5);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
let xMoves = 0;
|
||||
let yMoves = 0;
|
||||
for (let i = 1; i < path!.length; i++) {
|
||||
const dx = map.x(path![i]) - map.x(path![i - 1]);
|
||||
const dy = map.y(path![i]) - map.y(path![i - 1]);
|
||||
if (dx !== 0) xMoves++;
|
||||
if (dy !== 0) yMoves++;
|
||||
}
|
||||
|
||||
expect(xMoves).toBe(15);
|
||||
expect(yMoves).toBe(0);
|
||||
});
|
||||
|
||||
it("vertical path has only Y movement", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Pure vertical: (5,0) → (5,15)
|
||||
const from = map.ref(5, 0);
|
||||
const to = map.ref(5, 15);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
let xMoves = 0;
|
||||
let yMoves = 0;
|
||||
for (let i = 1; i < path!.length; i++) {
|
||||
const dx = map.x(path![i]) - map.x(path![i - 1]);
|
||||
const dy = map.y(path![i]) - map.y(path![i - 1]);
|
||||
if (dx !== 0) xMoves++;
|
||||
if (dy !== 0) yMoves++;
|
||||
}
|
||||
|
||||
expect(xMoves).toBe(0);
|
||||
expect(yMoves).toBe(15);
|
||||
});
|
||||
|
||||
it("adjacent tiles produce minimal path", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const from = map.ref(5, 5);
|
||||
const to = map.ref(6, 5);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(2);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![1]).toBe(to);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { Game } from "../../../src/core/game/Game";
|
||||
import { TileRef } from "../../../src/core/game/GameMap";
|
||||
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
|
||||
import { SteppingPathFinder } from "../../../src/core/pathfinding/types";
|
||||
import { setup } from "../../util/Setup";
|
||||
|
||||
describe("PathFinding.Rail", () => {
|
||||
let game: Game;
|
||||
let pathFinder: SteppingPathFinder<TileRef>;
|
||||
|
||||
beforeAll(async () => {
|
||||
game = await setup("ocean_and_land");
|
||||
pathFinder = PathFinding.Rail(game);
|
||||
});
|
||||
|
||||
describe("findPath", () => {
|
||||
it("finds path on land tiles", () => {
|
||||
const map = game.map();
|
||||
|
||||
// Adjacent land tiles: (0,0) and (1,0)
|
||||
const from = map.ref(0, 0);
|
||||
const to = map.ref(1, 0);
|
||||
|
||||
expect(map.isLand(from)).toBe(true);
|
||||
expect(map.isLand(to)).toBe(true);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(2);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![1]).toBe(to);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Game } from "../../../src/core/game/Game";
|
||||
import { TileRef } from "../../../src/core/game/GameMap";
|
||||
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
|
||||
import {
|
||||
PathStatus,
|
||||
SteppingPathFinder,
|
||||
} from "../../../src/core/pathfinding/types";
|
||||
import { setup } from "../../util/Setup";
|
||||
import { createGame, L, W } from "./_fixtures";
|
||||
|
||||
describe("PathFinding.Water", () => {
|
||||
let game: Game;
|
||||
let worldGame: Game;
|
||||
|
||||
function createPathFinder(g: Game = game): SteppingPathFinder<TileRef> {
|
||||
return PathFinding.Water(g);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
game = await setup("ocean_and_land");
|
||||
worldGame = await setup("world", { disableNavMesh: false });
|
||||
});
|
||||
|
||||
describe("findPath", () => {
|
||||
it("finds path between adjacent water tiles", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const from = map.ref(8, 0);
|
||||
const to = map.ref(9, 0);
|
||||
|
||||
expect(map.isWater(from)).toBe(true);
|
||||
expect(map.isWater(to)).toBe(true);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(2);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![1]).toBe(to);
|
||||
});
|
||||
|
||||
it("returns null for land tiles", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const landTile = map.ref(0, 0);
|
||||
const waterTile = map.ref(8, 0);
|
||||
|
||||
expect(map.isLand(landTile)).toBe(true);
|
||||
expect(map.isShore(landTile)).toBe(false);
|
||||
expect(map.isWater(waterTile)).toBe(true);
|
||||
|
||||
const path = pathFinder.findPath(landTile, waterTile);
|
||||
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
|
||||
it("returns single-tile path when from equals to", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const waterTile = map.ref(8, 0);
|
||||
expect(map.isWater(waterTile)).toBe(true);
|
||||
|
||||
const path = pathFinder.findPath(waterTile, waterTile);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(1);
|
||||
expect(path![0]).toBe(waterTile);
|
||||
});
|
||||
|
||||
it("supports multiple start tiles", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
const dest = map.ref(8, 0);
|
||||
const source1 = map.ref(9, 0);
|
||||
const source2 = map.ref(8, 1);
|
||||
|
||||
expect(map.isWater(dest)).toBe(true);
|
||||
expect(map.isWater(source1)).toBe(true);
|
||||
expect(map.isWater(source2)).toBe(true);
|
||||
|
||||
const from = [source1, source2];
|
||||
const path = pathFinder.findPath(from, dest);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(2);
|
||||
expect(from).toContain(path![0]);
|
||||
expect(path![1]).toBe(dest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path validity", () => {
|
||||
it("all consecutive tiles in path are connected", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Distant water tiles: (8,0) → (15,4), distance = 11
|
||||
const from = map.ref(8, 0);
|
||||
const to = map.ref(15, 4);
|
||||
|
||||
expect(map.isWater(from)).toBe(true);
|
||||
expect(map.isWater(to)).toBe(true);
|
||||
expect(map.manhattanDist(from, to)).toBe(11);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
for (let i = 1; i < path!.length; i++) {
|
||||
const dist = map.manhattanDist(path![i - 1], path![i]);
|
||||
expect(dist).toEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shore handling", () => {
|
||||
it("path from shore to shore starts and ends on shore", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Shore tiles at (7,0) and (7,6), distance = 6
|
||||
// Both have water neighbors at (8,0) and (8,6)
|
||||
const from = map.ref(7, 0);
|
||||
const to = map.ref(7, 6);
|
||||
|
||||
expect(map.isShore(from)).toBe(true);
|
||||
expect(map.isShore(to)).toBe(true);
|
||||
expect(map.manhattanDist(from, to)).toBe(6);
|
||||
|
||||
const path = pathFinder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determinism", () => {
|
||||
it("same inputs produce identical paths", () => {
|
||||
const pathFinder1 = createPathFinder();
|
||||
const pathFinder2 = createPathFinder();
|
||||
const map = game.map();
|
||||
|
||||
// Distant water tiles: (8,0) → (15,4)
|
||||
const from = map.ref(8, 0);
|
||||
const to = map.ref(15, 4);
|
||||
|
||||
const path1 = pathFinder1.findPath(from, to);
|
||||
const path2 = pathFinder2.findPath(from, to);
|
||||
|
||||
expect(path1).not.toBeNull();
|
||||
expect(path2).not.toBeNull();
|
||||
expect(path1).toEqual(path2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("World map routes", () => {
|
||||
it("Spain to France (Mediterranean)", () => {
|
||||
const pathFinder = createPathFinder(worldGame);
|
||||
const path = pathFinder.findPath(
|
||||
worldGame.ref(926, 283),
|
||||
worldGame.ref(950, 257),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Miami to Rio (Atlantic)", () => {
|
||||
const pathFinder = createPathFinder(worldGame);
|
||||
const path = pathFinder.findPath(
|
||||
worldGame.ref(488, 355),
|
||||
worldGame.ref(680, 658),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("France to Poland (around Europe)", () => {
|
||||
const pathFinder = createPathFinder(worldGame);
|
||||
const path = pathFinder.findPath(
|
||||
worldGame.ref(950, 257),
|
||||
worldGame.ref(1033, 175),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Miami to Spain (transatlantic)", () => {
|
||||
const pathFinder = createPathFinder(worldGame);
|
||||
const path = pathFinder.findPath(
|
||||
worldGame.ref(488, 355),
|
||||
worldGame.ref(926, 283),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Rio to Poland (South Atlantic to Baltic)", () => {
|
||||
const pathFinder = createPathFinder(worldGame);
|
||||
const path = pathFinder.findPath(
|
||||
worldGame.ref(680, 658),
|
||||
worldGame.ref(1033, 175),
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("returns NOT_FOUND for null source", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const result = pathFinder.next(
|
||||
null as unknown as TileRef,
|
||||
game.ref(8, 0),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for null destination", () => {
|
||||
const pathFinder = createPathFinder();
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const result = pathFinder.next(
|
||||
game.ref(8, 0),
|
||||
null as unknown as TileRef,
|
||||
);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Known bugs", () => {
|
||||
it("path can cross 1-tile land barrier", () => {
|
||||
const syntheticGame = createGame({
|
||||
width: 10,
|
||||
height: 1,
|
||||
grid: [W, L, L, W, L, W, W, L, L, W],
|
||||
});
|
||||
|
||||
const pathFinder = createPathFinder(syntheticGame);
|
||||
const path = pathFinder.findPath(
|
||||
syntheticGame.ref(0, 0),
|
||||
syntheticGame.ref(9, 0),
|
||||
);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("path can cross diagonal land barrier", () => {
|
||||
const syntheticGame = createGame({
|
||||
width: 2,
|
||||
height: 2,
|
||||
grid: [W, L, L, W],
|
||||
});
|
||||
|
||||
const pathFinder = createPathFinder(syntheticGame);
|
||||
const path = pathFinder.findPath(
|
||||
syntheticGame.ref(0, 0),
|
||||
syntheticGame.ref(1, 1),
|
||||
);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
|
||||
import {
|
||||
Game,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { TileRef } from "../../../src/core/game/GameMap";
|
||||
import { SpatialQuery } from "../../../src/core/pathfinding/spatial/SpatialQuery";
|
||||
import { createGame, L, W } from "./_fixtures";
|
||||
|
||||
// Spawns player and **expands territory** via getSpawnTiles (euclidean dist 4)
|
||||
// Ref: src/core/execution/Util.ts
|
||||
function addPlayer(game: Game, tile: TileRef): Player {
|
||||
const info = new PlayerInfo("test", PlayerType.Human, null, "test_id");
|
||||
game.addPlayer(info);
|
||||
game.addExecution(new SpawnExecution("game_id", info, tile));
|
||||
while (game.inSpawnPhase()) game.executeNextTick();
|
||||
return game.player(info.id);
|
||||
}
|
||||
|
||||
describe("SpatialQuery", () => {
|
||||
describe("closestShore", () => {
|
||||
it("finds shore tile owned by player", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 5, height: 5, grid: [
|
||||
W, W, W, W, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, W, W, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(2, 2));
|
||||
|
||||
// All land tiles owned by player because of spawn expansion
|
||||
const result = spatial.closestShore(player, game.ref(2, 2));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(game.isShore(result!)).toBe(true);
|
||||
expect(game.ownerID(result!)).toBe(player.smallID());
|
||||
});
|
||||
|
||||
it("returns null when no shore within maxDist", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 7, height: 7, grid: [
|
||||
W, W, W, W, W, W, W,
|
||||
W, L, L, L, L, L, W,
|
||||
W, L, L, L, L, L, W,
|
||||
W, L, L, L, L, L, W,
|
||||
W, L, L, L, L, L, W,
|
||||
W, L, L, L, L, L, W,
|
||||
W, W, W, W, W, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(3, 3));
|
||||
|
||||
// maxDist=1 from center (3,3) - shore is 2 tiles away
|
||||
const result = spatial.closestShore(player, game.ref(3, 3), 1);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("finds shore on player's island (two separate islands)", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 8, height: 4, grid: [
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(0, 0));
|
||||
|
||||
const result = spatial.closestShore(player, game.ref(0, 2));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(game.isShore(result!)).toBe(true);
|
||||
expect(game.ownerID(result!)).toBe(player.smallID());
|
||||
expect(game.x(result!)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("finds shore even if no land path exists (two separate islands)", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 8, height: 4, grid: [
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
L, L, W, W, W, W, L, L,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(0, 0));
|
||||
|
||||
const result = spatial.closestShore(player, game.ref(7, 2));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(game.isShore(result!)).toBe(true);
|
||||
expect(game.ownerID(result!)).toBe(player.smallID());
|
||||
expect(game.x(result!)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("finds shore for terra nullius when land is unclaimed", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 5, height: 5, grid: [
|
||||
W, W, W, W, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, W, W, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const terraNullius = game.terraNullius();
|
||||
|
||||
const result = spatial.closestShore(terraNullius, game.ref(2, 2));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(game.isShore(result!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closestShoreByWater", () => {
|
||||
it("returns null for terra nullius", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 5, height: 5, grid: [
|
||||
W, W, W, W, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, W, W, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const terraNullius = game.terraNullius();
|
||||
|
||||
const result = spatial.closestShoreByWater(terraNullius, game.ref(0, 0));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when target is on land", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 5, height: 5, grid: [
|
||||
W, W, W, W, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, W, W, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(2, 2));
|
||||
|
||||
const result = spatial.closestShoreByWater(player, game.ref(2, 2));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when target is in disconnected water body", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 14, height: 6, grid: [
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(3, 2));
|
||||
const result = spatial.closestShoreByWater(player, game.ref(13, 2));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("finds shore via long water path around island", () => {
|
||||
// prettier-ignore
|
||||
const game = createGame({
|
||||
width: 18, height: 14, grid: [
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
||||
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, L,
|
||||
],
|
||||
});
|
||||
|
||||
const spatial = new SpatialQuery(game);
|
||||
const player = addPlayer(game, game.ref(4, 4));
|
||||
|
||||
const target = game.ref(17, 13);
|
||||
const result = spatial.closestShoreByWater(player, target);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(game.isShore(result!)).toBe(true);
|
||||
expect(game.ownerID(result!)).toBe(player.smallID());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameMapImpl } from "../../../src/core/game/GameMap";
|
||||
import { UniversalPathFinding } from "../../../src/core/pathfinding/PathFinder";
|
||||
import { PathStatus } from "../../../src/core/pathfinding/types";
|
||||
|
||||
describe("UniversalPathFinding.Parabola", () => {
|
||||
function createLargeMap() {
|
||||
// Create a larger map for parabola tests (need space for arcs)
|
||||
const W = 0x20;
|
||||
const terrain = new Uint8Array(10000).fill(W);
|
||||
return new GameMapImpl(100, 100, terrain, 0);
|
||||
}
|
||||
|
||||
describe("findPath", () => {
|
||||
it("returns parabolic arc between two points", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(39);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
|
||||
it("throws error for multiple start points", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = [map.ref(10, 50), map.ref(20, 50)];
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
expect(() => finder.findPath(from, to)).toThrow(
|
||||
"does not support multiple start points",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles same start and end point", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const tile = map.ref(50, 50);
|
||||
|
||||
const path = finder.findPath(tile, tile);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(26);
|
||||
});
|
||||
|
||||
it("creates arc across map", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(0, 50);
|
||||
const to = map.ref(99, 50);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(43);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next (stepping)", () => {
|
||||
it("returns NEXT with node when not at destination", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
const result = finder.next(from, to);
|
||||
|
||||
expect(result.status).toBe(PathStatus.NEXT);
|
||||
expect("node" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("respects speed parameter (higher speed = further movement)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder1 = UniversalPathFinding.Parabola(map);
|
||||
const finder2 = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
// Step with speed 1
|
||||
const result1 = finder1.next(from, to, 1);
|
||||
|
||||
// Step with speed 5
|
||||
const result2 = finder2.next(from, to, 5);
|
||||
|
||||
// Both should be NEXT (not at destination yet)
|
||||
expect(result1.status).toBe(PathStatus.NEXT);
|
||||
expect(result2.status).toBe(PathStatus.NEXT);
|
||||
|
||||
const node1 = (
|
||||
result1 as { status: typeof PathStatus.NEXT; node: number }
|
||||
).node;
|
||||
const node2 = (
|
||||
result2 as { status: typeof PathStatus.NEXT; node: number }
|
||||
).node;
|
||||
|
||||
// Speed 5 should move strictly further than speed 1
|
||||
const dist1 = map.manhattanDist(from, node1);
|
||||
const dist2 = map.manhattanDist(from, node2);
|
||||
expect(dist2).toBeGreaterThan(dist1);
|
||||
|
||||
expect(finder2.currentIndex()).toBeGreaterThan(finder1.currentIndex());
|
||||
});
|
||||
});
|
||||
|
||||
describe("options", () => {
|
||||
it("increment option affects path density", () => {
|
||||
const map = createLargeMap();
|
||||
const finder1 = UniversalPathFinding.Parabola(map, { increment: 1 });
|
||||
const finder2 = UniversalPathFinding.Parabola(map, { increment: 10 });
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
const path1 = finder1.findPath(from, to);
|
||||
const path2 = finder2.findPath(from, to);
|
||||
|
||||
expect(path1).not.toBeNull();
|
||||
expect(path2).not.toBeNull();
|
||||
|
||||
expect(path1!.length).toBeGreaterThan(path2!.length);
|
||||
});
|
||||
|
||||
it("distanceBasedHeight option affects arc height", () => {
|
||||
const map = createLargeMap();
|
||||
const finder1 = UniversalPathFinding.Parabola(map, {
|
||||
distanceBasedHeight: true,
|
||||
});
|
||||
const finder2 = UniversalPathFinding.Parabola(map, {
|
||||
distanceBasedHeight: false,
|
||||
});
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
const path1 = finder1.findPath(from, to);
|
||||
const path2 = finder2.findPath(from, to);
|
||||
|
||||
expect(path1).not.toBeNull();
|
||||
expect(path2).not.toBeNull();
|
||||
|
||||
// With distanceBasedHeight=true, path should have Y deviation
|
||||
// With distanceBasedHeight=false, path should be more direct
|
||||
const getMaxYDeviation = (path: number[]) => {
|
||||
const midY = map.y(from);
|
||||
return Math.max(...path.map((t) => Math.abs(map.y(t) - midY)));
|
||||
};
|
||||
|
||||
const dev1 = getMaxYDeviation(path1!);
|
||||
const dev2 = getMaxYDeviation(path2!);
|
||||
expect(dev1).toBeGreaterThan(dev2);
|
||||
});
|
||||
|
||||
it("directionUp option affects arc direction", () => {
|
||||
const map = createLargeMap();
|
||||
const finderUp = UniversalPathFinding.Parabola(map, {
|
||||
directionUp: true,
|
||||
});
|
||||
const finderDown = UniversalPathFinding.Parabola(map, {
|
||||
directionUp: false,
|
||||
});
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
const pathUp = finderUp.findPath(from, to);
|
||||
const pathDown = finderDown.findPath(from, to);
|
||||
|
||||
expect(pathUp).not.toBeNull();
|
||||
expect(pathDown).not.toBeNull();
|
||||
|
||||
// Get midpoint Y values
|
||||
const midIdx = Math.floor(pathUp!.length / 2);
|
||||
const midY_Up = map.y(pathUp![midIdx]);
|
||||
const midY_Down = map.y(pathDown![midIdx]);
|
||||
const startY = map.y(from);
|
||||
|
||||
// directionUp=true means Y decreases (goes "up" on screen)
|
||||
// directionUp=false means Y increases (goes "down" on screen)
|
||||
expect(midY_Up).toBeLessThan(startY);
|
||||
expect(midY_Down).toBeGreaterThan(startY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentIndex", () => {
|
||||
it("returns 0 when no curve", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
expect(finder.currentIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it("increments as path is stepped", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(10, 50);
|
||||
const to = map.ref(90, 50);
|
||||
|
||||
let current = from;
|
||||
let previousIndex = 0;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = finder.next(current, to);
|
||||
expect(result.status).toBe(PathStatus.NEXT);
|
||||
|
||||
const index = finder.currentIndex();
|
||||
expect(index).toBeGreaterThanOrEqual(previousIndex);
|
||||
previousIndex = index;
|
||||
|
||||
current = (result as { status: typeof PathStatus.NEXT; node: number })
|
||||
.node;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("short distances", () => {
|
||||
it("creates valid arc for distance < 50 (PARABOLA_MIN_HEIGHT)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map, {
|
||||
distanceBasedHeight: true,
|
||||
});
|
||||
|
||||
// Distance of 30 is less than PARABOLA_MIN_HEIGHT (50)
|
||||
const from = map.ref(50, 50);
|
||||
const to = map.ref(80, 50);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(28);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
|
||||
it("creates valid path for adjacent tiles (distance=1)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map);
|
||||
|
||||
const from = map.ref(50, 50);
|
||||
const to = map.ref(51, 50);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path!.length).toBe(26);
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
|
||||
it("creates valid path for very short distance (distance=5)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map, {
|
||||
distanceBasedHeight: true,
|
||||
});
|
||||
|
||||
const from = map.ref(50, 50);
|
||||
const to = map.ref(55, 50);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
expect(path![0]).toBe(from);
|
||||
expect(path![path!.length - 1]).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("map boundary clipping", () => {
|
||||
it("arc clipped at map top boundary (directionUp near y=0)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map, {
|
||||
directionUp: true,
|
||||
distanceBasedHeight: true,
|
||||
});
|
||||
|
||||
// Start near top of map
|
||||
const from = map.ref(10, 5);
|
||||
const to = map.ref(90, 5);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
for (const t of path!) {
|
||||
expect(map.y(t)).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("arc clipped at map bottom boundary (directionDown near y=max)", () => {
|
||||
const map = createLargeMap();
|
||||
const finder = UniversalPathFinding.Parabola(map, {
|
||||
directionUp: false,
|
||||
distanceBasedHeight: true,
|
||||
});
|
||||
|
||||
const from = map.ref(10, 95);
|
||||
const to = map.ref(90, 95);
|
||||
|
||||
const path = finder.findPath(from, to);
|
||||
|
||||
expect(path).not.toBeNull();
|
||||
|
||||
for (const t of path!) {
|
||||
expect(map.y(t)).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ConnectedComponents,
|
||||
LAND_MARKER,
|
||||
} from "../../../src/core/pathfinding/algorithms/ConnectedComponents";
|
||||
import { createGameMap, createIslandMap, L, W } from "./_fixtures";
|
||||
|
||||
// prettier-ignore
|
||||
const twoComponentsMapData = {
|
||||
width: 7, height: 5, grid: [
|
||||
W, W, L, L, L, W, W,
|
||||
W, W, L, L, L, W, W,
|
||||
W, W, L, L, L, W, W,
|
||||
W, W, L, L, L, W, W,
|
||||
W, W, L, L, L, W, W,
|
||||
],
|
||||
};
|
||||
|
||||
describe("ConnectedComponents", () => {
|
||||
describe("getComponentId", () => {
|
||||
it("returns 0 before initialization", () => {
|
||||
const map = createGameMap(createIslandMap());
|
||||
const wc = new ConnectedComponents(map);
|
||||
|
||||
// Water tile at (0,0) - should return 0 (not initialized)
|
||||
const waterTile = map.ref(0, 0);
|
||||
expect(wc.getComponentId(waterTile)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns same component ID for all water tiles in single connected area", () => {
|
||||
const map = createGameMap(createIslandMap());
|
||||
const wc = new ConnectedComponents(map);
|
||||
wc.initialize();
|
||||
|
||||
const water1 = map.ref(0, 0);
|
||||
const water2 = map.ref(4, 0);
|
||||
const water3 = map.ref(0, 4);
|
||||
const water4 = map.ref(4, 4);
|
||||
|
||||
expect(map.isWater(water1)).toBe(true);
|
||||
expect(map.isWater(water2)).toBe(true);
|
||||
expect(map.isWater(water3)).toBe(true);
|
||||
expect(map.isWater(water4)).toBe(true);
|
||||
|
||||
const id1 = wc.getComponentId(water1);
|
||||
const id2 = wc.getComponentId(water2);
|
||||
const id3 = wc.getComponentId(water3);
|
||||
const id4 = wc.getComponentId(water4);
|
||||
|
||||
expect(id1).toBe(1);
|
||||
expect(id2).toBe(id1);
|
||||
expect(id3).toBe(id1);
|
||||
expect(id4).toBe(id1);
|
||||
});
|
||||
|
||||
it("returns different component IDs for disconnected water areas", () => {
|
||||
const map = createGameMap(twoComponentsMapData);
|
||||
const wc = new ConnectedComponents(map);
|
||||
wc.initialize();
|
||||
|
||||
const leftWater1 = map.ref(0, 0);
|
||||
const leftWater2 = map.ref(1, 2);
|
||||
const rightWater1 = map.ref(5, 0);
|
||||
const rightWater2 = map.ref(6, 4);
|
||||
|
||||
expect(map.isWater(leftWater1)).toBe(true);
|
||||
expect(map.isWater(leftWater2)).toBe(true);
|
||||
expect(map.isWater(rightWater1)).toBe(true);
|
||||
expect(map.isWater(rightWater2)).toBe(true);
|
||||
|
||||
const leftId1 = wc.getComponentId(leftWater1);
|
||||
const leftId2 = wc.getComponentId(leftWater2);
|
||||
const rightId1 = wc.getComponentId(rightWater1);
|
||||
const rightId2 = wc.getComponentId(rightWater2);
|
||||
|
||||
expect(leftId1).not.toBe(rightId1);
|
||||
|
||||
expect(leftId1).toBe(leftId2);
|
||||
expect(leftId1).toBeGreaterThan(0);
|
||||
expect(leftId1).not.toBe(LAND_MARKER);
|
||||
|
||||
expect(rightId1).toBe(rightId2);
|
||||
expect(rightId1).toBeGreaterThan(0);
|
||||
expect(rightId1).not.toBe(LAND_MARKER);
|
||||
});
|
||||
|
||||
it("returns LAND_MARKER for land tiles", () => {
|
||||
const map = createGameMap(twoComponentsMapData);
|
||||
const wc = new ConnectedComponents(map);
|
||||
wc.initialize();
|
||||
|
||||
const landTile1 = map.ref(2, 0);
|
||||
const landTile2 = map.ref(3, 2);
|
||||
const landTile3 = map.ref(4, 4);
|
||||
|
||||
expect(map.isLand(landTile1)).toBe(true);
|
||||
expect(map.isLand(landTile2)).toBe(true);
|
||||
expect(map.isLand(landTile3)).toBe(true);
|
||||
|
||||
expect(wc.getComponentId(landTile1)).toBe(LAND_MARKER);
|
||||
expect(wc.getComponentId(landTile2)).toBe(LAND_MARKER);
|
||||
expect(wc.getComponentId(landTile3)).toBe(LAND_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determinism", () => {
|
||||
it("produces same component IDs on repeated initialization", () => {
|
||||
const map = createGameMap(twoComponentsMapData);
|
||||
const wc1 = new ConnectedComponents(map);
|
||||
const wc2 = new ConnectedComponents(map);
|
||||
|
||||
wc1.initialize();
|
||||
wc2.initialize();
|
||||
|
||||
// Check all tiles have same component ID
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
const tile = map.ref(x, y);
|
||||
expect(wc1.getComponentId(tile)).toBe(wc2.getComponentId(tile));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct terrain access optimization", () => {
|
||||
it("produces same results with accessTerrainDirectly=false", () => {
|
||||
const map = createGameMap(twoComponentsMapData);
|
||||
const wcDirect = new ConnectedComponents(map, true);
|
||||
const wcIndirect = new ConnectedComponents(map, false);
|
||||
|
||||
wcDirect.initialize();
|
||||
wcIndirect.initialize();
|
||||
|
||||
// Check all tiles have same component ID
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
const tile = map.ref(x, y);
|
||||
expect(wcDirect.getComponentId(tile)).toBe(
|
||||
wcIndirect.getComponentId(tile),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// Minimal test maps for pathfinding unit tests
|
||||
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { createGame as createGameImpl } from "../../../src/core/game/GameImpl";
|
||||
import { GameMapImpl } from "../../../src/core/game/GameMap";
|
||||
import { UserSettings } from "../../../src/core/game/UserSettings";
|
||||
import { TestConfig } from "../../util/TestConfig";
|
||||
import { TestServerConfig } from "../../util/TestServerConfig";
|
||||
|
||||
export const W = "W"; // Water
|
||||
export const L = "L"; // Land
|
||||
|
||||
// Terrain encoding
|
||||
const WATER_BIT = 0x20;
|
||||
const LAND_BIT = 0x80;
|
||||
const SHORELINE_BIT = 6;
|
||||
|
||||
export type TestMapData = {
|
||||
width: number;
|
||||
height: number;
|
||||
grid: string[];
|
||||
};
|
||||
|
||||
// Compute shoreline bit for tiles adjacent to opposite terrain
|
||||
function computeShoreline(
|
||||
terrain: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
const isLand = (terrain[idx] & LAND_BIT) !== 0;
|
||||
const neighbors = [
|
||||
[x - 1, y],
|
||||
[x + 1, y],
|
||||
[x, y - 1],
|
||||
[x, y + 1],
|
||||
];
|
||||
|
||||
for (const [nx, ny] of neighbors) {
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
const neighborIsLand = (terrain[ny * width + nx] & LAND_BIT) !== 0;
|
||||
if (isLand !== neighborIsLand) {
|
||||
terrain[idx] |= 1 << SHORELINE_BIT;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5x5 simple island
|
||||
export function createIslandMap(): TestMapData {
|
||||
// prettier-ignore
|
||||
const grid = [
|
||||
W, W, W, W, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, L, L, L, W,
|
||||
W, W, W, W, W,
|
||||
];
|
||||
return { width: 5, height: 5, grid };
|
||||
}
|
||||
|
||||
// Create Game from test map data (computes shoreline bits)
|
||||
export function createGame(data: TestMapData): Game {
|
||||
const { width, height, grid } = data;
|
||||
|
||||
// Convert string grid to terrain bytes
|
||||
const terrain = new Uint8Array(width * height);
|
||||
let numLand = 0;
|
||||
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
if (grid[i] === L) {
|
||||
terrain[i] = LAND_BIT;
|
||||
numLand++;
|
||||
} else {
|
||||
terrain[i] = WATER_BIT;
|
||||
}
|
||||
}
|
||||
|
||||
computeShoreline(terrain, width, height);
|
||||
|
||||
const gameMap = new GameMapImpl(width, height, terrain, numLand);
|
||||
|
||||
// Create miniMap (2x2→1, water if ANY water)
|
||||
const miniWidth = Math.ceil(width / 2);
|
||||
const miniHeight = Math.ceil(height / 2);
|
||||
const miniTerrain = new Uint8Array(miniWidth * miniHeight);
|
||||
let miniNumLand = 0;
|
||||
|
||||
for (let my = 0; my < miniHeight; my++) {
|
||||
for (let mx = 0; mx < miniWidth; mx++) {
|
||||
const mIdx = my * miniWidth + mx;
|
||||
let hasWater = false;
|
||||
|
||||
for (let dy = 0; dy < 2; dy++) {
|
||||
for (let dx = 0; dx < 2; dx++) {
|
||||
const x = mx * 2 + dx;
|
||||
const y = my * 2 + dy;
|
||||
if (x < width && y < height && !(terrain[y * width + x] & LAND_BIT)) {
|
||||
hasWater = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWater) {
|
||||
miniTerrain[mIdx] = WATER_BIT;
|
||||
} else {
|
||||
miniTerrain[mIdx] = LAND_BIT;
|
||||
miniNumLand++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
computeShoreline(miniTerrain, miniWidth, miniHeight);
|
||||
|
||||
const miniGameMap = new GameMapImpl(
|
||||
miniWidth,
|
||||
miniHeight,
|
||||
miniTerrain,
|
||||
miniNumLand,
|
||||
);
|
||||
|
||||
const serverConfig = new TestServerConfig();
|
||||
const gameConfig = {
|
||||
gameMap: GameMapType.Asia,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
gameMode: GameMode.FFA,
|
||||
gameType: GameType.Singleplayer,
|
||||
difficulty: Difficulty.Medium,
|
||||
disableNations: false,
|
||||
donateGold: false,
|
||||
donateTroops: false,
|
||||
bots: 0,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNavMesh: false,
|
||||
randomSpawn: false,
|
||||
};
|
||||
const config = new TestConfig(
|
||||
serverConfig,
|
||||
gameConfig,
|
||||
new UserSettings(),
|
||||
false,
|
||||
);
|
||||
|
||||
return createGameImpl([], [], gameMap, miniGameMap, config);
|
||||
}
|
||||
|
||||
// Create GameMapImpl from test map data (for map-only tests)
|
||||
export function createGameMap(data: TestMapData): GameMapImpl {
|
||||
const { width, height, grid } = data;
|
||||
|
||||
const terrain = new Uint8Array(width * height);
|
||||
let numLand = 0;
|
||||
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
if (grid[i] === L) {
|
||||
terrain[i] = LAND_BIT;
|
||||
numLand++;
|
||||
} else {
|
||||
terrain[i] = WATER_BIT;
|
||||
}
|
||||
}
|
||||
|
||||
computeShoreline(terrain, width, height);
|
||||
|
||||
return new GameMapImpl(width, height, terrain, numLand);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer";
|
||||
import { PathFinder } from "../../../../src/core/pathfinding/types";
|
||||
|
||||
describe("ComponentCheckTransformer", () => {
|
||||
// Mock PathFinder that records calls and returns a simple path
|
||||
function createMockPathFinder(): PathFinder<number> & {
|
||||
calls: Array<{ from: number | number[]; to: number }>;
|
||||
} {
|
||||
const calls: Array<{ from: number | number[]; to: number }> = [];
|
||||
|
||||
return {
|
||||
calls,
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
calls.push({ from, to });
|
||||
const start = Array.isArray(from) ? from[0] : from;
|
||||
return [start, to];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Component function: even numbers → component 0, odd → component 1
|
||||
const evenOddComponent = (t: number) => t % 2;
|
||||
|
||||
describe("findPath", () => {
|
||||
it("delegates when source and destination in same component", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath(2, 4); // both even → component 0
|
||||
|
||||
expect(result).toEqual([2, 4]);
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(inner.calls[0]).toEqual({ from: 2, to: 4 });
|
||||
});
|
||||
|
||||
it("returns null when source and destination in different components", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath(2, 3); // even → odd
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0); // inner not called
|
||||
});
|
||||
|
||||
it("filters multiple sources to only valid ones", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
// Sources: 1, 2, 3, 4 → odd, even, odd, even
|
||||
// Destination: 4 → even
|
||||
// Valid sources: 2, 4
|
||||
const result = transformer.findPath([1, 2, 3, 4], 4);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(inner.calls[0].from).toEqual([2, 4]); // filtered to valid
|
||||
expect(inner.calls[0].to).toBe(4);
|
||||
});
|
||||
|
||||
it("returns null when no source in same component", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
// All sources odd, destination even
|
||||
const result = transformer.findPath([1, 3, 5], 4);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("unwraps single valid source from array", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
// Only one source matches
|
||||
const result = transformer.findPath([1, 2, 3], 4);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(inner.calls[0].from).toBe(2);
|
||||
});
|
||||
|
||||
it("handles single source (not array)", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath(4, 6);
|
||||
|
||||
expect(result).toEqual([4, 6]);
|
||||
expect(inner.calls[0].from).toBe(4);
|
||||
});
|
||||
|
||||
it("propagates null from inner pathfinder", () => {
|
||||
const inner: PathFinder<number> = {
|
||||
findPath: () => null,
|
||||
};
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath(2, 4);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("propagates path from inner pathfinder", () => {
|
||||
const inner: PathFinder<number> = {
|
||||
findPath: () => [10, 20, 30, 40],
|
||||
};
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath(2, 4);
|
||||
|
||||
expect(result).toEqual([10, 20, 30, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty source array", () => {
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ComponentCheckTransformer(
|
||||
inner,
|
||||
evenOddComponent,
|
||||
);
|
||||
|
||||
const result = transformer.findPath([], 4);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("works with custom component function", () => {
|
||||
const inner = createMockPathFinder();
|
||||
// Component by tens digit: 10-19 → 1, 20-29 → 2, etc.
|
||||
const tensComponent = (t: number) => Math.floor(t / 10);
|
||||
const transformer = new ComponentCheckTransformer(inner, tensComponent);
|
||||
|
||||
// Same component
|
||||
expect(transformer.findPath(15, 18)).not.toBeNull();
|
||||
|
||||
// Different component
|
||||
expect(transformer.findPath(15, 25)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameMapImpl } from "../../../../src/core/game/GameMap";
|
||||
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer";
|
||||
import { PathFinder } from "../../../../src/core/pathfinding/types";
|
||||
|
||||
describe("MiniMapTransformer", () => {
|
||||
// Create test maps: main map is 10x10, minimap is 5x5 (2x downscale)
|
||||
function createTestMaps() {
|
||||
const W = 0x20; // Water
|
||||
const mainTerrain = new Uint8Array(100).fill(W); // 10x10 all water
|
||||
const miniTerrain = new Uint8Array(25).fill(W); // 5x5 all water
|
||||
|
||||
const map = new GameMapImpl(10, 10, mainTerrain, 0);
|
||||
const miniMap = new GameMapImpl(5, 5, miniTerrain, 0);
|
||||
|
||||
return { map, miniMap };
|
||||
}
|
||||
|
||||
function createMockPathFinder(): PathFinder<number> & {
|
||||
calls: Array<{ from: number | number[]; to: number }>;
|
||||
returnPath: number[] | null | undefined;
|
||||
} {
|
||||
const mock = {
|
||||
calls: [] as Array<{ from: number | number[]; to: number }>,
|
||||
returnPath: undefined as number[] | null | undefined,
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
mock.calls.push({ from, to });
|
||||
if (mock.returnPath !== undefined) return mock.returnPath;
|
||||
const start = Array.isArray(from) ? from[0] : from;
|
||||
return [start, to];
|
||||
},
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe("findPath", () => {
|
||||
it("converts coordinates to minimap scale", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
const from = map.ref(4, 6);
|
||||
const to = map.ref(8, 2);
|
||||
|
||||
const miniFrom = miniMap.ref(2, 3);
|
||||
const miniTo = miniMap.ref(4, 1);
|
||||
inner.returnPath = [miniFrom, miniTo];
|
||||
|
||||
transformer.findPath(from, to);
|
||||
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(inner.calls[0].from).toBe(miniFrom);
|
||||
expect(inner.calls[0].to).toBe(miniTo);
|
||||
});
|
||||
|
||||
it("upscales minimap path back to full resolution", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
const from = map.ref(0, 0);
|
||||
const to = map.ref(8, 0);
|
||||
|
||||
// Minimap path: (0,0) → (4,0) - straight horizontal
|
||||
inner.returnPath = [
|
||||
miniMap.ref(0, 0),
|
||||
miniMap.ref(1, 0),
|
||||
miniMap.ref(2, 0),
|
||||
miniMap.ref(3, 0),
|
||||
miniMap.ref(4, 0),
|
||||
];
|
||||
|
||||
const result = transformer.findPath(from, to);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(from);
|
||||
expect(result![result!.length - 1]).toBe(to);
|
||||
});
|
||||
|
||||
it("returns null when inner returns null", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
inner.returnPath = null;
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when inner returns empty path", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
inner.returnPath = [];
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles multiple sources", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
const from1 = map.ref(0, 0);
|
||||
const from2 = map.ref(2, 0);
|
||||
const to = map.ref(8, 0);
|
||||
|
||||
inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 0)];
|
||||
|
||||
const result = transformer.findPath([from1, from2], to);
|
||||
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(Array.isArray(inner.calls[0].from)).toBe(true);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("fixes path extremes to match original from/to", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
// From odd coords - won't exactly map to minimap
|
||||
const from = map.ref(1, 1);
|
||||
const to = map.ref(9, 9);
|
||||
|
||||
inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 4)];
|
||||
|
||||
const result = transformer.findPath(from, to);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(from);
|
||||
expect(result![result!.length - 1]).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coordinate mapping", () => {
|
||||
it("maps main coords (0,0) to mini coords (0,0)", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
inner.returnPath = [miniMap.ref(0, 0)];
|
||||
|
||||
transformer.findPath(map.ref(0, 0), map.ref(0, 0));
|
||||
|
||||
expect(inner.calls[0].from).toBe(miniMap.ref(0, 0));
|
||||
expect(inner.calls[0].to).toBe(miniMap.ref(0, 0));
|
||||
});
|
||||
|
||||
it("maps main coords (1,1) to mini coords (0,0) (floor division)", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
inner.returnPath = [miniMap.ref(0, 0)];
|
||||
|
||||
transformer.findPath(map.ref(1, 1), map.ref(1, 1));
|
||||
|
||||
expect(inner.calls[0].from).toBe(miniMap.ref(0, 0));
|
||||
expect(inner.calls[0].to).toBe(miniMap.ref(0, 0));
|
||||
});
|
||||
|
||||
it("maps main coords (2,2) to mini coords (1,1)", () => {
|
||||
const { map, miniMap } = createTestMaps();
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new MiniMapTransformer(inner, map, miniMap);
|
||||
|
||||
inner.returnPath = [miniMap.ref(1, 1)];
|
||||
|
||||
transformer.findPath(map.ref(2, 2), map.ref(2, 2));
|
||||
|
||||
expect(inner.calls[0].from).toBe(miniMap.ref(1, 1));
|
||||
expect(inner.calls[0].to).toBe(miniMap.ref(1, 1));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer";
|
||||
import { PathFinder } from "../../../../src/core/pathfinding/types";
|
||||
import { createGameMap, createIslandMap, L, W } from "../_fixtures";
|
||||
|
||||
describe("ShoreCoercingTransformer", () => {
|
||||
// Mock PathFinder that records calls and returns configurable path
|
||||
function createMockPathFinder(): PathFinder<number> & {
|
||||
calls: Array<{ from: number | number[]; to: number }>;
|
||||
returnPath: number[] | null | undefined;
|
||||
} {
|
||||
const mock = {
|
||||
calls: [] as Array<{ from: number | number[]; to: number }>,
|
||||
returnPath: undefined as number[] | null | undefined,
|
||||
findPath(from: number | number[], to: number): number[] | null {
|
||||
mock.calls.push({ from, to });
|
||||
if (mock.returnPath !== undefined) return mock.returnPath;
|
||||
const start = Array.isArray(from) ? from[0] : from;
|
||||
return [start, to];
|
||||
},
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe("findPath", () => {
|
||||
it("passes water tiles unchanged", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const water1 = map.ref(0, 0);
|
||||
const water2 = map.ref(4, 0);
|
||||
inner.returnPath = [water1, water2];
|
||||
|
||||
const result = transformer.findPath(water1, water2);
|
||||
|
||||
expect(result).toEqual([water1, water2]);
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
expect(inner.calls[0].from).toBe(water1);
|
||||
expect(inner.calls[0].to).toBe(water2);
|
||||
});
|
||||
|
||||
it("coerces shore start to water and prepends original", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const shore = map.ref(1, 1);
|
||||
const water = map.ref(4, 4);
|
||||
const shoreWaterNeighbor = map.ref(1, 0);
|
||||
|
||||
const result = transformer.findPath(shore, water);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(shore);
|
||||
expect(result![1]).toBe(shoreWaterNeighbor);
|
||||
});
|
||||
|
||||
it("coerces shore destination to water and appends original", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const water = map.ref(0, 0);
|
||||
const shore = map.ref(1, 1);
|
||||
const shoreWaterNeighbor = map.ref(1, 0);
|
||||
|
||||
const result = transformer.findPath(water, shore);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(water);
|
||||
expect(result![result!.length - 2]).toBe(shoreWaterNeighbor);
|
||||
expect(result![result!.length - 1]).toBe(shore);
|
||||
});
|
||||
|
||||
it("coerces both shore start and destination", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const shore1 = map.ref(1, 1);
|
||||
const shore1WaterNeighbor = map.ref(1, 0);
|
||||
const shore2 = map.ref(3, 3);
|
||||
const shore2WaterNeighbor = map.ref(3, 4);
|
||||
|
||||
const result = transformer.findPath(shore1, shore2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(shore1);
|
||||
expect(result![1]).toBe(shore1WaterNeighbor);
|
||||
expect(result![result!.length - 2]).toBe(shore2WaterNeighbor);
|
||||
expect(result![result!.length - 1]).toBe(shore2);
|
||||
});
|
||||
|
||||
it("returns null when source has no water neighbor", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
// Center land tile (2,2) has no water neighbors
|
||||
const land = map.ref(2, 2);
|
||||
const water = map.ref(0, 0);
|
||||
|
||||
const result = transformer.findPath(land, water);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null when destination has no water neighbor", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
// Center land tile (2,2) has no water neighbors
|
||||
const land = map.ref(2, 2);
|
||||
const water = map.ref(0, 0);
|
||||
|
||||
const result = transformer.findPath(water, land);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null when inner pathfinder returns null", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
inner.returnPath = null;
|
||||
const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when inner pathfinder returns empty path", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
inner.returnPath = [];
|
||||
const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles multiple sources, filters invalid ones", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const waterSrc = map.ref(0, 0);
|
||||
const shoreSrc = map.ref(1, 1);
|
||||
const landSrc = map.ref(2, 2);
|
||||
const waterDest = map.ref(4, 4);
|
||||
|
||||
inner.returnPath = [waterSrc, waterDest];
|
||||
|
||||
const result = transformer.findPath(
|
||||
[waterSrc, shoreSrc, landSrc],
|
||||
waterDest,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(inner.calls).toHaveLength(1);
|
||||
|
||||
const fromArg = inner.calls[0].from;
|
||||
expect(Array.isArray(fromArg)).toBe(true);
|
||||
expect((fromArg as number[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when all sources are invalid", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const land = map.ref(2, 2);
|
||||
|
||||
const result = transformer.findPath([land], map.ref(0, 0));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(inner.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determinism", () => {
|
||||
it("shore with multiple water neighbors selects consistently", () => {
|
||||
// prettier-ignore
|
||||
const map = createGameMap({
|
||||
width: 5, height: 5, grid: [
|
||||
L, L, W, W, W,
|
||||
L, L, W, W, W,
|
||||
L, L, W, L, L,
|
||||
W, W, W, L, L,
|
||||
W, W, W, L, L,
|
||||
],
|
||||
});
|
||||
|
||||
const shoreWithMultipleWater = map.ref(1, 2);
|
||||
const expectedWaterNeighbor = map.ref(1, 3);
|
||||
|
||||
const inner1 = createMockPathFinder();
|
||||
const inner2 = createMockPathFinder();
|
||||
const transformer1 = new ShoreCoercingTransformer(inner1, map);
|
||||
const transformer2 = new ShoreCoercingTransformer(inner2, map);
|
||||
|
||||
const waterDest = map.ref(2, 4);
|
||||
|
||||
transformer1.findPath(shoreWithMultipleWater, waterDest);
|
||||
transformer2.findPath(shoreWithMultipleWater, waterDest);
|
||||
|
||||
// Both select the same water neighbor: (1,3)
|
||||
expect(inner1.calls[0].from).toBe(expectedWaterNeighbor);
|
||||
expect(inner2.calls[0].from).toBe(expectedWaterNeighbor);
|
||||
});
|
||||
|
||||
it("corner shore with water neighbors works correctly", () => {
|
||||
const mapData = createIslandMap();
|
||||
const map = createGameMap(mapData);
|
||||
const inner = createMockPathFinder();
|
||||
const transformer = new ShoreCoercingTransformer(inner, map);
|
||||
|
||||
const cornerShore = map.ref(1, 1);
|
||||
const waterNeighbor = map.ref(1, 0);
|
||||
const waterDest = map.ref(4, 4);
|
||||
|
||||
inner.returnPath = [waterNeighbor, waterDest];
|
||||
|
||||
const result = transformer.findPath(cornerShore, waterDest);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual([cornerShore, waterNeighbor, waterDest]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { createGame } from "../../../src/core/game/GameImpl";
|
||||
import { GameMapImpl } from "../../../src/core/game/GameMap";
|
||||
import { UserSettings } from "../../../src/core/game/UserSettings";
|
||||
import { TestConfig } from "../../util/TestConfig";
|
||||
import { TestServerConfig } from "../../util/TestServerConfig";
|
||||
|
||||
const LAND_BIT = 7;
|
||||
const OCEAN_BIT = 5;
|
||||
|
||||
/**
|
||||
* Creates a Game from inline map strings.
|
||||
* Each char = 1 tile: W=water (ocean), L=land
|
||||
* miniMap automatically generated (2x2→1, water if ANY tile water)
|
||||
*
|
||||
* Example:
|
||||
* const game = await gameFromString([
|
||||
* 'WWWWW',
|
||||
* 'WLLLW',
|
||||
* 'WWWWW'
|
||||
* ]);
|
||||
*/
|
||||
export async function gameFromString(mapRows: string[]): Promise<Game> {
|
||||
const height = mapRows.length;
|
||||
const width = mapRows[0].length;
|
||||
|
||||
for (const row of mapRows) {
|
||||
if (row.length !== width) {
|
||||
throw new Error(
|
||||
`All rows must have same width. Expected ${width}, got ${row.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const terrainData = new Uint8Array(width * height);
|
||||
let numLandTiles = 0;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
const char = mapRows[y][x];
|
||||
|
||||
if (char === "L") {
|
||||
terrainData[idx] = 1 << LAND_BIT; // Set land bit
|
||||
numLandTiles++;
|
||||
} else if (char === "W") {
|
||||
terrainData[idx] = 1 << OCEAN_BIT; // Set ocean bit (water)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown char '${char}' at (${x},${y}). Use W=water, L=land`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gameMap = new GameMapImpl(width, height, terrainData, numLandTiles);
|
||||
|
||||
// Create miniMap (2x2→1, water if ANY tile water)
|
||||
const miniWidth = Math.ceil(width / 2);
|
||||
const miniHeight = Math.ceil(height / 2);
|
||||
const miniTerrainData = new Uint8Array(miniWidth * miniHeight);
|
||||
let miniNumLandTiles = 0;
|
||||
|
||||
for (let miniY = 0; miniY < miniHeight; miniY++) {
|
||||
for (let miniX = 0; miniX < miniWidth; miniX++) {
|
||||
const miniIdx = miniY * miniWidth + miniX;
|
||||
|
||||
// Check 2x2 chunk: if ANY tile is water, miniMap tile is water
|
||||
let water = false;
|
||||
|
||||
for (let dy = 0; dy < 2; dy++) {
|
||||
for (let dx = 0; dx < 2; dx++) {
|
||||
const x = miniX * 2 + dx;
|
||||
const y = miniY * 2 + dy;
|
||||
|
||||
if (x < width && y < height) {
|
||||
const idx = y * width + x;
|
||||
if (!(terrainData[idx] & (1 << LAND_BIT))) {
|
||||
water = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Water if ANY tile is water
|
||||
if (water) {
|
||||
miniTerrainData[miniIdx] = 1 << OCEAN_BIT; // ocean
|
||||
} else {
|
||||
miniTerrainData[miniIdx] = 1 << LAND_BIT; // land
|
||||
miniNumLandTiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const miniGameMap = new GameMapImpl(
|
||||
miniWidth,
|
||||
miniHeight,
|
||||
miniTerrainData,
|
||||
miniNumLandTiles,
|
||||
);
|
||||
|
||||
// Create game config
|
||||
const serverConfig = new TestServerConfig();
|
||||
const gameConfig = {
|
||||
gameMap: GameMapType.Asia,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
gameMode: GameMode.FFA,
|
||||
gameType: GameType.Singleplayer,
|
||||
difficulty: Difficulty.Medium,
|
||||
disableNations: false,
|
||||
donateGold: false,
|
||||
donateTroops: false,
|
||||
bots: 0,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNavMesh: false,
|
||||
randomSpawn: false,
|
||||
};
|
||||
const config = new TestConfig(
|
||||
serverConfig,
|
||||
gameConfig,
|
||||
new UserSettings(),
|
||||
false,
|
||||
);
|
||||
|
||||
return createGame([], [], gameMap, miniGameMap, config);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Compare pathfinding adapters side-by-side
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
|
||||
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx tests/pathfinding/benchmark/compare.ts default hpa,legacy
|
||||
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,legacy,a.optimized
|
||||
*/
|
||||
|
||||
import {
|
||||
type BenchmarkResult,
|
||||
calculateStats,
|
||||
getAdapter,
|
||||
getScenario,
|
||||
measureExecutionTime,
|
||||
measurePathLength,
|
||||
} from "../utils";
|
||||
|
||||
interface AdapterResults {
|
||||
adapter: string;
|
||||
initTime: number;
|
||||
totalTime: number;
|
||||
totalDistance: number;
|
||||
successfulRoutes: number;
|
||||
totalRoutes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ITERATIONS = 1;
|
||||
|
||||
async function runBenchmark(
|
||||
scenarioName: string,
|
||||
adapterName: string,
|
||||
): Promise<AdapterResults> {
|
||||
const { game, routes, initTime } = await getScenario(
|
||||
scenarioName,
|
||||
adapterName,
|
||||
);
|
||||
const adapter = getAdapter(game, adapterName);
|
||||
|
||||
const results: BenchmarkResult[] = [];
|
||||
|
||||
// Measure path lengths
|
||||
for (const route of routes) {
|
||||
const pathLength = measurePathLength(adapter, route);
|
||||
results.push({ route: route.name, pathLength, executionTime: null });
|
||||
}
|
||||
|
||||
// Measure execution times
|
||||
for (const route of routes) {
|
||||
const result = results.find((r) => r.route === route.name);
|
||||
if (result && result.pathLength !== null) {
|
||||
const execTime = measureExecutionTime(adapter, route, DEFAULT_ITERATIONS);
|
||||
result.executionTime = execTime;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = calculateStats(results);
|
||||
|
||||
return {
|
||||
adapter: adapterName,
|
||||
initTime,
|
||||
totalTime: stats.totalTime,
|
||||
totalDistance: stats.totalDistance,
|
||||
successfulRoutes: stats.successfulRoutes,
|
||||
totalRoutes: stats.totalRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
const TABLE_HEADERS = [
|
||||
"Adapter",
|
||||
"Init (ms)",
|
||||
"Path (ms)",
|
||||
"Distance",
|
||||
"Routes",
|
||||
];
|
||||
|
||||
const TABLE_WIDTHS = [20, 12, 12, 12, 10];
|
||||
|
||||
function printTableHeader(scenarioName: string) {
|
||||
console.log(`\nResults: ${scenarioName}`);
|
||||
console.log("=".repeat(70));
|
||||
console.log(TABLE_HEADERS.map((h, i) => h.padEnd(TABLE_WIDTHS[i])).join(" "));
|
||||
console.log("-".repeat(70));
|
||||
}
|
||||
|
||||
function printTableRow(r: AdapterResults) {
|
||||
const row = [
|
||||
r.adapter,
|
||||
r.initTime.toFixed(2),
|
||||
r.totalTime.toFixed(2),
|
||||
r.totalDistance.toString(),
|
||||
`${r.successfulRoutes}/${r.totalRoutes}`,
|
||||
];
|
||||
console.log(row.map((c, i) => c.padEnd(TABLE_WIDTHS[i])).join(" "));
|
||||
}
|
||||
|
||||
function printTableFooter() {
|
||||
console.log("-".repeat(70));
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
|
||||
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
|
||||
|
||||
Arguments:
|
||||
<scenario> Name of the scenario (default: "default")
|
||||
<adapters> Comma-separated list of adapters to compare (e.g., "hpa,legacy,a.optimized")
|
||||
|
||||
Examples:
|
||||
npx tsx tests/pathfinding/benchmark/compare.ts default hpa,legacy
|
||||
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,legacy,a.optimized
|
||||
|
||||
Available adapters:
|
||||
hpa - Hierarchical pathfinding (no cache)
|
||||
hpa.cached - Hierarchical pathfinding (with cache)
|
||||
legacy - Legacy A* algorithm
|
||||
a.optimized - Optimized A* algorithm
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const isSynthetic = args.includes("--synthetic");
|
||||
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (nonFlagArgs.length < 2) {
|
||||
console.error("Error: requires <scenario> and <adapters> arguments");
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenarioArg = nonFlagArgs[0];
|
||||
const adaptersArg = nonFlagArgs[1];
|
||||
const adapters = adaptersArg.split(",").map((a) => a.trim());
|
||||
|
||||
if (adapters.length < 1) {
|
||||
console.error("Error: at least one adapter required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenarioName = isSynthetic ? `synthetic/${scenarioArg}` : scenarioArg;
|
||||
|
||||
console.log(
|
||||
`Comparing ${adapters.length} adapters on scenario: ${scenarioName}`,
|
||||
);
|
||||
console.log(`Adapters: ${adapters.join(", ")}`);
|
||||
console.log("");
|
||||
|
||||
printTableHeader(scenarioName);
|
||||
|
||||
for (const adapter of adapters) {
|
||||
try {
|
||||
const result = await runBenchmark(scenarioName, adapter);
|
||||
printTableRow(result);
|
||||
} catch (error) {
|
||||
console.log(`${adapter.padEnd(TABLE_WIDTHS[0])} FAILED: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
printTableFooter();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { Game } from "../../../../src/core/game/Game.js";
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
|
||||
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
|
||||
import { setupFromPath } from "../../utils.js";
|
||||
|
||||
// Available comparison adapters
|
||||
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison
|
||||
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"];
|
||||
|
||||
export interface MapInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -13,7 +16,7 @@ export interface MapInfo {
|
||||
|
||||
export interface MapCache {
|
||||
game: Game;
|
||||
navMesh: NavMesh;
|
||||
hpaStar: AStarWaterHierarchical;
|
||||
}
|
||||
|
||||
const cache = new Map<string, MapCache>();
|
||||
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
|
||||
const mapsDir = getMapsDirectory();
|
||||
|
||||
// Use the existing setupFromPath utility to load the map
|
||||
const game = await setupFromPath(mapsDir, mapName);
|
||||
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
|
||||
|
||||
// Initialize NavMesh
|
||||
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
|
||||
navMesh.initialize();
|
||||
// Get pre-built graph from game
|
||||
const graph = game.miniWaterGraph();
|
||||
if (!graph) {
|
||||
throw new Error(`No water graph available for map: ${mapName}`);
|
||||
}
|
||||
|
||||
const cacheEntry: MapCache = { game, navMesh };
|
||||
// Initialize AStarWaterHierarchical with minimap and graph
|
||||
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
|
||||
cachePaths: config.cachePaths,
|
||||
});
|
||||
|
||||
const cacheEntry: MapCache = { game, hpaStar };
|
||||
|
||||
// Store in cache
|
||||
cache.set(mapName, cacheEntry);
|
||||
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
|
||||
* Get map metadata for client
|
||||
*/
|
||||
export async function getMapMetadata(mapName: string) {
|
||||
const { game, navMesh } = await loadMap(mapName);
|
||||
const { game, hpaStar } = await loadMap(mapName);
|
||||
|
||||
// Extract map data
|
||||
const mapData: number[] = [];
|
||||
@@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract static graph data from NavMesh
|
||||
// Extract static graph data from GameMapHPAStar
|
||||
// Access internal graph via type casting (test code only)
|
||||
const graph = (hpaStar as any).graph;
|
||||
const miniMap = game.miniMap();
|
||||
const navMeshGraph = (navMesh as any).graph;
|
||||
|
||||
// Convert gateways from Map to array
|
||||
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
|
||||
const allGateways = gatewaysArray.map((gw: any) => ({
|
||||
id: gw.id,
|
||||
x: miniMap.x(gw.tile),
|
||||
y: miniMap.y(gw.tile),
|
||||
// Convert nodes to client format
|
||||
const allNodes = graph.getAllNodes().map((node: any) => ({
|
||||
id: node.id,
|
||||
x: miniMap.x(node.tile),
|
||||
y: miniMap.y(node.tile),
|
||||
}));
|
||||
|
||||
// Create a lookup map from gateway ID to gateway for edge conversion
|
||||
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
|
||||
// Convert edges to client format
|
||||
const edges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: number[];
|
||||
to: number[];
|
||||
cost: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < graph.edgeCount; i++) {
|
||||
const edge = graph.getEdge(i);
|
||||
if (!edge) continue;
|
||||
|
||||
// Convert edges from Map<gatewayId, Edge[]> to flat array
|
||||
// The edges Map has gateway IDs as keys, and arrays of edges as values
|
||||
const allEdges: any[] = [];
|
||||
for (const edgeArray of navMeshGraph.edges.values()) {
|
||||
allEdges.push(...edgeArray);
|
||||
const nodeA = graph.getNode(edge.nodeA);
|
||||
const nodeB = graph.getNode(edge.nodeB);
|
||||
if (!nodeA || !nodeB) continue;
|
||||
|
||||
edges.push({
|
||||
fromId: edge.nodeA,
|
||||
toId: edge.nodeB,
|
||||
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
|
||||
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
|
||||
cost: edge.cost,
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate edges (they're bidirectional, so each edge appears twice)
|
||||
const seenEdges = new Set<string>();
|
||||
const edges = allEdges
|
||||
.filter((edge: any) => {
|
||||
const edgeKey =
|
||||
edge.from < edge.to
|
||||
? `${edge.from}-${edge.to}`
|
||||
: `${edge.to}-${edge.from}`;
|
||||
if (seenEdges.has(edgeKey)) return false;
|
||||
seenEdges.add(edgeKey);
|
||||
return true;
|
||||
})
|
||||
.map((edge: any) => {
|
||||
const fromGateway = gatewayById.get(edge.from);
|
||||
const toGateway = gatewayById.get(edge.to);
|
||||
|
||||
return {
|
||||
fromId: edge.from,
|
||||
toId: edge.to,
|
||||
from: fromGateway
|
||||
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
|
||||
: [0, 0],
|
||||
to: toGateway
|
||||
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
|
||||
: [0, 0],
|
||||
cost: edge.cost,
|
||||
path: edge.path
|
||||
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
|
||||
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
|
||||
);
|
||||
|
||||
const sectorSize = navMeshGraph.sectorSize;
|
||||
const clusterSize = graph.clusterSize;
|
||||
|
||||
return {
|
||||
name: mapName,
|
||||
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
|
||||
height: game.height(),
|
||||
mapData,
|
||||
graphDebug: {
|
||||
allGateways,
|
||||
allNodes,
|
||||
edges,
|
||||
sectorSize,
|
||||
clusterSize,
|
||||
},
|
||||
adapters: COMPARISON_ADAPTERS,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
|
||||
import { loadMap } from "./maps.js";
|
||||
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
|
||||
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js";
|
||||
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js";
|
||||
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js";
|
||||
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js";
|
||||
import {
|
||||
PathFinder,
|
||||
SteppingPathFinder,
|
||||
} from "../../../../src/core/pathfinding/types.js";
|
||||
import { getAdapter } from "../../utils.js";
|
||||
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
|
||||
|
||||
interface PathfindingOptions {
|
||||
includePfMini?: boolean;
|
||||
includeNavMesh?: boolean;
|
||||
}
|
||||
|
||||
interface NavMeshResult {
|
||||
// Primary result with debug info
|
||||
interface PrimaryResult {
|
||||
path: Array<[number, number]> | null;
|
||||
initialPath: Array<[number, number]> | null;
|
||||
gateways: Array<[number, number]> | null;
|
||||
timings: any;
|
||||
length: number;
|
||||
time: number;
|
||||
debug: {
|
||||
nodePath: Array<[number, number]> | null;
|
||||
initialPath: Array<[number, number]> | null;
|
||||
timings: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
interface PfMiniResult {
|
||||
// Comparison result (path + timing only)
|
||||
interface ComparisonResult {
|
||||
adapter: string;
|
||||
path: Array<[number, number]> | null;
|
||||
length: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// Cache pathfinding adapters per map
|
||||
const pfMiniCache = new Map<string, MiniAStarAdapter>();
|
||||
export interface PathfindResult {
|
||||
primary: PrimaryResult;
|
||||
comparisons: ComparisonResult[];
|
||||
}
|
||||
|
||||
// Cache adapters per map
|
||||
const adapterCache = new Map<
|
||||
string,
|
||||
Map<string, SteppingPathFinder<TileRef>>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Get or create MiniAStar adapter for a map
|
||||
* Get or create an adapter for a map
|
||||
*/
|
||||
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
|
||||
if (!pfMiniCache.has(mapName)) {
|
||||
const adapter = new MiniAStarAdapter(game, { waterPath: true });
|
||||
pfMiniCache.set(mapName, adapter);
|
||||
function getOrCreateAdapter(
|
||||
mapName: string,
|
||||
adapterName: string,
|
||||
game: any,
|
||||
): SteppingPathFinder<TileRef> {
|
||||
if (!adapterCache.has(mapName)) {
|
||||
adapterCache.set(mapName, new Map());
|
||||
}
|
||||
return pfMiniCache.get(mapName)!;
|
||||
const mapAdapters = adapterCache.get(mapName)!;
|
||||
|
||||
if (!mapAdapters.has(adapterName)) {
|
||||
mapAdapters.set(adapterName, getAdapter(game, adapterName));
|
||||
}
|
||||
return mapAdapters.get(adapterName)!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,110 +73,177 @@ function pathToCoords(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pathfinding between two points
|
||||
* Build the full transformer chain like PathFinding.Water() does
|
||||
*/
|
||||
export async function computePath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
options: PathfindingOptions = {},
|
||||
): Promise<NavMeshResult> {
|
||||
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
|
||||
function buildWrappedPathFinder(
|
||||
hpaStar: AStarWaterHierarchical,
|
||||
game: any,
|
||||
graph: any,
|
||||
): PathFinder<TileRef> {
|
||||
const miniMap = game.miniMap();
|
||||
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||
|
||||
// Convert coordinates to TileRefs
|
||||
const fromRef = game.ref(from[0], from[1]);
|
||||
const toRef = game.ref(to[0], to[1]);
|
||||
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
|
||||
const withComponentCheck = new ComponentCheckTransformer(
|
||||
hpaStar,
|
||||
componentCheckFn,
|
||||
);
|
||||
const withSmoothing = new BresenhamSmoothingTransformer(
|
||||
withComponentCheck,
|
||||
miniMap,
|
||||
);
|
||||
const withShoreCoercing = new ShoreCoercingTransformer(
|
||||
withSmoothing,
|
||||
miniMap,
|
||||
);
|
||||
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
|
||||
|
||||
// Validate that both points are water tiles
|
||||
if (!game.isWater(fromRef)) {
|
||||
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
|
||||
}
|
||||
if (!game.isWater(toRef)) {
|
||||
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
|
||||
}
|
||||
|
||||
// Compute NavMesh path
|
||||
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
|
||||
const path = pathToCoords(navMeshPath, game);
|
||||
return withMiniMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute primary path using AStarWaterHierarchical with debug info
|
||||
* Uses the same transformer chain as PathFinding.Water()
|
||||
*/
|
||||
function computePrimaryPath(
|
||||
hpaStar: AStarWaterHierarchical,
|
||||
game: any,
|
||||
graph: any,
|
||||
fromRef: TileRef,
|
||||
toRef: TileRef,
|
||||
): PrimaryResult {
|
||||
const miniMap = game.miniMap();
|
||||
|
||||
// Extract debug info
|
||||
let gateways: Array<[number, number]> | null = null;
|
||||
// Build wrapped pathfinder with all transformers
|
||||
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph);
|
||||
|
||||
// Enable debug mode to capture internal state
|
||||
hpaStar.debugMode = true;
|
||||
|
||||
const start = performance.now();
|
||||
const path = wrappedPf.findPath(fromRef, toRef);
|
||||
const time = performance.now() - start;
|
||||
|
||||
const debugInfo = hpaStar.debugInfo;
|
||||
|
||||
// Convert node path (miniMap coords) to full map coords
|
||||
let nodePath: Array<[number, number]> | null = null;
|
||||
if (debugInfo?.nodePath) {
|
||||
nodePath = debugInfo.nodePath.map((tile: TileRef) => {
|
||||
const x = miniMap.x(tile) * 2;
|
||||
const y = miniMap.y(tile) * 2;
|
||||
return [x, y] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
// Convert initialPath (miniMap TileRefs) to full map coords
|
||||
let initialPath: Array<[number, number]> | null = null;
|
||||
let timings: any = {};
|
||||
|
||||
if (navMeshAdapter.debugInfo) {
|
||||
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
|
||||
if (navMeshAdapter.debugInfo.gatewayPath) {
|
||||
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
|
||||
const x = miniMap.x(tile) * 2;
|
||||
const y = miniMap.y(tile) * 2;
|
||||
return [x, y] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
// Convert initial path
|
||||
if (navMeshAdapter.debugInfo.initialPath) {
|
||||
initialPath = navMeshAdapter.debugInfo.initialPath.map(
|
||||
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
|
||||
);
|
||||
}
|
||||
|
||||
timings = navMeshAdapter.debugInfo.timings || {};
|
||||
if (debugInfo?.initialPath) {
|
||||
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
|
||||
const x = miniMap.x(tile) * 2;
|
||||
const y = miniMap.y(tile) * 2;
|
||||
return [x, y] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
initialPath,
|
||||
gateways,
|
||||
timings,
|
||||
path: pathToCoords(path, game),
|
||||
length: path ? path.length : 0,
|
||||
time: timings.total ?? 0,
|
||||
time,
|
||||
debug: {
|
||||
nodePath,
|
||||
initialPath,
|
||||
timings: debugInfo?.timings ?? {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute only PathFinder.Mini path
|
||||
* Compute comparison path using adapter
|
||||
*/
|
||||
export async function computePfMiniPath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
): Promise<PfMiniResult> {
|
||||
const { game } = await loadMap(mapName);
|
||||
|
||||
// Convert coordinates to TileRefs
|
||||
const fromRef = game.ref(from[0], from[1]);
|
||||
const toRef = game.ref(to[0], to[1]);
|
||||
|
||||
// Validate that both points are water tiles
|
||||
if (!game.isWater(fromRef)) {
|
||||
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
|
||||
}
|
||||
if (!game.isWater(toRef)) {
|
||||
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
|
||||
}
|
||||
|
||||
// Compute PathFinder.Mini path
|
||||
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
|
||||
const pfMiniStart = performance.now();
|
||||
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
|
||||
const pfMiniEnd = performance.now();
|
||||
|
||||
const path = pathToCoords(pfMiniPath, game);
|
||||
const time = pfMiniEnd - pfMiniStart;
|
||||
function computeComparisonPath(
|
||||
adapter: SteppingPathFinder<TileRef>,
|
||||
game: any,
|
||||
fromRef: TileRef,
|
||||
toRef: TileRef,
|
||||
adapterName: string,
|
||||
): ComparisonResult {
|
||||
const start = performance.now();
|
||||
const path = adapter.findPath(fromRef, toRef);
|
||||
const time = performance.now() - start;
|
||||
|
||||
return {
|
||||
path,
|
||||
adapter: adapterName,
|
||||
path: pathToCoords(path, game),
|
||||
length: path ? path.length : 0,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pathfinding between two points
|
||||
*/
|
||||
export async function computePath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
options: { adapters?: string[] } = {},
|
||||
): Promise<PathfindResult> {
|
||||
const { game, hpaStar } = await loadMap(mapName);
|
||||
const graph = game.miniWaterGraph();
|
||||
|
||||
// Convert coordinates to TileRefs
|
||||
const fromRef = game.ref(from[0], from[1]);
|
||||
const toRef = game.ref(to[0], to[1]);
|
||||
|
||||
// Validate that both points are water tiles
|
||||
if (!game.isWater(fromRef)) {
|
||||
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
|
||||
}
|
||||
if (!game.isWater(toRef)) {
|
||||
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
|
||||
}
|
||||
|
||||
// Compute primary path (HPA* with debug)
|
||||
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef);
|
||||
|
||||
// Compute comparison paths
|
||||
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
|
||||
const comparisons: ComparisonResult[] = [];
|
||||
|
||||
for (const adapterName of selectedAdapters) {
|
||||
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
|
||||
console.warn(`Unknown adapter: ${adapterName}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = getOrCreateAdapter(mapName, adapterName, game);
|
||||
const result = computeComparisonPath(
|
||||
adapter,
|
||||
game,
|
||||
fromRef,
|
||||
toRef,
|
||||
adapterName,
|
||||
);
|
||||
comparisons.push(result);
|
||||
} catch (error) {
|
||||
console.error(`Error with adapter ${adapterName}:`, error);
|
||||
comparisons.push({
|
||||
adapter: adapterName,
|
||||
path: null,
|
||||
length: 0,
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { primary, comparisons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pathfinding adapter caches
|
||||
*/
|
||||
export function clearAdapterCaches() {
|
||||
pfMiniCache.clear();
|
||||
adapterCache.clear();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,13 +129,13 @@
|
||||
<button class="toggle-button" id="showInitialPath" data-active="false">
|
||||
Initial Path
|
||||
</button>
|
||||
<button class="toggle-button" id="showUsedGateways" data-active="false">
|
||||
Used Gateways
|
||||
<button class="toggle-button" id="showUsedNodes" data-active="false">
|
||||
Used Nodes
|
||||
</button>
|
||||
</div>
|
||||
<div class="debug-panel-row">
|
||||
<button class="toggle-button" id="showGateways" data-active="false">
|
||||
Gateways
|
||||
<button class="toggle-button" id="showNodes" data-active="false">
|
||||
Nodes
|
||||
</button>
|
||||
<button class="toggle-button" id="showSectorGrid" data-active="false">
|
||||
Sectors
|
||||
@@ -166,75 +166,42 @@
|
||||
<div class="timing-label">
|
||||
<button
|
||||
class="refresh-icon"
|
||||
id="refreshNavMesh"
|
||||
title="Recompute NavMesh path"
|
||||
id="refreshHpa"
|
||||
title="Recompute HPA* path"
|
||||
>
|
||||
<span>↻</span>
|
||||
</button>
|
||||
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
|
||||
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
|
||||
</div>
|
||||
<div class="timing-value-large" id="navMeshTime">—</div>
|
||||
<div class="timing-value-large" id="hpaTime">—</div>
|
||||
|
||||
<div class="timing-breakdown" id="timingBreakdown">
|
||||
<div class="timing-item" id="timingEarlyExit" style="display: none">
|
||||
<span class="timing-name">Early Exit:</span>
|
||||
<span class="timing-value" id="timingEarlyExitValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingFindNodes" style="display: none">
|
||||
<span class="timing-name">Find Nodes:</span>
|
||||
<span class="timing-value" id="timingFindNodesValue">—</span>
|
||||
</div>
|
||||
<div
|
||||
class="timing-item"
|
||||
id="timingFindGateways"
|
||||
id="timingAbstractPath"
|
||||
style="display: none"
|
||||
>
|
||||
<span class="timing-name">Find Gateways:</span>
|
||||
<span class="timing-value" id="timingFindGatewaysValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingGatewayPath" style="display: none">
|
||||
<span class="timing-name">Gateway Path:</span>
|
||||
<span class="timing-value" id="timingGatewayPathValue">—</span>
|
||||
<span class="timing-name">Abstract Path:</span>
|
||||
<span class="timing-value" id="timingAbstractPathValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingInitialPath" style="display: none">
|
||||
<span class="timing-name">Initial Path:</span>
|
||||
<span class="timing-value" id="timingInitialPathValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingSmoothPath" style="display: none">
|
||||
<span class="timing-name">Smooth Path:</span>
|
||||
<span class="timing-value" id="timingSmoothPathValue">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-section" id="pfMiniRequestSection">
|
||||
<button
|
||||
id="requestPfMini"
|
||||
class="timing-button"
|
||||
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
|
||||
disabled
|
||||
>
|
||||
Request PathFinder.Mini
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="timing-section"
|
||||
id="pfMiniTimingSection"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="timing-label">
|
||||
<button
|
||||
class="refresh-icon"
|
||||
id="refreshPfMini"
|
||||
title="Recompute PF.Mini path"
|
||||
>
|
||||
<span>↻</span>
|
||||
</button>
|
||||
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
|
||||
</div>
|
||||
<div class="timing-value-large" id="pfMiniTime">—</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-section" id="speedupSection" style="display: none">
|
||||
<div class="timing-label">Speedup</div>
|
||||
<div class="timing-value-speedup" id="speedupValue">—</div>
|
||||
<div class="timing-section" id="comparisonsSection" style="display: none">
|
||||
<div class="timing-label">Comparisons</div>
|
||||
<div id="comparisonsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,13 +223,9 @@
|
||||
></div>
|
||||
<span>End Point</span>
|
||||
</div>
|
||||
<div class="legend-item" id="pfMiniLegend" style="display: none">
|
||||
<div class="legend-color" style="background: #ffaa00"></div>
|
||||
<span>PathFinder.Mini</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #00ffff"></div>
|
||||
<span>NavMesh</span>
|
||||
<span>HPA*</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ff00ff"></div>
|
||||
@@ -273,7 +236,7 @@
|
||||
class="legend-color"
|
||||
style="background: #ffff00; height: 8px"
|
||||
></div>
|
||||
<span>Used Gateways</span>
|
||||
<span>Used Nodes</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
@@ -285,7 +248,7 @@
|
||||
border-radius: 50%;
|
||||
"
|
||||
></div>
|
||||
<span>Gateways</span>
|
||||
<span>Nodes</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
|
||||
@@ -500,6 +500,67 @@ canvas {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Comparison rows */
|
||||
.comparison-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 0 -8px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #333;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.comparison-row:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.comparison-row.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.comparison-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comp-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.comparison-row.active .comp-color {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comp-name {
|
||||
color: #aaa;
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.comp-tiles {
|
||||
font-family: monospace;
|
||||
color: #888;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.comp-time {
|
||||
font-family: monospace;
|
||||
color: #f5f5f5;
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Legend panel (right side) */
|
||||
.legend-panel {
|
||||
position: fixed;
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
listMaps,
|
||||
setConfig,
|
||||
} from "./api/maps.js";
|
||||
import {
|
||||
clearAdapterCaches,
|
||||
computePath,
|
||||
computePfMiniPath,
|
||||
} from "./api/pathfinding.js";
|
||||
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
|
||||
|
||||
// Parse command-line arguments
|
||||
const args = process.argv.slice(2);
|
||||
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
|
||||
* map: string,
|
||||
* from: [x, y],
|
||||
* to: [x, y],
|
||||
* includePfMini?: boolean
|
||||
* adapters?: string[] // Optional: which comparison adapters to run
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
|
||||
* comparisons: [{ adapter, path, length, time }, ...]
|
||||
* }
|
||||
*/
|
||||
app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, from, to, includePfMini } = req.body;
|
||||
const { map, from, to, adapters } = req.body;
|
||||
|
||||
// Validate request
|
||||
if (!map || !from || !to) {
|
||||
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
map,
|
||||
from as [number, number],
|
||||
to as [number, number],
|
||||
{ includePfMini: !!includePfMini },
|
||||
{ adapters },
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
@@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pathfind-pfmini
|
||||
* Compute only PathFinder.Mini path
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* map: string,
|
||||
* from: [x, y],
|
||||
* to: [x, y]
|
||||
* }
|
||||
*/
|
||||
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, from, to } = req.body;
|
||||
|
||||
// Validate request
|
||||
if (!map || !from || !to) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid request",
|
||||
message: "Missing required fields: map, from, to",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(from) ||
|
||||
from.length !== 2 ||
|
||||
!Array.isArray(to) ||
|
||||
to.length !== 2
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid coordinates",
|
||||
message: "from and to must be [x, y] coordinate arrays",
|
||||
});
|
||||
}
|
||||
|
||||
// Compute PF.Mini path only
|
||||
const result = await computePfMiniPath(
|
||||
map,
|
||||
from as [number, number],
|
||||
to as [number, number],
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error computing PF.Mini path:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("is not water")) {
|
||||
res.status(400).json({
|
||||
error: "Invalid coordinates",
|
||||
message: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to compute PF.Mini path",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cache/clear
|
||||
* Clear all caches (useful for development)
|
||||
|
||||
+58
-16
@@ -17,10 +17,19 @@ import {
|
||||
MapManifest,
|
||||
} from "../../src/core/game/TerrainMapLoader";
|
||||
import { UserSettings } from "../../src/core/game/UserSettings";
|
||||
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
|
||||
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
|
||||
import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water";
|
||||
import { AStarWaterHierarchical } from "../../src/core/pathfinding/algorithms/AStar.WaterHierarchical";
|
||||
import { PathFinding } from "../../src/core/pathfinding/PathFinder";
|
||||
import { PathFinderBuilder } from "../../src/core/pathfinding/PathFinderBuilder";
|
||||
import { StepperConfig } from "../../src/core/pathfinding/PathFinderStepper";
|
||||
import { MiniMapTransformer } from "../../src/core/pathfinding/transformers/MiniMapTransformer";
|
||||
import {
|
||||
PathStatus,
|
||||
SteppingPathFinder,
|
||||
} from "../../src/core/pathfinding/types";
|
||||
import { GameConfig } from "../../src/core/Schemas";
|
||||
import { TestConfig } from "../util/TestConfig";
|
||||
|
||||
export type BenchmarkRoute = {
|
||||
name: string;
|
||||
from: TileRef;
|
||||
@@ -42,25 +51,58 @@ export type BenchmarkSummary = {
|
||||
avgTime: number;
|
||||
};
|
||||
|
||||
export function getAdapter(game: Game, name: string): PathFinder {
|
||||
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
|
||||
return {
|
||||
equals: (a, b) => a === b,
|
||||
distance: (a, b) => game.manhattanDist(a, b),
|
||||
preCheck: (from, to) =>
|
||||
typeof from !== "number" ||
|
||||
typeof to !== "number" ||
|
||||
!game.isValidRef(from) ||
|
||||
!game.isValidRef(to)
|
||||
? { status: PathStatus.NOT_FOUND }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdapter(
|
||||
game: Game,
|
||||
name: string,
|
||||
): SteppingPathFinder<TileRef> {
|
||||
switch (name) {
|
||||
case "legacy":
|
||||
return PathFinders.WaterLegacy(game, {
|
||||
iterations: 500_000,
|
||||
maxTries: 50,
|
||||
});
|
||||
case "a.baseline": {
|
||||
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
case "a.generic": {
|
||||
// Same as baseline - uses AStarWater on minimap
|
||||
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
|
||||
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
|
||||
.buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
case "a.full": {
|
||||
return PathFinderBuilder.create(
|
||||
new AStarWater(game.map()),
|
||||
).buildWithStepper(tileStepperConfig(game));
|
||||
}
|
||||
case "hpa": {
|
||||
// Recreate NavMesh without cache, this approach was chosen
|
||||
// Recreate AStarWaterHierarchical without cache, this approach was chosen
|
||||
// over adding cache toggles to the existing game instance
|
||||
// to avoid adding side effect from benchmark to the game
|
||||
const navMesh = new NavMesh(game, { cachePaths: false });
|
||||
navMesh.initialize();
|
||||
(game as any)._navMesh = navMesh;
|
||||
const graph = game.miniWaterGraph();
|
||||
if (!graph) {
|
||||
throw new Error("miniWaterGraph not available");
|
||||
}
|
||||
const hpa = new AStarWaterHierarchical(game.miniMap(), graph, {
|
||||
cachePaths: false,
|
||||
});
|
||||
(game as any)._miniWaterHPA = hpa;
|
||||
|
||||
return PathFinders.Water(game);
|
||||
return PathFinding.Water(game);
|
||||
}
|
||||
case "hpa.cached":
|
||||
return PathFinders.Water(game);
|
||||
return PathFinding.Water(game);
|
||||
default:
|
||||
throw new Error(`Unknown pathfinding adapter: ${name}`);
|
||||
}
|
||||
@@ -102,7 +144,7 @@ export async function getScenario(
|
||||
}
|
||||
|
||||
export function measurePathLength(
|
||||
adapter: PathFinder,
|
||||
adapter: SteppingPathFinder<TileRef>,
|
||||
route: BenchmarkRoute,
|
||||
): number | null {
|
||||
const path = adapter.findPath(route.from, route.to);
|
||||
@@ -117,7 +159,7 @@ export function measureTime<T>(fn: () => T): { result: T; time: number } {
|
||||
}
|
||||
|
||||
export function measureExecutionTime(
|
||||
adapter: PathFinder,
|
||||
adapter: SteppingPathFinder<TileRef>,
|
||||
route: BenchmarkRoute,
|
||||
executions: number = 1,
|
||||
): number | null {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import Benchmark from "benchmark";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding";
|
||||
import { setup } from "../util/Setup";
|
||||
|
||||
const game = await setup(
|
||||
"giantworldmap",
|
||||
{},
|
||||
[],
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
);
|
||||
|
||||
new Benchmark.Suite()
|
||||
.add("top-left-to-bottom-right", () => {
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(0, 0),
|
||||
game.ref(4077, 1929),
|
||||
);
|
||||
})
|
||||
.add("hawaii to svalbard", () => {
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(186, 800),
|
||||
game.ref(2205, 52),
|
||||
);
|
||||
})
|
||||
.add("black sea to california", () => {
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(2349, 455),
|
||||
game.ref(511, 536),
|
||||
);
|
||||
})
|
||||
.on("cycle", (event: any) => {
|
||||
console.log(String(event.target));
|
||||
})
|
||||
.run({ async: true });
|
||||
Reference in New Issue
Block a user