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;
}