mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 16:16:50 +00:00
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.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Coarse-to-fine pathfinding (boats) — notes
|
||||
|
||||
## Why
|
||||
|
||||
Full-res water BFS is optimal and simple, but the “ocean case” can still expand a lot of tiles.
|
||||
Coarse-to-fine is the next lever: do a cheap solve on a low-res map to guide / bound the expensive solve.
|
||||
|
||||
## Do we already have low-res maps?
|
||||
|
||||
Yes. The terrain loader already ships multiple resolutions per map:
|
||||
|
||||
- `manifest.map` + `map.bin` (full res)
|
||||
- `manifest.map4x` + `map4x.bin` (coarser)
|
||||
- `manifest.map16x` + `map16x.bin` (even coarser)
|
||||
|
||||
At runtime we already load both:
|
||||
|
||||
- `gameMap`: full res for normal games (or `map4x` for compact games)
|
||||
- `miniGameMap`: lower res (`map4x` for normal games, or `map16x` for compact games)
|
||||
|
||||
So we can prototype coarse-to-fine without extending mapgen first.
|
||||
|
||||
## Core idea (don’t overthink it)
|
||||
|
||||
Stage 1 (coarse):
|
||||
- Run the same multi-source/any-target search on `miniGameMap` (BFS, water-only, king-moves if desired).
|
||||
- Result is a coarse path (or just a coarse distance field).
|
||||
|
||||
Stage 2 (refine):
|
||||
- Run full-res BFS on `gameMap`, but **restricted** by what stage 1 learned (a “corridor”) *or* guided by a coarse heuristic.
|
||||
|
||||
Important: the coarse map is an approximation. It must never be allowed to make the final path invalid.
|
||||
If the refine stage fails inside the corridor, fall back to full-res BFS.
|
||||
|
||||
## Option A: Coarse corridor (usually the biggest win)
|
||||
|
||||
1) Map fine tiles → coarse cells by integer scaling:
|
||||
- `scaleX = gameMap.width / miniGameMap.width`
|
||||
- `scaleY = gameMap.height / miniGameMap.height`
|
||||
2) Solve on coarse, get a coarse cell path.
|
||||
3) Inflate that path into a corridor:
|
||||
- include all coarse cells within radius `r` of the coarse path (e.g. `r = 1..3` )(Manhattan or Chebyshev radius depending on move rules)
|
||||
4) Refine on full-res with a fast mask:
|
||||
- `passableFine(tile) = gm.isWater(tile) && corridorMask[coarseOf(tile)]`
|
||||
5) If no path found, retry without the corridor (or inflate `r` and retry once).
|
||||
|
||||
Notes:
|
||||
- If the low-res generation is “optimistic” (water if any child tile is water), the coarse path can cut across land.
|
||||
Inflation + fallback is what keeps this safe.
|
||||
|
||||
## Option B: Coarse heuristic for A*
|
||||
|
||||
If we ever move from BFS → A* on full-res, a cheap heuristic is:
|
||||
|
||||
- Precompute `coarseDist[coarseCell]` by BFS on `miniGameMap` seeded from coarse targets.
|
||||
- Use `h(tile) = coarseDist[coarseOf(tile)] * min(scaleX, scaleY)`
|
||||
|
||||
If the coarse map is “more passable” than the fine map (typical for minimaps), `coarseDist` tends to **underestimate**,
|
||||
which is admissible (safe) but not always very tight.
|
||||
|
||||
## Where component IDs fit
|
||||
|
||||
Water-component IDs are still a free early reject:
|
||||
|
||||
- `WaterComponents.ts` already precomputes IDs per `GameMap` instance.
|
||||
- Do the same check on `miniGameMap` if useful, but full-res component filtering already prevents the worst “wrong ocean” searches.
|
||||
|
||||
## Practical next steps (incremental)
|
||||
|
||||
1) Add a coarse route helper that mirrors the existing API but runs on `miniGameMap`.
|
||||
2) Implement corridor masking + refine fallback as a generic helper (so transport/trade/warship can all share it).
|
||||
3) Measure: expansions + ms, before/after, on worst-case oceans.
|
||||
4) Only then decide if mapgen needs a better “navmap” (e.g. conservative water, coastline preservation, etc.).
|
||||
@@ -13,7 +13,9 @@ import { TileRef } from "../game/GameMap";
|
||||
import {
|
||||
bestTransportShipRoute,
|
||||
boatPathFromTileToShore,
|
||||
candidateShoreTiles,
|
||||
} from "../game/TransportShipUtils";
|
||||
import { getWaterComponentIds } from "../pathfinding/WaterComponents";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
@@ -168,6 +170,14 @@ export class TransportShipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// In team games, disconnected players can have their ships captured by teammates on conquest.
|
||||
// Until ownership transfers, avoid progressing the ship (prevents it from completing an attack
|
||||
// while the original owner is already disconnected).
|
||||
if (this.originalOwner.isDisconnected() && this.boat.owner() === this.originalOwner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks - this.lastMove < this.ticksPerMove) {
|
||||
return;
|
||||
}
|
||||
@@ -186,15 +196,67 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.boat.retreating()) {
|
||||
const curr = this.boat.tile();
|
||||
if (curr === null) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure retreat source is still valid for (new) owner
|
||||
if (this.mg.owner(this.src!) !== this.attacker) {
|
||||
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
||||
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
||||
if (newSrc === false) {
|
||||
this.src = null;
|
||||
} else {
|
||||
this.src = newSrc;
|
||||
if (
|
||||
this.src === null ||
|
||||
!this.mg.isValidRef(this.src) ||
|
||||
this.mg.owner(this.src) !== this.attacker ||
|
||||
!this.mg.isShore(this.src)
|
||||
) {
|
||||
// Pick a reachable shore tile owned by the new owner.
|
||||
const ids = getWaterComponentIds(this.mg);
|
||||
const currComps = new Set<number>();
|
||||
if (this.mg.isWater(curr)) {
|
||||
const id = ids[curr] ?? 0;
|
||||
if (id !== 0) currComps.add(id);
|
||||
} else if (this.mg.isShore(curr)) {
|
||||
for (const n of this.mg.neighbors(curr)) {
|
||||
if (!this.mg.isWater(n)) continue;
|
||||
const id = ids[n] ?? 0;
|
||||
if (id !== 0) currComps.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
let newSrc: TileRef | null = null;
|
||||
if (currComps.size !== 0) {
|
||||
const candidates = candidateShoreTiles(this.mg, this.attacker, curr);
|
||||
for (const shore of candidates) {
|
||||
if (shore === curr || shore === this.ref) continue;
|
||||
for (const n of this.mg.neighbors(shore)) {
|
||||
if (!this.mg.isWater(n)) continue;
|
||||
const id = ids[n] ?? 0;
|
||||
if (id !== 0 && currComps.has(id)) {
|
||||
newSrc = shore;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newSrc !== null) break;
|
||||
}
|
||||
|
||||
if (newSrc === null) {
|
||||
for (const t of this.attacker.borderTiles()) {
|
||||
if (!this.mg.isShore(t)) continue;
|
||||
if (t === curr || t === this.ref) continue;
|
||||
for (const n of this.mg.neighbors(t)) {
|
||||
if (!this.mg.isWater(n)) continue;
|
||||
const id = ids[n] ?? 0;
|
||||
if (id !== 0 && currComps.has(id)) {
|
||||
newSrc = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newSrc !== null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.src = newSrc;
|
||||
}
|
||||
|
||||
if (this.src === null) {
|
||||
@@ -215,18 +277,14 @@ export class TransportShipExecution implements Execution {
|
||||
// Retreat is just the existing forward path in reverse (hot-path friendly).
|
||||
// Fallback to a recompute only if we can't safely reverse (e.g. path invalidated).
|
||||
if (!this.usingReverseRetreatPath) {
|
||||
const curr = this.boat.tile();
|
||||
const idx = curr === null ? -1 : this.forwardPath.indexOf(curr);
|
||||
if (idx >= 0) {
|
||||
const originalSrc = this.forwardPath[0] ?? null;
|
||||
const idx = this.forwardPath.indexOf(curr);
|
||||
if (originalSrc !== null && this.dst === originalSrc && idx >= 0) {
|
||||
this.path = this.forwardPath.slice(0, idx + 1).reverse();
|
||||
this.pathIndex = 0;
|
||||
this.usingReverseRetreatPath = true;
|
||||
} else {
|
||||
const retreatPath = boatPathFromTileToShore(
|
||||
this.mg,
|
||||
curr!,
|
||||
this.dst,
|
||||
);
|
||||
const retreatPath = boatPathFromTileToShore(this.mg, curr, this.dst);
|
||||
if (retreatPath !== null) {
|
||||
this.path = retreatPath;
|
||||
this.pathIndex = 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MultiSourceAnyTargetBFS } from "../pathfinding/MultiSourceAnyTargetBFS";
|
||||
import { findWaterPathFromSeedsCoarseToFine } from "../pathfinding/CoarseToFineWaterPath";
|
||||
import { getWaterComponentIds } from "../pathfinding/WaterComponents";
|
||||
import { Game, Player, UnitType } from "./Game";
|
||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
|
||||
@@ -9,15 +9,10 @@ type BoatRoute = {
|
||||
path: TileRef[];
|
||||
};
|
||||
|
||||
let boatBfs: MultiSourceAnyTargetBFS | null = null;
|
||||
let boatBfsNumTiles = 0;
|
||||
function getBoatBfs(gm: GameMap): MultiSourceAnyTargetBFS {
|
||||
const numTiles = gm.width() * gm.height();
|
||||
if (boatBfs === null || boatBfsNumTiles !== numTiles) {
|
||||
boatBfs = new MultiSourceAnyTargetBFS(numTiles);
|
||||
boatBfsNumTiles = numTiles;
|
||||
}
|
||||
return boatBfs;
|
||||
function miniMapOrNull(gm: GameMap): GameMap | null {
|
||||
const mm = (gm as any).miniMap;
|
||||
if (typeof mm === "function") return mm.call(gm) as GameMap;
|
||||
return null;
|
||||
}
|
||||
|
||||
function insertTopK(
|
||||
@@ -88,6 +83,13 @@ function shoreTargetsNearClick(
|
||||
|
||||
// Default behavior: scan a bounding box near the click for candidate shore tiles.
|
||||
// (Previously, player targets used all border tiles, which could pick very distant shores.)
|
||||
// TerraNullius targets keep the classic "find closest unowned shore within a radius" behavior
|
||||
// so inland clicks still resolve to a nearby shoreline.
|
||||
if (!targetOwner.isPlayer()) {
|
||||
const tn = closestShoreTN(gm, click, 50);
|
||||
return tn === null ? [] : [tn];
|
||||
}
|
||||
|
||||
const top: { tile: TileRef; dist: number }[] = [];
|
||||
const cx = gm.x(click);
|
||||
const cy = gm.y(click);
|
||||
@@ -197,9 +199,8 @@ export function boatPathFromTileToShore(
|
||||
const targetWater = targetWaterAll.filter((t) => seedComps.has(ids[t] ?? 0));
|
||||
if (targetWater.length === 0) return null;
|
||||
|
||||
const bfs = getBoatBfs(gm);
|
||||
const startTime = performance.now();
|
||||
const result = bfs.findWaterPathFromSeeds(
|
||||
const result = findWaterPathFromSeedsCoarseToFine(
|
||||
gm,
|
||||
seedNodesFiltered,
|
||||
seedOriginsFiltered,
|
||||
@@ -208,6 +209,7 @@ export function boatPathFromTileToShore(
|
||||
kingMoves: true,
|
||||
noCornerCutting: true,
|
||||
},
|
||||
miniMapOrNull(gm),
|
||||
);
|
||||
const duration = performance.now() - startTime;
|
||||
if (result === null) return null;
|
||||
@@ -269,12 +271,18 @@ export function boatPathFromTileToWater(
|
||||
}
|
||||
if (seedNodesFiltered.length === 0) return null;
|
||||
|
||||
const bfs = getBoatBfs(gm);
|
||||
const startTime = performance.now();
|
||||
const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, [dstWater], {
|
||||
kingMoves: true,
|
||||
noCornerCutting: true,
|
||||
});
|
||||
const result = findWaterPathFromSeedsCoarseToFine(
|
||||
gm,
|
||||
seedNodesFiltered,
|
||||
seedOriginsFiltered,
|
||||
[dstWater],
|
||||
{
|
||||
kingMoves: true,
|
||||
noCornerCutting: true,
|
||||
},
|
||||
miniMapOrNull(gm),
|
||||
);
|
||||
const duration = performance.now() - startTime;
|
||||
if (result === null) return null;
|
||||
|
||||
@@ -324,21 +332,32 @@ export function bestTransportShipRoute(
|
||||
}
|
||||
if (targetWater.length === 0) return false;
|
||||
|
||||
const sourceShores: TileRef[] =
|
||||
preferredSrc !== null && gm.isValidRef(preferredSrc)
|
||||
? [preferredSrc]
|
||||
: candidateShoreTiles(gm, attacker, clickTile);
|
||||
const preferredRequested =
|
||||
preferredSrc !== null && gm.isValidRef(preferredSrc);
|
||||
|
||||
const seedNodeToOrigin = new Map<TileRef, TileRef>();
|
||||
for (const shore of sourceShores) {
|
||||
if (!gm.isValidRef(shore)) continue;
|
||||
if (gm.owner(shore) !== attacker) continue;
|
||||
if (!gm.isShore(shore)) continue;
|
||||
for (const w of adjacentWaterTiles(gm, shore)) {
|
||||
if (!seedNodeToOrigin.has(w)) {
|
||||
seedNodeToOrigin.set(w, shore);
|
||||
const buildSeedNodeToOrigin = (sourceShores: readonly TileRef[]) => {
|
||||
const out = new Map<TileRef, TileRef>();
|
||||
for (const shore of sourceShores) {
|
||||
if (!gm.isValidRef(shore)) continue;
|
||||
if (gm.owner(shore) !== attacker) continue;
|
||||
if (!gm.isShore(shore)) continue;
|
||||
for (const w of adjacentWaterTiles(gm, shore)) {
|
||||
if (!out.has(w)) {
|
||||
out.set(w, shore);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let seedNodeToOrigin = buildSeedNodeToOrigin(
|
||||
preferredRequested ? [preferredSrc!] : candidateShoreTiles(gm, attacker, clickTile),
|
||||
);
|
||||
// Preferred src is a hint; if it doesn't yield a valid spawn shore, fall back to normal candidates.
|
||||
if (seedNodeToOrigin.size === 0 && preferredRequested) {
|
||||
seedNodeToOrigin = buildSeedNodeToOrigin(
|
||||
candidateShoreTiles(gm, attacker, clickTile),
|
||||
);
|
||||
}
|
||||
if (seedNodeToOrigin.size === 0) return false;
|
||||
|
||||
@@ -375,12 +394,18 @@ export function bestTransportShipRoute(
|
||||
const targetWaterFiltered = targetWater.filter((t) => seedComps.has(ids[t] ?? 0));
|
||||
if (targetWaterFiltered.length === 0) return false;
|
||||
|
||||
const bfs = getBoatBfs(gm);
|
||||
const startTime = performance.now();
|
||||
const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, targetWaterFiltered, {
|
||||
kingMoves: true,
|
||||
noCornerCutting: true,
|
||||
});
|
||||
const result = findWaterPathFromSeedsCoarseToFine(
|
||||
gm,
|
||||
seedNodesFiltered,
|
||||
seedOriginsFiltered,
|
||||
targetWaterFiltered,
|
||||
{
|
||||
kingMoves: true,
|
||||
noCornerCutting: true,
|
||||
},
|
||||
gm.miniMap(),
|
||||
);
|
||||
const duration = performance.now() - startTime;
|
||||
if (result === null) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,17 @@ export type MultiSourceAnyTargetBFSResult = {
|
||||
export type MultiSourceAnyTargetBFSOptions = {
|
||||
kingMoves?: boolean;
|
||||
noCornerCutting?: boolean;
|
||||
/**
|
||||
* Optional region mask to restrict traversal.
|
||||
*
|
||||
* Intended for coarse-to-fine corridors: map each fine tile to a coarse region and
|
||||
* allow only regions whose stamp matches.
|
||||
*/
|
||||
allowedMask?: {
|
||||
tileToRegion: Uint32Array;
|
||||
regionStamp: Uint32Array;
|
||||
stamp: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,11 +78,19 @@ export class MultiSourceAnyTargetBFS {
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
const allowed = opts.allowedMask;
|
||||
|
||||
const count = Math.min(seedNodes.length, seedOrigins.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const node = seedNodes[i]!;
|
||||
const origin = seedOrigins[i]!;
|
||||
if (node < 0 || node >= this.visitedStamp.length) continue;
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[node]!] !== allowed.stamp
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!gm.isWater(node)) continue;
|
||||
if (this.visitedStamp[node] === stamp) continue;
|
||||
this.visitedStamp[node] = stamp;
|
||||
@@ -102,29 +121,57 @@ export class MultiSourceAnyTargetBFS {
|
||||
if (node >= w) {
|
||||
const n = node - w;
|
||||
if (gm.isWater(n) && this.visitedStamp[n] !== stamp) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[n]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(n, node, stamp);
|
||||
this.queue[tail++] = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node < lastRowStart) {
|
||||
const s = node + w;
|
||||
if (gm.isWater(s) && this.visitedStamp[s] !== stamp) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[s]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(s, node, stamp);
|
||||
this.queue[tail++] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (x !== 0) {
|
||||
const wv = node - 1;
|
||||
if (gm.isWater(wv) && this.visitedStamp[wv] !== stamp) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[wv]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(wv, node, stamp);
|
||||
this.queue[tail++] = wv;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (x !== w - 1) {
|
||||
const ev = node + 1;
|
||||
if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[ev]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(ev, node, stamp);
|
||||
this.queue[tail++] = ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +185,15 @@ export class MultiSourceAnyTargetBFS {
|
||||
(!noCornerCutting || (gm.isWater(node - w) && gm.isWater(node - 1))) &&
|
||||
this.visitedStamp[nw] !== stamp
|
||||
) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[nw]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(nw, node, stamp);
|
||||
this.queue[tail++] = nw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node >= w && x !== w - 1) {
|
||||
@@ -149,8 +203,15 @@ export class MultiSourceAnyTargetBFS {
|
||||
(!noCornerCutting || (gm.isWater(node - w) && gm.isWater(node + 1))) &&
|
||||
this.visitedStamp[ne] !== stamp
|
||||
) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[ne]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(ne, node, stamp);
|
||||
this.queue[tail++] = ne;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node < lastRowStart && x !== 0) {
|
||||
@@ -160,8 +221,15 @@ export class MultiSourceAnyTargetBFS {
|
||||
(!noCornerCutting || (gm.isWater(node + w) && gm.isWater(node - 1))) &&
|
||||
this.visitedStamp[sw] !== stamp
|
||||
) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[sw]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(sw, node, stamp);
|
||||
this.queue[tail++] = sw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node < lastRowStart && x !== w - 1) {
|
||||
@@ -171,8 +239,15 @@ export class MultiSourceAnyTargetBFS {
|
||||
(!noCornerCutting || (gm.isWater(node + w) && gm.isWater(node + 1))) &&
|
||||
this.visitedStamp[se] !== stamp
|
||||
) {
|
||||
if (
|
||||
allowed &&
|
||||
allowed.regionStamp[allowed.tileToRegion[se]!] !== allowed.stamp
|
||||
) {
|
||||
// skip
|
||||
} else {
|
||||
this.visit(se, node, stamp);
|
||||
this.queue[tail++] = se;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { findWaterPathFromSeedsCoarseToFine } from "../../../src/core/pathfinding/CoarseToFineWaterPath";
|
||||
|
||||
type TileRef = number;
|
||||
|
||||
function makeGridWaterMap(w: number, h: number, water: boolean[]) {
|
||||
const num = w * h;
|
||||
if (water.length !== num) throw new Error("bad water array");
|
||||
return {
|
||||
width: () => w,
|
||||
height: () => h,
|
||||
x: (ref: TileRef) => ref % w,
|
||||
y: (ref: TileRef) => Math.floor(ref / w),
|
||||
isWater: (ref: TileRef) => water[ref] === true,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function coarseCellOfFine(ref: TileRef, fineW: number): number {
|
||||
const x = ref % fineW;
|
||||
const y = Math.floor(ref / fineW);
|
||||
const cx = x >= 4 ? 1 : 0;
|
||||
const cy = y >= 4 ? 1 : 0;
|
||||
return cy * 2 + cx;
|
||||
}
|
||||
|
||||
describe("findWaterPathFromSeedsCoarseToFine (Option A)", () => {
|
||||
it("finds a route inside a tight corridor on all-water maps", () => {
|
||||
const fineW = 8;
|
||||
const fineH = 8;
|
||||
const fine = makeGridWaterMap(fineW, fineH, new Array(64).fill(true));
|
||||
const coarse = makeGridWaterMap(2, 2, new Array(4).fill(true));
|
||||
|
||||
const res = findWaterPathFromSeedsCoarseToFine(
|
||||
fine,
|
||||
[0],
|
||||
[0],
|
||||
[63],
|
||||
{ kingMoves: true, noCornerCutting: true },
|
||||
coarse,
|
||||
{ corridorRadius: 0, maxAttempts: 1 },
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
// Corridor from coarse path [0,3] at radius 0 only allows coarse cells {0,3}.
|
||||
const usedCells = new Set(
|
||||
res!.path.map((t) => coarseCellOfFine(t, fineW)),
|
||||
);
|
||||
expect(Array.from(usedCells).sort()).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
it("falls back to unrestricted fine BFS when the corridor is too tight", () => {
|
||||
const fineW = 8;
|
||||
const fineH = 8;
|
||||
const water = new Array(64).fill(true) as boolean[];
|
||||
|
||||
// Block the central no-corner-cutting diagonal from (3,3)->(4,4):
|
||||
// it requires both orthogonals (4,3) and (3,4) to be water.
|
||||
water[4 + 3 * fineW] = false; // (4,3)
|
||||
water[3 + 4 * fineW] = false; // (3,4)
|
||||
|
||||
const fine = makeGridWaterMap(fineW, fineH, water);
|
||||
const coarse = makeGridWaterMap(2, 2, new Array(4).fill(true));
|
||||
|
||||
const res = findWaterPathFromSeedsCoarseToFine(
|
||||
fine,
|
||||
[0],
|
||||
[0],
|
||||
[63],
|
||||
{ kingMoves: true, noCornerCutting: true },
|
||||
coarse,
|
||||
{ corridorRadius: 0, maxAttempts: 1 },
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
// With the diagonal blocked, any fine path from cell 0 to cell 3 must pass through cell 1 or 2.
|
||||
const usedCells = new Set(
|
||||
res!.path.map((t) => coarseCellOfFine(t, fineW)),
|
||||
);
|
||||
expect(usedCells.has(1) || usedCells.has(2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user