Pathfinding Refactor

This commit is contained in:
Arkadiusz Sygulski
2026-01-11 21:34:37 +01:00
parent 8235da9335
commit 13b4142317
75 changed files with 6809 additions and 4200 deletions
+54 -60
View File
@@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js";
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { setupFromPath } from "../../utils.js";
// Available comparison adapters
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"];
export interface MapInfo {
name: string;
displayName: string;
@@ -13,7 +16,7 @@ export interface MapInfo {
export interface MapCache {
game: Game;
navMesh: NavMesh;
hpaStar: AStarWaterHierarchical;
}
const cache = new Map<string, MapCache>();
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
const mapsDir = getMapsDirectory();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName);
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Initialize NavMesh
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
navMesh.initialize();
// Get pre-built graph from game
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error(`No water graph available for map: ${mapName}`);
}
const cacheEntry: MapCache = { game, navMesh };
// Initialize AStarWaterHierarchical with minimap and graph
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: config.cachePaths,
});
const cacheEntry: MapCache = { game, hpaStar };
// Store in cache
cache.set(mapName, cacheEntry);
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, navMesh } = await loadMap(mapName);
const { game, hpaStar } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
@@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) {
}
}
// Extract static graph data from NavMesh
// Extract static graph data from GameMapHPAStar
// Access internal graph via type casting (test code only)
const graph = (hpaStar as any).graph;
const miniMap = game.miniMap();
const navMeshGraph = (navMesh as any).graph;
// Convert gateways from Map to array
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
const allGateways = gatewaysArray.map((gw: any) => ({
id: gw.id,
x: miniMap.x(gw.tile),
y: miniMap.y(gw.tile),
// Convert nodes to client format
const allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Create a lookup map from gateway ID to gateway for edge conversion
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
// Convert edges to client format
const edges: Array<{
fromId: number;
toId: number;
from: number[];
to: number[];
cost: number;
}> = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
// Convert edges from Map<gatewayId, Edge[]> to flat array
// The edges Map has gateway IDs as keys, and arrays of edges as values
const allEdges: any[] = [];
for (const edgeArray of navMeshGraph.edges.values()) {
allEdges.push(...edgeArray);
const nodeA = graph.getNode(edge.nodeA);
const nodeB = graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
edges.push({
fromId: edge.nodeA,
toId: edge.nodeB,
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
cost: edge.cost,
});
}
// Deduplicate edges (they're bidirectional, so each edge appears twice)
const seenEdges = new Set<string>();
const edges = allEdges
.filter((edge: any) => {
const edgeKey =
edge.from < edge.to
? `${edge.from}-${edge.to}`
: `${edge.to}-${edge.from}`;
if (seenEdges.has(edgeKey)) return false;
seenEdges.add(edgeKey);
return true;
})
.map((edge: any) => {
const fromGateway = gatewayById.get(edge.from);
const toGateway = gatewayById.get(edge.to);
return {
fromId: edge.from,
toId: edge.to,
from: fromGateway
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
: [0, 0],
to: toGateway
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
: [0, 0],
cost: edge.cost,
path: edge.path
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
: null,
};
});
console.log(
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
);
const sectorSize = navMeshGraph.sectorSize;
const clusterSize = graph.clusterSize;
return {
name: mapName,
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
height: game.height(),
mapData,
graphDebug: {
allGateways,
allNodes,
edges,
sectorSize,
clusterSize,
},
adapters: COMPARISON_ADAPTERS,
};
}
+191 -99
View File
@@ -1,39 +1,64 @@
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
import { loadMap } from "./maps.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js";
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js";
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js";
import {
PathFinder,
SteppingPathFinder,
} from "../../../../src/core/pathfinding/types.js";
import { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
interface PathfindingOptions {
includePfMini?: boolean;
includeNavMesh?: boolean;
}
interface NavMeshResult {
// Primary result with debug info
interface PrimaryResult {
path: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
gateways: Array<[number, number]> | null;
timings: any;
length: number;
time: number;
debug: {
nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
timings: Record<string, number>;
};
}
interface PfMiniResult {
// Comparison result (path + timing only)
interface ComparisonResult {
adapter: string;
path: Array<[number, number]> | null;
length: number;
time: number;
}
// Cache pathfinding adapters per map
const pfMiniCache = new Map<string, MiniAStarAdapter>();
export interface PathfindResult {
primary: PrimaryResult;
comparisons: ComparisonResult[];
}
// Cache adapters per map
const adapterCache = new Map<
string,
Map<string, SteppingPathFinder<TileRef>>
>();
/**
* Get or create MiniAStar adapter for a map
* Get or create an adapter for a map
*/
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
if (!pfMiniCache.has(mapName)) {
const adapter = new MiniAStarAdapter(game, { waterPath: true });
pfMiniCache.set(mapName, adapter);
function getOrCreateAdapter(
mapName: string,
adapterName: string,
game: any,
): SteppingPathFinder<TileRef> {
if (!adapterCache.has(mapName)) {
adapterCache.set(mapName, new Map());
}
return pfMiniCache.get(mapName)!;
const mapAdapters = adapterCache.get(mapName)!;
if (!mapAdapters.has(adapterName)) {
mapAdapters.set(adapterName, getAdapter(game, adapterName));
}
return mapAdapters.get(adapterName)!;
}
/**
@@ -48,110 +73,177 @@ function pathToCoords(
}
/**
* Compute pathfinding between two points
* Build the full transformer chain like PathFinding.Water() does
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: PathfindingOptions = {},
): Promise<NavMeshResult> {
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
function buildWrappedPathFinder(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
): PathFinder<TileRef> {
const miniMap = game.miniMap();
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
const withComponentCheck = new ComponentCheckTransformer(
hpaStar,
componentCheckFn,
);
const withSmoothing = new BresenhamSmoothingTransformer(
withComponentCheck,
miniMap,
);
const withShoreCoercing = new ShoreCoercingTransformer(
withSmoothing,
miniMap,
);
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute NavMesh path
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
const path = pathToCoords(navMeshPath, game);
return withMiniMap;
}
/**
* Compute primary path using AStarWaterHierarchical with debug info
* Uses the same transformer chain as PathFinding.Water()
*/
function computePrimaryPath(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
fromRef: TileRef,
toRef: TileRef,
): PrimaryResult {
const miniMap = game.miniMap();
// Extract debug info
let gateways: Array<[number, number]> | null = null;
// Build wrapped pathfinder with all transformers
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph);
// Enable debug mode to capture internal state
hpaStar.debugMode = true;
const start = performance.now();
const path = wrappedPf.findPath(fromRef, toRef);
const time = performance.now() - start;
const debugInfo = hpaStar.debugInfo;
// Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null;
if (debugInfo?.nodePath) {
nodePath = debugInfo.nodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null;
let timings: any = {};
if (navMeshAdapter.debugInfo) {
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
if (navMeshAdapter.debugInfo.gatewayPath) {
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initial path
if (navMeshAdapter.debugInfo.initialPath) {
initialPath = navMeshAdapter.debugInfo.initialPath.map(
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
);
}
timings = navMeshAdapter.debugInfo.timings || {};
if (debugInfo?.initialPath) {
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
return {
path,
initialPath,
gateways,
timings,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time: timings.total ?? 0,
time,
debug: {
nodePath,
initialPath,
timings: debugInfo?.timings ?? {},
},
};
}
/**
* Compute only PathFinder.Mini path
* Compute comparison path using adapter
*/
export async function computePfMiniPath(
mapName: string,
from: [number, number],
to: [number, number],
): Promise<PfMiniResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute PathFinder.Mini path
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
const pfMiniStart = performance.now();
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
const pfMiniEnd = performance.now();
const path = pathToCoords(pfMiniPath, game);
const time = pfMiniEnd - pfMiniStart;
function computeComparisonPath(
adapter: SteppingPathFinder<TileRef>,
game: any,
fromRef: TileRef,
toRef: TileRef,
adapterName: string,
): ComparisonResult {
const start = performance.now();
const path = adapter.findPath(fromRef, toRef);
const time = performance.now() - start;
return {
path,
adapter: adapterName,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time,
};
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: { adapters?: string[] } = {},
): Promise<PathfindResult> {
const { game, hpaStar } = await loadMap(mapName);
const graph = game.miniWaterGraph();
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute primary path (HPA* with debug)
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef);
// Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
const comparisons: ComparisonResult[] = [];
for (const adapterName of selectedAdapters) {
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
console.warn(`Unknown adapter: ${adapterName}, skipping`);
continue;
}
try {
const adapter = getOrCreateAdapter(mapName, adapterName, game);
const result = computeComparisonPath(
adapter,
game,
fromRef,
toRef,
adapterName,
);
comparisons.push(result);
} catch (error) {
console.error(`Error with adapter ${adapterName}:`, error);
comparisons.push({
adapter: adapterName,
path: null,
length: 0,
time: 0,
});
}
}
return { primary, comparisons };
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
pfMiniCache.clear();
adapterCache.clear();
}
File diff suppressed because it is too large Load Diff
+21 -58
View File
@@ -129,13 +129,13 @@
<button class="toggle-button" id="showInitialPath" data-active="false">
Initial Path
</button>
<button class="toggle-button" id="showUsedGateways" data-active="false">
Used Gateways
<button class="toggle-button" id="showUsedNodes" data-active="false">
Used Nodes
</button>
</div>
<div class="debug-panel-row">
<button class="toggle-button" id="showGateways" data-active="false">
Gateways
<button class="toggle-button" id="showNodes" data-active="false">
Nodes
</button>
<button class="toggle-button" id="showSectorGrid" data-active="false">
Sectors
@@ -166,75 +166,42 @@
<div class="timing-label">
<button
class="refresh-icon"
id="refreshNavMesh"
title="Recompute NavMesh path"
id="refreshHpa"
title="Recompute HPA* path"
>
<span></span>
</button>
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
</div>
<div class="timing-value-large" id="navMeshTime"></div>
<div class="timing-value-large" id="hpaTime"></div>
<div class="timing-breakdown" id="timingBreakdown">
<div class="timing-item" id="timingEarlyExit" style="display: none">
<span class="timing-name">Early Exit:</span>
<span class="timing-value" id="timingEarlyExitValue"></span>
</div>
<div class="timing-item" id="timingFindNodes" style="display: none">
<span class="timing-name">Find Nodes:</span>
<span class="timing-value" id="timingFindNodesValue"></span>
</div>
<div
class="timing-item"
id="timingFindGateways"
id="timingAbstractPath"
style="display: none"
>
<span class="timing-name">Find Gateways:</span>
<span class="timing-value" id="timingFindGatewaysValue"></span>
</div>
<div class="timing-item" id="timingGatewayPath" style="display: none">
<span class="timing-name">Gateway Path:</span>
<span class="timing-value" id="timingGatewayPathValue"></span>
<span class="timing-name">Abstract Path:</span>
<span class="timing-value" id="timingAbstractPathValue"></span>
</div>
<div class="timing-item" id="timingInitialPath" style="display: none">
<span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span>
</div>
<div class="timing-item" id="timingSmoothPath" style="display: none">
<span class="timing-name">Smooth Path:</span>
<span class="timing-value" id="timingSmoothPathValue"></span>
</div>
</div>
</div>
<div class="timing-section" id="pfMiniRequestSection">
<button
id="requestPfMini"
class="timing-button"
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
disabled
>
Request PathFinder.Mini
</button>
</div>
<div
class="timing-section"
id="pfMiniTimingSection"
style="display: none"
>
<div class="timing-label">
<button
class="refresh-icon"
id="refreshPfMini"
title="Recompute PF.Mini path"
>
<span></span>
</button>
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
</div>
<div class="timing-value-large" id="pfMiniTime"></div>
</div>
<div class="timing-section" id="speedupSection" style="display: none">
<div class="timing-label">Speedup</div>
<div class="timing-value-speedup" id="speedupValue"></div>
<div class="timing-section" id="comparisonsSection" style="display: none">
<div class="timing-label">Comparisons</div>
<div id="comparisonsContainer"></div>
</div>
</div>
@@ -256,13 +223,9 @@
></div>
<span>End Point</span>
</div>
<div class="legend-item" id="pfMiniLegend" style="display: none">
<div class="legend-color" style="background: #ffaa00"></div>
<span>PathFinder.Mini</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00ffff"></div>
<span>NavMesh</span>
<span>HPA*</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff00ff"></div>
@@ -273,7 +236,7 @@
class="legend-color"
style="background: #ffff00; height: 8px"
></div>
<span>Used Gateways</span>
<span>Used Nodes</span>
</div>
<div class="legend-item">
<div
@@ -285,7 +248,7 @@
border-radius: 50%;
"
></div>
<span>Gateways</span>
<span>Nodes</span>
</div>
<div class="legend-item">
<div
@@ -500,6 +500,67 @@ canvas {
font-size: 20px;
}
/* Comparison rows */
.comparison-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 0 -8px;
font-size: 14px;
border-bottom: 1px solid #333;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.comparison-row:hover {
background: rgba(255, 255, 255, 0.1);
}
.comparison-row.active {
background: rgba(255, 255, 255, 0.15);
}
.comparison-row:last-child {
border-bottom: none;
}
.comp-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.comparison-row.active .comp-color {
opacity: 1;
}
.comp-name {
color: #aaa;
flex: 1;
font-family: monospace;
}
.comp-tiles {
font-family: monospace;
color: #888;
width: 50px;
text-align: right;
margin-right: 10px;
}
.comp-time {
font-family: monospace;
color: #f5f5f5;
width: 60px;
text-align: right;
}
/* Legend panel (right side) */
.legend-panel {
position: fixed;
+10 -68
View File
@@ -8,11 +8,7 @@ import {
listMaps,
setConfig,
} from "./api/maps.js";
import {
clearAdapterCaches,
computePath,
computePfMiniPath,
} from "./api/pathfinding.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
* map: string,
* from: [x, y],
* to: [x, y],
* includePfMini?: boolean
* adapters?: string[] // Optional: which comparison adapters to run
* }
*
* Response:
* {
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
* comparisons: [{ adapter, path, length, time }, ...]
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, includePfMini } = req.body;
const { map, from, to, adapters } = req.body;
// Validate request
if (!map || !from || !to) {
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
map,
from as [number, number],
to as [number, number],
{ includePfMini: !!includePfMini },
{ adapters },
);
res.json(result);
@@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
}
});
/**
* POST /api/pathfind-pfmini
* Compute only PathFinder.Mini path
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y]
* }
*/
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
try {
const { map, from, to } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute PF.Mini path only
const result = await computePfMiniPath(
map,
from as [number, number],
to as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing PF.Mini path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute PF.Mini path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)