mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 02:37:44 +00:00
Merge branch 'main' into factory-radius-layer
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore");
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
includeIgnoreFile(gitignorePath),
|
||||
{ ignores: ["src/server/gatekeeper/**"] },
|
||||
{ ignores: ["src/server/gatekeeper/**", "tests/pathfinding/playground/**"] },
|
||||
{ files: ["**/*.{js,mjs,cjs,ts}"] },
|
||||
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
|
||||
pluginJs.configs.recommended,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"name": "World",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [375, 272],
|
||||
"flag": "us",
|
||||
"name": "United States"
|
||||
},
|
||||
{
|
||||
"coordinates": [372, 136],
|
||||
"flag": "ca",
|
||||
"name": "Canada"
|
||||
},
|
||||
{
|
||||
"coordinates": [375, 374],
|
||||
"flag": "mx",
|
||||
"name": "Mexico"
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 378],
|
||||
"flag": "cu",
|
||||
"name": "Cuba"
|
||||
},
|
||||
{
|
||||
"coordinates": [524, 474],
|
||||
"flag": "co",
|
||||
"name": "Colombia"
|
||||
},
|
||||
{
|
||||
"coordinates": [593, 473],
|
||||
"flag": "ve",
|
||||
"name": "Venezuela"
|
||||
},
|
||||
{
|
||||
"coordinates": [596, 705],
|
||||
"flag": "ar",
|
||||
"name": "Argentina"
|
||||
},
|
||||
{
|
||||
"coordinates": [637, 567],
|
||||
"flag": "br",
|
||||
"name": "Brazil"
|
||||
},
|
||||
{
|
||||
"coordinates": [1280, 975],
|
||||
"flag": "aq",
|
||||
"name": "Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [709, 57],
|
||||
"flag": "gl",
|
||||
"name": "Greenland"
|
||||
},
|
||||
{
|
||||
"coordinates": [831, 112],
|
||||
"flag": "is",
|
||||
"name": "Iceland"
|
||||
},
|
||||
{
|
||||
"coordinates": [925, 186],
|
||||
"flag": "gb",
|
||||
"name": "United Kingdom"
|
||||
},
|
||||
{
|
||||
"coordinates": [887, 183],
|
||||
"flag": "ie",
|
||||
"name": "Ireland"
|
||||
},
|
||||
{
|
||||
"coordinates": [908, 264],
|
||||
"flag": "es",
|
||||
"name": "Spain"
|
||||
},
|
||||
{
|
||||
"coordinates": [1004, 250],
|
||||
"flag": "it",
|
||||
"name": "Italy"
|
||||
},
|
||||
{
|
||||
"coordinates": [958, 220],
|
||||
"flag": "fr",
|
||||
"name": "France"
|
||||
},
|
||||
{
|
||||
"coordinates": [997, 205],
|
||||
"flag": "de",
|
||||
"name": "Germany"
|
||||
},
|
||||
{
|
||||
"coordinates": [1064, 101],
|
||||
"flag": "se",
|
||||
"name": "Sweden"
|
||||
},
|
||||
{
|
||||
"coordinates": [1046, 193],
|
||||
"flag": "pl",
|
||||
"name": "Poland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1061, 188],
|
||||
"flag": "by",
|
||||
"name": "Belarus"
|
||||
},
|
||||
{
|
||||
"coordinates": [1073, 243],
|
||||
"flag": "ro",
|
||||
"name": "Romania"
|
||||
},
|
||||
{
|
||||
"coordinates": [1161, 274],
|
||||
"flag": "tr",
|
||||
"name": "Turkey"
|
||||
},
|
||||
{
|
||||
"coordinates": [969, 133],
|
||||
"flag": "no",
|
||||
"name": "Norway"
|
||||
},
|
||||
{
|
||||
"coordinates": [1062, 133],
|
||||
"flag": "fi",
|
||||
"name": "Finland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 211],
|
||||
"flag": "ua",
|
||||
"name": "Ukraine"
|
||||
},
|
||||
{
|
||||
"coordinates": [1344, 136],
|
||||
"flag": "ru",
|
||||
"name": "Russia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 186],
|
||||
"flag": "mn",
|
||||
"name": "Mongolia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1524, 328],
|
||||
"flag": "cn",
|
||||
"name": "China"
|
||||
},
|
||||
{
|
||||
"coordinates": [1368, 373],
|
||||
"flag": "in",
|
||||
"name": "India"
|
||||
},
|
||||
{
|
||||
"coordinates": [1276, 239],
|
||||
"flag": "kz",
|
||||
"name": "Kazakhstan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1238, 309],
|
||||
"flag": "ir",
|
||||
"name": "Islamic Republic Of Iran"
|
||||
},
|
||||
{
|
||||
"coordinates": [1178, 351],
|
||||
"flag": "sa",
|
||||
"name": "Saudi Arabia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1679, 657],
|
||||
"flag": "au",
|
||||
"name": "Australia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1890, 775],
|
||||
"flag": "nz",
|
||||
"name": "New Zealand"
|
||||
},
|
||||
{
|
||||
"coordinates": [918, 342],
|
||||
"flag": "dz",
|
||||
"name": "Algeria"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 332],
|
||||
"flag": "ly",
|
||||
"name": "Libyan Arab Jamahiriya"
|
||||
},
|
||||
{
|
||||
"coordinates": [1092, 335],
|
||||
"flag": "eg",
|
||||
"name": "Egypt"
|
||||
},
|
||||
{
|
||||
"coordinates": [963, 410],
|
||||
"flag": "ne",
|
||||
"name": "Niger"
|
||||
},
|
||||
{
|
||||
"coordinates": [1112, 406],
|
||||
"flag": "sd",
|
||||
"name": "Sudan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1074, 508],
|
||||
"flag": "cd",
|
||||
"name": "DR Congo"
|
||||
},
|
||||
{
|
||||
"coordinates": [1154, 443],
|
||||
"flag": "et",
|
||||
"name": "Ethiopia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 707],
|
||||
"flag": "za",
|
||||
"name": "South Africa"
|
||||
},
|
||||
{
|
||||
"coordinates": [1194, 627],
|
||||
"flag": "mg",
|
||||
"name": "Madagascar"
|
||||
},
|
||||
{
|
||||
"coordinates": [1052, 420],
|
||||
"flag": "td",
|
||||
"name": "Chad"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 665],
|
||||
"flag": "na",
|
||||
"name": "Namibia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1632, 465],
|
||||
"flag": "ph",
|
||||
"name": "Philippines"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 426],
|
||||
"flag": "th",
|
||||
"name": "Thailand"
|
||||
},
|
||||
{
|
||||
"coordinates": [1610, 364],
|
||||
"flag": "tw",
|
||||
"name": "Taiwan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1710, 290],
|
||||
"flag": "jp",
|
||||
"name": "Japan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1869, 119],
|
||||
"flag": "ru",
|
||||
"name": "Siberia"
|
||||
},
|
||||
{
|
||||
"coordinates": [74, 117],
|
||||
"flag": "polar_bears",
|
||||
"name": "Polar Bears"
|
||||
},
|
||||
{
|
||||
"coordinates": [419, 975],
|
||||
"flag": "aq",
|
||||
"name": "West Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [542, 603],
|
||||
"flag": "pe",
|
||||
"name": "Peru"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 615],
|
||||
"flag": "zm",
|
||||
"name": "Zambia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 165],
|
||||
"flag": "lv",
|
||||
"name": "Latvia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1427, 336],
|
||||
"flag": "bt",
|
||||
"name": "Bhutan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1511, 524],
|
||||
"flag": "id",
|
||||
"name": "Indonesia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1809, 977],
|
||||
"flag": "aq",
|
||||
"name": "East Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [1255, 382],
|
||||
"flag": "om",
|
||||
"name": "Oman"
|
||||
},
|
||||
{
|
||||
"coordinates": [853, 373],
|
||||
"flag": "ma",
|
||||
"name": "Morocco"
|
||||
},
|
||||
{
|
||||
"coordinates": [656, 678],
|
||||
"flag": "uy",
|
||||
"name": "Uruguay"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -70,6 +70,7 @@ var maps = []struct {
|
||||
{Name: "ocean_and_land", IsTest: true},
|
||||
{Name: "plains", IsTest: true},
|
||||
{Name: "giantworldmap", IsTest: true},
|
||||
{Name: "world", IsTest: true},
|
||||
}
|
||||
|
||||
// outputMapDir returns the absolute path to the directory where generated map files should be written.
|
||||
|
||||
@@ -182,6 +182,7 @@ export const GameConfigSchema = z.object({
|
||||
infiniteGold: z.boolean(),
|
||||
infiniteTroops: z.boolean(),
|
||||
instantBuild: z.boolean(),
|
||||
disableNavMesh: z.boolean().optional(),
|
||||
randomSpawn: z.boolean(),
|
||||
maxPlayers: z.number().optional(),
|
||||
maxTimerValue: z.number().int().min(1).max(120).optional(),
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface Config {
|
||||
infiniteTroops(): boolean;
|
||||
donateTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
disableNavMesh(): boolean;
|
||||
isRandomSpawn(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
|
||||
@@ -333,6 +333,9 @@ export class DefaultConfig implements Config {
|
||||
instantBuild(): boolean {
|
||||
return this._gameConfig.instantBuild;
|
||||
}
|
||||
disableNavMesh(): boolean {
|
||||
return this._gameConfig.disableNavMesh ?? false;
|
||||
}
|
||||
isRandomSpawn(): boolean {
|
||||
return this._gameConfig.randomSpawn;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { distSortUnit } from "../Util";
|
||||
|
||||
export class TradeShipExecution implements Execution {
|
||||
@@ -28,7 +27,7 @@ export class TradeShipExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 2500);
|
||||
this.pathFinder = PathFinders.Water(mg);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
@@ -102,14 +101,14 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(curTile, this._dstPort.tile());
|
||||
const result = this.pathFinder.next(curTile, this._dstPort.tile());
|
||||
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Pending:
|
||||
switch (result.status) {
|
||||
case PathStatus.PENDING:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.move(curTile);
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
case PathStatus.NEXT:
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
@@ -117,10 +116,10 @@ export class TradeShipExecution implements Execution {
|
||||
this.tradeShip.move(result.node);
|
||||
this.tilesTraveled++;
|
||||
break;
|
||||
case PathFindResultType.Completed:
|
||||
case PathStatus.COMPLETE:
|
||||
this.complete();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
case PathStatus.NOT_FOUND:
|
||||
console.warn("captured trade ship cannot find route");
|
||||
if (this.tradeShip.isActive()) {
|
||||
this.tradeShip.delete(false);
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
@@ -70,7 +69,7 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 100);
|
||||
this.pathFinder = PathFinders.Water(mg);
|
||||
|
||||
if (
|
||||
this.attacker.unitCount(UnitType.TransportShip) >=
|
||||
@@ -224,9 +223,9 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
const result = this.pathFinder.next(this.boat.tile(), this.dst);
|
||||
switch (result.status) {
|
||||
case PathStatus.COMPLETE:
|
||||
if (this.mg.owner(this.dst) === this.attacker) {
|
||||
const deaths = this.boat.troops() * (malusForRetreat / 100);
|
||||
const survivors = this.boat.troops() - deaths;
|
||||
@@ -269,12 +268,12 @@ export class TransportShipExecution implements Execution {
|
||||
.stats()
|
||||
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
case PathStatus.NEXT:
|
||||
this.boat.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
case PathStatus.PENDING:
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
case PathStatus.NOT_FOUND:
|
||||
// TODO: add to poisoned port list
|
||||
console.warn(`path not found to dst`);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
@@ -27,7 +26,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
|
||||
this.pathfinder = PathFinders.Water(mg);
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
if (isUnit(this.input)) {
|
||||
this.warship = this.input;
|
||||
@@ -177,24 +176,24 @@ export class WarshipExecution implements Execution {
|
||||
private huntDownTradeShip() {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// target is trade ship so capture it.
|
||||
const result = this.pathfinder.nextTile(
|
||||
const result = this.pathfinder.next(
|
||||
this.warship.tile(),
|
||||
this.warship.targetUnit()!.tile(),
|
||||
5,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
switch (result.status) {
|
||||
case PathStatus.COMPLETE:
|
||||
this.warship.owner().captureUnit(this.warship.targetUnit()!);
|
||||
this.warship.setTargetUnit(undefined);
|
||||
this.warship.move(this.warship.tile());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
case PathStatus.NOT_FOUND:
|
||||
console.log(`path not found to target`);
|
||||
break;
|
||||
}
|
||||
@@ -209,22 +208,22 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.pathfinder.nextTile(
|
||||
const result = this.pathfinder.next(
|
||||
this.warship.tile(),
|
||||
this.warship.targetTile()!,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
switch (result.status) {
|
||||
case PathStatus.COMPLETE:
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
case PathStatus.NOT_FOUND:
|
||||
console.warn(`path not found to target tile`);
|
||||
this.warship.setTargetTile(undefined);
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Config } from "../configuration/Config";
|
||||
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
|
||||
import { AllPlayersStats, ClientID } from "../Schemas";
|
||||
import { getClanTag } from "../Util";
|
||||
import { GameMap, TileRef } from "./GameMap";
|
||||
@@ -795,6 +796,7 @@ export interface Game extends GameMap {
|
||||
addUpdate(update: GameUpdate): void;
|
||||
railNetwork(): RailNetwork;
|
||||
conquerPlayer(conqueror: Player, conquered: Player): void;
|
||||
navMesh(): NavMesh | null;
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderNumber } from "../../client/Utils";
|
||||
import { Config } from "../configuration/Config";
|
||||
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
|
||||
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceImpl } from "./AllianceImpl";
|
||||
@@ -86,6 +87,7 @@ export class GameImpl implements Game {
|
||||
private nextAllianceID: number = 0;
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
private _navMesh: NavMesh | null = null;
|
||||
|
||||
constructor(
|
||||
private _humans: PlayerInfo[],
|
||||
@@ -104,6 +106,11 @@ export class GameImpl implements Game {
|
||||
this.populateTeams();
|
||||
}
|
||||
this.addPlayers();
|
||||
|
||||
if (!_config.disableNavMesh()) {
|
||||
this._navMesh = new NavMesh(this, { cachePaths: true });
|
||||
this._navMesh.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
private populateTeams() {
|
||||
@@ -957,6 +964,9 @@ export class GameImpl implements Game {
|
||||
railNetwork(): RailNetwork {
|
||||
return this._railNetwork;
|
||||
}
|
||||
navMesh(): NavMesh | null {
|
||||
return this._navMesh;
|
||||
}
|
||||
conquerPlayer(conqueror: Player, conquered: Player) {
|
||||
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
|
||||
const ships = conquered
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
|
||||
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
}
|
||||
|
||||
export type PathResult =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: TileRef }
|
||||
| { status: PathStatus.COMPLETE; node: TileRef }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
|
||||
export interface PathFinder {
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult;
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null;
|
||||
}
|
||||
|
||||
export interface MiniAStarOptions {
|
||||
waterPath?: boolean;
|
||||
iterations?: number;
|
||||
maxTries?: number;
|
||||
}
|
||||
|
||||
export class PathFinders {
|
||||
static Water(game: Game): PathFinder {
|
||||
if (!game.navMesh()) {
|
||||
// Fall back to old water pathfinder if navmesh is not available
|
||||
return PathFinders.WaterLegacy(game);
|
||||
}
|
||||
|
||||
return new NavMeshAdapter(game);
|
||||
}
|
||||
|
||||
static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder {
|
||||
return new MiniAStarAdapter(game, options);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export class AirPathFinder {
|
||||
}
|
||||
}
|
||||
|
||||
export class PathFinder {
|
||||
export class MiniPathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
private path: TileRef[] | null = null;
|
||||
@@ -122,28 +122,23 @@ export class PathFinder {
|
||||
private aStar: AStar<TileRef>;
|
||||
private computeFinished = true;
|
||||
|
||||
private constructor(
|
||||
constructor(
|
||||
private game: Game,
|
||||
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
|
||||
private iterations: number,
|
||||
private waterPath: boolean,
|
||||
private maxTries: number,
|
||||
) {}
|
||||
|
||||
public static Mini(
|
||||
game: Game,
|
||||
iterations: number,
|
||||
waterPath: boolean = true,
|
||||
maxTries: number = 20,
|
||||
) {
|
||||
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
|
||||
return new MiniAStar(
|
||||
game.map(),
|
||||
game.miniMap(),
|
||||
curr,
|
||||
dst,
|
||||
iterations,
|
||||
maxTries,
|
||||
waterPath,
|
||||
);
|
||||
});
|
||||
private createAStar(curr: TileRef, dst: TileRef): AStar<TileRef> {
|
||||
return new MiniAStar(
|
||||
this.game.map(),
|
||||
this.game.miniMap(),
|
||||
curr,
|
||||
dst,
|
||||
this.iterations,
|
||||
this.maxTries,
|
||||
this.waterPath,
|
||||
);
|
||||
}
|
||||
|
||||
nextTile(
|
||||
@@ -171,7 +166,7 @@ export class PathFinder {
|
||||
this.dst = dst;
|
||||
this.path = null;
|
||||
this.path_idx = 0;
|
||||
this.aStar = this.newAStar(curr, dst);
|
||||
this.aStar = this.createAStar(curr, dst);
|
||||
this.computeFinished = false;
|
||||
return this.nextTile(curr, dst);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PathFindResultType } from "../AStar";
|
||||
import {
|
||||
MiniAStarOptions,
|
||||
PathFinder,
|
||||
PathResult,
|
||||
PathStatus,
|
||||
} from "../PathFinder";
|
||||
import { MiniPathFinder } from "../PathFinding";
|
||||
|
||||
const DEFAULT_ITERATIONS = 10_000;
|
||||
const DEFAULT_MAX_TRIES = 100;
|
||||
|
||||
export class MiniAStarAdapter implements PathFinder {
|
||||
private miniPathFinder: MiniPathFinder;
|
||||
|
||||
constructor(game: Game, options?: MiniAStarOptions) {
|
||||
this.miniPathFinder = new MiniPathFinder(
|
||||
game,
|
||||
options?.iterations ?? DEFAULT_ITERATIONS,
|
||||
options?.waterPath ?? true,
|
||||
options?.maxTries ?? DEFAULT_MAX_TRIES,
|
||||
);
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
const result = this.miniPathFinder.nextTile(from, to, dist);
|
||||
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Pending:
|
||||
return { status: PathStatus.PENDING };
|
||||
case PathFindResultType.NextTile:
|
||||
return { status: PathStatus.NEXT, node: result.node };
|
||||
case PathFindResultType.Completed:
|
||||
return { status: PathStatus.COMPLETE, node: result.node };
|
||||
case PathFindResultType.PathNotFound:
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const path: TileRef[] = [from];
|
||||
let current = from;
|
||||
const maxSteps = 100_000;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
const result = this.next(current, to);
|
||||
|
||||
if (result.status === PathStatus.COMPLETE) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.status === PathStatus.NEXT) {
|
||||
current = result.node;
|
||||
path.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { NavMesh } from "../navmesh/NavMesh";
|
||||
import { PathFinder, PathResult, PathStatus } from "../PathFinder";
|
||||
|
||||
export class NavMeshAdapter implements PathFinder {
|
||||
private navMesh: NavMesh;
|
||||
private pathIndex = 0;
|
||||
private path: TileRef[] | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
constructor(private game: Game) {
|
||||
const navMesh = game.navMesh();
|
||||
if (!navMesh) {
|
||||
throw new Error("NavMeshAdapter requires game.navMesh() to be available");
|
||||
}
|
||||
this.navMesh = navMesh;
|
||||
}
|
||||
|
||||
next(from: TileRef, to: TileRef, dist?: number): PathResult {
|
||||
if (typeof from !== "number" || typeof to !== "number") {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (!this.game.isValidRef(from) || !this.game.isValidRef(to)) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
if (dist !== undefined && dist > 0) {
|
||||
const distance = this.game.manhattanDist(from, to);
|
||||
|
||||
if (distance <= dist) {
|
||||
return { status: PathStatus.COMPLETE, node: from };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastTo !== to) {
|
||||
this.path = null;
|
||||
this.pathIndex = 0;
|
||||
this.lastTo = to;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute if deviated from planned path
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
if (this.pathIndex > 0 && from !== expectedPos) {
|
||||
this.cachePath(from, to);
|
||||
|
||||
if (this.path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pathIndex >= this.path.length) {
|
||||
return { status: PathStatus.COMPLETE, node: to };
|
||||
}
|
||||
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
}
|
||||
|
||||
findPath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
return this.navMesh.findPath(from, to);
|
||||
}
|
||||
|
||||
private cachePath(from: TileRef, to: TileRef): boolean {
|
||||
try {
|
||||
this.path = this.navMesh.findPath(from, to);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pathIndex = 0;
|
||||
|
||||
// Path starts with 'from', skip to next tile
|
||||
if (this.path.length > 0 && this.path[0] === from) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// A* optimized for performance for small to medium graphs.
|
||||
// Works with node IDs represented as integers (0 to numNodes-1)
|
||||
|
||||
export interface FastAStarAdapter {
|
||||
getNeighbors(node: number): number[];
|
||||
getCost(from: number, to: number): number;
|
||||
heuristic(node: number, goal: number): number;
|
||||
}
|
||||
|
||||
// Simple binary min-heap for open set using typed arrays
|
||||
class MinHeap {
|
||||
private heap: Int32Array;
|
||||
private scores: Float32Array;
|
||||
private size = 0;
|
||||
|
||||
constructor(capacity: number, scores: Float32Array) {
|
||||
this.heap = new Int32Array(capacity);
|
||||
this.scores = scores;
|
||||
}
|
||||
|
||||
push(node: number): void {
|
||||
let i = this.size++;
|
||||
this.heap[i] = node;
|
||||
|
||||
// Bubble up
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.scores[this.heap[parent]] <= this.scores[this.heap[i]]) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[parent];
|
||||
this.heap[parent] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): number {
|
||||
const result = this.heap[0];
|
||||
this.heap[0] = this.heap[--this.size];
|
||||
|
||||
// Bubble down
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = (i << 1) + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
|
||||
if (
|
||||
left < this.size &&
|
||||
this.scores[this.heap[left]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
|
||||
if (
|
||||
right < this.size &&
|
||||
this.scores[this.heap[right]] < this.scores[this.heap[smallest]]
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
|
||||
if (smallest === i) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Swap
|
||||
const tmp = this.heap[smallest];
|
||||
this.heap[smallest] = this.heap[i];
|
||||
this.heap[i] = tmp;
|
||||
i = smallest;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class FastAStar {
|
||||
private stamp = 1;
|
||||
private readonly closedStamp: Uint32Array; // Tracks fully processed nodes
|
||||
private readonly gScoreStamp: Uint32Array; // Tracks valid gScores
|
||||
private readonly gScore: Float32Array;
|
||||
private readonly fScore: Float32Array;
|
||||
private readonly cameFrom: Int32Array;
|
||||
private readonly openHeap: MinHeap;
|
||||
|
||||
constructor(numNodes: number) {
|
||||
this.closedStamp = new Uint32Array(numNodes);
|
||||
this.gScoreStamp = new Uint32Array(numNodes);
|
||||
this.gScore = new Float32Array(numNodes);
|
||||
this.fScore = new Float32Array(numNodes);
|
||||
this.cameFrom = new Int32Array(numNodes);
|
||||
this.openHeap = new MinHeap(numNodes, this.fScore);
|
||||
}
|
||||
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
this.closedStamp.fill(0);
|
||||
this.gScoreStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
|
||||
search(
|
||||
start: number,
|
||||
goal: number,
|
||||
adapter: FastAStarAdapter,
|
||||
maxIterations: number = 100000,
|
||||
): number[] | null {
|
||||
const stamp = this.nextStamp();
|
||||
|
||||
this.openHeap.clear();
|
||||
this.gScore[start] = 0;
|
||||
this.gScoreStamp[start] = stamp;
|
||||
this.fScore[start] = adapter.heuristic(start, goal);
|
||||
this.cameFrom[start] = -1;
|
||||
this.openHeap.push(start);
|
||||
|
||||
let iterations = 0;
|
||||
|
||||
while (!this.openHeap.isEmpty() && iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
const current = this.openHeap.pop();
|
||||
|
||||
// Skip if already processed (duplicate from heap)
|
||||
if (this.closedStamp[current] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
this.closedStamp[current] = stamp;
|
||||
|
||||
// Found goal
|
||||
if (current === goal) {
|
||||
return this.reconstructPath(start, goal);
|
||||
}
|
||||
|
||||
const neighbors = adapter.getNeighbors(current);
|
||||
const currentGScore = this.gScore[current];
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
// Skip already processed neighbors
|
||||
if (this.closedStamp[neighbor] === stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tentativeGScore =
|
||||
currentGScore + adapter.getCost(current, neighbor);
|
||||
|
||||
// If we haven't visited this neighbor yet, or found a better path
|
||||
const hasValidGScore = this.gScoreStamp[neighbor] === stamp;
|
||||
if (!hasValidGScore || tentativeGScore < this.gScore[neighbor]) {
|
||||
this.cameFrom[neighbor] = current;
|
||||
this.gScore[neighbor] = tentativeGScore;
|
||||
this.gScoreStamp[neighbor] = stamp;
|
||||
this.fScore[neighbor] =
|
||||
tentativeGScore + adapter.heuristic(neighbor, goal);
|
||||
|
||||
// Add to heap (allow duplicates for better paths)
|
||||
this.openHeap.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private reconstructPath(start: number, goal: number): number[] {
|
||||
const path: number[] = [];
|
||||
let current = goal;
|
||||
|
||||
while (current !== start) {
|
||||
path.push(current);
|
||||
current = this.cameFrom[current];
|
||||
|
||||
// Safety check
|
||||
if (current === -1) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
path.push(start);
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastAStarAdapter } from "./FastAStar";
|
||||
import { GatewayGraph } from "./GatewayGraph";
|
||||
|
||||
export class GatewayGraphAdapter implements FastAStarAdapter {
|
||||
constructor(private graph: GatewayGraph) {}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const edges = this.graph.getEdges(node);
|
||||
return edges.map((edge) => edge.to);
|
||||
}
|
||||
|
||||
getCost(from: number, to: number): number {
|
||||
const edges = this.graph.getEdges(from);
|
||||
const edge = edges.find((edge) => edge.to === to);
|
||||
return edge?.cost ?? 1;
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeGw = this.graph.getGateway(node);
|
||||
const goalGw = this.graph.getGateway(goal);
|
||||
|
||||
if (!nodeGw || !goalGw) {
|
||||
throw new Error(
|
||||
`Invalid gateway ID in heuristic: node=${node} (${nodeGw ? "exists" : "missing"}), goal=${goal} (${goalGw ? "exists" : "missing"})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Manhattan distance heuristic
|
||||
const dx = Math.abs(nodeGw.x - goalGw.x);
|
||||
const dy = Math.abs(nodeGw.y - goalGw.y);
|
||||
return dx + dy;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedGameMapAdapter implements FastAStarAdapter {
|
||||
private readonly minX: number;
|
||||
private readonly minY: number;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly startTile: TileRef;
|
||||
private readonly goalTile: TileRef;
|
||||
|
||||
readonly numNodes: number;
|
||||
|
||||
constructor(
|
||||
private map: GameMap,
|
||||
startTile: TileRef,
|
||||
goalTile: TileRef,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
) {
|
||||
this.startTile = startTile;
|
||||
this.goalTile = goalTile;
|
||||
|
||||
this.minX = bounds.minX;
|
||||
this.minY = bounds.minY;
|
||||
this.width = bounds.maxX - bounds.minX + 1;
|
||||
this.height = bounds.maxY - bounds.minY + 1;
|
||||
|
||||
this.numNodes = this.width * this.height;
|
||||
}
|
||||
|
||||
// Convert global TileRef to local node ID
|
||||
tileToNode(tile: TileRef): number {
|
||||
const x = this.map.x(tile) - this.minX;
|
||||
const y = this.map.y(tile) - this.minY;
|
||||
|
||||
// Allow start and goal tiles to be outside bounds (matching graph building behavior)
|
||||
const isOutsideBounds =
|
||||
x < 0 || x >= this.width || y < 0 || y >= this.height;
|
||||
const isStartOrGoal = tile === this.startTile || tile === this.goalTile;
|
||||
if (isOutsideBounds && !isStartOrGoal) {
|
||||
return -1; // Outside bounds
|
||||
}
|
||||
|
||||
// Clamp coordinates for start/goal tiles that are outside bounds
|
||||
const clampedX = Math.max(0, Math.min(this.width - 1, x));
|
||||
const clampedY = Math.max(0, Math.min(this.height - 1, y));
|
||||
|
||||
return clampedY * this.width + clampedX;
|
||||
}
|
||||
|
||||
// Convert local node ID to global TileRef
|
||||
nodeToTile(node: number): TileRef {
|
||||
const localX = node % this.width;
|
||||
const localY = Math.floor(node / this.width);
|
||||
return this.map.ref(localX + this.minX, localY + this.minY);
|
||||
}
|
||||
|
||||
getNeighbors(node: number): number[] {
|
||||
const tile = this.nodeToTile(node);
|
||||
const neighbors = this.map.neighbors(tile);
|
||||
const result: number[] = [];
|
||||
|
||||
for (const neighborTile of neighbors) {
|
||||
if (!this.map.isWater(neighborTile)) continue;
|
||||
|
||||
const neighborNode = this.tileToNode(neighborTile);
|
||||
if (neighborNode !== -1) {
|
||||
result.push(neighborNode);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCost(_from: number, _to: number): number {
|
||||
return 1; // Uniform cost for water tiles
|
||||
}
|
||||
|
||||
heuristic(node: number, goal: number): number {
|
||||
const nodeTile = this.nodeToTile(node);
|
||||
const goalTile = this.nodeToTile(goal);
|
||||
|
||||
const dx = Math.abs(this.map.x(nodeTile) - this.map.x(goalTile));
|
||||
const dy = Math.abs(this.map.y(nodeTile) - this.map.y(goalTile));
|
||||
|
||||
return dx + dy; // Manhattan distance
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export interface FastBFSAdapter<T> {
|
||||
visitor(node: number, dist: number): T | null | undefined;
|
||||
isValidNode(node: number): boolean;
|
||||
}
|
||||
|
||||
// Optimized BFS using stamp-based visited tracking and typed array queue
|
||||
export class FastBFS {
|
||||
private stamp = 1;
|
||||
private readonly visitedStamp: Uint32Array;
|
||||
private readonly queue: Int32Array;
|
||||
private readonly dist: Uint16Array;
|
||||
|
||||
constructor(numNodes: number) {
|
||||
this.visitedStamp = new Uint32Array(numNodes);
|
||||
this.queue = new Int32Array(numNodes);
|
||||
this.dist = new Uint16Array(numNodes);
|
||||
}
|
||||
|
||||
search<T>(
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
maxDistance: number,
|
||||
isValidNode: FastBFSAdapter<T>["isValidNode"],
|
||||
visitor: FastBFSAdapter<T>["visitor"],
|
||||
): T | null {
|
||||
const stamp = this.nextStamp();
|
||||
const lastRowStart = (height - 1) * width;
|
||||
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
this.visitedStamp[start] = stamp;
|
||||
this.dist[start] = 0;
|
||||
this.queue[tail++] = start;
|
||||
|
||||
while (head < tail) {
|
||||
const node = this.queue[head++];
|
||||
const currentDist = this.dist[node];
|
||||
|
||||
if (currentDist > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call visitor:
|
||||
// - Returns T: Found target, return immediately
|
||||
// - Returns null: Reject tile, don't explore neighbors
|
||||
// - Returns undefined: Valid tile, explore neighbors
|
||||
const result = visitor(node, currentDist);
|
||||
|
||||
if (result !== null && result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If visitor returned null, reject this tile and don't explore neighbors
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDist = currentDist + 1;
|
||||
const x = node % width;
|
||||
|
||||
// North
|
||||
if (node >= width) {
|
||||
const n = node - width;
|
||||
if (this.visitedStamp[n] !== stamp && isValidNode(n)) {
|
||||
this.visitedStamp[n] = stamp;
|
||||
this.dist[n] = nextDist;
|
||||
this.queue[tail++] = n;
|
||||
}
|
||||
}
|
||||
|
||||
// South
|
||||
if (node < lastRowStart) {
|
||||
const s = node + width;
|
||||
if (this.visitedStamp[s] !== stamp && isValidNode(s)) {
|
||||
this.visitedStamp[s] = stamp;
|
||||
this.dist[s] = nextDist;
|
||||
this.queue[tail++] = s;
|
||||
}
|
||||
}
|
||||
|
||||
// West
|
||||
if (x !== 0) {
|
||||
const wv = node - 1;
|
||||
if (this.visitedStamp[wv] !== stamp && isValidNode(wv)) {
|
||||
this.visitedStamp[wv] = stamp;
|
||||
this.dist[wv] = nextDist;
|
||||
this.queue[tail++] = wv;
|
||||
}
|
||||
}
|
||||
|
||||
// East
|
||||
if (x !== width - 1) {
|
||||
const ev = node + 1;
|
||||
if (this.visitedStamp[ev] !== stamp && isValidNode(ev)) {
|
||||
this.visitedStamp[ev] = stamp;
|
||||
this.dist[ev] = nextDist;
|
||||
this.queue[tail++] = ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private nextStamp(): number {
|
||||
const stamp = this.stamp++;
|
||||
|
||||
if (this.stamp === 0) {
|
||||
// Overflow - reset (extremely rare)
|
||||
this.visitedStamp.fill(0);
|
||||
this.stamp = 1;
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { WaterComponents } from "./WaterComponents";
|
||||
|
||||
export interface Gateway {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tile: TileRef;
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
from: number;
|
||||
to: number;
|
||||
cost: number;
|
||||
path?: TileRef[];
|
||||
sectorX: number;
|
||||
sectorY: number;
|
||||
}
|
||||
|
||||
export interface Sector {
|
||||
x: number;
|
||||
y: number;
|
||||
gateways: Gateway[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export type BuildDebugInfo = {
|
||||
sectors: number | null;
|
||||
gateways: number | null;
|
||||
edges: number | null;
|
||||
actualBFSCalls: number | null;
|
||||
potentialBFSCalls: number | null;
|
||||
skippedByComponentFilter: number | null;
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class GatewayGraph {
|
||||
constructor(
|
||||
readonly sectors: ReadonlyMap<number, Sector>,
|
||||
readonly gateways: ReadonlyMap<number, Gateway>,
|
||||
readonly edges: ReadonlyMap<number, Edge[]>,
|
||||
readonly sectorSize: number,
|
||||
readonly sectorsX: number,
|
||||
) {}
|
||||
|
||||
getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
getSector(sectorX: number, sectorY: number): Sector | undefined {
|
||||
return this.sectors.get(this.getSectorKey(sectorX, sectorY));
|
||||
}
|
||||
|
||||
getGateway(id: number): Gateway | undefined {
|
||||
return this.gateways.get(id);
|
||||
}
|
||||
|
||||
getEdges(gatewayId: number): Edge[] {
|
||||
return this.edges.get(gatewayId) ?? [];
|
||||
}
|
||||
|
||||
getNearbySectorGateways(sectorX: number, sectorY: number): Gateway[] {
|
||||
const nearby: Gateway[] = [];
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sector = this.getSector(sectorX + dx, sectorY + dy);
|
||||
if (sector) {
|
||||
nearby.push(...sector.gateways);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearby;
|
||||
}
|
||||
|
||||
getAllGateways(): Gateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayGraphBuilder {
|
||||
static readonly SECTOR_SIZE = 32;
|
||||
|
||||
// Derived immutable state
|
||||
private readonly miniMap: GameMap;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly sectorsX: number;
|
||||
private readonly sectorsY: number;
|
||||
private readonly fastBFS: FastBFS;
|
||||
private readonly waterComponents: WaterComponents;
|
||||
|
||||
// Mutable build state
|
||||
private sectors = new Map<number, Sector>();
|
||||
private gateways = new Map<number, Gateway>();
|
||||
private tileToGateway = new Map<TileRef, Gateway>();
|
||||
private edges = new Map<number, Edge[]>();
|
||||
private nextGatewayId = 0;
|
||||
|
||||
// Programatically accessible debug info
|
||||
public debugInfo: BuildDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly game: Game,
|
||||
private readonly sectorSize: number,
|
||||
) {
|
||||
this.miniMap = game.miniMap();
|
||||
this.width = this.miniMap.width();
|
||||
this.height = this.miniMap.height();
|
||||
this.sectorsX = Math.ceil(this.width / sectorSize);
|
||||
this.sectorsY = Math.ceil(this.height / sectorSize);
|
||||
this.fastBFS = new FastBFS(this.width * this.height);
|
||||
this.waterComponents = new WaterComponents(this.miniMap);
|
||||
}
|
||||
|
||||
build(debug: boolean): GatewayGraph {
|
||||
performance.mark("navsat:build:start");
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Building gateway graph with sector size ${this.sectorSize} (${this.sectorsX}x${this.sectorsY} sectors)`,
|
||||
);
|
||||
|
||||
this.debugInfo = {
|
||||
sectors: null,
|
||||
gateways: null,
|
||||
edges: null,
|
||||
actualBFSCalls: null,
|
||||
potentialBFSCalls: null,
|
||||
skippedByComponentFilter: null,
|
||||
timings: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize water components before building gateway graph
|
||||
performance.mark("navsat:build:water-component:start");
|
||||
this.waterComponents.initialize();
|
||||
performance.mark("navsat:build:water-component:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:build:water-component",
|
||||
"navsat:build:water-component:start",
|
||||
"navsat:build:water-component:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Water Component Identification: ${measure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:gateways:start");
|
||||
for (let sy = 0; sy < this.sectorsY; sy++) {
|
||||
for (let sx = 0; sx < this.sectorsX; sx++) {
|
||||
this.processSector(sx, sy);
|
||||
}
|
||||
}
|
||||
performance.mark("navsat:build:gateways:end");
|
||||
const gatewaysMeasure = performance.measure(
|
||||
"navsat:build:gateways",
|
||||
"navsat:build:gateways:start",
|
||||
"navsat:build:gateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway identification: ${gatewaysMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
this.debugInfo!.edges = 0;
|
||||
this.debugInfo!.potentialBFSCalls = 0;
|
||||
this.debugInfo!.skippedByComponentFilter = 0;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:start");
|
||||
for (const sector of this.sectors.values()) {
|
||||
const gws = sector.gateways;
|
||||
const numGateways = gws.length;
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.potentialBFSCalls! +=
|
||||
(numGateways * (numGateways - 1)) / 2;
|
||||
|
||||
for (let i = 0; i < gws.length; i++) {
|
||||
for (let j = i + 1; j < gws.length; j++) {
|
||||
if (gws[i].componentId !== gws[j].componentId) {
|
||||
this.debugInfo!.skippedByComponentFilter!++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.buildSectorConnections(sector);
|
||||
|
||||
if (debug) {
|
||||
// Divide by 2 because bidirectional
|
||||
this.debugInfo!.edges! += sector.edges.length / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.actualBFSCalls =
|
||||
this.debugInfo!.potentialBFSCalls! -
|
||||
this.debugInfo!.skippedByComponentFilter!;
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:edges:end");
|
||||
const edgesMeasure = performance.measure(
|
||||
"navsat:build:edges",
|
||||
"navsat:build:edges:start",
|
||||
"navsat:build:edges:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Edges Identification: ${edgesMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Edges Found: ${this.debugInfo!.edges} (${((this.debugInfo!.edges! / this.debugInfo!.actualBFSCalls!) * 100).toFixed(1)}% success rate)`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:build:end");
|
||||
const totalMeasure = performance.measure(
|
||||
"navsat:build:total",
|
||||
"navsat:build:start",
|
||||
"navsat:build:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway graph built in ${totalMeasure.duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`[DEBUG] Gateways: ${this.gateways.size}`);
|
||||
console.log(`[DEBUG] Sectors: ${this.sectors.size}`);
|
||||
}
|
||||
|
||||
return new GatewayGraph(
|
||||
this.sectors,
|
||||
this.gateways,
|
||||
this.edges,
|
||||
this.sectorSize,
|
||||
this.sectorsX,
|
||||
);
|
||||
}
|
||||
|
||||
private getSectorKey(sectorX: number, sectorY: number): number {
|
||||
return sectorY * this.sectorsX + sectorX;
|
||||
}
|
||||
|
||||
private getOrCreateGateway(x: number, y: number): Gateway {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
|
||||
// O(1) lookup using tile reference
|
||||
const existing = this.tileToGateway.get(tile);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const gateway: Gateway = {
|
||||
id: this.nextGatewayId++,
|
||||
x: x,
|
||||
y: y,
|
||||
tile: tile,
|
||||
componentId: this.waterComponents.getComponentId(tile),
|
||||
};
|
||||
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
this.tileToGateway.set(tile, gateway);
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private addGatewayToSector(sector: Sector, gateway: Gateway): void {
|
||||
// Check for duplicates: a gateway at a sector corner can be
|
||||
// detected by both horizontal and vertical edge scans
|
||||
for (const existingGw of sector.gateways) {
|
||||
if (existingGw.x === gateway.x && existingGw.y === gateway.y) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway doesn't exist in sector yet, add it
|
||||
sector.gateways.push(gateway);
|
||||
}
|
||||
|
||||
private processSector(sx: number, sy: number): void {
|
||||
const sectorKey = this.getSectorKey(sx, sy);
|
||||
let sector = this.sectors.get(sectorKey);
|
||||
|
||||
if (!sector) {
|
||||
sector = { x: sx, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(sectorKey, sector);
|
||||
}
|
||||
|
||||
const baseX = sx * this.sectorSize;
|
||||
const baseY = sy * this.sectorSize;
|
||||
|
||||
if (sx < this.sectorsX - 1) {
|
||||
const edgeX = Math.min(baseX + this.sectorSize - 1, this.width - 1);
|
||||
const newGateways = this.findGatewaysOnVerticalEdge(edgeX, baseY);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const rightSectorKey = this.getSectorKey(sx + 1, sy);
|
||||
let rightSector = this.sectors.get(rightSectorKey);
|
||||
|
||||
if (!rightSector) {
|
||||
rightSector = { x: sx + 1, y: sy, gateways: [], edges: [] };
|
||||
this.sectors.set(rightSectorKey, rightSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(rightSector, gateway);
|
||||
}
|
||||
}
|
||||
|
||||
if (sy < this.sectorsY - 1) {
|
||||
const edgeY = Math.min(baseY + this.sectorSize - 1, this.height - 1);
|
||||
const newGateways = this.findGatewaysOnHorizontalEdge(edgeY, baseX);
|
||||
|
||||
for (const gateway of newGateways) {
|
||||
this.addGatewayToSector(sector, gateway);
|
||||
|
||||
const bottomSectorKey = this.getSectorKey(sx, sy + 1);
|
||||
let bottomSector = this.sectors.get(bottomSectorKey);
|
||||
|
||||
if (!bottomSector) {
|
||||
bottomSector = { x: sx, y: sy + 1, gateways: [], edges: [] };
|
||||
this.sectors.set(bottomSectorKey, bottomSector);
|
||||
}
|
||||
|
||||
this.addGatewayToSector(bottomSector, gateway);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findGatewaysOnVerticalEdge(x: number, baseY: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxY = Math.min(baseY + this.sectorSize, this.height);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (y: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = y - gatewayStart;
|
||||
const midY = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(x, midY);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let y = baseY; y < maxY; y++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
x + 1 < this.miniMap.width() ? this.miniMap.ref(x + 1, y) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = y;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(y);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxY);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private findGatewaysOnHorizontalEdge(y: number, baseX: number): Gateway[] {
|
||||
const gateways: Gateway[] = [];
|
||||
const maxX = Math.min(baseX + this.sectorSize, this.width);
|
||||
|
||||
let gatewayStart = -1;
|
||||
|
||||
const tryAddGateway = (x: number) => {
|
||||
if (gatewayStart === -1) return;
|
||||
|
||||
const gatewayLength = x - gatewayStart;
|
||||
const midX = gatewayStart + Math.floor(gatewayLength / 2);
|
||||
|
||||
gatewayStart = -1;
|
||||
|
||||
const gateway = this.getOrCreateGateway(midX, y);
|
||||
gateways.push(gateway);
|
||||
};
|
||||
|
||||
for (let x = baseX; x < maxX; x++) {
|
||||
const tile = this.miniMap.ref(x, y);
|
||||
const nextTile =
|
||||
y + 1 < this.miniMap.height() ? this.miniMap.ref(x, y + 1) : -1;
|
||||
const isGateway =
|
||||
this.miniMap.isWater(tile) &&
|
||||
nextTile !== -1 &&
|
||||
this.miniMap.isWater(nextTile);
|
||||
|
||||
if (isGateway) {
|
||||
if (gatewayStart === -1) {
|
||||
gatewayStart = x;
|
||||
}
|
||||
} else {
|
||||
tryAddGateway(x);
|
||||
}
|
||||
}
|
||||
|
||||
tryAddGateway(maxX);
|
||||
|
||||
return gateways;
|
||||
}
|
||||
|
||||
private buildSectorConnections(sector: Sector): void {
|
||||
const gateways = sector.gateways;
|
||||
|
||||
// Calculate bounding box once for this sector
|
||||
const sectorMinX = sector.x * this.sectorSize;
|
||||
const sectorMinY = sector.y * this.sectorSize;
|
||||
const sectorMaxX = Math.min(
|
||||
this.width - 1,
|
||||
sectorMinX + this.sectorSize - 1,
|
||||
);
|
||||
const sectorMaxY = Math.min(
|
||||
this.height - 1,
|
||||
sectorMinY + this.sectorSize - 1,
|
||||
);
|
||||
|
||||
for (let i = 0; i < gateways.length; i++) {
|
||||
const fromGateway = gateways[i];
|
||||
|
||||
// Build list of target gateways (only those we haven't processed yet)
|
||||
const targetGateways: Gateway[] = [];
|
||||
for (let j = i + 1; j < gateways.length; j++) {
|
||||
// Skip if gateways are in different water components
|
||||
if (gateways[i].componentId !== gateways[j].componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targetGateways.push(gateways[j]);
|
||||
}
|
||||
|
||||
if (targetGateways.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single BFS to find all reachable target gateways
|
||||
const reachableGateways = this.findAllReachableGatewaysInBounds(
|
||||
fromGateway.tile,
|
||||
targetGateways,
|
||||
sectorMinX,
|
||||
sectorMaxX,
|
||||
sectorMinY,
|
||||
sectorMaxY,
|
||||
);
|
||||
|
||||
// Create edges for all reachable gateways
|
||||
for (const [targetId, cost] of reachableGateways.entries()) {
|
||||
if (!this.edges.has(fromGateway.id)) {
|
||||
this.edges.set(fromGateway.id, []);
|
||||
}
|
||||
|
||||
if (!this.edges.has(targetId)) {
|
||||
this.edges.set(targetId, []);
|
||||
}
|
||||
|
||||
// Check for existing edges - gateways may live in 2 sectors, keep only cheaper connection
|
||||
const existingEdgeFromI = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.find((e) => e.to === targetId);
|
||||
const existingEdgeFromJ = this.edges
|
||||
.get(targetId)!
|
||||
.find((e) => e.to === fromGateway.id);
|
||||
|
||||
// If edge doesn't exist or new cost is cheaper, update it
|
||||
if (!existingEdgeFromI || cost < existingEdgeFromI.cost) {
|
||||
const edge1: Edge = {
|
||||
from: fromGateway.id,
|
||||
to: targetId,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
const edge2: Edge = {
|
||||
from: targetId,
|
||||
to: fromGateway.id,
|
||||
cost: cost,
|
||||
sectorX: sector.x,
|
||||
sectorY: sector.y,
|
||||
};
|
||||
|
||||
// Add to sector edges for tracking
|
||||
sector.edges.push(edge1, edge2);
|
||||
|
||||
if (existingEdgeFromI) {
|
||||
const idx1 = this.edges
|
||||
.get(fromGateway.id)!
|
||||
.indexOf(existingEdgeFromI);
|
||||
this.edges.get(fromGateway.id)![idx1] = edge1;
|
||||
|
||||
const idx2 = this.edges.get(targetId)!.indexOf(existingEdgeFromJ!);
|
||||
this.edges.get(targetId)![idx2] = edge2;
|
||||
} else {
|
||||
this.edges.get(fromGateway.id)!.push(edge1);
|
||||
this.edges.get(targetId)!.push(edge2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findAllReachableGatewaysInBounds(
|
||||
from: TileRef,
|
||||
targetGateways: Gateway[],
|
||||
minX: number,
|
||||
maxX: number,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
): Map<number, number> {
|
||||
const fromX = this.miniMap.x(from);
|
||||
const fromY = this.miniMap.y(from);
|
||||
|
||||
// Create a map of tile positions to gateway IDs for fast lookup
|
||||
const tileToGateway = new Map<TileRef, number>();
|
||||
let maxManhattanDist = 0;
|
||||
|
||||
for (const gateway of targetGateways) {
|
||||
tileToGateway.set(gateway.tile, gateway.id);
|
||||
const dx = Math.abs(gateway.x - fromX);
|
||||
const dy = Math.abs(gateway.y - fromY);
|
||||
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
|
||||
}
|
||||
|
||||
const maxDistance = maxManhattanDist * 4; // Allow path deviation
|
||||
const reachable = new Map<number, number>();
|
||||
let foundCount = 0;
|
||||
|
||||
this.fastBFS.search(
|
||||
this.miniMap.width(),
|
||||
this.miniMap.height(),
|
||||
from,
|
||||
maxDistance,
|
||||
(tile: number) => this.miniMap.isWater(tile),
|
||||
(tile: number, dist: number) => {
|
||||
const x = this.miniMap.x(tile);
|
||||
const y = this.miniMap.y(tile);
|
||||
|
||||
// Reject if outside of bounding box
|
||||
const isStartOrEnd = tile === from || tileToGateway.has(tile);
|
||||
if (!isStartOrEnd && (x < minX || x > maxX || y < minY || y > maxY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this tile is one of our target gateways
|
||||
const gatewayId = tileToGateway.get(tile);
|
||||
|
||||
if (gatewayId !== undefined) {
|
||||
reachable.set(gatewayId, dist);
|
||||
foundCount++;
|
||||
|
||||
// Early exit if we've found all target gateways
|
||||
if (foundCount === targetGateways.length) {
|
||||
return dist; // Return to stop BFS
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return reachable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import { Game } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { FastAStar } from "./FastAStar";
|
||||
import { BoundedGameMapAdapter, GatewayGraphAdapter } from "./FastAStarAdapter";
|
||||
import { FastBFS } from "./FastBFS";
|
||||
import { Gateway, GatewayGraph, GatewayGraphBuilder } from "./GatewayGraph";
|
||||
|
||||
type PathDebugInfo = {
|
||||
gatewayPath: TileRef[] | null;
|
||||
initialPath: TileRef[] | null;
|
||||
smoothPath: TileRef[] | null;
|
||||
graph: {
|
||||
sectorSize: number;
|
||||
gateways: Array<{ id: number; tile: TileRef }>;
|
||||
edges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}>;
|
||||
};
|
||||
timings: { [key: string]: number };
|
||||
};
|
||||
|
||||
export class NavMesh {
|
||||
private graph!: GatewayGraph;
|
||||
private initialized = false;
|
||||
private fastBFS!: FastBFS;
|
||||
private gatewayAStar!: FastAStar;
|
||||
private localAStar!: FastAStar;
|
||||
private localAStarMultiSector!: FastAStar;
|
||||
|
||||
public debugInfo: PathDebugInfo | null = null;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private options: {
|
||||
cachePaths?: boolean;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
initialize(debug: boolean = false) {
|
||||
const gatewayGraphBuilder = new GatewayGraphBuilder(
|
||||
this.game,
|
||||
GatewayGraphBuilder.SECTOR_SIZE,
|
||||
);
|
||||
this.graph = gatewayGraphBuilder.build(debug);
|
||||
|
||||
const miniMap = this.game.miniMap();
|
||||
this.fastBFS = new FastBFS(miniMap.width() * miniMap.height());
|
||||
|
||||
const gatewayCount = this.graph.getAllGateways().length;
|
||||
this.gatewayAStar = new FastAStar(gatewayCount);
|
||||
|
||||
// Fixed-size FastAStar for sector-bounded local pathfinding
|
||||
// Single sector: 32×32 = 1,024 nodes
|
||||
const sectorSize = GatewayGraphBuilder.SECTOR_SIZE;
|
||||
const maxLocalNodes = sectorSize * sectorSize; // 1,024 nodes
|
||||
this.localAStar = new FastAStar(maxLocalNodes);
|
||||
|
||||
// Multi-sector FastAStar for cross-sector pathfinding (same gateway, different sectors)
|
||||
// 3×3 sectors: 96×96 = 9,216 nodes
|
||||
const multiSectorSize = sectorSize * 3;
|
||||
const maxMultiSectorNodes = multiSectorSize * multiSectorSize;
|
||||
this.localAStarMultiSector = new FastAStar(maxMultiSectorNodes);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
debug: boolean = false,
|
||||
): TileRef[] | null {
|
||||
if (!this.initialized) {
|
||||
throw new Error(
|
||||
"NavMesh not initialized. Call initialize() before using findPath().",
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
// Collect all edges with their paths for visualization
|
||||
const allEdges: Array<{
|
||||
fromId: number;
|
||||
toId: number;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
cost: number;
|
||||
path: TileRef[] | null;
|
||||
}> = [];
|
||||
|
||||
for (const [fromId, edges] of this.graph.edges.entries()) {
|
||||
const fromGw = this.graph.getGateway(fromId);
|
||||
if (!fromGw) continue;
|
||||
|
||||
for (const edge of edges) {
|
||||
const toGw = this.graph.getGateway(edge.to);
|
||||
if (!toGw) continue;
|
||||
|
||||
// Only add each edge once (not both directions)
|
||||
// Include self-loops (fromId === edge.to) for debugging
|
||||
if (fromId <= edge.to) {
|
||||
allEdges.push({
|
||||
fromId: fromId,
|
||||
toId: edge.to,
|
||||
from: fromGw.tile,
|
||||
to: toGw.tile,
|
||||
cost: edge.cost,
|
||||
path: edge.path ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.debugInfo = {
|
||||
gatewayPath: null,
|
||||
initialPath: null,
|
||||
smoothPath: null,
|
||||
graph: {
|
||||
sectorSize: this.graph.sectorSize,
|
||||
gateways: this.graph
|
||||
.getAllGateways()
|
||||
.map((gw) => ({ id: gw.id, tile: gw.tile })),
|
||||
edges: allEdges,
|
||||
},
|
||||
timings: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dist = this.game.manhattanDist(from, to);
|
||||
|
||||
// Early exit for very short distances that fit within multi-sector range
|
||||
if (dist <= this.graph.sectorSize) {
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:start");
|
||||
const map = this.game.map();
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const sectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const localPath = this.findLocalPath(
|
||||
from,
|
||||
to,
|
||||
sectorX,
|
||||
sectorY,
|
||||
2000,
|
||||
true,
|
||||
);
|
||||
performance.mark("navsat:findPath:earlyExitLocalPath:end");
|
||||
const measure = performance.measure(
|
||||
"navsat:findPath:earlyExitLocalPath",
|
||||
"navsat:findPath:earlyExitLocalPath:start",
|
||||
"navsat:findPath:earlyExitLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
|
||||
this.debugInfo!.timings.total += measure.duration;
|
||||
}
|
||||
|
||||
if (localPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Direct path failed for dist=${dist}, falling back to gateway graph`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGateways:start");
|
||||
const startGateway = this.findNearestGateway(from);
|
||||
const endGateway = this.findNearestGateway(to);
|
||||
performance.mark("navsat:findPath:findGateways:end");
|
||||
const findGatewaysMeasure = performance.measure(
|
||||
"navsat:findPath:findGateways",
|
||||
"navsat:findPath:findGateways:start",
|
||||
"navsat:findPath:findGateways:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGateways = findGatewaysMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewaysMeasure.duration;
|
||||
}
|
||||
|
||||
if (!startGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find start gateway for (${this.game.x(from)}, ${this.game.y(from)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!endGateway) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Cannot find end gateway for (${this.game.x(to)}, ${this.game.y(to)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startGateway.id === endGateway.id) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Start and end gateways are the same (ID=${startGateway.id}), finding local path with multi-sector search`,
|
||||
);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:start");
|
||||
const sectorX = Math.floor(startGateway.x / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(startGateway.y / this.graph.sectorSize);
|
||||
const path = this.findLocalPath(from, to, sectorX, sectorY, 10000, true);
|
||||
performance.mark("navsat:findPath:sameGatewayLocalPath:end");
|
||||
const sameGatewayMeasure = performance.measure(
|
||||
"navsat:findPath:sameGatewayLocalPath",
|
||||
"navsat:findPath:sameGatewayLocalPath:start",
|
||||
"navsat:findPath:sameGatewayLocalPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.sameGatewayLocalPath =
|
||||
sameGatewayMeasure.duration;
|
||||
this.debugInfo!.timings.total += sameGatewayMeasure.duration;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:findGatewayPath:start");
|
||||
const gatewayPath = this.findGatewayPath(startGateway.id, endGateway.id);
|
||||
performance.mark("navsat:findPath:findGatewayPath:end");
|
||||
const findGatewayPathMeasure = performance.measure(
|
||||
"navsat:findPath:findGatewayPath",
|
||||
"navsat:findPath:findGatewayPath:start",
|
||||
"navsat:findPath:findGatewayPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.findGatewayPath = findGatewayPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += findGatewayPathMeasure.duration;
|
||||
|
||||
this.debugInfo!.gatewayPath = gatewayPath
|
||||
? gatewayPath
|
||||
.map((gwId) => {
|
||||
const gw = this.graph.getGateway(gwId);
|
||||
return gw ? gw.tile : -1;
|
||||
})
|
||||
.filter((tile) => tile !== -1)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!gatewayPath) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] No gateway path between gateways ${startGateway.id} and ${endGateway.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[DEBUG] Gateway path found: ${gatewayPath.length} waypoints`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialPath: TileRef[] = [];
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:start");
|
||||
|
||||
// 1. Find path from start to first gateway
|
||||
const firstGateway = this.graph.getGateway(gatewayPath[0])!;
|
||||
const firstGatewayTile = map.ref(
|
||||
miniMap.x(firstGateway.tile) * 2,
|
||||
miniMap.y(firstGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use start position's sector with multi-sector search (gateway may be on border)
|
||||
const startMiniX = Math.floor(map.x(from) / 2);
|
||||
const startMiniY = Math.floor(map.y(from) / 2);
|
||||
const startSectorX = Math.floor(startMiniX / this.graph.sectorSize);
|
||||
const startSectorY = Math.floor(startMiniY / this.graph.sectorSize);
|
||||
const startSegment = this.findLocalPath(
|
||||
from,
|
||||
firstGatewayTile,
|
||||
startSectorX,
|
||||
startSectorY,
|
||||
);
|
||||
|
||||
if (!startSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
initialPath.push(...startSegment);
|
||||
|
||||
// 2. Build path through gateways
|
||||
for (let i = 0; i < gatewayPath.length - 1; i++) {
|
||||
const fromGwId = gatewayPath[i];
|
||||
const toGwId = gatewayPath[i + 1];
|
||||
|
||||
const edges = this.graph.getEdges(fromGwId);
|
||||
const edge = edges.find((edge) => edge.to === toGwId);
|
||||
|
||||
if (!edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (edge.path) {
|
||||
// Use cached path if available
|
||||
initialPath.push(...edge.path.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromGw = this.graph.getGateway(fromGwId)!;
|
||||
const toGw = this.graph.getGateway(toGwId)!;
|
||||
const fromTile = map.ref(
|
||||
miniMap.x(fromGw.tile) * 2,
|
||||
miniMap.y(fromGw.tile) * 2,
|
||||
);
|
||||
const toTile = map.ref(
|
||||
miniMap.x(toGw.tile) * 2,
|
||||
miniMap.y(toGw.tile) * 2,
|
||||
);
|
||||
|
||||
const segmentPath = this.findLocalPath(
|
||||
fromTile,
|
||||
toTile,
|
||||
edge.sectorX,
|
||||
edge.sectorY,
|
||||
);
|
||||
|
||||
if (!segmentPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...segmentPath.slice(1));
|
||||
|
||||
if (this.options.cachePaths) {
|
||||
// Cache the path for future reuse on both directional edges
|
||||
edge.path = segmentPath;
|
||||
|
||||
// Also cache the reversed path on the opposite direction edge
|
||||
const reverseEdges = this.graph.getEdges(toGwId);
|
||||
const reverseEdge = reverseEdges.find((e) => e.to === fromGwId);
|
||||
if (reverseEdge) {
|
||||
reverseEdge.path = segmentPath.slice().reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find path from last gateway to end
|
||||
const lastGateway = this.graph.getGateway(
|
||||
gatewayPath[gatewayPath.length - 1],
|
||||
)!;
|
||||
const lastGatewayTile = map.ref(
|
||||
miniMap.x(lastGateway.tile) * 2,
|
||||
miniMap.y(lastGateway.tile) * 2,
|
||||
);
|
||||
|
||||
// Use end position's sector with multi-sector search (gateway may be on border)
|
||||
const endMiniX = Math.floor(map.x(to) / 2);
|
||||
const endMiniY = Math.floor(map.y(to) / 2);
|
||||
const endSectorX = Math.floor(endMiniX / this.graph.sectorSize);
|
||||
const endSectorY = Math.floor(endMiniY / this.graph.sectorSize);
|
||||
const endSegment = this.findLocalPath(
|
||||
lastGatewayTile,
|
||||
to,
|
||||
endSectorX,
|
||||
endSectorY,
|
||||
);
|
||||
|
||||
if (!endSegment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip first tile to avoid duplication
|
||||
initialPath.push(...endSegment.slice(1));
|
||||
|
||||
performance.mark("navsat:findPath:buildInitialPath:end");
|
||||
const buildInitialPathMeasure = performance.measure(
|
||||
"navsat:findPath:buildInitialPath",
|
||||
"navsat:findPath:buildInitialPath:start",
|
||||
"navsat:findPath:buildInitialPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildInitialPath =
|
||||
buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
|
||||
this.debugInfo!.initialPath = initialPath;
|
||||
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
|
||||
}
|
||||
|
||||
performance.mark("navsat:findPath:smoothPath:start");
|
||||
const smoothedPath = this.smoothPath(initialPath);
|
||||
performance.mark("navsat:findPath:smoothPath:end");
|
||||
const smoothPathMeasure = performance.measure(
|
||||
"navsat:findPath:smoothPath",
|
||||
"navsat:findPath:smoothPath:start",
|
||||
"navsat:findPath:smoothPath:end",
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
this.debugInfo!.timings.buildSmoothPath = smoothPathMeasure.duration;
|
||||
this.debugInfo!.timings.total += smoothPathMeasure.duration;
|
||||
this.debugInfo!.smoothPath = smoothedPath;
|
||||
console.log(
|
||||
`[DEBUG] Smoothed path: ${initialPath.length} → ${smoothedPath.length} tiles`,
|
||||
);
|
||||
}
|
||||
|
||||
return smoothedPath;
|
||||
}
|
||||
|
||||
private findNearestGateway(tile: TileRef): Gateway | null {
|
||||
const map = this.game.map();
|
||||
const x = map.x(tile);
|
||||
const y = map.y(tile);
|
||||
|
||||
// Convert to miniMap coordinates
|
||||
const miniMap = this.game.miniMap();
|
||||
const miniX = Math.floor(x / 2);
|
||||
const miniY = Math.floor(y / 2);
|
||||
const miniFrom = miniMap.ref(miniX, miniY);
|
||||
|
||||
// Check gateways in the tile's own sector (using miniMap coordinates)
|
||||
const sectorX = Math.floor(miniX / this.graph.sectorSize);
|
||||
const sectorY = Math.floor(miniY / this.graph.sectorSize);
|
||||
|
||||
// Calculate single sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
const minX = sectorX * sectorSize;
|
||||
const minY = sectorY * sectorSize;
|
||||
const maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
const maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
|
||||
// Get gateways from the tile's own sector only (includes border gateways)
|
||||
const sector = this.graph.getSector(sectorX, sectorY);
|
||||
|
||||
if (!sector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateGateways = sector.gateways;
|
||||
if (candidateGateways.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use BFS to find the nearest reachable gateway (by water path distance)
|
||||
// Search space is bounded by sector bounds, so maxDistance can be large
|
||||
const maxDistance = sectorSize * sectorSize;
|
||||
|
||||
return this.fastBFS.search(
|
||||
miniMap.width(),
|
||||
miniMap.height(),
|
||||
miniFrom,
|
||||
maxDistance,
|
||||
(tile: TileRef) => miniMap.isWater(tile),
|
||||
(tile: TileRef, _dist: number) => {
|
||||
const tileX = miniMap.x(tile);
|
||||
const tileY = miniMap.y(tile);
|
||||
|
||||
// Check if any candidate gateway is at this position first
|
||||
for (const gateway of candidateGateways) {
|
||||
if (gateway.x === tileX && gateway.y === tileY) {
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject non-gateway tiles outside the sector bounds
|
||||
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findGatewayPath(
|
||||
fromGatewayId: number,
|
||||
toGatewayId: number,
|
||||
): number[] | null {
|
||||
const adapter = new GatewayGraphAdapter(this.graph);
|
||||
return this.gatewayAStar.search(
|
||||
fromGatewayId,
|
||||
toGatewayId,
|
||||
adapter,
|
||||
100000,
|
||||
);
|
||||
}
|
||||
|
||||
private findLocalPath(
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
sectorX: number,
|
||||
sectorY: number,
|
||||
maxIterations: number = 10000,
|
||||
multiSector: boolean = false,
|
||||
): TileRef[] | null {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert full map coordinates to miniMap coordinates
|
||||
const miniFrom = miniMap.ref(
|
||||
Math.floor(map.x(from) / 2),
|
||||
Math.floor(map.y(from) / 2),
|
||||
);
|
||||
|
||||
const miniTo = miniMap.ref(
|
||||
Math.floor(map.x(to) / 2),
|
||||
Math.floor(map.y(to) / 2),
|
||||
);
|
||||
|
||||
// Calculate sector bounds
|
||||
const sectorSize = this.graph.sectorSize;
|
||||
|
||||
let minX: number;
|
||||
let minY: number;
|
||||
let maxX: number;
|
||||
let maxY: number;
|
||||
|
||||
if (multiSector) {
|
||||
// 3×3 sectors centered on the starting sector
|
||||
minX = Math.max(0, (sectorX - 1) * sectorSize);
|
||||
minY = Math.max(0, (sectorY - 1) * sectorSize);
|
||||
maxX = Math.min(miniMap.width() - 1, (sectorX + 2) * sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, (sectorY + 2) * sectorSize - 1);
|
||||
} else {
|
||||
// Single sector
|
||||
minX = sectorX * sectorSize;
|
||||
minY = sectorY * sectorSize;
|
||||
maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
|
||||
maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
|
||||
}
|
||||
|
||||
const adapter = new BoundedGameMapAdapter(miniMap, miniFrom, miniTo, {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
});
|
||||
|
||||
// Convert to local node IDs
|
||||
const startNode = adapter.tileToNode(miniFrom);
|
||||
const goalNode = adapter.tileToNode(miniTo);
|
||||
|
||||
if (startNode === -1 || goalNode === -1) {
|
||||
return null; // Start or goal outside bounds
|
||||
}
|
||||
|
||||
// Choose the appropriate FastAStar buffer based on search area
|
||||
const selectedAStar = multiSector
|
||||
? this.localAStarMultiSector
|
||||
: this.localAStar;
|
||||
|
||||
// Run FastAStar on bounded region
|
||||
const path = selectedAStar.search(
|
||||
startNode,
|
||||
goalNode,
|
||||
adapter,
|
||||
maxIterations,
|
||||
);
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert path from local node IDs back to miniMap TileRefs
|
||||
const miniPath = path.map((node: number) => adapter.nodeToTile(node));
|
||||
|
||||
// Upscale from miniMap to full map (same logic as MiniAStar)
|
||||
const result = this.upscalePathToFullMap(miniPath, from, to);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private upscalePathToFullMap(
|
||||
miniPath: TileRef[],
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
): TileRef[] {
|
||||
const map = this.game.map();
|
||||
const miniMap = this.game.miniMap();
|
||||
|
||||
// Convert miniMap path to cells
|
||||
const miniCells = miniPath.map((tile) => ({
|
||||
x: miniMap.x(tile),
|
||||
y: miniMap.y(tile),
|
||||
}));
|
||||
|
||||
// FIRST: Scale all points (2x)
|
||||
const scaledPath = miniCells.map((point) => ({
|
||||
x: point.x * 2,
|
||||
y: point.y * 2,
|
||||
}));
|
||||
|
||||
// SECOND: Interpolate between scaled points
|
||||
const smoothPath: Array<{ x: number; y: number }> = [];
|
||||
for (let i = 0; i < scaledPath.length - 1; i++) {
|
||||
const current = scaledPath[i];
|
||||
const next = scaledPath[i + 1];
|
||||
|
||||
// Add the current point
|
||||
smoothPath.push(current);
|
||||
|
||||
// Calculate dx/dy from SCALED coordinates
|
||||
const dx = next.x - current.x;
|
||||
const dy = next.y - current.y;
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const steps = distance;
|
||||
|
||||
// Add intermediate points
|
||||
for (let step = 1; step < steps; step++) {
|
||||
smoothPath.push({
|
||||
x: Math.round(current.x + (dx * step) / steps),
|
||||
y: Math.round(current.y + (dy * step) / steps),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add last point
|
||||
if (scaledPath.length > 0) {
|
||||
smoothPath.push(scaledPath[scaledPath.length - 1]);
|
||||
}
|
||||
|
||||
const scaledCells = smoothPath;
|
||||
|
||||
// Fix extremes to ensure exact start/end
|
||||
const fromCell = { x: map.x(from), y: map.y(from) };
|
||||
const toCell = { x: map.x(to), y: map.y(to) };
|
||||
|
||||
// Ensure start is correct
|
||||
const startIdx = scaledCells.findIndex(
|
||||
(c) => c.x === fromCell.x && c.y === fromCell.y,
|
||||
);
|
||||
if (startIdx === -1) {
|
||||
scaledCells.unshift(fromCell);
|
||||
} else if (startIdx !== 0) {
|
||||
scaledCells.splice(0, startIdx);
|
||||
}
|
||||
|
||||
// Ensure end is correct
|
||||
const endIdx = scaledCells.findIndex(
|
||||
(c) => c.x === toCell.x && c.y === toCell.y,
|
||||
);
|
||||
if (endIdx === -1) {
|
||||
scaledCells.push(toCell);
|
||||
} else if (endIdx !== scaledCells.length - 1) {
|
||||
scaledCells.splice(endIdx + 1);
|
||||
}
|
||||
|
||||
// Convert back to TileRefs
|
||||
return scaledCells.map((cell) => map.ref(cell.x, cell.y));
|
||||
}
|
||||
|
||||
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
|
||||
const x0 = this.game.x(from);
|
||||
const y0 = this.game.y(from);
|
||||
const x1 = this.game.x(to);
|
||||
const y1 = this.game.y(to);
|
||||
|
||||
const tiles: TileRef[] = [];
|
||||
|
||||
// Bresenham's line algorithm - trace and collect all tiles
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
// Safety limit to prevent excessive memory allocation
|
||||
const maxTiles = 100000;
|
||||
let iterations = 0;
|
||||
|
||||
while (true) {
|
||||
if (iterations++ > maxTiles) {
|
||||
return null; // Path too long
|
||||
}
|
||||
const tile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(tile)) {
|
||||
return null; // Path blocked
|
||||
}
|
||||
|
||||
tiles.push(tile);
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const e2 = 2 * err;
|
||||
const shouldMoveX = e2 > -dy;
|
||||
const shouldMoveY = e2 < dx;
|
||||
|
||||
if (shouldMoveX && shouldMoveY) {
|
||||
// Diagonal move - need to expand into two 4-directional moves
|
||||
// Try moving X first, then Y
|
||||
x += sx;
|
||||
err -= dy;
|
||||
|
||||
const intermediateTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(intermediateTile)) {
|
||||
// X first doesn't work, try Y first instead
|
||||
x -= sx; // undo
|
||||
err += dy; // undo
|
||||
|
||||
y += sy;
|
||||
err += dx;
|
||||
|
||||
const altTile = this.game.ref(x, y);
|
||||
if (!this.game.isWater(altTile)) {
|
||||
return null; // Neither direction works
|
||||
}
|
||||
tiles.push(altTile);
|
||||
|
||||
// Now move X
|
||||
x += sx;
|
||||
err -= dy;
|
||||
} else {
|
||||
tiles.push(intermediateTile);
|
||||
|
||||
// Now move Y
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
} else {
|
||||
// Single-axis move
|
||||
if (shouldMoveX) {
|
||||
x += sx;
|
||||
err -= dy;
|
||||
}
|
||||
|
||||
if (shouldMoveY) {
|
||||
y += sy;
|
||||
err += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private smoothPath(path: TileRef[]): TileRef[] {
|
||||
if (path.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const smoothed: TileRef[] = [];
|
||||
let current = 0;
|
||||
|
||||
while (current < path.length - 1) {
|
||||
// Look as far ahead as possible while maintaining line of sight
|
||||
let farthest = current + 1;
|
||||
let bestTrace: TileRef[] | null = null;
|
||||
|
||||
for (
|
||||
let i = current + 2;
|
||||
i < path.length;
|
||||
i += Math.max(1, Math.floor(path.length / 20))
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[i]);
|
||||
|
||||
if (trace !== null) {
|
||||
farthest = i;
|
||||
bestTrace = trace;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try the final tile if we haven't already
|
||||
if (
|
||||
farthest < path.length - 1 &&
|
||||
(path.length - 1 - current) % 10 !== 0
|
||||
) {
|
||||
const trace = this.tracePath(path[current], path[path.length - 1]);
|
||||
if (trace !== null) {
|
||||
farthest = path.length - 1;
|
||||
bestTrace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the traced path (or just current tile if no improvement)
|
||||
if (bestTrace !== null && farthest > current + 1) {
|
||||
// Add all tiles from the trace except the last one (to avoid duplication)
|
||||
smoothed.push(...bestTrace.slice(0, -1));
|
||||
} else {
|
||||
// No LOS improvement, just add current tile
|
||||
smoothed.push(path[current]);
|
||||
}
|
||||
|
||||
current = farthest;
|
||||
}
|
||||
|
||||
// Add the final tile
|
||||
smoothed.push(path[path.length - 1]);
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { GameMap, TileRef } from "../../game/GameMap";
|
||||
|
||||
const LAND_MARKER = 0xff; // Must fit in Uint8Array
|
||||
|
||||
/**
|
||||
* Manages water component identification using flood-fill.
|
||||
* Pre-allocates buffers and provides explicit initialization.
|
||||
*/
|
||||
export class WaterComponents {
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private readonly numTiles: number;
|
||||
private readonly lastRowStart: number;
|
||||
private readonly queue: Int32Array;
|
||||
private componentIds: Uint8Array | Uint16Array | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly map: GameMap,
|
||||
private readonly accessTerrainDirectly: boolean = true,
|
||||
) {
|
||||
this.width = map.width();
|
||||
this.height = map.height();
|
||||
this.numTiles = this.width * this.height;
|
||||
this.lastRowStart = (this.height - 1) * this.width;
|
||||
this.queue = new Int32Array(this.numTiles);
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
// Scan all tiles and flood-fill each unvisited water component
|
||||
for (let start = 0; start < this.numTiles; start++) {
|
||||
const value = ids[start];
|
||||
|
||||
// Skip if already visited (land=0xFF or water component >0)
|
||||
if (value === LAND_MARKER || value > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextId++;
|
||||
|
||||
// Dynamically upgrade to Uint16Array when we hit component 254
|
||||
if (nextId === 254 && ids instanceof Uint8Array) {
|
||||
ids = this.upgradeToUint16Array(ids);
|
||||
}
|
||||
|
||||
this.floodFillComponent(ids, start, nextId);
|
||||
}
|
||||
|
||||
this.componentIds = ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and prefill a Uint8Array with land markers.
|
||||
* Uses direct terrain access for performance.
|
||||
*/
|
||||
private createPrefilledIds(): Uint8Array {
|
||||
const ids = new Uint8Array(this.numTiles);
|
||||
|
||||
if (this.accessTerrainDirectly) {
|
||||
this.premarkLandTilesDirect(ids);
|
||||
} else {
|
||||
this.premarkLandTiles(ids);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-mark all land tiles in the ids array.
|
||||
* Land tiles are marked with 0xFF, water tiles remain 0.
|
||||
*/
|
||||
private premarkLandTiles(ids: Uint8Array): void {
|
||||
for (let i = 0; i < this.numTiles; i++) {
|
||||
ids[i] = this.map.isWater(i) ? 0 : LAND_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-mark all land tiles in the ids array.
|
||||
* Land tiles are marked with 0xFF, water tiles remain 0.
|
||||
*
|
||||
* This implementation accesses the terrain data **directly** without GameMap abstraction.
|
||||
* In tests it is 30% to 50% faster than using isWater() method calls.
|
||||
* As of 2026-01-05 it reduces avg. time for GWM from 15ms to 10ms.
|
||||
*/
|
||||
private premarkLandTilesDirect(ids: Uint8Array): void {
|
||||
const terrain = (this.map as any).terrain as Uint8Array;
|
||||
|
||||
// Write 4 bytes at once using Uint32Array view for better performance
|
||||
const numChunks = Math.floor(this.numTiles / 4);
|
||||
const terrain32 = new Uint32Array(
|
||||
terrain.buffer,
|
||||
terrain.byteOffset,
|
||||
numChunks,
|
||||
);
|
||||
const ids32 = new Uint32Array(ids.buffer, ids.byteOffset, numChunks);
|
||||
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
const chunk = terrain32[i];
|
||||
|
||||
// Extract bit 7 from each byte, negate, and combine into single 32-bit write
|
||||
// bit 7 = 0 (water) → -(0) = 0x00
|
||||
// bit 7 = 1 (land) → -(1) = 0xFF (truncated to 8 bits)
|
||||
const b0 = -((chunk >> 7) & 1) & 0xff;
|
||||
const b1 = -((chunk >> 15) & 1) & 0xff;
|
||||
const b2 = -((chunk >> 23) & 1) & 0xff;
|
||||
const b3 = -((chunk >> 31) & 1); // Upper byte, no mask needed
|
||||
|
||||
ids32[i] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
||||
}
|
||||
|
||||
// Handle remaining tiles (when numTiles not divisible by 4)
|
||||
for (let i = numChunks * 4; i < this.numTiles; i++) {
|
||||
ids[i] = -(terrain[i] >> 7);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade from Uint8Array to Uint16Array when we exceed 254 components.
|
||||
* Direct copy works because both use 0xFF for land marker.
|
||||
*/
|
||||
private upgradeToUint16Array(ids: Uint8Array): Uint16Array {
|
||||
const newIds = new Uint16Array(this.numTiles);
|
||||
for (let i = 0; i < this.numTiles; i++) {
|
||||
newIds[i] = ids[i];
|
||||
}
|
||||
return newIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood-fill a single connected water component using scan-line algorithm.
|
||||
* Processes horizontal spans of tiles for better memory locality and cache performance.
|
||||
*
|
||||
* Note: Land tiles are pre-marked, so ids[x] === 0 guarantees water tile.
|
||||
*/
|
||||
private floodFillComponent(
|
||||
ids: Uint8Array | Uint16Array,
|
||||
start: number,
|
||||
componentId: number,
|
||||
): void {
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
this.queue[tail++] = start;
|
||||
|
||||
while (head < tail) {
|
||||
const seed = this.queue[head++]!;
|
||||
|
||||
// Skip if already processed
|
||||
if (ids[seed] !== 0) continue;
|
||||
|
||||
// Scan left to find the start of this horizontal water span
|
||||
// No isWaterFast check needed - ids[x] === 0 guarantees water
|
||||
let left = seed;
|
||||
const rowStart = seed - (seed % this.width);
|
||||
while (left > rowStart && ids[left - 1] === 0) {
|
||||
left--;
|
||||
}
|
||||
|
||||
// Scan right to find the end of this horizontal water span
|
||||
let right = seed;
|
||||
const rowEnd = rowStart + this.width - 1;
|
||||
while (right < rowEnd && ids[right + 1] === 0) {
|
||||
right++;
|
||||
}
|
||||
|
||||
// Fill the entire horizontal span and check above/below for new spans
|
||||
for (let x = left; x <= right; x++) {
|
||||
ids[x] = componentId;
|
||||
|
||||
// Check tile above (if not in first row)
|
||||
if (x >= this.width) {
|
||||
const above = x - this.width;
|
||||
if (ids[above] === 0) {
|
||||
this.queue[tail++] = above;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tile below (if not in last row)
|
||||
if (x < this.lastRowStart) {
|
||||
const below = x + this.width;
|
||||
if (ids[below] === 0) {
|
||||
this.queue[tail++] = below;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component ID for a tile.
|
||||
* Returns 0 for land tiles or if not initialized.
|
||||
*/
|
||||
getComponentId(tile: TileRef): number {
|
||||
if (!this.componentIds) return 0;
|
||||
return this.componentIds[tile] ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
|
||||
import { Game, Player, Unit } from "../../../src/core/game/Game";
|
||||
import { PathStatus } from "../../../src/core/pathfinding/PathFinder";
|
||||
import { setup } from "../../util/Setup";
|
||||
|
||||
describe("TradeShipExecution", () => {
|
||||
@@ -83,7 +84,7 @@ describe("TradeShipExecution", () => {
|
||||
tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort);
|
||||
tradeShipExecution.init(game, 0);
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
nextTile: vi.fn(() => ({ type: 0, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
|
||||
} as any;
|
||||
tradeShipExecution["tradeShip"] = tradeShip;
|
||||
});
|
||||
@@ -114,7 +115,7 @@ describe("TradeShipExecution", () => {
|
||||
|
||||
it("should complete trade and award gold", () => {
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
nextTile: vi.fn(() => ({ type: 2, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
|
||||
} as any;
|
||||
tradeShipExecution.tick(1);
|
||||
expect(tradeShip.delete).toHaveBeenCalledWith(false);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
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,135 @@
|
||||
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,152 @@
|
||||
# Pathfinding Tests
|
||||
|
||||
This directory contains benchmarking tools, scenario generators, and an interactive playground for testing and optimizing pathfinding algorithms in OpenFrontIO.
|
||||
|
||||
## TLDR
|
||||
|
||||
```bash
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/pathfinding/
|
||||
├── benchmark.ts # Benchmarking tool
|
||||
├── scenarios/ # Scenarios for benchmarks
|
||||
│ ├── default.ts # Hand-picked scenario
|
||||
│ └── synthetic/ # Auto-generated synthetic scenarios
|
||||
└── playground/ # Interactive web-based visualization
|
||||
```
|
||||
|
||||
## Available algorithms
|
||||
|
||||
- **NavSat** - **future** implementation - NavigationSatellite (HPA\*)
|
||||
- **PF.Mini** - **current** implementation - PathFinder.Mini (A\*)
|
||||
|
||||
## Benchmarking
|
||||
|
||||
### Running a Single Scenario
|
||||
|
||||
```bash
|
||||
# Run default scenario with default adapter (NavSat)
|
||||
npx tsx tests/pathfinding/benchmark/run.ts
|
||||
|
||||
# Run specific scenario
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default
|
||||
|
||||
# Run with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
```
|
||||
|
||||
### Running Synthetic Scenarios
|
||||
|
||||
Synthetic scenarios are auto-generated from maps with random port selections and routes.
|
||||
|
||||
```bash
|
||||
# Run single synthetic scenario
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland
|
||||
|
||||
# Run single synthetic scenario with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
|
||||
# Run ALL synthetic scenarios (comprehensive benchmark)
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
|
||||
# Run all with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all legacy
|
||||
```
|
||||
|
||||
### Benchmark Metrics
|
||||
|
||||
The benchmark measures three key metrics:
|
||||
|
||||
1. **Initialization Time** - How long it takes to preprocess the map
|
||||
2. **Path Distance** - Total distance across all routes (quality metric)
|
||||
3. **Pathfinding Time** - How long it takes to compute paths (performance metric)
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
================================================================================
|
||||
METRIC 1: INITIALIZATION TIME
|
||||
================================================================================
|
||||
|
||||
Initialization time: 45.32ms
|
||||
|
||||
================================================================================
|
||||
METRIC 2: PATH DISTANCE
|
||||
================================================================================
|
||||
|
||||
Route Path Length
|
||||
Miami → Boston 346 tiles
|
||||
Miami → Houston 212 tiles
|
||||
...
|
||||
|
||||
Total distance: 52432 tiles
|
||||
Routes completed: 22 / 22
|
||||
|
||||
================================================================================
|
||||
METRIC 3: PATHFINDING TIME
|
||||
================================================================================
|
||||
|
||||
Route Time
|
||||
Miami → Boston 2.45ms
|
||||
Miami → Houston 1.82ms
|
||||
...
|
||||
|
||||
Total time: 156.34ms
|
||||
Average time: 7.11ms
|
||||
Routes benchmarked: 22 / 22
|
||||
|
||||
================================================================================
|
||||
SUMMARY
|
||||
================================================================================
|
||||
|
||||
Adapter: default
|
||||
Scenario: default
|
||||
|
||||
Scores:
|
||||
Initialization: 45.32ms
|
||||
Pathfinding: 156.34ms
|
||||
Distance: 52432 tiles
|
||||
```
|
||||
|
||||
## Generating Scenarios
|
||||
|
||||
### Generate Synthetic Scenarios
|
||||
|
||||
Synthetic scenarios are generated by:
|
||||
|
||||
1. Finding all water shoreline tiles on a map
|
||||
2. Randomly selecting 200 ports
|
||||
3. Creating 1000 routes connecting nearby ports
|
||||
|
||||
```bash
|
||||
# Generate scenario for a single map
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
|
||||
# Generate scenarios for all maps
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
|
||||
# Force overwrite existing scenarios
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland --force
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all --force
|
||||
```
|
||||
|
||||
## Interactive Playground
|
||||
|
||||
The playground provides a web-based UI for visualizing pathfinding results, comparing algorithms, and debugging.
|
||||
|
||||
### Starting the Playground
|
||||
|
||||
```bash
|
||||
# Start with path caching enabled (default)
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
|
||||
# Start without path caching (to measure uncached performance)
|
||||
npx tsx tests/pathfinding/playground/server.ts --no-cache
|
||||
```
|
||||
|
||||
Then open http://localhost:5555 in your browser.
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate synthetic benchmark scenarios for pathfinding tests
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { setupFromPath } from "../utils";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const pathfindingDir = dirname(currentFile);
|
||||
const projectRoot = join(pathfindingDir, "../../..");
|
||||
const mapsDirectory = join(projectRoot, "resources/maps");
|
||||
const scenariosDir = join(pathfindingDir, "scenarios", "synthetic");
|
||||
|
||||
const NUM_PORTS = 200;
|
||||
const NUM_ROUTES = 1000;
|
||||
const ROUTES_PER_PORT = 5;
|
||||
|
||||
interface GenerationOptions {
|
||||
force: boolean;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
async function generateScenarioForMap(
|
||||
mapName: string,
|
||||
options: GenerationOptions,
|
||||
): Promise<"created" | "skipped" | "error"> {
|
||||
const outputPath = join(scenariosDir, `${mapName}.ts`);
|
||||
|
||||
// Check if file exists and --force not provided
|
||||
if (existsSync(outputPath) && !options.force) {
|
||||
if (!options.silent) {
|
||||
console.log(
|
||||
`⚠️ ${mapName}: File already exists (use --force to overwrite)`,
|
||||
);
|
||||
}
|
||||
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const game = await setupFromPath(mapsDirectory, mapName);
|
||||
const map = game.map();
|
||||
|
||||
// Find all water shoreline tiles
|
||||
const shorelinePorts: Array<[number, number]> = [];
|
||||
|
||||
map.forEachTile((tile) => {
|
||||
if (map.isOcean(tile) && map.isShoreline(tile)) {
|
||||
shorelinePorts.push([map.x(tile), map.y(tile)]);
|
||||
}
|
||||
});
|
||||
|
||||
if (shorelinePorts.length < 10) {
|
||||
console.log(
|
||||
`❌ ${mapName}: Not enough water shoreline tiles (minimum 10 required)`,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Select random ports
|
||||
const numPortsToSelect = Math.min(NUM_PORTS, shorelinePorts.length);
|
||||
const selectedPorts: Array<[number, number]> = [];
|
||||
const shuffled = shorelinePorts.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < numPortsToSelect; i++) {
|
||||
selectedPorts.push(shuffled[i]);
|
||||
}
|
||||
|
||||
// Build ports array
|
||||
const ports: Port[] = selectedPorts.map((coord, index) => ({
|
||||
name: `Port${String(index + 1).padStart(3, "0")}`,
|
||||
coords: coord,
|
||||
}));
|
||||
|
||||
// Build routes array
|
||||
const routes: Route[] = [];
|
||||
|
||||
// Generate routes: each port connects to next N ports
|
||||
for (let i = 0; i < selectedPorts.length; i++) {
|
||||
for (
|
||||
let j = 1;
|
||||
j <= ROUTES_PER_PORT && i + j < selectedPorts.length;
|
||||
j++
|
||||
) {
|
||||
routes.push({
|
||||
from: `Port${String(i + 1).padStart(3, "0")}`,
|
||||
to: `Port${String(i + j + 1).padStart(3, "0")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add extra routes to reach target (or as many as possible)
|
||||
const targetRoutes = Math.min(NUM_ROUTES, routes.length + 200);
|
||||
const additionalRoutesNeeded = targetRoutes - routes.length;
|
||||
if (additionalRoutesNeeded > 0) {
|
||||
let added = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < selectedPorts.length && added < additionalRoutesNeeded;
|
||||
i++
|
||||
) {
|
||||
for (
|
||||
let j = ROUTES_PER_PORT + 1;
|
||||
j <= ROUTES_PER_PORT + 3 &&
|
||||
i + j < selectedPorts.length &&
|
||||
added < additionalRoutesNeeded;
|
||||
j++
|
||||
) {
|
||||
routes.push({
|
||||
from: `Port${String(i + 1).padStart(3, "0")}`,
|
||||
to: `Port${String(i + j + 1).padStart(3, "0")}`,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate content from template
|
||||
const content = generateScenarioContent({
|
||||
mapName,
|
||||
ports,
|
||||
routes,
|
||||
});
|
||||
|
||||
const routeCount = routes.length;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(scenariosDir, { recursive: true });
|
||||
|
||||
// Write to file
|
||||
writeFileSync(outputPath, content);
|
||||
|
||||
console.log(
|
||||
`✅ ${mapName} generated with ${numPortsToSelect} ports and ${routeCount} routes`,
|
||||
);
|
||||
|
||||
return "created";
|
||||
} catch (error) {
|
||||
console.error(`❌ ${mapName}:`, error);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
|
||||
|
||||
Arguments:
|
||||
<map-name> Name of the map to generate scenario for (e.g., iceland, giantworldmap)
|
||||
--all Generate scenarios for all available maps
|
||||
--force Overwrite existing scenario files
|
||||
|
||||
Examples:
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
|
||||
Available maps:
|
||||
Run 'ls resources/maps' to see all available maps
|
||||
`);
|
||||
}
|
||||
|
||||
// Parse command-line arguments
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const options: GenerationOptions = {
|
||||
force: args.includes("--force"),
|
||||
silent: args.includes("--all"),
|
||||
};
|
||||
|
||||
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (args.includes("--all")) {
|
||||
// Generate for all maps
|
||||
const maps = readdirSync(mapsDirectory, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name)
|
||||
.sort();
|
||||
|
||||
console.log(`Generating synthetic scenarios for ${maps.length} maps...`);
|
||||
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
|
||||
console.log(`Force overwrite: ${options.force}`);
|
||||
console.log(``);
|
||||
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const mapName of maps) {
|
||||
const result = await generateScenarioForMap(mapName, options);
|
||||
|
||||
if (result === "created") {
|
||||
createdCount++;
|
||||
} else if (result === "skipped") {
|
||||
skippedCount++;
|
||||
} else if (result === "error") {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdCount + errorCount > 0) {
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Created: ${createdCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`,
|
||||
);
|
||||
} else if (nonFlagArgs.length === 1) {
|
||||
// Generate for single map
|
||||
const mapName = nonFlagArgs[0];
|
||||
const mapPath = join(mapsDirectory, mapName);
|
||||
|
||||
if (!existsSync(mapPath)) {
|
||||
console.error(`Map not found: ${mapName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Generating synthetic scenario for ${mapName}...`);
|
||||
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
|
||||
console.log(`Force overwrite: ${options.force}`);
|
||||
console.log(``);
|
||||
|
||||
const result = await generateScenarioForMap(mapName, options);
|
||||
|
||||
if (result === "created") {
|
||||
console.log(``);
|
||||
console.log(`Scenario generated successfully!`);
|
||||
console.log(
|
||||
`You can now run: npx tsx tests/pathfinding/benchmark/run.ts --synthetic ${mapName}`,
|
||||
);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Invalid arguments.`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main().catch((error) => {
|
||||
console.error(`Fatal error:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Template for generating synthetic benchmark scenarios
|
||||
*/
|
||||
|
||||
interface Port {
|
||||
name: string;
|
||||
coords: [number, number];
|
||||
}
|
||||
|
||||
interface Route {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TemplateParams {
|
||||
mapName: string;
|
||||
ports: Port[];
|
||||
routes: Route[];
|
||||
}
|
||||
|
||||
function generateScenarioContent(params: TemplateParams): string {
|
||||
const { mapName, ports, routes } = params;
|
||||
|
||||
let content = ``;
|
||||
|
||||
// Simplified format - just data, no setup function
|
||||
content += `export const MAP_NAME = "${mapName}";\n\n`;
|
||||
|
||||
// Generate PORTS object
|
||||
content += `export const PORTS: { [k: string]: [number, number] } = {\n`;
|
||||
ports.forEach((port) => {
|
||||
content += ` ${port.name}: [${port.coords[0]}, ${port.coords[1]}],\n`;
|
||||
});
|
||||
content += `};\n\n`;
|
||||
|
||||
// Generate ROUTES array
|
||||
content += `export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [\n`;
|
||||
routes.forEach((route) => {
|
||||
content += ` ["${route.from}", "${route.to}"],\n`;
|
||||
});
|
||||
content += `];\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Benchmark pathfinding adapters on various scenarios
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
*/
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
type BenchmarkResult,
|
||||
calculateStats,
|
||||
getAdapter,
|
||||
getScenario,
|
||||
measureExecutionTime,
|
||||
measurePathLength,
|
||||
printHeader,
|
||||
printRow,
|
||||
} from "../utils";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const pathfindingDir = dirname(currentFile);
|
||||
const syntheticScenariosDir = join(pathfindingDir, "scenarios", "synthetic");
|
||||
|
||||
interface RunOptions {
|
||||
silent?: boolean;
|
||||
iterations?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ADAPTER = "hpa";
|
||||
const DEFAULT_SCENARIO = "default";
|
||||
const DEFAULT_ITERATIONS = 10;
|
||||
|
||||
async function runScenario(
|
||||
adapterName: string,
|
||||
scenarioName: string,
|
||||
options: RunOptions = {},
|
||||
) {
|
||||
const { game, routes, initTime } = await getScenario(
|
||||
scenarioName,
|
||||
adapterName,
|
||||
);
|
||||
const adapter = getAdapter(game, adapterName);
|
||||
const { silent = false } = options;
|
||||
|
||||
if (!silent) {
|
||||
console.log(`Date: ${new Date().toISOString()}`);
|
||||
console.log(`Benchmarking: ${adapterName}`);
|
||||
console.log(`Scenario: ${scenarioName}`);
|
||||
console.log(`Routes: ${routes.length}`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 1: INITIALIZATION TIME");
|
||||
}
|
||||
|
||||
const initializationTime = initTime;
|
||||
|
||||
if (!silent) {
|
||||
console.log(`Initialization time: ${initializationTime.toFixed(2)}ms`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 2: PATH DISTANCE");
|
||||
printRow(["Route", "Path Length"], [40, 12]);
|
||||
}
|
||||
|
||||
const results: BenchmarkResult[] = [];
|
||||
|
||||
for (const route of routes) {
|
||||
const pathLength = measurePathLength(adapter, route);
|
||||
results.push({ route: route.name, pathLength, executionTime: null });
|
||||
if (!silent) {
|
||||
printRow(
|
||||
[route.name, pathLength !== null ? `${pathLength} tiles` : "FAILED"],
|
||||
[40, 12],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { totalDistance, successfulRoutes, totalRoutes } =
|
||||
calculateStats(results);
|
||||
|
||||
if (!silent) {
|
||||
console.log(``);
|
||||
console.log(`Total distance: ${totalDistance} tiles`);
|
||||
console.log(`Routes completed: ${successfulRoutes} / ${totalRoutes}`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 3: PATHFINDING TIME");
|
||||
printRow(["Route", "Time"], [40, 12]);
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
const result = results.find((r) => r.route === route.name);
|
||||
|
||||
if (result && result.pathLength !== null) {
|
||||
const execTime = measureExecutionTime(
|
||||
adapter,
|
||||
route,
|
||||
options.iterations ?? DEFAULT_ITERATIONS,
|
||||
);
|
||||
result.executionTime = execTime;
|
||||
|
||||
if (!silent) {
|
||||
printRow([route.name, `${execTime!.toFixed(2)}ms`], [40, 12]);
|
||||
}
|
||||
} else {
|
||||
if (!silent) {
|
||||
printRow([route.name, "FAILED"], [40, 12]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stats = calculateStats(results);
|
||||
|
||||
if (!silent) {
|
||||
console.log(``);
|
||||
console.log(`Total time: ${stats.totalTime.toFixed(2)}ms`);
|
||||
console.log(`Average time: ${stats.avgTime.toFixed(2)}ms`);
|
||||
console.log(
|
||||
`Routes benchmarked: ${stats.timedRoutes} / ${stats.totalRoutes}`,
|
||||
);
|
||||
console.log(``);
|
||||
|
||||
// =============================================================================
|
||||
|
||||
printHeader("SUMMARY");
|
||||
|
||||
console.log(`Adapter: ${adapterName}`);
|
||||
console.log(`Scenario: ${scenarioName}`);
|
||||
console.log(``);
|
||||
|
||||
if (stats.successfulRoutes < stats.totalRoutes) {
|
||||
console.log(
|
||||
`Warning: Only ${stats.successfulRoutes} out of ${stats.totalRoutes} routes were completed successfully!`,
|
||||
);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
console.log("Scores:");
|
||||
console.log(` Initialization: ${initializationTime.toFixed(2)}ms`);
|
||||
console.log(` Pathfinding: ${stats.totalTime.toFixed(2)}ms`);
|
||||
console.log(` Distance: ${totalDistance} tiles`);
|
||||
console.log(``);
|
||||
} else {
|
||||
// Silent mode - just print a summary line
|
||||
const status = stats.successfulRoutes < stats.totalRoutes ? "⚠️ " : "✅";
|
||||
console.log(
|
||||
`${status} ${scenarioName.padEnd(35)} | Init: ${initializationTime.toFixed(2).padStart(8)}ms | Path: ${stats.totalTime.toFixed(2).padStart(9)}ms | Dist: ${totalDistance.toString().padStart(7)} tiles | Routes: ${stats.successfulRoutes}/${stats.totalRoutes}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
initializationTime,
|
||||
totalTime: stats.totalTime,
|
||||
totalDistance: totalDistance,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
|
||||
|
||||
Arguments:
|
||||
<scenario> Name of the scenario to benchmark (default: "default" -> giantworldmap with handpicked ports)
|
||||
<adapter> Pathfinding adapter: "hpa" (default), "hpa.cached", "legacy"
|
||||
--silent Minimize output, only print summary lines
|
||||
--synthetic Run synthetic scenarios
|
||||
--all Run all synthetic scenarios (requires --synthetic)
|
||||
|
||||
Examples:
|
||||
npx tsx tests/pathfinding/benchmark/run.ts
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
|
||||
Available synthetic scenarios:
|
||||
Run 'ls tests/pathfinding/benchmark/scenarios/synthetic' to see all available scenarios
|
||||
`);
|
||||
}
|
||||
|
||||
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 isAll = args.includes("--all");
|
||||
const isSilent = args.includes("--silent");
|
||||
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (isSynthetic) {
|
||||
if (isAll) {
|
||||
// Run all synthetic scenarios
|
||||
const adapterName = nonFlagArgs[0] || DEFAULT_ADAPTER;
|
||||
|
||||
// Find all synthetic scenario files
|
||||
const scenarioFiles = readdirSync(syntheticScenariosDir)
|
||||
.filter((file) => file.endsWith(".ts"))
|
||||
.map((file) => file.replace(".ts", ""))
|
||||
.sort();
|
||||
|
||||
console.log(
|
||||
`Running ${scenarioFiles.length} synthetic scenarios with ${adapterName} adapter...`,
|
||||
);
|
||||
console.log(``);
|
||||
|
||||
const results: {
|
||||
initializationTime: number;
|
||||
totalTime: number;
|
||||
totalDistance: number;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < scenarioFiles.length; i++) {
|
||||
const mapName = scenarioFiles[i];
|
||||
const scenarioName = `synthetic/${mapName}`;
|
||||
const result = await runScenario(adapterName, scenarioName, {
|
||||
silent: true,
|
||||
iterations: 1,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
console.log(``);
|
||||
console.log(`Completed ${scenarioFiles.length} scenarios`);
|
||||
console.log(
|
||||
`Total Initialization Time: ${results.reduce((sum, r) => sum + r.initializationTime, 0).toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Total Pathfinding Time: ${results.reduce((sum, r) => sum + r.totalTime, 0).toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Total Distance: ${results.reduce((sum, r) => sum + r.totalDistance, 0)} tiles`,
|
||||
);
|
||||
} else if (nonFlagArgs.length >= 1) {
|
||||
// Run single synthetic scenario
|
||||
const mapName = nonFlagArgs[0];
|
||||
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
|
||||
const scenarioName = `synthetic/${mapName}`;
|
||||
|
||||
await runScenario(adapterName, scenarioName, { silent: isSilent });
|
||||
} else {
|
||||
console.error("Error: --synthetic requires a map name or --all flag");
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Standard mode with positional arguments
|
||||
const scenarioName = nonFlagArgs[0] || DEFAULT_SCENARIO;
|
||||
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
|
||||
|
||||
await runScenario(adapterName, scenarioName, { silent: isSilent });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const MAP_NAME = "giantworldmap";
|
||||
|
||||
export const PORTS: { [k: string]: [number, number] } = {
|
||||
Miami: [1014, 694],
|
||||
Boston: [1120, 498],
|
||||
Houston: [844, 657],
|
||||
Chicago: [928, 508],
|
||||
Cleveland: [995, 514],
|
||||
Barcelona: [1943, 518],
|
||||
"Sao Paulo": [1396, 1288],
|
||||
"San Francisco": [520, 547],
|
||||
"Falkland Islands": [1259, 1616],
|
||||
"San Salvador": [901, 842],
|
||||
Luxor: [2302, 700],
|
||||
Venice: [2068, 468],
|
||||
"Kongo River": [2142, 965],
|
||||
Shanghai: [3317, 639],
|
||||
Tokyo: [3525, 583],
|
||||
"Abu Zabi": [2554, 711],
|
||||
Gdansk: [2143, 362],
|
||||
Neapol: [2081, 516],
|
||||
"San Felipe": [617, 637],
|
||||
Anchorage: [217, 281],
|
||||
Honolulu: [156, 768],
|
||||
"Ghana River": [1877, 869],
|
||||
"Guinea River": [1824, 868],
|
||||
"Peru River": [1097, 1162],
|
||||
"China Desert River": [2965, 599],
|
||||
"Australia River": [3663, 1343],
|
||||
};
|
||||
|
||||
export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [
|
||||
["Miami", "Boston"],
|
||||
["Miami", "Houston"],
|
||||
["Miami", "Chicago"],
|
||||
["Miami", "Cleveland"],
|
||||
["Miami", "Barcelona"],
|
||||
["Miami", "Sao Paulo"],
|
||||
["Miami", "San Francisco"],
|
||||
["Miami", "Falkland Islands"],
|
||||
["Miami", "San Salvador"],
|
||||
["Luxor", "Venice"],
|
||||
["Luxor", "Kongo River"],
|
||||
["Shanghai", "Tokyo"],
|
||||
["Shanghai", "San Francisco"],
|
||||
["Abu Zabi", "Gdansk"],
|
||||
["Chicago", "Cleveland"],
|
||||
["Barcelona", "Neapol"],
|
||||
["San Francisco", "San Felipe"],
|
||||
["Anchorage", "Honolulu"],
|
||||
["Kongo River", "Ghana River"],
|
||||
["Kongo River", "Guinea River"],
|
||||
["Peru River", "China Desert River"],
|
||||
["China Desert River", "Australia River"],
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# Pathfinding Playground
|
||||
|
||||
Interactive web-based visualization tool for exploring and comparing pathfinding algorithms.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server from the project root:
|
||||
|
||||
```bash
|
||||
# With path caching (default)
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
|
||||
# Without path caching
|
||||
npx tsx tests/pathfinding/playground/server.ts --no-cache
|
||||
```
|
||||
|
||||
Then open http://localhost:5555 in your browser.
|
||||
|
||||
## Options
|
||||
|
||||
- `--no-cache` - Disable path caching in NavMesh to measure uncached performance
|
||||
|
||||
## Clanker Disclosure
|
||||
|
||||
The source code of both the UI and the backend is mostly generated by a robot and therefore probably unmaintainable by humans. Since this is meant to be one-off playground project the author has not put additional thought into the inner workings. The UI however is very highly opinionated, if you believe a clanker could do this first pass, you live in the (possibly not far off) future.
|
||||
@@ -0,0 +1,224 @@
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { Game } from "../../../../src/core/game/Game.js";
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
|
||||
import { setupFromPath } from "../../utils.js";
|
||||
|
||||
export interface MapInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface MapCache {
|
||||
game: Game;
|
||||
navMesh: NavMesh;
|
||||
}
|
||||
|
||||
const cache = new Map<string, MapCache>();
|
||||
|
||||
/**
|
||||
* Global configuration for map loading
|
||||
*/
|
||||
let config = {
|
||||
cachePaths: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Set configuration options
|
||||
*/
|
||||
export function setConfig(options: { cachePaths?: boolean }) {
|
||||
config = { ...config, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resources/maps directory path
|
||||
*/
|
||||
function getMapsDirectory(): string {
|
||||
return join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../resources/maps",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format map name to title case with proper spacing
|
||||
* Handles: underscores, camelCase, existing spaces, and parentheses
|
||||
*/
|
||||
function formatMapName(name: string): string {
|
||||
return (
|
||||
name
|
||||
// Replace underscores with spaces
|
||||
.replace(/_/g, " ")
|
||||
// Add space before capital letters (for camelCase)
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
// Convert to lowercase first
|
||||
.toLowerCase()
|
||||
// Capitalize first letter of string
|
||||
.replace(/^\w/, (char) => char.toUpperCase())
|
||||
// Capitalize after spaces and opening parentheses
|
||||
.replace(/(\s+|[(])\w/g, (match) => match.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available maps by reading the resources/maps directory
|
||||
*/
|
||||
export function listMaps(): MapInfo[] {
|
||||
const mapsDir = getMapsDirectory();
|
||||
const maps: MapInfo[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(mapsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const name = entry.name;
|
||||
let displayName = formatMapName(name);
|
||||
|
||||
// Try to read displayName from manifest.json
|
||||
try {
|
||||
const manifestPath = join(mapsDir, name, "manifest.json");
|
||||
const manifestData = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
if (manifestData.name) {
|
||||
displayName = formatMapName(manifestData.name);
|
||||
}
|
||||
} catch (e) {
|
||||
// If manifest doesn't exist or doesn't have name, use formatted folder name
|
||||
console.warn(
|
||||
`Could not read manifest for ${name}:`,
|
||||
e instanceof Error ? e.message : e,
|
||||
);
|
||||
}
|
||||
|
||||
maps.push({ name, displayName });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to read maps directory:", e);
|
||||
}
|
||||
|
||||
return maps.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a map from cache or disk
|
||||
*/
|
||||
export async function loadMap(mapName: string): Promise<MapCache> {
|
||||
// Check cache first
|
||||
if (cache.has(mapName)) {
|
||||
return cache.get(mapName)!;
|
||||
}
|
||||
|
||||
const mapsDir = getMapsDirectory();
|
||||
|
||||
// Use the existing setupFromPath utility to load the map
|
||||
const game = await setupFromPath(mapsDir, mapName);
|
||||
|
||||
// Initialize NavMesh
|
||||
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
|
||||
navMesh.initialize();
|
||||
|
||||
const cacheEntry: MapCache = { game, navMesh };
|
||||
|
||||
// Store in cache
|
||||
cache.set(mapName, cacheEntry);
|
||||
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map metadata for client
|
||||
*/
|
||||
export async function getMapMetadata(mapName: string) {
|
||||
const { game, navMesh } = await loadMap(mapName);
|
||||
|
||||
// Extract map data
|
||||
const mapData: number[] = [];
|
||||
for (let y = 0; y < game.height(); y++) {
|
||||
for (let x = 0; x < game.width(); x++) {
|
||||
const tile = game.ref(x, y);
|
||||
mapData.push(game.isWater(tile) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract static graph data from NavMesh
|
||||
const miniMap = game.miniMap();
|
||||
const navMeshGraph = (navMesh as any).graph;
|
||||
|
||||
// Convert gateways from Map to array
|
||||
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
|
||||
const allGateways = gatewaysArray.map((gw: any) => ({
|
||||
id: gw.id,
|
||||
x: miniMap.x(gw.tile),
|
||||
y: miniMap.y(gw.tile),
|
||||
}));
|
||||
|
||||
// Create a lookup map from gateway ID to gateway for edge conversion
|
||||
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
|
||||
|
||||
// Convert edges from Map<gatewayId, Edge[]> to flat array
|
||||
// The edges Map has gateway IDs as keys, and arrays of edges as values
|
||||
const allEdges: any[] = [];
|
||||
for (const edgeArray of navMeshGraph.edges.values()) {
|
||||
allEdges.push(...edgeArray);
|
||||
}
|
||||
|
||||
// Deduplicate edges (they're bidirectional, so each edge appears twice)
|
||||
const seenEdges = new Set<string>();
|
||||
const edges = allEdges
|
||||
.filter((edge: any) => {
|
||||
const edgeKey =
|
||||
edge.from < edge.to
|
||||
? `${edge.from}-${edge.to}`
|
||||
: `${edge.to}-${edge.from}`;
|
||||
if (seenEdges.has(edgeKey)) return false;
|
||||
seenEdges.add(edgeKey);
|
||||
return true;
|
||||
})
|
||||
.map((edge: any) => {
|
||||
const fromGateway = gatewayById.get(edge.from);
|
||||
const toGateway = gatewayById.get(edge.to);
|
||||
|
||||
return {
|
||||
fromId: edge.from,
|
||||
toId: edge.to,
|
||||
from: fromGateway
|
||||
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
|
||||
: [0, 0],
|
||||
to: toGateway
|
||||
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
|
||||
: [0, 0],
|
||||
cost: edge.cost,
|
||||
path: edge.path
|
||||
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
|
||||
);
|
||||
|
||||
const sectorSize = navMeshGraph.sectorSize;
|
||||
|
||||
return {
|
||||
name: mapName,
|
||||
width: game.width(),
|
||||
height: game.height(),
|
||||
mapData,
|
||||
graphDebug: {
|
||||
allGateways,
|
||||
edges,
|
||||
sectorSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear map cache
|
||||
*/
|
||||
export function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
|
||||
import { loadMap } from "./maps.js";
|
||||
|
||||
interface PathfindingOptions {
|
||||
includePfMini?: boolean;
|
||||
includeNavMesh?: boolean;
|
||||
}
|
||||
|
||||
interface NavMeshResult {
|
||||
path: Array<[number, number]> | null;
|
||||
initialPath: Array<[number, number]> | null;
|
||||
gateways: Array<[number, number]> | null;
|
||||
timings: any;
|
||||
length: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface PfMiniResult {
|
||||
path: Array<[number, number]> | null;
|
||||
length: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// Cache pathfinding adapters per map
|
||||
const pfMiniCache = new Map<string, MiniAStarAdapter>();
|
||||
|
||||
/**
|
||||
* Get or create MiniAStar adapter for a map
|
||||
*/
|
||||
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
|
||||
if (!pfMiniCache.has(mapName)) {
|
||||
const adapter = new MiniAStarAdapter(game, { waterPath: true });
|
||||
pfMiniCache.set(mapName, adapter);
|
||||
}
|
||||
return pfMiniCache.get(mapName)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TileRef array to coordinate array
|
||||
*/
|
||||
function pathToCoords(
|
||||
path: TileRef[] | null,
|
||||
game: any,
|
||||
): Array<[number, number]> | null {
|
||||
if (!path) return null;
|
||||
return path.map((tile) => [game.x(tile), game.y(tile)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pathfinding between two points
|
||||
*/
|
||||
export async function computePath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
options: PathfindingOptions = {},
|
||||
): Promise<NavMeshResult> {
|
||||
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
|
||||
|
||||
// Convert coordinates to TileRefs
|
||||
const fromRef = game.ref(from[0], from[1]);
|
||||
const toRef = game.ref(to[0], to[1]);
|
||||
|
||||
// Validate that both points are water tiles
|
||||
if (!game.isWater(fromRef)) {
|
||||
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
|
||||
}
|
||||
if (!game.isWater(toRef)) {
|
||||
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
|
||||
}
|
||||
|
||||
// Compute NavMesh path
|
||||
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
|
||||
const path = pathToCoords(navMeshPath, game);
|
||||
|
||||
const miniMap = game.miniMap();
|
||||
|
||||
// Extract debug info
|
||||
let gateways: Array<[number, number]> | null = null;
|
||||
let initialPath: Array<[number, number]> | null = null;
|
||||
let timings: any = {};
|
||||
|
||||
if (navMeshAdapter.debugInfo) {
|
||||
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
|
||||
if (navMeshAdapter.debugInfo.gatewayPath) {
|
||||
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
|
||||
const x = miniMap.x(tile) * 2;
|
||||
const y = miniMap.y(tile) * 2;
|
||||
return [x, y] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
// Convert initial path
|
||||
if (navMeshAdapter.debugInfo.initialPath) {
|
||||
initialPath = navMeshAdapter.debugInfo.initialPath.map(
|
||||
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
|
||||
);
|
||||
}
|
||||
|
||||
timings = navMeshAdapter.debugInfo.timings || {};
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
initialPath,
|
||||
gateways,
|
||||
timings,
|
||||
length: path ? path.length : 0,
|
||||
time: timings.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute only PathFinder.Mini path
|
||||
*/
|
||||
export async function computePfMiniPath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
): Promise<PfMiniResult> {
|
||||
const { game } = await loadMap(mapName);
|
||||
|
||||
// Convert coordinates to TileRefs
|
||||
const fromRef = game.ref(from[0], from[1]);
|
||||
const toRef = game.ref(to[0], to[1]);
|
||||
|
||||
// Validate that both points are water tiles
|
||||
if (!game.isWater(fromRef)) {
|
||||
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
|
||||
}
|
||||
if (!game.isWater(toRef)) {
|
||||
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
|
||||
}
|
||||
|
||||
// Compute PathFinder.Mini path
|
||||
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
|
||||
const pfMiniStart = performance.now();
|
||||
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
|
||||
const pfMiniEnd = performance.now();
|
||||
|
||||
const path = pathToCoords(pfMiniPath, game);
|
||||
const time = pfMiniEnd - pfMiniStart;
|
||||
|
||||
return {
|
||||
path,
|
||||
length: path ? path.length : 0,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pathfinding adapter caches
|
||||
*/
|
||||
export function clearAdapterCaches() {
|
||||
pfMiniCache.clear();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pathfinding Playground - Interactive Visualization</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Fullscreen map container -->
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-wrapper" id="canvasWrapper">
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
<canvas id="overlayCanvas"></canvas>
|
||||
</div>
|
||||
<!-- Interactive canvas added dynamically outside wrapper -->
|
||||
</div>
|
||||
|
||||
<!-- Welcome screen -->
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<div class="welcome-content">
|
||||
<h1>Pathfinding Playground</h1>
|
||||
<p>Interactive visualization for naval pathfinding algorithms</p>
|
||||
|
||||
<!-- Map grid -->
|
||||
<div class="map-grid" id="mapGrid">
|
||||
<div class="map-card" data-map-index="0">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 1</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="1">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 2</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="2">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 3</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="3">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 4</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="4">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 5</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="5">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 6</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="6">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 7</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="7">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 8</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown for other maps -->
|
||||
<div class="welcome-selector">
|
||||
<label for="welcomeMapSelect">Or select from all maps:</label>
|
||||
<select id="welcomeMapSelect">
|
||||
<option value="">Loading maps...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top overlay panel -->
|
||||
<div class="top-panel">
|
||||
<h1>Pathfinding Playground</h1>
|
||||
|
||||
<div class="scenario-selector">
|
||||
<select id="scenarioSelect">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<span id="status">Select a scenario to begin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug controls panel (left) -->
|
||||
<div class="debug-panel">
|
||||
<div class="debug-panel-row">
|
||||
<button class="toggle-button" id="showInitialPath" data-active="false">
|
||||
Initial Path
|
||||
</button>
|
||||
<button class="toggle-button" id="showUsedGateways" data-active="false">
|
||||
Used Gateways
|
||||
</button>
|
||||
</div>
|
||||
<div class="debug-panel-row">
|
||||
<button class="toggle-button" id="showGateways" data-active="false">
|
||||
Gateways
|
||||
</button>
|
||||
<button class="toggle-button" id="showSectorGrid" data-active="false">
|
||||
Sectors
|
||||
</button>
|
||||
<button class="toggle-button" id="showEdges" data-active="false">
|
||||
Edges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View controls panel (right) -->
|
||||
<div class="view-panel">
|
||||
<div class="zoom-control">
|
||||
<input type="range" id="zoom" min="0.1" max="5" step="0.1" value="1" />
|
||||
<span id="zoomValue">1.0x</span>
|
||||
</div>
|
||||
<button class="toggle-button" id="showColoredMap" data-active="false">
|
||||
Colored Map
|
||||
</button>
|
||||
<button id="clearPoints" class="clear-button">Clear Points</button>
|
||||
</div>
|
||||
|
||||
<!-- Timings panel (left side) -->
|
||||
<div class="timings-panel" id="timingsPanel">
|
||||
<div class="timings-header">Performance</div>
|
||||
|
||||
<div class="timing-section">
|
||||
<div class="timing-label">
|
||||
<button
|
||||
class="refresh-icon"
|
||||
id="refreshNavMesh"
|
||||
title="Recompute NavMesh path"
|
||||
>
|
||||
<span>↻</span>
|
||||
</button>
|
||||
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
|
||||
</div>
|
||||
<div class="timing-value-large" id="navMeshTime">—</div>
|
||||
|
||||
<div class="timing-breakdown" id="timingBreakdown">
|
||||
<div class="timing-item" id="timingEarlyExit" style="display: none">
|
||||
<span class="timing-name">Early Exit:</span>
|
||||
<span class="timing-value" id="timingEarlyExitValue">—</span>
|
||||
</div>
|
||||
<div
|
||||
class="timing-item"
|
||||
id="timingFindGateways"
|
||||
style="display: none"
|
||||
>
|
||||
<span class="timing-name">Find Gateways:</span>
|
||||
<span class="timing-value" id="timingFindGatewaysValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingGatewayPath" style="display: none">
|
||||
<span class="timing-name">Gateway Path:</span>
|
||||
<span class="timing-value" id="timingGatewayPathValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingInitialPath" style="display: none">
|
||||
<span class="timing-name">Initial Path:</span>
|
||||
<span class="timing-value" id="timingInitialPathValue">—</span>
|
||||
</div>
|
||||
<div class="timing-item" id="timingSmoothPath" style="display: none">
|
||||
<span class="timing-name">Smooth Path:</span>
|
||||
<span class="timing-value" id="timingSmoothPathValue">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-section" id="pfMiniRequestSection">
|
||||
<button
|
||||
id="requestPfMini"
|
||||
class="timing-button"
|
||||
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
|
||||
disabled
|
||||
>
|
||||
Request PathFinder.Mini
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="timing-section"
|
||||
id="pfMiniTimingSection"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="timing-label">
|
||||
<button
|
||||
class="refresh-icon"
|
||||
id="refreshPfMini"
|
||||
title="Recompute PF.Mini path"
|
||||
>
|
||||
<span>↻</span>
|
||||
</button>
|
||||
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
|
||||
</div>
|
||||
<div class="timing-value-large" id="pfMiniTime">—</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-section" id="speedupSection" style="display: none">
|
||||
<div class="timing-label">Speedup</div>
|
||||
<div class="timing-value-speedup" id="speedupValue">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend panel -->
|
||||
<div class="legend-panel">
|
||||
<div class="legend-header">Legend</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #ff4444; height: 8px"
|
||||
></div>
|
||||
<span>Start Point</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #44ff44; height: 8px"
|
||||
></div>
|
||||
<span>End Point</span>
|
||||
</div>
|
||||
<div class="legend-item" id="pfMiniLegend" style="display: none">
|
||||
<div class="legend-color" style="background: #ffaa00"></div>
|
||||
<span>PathFinder.Mini</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #00ffff"></div>
|
||||
<span>NavMesh</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ff00ff"></div>
|
||||
<span>Initial Path</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #ffff00; height: 8px"
|
||||
></div>
|
||||
<span>Used Gateways</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="
|
||||
background: #aaaaaa;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
"
|
||||
></div>
|
||||
<span>Gateways</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #777777; height: 2px"
|
||||
></div>
|
||||
<span>Sectors</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #00ffaa; height: 2px"
|
||||
></div>
|
||||
<span>Edges</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error notification (toast) -->
|
||||
<div id="error" class="error-toast"></div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div id="tooltip"></div>
|
||||
|
||||
<script src="client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,795 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
.welcome-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(28, 28, 28, 0.98);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.welcome-screen.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 1100px;
|
||||
padding: 40px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.welcome-content h1 {
|
||||
font-size: 42px;
|
||||
color: #fff;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.welcome-content p {
|
||||
font-size: 18px;
|
||||
color: #aaa;
|
||||
margin: 0 0 30px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Map grid */
|
||||
.map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #404040;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-card:hover {
|
||||
border-color: #0066cc;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.map-card img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-card-name {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
background: #2a2a2a;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.welcome-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-selector label {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.welcome-selector select {
|
||||
width: 400px;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #404040;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.welcome-selector select:hover {
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.welcome-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
/* Fullscreen canvas container */
|
||||
.canvas-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #3c3c3c;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.canvas-wrapper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas-wrapper.selecting {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mapCanvas {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#overlayCanvas {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Top panel - overlay on map */
|
||||
.top-panel {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
max-width: 900px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.top-panel h1 {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.scenario-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.scenario-selector select {
|
||||
width: 350px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Debug panel (bottom left) */
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* View panel (bottom right) */
|
||||
.view-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #404040;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.zoom-control input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-control span {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: #cc3333;
|
||||
color: white;
|
||||
border: 2px solid #dd4444;
|
||||
padding: 10px 12px 10px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: #aa2222;
|
||||
border-color: #cc3333;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #555;
|
||||
padding: 10px 12px 10px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
min-width: 100px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toggle-button::before {
|
||||
content: "☐";
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: #404040;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"] {
|
||||
background: #0066cc;
|
||||
border-color: #0088ff;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(0, 102, 204, 0.4);
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"]::before {
|
||||
content: "☑";
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"]:hover {
|
||||
background: #0052a3;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
/* Timings panel (left side) */
|
||||
.timings-panel {
|
||||
position: fixed;
|
||||
top: 250px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 20px 20px 15px 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.timings-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timing-section {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timing-section + .timing-section {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
font-size: 18px;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #00aaff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.refresh-icon span {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.refresh-icon:hover {
|
||||
color: #00ccff;
|
||||
background: rgba(0, 170, 255, 0.1);
|
||||
}
|
||||
|
||||
.refresh-icon.spinning span {
|
||||
animation: spin 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.timing-label-detail {
|
||||
color: #888;
|
||||
text-transform: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timing-value-large {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
font-family: "Courier New", monospace;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.timing-value-large.faded {
|
||||
color: #888888;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timing-value-speedup {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #ffaa00;
|
||||
font-family: "Courier New", monospace;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timing-breakdown {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.timing-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.timing-name {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.timing-value {
|
||||
font-family:
|
||||
"Consolas", "Monaco", "SF Mono", "Roboto Mono", "Courier New", monospace;
|
||||
color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Legend panel (right side) */
|
||||
.legend-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.legend-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #404040;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 28px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #404040;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timing-button {
|
||||
width: 100%;
|
||||
background: #555;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.timing-button:hover:not(:disabled) {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.timing-button:disabled {
|
||||
background: #404040;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#status.loading {
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
#status.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Error toast notification */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(138, 26, 26, 0.98);
|
||||
color: #ff6b6b;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.7);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
border: 2px solid #ff6b6b;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.error-toast.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translate(-50%, -60%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
border: 1px solid #666;
|
||||
max-width: 350px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#tooltip.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #404040;
|
||||
border-top-color: #00aaff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.top-panel {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
left: 10px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
right: 10px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
min-width: auto;
|
||||
padding: 8px 10px 8px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 8px 10px 8px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.legend-panel {
|
||||
right: 10px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-panel h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
gap: 6px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
min-width: auto;
|
||||
padding: 7px 8px 7px 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 7px 8px 7px 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timings-panel {
|
||||
top: auto;
|
||||
bottom: 180px;
|
||||
left: 10px;
|
||||
transform: none;
|
||||
min-width: auto;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.legend-panel {
|
||||
top: auto;
|
||||
bottom: 200px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import compression from "compression";
|
||||
import express, { Request, Response } from "express";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
clearCache as clearMapCache,
|
||||
getMapMetadata,
|
||||
listMaps,
|
||||
setConfig,
|
||||
} from "./api/maps.js";
|
||||
import {
|
||||
clearAdapterCaches,
|
||||
computePath,
|
||||
computePfMiniPath,
|
||||
} from "./api/pathfinding.js";
|
||||
|
||||
// Parse command-line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const noCache = args.includes("--no-cache");
|
||||
|
||||
// Configure map loading
|
||||
if (noCache) {
|
||||
setConfig({ cachePaths: false });
|
||||
console.log("Path caching disabled (--no-cache)");
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 5555;
|
||||
|
||||
// Middleware
|
||||
app.use(compression()); // gzip compression for large responses
|
||||
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
|
||||
|
||||
// Serve static files from public directory
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
// API Routes
|
||||
|
||||
/**
|
||||
* GET /api/maps
|
||||
* List all available maps
|
||||
*/
|
||||
app.get("/api/maps", (req: Request, res: Response) => {
|
||||
try {
|
||||
const maps = listMaps();
|
||||
res.json({ maps });
|
||||
} catch (error) {
|
||||
console.error("Error listing maps:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to list maps",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:name
|
||||
* Get map metadata (map data, dimensions)
|
||||
*/
|
||||
app.get("/api/maps/:name", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const metadata = await getMapMetadata(name);
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
console.error(`Error loading map ${req.params.name}:`, error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("ENOENT")) {
|
||||
res.status(404).json({
|
||||
error: "Map not found",
|
||||
message: `Map "${req.params.name}" does not exist`,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to load map",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:name/thumbnail
|
||||
* Get map thumbnail image
|
||||
*/
|
||||
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const thumbnailPath = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../resources/maps",
|
||||
name,
|
||||
"thumbnail.webp",
|
||||
);
|
||||
res.sendFile(thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
|
||||
res.status(404).json({
|
||||
error: "Thumbnail not found",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pathfind
|
||||
* Compute pathfinding between two points
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* map: string,
|
||||
* from: [x, y],
|
||||
* to: [x, y],
|
||||
* includePfMini?: boolean
|
||||
* }
|
||||
*/
|
||||
app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, from, to, includePfMini } = 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 paths
|
||||
const result = await computePath(
|
||||
map,
|
||||
from as [number, number],
|
||||
to as [number, number],
|
||||
{ includePfMini: !!includePfMini },
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error computing 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 path",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pathfind-pfmini
|
||||
* Compute only PathFinder.Mini path
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* map: string,
|
||||
* from: [x, y],
|
||||
* to: [x, y]
|
||||
* }
|
||||
*/
|
||||
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, from, to } = req.body;
|
||||
|
||||
// Validate request
|
||||
if (!map || !from || !to) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid request",
|
||||
message: "Missing required fields: map, from, to",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(from) ||
|
||||
from.length !== 2 ||
|
||||
!Array.isArray(to) ||
|
||||
to.length !== 2
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid coordinates",
|
||||
message: "from and to must be [x, y] coordinate arrays",
|
||||
});
|
||||
}
|
||||
|
||||
// Compute PF.Mini path only
|
||||
const result = await computePfMiniPath(
|
||||
map,
|
||||
from as [number, number],
|
||||
to as [number, number],
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error computing PF.Mini path:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("is not water")) {
|
||||
res.status(400).json({
|
||||
error: "Invalid coordinates",
|
||||
message: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to compute PF.Mini path",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cache/clear
|
||||
* Clear all caches (useful for development)
|
||||
*/
|
||||
app.post("/api/cache/clear", (req: Request, res: Response) => {
|
||||
try {
|
||||
clearMapCache();
|
||||
clearAdapterCaches();
|
||||
res.json({ message: "Caches cleared successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error clearing caches:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to clear caches",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: Error, req: Request, res: Response, next: any) => {
|
||||
console.error("Unhandled error:", err);
|
||||
res.status(500).json({
|
||||
error: "Internal server error",
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Pathfinding Playground Server ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
|
||||
Server running at: http://localhost:${PORT}
|
||||
|
||||
Configuration:
|
||||
- Path caching: ${noCache ? "disabled" : "enabled"}
|
||||
|
||||
Press Ctrl+C to stop
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import fs from "fs";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
PlayerInfo,
|
||||
} from "../../src/core/game/Game";
|
||||
import { createGame } from "../../src/core/game/GameImpl";
|
||||
import { TileRef } from "../../src/core/game/GameMap";
|
||||
import {
|
||||
genTerrainFromBin,
|
||||
MapManifest,
|
||||
} from "../../src/core/game/TerrainMapLoader";
|
||||
import { UserSettings } from "../../src/core/game/UserSettings";
|
||||
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
|
||||
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
|
||||
import { GameConfig } from "../../src/core/Schemas";
|
||||
import { TestConfig } from "../util/TestConfig";
|
||||
export type BenchmarkRoute = {
|
||||
name: string;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
};
|
||||
|
||||
export type BenchmarkResult = {
|
||||
route: string;
|
||||
executionTime: number | null;
|
||||
pathLength: number | null;
|
||||
};
|
||||
|
||||
export type BenchmarkSummary = {
|
||||
totalRoutes: number;
|
||||
successfulRoutes: number;
|
||||
timedRoutes: number;
|
||||
totalDistance: number;
|
||||
totalTime: number;
|
||||
avgTime: number;
|
||||
};
|
||||
|
||||
export function getAdapter(game: Game, name: string): PathFinder {
|
||||
switch (name) {
|
||||
case "legacy":
|
||||
return PathFinders.WaterLegacy(game, {
|
||||
iterations: 500_000,
|
||||
maxTries: 50,
|
||||
});
|
||||
case "hpa": {
|
||||
// Recreate NavMesh without cache, this approach was chosen
|
||||
// over adding cache toggles to the existing game instance
|
||||
// to avoid adding side effect from benchmark to the game
|
||||
const navMesh = new NavMesh(game, { cachePaths: false });
|
||||
navMesh.initialize();
|
||||
(game as any)._navMesh = navMesh;
|
||||
|
||||
return PathFinders.Water(game);
|
||||
}
|
||||
case "hpa.cached":
|
||||
return PathFinders.Water(game);
|
||||
default:
|
||||
throw new Error(`Unknown pathfinding adapter: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScenario(
|
||||
scenarioName: string,
|
||||
adapterName: string = "hpa",
|
||||
) {
|
||||
const scenario = await import(`./benchmark/scenarios/${scenarioName}.js`);
|
||||
const enableNavMesh = adapterName.startsWith("hpa");
|
||||
|
||||
// Time game creation (includes NavMesh initialization for default adapter)
|
||||
const start = performance.now();
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.join(currentDir, "../..");
|
||||
const mapsDirectory = path.join(projectRoot, "resources/maps");
|
||||
const game = await setupFromPath(mapsDirectory, scenario.MAP_NAME, {
|
||||
disableNavMesh: !enableNavMesh,
|
||||
});
|
||||
const initTime = performance.now() - start;
|
||||
|
||||
const routes = scenario.ROUTES.map(([fromName, toName]: [string, string]) => {
|
||||
const fromCoord: [number, number] = scenario.PORTS[fromName];
|
||||
const toCoord: [number, number] = scenario.PORTS[toName];
|
||||
|
||||
return {
|
||||
name: `${fromName} → ${toName}`,
|
||||
from: game.ref(fromCoord[0], fromCoord[1]),
|
||||
to: game.ref(toCoord[0], toCoord[1]),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
game,
|
||||
routes,
|
||||
initTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function measurePathLength(
|
||||
adapter: PathFinder,
|
||||
route: BenchmarkRoute,
|
||||
): number | null {
|
||||
const path = adapter.findPath(route.from, route.to);
|
||||
return path ? path.length : null;
|
||||
}
|
||||
|
||||
export function measureTime<T>(fn: () => T): { result: T; time: number } {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
const end = performance.now();
|
||||
return { result, time: end - start };
|
||||
}
|
||||
|
||||
export function measureExecutionTime(
|
||||
adapter: PathFinder,
|
||||
route: BenchmarkRoute,
|
||||
executions: number = 1,
|
||||
): number | null {
|
||||
const { time } = measureTime(() => {
|
||||
for (let i = 0; i < executions; i++) {
|
||||
adapter.findPath(route.from, route.to);
|
||||
}
|
||||
});
|
||||
|
||||
return time / executions;
|
||||
}
|
||||
|
||||
export function calculateStats(results: BenchmarkResult[]): BenchmarkSummary {
|
||||
const successful = results.filter((r) => r.pathLength !== null);
|
||||
const timed = results.filter((r) => r.executionTime !== null);
|
||||
|
||||
const totalDistance = successful.reduce((sum, r) => sum + r.pathLength!, 0);
|
||||
const totalTime = timed.reduce((sum, r) => sum + r.executionTime!, 0);
|
||||
const avgTime = timed.length > 0 ? totalTime / timed.length : 0;
|
||||
|
||||
return {
|
||||
totalRoutes: results.length,
|
||||
successfulRoutes: successful.length,
|
||||
timedRoutes: timed.length,
|
||||
totalDistance,
|
||||
totalTime,
|
||||
avgTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function printRow(columns: (string | number)[], widths: number[]): void {
|
||||
const formatted = columns.map((col, i) => {
|
||||
const str = typeof col === "number" ? col.toString() : col;
|
||||
return str.padEnd(widths[i]);
|
||||
});
|
||||
|
||||
console.log(formatted.join(" "));
|
||||
}
|
||||
|
||||
export function printSeparator(width: number = 80): void {
|
||||
console.log("-".repeat(width));
|
||||
}
|
||||
|
||||
export function printHeader(title: string, width: number = 80): void {
|
||||
printSeparator(width);
|
||||
console.log(title);
|
||||
printSeparator(width);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
export async function setupFromPath(
|
||||
mapDirectory: string,
|
||||
mapName: string,
|
||||
gameConfig: Partial<GameConfig> = {},
|
||||
humans: PlayerInfo[] = [],
|
||||
): Promise<Game> {
|
||||
// Suppress console.debug for tests
|
||||
console.debug = () => {};
|
||||
|
||||
// Load map files from specified directory
|
||||
const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
|
||||
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
|
||||
const manifestPath = path.join(mapDirectory, mapName, "manifest.json");
|
||||
|
||||
// Check if files exist
|
||||
if (!fs.existsSync(mapBinPath)) {
|
||||
throw new Error(`Map not found: ${mapBinPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(miniMapBinPath)) {
|
||||
throw new Error(`Mini map not found: ${miniMapBinPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const mapBinBuffer = fs.readFileSync(mapBinPath);
|
||||
const miniMapBinBuffer = fs.readFileSync(miniMapBinPath);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(manifestPath, "utf8"),
|
||||
) satisfies MapManifest;
|
||||
|
||||
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
|
||||
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
|
||||
|
||||
// Configure the game
|
||||
const config = new TestConfig(
|
||||
new (await import("../util/TestServerConfig")).TestServerConfig(),
|
||||
{
|
||||
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,
|
||||
randomSpawn: false,
|
||||
...gameConfig,
|
||||
},
|
||||
new UserSettings(),
|
||||
false,
|
||||
);
|
||||
|
||||
return createGame(humans, [], gameMap, miniGameMap, config);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Benchmark from "benchmark";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { PathFinder } from "../../src/core/pathfinding/PathFinding";
|
||||
import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding";
|
||||
import { setup } from "../util/Setup";
|
||||
|
||||
const game = await setup(
|
||||
@@ -13,19 +13,19 @@ const game = await setup(
|
||||
|
||||
new Benchmark.Suite()
|
||||
.add("top-left-to-bottom-right", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(0, 0),
|
||||
game.ref(4077, 1929),
|
||||
);
|
||||
})
|
||||
.add("hawaii to svalbard", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(186, 800),
|
||||
game.ref(2205, 52),
|
||||
);
|
||||
})
|
||||
.add("black sea to california", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(2349, 455),
|
||||
game.ref(511, 536),
|
||||
);
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1000,
|
||||
"num_land_tiles": 651761,
|
||||
"width": 2000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 250,
|
||||
"num_land_tiles": 37185,
|
||||
"width": 500
|
||||
},
|
||||
"map4x": {
|
||||
"height": 500,
|
||||
"num_land_tiles": 157860,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "World",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [375, 272],
|
||||
"flag": "us",
|
||||
"name": "United States"
|
||||
},
|
||||
{
|
||||
"coordinates": [372, 136],
|
||||
"flag": "ca",
|
||||
"name": "Canada"
|
||||
},
|
||||
{
|
||||
"coordinates": [375, 374],
|
||||
"flag": "mx",
|
||||
"name": "Mexico"
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 378],
|
||||
"flag": "cu",
|
||||
"name": "Cuba"
|
||||
},
|
||||
{
|
||||
"coordinates": [524, 474],
|
||||
"flag": "co",
|
||||
"name": "Colombia"
|
||||
},
|
||||
{
|
||||
"coordinates": [593, 473],
|
||||
"flag": "ve",
|
||||
"name": "Venezuela"
|
||||
},
|
||||
{
|
||||
"coordinates": [596, 705],
|
||||
"flag": "ar",
|
||||
"name": "Argentina"
|
||||
},
|
||||
{
|
||||
"coordinates": [637, 567],
|
||||
"flag": "br",
|
||||
"name": "Brazil"
|
||||
},
|
||||
{
|
||||
"coordinates": [1280, 975],
|
||||
"flag": "aq",
|
||||
"name": "Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [709, 57],
|
||||
"flag": "gl",
|
||||
"name": "Greenland"
|
||||
},
|
||||
{
|
||||
"coordinates": [831, 112],
|
||||
"flag": "is",
|
||||
"name": "Iceland"
|
||||
},
|
||||
{
|
||||
"coordinates": [925, 186],
|
||||
"flag": "gb",
|
||||
"name": "United Kingdom"
|
||||
},
|
||||
{
|
||||
"coordinates": [887, 183],
|
||||
"flag": "ie",
|
||||
"name": "Ireland"
|
||||
},
|
||||
{
|
||||
"coordinates": [908, 264],
|
||||
"flag": "es",
|
||||
"name": "Spain"
|
||||
},
|
||||
{
|
||||
"coordinates": [1004, 250],
|
||||
"flag": "it",
|
||||
"name": "Italy"
|
||||
},
|
||||
{
|
||||
"coordinates": [958, 220],
|
||||
"flag": "fr",
|
||||
"name": "France"
|
||||
},
|
||||
{
|
||||
"coordinates": [997, 205],
|
||||
"flag": "de",
|
||||
"name": "Germany"
|
||||
},
|
||||
{
|
||||
"coordinates": [1064, 101],
|
||||
"flag": "se",
|
||||
"name": "Sweden"
|
||||
},
|
||||
{
|
||||
"coordinates": [1046, 193],
|
||||
"flag": "pl",
|
||||
"name": "Poland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1061, 188],
|
||||
"flag": "by",
|
||||
"name": "Belarus"
|
||||
},
|
||||
{
|
||||
"coordinates": [1073, 243],
|
||||
"flag": "ro",
|
||||
"name": "Romania"
|
||||
},
|
||||
{
|
||||
"coordinates": [1161, 274],
|
||||
"flag": "tr",
|
||||
"name": "Turkey"
|
||||
},
|
||||
{
|
||||
"coordinates": [969, 133],
|
||||
"flag": "no",
|
||||
"name": "Norway"
|
||||
},
|
||||
{
|
||||
"coordinates": [1062, 133],
|
||||
"flag": "fi",
|
||||
"name": "Finland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 211],
|
||||
"flag": "ua",
|
||||
"name": "Ukraine"
|
||||
},
|
||||
{
|
||||
"coordinates": [1344, 136],
|
||||
"flag": "ru",
|
||||
"name": "Russia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 186],
|
||||
"flag": "mn",
|
||||
"name": "Mongolia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1524, 328],
|
||||
"flag": "cn",
|
||||
"name": "China"
|
||||
},
|
||||
{
|
||||
"coordinates": [1368, 373],
|
||||
"flag": "in",
|
||||
"name": "India"
|
||||
},
|
||||
{
|
||||
"coordinates": [1276, 239],
|
||||
"flag": "kz",
|
||||
"name": "Kazakhstan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1238, 309],
|
||||
"flag": "ir",
|
||||
"name": "Islamic Republic Of Iran"
|
||||
},
|
||||
{
|
||||
"coordinates": [1178, 351],
|
||||
"flag": "sa",
|
||||
"name": "Saudi Arabia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1679, 657],
|
||||
"flag": "au",
|
||||
"name": "Australia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1890, 775],
|
||||
"flag": "nz",
|
||||
"name": "New Zealand"
|
||||
},
|
||||
{
|
||||
"coordinates": [918, 342],
|
||||
"flag": "dz",
|
||||
"name": "Algeria"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 332],
|
||||
"flag": "ly",
|
||||
"name": "Libyan Arab Jamahiriya"
|
||||
},
|
||||
{
|
||||
"coordinates": [1092, 335],
|
||||
"flag": "eg",
|
||||
"name": "Egypt"
|
||||
},
|
||||
{
|
||||
"coordinates": [963, 410],
|
||||
"flag": "ne",
|
||||
"name": "Niger"
|
||||
},
|
||||
{
|
||||
"coordinates": [1112, 406],
|
||||
"flag": "sd",
|
||||
"name": "Sudan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1074, 508],
|
||||
"flag": "cd",
|
||||
"name": "DR Congo"
|
||||
},
|
||||
{
|
||||
"coordinates": [1154, 443],
|
||||
"flag": "et",
|
||||
"name": "Ethiopia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 707],
|
||||
"flag": "za",
|
||||
"name": "South Africa"
|
||||
},
|
||||
{
|
||||
"coordinates": [1194, 627],
|
||||
"flag": "mg",
|
||||
"name": "Madagascar"
|
||||
},
|
||||
{
|
||||
"coordinates": [1052, 420],
|
||||
"flag": "td",
|
||||
"name": "Chad"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 665],
|
||||
"flag": "na",
|
||||
"name": "Namibia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1632, 465],
|
||||
"flag": "ph",
|
||||
"name": "Philippines"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 426],
|
||||
"flag": "th",
|
||||
"name": "Thailand"
|
||||
},
|
||||
{
|
||||
"coordinates": [1610, 364],
|
||||
"flag": "tw",
|
||||
"name": "Taiwan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1710, 290],
|
||||
"flag": "jp",
|
||||
"name": "Japan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1869, 119],
|
||||
"flag": "ru",
|
||||
"name": "Siberia"
|
||||
},
|
||||
{
|
||||
"coordinates": [74, 117],
|
||||
"flag": "polar_bears",
|
||||
"name": "Polar Bears"
|
||||
},
|
||||
{
|
||||
"coordinates": [419, 975],
|
||||
"flag": "aq",
|
||||
"name": "West Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [542, 603],
|
||||
"flag": "pe",
|
||||
"name": "Peru"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 615],
|
||||
"flag": "zm",
|
||||
"name": "Zambia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 165],
|
||||
"flag": "lv",
|
||||
"name": "Latvia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1427, 336],
|
||||
"flag": "bt",
|
||||
"name": "Bhutan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1511, 524],
|
||||
"flag": "id",
|
||||
"name": "Indonesia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1809, 977],
|
||||
"flag": "aq",
|
||||
"name": "East Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [1255, 382],
|
||||
"flag": "om",
|
||||
"name": "Oman"
|
||||
},
|
||||
{
|
||||
"coordinates": [853, 373],
|
||||
"flag": "ma",
|
||||
"name": "Morocco"
|
||||
},
|
||||
{
|
||||
"coordinates": [656, 678],
|
||||
"flag": "uy",
|
||||
"name": "Uruguay"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -14,6 +14,10 @@ export class TestConfig extends DefaultConfig {
|
||||
private _defaultNukeSpeed: number = 4;
|
||||
private _spawnImmunityDuration: number = 0;
|
||||
|
||||
disableNavMesh(): boolean {
|
||||
return this.gameConfig().disableNavMesh ?? true;
|
||||
}
|
||||
|
||||
radiusPortSpawn(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user