From ae9a8cc87d0e11c9a5af38232e492dd093dc5e6a Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:17:05 +0100 Subject: [PATCH] =?UTF-8?q?Implemented=20post-refinement=20=E2=80=9Cstring?= =?UTF-8?q?=20pulling=E2=80=9D=20and=20exposed=20a=20sparse=20polyline=20f?= =?UTF-8?q?or=20=20later=20spline-render.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added rubberBandWaterPath() to PathRubberBand.ts (bounded LOS checks; returns { path, waypoints }). - Hooked it into findWaterPathFromSeedsCoarseToFine() so every returned fine water path is now rubber-banded and also carries waypoints in CoarseToFineWaterPath.ts. - Extended MultiSourceAnyTargetBFSResult with optional waypoints?: TileRef[] in MultiSourceAnyTargetBFS.ts. - Plumbed full-route waypoints ([src, ...waterWaypoints, dst]) into bestTransportShipRoute() return value in TransportShipUtils.ts (line 318). --- src/core/game/TransportShipUtils.ts | 5 +- src/core/pathfinding/CoarseToFineWaterPath.ts | 82 +++++++++++-------- .../pathfinding/MultiSourceAnyTargetBFS.ts | 5 ++ src/core/pathfinding/PathRubberBand.ts | 63 +++++++++++++- 4 files changed, 118 insertions(+), 37 deletions(-) diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index c8ee023eb..7611bd7ea 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -7,6 +7,7 @@ type BoatRoute = { src: TileRef; dst: TileRef; path: TileRef[]; + waypoints?: TileRef[]; }; function miniMapOrNull(gm: GameMap): GameMap | null { @@ -426,13 +427,15 @@ export function bestTransportShipRoute( const src = result.source; // Full route includes the shore endpoints to drive unit movement. const path = [src, ...result.path, dst]; + const waterWaypoints = result.waypoints ?? result.path; + const waypoints = [src, ...waterWaypoints, dst]; console.log( `bestTransportShipRoute: ${duration.toFixed(2)}ms, steps=${Math.max( 0, path.length - 1, )}`, ); - return { src, dst, path }; + return { src, dst, path, waypoints }; } export function canBuildTransportShip( diff --git a/src/core/pathfinding/CoarseToFineWaterPath.ts b/src/core/pathfinding/CoarseToFineWaterPath.ts index 7fd257047..e04d51b54 100644 --- a/src/core/pathfinding/CoarseToFineWaterPath.ts +++ b/src/core/pathfinding/CoarseToFineWaterPath.ts @@ -4,7 +4,7 @@ import { MultiSourceAnyTargetBFSOptions, MultiSourceAnyTargetBFSResult, } from "./MultiSourceAnyTargetBFS"; -import { rubberBandCoarsePath } from "./PathRubberBand"; +import { rubberBandCoarsePath, rubberBandWaterPath } from "./PathRubberBand"; export type CoarseToFineWaterPathOptions = { /** @@ -192,24 +192,36 @@ export function findWaterPathFromSeedsCoarseToFine( ): MultiSourceAnyTargetBFSResult | null { const fineBfs = getBfs(fineMap); + const postprocessFine = ( + result: MultiSourceAnyTargetBFSResult | null, + ): MultiSourceAnyTargetBFSResult | null => { + if (result === null) return null; + const rb = rubberBandWaterPath(fineMap, result.path, bfsOpts); + return { ...result, path: rb.path, waypoints: rb.waypoints }; + }; + if (!coarseMap) { - return fineBfs.findWaterPathFromSeeds( - fineMap, - seedNodes, - seedOrigins, - targets, - bfsOpts, + return postprocessFine( + fineBfs.findWaterPathFromSeeds( + fineMap, + seedNodes, + seedOrigins, + targets, + bfsOpts, + ), ); } const mapping = getFineToCoarseMapping(fineMap, coarseMap); if (mapping === null) { - return fineBfs.findWaterPathFromSeeds( - fineMap, - seedNodes, - seedOrigins, - targets, - bfsOpts, + return postprocessFine( + fineBfs.findWaterPathFromSeeds( + fineMap, + seedNodes, + seedOrigins, + targets, + bfsOpts, + ), ); } @@ -242,12 +254,14 @@ export function findWaterPathFromSeedsCoarseToFine( ); if (coarseSeeds.length === 0 || coarseTargets.length === 0) { - return fineBfs.findWaterPathFromSeeds( - fineMap, - seedNodes, - seedOrigins, - targets, - bfsOpts, + return postprocessFine( + fineBfs.findWaterPathFromSeeds( + fineMap, + seedNodes, + seedOrigins, + targets, + bfsOpts, + ), ); } @@ -262,12 +276,14 @@ export function findWaterPathFromSeedsCoarseToFine( 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, + return postprocessFine( + fineBfs.findWaterPathFromSeeds( + fineMap, + seedNodes, + seedOrigins, + targets, + bfsOpts, + ), ); } @@ -336,14 +352,16 @@ export function findWaterPathFromSeedsCoarseToFine( return newCount; }, ); - if (refined !== null) return refined; + if (refined !== null) return postprocessFine(refined); // Final fallback: unrestricted fine BFS. - return fineBfs.findWaterPathFromSeeds( - fineMap, - seedNodes, - seedOrigins, - targets, - bfsOpts, + return postprocessFine( + fineBfs.findWaterPathFromSeeds( + fineMap, + seedNodes, + seedOrigins, + targets, + bfsOpts, + ), ); } diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index c7c904e75..c898bca49 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -4,6 +4,11 @@ export type MultiSourceAnyTargetBFSResult = { source: TileRef; target: TileRef; path: TileRef[]; + /** + * Optional sparse polyline representation of `path` (e.g. for rendering/splines). + * When present, `path` should still be treated as the authoritative tile-valid route. + */ + waypoints?: TileRef[]; }; export type MultiSourceAnyTargetBFSOptions = { diff --git a/src/core/pathfinding/PathRubberBand.ts b/src/core/pathfinding/PathRubberBand.ts index e975d6866..a38a28dae 100644 --- a/src/core/pathfinding/PathRubberBand.ts +++ b/src/core/pathfinding/PathRubberBand.ts @@ -2,6 +2,11 @@ import { GameMap, TileRef } from "../game/GameMap"; import { BezenhamLine } from "../utilities/Line"; import { MultiSourceAnyTargetBFSOptions } from "./MultiSourceAnyTargetBFS"; +export type RubberBandPathResult = { + waypoints: TileRef[]; + path: TileRef[]; +}; + function sign(n: number): -1 | 0 | 1 { return n === 0 ? 0 : n > 0 ? 1 : -1; } @@ -12,6 +17,7 @@ function lineOfSightWater( to: TileRef, noCornerCutting: boolean, ): boolean { + const w = gm.width(); const x0 = gm.x(from); const y0 = gm.y(from); const x1 = gm.x(to); @@ -23,15 +29,15 @@ function lineOfSightWater( let prevY = y0; let point = line.increment(); while (point !== true) { - const t = gm.ref(point.x, point.y); + const t = point.y * w + point.x; if (!gm.isWater(t)) return false; if (noCornerCutting) { const dx = sign(point.x - prevX); const dy = sign(point.y - prevY); if (dx !== 0 && dy !== 0) { - const orthoA = gm.ref(prevX + dx, prevY); - const orthoB = gm.ref(prevX, prevY + dy); + const orthoA = prevY * w + (prevX + dx); + const orthoB = (prevY + dy) * w + prevX; if (!gm.isWater(orthoA) || !gm.isWater(orthoB)) return false; } } @@ -45,6 +51,7 @@ function lineOfSightWater( } function expandLine(gm: GameMap, from: TileRef, to: TileRef, out: TileRef[]) { + const w = gm.width(); const x0 = gm.x(from); const y0 = gm.y(from); const x1 = gm.x(to); @@ -52,7 +59,7 @@ function expandLine(gm: GameMap, from: TileRef, to: TileRef, out: TileRef[]) { const line = new BezenhamLine({ x: x0, y: y0 }, { x: x1, y: y1 }); let point = line.increment(); while (point !== true) { - const t = gm.ref(point.x, point.y); + const t = point.y * w + point.x; if (out.length === 0 || out[out.length - 1] !== t) out.push(t); point = line.increment(); } @@ -103,3 +110,51 @@ export function rubberBandCoarsePath( } return spine.length > 0 ? spine : [...coarsePath]; } + +/** + * "String pulling" / rubber banding for a water-only tile path. + * + * Returns both: + * - `waypoints`: a sparse polyline (for rendering/splines later) + * - `path`: a tile-valid path expanded along the waypoint segments + * + * This is bounded (lookahead + checks per anchor) to keep it hot-path friendly. + */ +export function rubberBandWaterPath( + gm: GameMap, + waterPath: readonly TileRef[], + bfsOpts: MultiSourceAnyTargetBFSOptions, +): RubberBandPathResult { + if (waterPath.length <= 2) return { waypoints: [...waterPath], path: [...waterPath] }; + + const kingMoves = bfsOpts.kingMoves ?? true; + if (!kingMoves) return { waypoints: [...waterPath], path: [...waterPath] }; + const noCornerCutting = bfsOpts.noCornerCutting ?? true; + + const maxLookahead = 2048; + const maxChecksPerAnchor = 96; + + const waypoints: TileRef[] = [waterPath[0]!]; + let i = 0; + while (i < waterPath.length - 1) { + const end = Math.min(waterPath.length - 1, i + maxLookahead); + let best = i + 1; + let checks = 0; + for (let j = end; j > i; j--) { + if (lineOfSightWater(gm, waterPath[i]!, waterPath[j]!, noCornerCutting)) { + best = j; + break; + } + if (++checks >= maxChecksPerAnchor) break; + } + waypoints.push(waterPath[best]!); + i = best; + } + + const out: TileRef[] = []; + for (let k = 0; k < waypoints.length - 1; k++) { + expandLine(gm, waypoints[k]!, waypoints[k + 1]!, out); + } + + return out.length > 0 ? { waypoints, path: out } : { waypoints: [...waterPath], path: [...waterPath] }; +}