mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
Pathfinding Refactor pt. 2 (#2866)
## Playtest https://pf-pt-2.openfront.dev/ ## Pathfinding Refactor pt. 2 <img width="1536" height="1024" alt="image" src="https://github.com/user-attachments/assets/9477958e-54b7-4c83-b317-ba789e809e9e" /> This is a follow-up to a previous PR introducing pathfinding changes. This time, it introduces a complete refactor of `pathfinding` directory and breakdown into composable pieces. ### Unified PathFinder interface `PathFinder<T>` and `SteppingPathFinder<T>` are introduced to unify **all** pathfinding across the application. First one exposes complete path, while stepping variant allows the callee to iterate over the path by calling `.next`. All pathfinders share this one common interface, which makes them easy to use in any scenario - `PathFinding.Water(game).search(from, to)`. `SteppingPathFinder<T>` extends `PathFinder<T>` with an ability to iterate over the path. It handles caching, storing current index and invalidation. This allows the units to not care about the inner workings of the pathfinder and just call `pf.next(current, target)` and receive instructions on what to do next. ### Common entry point All pathfinders are now exposed from common `PathFinding` entrypoint: - `PathFinding.Water` - `PathFinding.Rail` - `PathFinding.Stations` - `PathFinding.Rail` Additional entry point is introduced for pathfinders which need to work both in the worker, but also on the frontend, which lacks `Game` interface. Currently only `UniversalPathFinding.Parabola` is available. ### Spatial Query New module has been introduced close to `pathfinding` - `SpatialQuery`. It aims to resolve any questions game may have about finding tiles meeting criteria. Currently `SpatialQuery.closestShore(player, target)` and `SpatialQuery.closestShoreByWater(player, target)` are available - they help answering questions about naval invasion: "What is the best landing location from user's click?" and "Which our tile should be used to launch the transport ship?". Under the hood they use very similar mechanics to pathfinding, so it felt right to put them close by. ### Modular architecture Pathfinders now support transformers: `MiniMapTransformer`, `ShoreCoercingTransformer`, `ComponentCheckTransformer`, `SmoothingTransformer`. Transformers functions like a middleware in the pathfinding chain. They wrap around the pathfinder and provide additional functionality. This allows the pathfinder to focus on actually finding the path instead of doing unrelated things. Example chain for simple (A*) water pathfinding: ```ts 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)); } ``` The Pathfinder - here `AStarWater` - does not care about the conversion between minimap and main map tiles. It also does not care if the source or destination is a land tile. The transformers take care of that. The pathfinder gets a set of valid coordinates and produces the path - that's it. Modular approach makes working on a particular set of utilities much easier - for example map upscaling is handled consistently across all pathfinders. Additionally, the pathfinders are not tied to the particular map resolution used. Pass them a different map and they will work the same. ### Algorithms Algorithms used are neatly organized inside `src/core/pathfinding/algorithms`. They are prefixed with the algorithm name and suffixed with the use case. File without suffix exposes generic version ready to traverse any graph with adapters. Specialized versions either use an adapter or inline logic when performance is critical - using adapters leads to 20-30% performance loss. The directory includes `A*` and `BFS` but also other useful utils, such as `AbstractGraph` used to generate... an abstract graph on top of the tile map and `ConnectedComponents` helping to identify whether two tiles are connected by a path without actually computing the path. ### Playground The playground have been updated with new algorithms, including tweaked very greedy `A*`. <img width="2175" height="1424" alt="image" src="https://github.com/user-attachments/assets/1f833651-0024-4299-bf86-882f5368358c" /> ### Tests Yeah, there are some, a little too many if I say so myself. But there are no useless tests. I had to ensure refactored code works somehow reliably. This PR comes with trust me bro guarantee, but I would appreciate someone confirming **naval invasions, nukes (esp. MIRV) and warships**. ### Discord `moleole` GL & HF
This commit is contained in:
@@ -2,7 +2,7 @@ import { EventBus } from "../../../core/EventBus";
|
|||||||
import { UnitType } from "../../../core/game/Game";
|
import { UnitType } from "../../../core/game/Game";
|
||||||
import { TileRef } from "../../../core/game/GameMap";
|
import { TileRef } from "../../../core/game/GameMap";
|
||||||
import { GameView } from "../../../core/game/GameView";
|
import { GameView } from "../../../core/game/GameView";
|
||||||
import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding";
|
import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder";
|
||||||
import {
|
import {
|
||||||
GhostStructureChangedEvent,
|
GhostStructureChangedEvent,
|
||||||
MouseMoveEvent,
|
MouseMoveEvent,
|
||||||
@@ -211,20 +211,16 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
|||||||
|
|
||||||
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
|
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
// Calculate trajectory using ParabolaPathFinder with cached spawn tile
|
// Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile
|
||||||
const pathFinder = new ParabolaPathFinder(this.game);
|
|
||||||
const speed = this.game.config().defaultNukeSpeed();
|
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.trajectoryPoints =
|
||||||
this.cachedSpawnTile,
|
pathFinder.findPath(this.cachedSpawnTile, targetTile) ?? [];
|
||||||
targetTile,
|
|
||||||
speed,
|
|
||||||
distanceBasedHeight,
|
|
||||||
this.uiState.rocketDirectionUp,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.trajectoryPoints = pathFinder.allTiles();
|
|
||||||
|
|
||||||
// NOTE: This is a lot to do in the rendering method, naive
|
// NOTE: This is a lot to do in the rendering method, naive
|
||||||
// But trajectory is already calculated here and needed for prediction.
|
// But trajectory is already calculated here and needed for prediction.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
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 { PseudoRandom } from "../PseudoRandom";
|
||||||
import { simpleHash } from "../Util";
|
import { simpleHash } from "../Util";
|
||||||
import { NukeExecution } from "./NukeExecution";
|
import { NukeExecution } from "./NukeExecution";
|
||||||
@@ -30,11 +32,12 @@ export class MirvExecution implements Execution {
|
|||||||
|
|
||||||
private random: PseudoRandom;
|
private random: PseudoRandom;
|
||||||
|
|
||||||
private pathFinder: ParabolaPathFinder;
|
private pathFinder: ParabolaUniversalPathFinder;
|
||||||
|
|
||||||
private targetPlayer: Player | TerraNullius;
|
private targetPlayer: Player | TerraNullius;
|
||||||
|
|
||||||
private separateDst: TileRef;
|
private separateDst: TileRef;
|
||||||
|
private spawnTile: TileRef;
|
||||||
|
|
||||||
private speed: number = -1;
|
private speed: number = -1;
|
||||||
|
|
||||||
@@ -46,9 +49,11 @@ export class MirvExecution implements Execution {
|
|||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, ticks: number): void {
|
||||||
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id()));
|
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id()));
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.pathFinder = new ParabolaPathFinder(mg);
|
|
||||||
this.targetPlayer = this.mg.owner(this.dst);
|
this.targetPlayer = this.mg.owner(this.dst);
|
||||||
this.speed = this.mg.config().defaultNukeSpeed();
|
this.speed = this.mg.config().defaultNukeSpeed();
|
||||||
|
this.pathFinder = UniversalPathFinding.Parabola(mg, {
|
||||||
|
increment: this.speed,
|
||||||
|
});
|
||||||
|
|
||||||
// Betrayal on launch
|
// Betrayal on launch
|
||||||
if (this.targetPlayer.isPlayer()) {
|
if (this.targetPlayer.isPlayer()) {
|
||||||
@@ -70,6 +75,7 @@ export class MirvExecution implements Execution {
|
|||||||
this.active = false;
|
this.active = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.spawnTile = spawn;
|
||||||
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {
|
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {
|
||||||
targetTile: this.dst,
|
targetTile: this.dst,
|
||||||
});
|
});
|
||||||
@@ -79,7 +85,6 @@ export class MirvExecution implements Execution {
|
|||||||
);
|
);
|
||||||
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
|
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
|
||||||
this.separateDst = this.mg.ref(x, y);
|
this.separateDst = this.mg.ref(x, y);
|
||||||
this.pathFinder.computeControlPoints(spawn, this.separateDst);
|
|
||||||
|
|
||||||
this.mg.displayIncomingUnit(
|
this.mg.displayIncomingUnit(
|
||||||
this.nuke.id(),
|
this.nuke.id(),
|
||||||
@@ -90,15 +95,19 @@ export class MirvExecution implements Execution {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.pathFinder.nextTile(this.speed);
|
const result = this.pathFinder.next(
|
||||||
if (result === true) {
|
this.spawnTile,
|
||||||
|
this.separateDst,
|
||||||
|
this.speed,
|
||||||
|
);
|
||||||
|
if (result.status === PathStatus.COMPLETE) {
|
||||||
this.separate();
|
this.separate();
|
||||||
this.active = false;
|
this.active = false;
|
||||||
// Record stats
|
// Record stats
|
||||||
this.mg.stats().bombLand(this.player, this.targetPlayer, UnitType.MIRV);
|
this.mg.stats().bombLand(this.player, this.targetPlayer, UnitType.MIRV);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else if (result.status === PathStatus.NEXT) {
|
||||||
this.nuke.move(result);
|
this.nuke.move(result.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
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 { PseudoRandom } from "../PseudoRandom";
|
||||||
import { NukeType } from "../StatsSchemas";
|
import { NukeType } from "../StatsSchemas";
|
||||||
import { computeNukeBlastCounts } from "./Util";
|
import { computeNukeBlastCounts } from "./Util";
|
||||||
@@ -22,7 +24,7 @@ export class NukeExecution implements Execution {
|
|||||||
private mg: Game;
|
private mg: Game;
|
||||||
private nuke: Unit | null = null;
|
private nuke: Unit | null = null;
|
||||||
private tilesToDestroyCache: Set<TileRef> | undefined;
|
private tilesToDestroyCache: Set<TileRef> | undefined;
|
||||||
private pathFinder: ParabolaPathFinder;
|
private pathFinder: ParabolaUniversalPathFinder;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private nukeType: NukeType,
|
private nukeType: NukeType,
|
||||||
@@ -39,7 +41,11 @@ export class NukeExecution implements Execution {
|
|||||||
if (this.speed === -1) {
|
if (this.speed === -1) {
|
||||||
this.speed = this.mg.config().defaultNukeSpeed();
|
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 {
|
public target(): Player | TerraNullius {
|
||||||
@@ -123,13 +129,6 @@ export class NukeExecution implements Execution {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.src = spawn;
|
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, {
|
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
|
||||||
targetTile: this.dst,
|
targetTile: this.dst,
|
||||||
trajectory: this.getTrajectory(this.dst),
|
trajectory: this.getTrajectory(this.dst),
|
||||||
@@ -186,13 +185,13 @@ export class NukeExecution implements Execution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move to next tile
|
// Move to next tile
|
||||||
const nextTile = this.pathFinder.nextTile(this.speed);
|
const result = this.pathFinder.next(this.src!, this.dst, this.speed);
|
||||||
if (nextTile === true) {
|
if (result.status === PathStatus.COMPLETE) {
|
||||||
this.detonate();
|
this.detonate();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else if (result.status === PathStatus.NEXT) {
|
||||||
this.updateNukeTargetable();
|
this.updateNukeTargetable();
|
||||||
this.nuke.move(nextTile);
|
this.nuke.move(result.node);
|
||||||
// Update index so SAM can interpolate future position
|
// Update index so SAM can interpolate future position
|
||||||
this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex());
|
this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex());
|
||||||
}
|
}
|
||||||
@@ -206,7 +205,7 @@ export class NukeExecution implements Execution {
|
|||||||
const trajectoryTiles: TrajectoryTile[] = [];
|
const trajectoryTiles: TrajectoryTile[] = [];
|
||||||
const targetRangeSquared =
|
const targetRangeSquared =
|
||||||
this.mg.config().defaultNukeTargetableRange() ** 2;
|
this.mg.config().defaultNukeTargetableRange() ** 2;
|
||||||
const allTiles: TileRef[] = this.pathFinder.allTiles();
|
const allTiles = this.pathFinder.findPath(this.src!, target) ?? [];
|
||||||
for (const tile of allTiles) {
|
for (const tile of allTiles) {
|
||||||
trajectoryTiles.push({
|
trajectoryTiles.push({
|
||||||
tile,
|
tile,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
import { PathFinding } from "../pathfinding/PathFinder";
|
||||||
import { PseudoRandom } from "../PseudoRandom";
|
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||||
import { NukeType } from "../StatsSchemas";
|
import { NukeType } from "../StatsSchemas";
|
||||||
|
|
||||||
export class SAMMissileExecution implements Execution {
|
export class SAMMissileExecution implements Execution {
|
||||||
private active = true;
|
private active = true;
|
||||||
private pathFinder: AirPathFinder;
|
private pathFinder: SteppingPathFinder<TileRef>;
|
||||||
private SAMMissile: Unit | undefined;
|
private SAMMissile: Unit | undefined;
|
||||||
private mg: Game;
|
private mg: Game;
|
||||||
private speed: number = 0;
|
private speed: number = 0;
|
||||||
@@ -27,7 +27,7 @@ export class SAMMissileExecution implements Execution {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, ticks: number): void {
|
||||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
this.pathFinder = PathFinding.Air(mg);
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.speed = this.mg.config().defaultSamMissileSpeed();
|
this.speed = this.mg.config().defaultSamMissileSpeed();
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,11 @@ export class SAMMissileExecution implements Execution {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < this.speed; i++) {
|
for (let i = 0; i < this.speed; i++) {
|
||||||
const result = this.pathFinder.nextTile(
|
const result = this.pathFinder.next(
|
||||||
this.SAMMissile.tile(),
|
this.SAMMissile.tile(),
|
||||||
this.targetTile,
|
this.targetTile,
|
||||||
);
|
);
|
||||||
if (result === true) {
|
if (result.status === PathStatus.COMPLETE) {
|
||||||
this.mg.displayMessage(
|
this.mg.displayMessage(
|
||||||
"events_display.missile_intercepted",
|
"events_display.missile_intercepted",
|
||||||
MessageType.SAM_HIT,
|
MessageType.SAM_HIT,
|
||||||
@@ -76,8 +76,8 @@ export class SAMMissileExecution implements Execution {
|
|||||||
.stats()
|
.stats()
|
||||||
.bombIntercept(this._owner, this.target.type() as NukeType, 1);
|
.bombIntercept(this._owner, this.target.type() as NukeType, 1);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else if (result.status === PathStatus.NEXT) {
|
||||||
this.SAMMissile.move(result);
|
this.SAMMissile.move(result.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
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";
|
import { PseudoRandom } from "../PseudoRandom";
|
||||||
|
|
||||||
export class ShellExecution implements Execution {
|
export class ShellExecution implements Execution {
|
||||||
private active = true;
|
private active = true;
|
||||||
private pathFinder: AirPathFinder;
|
private pathFinder: SteppingPathFinder<TileRef>;
|
||||||
private shell: Unit | undefined;
|
private shell: Unit | undefined;
|
||||||
private mg: Game;
|
private mg: Game;
|
||||||
private destroyAtTick: number = -1;
|
private destroyAtTick: number = -1;
|
||||||
@@ -19,7 +20,7 @@ export class ShellExecution implements Execution {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, ticks: number): void {
|
||||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
this.pathFinder = PathFinding.Air(mg);
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.random = new PseudoRandom(mg.ticks());
|
this.random = new PseudoRandom(mg.ticks());
|
||||||
}
|
}
|
||||||
@@ -45,18 +46,18 @@ export class ShellExecution implements Execution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const result = this.pathFinder.nextTile(
|
const result = this.pathFinder.next(
|
||||||
this.shell.tile(),
|
this.shell.tile(),
|
||||||
this.target.tile(),
|
this.target.tile(),
|
||||||
);
|
);
|
||||||
if (result === true) {
|
if (result.status === PathStatus.COMPLETE) {
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
|
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
|
||||||
this.shell.setReachedTarget();
|
this.shell.setReachedTarget();
|
||||||
this.shell.delete(false);
|
this.shell.delete(false);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else if (result.status === PathStatus.NEXT) {
|
||||||
this.shell.move(result);
|
this.shell.move(result.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
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";
|
import { distSortUnit } from "../Util";
|
||||||
|
|
||||||
export class TradeShipExecution implements Execution {
|
export class TradeShipExecution implements Execution {
|
||||||
@@ -16,7 +17,7 @@ export class TradeShipExecution implements Execution {
|
|||||||
private mg: Game;
|
private mg: Game;
|
||||||
private tradeShip: Unit | undefined;
|
private tradeShip: Unit | undefined;
|
||||||
private wasCaptured = false;
|
private wasCaptured = false;
|
||||||
private pathFinder: PathFinder;
|
private pathFinder: SteppingPathFinder<TileRef>;
|
||||||
private tilesTraveled = 0;
|
private tilesTraveled = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -27,7 +28,7 @@ export class TradeShipExecution implements Execution {
|
|||||||
|
|
||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, ticks: number): void {
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.pathFinder = PathFinders.Water(mg);
|
this.pathFinder = PathFinding.Water(mg);
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(ticks: number): void {
|
tick(ticks: number): void {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
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";
|
import { AttackExecution } from "./AttackExecution";
|
||||||
|
|
||||||
const malusForRetreat = 25;
|
const malusForRetreat = 25;
|
||||||
@@ -29,11 +30,10 @@ export class TransportShipExecution implements Execution {
|
|||||||
// TODO make private
|
// TODO make private
|
||||||
public path: TileRef[];
|
public path: TileRef[];
|
||||||
private dst: TileRef | null;
|
private dst: TileRef | null;
|
||||||
private dstShore: TileRef | null;
|
|
||||||
|
|
||||||
private boat: Unit;
|
private boat: Unit;
|
||||||
|
|
||||||
private pathFinder: PathFinder;
|
private pathFinder: SteppingPathFinder<TileRef>;
|
||||||
|
|
||||||
private originalOwner: Player;
|
private originalOwner: Player;
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export class TransportShipExecution implements Execution {
|
|||||||
|
|
||||||
this.lastMove = ticks;
|
this.lastMove = ticks;
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.pathFinder = PathFinders.Water(mg);
|
this.pathFinder = PathFinding.Water(mg);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.attacker.unitCount(UnitType.TransportShip) >=
|
this.attacker.unitCount(UnitType.TransportShip) >=
|
||||||
@@ -106,8 +106,8 @@ export class TransportShipExecution implements Execution {
|
|||||||
|
|
||||||
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
|
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
|
||||||
|
|
||||||
this.dstShore = targetTransportTile(this.mg, this.ref);
|
this.dst = targetTransportTile(this.mg, this.ref);
|
||||||
if (this.dstShore === null) {
|
if (this.dst === null) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
|
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
|
||||||
);
|
);
|
||||||
@@ -115,18 +115,9 @@ export class TransportShipExecution implements Execution {
|
|||||||
return;
|
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(
|
const closestTileSrc = this.attacker.canBuild(
|
||||||
UnitType.TransportShip,
|
UnitType.TransportShip,
|
||||||
this.dstShore,
|
this.dst,
|
||||||
);
|
);
|
||||||
if (closestTileSrc === false) {
|
if (closestTileSrc === false) {
|
||||||
console.warn(`can't build transport ship`);
|
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, {
|
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
|
||||||
troops: this.startTroops,
|
troops: this.startTroops,
|
||||||
targetTile: this.dst ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move boat from shore to adjacent water for pathfinding
|
if (this.dst !== null) {
|
||||||
const spawnWater = this.adjacentWater(this.src);
|
this.boat.setTargetTile(this.dst);
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
this.boat.setTargetTile(undefined);
|
this.boat.setTargetTile(undefined);
|
||||||
}
|
}
|
||||||
@@ -222,7 +202,6 @@ export class TransportShipExecution implements Execution {
|
|||||||
if (this.mg.owner(this.src!) !== this.attacker) {
|
if (this.mg.owner(this.src!) !== this.attacker) {
|
||||||
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
||||||
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
||||||
|
|
||||||
if (newSrc === false) {
|
if (newSrc === false) {
|
||||||
this.src = null;
|
this.src = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -239,19 +218,10 @@ export class TransportShipExecution implements Execution {
|
|||||||
this.active = false;
|
this.active = false;
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.dstShore = this.src;
|
this.dst = 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;
|
|
||||||
|
|
||||||
if (this.boat.targetTile() !== this.dstShore) {
|
if (this.boat.targetTile() !== this.dst) {
|
||||||
this.boat.setTargetTile(this.dstShore!);
|
this.boat.setTargetTile(this.dst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +229,7 @@ export class TransportShipExecution implements Execution {
|
|||||||
const result = this.pathFinder.next(this.boat.tile(), this.dst);
|
const result = this.pathFinder.next(this.boat.tile(), this.dst);
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case PathStatus.COMPLETE:
|
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 deaths = this.boat.troops() * (malusForRetreat / 100);
|
||||||
const survivors = this.boat.troops() - deaths;
|
const survivors = this.boat.troops() - deaths;
|
||||||
this.attacker.addTroops(survivors);
|
this.attacker.addTroops(survivors);
|
||||||
@@ -281,7 +251,7 @@ export class TransportShipExecution implements Execution {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.attacker.conquer(this.dstShore!);
|
this.attacker.conquer(this.dst);
|
||||||
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
|
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
|
||||||
this.attacker.addTroops(this.boat.troops());
|
this.attacker.addTroops(this.boat.troops());
|
||||||
} else {
|
} else {
|
||||||
@@ -290,7 +260,7 @@ export class TransportShipExecution implements Execution {
|
|||||||
this.boat.troops(),
|
this.boat.troops(),
|
||||||
this.attacker,
|
this.attacker,
|
||||||
this.targetID,
|
this.targetID,
|
||||||
this.dstShore!,
|
this.dst,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -308,13 +278,18 @@ export class TransportShipExecution implements Execution {
|
|||||||
break;
|
break;
|
||||||
case PathStatus.PENDING:
|
case PathStatus.PENDING:
|
||||||
break;
|
break;
|
||||||
case PathStatus.NOT_FOUND:
|
case PathStatus.NOT_FOUND: {
|
||||||
// TODO: add to poisoned port list
|
// 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.attacker.addTroops(this.boat.troops());
|
||||||
this.boat.delete(false);
|
this.boat.delete(false);
|
||||||
this.active = false;
|
this.active = false;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,17 +300,4 @@ export class TransportShipExecution implements Execution {
|
|||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.active;
|
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,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
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 { PseudoRandom } from "../PseudoRandom";
|
||||||
import { ShellExecution } from "./ShellExecution";
|
import { ShellExecution } from "./ShellExecution";
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ export class WarshipExecution implements Execution {
|
|||||||
private random: PseudoRandom;
|
private random: PseudoRandom;
|
||||||
private warship: Unit;
|
private warship: Unit;
|
||||||
private mg: Game;
|
private mg: Game;
|
||||||
private pathfinder: PathFinder;
|
private pathfinder: SteppingPathFinder<TileRef>;
|
||||||
private lastShellAttack = 0;
|
private lastShellAttack = 0;
|
||||||
private alreadySentShell = new Set<Unit>();
|
private alreadySentShell = new Set<Unit>();
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export class WarshipExecution implements Execution {
|
|||||||
|
|
||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, ticks: number): void {
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
this.pathfinder = PathFinders.Water(mg);
|
this.pathfinder = PathFinding.Water(mg);
|
||||||
this.random = new PseudoRandom(mg.ticks());
|
this.random = new PseudoRandom(mg.ticks());
|
||||||
if (isUnit(this.input)) {
|
if (isUnit(this.input)) {
|
||||||
this.warship = this.input;
|
this.warship = this.input;
|
||||||
@@ -193,9 +194,10 @@ export class WarshipExecution implements Execution {
|
|||||||
case PathStatus.PENDING:
|
case PathStatus.PENDING:
|
||||||
this.warship.touch();
|
this.warship.touch();
|
||||||
break;
|
break;
|
||||||
case PathStatus.NOT_FOUND:
|
case PathStatus.NOT_FOUND: {
|
||||||
console.log(`path not found to target`);
|
console.log(`path not found to target`);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,10 +225,10 @@ export class WarshipExecution implements Execution {
|
|||||||
case PathStatus.PENDING:
|
case PathStatus.PENDING:
|
||||||
this.warship.touch();
|
this.warship.touch();
|
||||||
return;
|
return;
|
||||||
case PathStatus.NOT_FOUND:
|
case PathStatus.NOT_FOUND: {
|
||||||
console.warn(`path not found to target tile`);
|
console.log(`path not found to target`);
|
||||||
this.warship.setTargetTile(undefined);
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +245,10 @@ export class WarshipExecution implements Execution {
|
|||||||
const maxAttemptBeforeExpand: number = 500;
|
const maxAttemptBeforeExpand: number = 500;
|
||||||
let attempts: number = 0;
|
let attempts: number = 0;
|
||||||
let expandCount: 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) {
|
while (expandCount < 3) {
|
||||||
const x =
|
const x =
|
||||||
this.mg.x(this.warship.patrolTile()!) +
|
this.mg.x(this.warship.patrolTile()!) +
|
||||||
@@ -267,6 +273,20 @@ export class WarshipExecution implements Execution {
|
|||||||
}
|
}
|
||||||
continue;
|
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;
|
return tile;
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../../game/Game";
|
} from "../../game/Game";
|
||||||
import { TileRef, euclDistFN } from "../../game/GameMap";
|
import { TileRef, euclDistFN } from "../../game/GameMap";
|
||||||
import { ParabolaPathFinder } from "../../pathfinding/PathFinding";
|
import { UniversalPathFinding } from "../../pathfinding/PathFinder";
|
||||||
import { PseudoRandom } from "../../PseudoRandom";
|
import { PseudoRandom } from "../../PseudoRandom";
|
||||||
import { assertNever, boundingBoxTiles } from "../../Util";
|
import { assertNever, boundingBoxTiles } from "../../Util";
|
||||||
import { NukeExecution } from "../NukeExecution";
|
import { NukeExecution } from "../NukeExecution";
|
||||||
@@ -456,20 +456,14 @@ export class NationNukeBehavior {
|
|||||||
spawnTile: TileRef,
|
spawnTile: TileRef,
|
||||||
targetTile: TileRef,
|
targetTile: TileRef,
|
||||||
): boolean {
|
): boolean {
|
||||||
const pathFinder = new ParabolaPathFinder(this.game);
|
|
||||||
const speed = this.game.config().defaultNukeSpeed();
|
const speed = this.game.config().defaultNukeSpeed();
|
||||||
const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height
|
const pathFinder = UniversalPathFinding.Parabola(this.game, {
|
||||||
const rocketDirectionUp = true; // AI nukes always go "up" for now
|
increment: speed,
|
||||||
|
distanceBasedHeight: true, // Atom/Hydrogen bombs use distance-based height
|
||||||
|
directionUp: true, // AI nukes always go "up" for now
|
||||||
|
});
|
||||||
|
|
||||||
pathFinder.computeControlPoints(
|
const trajectory = pathFinder.findPath(spawnTile, targetTile) ?? [];
|
||||||
spawnTile,
|
|
||||||
targetTile,
|
|
||||||
speed,
|
|
||||||
distanceBasedHeight,
|
|
||||||
rocketDirectionUp,
|
|
||||||
);
|
|
||||||
|
|
||||||
const trajectory = pathFinder.allTiles();
|
|
||||||
if (trajectory.length === 0) {
|
if (trajectory.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Config } from "../configuration/Config";
|
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 { AllPlayersStats, ClientID } from "../Schemas";
|
||||||
import { getClanTag } from "../Util";
|
import { getClanTag } from "../Util";
|
||||||
import { GameMap, TileRef } from "./GameMap";
|
import { GameMap, TileRef } from "./GameMap";
|
||||||
@@ -802,7 +803,10 @@ export interface Game extends GameMap {
|
|||||||
addUpdate(update: GameUpdate): void;
|
addUpdate(update: GameUpdate): void;
|
||||||
railNetwork(): RailNetwork;
|
railNetwork(): RailNetwork;
|
||||||
conquerPlayer(conqueror: Player, conquered: Player): void;
|
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 {
|
export interface PlayerActions {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { renderNumber } from "../../client/Utils";
|
import { renderNumber } from "../../client/Utils";
|
||||||
import { Config } from "../configuration/Config";
|
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 { AllPlayersStats, ClientID, Winner } from "../Schemas";
|
||||||
import { simpleHash } from "../Util";
|
import { simpleHash } from "../Util";
|
||||||
import { AllianceImpl } from "./AllianceImpl";
|
import { AllianceImpl } from "./AllianceImpl";
|
||||||
@@ -87,7 +92,8 @@ export class GameImpl implements Game {
|
|||||||
private nextAllianceID: number = 0;
|
private nextAllianceID: number = 0;
|
||||||
|
|
||||||
private _isPaused: boolean = false;
|
private _isPaused: boolean = false;
|
||||||
private _navMesh: NavMesh | null = null;
|
private _miniWaterGraph: AbstractGraph | null = null;
|
||||||
|
private _miniWaterHPA: AStarWaterHierarchical | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _humans: PlayerInfo[],
|
private _humans: PlayerInfo[],
|
||||||
@@ -108,8 +114,14 @@ export class GameImpl implements Game {
|
|||||||
this.addPlayers();
|
this.addPlayers();
|
||||||
|
|
||||||
if (!_config.disableNavMesh()) {
|
if (!_config.disableNavMesh()) {
|
||||||
this._navMesh = new NavMesh(this, { cachePaths: true });
|
const graphBuilder = new AbstractGraphBuilder(this.miniGameMap);
|
||||||
this._navMesh.initialize();
|
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 {
|
railNetwork(): RailNetwork {
|
||||||
return this._railNetwork;
|
return this._railNetwork;
|
||||||
}
|
}
|
||||||
navMesh(): NavMesh | null {
|
miniWaterHPA(): PathFinder<number> | null {
|
||||||
return this._navMesh;
|
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) {
|
conquerPlayer(conqueror: Player, conquered: Player) {
|
||||||
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
|
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
|
||||||
|
|||||||
@@ -1259,6 +1259,6 @@ export class PlayerImpl implements Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
|
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 { Unit } from "./Game";
|
||||||
|
import { StationManager } from "./RailNetworkImpl";
|
||||||
import { TrainStation } from "./TrainStation";
|
import { TrainStation } from "./TrainStation";
|
||||||
|
|
||||||
export interface RailNetwork {
|
export interface RailNetwork {
|
||||||
connectStation(station: TrainStation): void;
|
connectStation(station: TrainStation): void;
|
||||||
removeStation(unit: Unit): void;
|
removeStation(unit: Unit): void;
|
||||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||||
|
stationManager(): StationManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { RailroadExecution } from "../execution/RailroadExecution";
|
import { RailroadExecution } from "../execution/RailroadExecution";
|
||||||
import { PathFindResultType } from "../pathfinding/AStar";
|
import { PathFinding } from "../pathfinding/PathFinder";
|
||||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
|
||||||
import { SerialAStar } from "../pathfinding/SerialAStar";
|
|
||||||
import { Game, Unit, UnitType } from "./Game";
|
import { Game, Unit, UnitType } from "./Game";
|
||||||
import { TileRef } from "./GameMap";
|
import { TileRef } from "./GameMap";
|
||||||
import { RailNetwork } from "./RailNetwork";
|
import { RailNetwork } from "./RailNetwork";
|
||||||
import { Railroad } from "./Railroad";
|
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,
|
* The Stations handle their own neighbors so the graph is naturally traversable,
|
||||||
@@ -18,16 +16,23 @@ export interface StationManager {
|
|||||||
removeStation(station: TrainStation): void;
|
removeStation(station: TrainStation): void;
|
||||||
findStation(unit: Unit): TrainStation | null;
|
findStation(unit: Unit): TrainStation | null;
|
||||||
getAll(): Set<TrainStation>;
|
getAll(): Set<TrainStation>;
|
||||||
|
getById(id: number): TrainStation | undefined;
|
||||||
|
count(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StationManagerImpl implements StationManager {
|
export class StationManagerImpl implements StationManager {
|
||||||
private stations: Set<TrainStation> = new Set();
|
private stations: Set<TrainStation> = new Set();
|
||||||
|
private stationsById: (TrainStation | undefined)[] = [];
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
addStation(station: TrainStation) {
|
addStation(station: TrainStation) {
|
||||||
|
station.id = this.nextId++;
|
||||||
|
this.stationsById[station.id] = station;
|
||||||
this.stations.add(station);
|
this.stations.add(station);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeStation(station: TrainStation) {
|
removeStation(station: TrainStation) {
|
||||||
|
this.stationsById[station.id] = undefined;
|
||||||
this.stations.delete(station);
|
this.stations.delete(station);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,14 @@ export class StationManagerImpl implements StationManager {
|
|||||||
getAll(): Set<TrainStation> {
|
getAll(): Set<TrainStation> {
|
||||||
return this.stations;
|
return this.stations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getById(id: number): TrainStation | undefined {
|
||||||
|
return this.stationsById[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return this.nextId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RailPathFinderService {
|
export interface RailPathFinderService {
|
||||||
@@ -52,32 +65,11 @@ class RailPathFinderServiceImpl implements RailPathFinderService {
|
|||||||
constructor(private game: Game) {}
|
constructor(private game: Game) {}
|
||||||
|
|
||||||
findTilePath(from: TileRef, to: TileRef): TileRef[] {
|
findTilePath(from: TileRef, to: TileRef): TileRef[] {
|
||||||
const astar = new MiniAStar(
|
return PathFinding.Rail(this.game).findPath(from, to) ?? [];
|
||||||
this.game.map(),
|
|
||||||
this.game.miniMap(),
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
5000,
|
|
||||||
20,
|
|
||||||
false,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
return astar.compute() === PathFindResultType.Completed
|
|
||||||
? astar.reconstructPath()
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
|
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
|
||||||
const stationAStar = new SerialAStar(
|
return PathFinding.Stations(this.game).findPath(from, to) ?? [];
|
||||||
from,
|
|
||||||
to,
|
|
||||||
5000,
|
|
||||||
20,
|
|
||||||
new TrainStationMapAdapter(this.game),
|
|
||||||
);
|
|
||||||
return stationAStar.compute() === PathFindResultType.Completed
|
|
||||||
? stationAStar.reconstructPath()
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,22 +84,26 @@ export class RailNetworkImpl implements RailNetwork {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private game: Game,
|
private game: Game,
|
||||||
private stationManager: StationManager,
|
private _stationManager: StationManager,
|
||||||
private pathService: RailPathFinderService,
|
private pathService: RailPathFinderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
stationManager(): StationManager {
|
||||||
|
return this._stationManager;
|
||||||
|
}
|
||||||
|
|
||||||
connectStation(station: TrainStation) {
|
connectStation(station: TrainStation) {
|
||||||
this.stationManager.addStation(station);
|
this._stationManager.addStation(station);
|
||||||
this.connectToNearbyStations(station);
|
this.connectToNearbyStations(station);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeStation(unit: Unit): void {
|
removeStation(unit: Unit): void {
|
||||||
const station = this.stationManager.findStation(unit);
|
const station = this._stationManager.findStation(unit);
|
||||||
if (!station) return;
|
if (!station) return;
|
||||||
|
|
||||||
const neighbors = station.neighbors();
|
const neighbors = station.neighbors();
|
||||||
this.disconnectFromNetwork(station);
|
this.disconnectFromNetwork(station);
|
||||||
this.stationManager.removeStation(station);
|
this._stationManager.removeStation(station);
|
||||||
|
|
||||||
const cluster = station.getCluster();
|
const cluster = station.getCluster();
|
||||||
if (!cluster) return;
|
if (!cluster) return;
|
||||||
@@ -142,7 +138,7 @@ export class RailNetworkImpl implements RailNetwork {
|
|||||||
|
|
||||||
for (const neighbor of neighbors) {
|
for (const neighbor of neighbors) {
|
||||||
if (neighbor.unit === station.unit) continue;
|
if (neighbor.unit === station.unit) continue;
|
||||||
const neighborStation = this.stationManager.findStation(neighbor.unit);
|
const neighborStation = this._stationManager.findStation(neighbor.unit);
|
||||||
if (!neighborStation) continue;
|
if (!neighborStation) continue;
|
||||||
|
|
||||||
const distanceToStation = this.distanceFrom(
|
const distanceToStation = this.distanceFrom(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TrainExecution } from "../execution/TrainExecution";
|
import { TrainExecution } from "../execution/TrainExecution";
|
||||||
import { GraphAdapter } from "../pathfinding/SerialAStar";
|
|
||||||
import { PseudoRandom } from "../PseudoRandom";
|
import { PseudoRandom } from "../PseudoRandom";
|
||||||
import { Game, Player, Unit, UnitType } from "./Game";
|
import { Game, Player, Unit, UnitType } from "./Game";
|
||||||
import { TileRef } from "./GameMap";
|
import { TileRef } from "./GameMap";
|
||||||
@@ -72,6 +71,7 @@ export function createTrainStopHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TrainStation {
|
export class TrainStation {
|
||||||
|
id: number = -1; // assigned by StationManager
|
||||||
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
||||||
{};
|
{};
|
||||||
private cluster: Cluster | null;
|
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
|
* Cluster of connected stations
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PathFindResultType } from "../pathfinding/AStar";
|
import { SpatialQuery } from "../pathfinding/spatial/SpatialQuery";
|
||||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
|
||||||
import { Game, Player, UnitType } from "./Game";
|
import { Game, Player, UnitType } from "./Game";
|
||||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
|
import { TileRef } from "./GameMap";
|
||||||
|
|
||||||
export function canBuildTransportShip(
|
export function canBuildTransportShip(
|
||||||
game: Game,
|
game: Game,
|
||||||
@@ -27,236 +26,20 @@ export function canBuildTransportShip(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.isOceanShore(dst)) {
|
const spatial = new SpatialQuery(game);
|
||||||
let myPlayerBordersOcean = false;
|
return spatial.closestShoreByWater(player, dst) ?? 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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
|
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
|
||||||
const dst = gm.playerBySmallID(gm.ownerID(tile));
|
const spatial = new SpatialQuery(gm);
|
||||||
let dstTile: TileRef | null = null;
|
return spatial.closestShore(gm.owner(tile), tile);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bestShoreDeploymentSource(
|
export function bestShoreDeploymentSource(
|
||||||
gm: Game,
|
gm: Game,
|
||||||
player: Player,
|
player: Player,
|
||||||
target: TileRef,
|
dst: 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,
|
|
||||||
): TileRef | null {
|
): TileRef | null {
|
||||||
const tn = Array.from(
|
const spatial = new SpatialQuery(gm);
|
||||||
gm.bfs(
|
return spatial.closestShoreByWater(player, dst);
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,66 @@
|
|||||||
|
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 (x === dstX) {
|
||||||
|
// Can only move in Y
|
||||||
|
nextY += y < dstY ? 1 : -1;
|
||||||
|
} else if (y === dstY) {
|
||||||
|
// Can only move in X
|
||||||
|
nextX += x < dstX ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
if (random.chance(ratio)) {
|
||||||
|
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 { Game } from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { GameMap, TileRef } from "../game/GameMap";
|
||||||
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
|
import { TrainStation } from "../game/TrainStation";
|
||||||
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
|
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,
|
* Pathfinders that work with GameMap - usable in both simulation and UI layers
|
||||||
PENDING,
|
*/
|
||||||
COMPLETE,
|
export class UniversalPathFinding {
|
||||||
NOT_FOUND,
|
static Parabola(
|
||||||
|
gameMap: GameMap,
|
||||||
|
options?: ParabolaOptions,
|
||||||
|
): ParabolaUniversalPathFinder {
|
||||||
|
return new ParabolaUniversalPathFinder(gameMap, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PathResult =
|
/**
|
||||||
| { status: PathStatus.PENDING }
|
* Pathfinders that require Game - simulation layer only
|
||||||
| { status: PathStatus.NEXT; node: TileRef }
|
*/
|
||||||
| { status: PathStatus.COMPLETE; node: TileRef }
|
export class PathFinding {
|
||||||
| { status: PathStatus.NOT_FOUND };
|
static Water(game: Game): SteppingPathFinder<TileRef> {
|
||||||
|
const pf = game.miniWaterHPA();
|
||||||
|
const graph = game.miniWaterGraph();
|
||||||
|
|
||||||
export interface PathFinder {
|
if (!pf || !graph || graph.nodeCount < 100) {
|
||||||
next(from: TileRef, to: TileRef, dist?: number): PathResult;
|
return PathFinding.WaterSimple(game);
|
||||||
findPath(from: TileRef, to: TileRef): TileRef[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MiniAStarOptions {
|
|
||||||
waterPath?: boolean;
|
|
||||||
iterations?: number;
|
|
||||||
maxTries?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PathFinders {
|
|
||||||
static Water(game: Game): PathFinder {
|
|
||||||
if (!game.navMesh()) {
|
|
||||||
// Fall back to old water pathfinder if navmesh is not available
|
|
||||||
return PathFinders.WaterLegacy(game);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NavMeshAdapter(game);
|
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 {
|
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
|
||||||
return new MiniAStarAdapter(game, options);
|
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,119 @@
|
|||||||
|
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 fromArray = Array.isArray(from) ? from : [from];
|
||||||
|
|
||||||
|
const allFailed = fromArray.every((f) => {
|
||||||
|
const result = this.config.preCheck!(f, to);
|
||||||
|
return result?.status === PathStatus.NOT_FOUND;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allFailed) {
|
||||||
|
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> {
|
// 4-direction grid BFS with stamp-based visited tracking
|
||||||
visitor(node: number, dist: number): T | null | undefined;
|
export class BFSGrid {
|
||||||
isValidNode(node: number): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimized BFS using stamp-based visited tracking and typed array queue
|
|
||||||
export class FastBFS {
|
|
||||||
private stamp = 1;
|
private stamp = 1;
|
||||||
|
|
||||||
private readonly visitedStamp: Uint32Array;
|
private readonly visitedStamp: Uint32Array;
|
||||||
private readonly queue: Int32Array;
|
private readonly queue: Int32Array;
|
||||||
private readonly dist: Uint16Array;
|
private readonly dist: Uint16Array;
|
||||||
@@ -16,48 +12,57 @@ export class FastBFS {
|
|||||||
this.dist = new Uint16Array(numNodes);
|
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,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
start: number,
|
start: number | number[],
|
||||||
maxDistance: number,
|
maxDistance: number,
|
||||||
isValidNode: FastBFSAdapter<T>["isValidNode"],
|
isValidNode: (node: number) => boolean,
|
||||||
visitor: FastBFSAdapter<T>["visitor"],
|
visitor: (node: number, dist: number) => R | null | undefined,
|
||||||
): T | null {
|
): R | null {
|
||||||
const stamp = this.nextStamp();
|
const stamp = this.nextStamp();
|
||||||
const lastRowStart = (height - 1) * width;
|
const lastRowStart = (height - 1) * width;
|
||||||
|
const starts = typeof start === "number" ? [start] : start;
|
||||||
|
|
||||||
let head = 0;
|
let head = 0;
|
||||||
let tail = 0;
|
let tail = 0;
|
||||||
|
|
||||||
this.visitedStamp[start] = stamp;
|
for (const s of starts) {
|
||||||
this.dist[start] = 0;
|
this.visitedStamp[s] = stamp;
|
||||||
this.queue[tail++] = start;
|
this.dist[s] = 0;
|
||||||
|
this.queue[tail++] = s;
|
||||||
|
}
|
||||||
|
|
||||||
while (head < tail) {
|
while (head < tail) {
|
||||||
const node = this.queue[head++];
|
const node = this.queue[head++];
|
||||||
const currentDist = this.dist[node];
|
const dist = this.dist[node];
|
||||||
|
|
||||||
if (currentDist > maxDistance) {
|
const result = visitor(node, dist);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call visitor:
|
|
||||||
// - Returns T: Found target, return immediately
|
|
||||||
// - Returns null: Reject tile, don't explore neighbors
|
|
||||||
// - Returns undefined: Valid tile, explore neighbors
|
|
||||||
const result = visitor(node, currentDist);
|
|
||||||
|
|
||||||
if (result !== null && result !== undefined) {
|
if (result !== null && result !== undefined) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If visitor returned null, reject this tile and don't explore neighbors
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDist = currentDist + 1;
|
const nextDist = dist + 1;
|
||||||
|
|
||||||
|
if (nextDist > maxDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const x = node % width;
|
const x = node % width;
|
||||||
|
|
||||||
// North
|
// North
|
||||||
@@ -107,8 +112,7 @@ export class FastBFS {
|
|||||||
private nextStamp(): number {
|
private nextStamp(): number {
|
||||||
const stamp = this.stamp++;
|
const stamp = this.stamp++;
|
||||||
|
|
||||||
if (this.stamp === 0) {
|
if (this.stamp > 0xffffffff) {
|
||||||
// Overflow - reset (extremely rare)
|
|
||||||
this.visitedStamp.fill(0);
|
this.visitedStamp.fill(0);
|
||||||
this.stamp = 1;
|
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";
|
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.
|
* Connected component labeling for grid-based maps.
|
||||||
* Pre-allocates buffers and provides explicit initialization.
|
* Identifies isolated regions using scan-line flood-fill.
|
||||||
*/
|
*/
|
||||||
export class WaterComponents {
|
export class ConnectedComponents {
|
||||||
private readonly width: number;
|
private readonly width: number;
|
||||||
private readonly height: number;
|
private readonly height: number;
|
||||||
private readonly numTiles: number;
|
private readonly numTiles: number;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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(private capacity: number) {
|
||||||
|
this.heap = new Int32Array(capacity);
|
||||||
|
this.priorities = new Float32Array(capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
push(node: number, priority: number): void {
|
||||||
|
if (this.size >= this.capacity) {
|
||||||
|
throw new Error(`MinHeap capacity exceeded: ${this.capacity}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,91 @@
|
|||||||
|
// 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 {
|
||||||
|
const fromArray = Array.isArray(from) ? from : [from];
|
||||||
|
const waterToOriginal = new Map<TileRef, TileRef | null>();
|
||||||
|
const waterFrom: TileRef[] = [];
|
||||||
|
|
||||||
|
for (const f of fromArray) {
|
||||||
|
const coerced = this.coerceToWater(f);
|
||||||
|
if (coerced.water !== null) {
|
||||||
|
waterFrom.push(coerced.water);
|
||||||
|
waterToOriginal.set(coerced.water, coerced.original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waterFrom.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce to tile
|
||||||
|
const coercedTo = this.coerceToWater(to);
|
||||||
|
if (coercedTo.water === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search on water tiles
|
||||||
|
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
|
||||||
|
const path = this.inner.findPath(fromTiles, coercedTo.water);
|
||||||
|
if (!path || path.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the actual path start in the map
|
||||||
|
const originalShore = waterToOriginal.get(path[0]);
|
||||||
|
if (originalShore !== undefined && originalShore !== null) {
|
||||||
|
path.unshift(originalShore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append original to if different
|
||||||
|
if (
|
||||||
|
coercedTo.original !== null &&
|
||||||
|
path[path.length - 1] !== coercedTo.original
|
||||||
|
) {
|
||||||
|
path.push(coercedTo.original);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
player1.conquer(game.map().ref(coastX, 4));
|
||||||
player2.conquer(game.map().ref(coastX, 1));
|
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);
|
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||||
|
|
||||||
game.addExecution(
|
game.addExecution(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
|
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
|
||||||
import { Game, Player, Unit } from "../../../src/core/game/Game";
|
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";
|
import { setup } from "../../util/Setup";
|
||||||
|
|
||||||
describe("TradeShipExecution", () => {
|
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,180 @@
|
|||||||
|
#!/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,a.baseline
|
||||||
|
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,a.baseline")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline
|
||||||
|
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
|
||||||
|
|
||||||
|
Available adapters:
|
||||||
|
a.baseline - A* on minimap (inlined)
|
||||||
|
a.generic - A* on minimap (adapter)
|
||||||
|
a.full - A* on full map
|
||||||
|
hpa - Hierarchical pathfinding (no cache)
|
||||||
|
hpa.cached - Hierarchical pathfinding (with cache)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { dirname, join } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { Game } from "../../../../src/core/game/Game.js";
|
import { Game } from "../../../../src/core/game/Game.js";
|
||||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
|
||||||
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
|
|
||||||
import { setupFromPath } from "../../utils.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 {
|
export interface MapInfo {
|
||||||
name: string;
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -13,7 +16,7 @@ export interface MapInfo {
|
|||||||
|
|
||||||
export interface MapCache {
|
export interface MapCache {
|
||||||
game: Game;
|
game: Game;
|
||||||
navMesh: NavMesh;
|
hpaStar: AStarWaterHierarchical;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = new Map<string, MapCache>();
|
const cache = new Map<string, MapCache>();
|
||||||
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
|
|||||||
const mapsDir = getMapsDirectory();
|
const mapsDir = getMapsDirectory();
|
||||||
|
|
||||||
// Use the existing setupFromPath utility to load the map
|
// 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
|
// Get pre-built graph from game
|
||||||
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
|
const graph = game.miniWaterGraph();
|
||||||
navMesh.initialize();
|
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
|
// Store in cache
|
||||||
cache.set(mapName, cacheEntry);
|
cache.set(mapName, cacheEntry);
|
||||||
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
|
|||||||
* Get map metadata for client
|
* Get map metadata for client
|
||||||
*/
|
*/
|
||||||
export async function getMapMetadata(mapName: string) {
|
export async function getMapMetadata(mapName: string) {
|
||||||
const { game, navMesh } = await loadMap(mapName);
|
const { game, hpaStar } = await loadMap(mapName);
|
||||||
|
|
||||||
// Extract map data
|
// Extract map data
|
||||||
const mapData: number[] = [];
|
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 miniMap = game.miniMap();
|
||||||
const navMeshGraph = (navMesh as any).graph;
|
|
||||||
|
|
||||||
// Convert gateways from Map to array
|
// Convert nodes to client format
|
||||||
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
|
const allNodes = graph.getAllNodes().map((node: any) => ({
|
||||||
const allGateways = gatewaysArray.map((gw: any) => ({
|
id: node.id,
|
||||||
id: gw.id,
|
x: miniMap.x(node.tile),
|
||||||
x: miniMap.x(gw.tile),
|
y: miniMap.y(node.tile),
|
||||||
y: miniMap.y(gw.tile),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create a lookup map from gateway ID to gateway for edge conversion
|
// Convert edges to client format
|
||||||
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
|
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
|
const nodeA = graph.getNode(edge.nodeA);
|
||||||
// The edges Map has gateway IDs as keys, and arrays of edges as values
|
const nodeB = graph.getNode(edge.nodeB);
|
||||||
const allEdges: any[] = [];
|
if (!nodeA || !nodeB) continue;
|
||||||
for (const edgeArray of navMeshGraph.edges.values()) {
|
|
||||||
allEdges.push(...edgeArray);
|
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(
|
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 {
|
return {
|
||||||
name: mapName,
|
name: mapName,
|
||||||
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
|
|||||||
height: game.height(),
|
height: game.height(),
|
||||||
mapData,
|
mapData,
|
||||||
graphDebug: {
|
graphDebug: {
|
||||||
allGateways,
|
allNodes,
|
||||||
edges,
|
edges,
|
||||||
sectorSize,
|
clusterSize,
|
||||||
},
|
},
|
||||||
|
adapters: COMPARISON_ADAPTERS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,64 @@
|
|||||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||||
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
|
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
|
||||||
import { loadMap } from "./maps.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 {
|
// Primary result with debug info
|
||||||
includePfMini?: boolean;
|
interface PrimaryResult {
|
||||||
includeNavMesh?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavMeshResult {
|
|
||||||
path: Array<[number, number]> | null;
|
path: Array<[number, number]> | null;
|
||||||
initialPath: Array<[number, number]> | null;
|
|
||||||
gateways: Array<[number, number]> | null;
|
|
||||||
timings: any;
|
|
||||||
length: number;
|
length: number;
|
||||||
time: 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;
|
path: Array<[number, number]> | null;
|
||||||
length: number;
|
length: number;
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache pathfinding adapters per map
|
export interface PathfindResult {
|
||||||
const pfMiniCache = new Map<string, MiniAStarAdapter>();
|
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 {
|
function getOrCreateAdapter(
|
||||||
if (!pfMiniCache.has(mapName)) {
|
mapName: string,
|
||||||
const adapter = new MiniAStarAdapter(game, { waterPath: true });
|
adapterName: string,
|
||||||
pfMiniCache.set(mapName, adapter);
|
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(
|
function buildWrappedPathFinder(
|
||||||
mapName: string,
|
hpaStar: AStarWaterHierarchical,
|
||||||
from: [number, number],
|
game: any,
|
||||||
to: [number, number],
|
graph: any,
|
||||||
options: PathfindingOptions = {},
|
): PathFinder<TileRef> {
|
||||||
): Promise<NavMeshResult> {
|
const miniMap = game.miniMap();
|
||||||
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
|
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
|
||||||
|
|
||||||
// Convert coordinates to TileRefs
|
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
|
||||||
const fromRef = game.ref(from[0], from[1]);
|
const withComponentCheck = new ComponentCheckTransformer(
|
||||||
const toRef = game.ref(to[0], to[1]);
|
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
|
return withMiniMap;
|
||||||
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);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
const miniMap = game.miniMap();
|
||||||
|
|
||||||
// Extract debug info
|
// Build wrapped pathfinder with all transformers
|
||||||
let gateways: Array<[number, number]> | null = null;
|
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 initialPath: Array<[number, number]> | null = null;
|
||||||
let timings: any = {};
|
if (debugInfo?.initialPath) {
|
||||||
|
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
|
||||||
if (navMeshAdapter.debugInfo) {
|
const x = miniMap.x(tile) * 2;
|
||||||
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
|
const y = miniMap.y(tile) * 2;
|
||||||
if (navMeshAdapter.debugInfo.gatewayPath) {
|
return [x, y] as [number, number];
|
||||||
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 || {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path: pathToCoords(path, game),
|
||||||
initialPath,
|
|
||||||
gateways,
|
|
||||||
timings,
|
|
||||||
length: path ? path.length : 0,
|
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(
|
function computeComparisonPath(
|
||||||
mapName: string,
|
adapter: SteppingPathFinder<TileRef>,
|
||||||
from: [number, number],
|
game: any,
|
||||||
to: [number, number],
|
fromRef: TileRef,
|
||||||
): Promise<PfMiniResult> {
|
toRef: TileRef,
|
||||||
const { game } = await loadMap(mapName);
|
adapterName: string,
|
||||||
|
): ComparisonResult {
|
||||||
// Convert coordinates to TileRefs
|
const start = performance.now();
|
||||||
const fromRef = game.ref(from[0], from[1]);
|
const path = adapter.findPath(fromRef, toRef);
|
||||||
const toRef = game.ref(to[0], to[1]);
|
const time = performance.now() - start;
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
adapter: adapterName,
|
||||||
|
path: pathToCoords(path, game),
|
||||||
length: path ? path.length : 0,
|
length: path ? path.length : 0,
|
||||||
time,
|
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
|
* Clear pathfinding adapter caches
|
||||||
*/
|
*/
|
||||||
export function clearAdapterCaches() {
|
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">
|
<button class="toggle-button" id="showInitialPath" data-active="false">
|
||||||
Initial Path
|
Initial Path
|
||||||
</button>
|
</button>
|
||||||
<button class="toggle-button" id="showUsedGateways" data-active="false">
|
<button class="toggle-button" id="showUsedNodes" data-active="false">
|
||||||
Used Gateways
|
Used Nodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="debug-panel-row">
|
<div class="debug-panel-row">
|
||||||
<button class="toggle-button" id="showGateways" data-active="false">
|
<button class="toggle-button" id="showNodes" data-active="false">
|
||||||
Gateways
|
Nodes
|
||||||
</button>
|
</button>
|
||||||
<button class="toggle-button" id="showSectorGrid" data-active="false">
|
<button class="toggle-button" id="showSectorGrid" data-active="false">
|
||||||
Sectors
|
Sectors
|
||||||
@@ -166,75 +166,42 @@
|
|||||||
<div class="timing-label">
|
<div class="timing-label">
|
||||||
<button
|
<button
|
||||||
class="refresh-icon"
|
class="refresh-icon"
|
||||||
id="refreshNavMesh"
|
id="refreshHpa"
|
||||||
title="Recompute NavMesh path"
|
title="Recompute HPA* path"
|
||||||
>
|
>
|
||||||
<span>↻</span>
|
<span>↻</span>
|
||||||
</button>
|
</button>
|
||||||
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
|
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
|
||||||
</div>
|
</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-breakdown" id="timingBreakdown">
|
||||||
<div class="timing-item" id="timingEarlyExit" style="display: none">
|
<div class="timing-item" id="timingEarlyExit" style="display: none">
|
||||||
<span class="timing-name">Early Exit:</span>
|
<span class="timing-name">Early Exit:</span>
|
||||||
<span class="timing-value" id="timingEarlyExitValue">—</span>
|
<span class="timing-value" id="timingEarlyExitValue">—</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="timing-item"
|
class="timing-item"
|
||||||
id="timingFindGateways"
|
id="timingAbstractPath"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
>
|
>
|
||||||
<span class="timing-name">Find Gateways:</span>
|
<span class="timing-name">Abstract Path:</span>
|
||||||
<span class="timing-value" id="timingFindGatewaysValue">—</span>
|
<span class="timing-value" id="timingAbstractPathValue">—</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="timing-item" id="timingInitialPath" style="display: none">
|
<div class="timing-item" id="timingInitialPath" style="display: none">
|
||||||
<span class="timing-name">Initial Path:</span>
|
<span class="timing-name">Initial Path:</span>
|
||||||
<span class="timing-value" id="timingInitialPathValue">—</span>
|
<span class="timing-value" id="timingInitialPathValue">—</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="timing-section" id="pfMiniRequestSection">
|
<div class="timing-section" id="comparisonsSection" style="display: none">
|
||||||
<button
|
<div class="timing-label">Comparisons</div>
|
||||||
id="requestPfMini"
|
<div id="comparisonsContainer"></div>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,13 +223,9 @@
|
|||||||
></div>
|
></div>
|
||||||
<span>End Point</span>
|
<span>End Point</span>
|
||||||
</div>
|
</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-item">
|
||||||
<div class="legend-color" style="background: #00ffff"></div>
|
<div class="legend-color" style="background: #00ffff"></div>
|
||||||
<span>NavMesh</span>
|
<span>HPA*</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color" style="background: #ff00ff"></div>
|
<div class="legend-color" style="background: #ff00ff"></div>
|
||||||
@@ -273,7 +236,7 @@
|
|||||||
class="legend-color"
|
class="legend-color"
|
||||||
style="background: #ffff00; height: 8px"
|
style="background: #ffff00; height: 8px"
|
||||||
></div>
|
></div>
|
||||||
<span>Used Gateways</span>
|
<span>Used Nodes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div
|
<div
|
||||||
@@ -285,7 +248,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
<span>Gateways</span>
|
<span>Nodes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -500,6 +500,67 @@ canvas {
|
|||||||
font-size: 20px;
|
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 (right side) */
|
||||||
.legend-panel {
|
.legend-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
listMaps,
|
listMaps,
|
||||||
setConfig,
|
setConfig,
|
||||||
} from "./api/maps.js";
|
} from "./api/maps.js";
|
||||||
import {
|
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
|
||||||
clearAdapterCaches,
|
|
||||||
computePath,
|
|
||||||
computePfMiniPath,
|
|
||||||
} from "./api/pathfinding.js";
|
|
||||||
|
|
||||||
// Parse command-line arguments
|
// Parse command-line arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
|
|||||||
* map: string,
|
* map: string,
|
||||||
* from: [x, y],
|
* from: [x, y],
|
||||||
* to: [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) => {
|
app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { map, from, to, includePfMini } = req.body;
|
const { map, from, to, adapters } = req.body;
|
||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if (!map || !from || !to) {
|
if (!map || !from || !to) {
|
||||||
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
|
|||||||
map,
|
map,
|
||||||
from as [number, number],
|
from as [number, number],
|
||||||
to as [number, number],
|
to as [number, number],
|
||||||
{ includePfMini: !!includePfMini },
|
{ adapters },
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(result);
|
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
|
* POST /api/cache/clear
|
||||||
* Clear all caches (useful for development)
|
* Clear all caches (useful for development)
|
||||||
|
|||||||
+58
-16
@@ -17,10 +17,19 @@ import {
|
|||||||
MapManifest,
|
MapManifest,
|
||||||
} from "../../src/core/game/TerrainMapLoader";
|
} from "../../src/core/game/TerrainMapLoader";
|
||||||
import { UserSettings } from "../../src/core/game/UserSettings";
|
import { UserSettings } from "../../src/core/game/UserSettings";
|
||||||
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
|
import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water";
|
||||||
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
|
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 { GameConfig } from "../../src/core/Schemas";
|
||||||
import { TestConfig } from "../util/TestConfig";
|
import { TestConfig } from "../util/TestConfig";
|
||||||
|
|
||||||
export type BenchmarkRoute = {
|
export type BenchmarkRoute = {
|
||||||
name: string;
|
name: string;
|
||||||
from: TileRef;
|
from: TileRef;
|
||||||
@@ -42,25 +51,58 @@ export type BenchmarkSummary = {
|
|||||||
avgTime: number;
|
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) {
|
switch (name) {
|
||||||
case "legacy":
|
case "a.baseline": {
|
||||||
return PathFinders.WaterLegacy(game, {
|
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
|
||||||
iterations: 500_000,
|
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
|
||||||
maxTries: 50,
|
.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": {
|
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
|
// over adding cache toggles to the existing game instance
|
||||||
// to avoid adding side effect from benchmark to the game
|
// to avoid adding side effect from benchmark to the game
|
||||||
const navMesh = new NavMesh(game, { cachePaths: false });
|
const graph = game.miniWaterGraph();
|
||||||
navMesh.initialize();
|
if (!graph) {
|
||||||
(game as any)._navMesh = navMesh;
|
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":
|
case "hpa.cached":
|
||||||
return PathFinders.Water(game);
|
return PathFinding.Water(game);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown pathfinding adapter: ${name}`);
|
throw new Error(`Unknown pathfinding adapter: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -102,7 +144,7 @@ export async function getScenario(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function measurePathLength(
|
export function measurePathLength(
|
||||||
adapter: PathFinder,
|
adapter: SteppingPathFinder<TileRef>,
|
||||||
route: BenchmarkRoute,
|
route: BenchmarkRoute,
|
||||||
): number | null {
|
): number | null {
|
||||||
const path = adapter.findPath(route.from, route.to);
|
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(
|
export function measureExecutionTime(
|
||||||
adapter: PathFinder,
|
adapter: SteppingPathFinder<TileRef>,
|
||||||
route: BenchmarkRoute,
|
route: BenchmarkRoute,
|
||||||
executions: number = 1,
|
executions: number = 1,
|
||||||
): number | null {
|
): 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