Optimize here and there

This commit is contained in:
Arkadiusz Sygulski
2026-01-14 22:20:14 +01:00
parent 9ca1f64972
commit 7813d2e7dd
11 changed files with 1130 additions and 70 deletions
@@ -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<number> {
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 {
@@ -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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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);
}
@@ -12,6 +12,7 @@ export class AStarWaterHierarchical implements PathFinder<number> {
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<number> {
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<number> {
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<number> {
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);
@@ -94,6 +94,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 +104,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 +116,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 +126,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 +137,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 +155,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;
}
+102 -22
View File
@@ -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,30 +79,34 @@ 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 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;
const path = PathFinding.Water(gm).findPath(shores, target);
if (!path || path.length === 0) return null;
return this.refineStartTile(path, shores, gm);
return DebugSpan.wrap("SpatialQuery.refineStartTile", () =>
this.refineStartTile(path, shores, gm),
);
});
}
private refineStartTile(
@@ -95,8 +114,10 @@ export class SpatialQuery {
shores: TileRef[],
gm: Game,
): TileRef {
const CANDIDATE_RADIUS = 10;
const WAYPOINT_DIST = 20;
const CANDIDATE_RADIUS = 20;
const MIN_WAYPOINT_DIST = 50;
const MAX_WAYPOINT_DIST = 200;
const PADDING = 10;
const bestTile = path[0];
const map = gm.map();
@@ -107,13 +128,72 @@ export class SpatialQuery {
if (candidates.length <= 1) return bestTile;
const waypointIdx = Math.min(WAYPOINT_DIST, path.length - 1);
const waypoint = path[waypointIdx];
// Precompute candidate bounds
let candMinX = map.x(candidates[0]);
let candMaxX = candMinX;
let candMinY = map.y(candidates[0]);
let candMaxY = candMinY;
const refinedPath = PathFinding.WaterSimple(gm).findPath(
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 boundsArea =
(bounds.maxX - bounds.minX + 1) * (bounds.maxY - bounds.minY + 1);
if (boundsArea > REFINE_MAX_SEARCH_AREA) return bestTile;
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;
}
}
@@ -54,6 +54,9 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
this.refineEndpoints(smoothed),
);
// Pass 3: LOS smoothing again (refinement may create new shortcut opportunities)
smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed));
return smoothed;
}
@@ -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<string, number>;
};
}
/**
* Extract timings from DebugSpan hierarchy
*/
function extractTimings(span: {
name: string;
duration?: number;
children: any[];
}): Record<string, number> {
const timings: Record<string, number> = {};
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<SpatialQueryResult> {
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,
},
};
}
+529 -5
View File
@@ -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,154 @@ 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?.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 +1553,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 +1696,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";
+78 -1
View File
@@ -118,11 +118,88 @@
</select>
</div>
<div class="mode-switch">
<button
class="mode-button active"
id="modePathfinding"
data-mode="pathfinding"
>
Pathfinding
</button>
<button class="mode-button" id="modeTransport" data-mode="transport">
Transport Ship
</button>
</div>
<div class="status-section">
<span id="status">Select a scenario to begin</span>
</div>
</div>
<!-- Transport Ship controls (only visible in transport mode) -->
<div
class="transport-controls"
id="transportControls"
style="display: none"
>
<div class="transport-legend">
<div class="transport-legend-item">
<div
class="transport-legend-color"
style="background: rgba(66, 135, 245, 0.7)"
></div>
<span>Territory</span>
</div>
<div class="transport-legend-item">
<div class="transport-legend-color" style="background: #2a4a6a"></div>
<span>Shores</span>
</div>
<div class="transport-legend-item">
<div
class="transport-legend-color"
style="background: rgba(200, 170, 80, 0.9)"
></div>
<span>Candidates</span>
</div>
<div class="transport-legend-item">
<div class="transport-legend-color" style="background: #ff8800"></div>
<span>Original</span>
</div>
<div class="transport-legend-item">
<div class="transport-legend-color" style="background: #44ff44"></div>
<span>Selected</span>
</div>
<div class="transport-legend-item">
<div
class="transport-legend-color"
style="background: #00ffff; height: 2px"
></div>
<span>Path</span>
</div>
</div>
<div class="transport-control-row">
<label>Brush Size:</label>
<input
type="range"
id="brushSize"
min="1"
max="20"
step="1"
value="5"
/>
<span id="brushSizeValue">5</span>
</div>
<div class="transport-control-row">
<button id="clearTerritory" class="clear-button">
Clear Territory
</button>
</div>
<div class="transport-info">
<span>Painted tiles: <strong id="paintedCount">0</strong></span>
<span>Shores: <strong id="shoreCount">0</strong></span>
</div>
</div>
<!-- Debug controls panel (left) -->
<div class="debug-panel">
<div class="debug-panel-row">
@@ -149,7 +226,7 @@
<!-- 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" />
<input type="range" id="zoom" min="0.1" max="10" step="0.1" value="1" />
<span id="zoomValue">1.0x</span>
</div>
<button class="toggle-button" id="showColoredMap" data-active="false">
+119 -1
View File
@@ -369,7 +369,7 @@ canvas {
/* Timings panel (left side) */
.timings-panel {
position: fixed;
top: 250px;
top: 280px;
left: 20px;
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
@@ -858,3 +858,121 @@ button:disabled {
transform: none;
}
}
/* Mode switch */
.mode-switch {
display: flex;
gap: 0;
margin-bottom: 15px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #404040;
}
.mode-button {
flex: 1;
background: #1a1a1a;
color: #888;
border: none;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border-radius: 0;
}
.mode-button:hover {
background: #2a2a2a;
color: #aaa;
}
.mode-button.active {
background: #0066cc;
color: white;
}
.mode-button.active:hover {
background: #0052a3;
}
/* Transport Ship controls - positioned at bottom left like debug panel */
.transport-controls {
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);
min-width: 220px;
}
.transport-control-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.transport-control-row label {
font-size: 14px;
color: #aaa;
min-width: 80px;
}
.transport-control-row input[type="range"] {
flex: 1;
}
.transport-control-row span {
font-size: 14px;
color: #e0e0e0;
min-width: 24px;
text-align: right;
}
.transport-control-row .clear-button {
width: 100%;
}
.transport-info {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid #404040;
font-size: 13px;
color: #888;
}
.transport-info strong {
color: #e0e0e0;
}
.transport-legend {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #404040;
}
.transport-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #aaa;
}
.transport-legend-color {
width: 14px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
+53
View File
@@ -8,6 +8,7 @@ import {
listMaps,
} from "./api/maps.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
import { computeSpatialQuery } from "./api/spatialQuery.js";
const app = express();
const PORT = process.env.PORT ?? 5555;
@@ -156,6 +157,58 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
}
});
/**
* POST /api/spatial-query
* Compute spatial query for transport ship (closestShoreByWater)
*
* Request body:
* {
* map: string,
* ownedTiles: number[], // Array of tile indices (y * width + x)
* target: [x, y]
* }
*/
app.post("/api/spatial-query", async (req: Request, res: Response) => {
try {
const { map, ownedTiles, target } = req.body;
if (!map || !ownedTiles || !target) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, ownedTiles, target",
});
}
if (!Array.isArray(ownedTiles)) {
return res.status(400).json({
error: "Invalid ownedTiles",
message: "ownedTiles must be an array of tile indices",
});
}
if (!Array.isArray(target) || target.length !== 2) {
return res.status(400).json({
error: "Invalid target",
message: "target must be [x, y] coordinate array",
});
}
const result = await computeSpatialQuery(
map,
ownedTiles,
target as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing spatial query:", error);
res.status(500).json({
error: "Failed to compute spatial query",
message: error instanceof Error ? error.message : String(error),
});
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)