mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 10:05:03 +00:00
Optimize here and there
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user