diff --git a/src/core/pathfinding/algorithms/AStar.Water.ts b/src/core/pathfinding/algorithms/AStar.Water.ts index 6452114a5..d6a366293 100644 --- a/src/core/pathfinding/algorithms/AStar.Water.ts +++ b/src/core/pathfinding/algorithms/AStar.Water.ts @@ -1,6 +1,6 @@ import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; -import { BucketQueue, PriorityQueue } from "./PriorityQueue"; +import { MinHeap, PriorityQueue } from "./PriorityQueue"; const LAND_BIT = 7; // Bit 7 in terrain indicates land const MAGNITUDE_MASK = 0x1f; @@ -45,11 +45,7 @@ export class AStarWater implements PathFinder { this.gScore = new Uint32Array(this.numNodes); this.cameFrom = new Int32Array(this.numNodes); - // Account for scaled costs + tie-breaker headroom - const maxDim = map.width() + map.height(); - const maxF = - (this.heuristicWeight + 1) * BASE_COST * maxDim + COST_SCALE * maxDim; - this.queue = new BucketQueue(maxF); + this.queue = new MinHeap(this.numNodes); } findPath(start: number | number[], goal: number): number[] | null { diff --git a/src/core/pathfinding/algorithms/AStar.WaterBounded.ts b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts index aa8bdd693..8af127ef4 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterBounded.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts @@ -1,6 +1,6 @@ import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; -import { BucketQueue } from "./PriorityQueue"; +import { MinHeap } from "./PriorityQueue"; const LAND_BIT = 7; const MAGNITUDE_MASK = 0x1f; @@ -33,7 +33,7 @@ export class AStarWaterBounded implements PathFinder { private readonly gScoreStamp: Uint32Array; private readonly gScore: Uint32Array; private readonly cameFrom: Int32Array; - private readonly queue: BucketQueue; + private readonly queue: MinHeap; private readonly terrain: Uint8Array; private readonly mapWidth: number; private readonly heuristicWeight: number; @@ -54,11 +54,7 @@ export class AStarWaterBounded implements PathFinder { this.gScore = new Uint32Array(maxSearchArea); this.cameFrom = new Int32Array(maxSearchArea); - const maxDim = Math.ceil(Math.sqrt(maxSearchArea)); - // Account for scaled costs + tie-breaker headroom - const maxF = - (this.heuristicWeight + 1) * BASE_COST * maxDim * 2 + COST_SCALE * maxDim; - this.queue = new BucketQueue(maxF); + this.queue = new MinHeap(maxSearchArea * 4); } findPath(start: number | number[], goal: number): number[] | null { @@ -209,6 +205,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const ny = currentY - 1; + const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -219,11 +217,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const ny = currentY - 1; - const h = - weight * - BASE_COST * - (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighborLocal, f); } @@ -238,6 +232,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const ny = currentY + 1; + const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -248,11 +244,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const ny = currentY + 1; - const h = - weight * - BASE_COST * - (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighborLocal, f); } @@ -267,6 +259,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const nx = currentX - 1; + const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -277,11 +271,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const nx = currentX - 1; - const h = - weight * - BASE_COST * - (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighborLocal, f); } @@ -296,6 +286,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const nx = currentX + 1; + const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -306,11 +298,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const nx = currentX + 1; - const h = - weight * - BASE_COST * - (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighborLocal, f); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index ce8ceb2a7..78a8ff6bc 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -12,6 +12,7 @@ export class AStarWaterHierarchical implements PathFinder { private abstractAStar: AbstractGraphAStar; private localAStar: AStarWaterBounded; private localAStarMultiCluster: AStarWaterBounded; + private localAStarShortPath: AStarWaterBounded; private sourceResolver: SourceResolver; constructor( @@ -41,6 +42,11 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); + // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) + const shortPathSize = 140; + const maxShortPathNodes = shortPathSize * shortPathSize; + this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); + // SourceResolver for multi-source search this.sourceResolver = new SourceResolver(this.map, this.graph); } @@ -62,6 +68,10 @@ export class AStarWaterHierarchical implements PathFinder { sources: TileRef[], target: TileRef, ): TileRef[] | null { + // Early exit: try bounded A* for sources close to target + const shortPath = this.tryShortPathMultiSource(sources, target); + if (shortPath) return shortPath; + // 1. Resolve target to abstract node const targetNode = this.sourceResolver.resolveTarget(target); if (!targetNode) return null; @@ -82,6 +92,44 @@ export class AStarWaterHierarchical implements PathFinder { return this.findPathSingle(winningSource, target); } + private tryShortPathMultiSource( + sources: TileRef[], + target: TileRef, + ): TileRef[] | null { + const SHORT_PATH_THRESHOLD = 120; + const PADDING = 10; + + const candidates = sources.filter( + (s) => this.map.manhattanDist(s, target) <= SHORT_PATH_THRESHOLD, + ); + if (candidates.length === 0) return null; + + const toX = this.map.x(target); + const toY = this.map.y(target); + let minX = toX, + maxX = toX, + minY = toY, + maxY = toY; + + for (const s of candidates) { + const sx = this.map.x(s); + const sy = this.map.y(s); + minX = Math.min(minX, sx); + maxX = Math.max(maxX, sx); + minY = Math.min(minY, sy); + maxY = Math.max(maxY, sy); + } + + const bounds = { + minX: Math.max(0, minX - PADDING), + maxX: Math.min(this.map.width() - 1, maxX + PADDING), + minY: Math.max(0, minY - PADDING), + maxY: Math.min(this.map.height() - 1, maxY + PADDING), + }; + + return this.localAStarShortPath.searchBounded(candidates, target, bounds); + } + findPathSingle(from: TileRef, to: TileRef): TileRef[] | null { const dist = this.map.manhattanDist(from, to); diff --git a/src/core/pathfinding/algorithms/PriorityQueue.ts b/src/core/pathfinding/algorithms/PriorityQueue.ts index c8f525f0b..df7f52919 100644 --- a/src/core/pathfinding/algorithms/PriorityQueue.ts +++ b/src/core/pathfinding/algorithms/PriorityQueue.ts @@ -18,7 +18,20 @@ export class MinHeap implements PriorityQueue { push(node: number, priority: number): void { if (this.size >= this.capacity) { - throw new Error(`MinHeap capacity exceeded: ${this.capacity}`); + console.error( + `MinHeap capacity exceeded (${this.capacity}). ` + + "Resizing, but this indicates a bug. Please investigate.", + ); + + this.capacity *= 2; + + const newHeap = new Int32Array(this.capacity); + const newPri = new Float32Array(this.capacity); + newHeap.set(this.heap); + newPri.set(this.priorities); + + this.heap = newHeap; + this.priorities = newPri; } let i = this.size++; @@ -94,6 +107,8 @@ export class MinHeap implements PriorityQueue { export class BucketQueue implements PriorityQueue { private buckets: Int32Array[]; private bucketSizes: Int32Array; + private bucketStamp: Uint32Array; + private stamp = 0; private minBucket: number; private maxBucket: number; private size: number; @@ -102,6 +117,7 @@ export class BucketQueue implements PriorityQueue { this.maxBucket = maxPriority + 1; this.buckets = new Array(this.maxBucket); this.bucketSizes = new Int32Array(this.maxBucket); + this.bucketStamp = new Uint32Array(this.maxBucket); this.minBucket = this.maxBucket; this.size = 0; } @@ -113,7 +129,9 @@ export class BucketQueue implements PriorityQueue { this.buckets[bucket] = new Int32Array(64); } - const size = this.bucketSizes[bucket]; + const size = + this.bucketStamp[bucket] === this.stamp ? this.bucketSizes[bucket] : 0; + if (size >= this.buckets[bucket].length) { const newBucket = new Int32Array(this.buckets[bucket].length * 2); newBucket.set(this.buckets[bucket]); @@ -121,7 +139,8 @@ export class BucketQueue implements PriorityQueue { } this.buckets[bucket][size] = node; - this.bucketSizes[bucket]++; + this.bucketSizes[bucket] = size + 1; + this.bucketStamp[bucket] = this.stamp; this.size++; if (bucket < this.minBucket) { @@ -131,11 +150,13 @@ export class BucketQueue implements PriorityQueue { pop(): number { while (this.minBucket < this.maxBucket) { - const size = this.bucketSizes[this.minBucket]; - if (size > 0) { - this.bucketSizes[this.minBucket]--; - this.size--; - return this.buckets[this.minBucket][size - 1]; + if (this.bucketStamp[this.minBucket] === this.stamp) { + const size = this.bucketSizes[this.minBucket]; + if (size > 0) { + this.bucketSizes[this.minBucket]--; + this.size--; + return this.buckets[this.minBucket][size - 1]; + } } this.minBucket++; } @@ -147,7 +168,11 @@ export class BucketQueue implements PriorityQueue { } clear(): void { - this.bucketSizes.fill(0); + this.stamp++; + if (this.stamp > 0xffffffff) { + this.bucketStamp.fill(0); + this.stamp = 1; + } this.minBucket = this.maxBucket; this.size = 0; } diff --git a/src/core/pathfinding/spatial/SpatialQuery.ts b/src/core/pathfinding/spatial/SpatialQuery.ts index 1336a636f..9128dd0b4 100644 --- a/src/core/pathfinding/spatial/SpatialQuery.ts +++ b/src/core/pathfinding/spatial/SpatialQuery.ts @@ -1,12 +1,27 @@ import { Game, Player, TerraNullius } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; import { PathFinding } from "../PathFinder"; +import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded"; type Owner = Player | TerraNullius; +const REFINE_MAX_SEARCH_AREA = 100 * 100; + export class SpatialQuery { + private boundedAStar: AStarWaterBounded | null = null; + constructor(private game: Game) {} + private getBoundedAStar(): AStarWaterBounded { + this.boundedAStar ??= new AStarWaterBounded( + this.game.map(), + REFINE_MAX_SEARCH_AREA, + ); + + return this.boundedAStar; + } + /** * Find nearest tile matching predicate using BFS traversal. * Uses Manhattan distance filter, ignores terrain barriers. @@ -64,27 +79,125 @@ export class SpatialQuery { * Returns null for terra nullius (no borderTiles). */ closestShoreByWater(owner: Owner, target: TileRef): TileRef | null { - if (!owner.isPlayer()) return null; + return DebugSpan.wrap("SpatialQuery.closestShoreByWater", () => { + if (!owner.isPlayer()) return null; - const gm = this.game; - const player = owner as Player; + const gm = this.game; + const player = owner as Player; - // Target must be water or shore (land adjacent to water) - if (!gm.isWater(target) && !gm.isShore(target)) return null; + // Target must be water or shore (land adjacent to water) + if (!gm.isWater(target) && !gm.isShore(target)) return null; - const targetComponent = gm.getWaterComponent(target); - if (targetComponent === null) return null; + const targetComponent = gm.getWaterComponent(target); + if (targetComponent === null) return null; - const isValidTile = (t: TileRef) => { - if (!gm.isShore(t) || !gm.isLand(t)) return false; - const tComponent = gm.getWaterComponent(t); - return tComponent === targetComponent; + const isValidTile = (t: TileRef) => { + if (!gm.isShore(t) || !gm.isLand(t)) return false; + const tComponent = gm.getWaterComponent(t); + return tComponent === targetComponent; + }; + + const shores = Array.from(player.borderTiles()).filter(isValidTile); + if (shores.length === 0) return null; + + const path = PathFinding.Water(gm).findPath(shores, target); + if (!path || path.length === 0) return null; + + return DebugSpan.wrap("SpatialQuery.refineStartTile", () => + this.refineStartTile(path, shores, gm), + ); + }); + } + + private refineStartTile( + path: TileRef[], + shores: TileRef[], + gm: Game, + ): TileRef { + const CANDIDATE_RADIUS = 20; + const MIN_WAYPOINT_DIST = 50; + const MAX_WAYPOINT_DIST = 200; + const PADDING = 10; + + if (path.length <= MIN_WAYPOINT_DIST) { + return path[0]; + } + + const bestTile = path[0]; + const map = gm.map(); + + const candidates = shores.filter( + (s) => map.manhattanDist(s, bestTile) <= CANDIDATE_RADIUS, + ); + + if (candidates.length <= 1) return bestTile; + + // Precompute candidate bounds + let candMinX = map.x(candidates[0]); + let candMaxX = candMinX; + let candMinY = map.y(candidates[0]); + let candMaxY = candMinY; + + for (let i = 1; i < candidates.length; i++) { + const sx = map.x(candidates[i]); + const sy = map.y(candidates[i]); + candMinX = Math.min(candMinX, sx); + candMaxX = Math.max(candMaxX, sx); + candMinY = Math.min(candMinY, sy); + candMaxY = Math.max(candMaxY, sy); + } + + // Binary search for furthest waypoint that keeps bounds within limit + let lo = MIN_WAYPOINT_DIST; + let hi = Math.min(MAX_WAYPOINT_DIST, path.length - 1); + let bestWaypointIdx = lo; + + for (let i = 0; i < 5 && lo <= hi; i++) { + const mid = (lo + hi) >> 1; + const wp = path[mid]; + const wpX = map.x(wp); + const wpY = map.y(wp); + + const minX = Math.min(candMinX, wpX) - PADDING; + const maxX = Math.max(candMaxX, wpX) + PADDING; + const minY = Math.min(candMinY, wpY) - PADDING; + const maxY = Math.max(candMaxY, wpY) + PADDING; + + const area = (maxX - minX + 1) * (maxY - minY + 1); + if (area <= REFINE_MAX_SEARCH_AREA) { + bestWaypointIdx = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + const waypoint = path[bestWaypointIdx]; + const wpX = map.x(waypoint); + const wpY = map.y(waypoint); + + const bounds = { + minX: Math.max(0, Math.min(candMinX, wpX) - PADDING), + maxX: Math.min(map.width() - 1, Math.max(candMaxX, wpX) + PADDING), + minY: Math.max(0, Math.min(candMinY, wpY) - PADDING), + maxY: Math.min(map.height() - 1, Math.max(candMaxY, wpY) + PADDING), }; - const shores = Array.from(player.borderTiles()).filter(isValidTile); - if (shores.length === 0) return null; + const boundsArea = + (bounds.maxX - bounds.minX + 1) * (bounds.maxY - bounds.minY + 1); + if (boundsArea > REFINE_MAX_SEARCH_AREA) return bestTile; - const path = PathFinding.Water(gm).findPath(shores, target); - return path?.[0] ?? null; + const refinedPath = this.getBoundedAStar().searchBounded( + candidates, + waypoint, + bounds, + ); + + DebugSpan.set("$candidates", () => candidates); + DebugSpan.set("$refinedPath", () => refinedPath); + DebugSpan.set("$originalBestTile", () => bestTile); + DebugSpan.set("$newBestTile", () => refinedPath?.[0] ?? bestTile); + + return refinedPath?.[0] ?? bestTile; } } diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 63b30c97f..5b4bd0b0c 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -54,6 +54,9 @@ export class SmoothingWaterTransformer implements PathFinder { this.refineEndpoints(smoothed), ); + // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) + smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + return smoothed; } diff --git a/tests/pathfinding/playground/api/spatialQuery.ts b/tests/pathfinding/playground/api/spatialQuery.ts new file mode 100644 index 000000000..df209fd77 --- /dev/null +++ b/tests/pathfinding/playground/api/spatialQuery.ts @@ -0,0 +1,161 @@ +import { TileRef } from "../../../../src/core/game/GameMap.js"; +import { PathFinding } from "../../../../src/core/pathfinding/PathFinder.js"; +import { SpatialQuery } from "../../../../src/core/pathfinding/spatial/SpatialQuery.js"; +import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.js"; +import { loadMap } from "./maps.js"; + +export interface SpatialQueryResult { + selectedShore: [number, number] | null; + path: Array<[number, number]> | null; + shores: Array<[number, number]>; + debug: { + candidates: Array<[number, number]> | null; + refinedPath: Array<[number, number]> | null; + originalBestTile: [number, number] | null; + newBestTile: [number, number] | null; + timings: Record; + }; +} + +/** + * Extract timings from DebugSpan hierarchy + */ +function extractTimings(span: { + name: string; + duration?: number; + children: any[]; +}): Record { + const timings: Record = {}; + + if (span.duration !== undefined) { + timings[span.name] = span.duration; + } + + for (const child of span.children) { + Object.assign(timings, extractTimings(child)); + } + + return timings; +} + +/** + * Convert TileRef to coordinate tuple + */ +function tileToCoord(tile: TileRef, game: any): [number, number] { + return [game.x(tile), game.y(tile)]; +} + +/** + * Convert TileRef array to coordinate array + */ +function tilesToCoords( + tiles: TileRef[] | null | undefined, + game: any, +): Array<[number, number]> | null { + if (!tiles) return null; + return tiles.map((tile) => tileToCoord(tile, game)); +} + +/** + * Compute spatial query for transport ship launch + */ +export async function computeSpatialQuery( + mapName: string, + ownedTiles: number[], + target: [number, number], +): Promise { + const { game } = await loadMap(mapName); + + const targetRef = game.ref(target[0], target[1]) as TileRef; + + // Validate target is water or shore + if (!game.isWater(targetRef) && !game.isShore(targetRef)) { + throw new Error( + `Target (${target[0]}, ${target[1]}) must be water or shore`, + ); + } + + // Convert owned tile indices to TileRefs + const ownedRefs = ownedTiles.map((idx) => { + const x = idx % game.width(); + const y = Math.floor(idx / game.width()); + return game.ref(x, y) as TileRef; + }); + + // Create mock player that returns owned tiles as border tiles + // The SpatialQuery will filter to actual shore tiles + const mockPlayer = { + isPlayer: () => true, + smallID: () => 999, // Arbitrary ID for visualization + borderTiles: function* () { + for (const tile of ownedRefs) { + yield tile; + } + }, + }; + + // Get target water component for filtering + const targetComponent = game.getWaterComponent(targetRef); + + // Pre-compute all valid shore tiles for visualization + const allShores: TileRef[] = []; + for (const tile of ownedRefs) { + if (game.isShore(tile) && game.isLand(tile)) { + const tComponent = game.getWaterComponent(tile); + if (tComponent === targetComponent) { + allShores.push(tile); + } + } + } + + // Enable DebugSpan to capture internal state + DebugSpan.enable(); + + // Run spatial query + const spatialQuery = new SpatialQuery(game); + const selectedShore = spatialQuery.closestShoreByWater( + mockPlayer as any, + targetRef, + ); + + // Get span data + const span = DebugSpan.getLastSpan(); + DebugSpan.disable(); + + // Extract debug info from span + let candidates: TileRef[] | null = null; + let refinedPath: TileRef[] | null = null; + let originalBestTile: TileRef | null = null; + let newBestTile: TileRef | null = null; + + if (span?.data) { + candidates = (span.data.$candidates as TileRef[] | undefined) ?? null; + refinedPath = (span.data.$refinedPath as TileRef[] | undefined) ?? null; + originalBestTile = + (span.data.$originalBestTile as TileRef | undefined) ?? null; + newBestTile = (span.data.$newBestTile as TileRef | undefined) ?? null; + } + + // Compute full path if we have a selected shore + let path: TileRef[] | null = null; + if (selectedShore) { + path = PathFinding.Water(game).findPath(selectedShore, targetRef); + } + + const timings = span ? extractTimings(span) : {}; + + return { + selectedShore: selectedShore ? tileToCoord(selectedShore, game) : null, + path: tilesToCoords(path, game), + shores: allShores.map((t) => tileToCoord(t, game)), + debug: { + candidates: tilesToCoords(candidates, game), + refinedPath: tilesToCoords(refinedPath, game), + originalBestTile: originalBestTile + ? tileToCoord(originalBestTile, game) + : null, + newBestTile: newBestTile ? tileToCoord(newBestTile, game) : null, + timings, + }, + }; +} diff --git a/tests/pathfinding/playground/public/client.js b/tests/pathfinding/playground/public/client.js index 0016ccdb7..c40a5ef9f 100644 --- a/tests/pathfinding/playground/public/client.js +++ b/tests/pathfinding/playground/public/client.js @@ -16,6 +16,11 @@ const state = { isMapLoading: false, // Loading state for map switching isHpaLoading: false, // Separate loading state for HPA* activeRefreshButton: null, // Track which refresh button is spinning + // Transport Ship mode + mode: "pathfinding", // "pathfinding" | "transport" + paintedTiles: new Set(), // Set of tile indices (y * width + x) + brushSize: 5, + transportResult: null, // Result from spatial query }; // Colors for comparison paths @@ -36,6 +41,8 @@ let dragStartX = 0; let dragStartY = 0; let dragStartPanX = 0; let dragStartPanY = 0; +let isPainting = false; +let isErasing = false; let mapCanvas, overlayCanvas, interactiveCanvas; let mapCtx, overlayCtx, interactiveCtx; @@ -203,6 +210,109 @@ function initializeControls() { document.getElementById("clearPoints").addEventListener("click", () => { clearPoints(); }); + + // Mode switch buttons + document.querySelectorAll(".mode-button").forEach((btn) => { + btn.addEventListener("click", () => { + const newMode = btn.dataset.mode; + if (newMode !== state.mode) { + setMode(newMode); + } + }); + }); + + // Transport controls + const brushSizeInput = document.getElementById("brushSize"); + const brushSizeValue = document.getElementById("brushSizeValue"); + brushSizeInput.addEventListener("input", (e) => { + state.brushSize = parseInt(e.target.value); + brushSizeValue.textContent = state.brushSize; + }); + + document.getElementById("clearTerritory").addEventListener("click", () => { + state.paintedTiles.clear(); + state.transportResult = null; + updateTransportInfo(); + renderInteractive(); + }); +} + +// Set application mode +function setMode(newMode) { + state.mode = newMode; + + // Update UI + document.querySelectorAll(".mode-button").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.mode === newMode); + }); + + const transportControls = document.getElementById("transportControls"); + const timingsPanel = document.getElementById("timingsPanel"); + const debugPanel = document.querySelector(".debug-panel"); + + if (newMode === "transport") { + transportControls.style.display = "block"; + timingsPanel.style.top = "280px"; + debugPanel.style.display = "none"; + setStatus("Paint territory, then click water target"); + } else { + transportControls.style.display = "none"; + timingsPanel.style.top = "280px"; + debugPanel.style.display = "flex"; + if (state.startPoint && state.endPoint) { + setStatus("Path computed successfully"); + } else if (state.startPoint) { + setStatus("Click on map to set end point"); + } else { + setStatus("Click on map to set start point"); + } + } + + renderInteractive(); +} + +// Update transport info display +function updateTransportInfo() { + const paintedCount = document.getElementById("paintedCount"); + const shoreCount = document.getElementById("shoreCount"); + + paintedCount.textContent = state.paintedTiles.size; + + // Count shore tiles + let shores = 0; + if (state.mapData) { + for (const idx of state.paintedTiles) { + if (isLandShore(idx)) { + shores++; + } + } + } + shoreCount.textContent = shores; +} + +// Check if tile is a land shore (land adjacent to water) +function isLandShore(tileIdx) { + const x = tileIdx % state.mapWidth; + const y = Math.floor(tileIdx / state.mapWidth); + + // Must be land + if (state.mapData[tileIdx] !== 0) return false; + + // Check 4 neighbors for water + const neighbors = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1], + ]; + + for (const [nx, ny] of neighbors) { + if (nx < 0 || nx >= state.mapWidth || ny < 0 || ny >= state.mapHeight) + continue; + const nIdx = ny * state.mapWidth + nx; + if (state.mapData[nIdx] === 1) return true; + } + return false; } // Helper function to check if mouse is over a start/end point @@ -250,6 +360,20 @@ function schedulePathRecalc() { // If not enough time has passed, skip this call (throttle) } +// Throttled spatial query recalculation (max once per 50ms for heavier computation) +let lastSpatialQueryTime = 0; +function scheduleSpatialQueryRecalc() { + const now = Date.now(); + const timeSinceLastCall = now - lastSpatialQueryTime; + + if (timeSinceLastCall >= 50) { + lastSpatialQueryTime = now; + if (state.endPoint && state.paintedTiles.size > 0) { + requestSpatialQuery(state.endPoint); + } + } +} + // Initialize drag and click controls function initializeDragControls() { const wrapper = document.getElementById("canvasWrapper"); @@ -260,10 +384,46 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; - // Check if clicking on a point + // Transport mode: check for dragging end point first, then painting + if (state.mode === "transport") { + // Check if clicking on end point to drag it + const pointAtMouse = getPointAtPosition(canvasX, canvasY); + if (pointAtMouse === "end") { + draggingPoint = "end"; + wrapper.style.cursor = "move"; + dragStartX = e.clientX; + dragStartY = e.clientY; + return; + } + + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX >= 0 && + tileX < state.mapWidth && + tileY >= 0 && + tileY < state.mapHeight + ) { + const tileIdx = tileY * state.mapWidth + tileX; + const isLand = state.mapData[tileIdx] === 0; + + if (isLand) { + // Start painting (or erasing with ctrl/right-click) + isErasing = e.ctrlKey || e.button === 2; + isPainting = true; + paintAtPosition(tileX, tileY, isErasing); + wrapper.style.cursor = isErasing ? "crosshair" : "pointer"; + return; + } + } + // Fall through to panning if not on land + } + + // Pathfinding mode: check if clicking on a point const pointAtMouse = getPointAtPosition(canvasX, canvasY); - if (pointAtMouse) { + if (pointAtMouse && state.mode === "pathfinding") { // Start dragging the point draggingPoint = pointAtMouse; wrapper.style.cursor = "move"; @@ -284,6 +444,53 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; + // Transport mode: continue painting + if (isPainting && state.mode === "transport") { + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + paintAtPosition(tileX, tileY, isErasing); + return; + } + + // Transport mode: dragging end point + if (draggingPoint === "end" && state.mode === "transport") { + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX >= 0 && + tileX < state.mapWidth && + tileY >= 0 && + tileY < state.mapHeight + ) { + const tileIndex = tileY * state.mapWidth + tileX; + const isWater = state.mapData[tileIndex] === 1; + + if (isWater) { + draggingPointPosition = [tileX, tileY]; + state.endPoint = [tileX, tileY]; + renderInteractive(); + + // Throttled spatial query recomputation + if (state.paintedTiles.size > 0) { + scheduleSpatialQueryRecalc(); + } + } + } + return; + } + + // Transport mode: check hover over end point + if (state.mode === "transport" && !isDragging) { + const pointAtMouse = getPointAtPosition(canvasX, canvasY); + if (pointAtMouse !== hoveredPoint) { + hoveredPoint = pointAtMouse; + renderInteractive(); + wrapper.style.cursor = hoveredPoint ? "move" : "grab"; + } + return; + } + if (draggingPoint) { // Dragging a start/end point - snap to water tile const tileX = Math.floor(canvasX); @@ -395,6 +602,26 @@ function initializeDragControls() { const dx = Math.abs(e.clientX - dragStartX); const dy = Math.abs(e.clientY - dragStartY); + // Transport mode: finish painting + if (isPainting) { + isPainting = false; + isErasing = false; + wrapper.style.cursor = "grab"; + return; + } + + // Transport mode: finish dragging end point + if (draggingPoint === "end" && state.mode === "transport") { + if (state.endPoint && state.paintedTiles.size > 0) { + requestSpatialQuery(state.endPoint); + } + draggingPoint = null; + draggingPointPosition = null; + renderInteractive(); + wrapper.style.cursor = "grab"; + return; + } + if (draggingPoint) { // Finished dragging a point // Request final path update to ensure we have the path for the final position @@ -408,7 +635,11 @@ function initializeDragControls() { updateURLState(); } else if (isDragging && dx < 5 && dy < 5) { // Was panning but didn't move much - treat as click - handleMapClick(e); + if (state.mode === "transport") { + handleTransportClick(e); + } else { + handleMapClick(e); + } } isDragging = false; @@ -418,13 +649,16 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; const pointAtMouse = getPointAtPosition(canvasX, canvasY); - wrapper.style.cursor = pointAtMouse ? "move" : "grab"; + wrapper.style.cursor = + pointAtMouse && state.mode === "pathfinding" ? "move" : "grab"; }); wrapper.addEventListener("mouseleave", () => { isDragging = false; draggingPoint = null; draggingPointPosition = null; + isPainting = false; + isErasing = false; tooltip.classList.remove("visible"); wrapper.style.cursor = "grab"; @@ -437,6 +671,13 @@ function initializeDragControls() { } }); + // Prevent context menu on right-click (for erasing) + wrapper.addEventListener("contextmenu", (e) => { + if (state.mode === "transport") { + e.preventDefault(); + } + }); + wrapper.addEventListener("wheel", (e) => { e.preventDefault(); @@ -446,7 +687,7 @@ function initializeDragControls() { const oldZoom = zoomLevel; const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; - zoomLevel = Math.max(0.1, Math.min(5, zoomLevel * zoomDelta)); + zoomLevel = Math.max(0.1, Math.min(10, zoomLevel * zoomDelta)); panX = mouseX - (mouseX - panX) * (zoomLevel / oldZoom); panY = mouseY - (mouseY - panY) * (zoomLevel / oldZoom); @@ -535,6 +776,155 @@ function clearPoints() { renderInteractive(); } +// Paint tiles in a brush area +function paintAtPosition(centerX, centerY, erase = false) { + const radius = Math.floor(state.brushSize / 2); + let changed = false; + + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = centerX + dx; + const y = centerY + dy; + + if (x < 0 || x >= state.mapWidth || y < 0 || y >= state.mapHeight) + continue; + + const idx = y * state.mapWidth + x; + const isLand = state.mapData[idx] === 0; + + if (!isLand) continue; + + if (erase) { + if (state.paintedTiles.has(idx)) { + state.paintedTiles.delete(idx); + changed = true; + } + } else { + if (!state.paintedTiles.has(idx)) { + state.paintedTiles.add(idx); + changed = true; + } + } + } + } + + if (changed) { + updateTransportInfo(); + renderInteractive(); + } +} + +// Handle clicks in transport mode +function handleTransportClick(e) { + if (!state.currentMap || state.isMapLoading) return; + + const wrapper = document.getElementById("canvasWrapper"); + const rect = wrapper.getBoundingClientRect(); + + const canvasX = (e.clientX - rect.left - panX) / zoomLevel; + const canvasY = (e.clientY - rect.top - panY) / zoomLevel; + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX < 0 || + tileX >= state.mapWidth || + tileY < 0 || + tileY >= state.mapHeight + ) { + return; + } + + const idx = tileY * state.mapWidth + tileX; + const isWater = state.mapData[idx] === 1; + + if (!isWater) { + return; + } + + // Clicked on water - run spatial query + if (state.paintedTiles.size === 0) { + showError("Paint some territory first"); + return; + } + + requestSpatialQuery([tileX, tileY]); +} + +// Request spatial query computation +async function requestSpatialQuery(target) { + setStatus("Computing spatial query...", true); + + try { + // Only send shore tiles (land adjacent to water) - much smaller payload + const ownedTiles = Array.from(state.paintedTiles).filter((idx) => + isLandShore(idx), + ); + + const response = await fetch("/api/spatial-query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + map: state.currentMap, + ownedTiles, + target, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Spatial query failed"); + } + + const result = await response.json(); + state.transportResult = result; + state.endPoint = target; + + renderInteractive(); + updateTransportTimings(result); + + if (result.selectedShore) { + setStatus( + `Shore selected: (${result.selectedShore[0]}, ${result.selectedShore[1]})`, + ); + } else { + setStatus("No valid shore found"); + } + } catch (error) { + showError(`Spatial query failed: ${error.message}`); + } +} + +// Update timings panel for transport mode +function updateTransportTimings(result) { + const hpaTimeEl = document.getElementById("hpaTime"); + const hpaTilesEl = document.getElementById("hpaTiles"); + + if (result.path) { + hpaTilesEl.textContent = `- ${result.path.length} tiles`; + } else { + hpaTilesEl.textContent = ""; + } + + const totalTime = + result.debug?.timings?.["SpatialQuery.closestShoreByWater"] ?? 0; + if (totalTime > 0) { + hpaTimeEl.textContent = `${totalTime.toFixed(2)}ms`; + hpaTimeEl.classList.remove("faded"); + } else { + hpaTimeEl.textContent = "0.00ms"; + hpaTimeEl.classList.add("faded"); + } + + // Hide pathfinding-specific timing breakdown in transport mode + document.getElementById("timingEarlyExit").style.display = "none"; + document.getElementById("timingFindNodes").style.display = "none"; + document.getElementById("timingAbstractPath").style.display = "none"; + document.getElementById("timingInitialPath").style.display = "none"; + document.getElementById("timingSmoothPath").style.display = "none"; + document.getElementById("comparisonsSection").style.display = "none"; +} + // Update transform for pan/zoom function updateTransform() { const transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`; @@ -1164,6 +1554,135 @@ function mapToScreen(mapX, mapY) { }; } +// Render transport mode elements +function renderTransportMode() { + const tileSize = Math.max(1, zoomLevel); + + // Draw painted territory + if (state.paintedTiles.size > 0) { + interactiveCtx.fillStyle = "rgba(66, 135, 245, 0.5)"; + + for (const idx of state.paintedTiles) { + const x = idx % state.mapWidth; + const y = Math.floor(idx / state.mapWidth); + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw all shore tiles (dark blue squares) + if (state.transportResult && state.transportResult.shores) { + interactiveCtx.fillStyle = "#2a4a6a"; + + for (const [x, y] of state.transportResult.shores) { + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw refinement candidates (muted yellow/gold squares) + if (state.transportResult?.debug?.candidates) { + interactiveCtx.fillStyle = "rgba(200, 170, 80, 0.7)"; + + for (const [x, y] of state.transportResult.debug.candidates) { + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw refined path (magenta) + if (state.transportResult?.debug?.refinedPath) { + interactiveCtx.strokeStyle = "#ff00ff"; + interactiveCtx.lineWidth = Math.max(1, zoomLevel * 0.8); + interactiveCtx.lineCap = "round"; + interactiveCtx.lineJoin = "round"; + interactiveCtx.beginPath(); + + for (let i = 0; i < state.transportResult.debug.refinedPath.length; i++) { + const [x, y] = state.transportResult.debug.refinedPath[i]; + const screen = mapToScreen(x + 0.5, y + 0.5); + if (i === 0) { + interactiveCtx.moveTo(screen.x, screen.y); + } else { + interactiveCtx.lineTo(screen.x, screen.y); + } + } + interactiveCtx.stroke(); + } + + // Draw full path (cyan) + if (state.transportResult && state.transportResult.path) { + interactiveCtx.strokeStyle = "#00ffff"; + interactiveCtx.lineWidth = Math.max(1, zoomLevel); + interactiveCtx.lineCap = "round"; + interactiveCtx.lineJoin = "round"; + interactiveCtx.beginPath(); + + for (let i = 0; i < state.transportResult.path.length; i++) { + const [x, y] = state.transportResult.path[i]; + const screen = mapToScreen(x + 0.5, y + 0.5); + if (i === 0) { + interactiveCtx.moveTo(screen.x, screen.y); + } else { + interactiveCtx.lineTo(screen.x, screen.y); + } + } + interactiveCtx.stroke(); + } + + // Draw original best tile (orange square) if different from new best + if (state.transportResult?.debug?.originalBestTile) { + const [ox, oy] = state.transportResult.debug.originalBestTile; + const newBest = state.transportResult.debug.newBestTile; + + // Only show if different from new best + if (!newBest || ox !== newBest[0] || oy !== newBest[1]) { + const screen = mapToScreen(ox, oy); + interactiveCtx.fillStyle = "#ff8800"; + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw selected shore (green square) + if (state.transportResult && state.transportResult.selectedShore) { + const [sx, sy] = state.transportResult.selectedShore; + const screen = mapToScreen(sx, sy); + interactiveCtx.fillStyle = "#44ff44"; + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + + // Draw target point (red circle, matching pathfinding mode style) + if (state.endPoint) { + const markerSize = Math.max(4, 3 * zoomLevel); + let mapX, mapY; + if (draggingPoint === "end" && draggingPointPosition) { + mapX = draggingPointPosition[0] + 0.5; + mapY = draggingPointPosition[1] + 0.5; + } else { + mapX = state.endPoint[0] + 0.5; + mapY = state.endPoint[1] + 0.5; + } + + const screen = mapToScreen(mapX, mapY); + + // Highlight ring if hovered + if (hoveredPoint === "end") { + interactiveCtx.strokeStyle = "#ff4444"; + interactiveCtx.lineWidth = Math.max(2, zoomLevel * 0.5); + interactiveCtx.globalAlpha = 0.5; + interactiveCtx.beginPath(); + interactiveCtx.arc(screen.x, screen.y, markerSize + 3, 0, Math.PI * 2); + interactiveCtx.stroke(); + interactiveCtx.globalAlpha = 1.0; + } + + interactiveCtx.fillStyle = "#ff4444"; + interactiveCtx.beginPath(); + interactiveCtx.arc(screen.x, screen.y, markerSize, 0, Math.PI * 2); + interactiveCtx.fill(); + } +} + // Render truly interactive/dynamic overlay (paths, points, highlights) at screen coordinates function renderInteractive() { // Clear viewport-sized canvas (super fast!) @@ -1178,6 +1697,12 @@ function renderInteractive() { const markerSize = Math.max(4, 3 * zoomLevel); + // Transport mode: render painted territory and results + if (state.mode === "transport") { + renderTransportMode(); + return; + } + // Check what to show const showUsedNodes = document.getElementById("showUsedNodes").dataset.active === "true"; diff --git a/tests/pathfinding/playground/public/index.html b/tests/pathfinding/playground/public/index.html index f03d041d3..f4dcbaf79 100644 --- a/tests/pathfinding/playground/public/index.html +++ b/tests/pathfinding/playground/public/index.html @@ -118,11 +118,88 @@ +
+ + +
+
Select a scenario to begin
+ + +
@@ -149,7 +226,7 @@
- + 1.0x