Files
OpenFrontIO/src/core/pathfinding/CoarseToFineWaterPath.ts
T
scamiv e08acdf09c Add coarse-to-fine pathfinding for water navigation
- Introduced a new `CoarseToFineWaterPath` module to enhance pathfinding efficiency for boats by utilizing a coarse map to guide fine path searches.
- Implemented a two-stage pathfinding approach: a coarse search on a low-resolution map followed by a refined search on the full-resolution map.
- Updated `TransportShipExecution` and `TransportShipUtils` to leverage the new coarse-to-fine pathfinding method, improving routing decisions and handling of retreat paths.
- Added tests to validate the new pathfinding functionality and ensure robustness in various scenarios.
2025-12-27 16:23:14 +01:00

274 lines
7.2 KiB
TypeScript

import { GameMap, TileRef } from "../game/GameMap";
import {
MultiSourceAnyTargetBFS,
MultiSourceAnyTargetBFSOptions,
MultiSourceAnyTargetBFSResult,
} from "./MultiSourceAnyTargetBFS";
export type CoarseToFineWaterPathOptions = {
/**
* Corridor radius in coarse cells (Chebyshev) around the coarse path.
* Larger = safer (less likely to miss due to minimap tearing), smaller = faster.
*/
corridorRadius?: number;
/**
* How many corridor attempts to try before falling back to unrestricted fine BFS.
*/
maxAttempts?: number;
/**
* Multiply radius each attempt (e.g. 2 turns 2 -> 4 -> 8 ...).
*/
radiusMultiplier?: number;
};
const bfsCache = new WeakMap<GameMap, MultiSourceAnyTargetBFS>();
function getBfs(gm: GameMap): MultiSourceAnyTargetBFS {
const cached = bfsCache.get(gm);
if (cached) return cached;
const bfs = new MultiSourceAnyTargetBFS(gm.width() * gm.height());
bfsCache.set(gm, bfs);
return bfs;
}
type FineToCoarseMapping = {
coarse: GameMap;
fineToCoarse: Uint32Array;
scaleX: number;
scaleY: number;
};
const fineToCoarseCache = new WeakMap<GameMap, FineToCoarseMapping>();
function getFineToCoarseMapping(
fine: GameMap,
coarse: GameMap,
): FineToCoarseMapping | null {
const cached = fineToCoarseCache.get(fine);
if (cached && cached.coarse === coarse) return cached;
const fw = fine.width();
const fh = fine.height();
const cw = coarse.width();
const ch = coarse.height();
if (cw <= 0 || ch <= 0) return null;
if (fw % cw !== 0 || fh % ch !== 0) return null;
const scaleX = fw / cw;
const scaleY = fh / ch;
if (!Number.isInteger(scaleX) || !Number.isInteger(scaleY)) return null;
if (scaleX <= 0 || scaleY <= 0) return null;
const fineToCoarse = new Uint32Array(fw * fh);
// Fill by coarse cell rectangles to avoid division in the inner loop.
for (let cy = 0; cy < ch; cy++) {
const fineYStart = cy * scaleY;
const fineYEnd = fineYStart + scaleY;
for (let cx = 0; cx < cw; cx++) {
const coarseRef = cy * cw + cx;
const fineXStart = cx * scaleX;
const fineXEnd = fineXStart + scaleX;
for (let y = fineYStart; y < fineYEnd; y++) {
let fineRef = y * fw + fineXStart;
for (let x = fineXStart; x < fineXEnd; x++) {
fineToCoarse[fineRef++] = coarseRef;
}
}
}
}
const entry: FineToCoarseMapping = { coarse, fineToCoarse, scaleX, scaleY };
fineToCoarseCache.set(fine, entry);
return entry;
}
type StampSet = { stamp: number; data: Uint32Array };
const stampSetCache = new WeakMap<GameMap, StampSet>();
function getStampSet(gm: GameMap): StampSet {
const cached = stampSetCache.get(gm);
if (cached) return cached;
const set: StampSet = { stamp: 1, data: new Uint32Array(gm.width() * gm.height()) };
stampSetCache.set(gm, set);
return set;
}
function nextStamp(set: StampSet): number {
const next = (set.stamp + 1) >>> 0;
set.stamp = next === 0 ? 1 : next;
return set.stamp;
}
function dedupeByStamp(
tiles: readonly TileRef[],
stampSet: StampSet,
stamp: number,
): TileRef[] {
const out: TileRef[] = [];
for (const t of tiles) {
if (t < 0 || t >= stampSet.data.length) continue;
if (stampSet.data[t] === stamp) continue;
stampSet.data[t] = stamp;
out.push(t);
}
return out;
}
function markCoarseCorridor(
coarseWidth: number,
coarseHeight: number,
corridorStamp: Uint32Array,
stamp: number,
coarsePath: readonly TileRef[],
radius: number,
) {
for (const ref of coarsePath) {
const x = ref % coarseWidth;
const y = Math.floor(ref / coarseWidth);
const y0 = Math.max(0, y - radius);
const y1 = Math.min(coarseHeight - 1, y + radius);
const x0 = Math.max(0, x - radius);
const x1 = Math.min(coarseWidth - 1, x + radius);
for (let yy = y0; yy <= y1; yy++) {
const row = yy * coarseWidth;
for (let xx = x0; xx <= x1; xx++) {
corridorStamp[row + xx] = stamp;
}
}
}
}
export function findWaterPathFromSeedsCoarseToFine(
fineMap: GameMap,
seedNodes: readonly TileRef[],
seedOrigins: readonly TileRef[],
targets: readonly TileRef[],
bfsOpts: MultiSourceAnyTargetBFSOptions = {},
coarseMap: GameMap | null = null,
coarseToFine: CoarseToFineWaterPathOptions = {},
): MultiSourceAnyTargetBFSResult | null {
const fineBfs = getBfs(fineMap);
if (!coarseMap) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
const mapping = getFineToCoarseMapping(fineMap, coarseMap);
if (mapping === null) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
const coarseWidth = coarseMap.width();
const coarseHeight = coarseMap.height();
const coarseStampSet = getStampSet(coarseMap);
const coarseSeedStamp = nextStamp(coarseStampSet);
const coarseTargetStamp = nextStamp(coarseStampSet);
const coarseSeedsRaw: TileRef[] = [];
for (const s of seedNodes) {
if (s < 0 || s >= mapping.fineToCoarse.length) continue;
coarseSeedsRaw.push(mapping.fineToCoarse[s] as TileRef);
}
const coarseTargetsRaw: TileRef[] = [];
for (const t of targets) {
if (t < 0 || t >= mapping.fineToCoarse.length) continue;
coarseTargetsRaw.push(mapping.fineToCoarse[t] as TileRef);
}
const coarseSeeds = dedupeByStamp(
coarseSeedsRaw,
coarseStampSet,
coarseSeedStamp,
);
const coarseTargets = dedupeByStamp(
coarseTargetsRaw,
coarseStampSet,
coarseTargetStamp,
);
if (coarseSeeds.length === 0 || coarseTargets.length === 0) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
// Coarse solve (cheap) to define a corridor.
const coarseBfs = getBfs(coarseMap);
const coarseResult = coarseBfs.findWaterPath(
coarseMap,
coarseSeeds,
coarseTargets,
bfsOpts,
);
if (coarseResult === null) {
// Safe fallback: if the coarse map is conservative, we might still have a fine path.
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
const corridorRadius0 = Math.max(0, coarseToFine.corridorRadius ?? 2);
const maxAttempts = Math.max(1, coarseToFine.maxAttempts ?? 2);
const radiusMultiplier = Math.max(1, coarseToFine.radiusMultiplier ?? 2);
const corridorSet = getStampSet(coarseMap);
for (let attempt = 0, radius = corridorRadius0; attempt < maxAttempts; attempt++) {
const corridorStamp = nextStamp(corridorSet);
markCoarseCorridor(
coarseWidth,
coarseHeight,
corridorSet.data,
corridorStamp,
coarseResult.path,
radius,
);
const refined = fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
{
...bfsOpts,
allowedMask: {
tileToRegion: mapping.fineToCoarse,
regionStamp: corridorSet.data,
stamp: corridorStamp,
},
},
);
if (refined !== null) return refined;
radius *= radiusMultiplier;
}
// Final fallback: unrestricted fine BFS.
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}