From 9e8ac07e7870d9092f66b8db7f81ee7b1474e1ca Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:57:25 +0100 Subject: [PATCH] PathRubberBand: offshore depth-snap + spline output for boat routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Add pass2 waypoint snapping to local max “depth” (distance-to-land) with odd windowSize and LOS validation. -Add optional Catmull–Rom spline sampling from waypoints (default on) for rendering, while keeping tile-valid path authoritative. -Plumb offshoreCleanup/waypointSpline through findWaterPathFromSeedsCoarseToFine and expose waypoints/spline on results. --- src/core/game/TransportShipUtils.ts | 9 +- src/core/pathfinding/CoarseToFineWaterPath.ts | 13 +- .../pathfinding/MultiSourceAnyTargetBFS.ts | 4 + src/core/pathfinding/PathRubberBand.ts | 155 ++++++++++++++++-- 4 files changed, 165 insertions(+), 16 deletions(-) diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index 7cdd7ce9d..635512efb 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -8,6 +8,7 @@ type BoatRoute = { dst: TileRef; path: TileRef[]; waypoints?: TileRef[]; + spline?: number[]; }; function miniMapOrNull(gm: GameMap): GameMap | null { @@ -222,7 +223,7 @@ export function boatPathFromTileToShore( }, coarseBoatMapOrNull(gm), { - offshoreCleanup: { windowSize: 16 }, + offshoreCleanup: { windowSize: 17 }, }, ); const duration = performance.now() - startTime; @@ -297,7 +298,7 @@ export function boatPathFromTileToWater( }, coarseBoatMapOrNull(gm), { - offshoreCleanup: { windowSize: 16 }, + offshoreCleanup: { windowSize: 17 }, }, ); const duration = performance.now() - startTime; @@ -424,7 +425,7 @@ export function bestTransportShipRoute( }, coarse, { - offshoreCleanup: { windowSize: 16 }, + offshoreCleanup: { windowSize: 17 }, }, ); const duration = performance.now() - startTime; @@ -444,7 +445,7 @@ export function bestTransportShipRoute( path.length - 1, )}`, ); - return { src, dst, path, waypoints }; + return { src, dst, path, waypoints, spline: result.spline }; } export function canBuildTransportShip( diff --git a/src/core/pathfinding/CoarseToFineWaterPath.ts b/src/core/pathfinding/CoarseToFineWaterPath.ts index 7dd885ea4..d8abe4637 100644 --- a/src/core/pathfinding/CoarseToFineWaterPath.ts +++ b/src/core/pathfinding/CoarseToFineWaterPath.ts @@ -6,6 +6,7 @@ import { } from "./MultiSourceAnyTargetBFS"; import { OffshoreCleanupOptions, + WaypointSplineOptions, rubberBandCoarsePath, rubberBandWaterPath, } from "./PathRubberBand"; @@ -28,6 +29,10 @@ export type CoarseToFineWaterPathOptions = { * Optional post-processing on the refined fine path. */ offshoreCleanup?: OffshoreCleanupOptions; + /** + * Optional spline sampling (rendering-only) built from the final waypoints. + */ + waypointSpline?: WaypointSplineOptions; }; const bfsCache = new WeakMap(); @@ -209,8 +214,14 @@ export function findWaterPathFromSeedsCoarseToFine( result.path, bfsOpts, coarseToFine.offshoreCleanup, + coarseToFine.waypointSpline, ); - return { ...result, path: rb.path, waypoints: rb.waypoints }; + return { + ...result, + path: rb.path, + waypoints: rb.waypoints, + spline: rb.spline, + }; }; if (!coarseMap) { diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index c898bca49..cef1dbaeb 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -9,6 +9,10 @@ export type MultiSourceAnyTargetBFSResult = { * When present, `path` should still be treated as the authoritative tile-valid route. */ waypoints?: TileRef[]; + /** + * Optional sampled spline in tile coordinates (x,y pairs), intended for rendering. + */ + spline?: number[]; }; export type MultiSourceAnyTargetBFSOptions = { diff --git a/src/core/pathfinding/PathRubberBand.ts b/src/core/pathfinding/PathRubberBand.ts index 0c0ae35af..cb4d37d5e 100644 --- a/src/core/pathfinding/PathRubberBand.ts +++ b/src/core/pathfinding/PathRubberBand.ts @@ -5,16 +5,36 @@ import { MultiSourceAnyTargetBFSOptions } from "./MultiSourceAnyTargetBFS"; export type RubberBandPathResult = { waypoints: TileRef[]; path: TileRef[]; + /** + * Optional sampled spline in tile coordinates (x,y pairs), intended for rendering. + * This does not affect the tile-valid `path`. + */ + spline?: number[]; }; export type OffshoreCleanupOptions = { /** * Square window size (in tiles) used to find the local maximum "depth" (distance-to-land). - * Typical: 16. + * + * Must be odd so the window is symmetric around the waypoint (even sizes bias by 1 tile). + * Typical: 33. */ windowSize?: number; }; +export type WaypointSplineOptions = { + enabled?: boolean; + /** + * Number of samples per waypoint segment (higher = smoother). + * Typical: 4..8. + */ + samplesPerSegment?: number; + /** + * Catmull-Rom tension (0..1). Typical: 0.5. + */ + tension?: number; +}; + const depthDirs8 = [ { dx: 0, dy: -1 }, { dx: 0, dy: 1 }, @@ -36,6 +56,114 @@ function sign(n: number): -1 | 0 | 1 { return n === 0 ? 0 : n > 0 ? 1 : -1; } +function catmullRom1D( + p0: number, + p1: number, + p2: number, + p3: number, + t: number, + tension: number, +): number { + // Standard Catmull-Rom (cubic Hermite form). + const t2 = t * t; + const t3 = t2 * t; + const m1 = (p2 - p0) * tension; + const m2 = (p3 - p1) * tension; + return ( + (2 * t3 - 3 * t2 + 1) * p1 + + (t3 - 2 * t2 + t) * m1 + + (-2 * t3 + 3 * t2) * p2 + + (t3 - t2) * m2 + ); +} + +function buildWaypointSplineSamples( + gm: GameMap, + waypoints: readonly TileRef[], + noCornerCutting: boolean, + opts: WaypointSplineOptions, +): number[] | undefined { + const enabled = opts.enabled ?? true; + if (!enabled) return undefined; + if (waypoints.length < 2) return undefined; + + const samplesPerSegment = Math.max(1, Math.min(16, opts.samplesPerSegment ?? 4)); + const tension = Math.max(0, Math.min(1, opts.tension ?? 0.5)); + + // Clamp for safety on pathological inputs. + const maxSegments = 1024; + const segCount = Math.min(maxSegments, waypoints.length - 1); + + const out: number[] = []; + out.length = 0; + + const pushPoint = (x: number, y: number) => { + out.push(x, y); + }; + + // Convert to tile-center coordinates to avoid bias. + const cx = (t: TileRef) => gm.x(t) + 0.5; + const cy = (t: TileRef) => gm.y(t) + 0.5; + + // Validate samples stay on water (coarse check) to avoid obvious curve-cutting over land. + const w = gm.width(); + const h = gm.height(); + const isSampleWater = (x: number, y: number) => { + const tx = Math.max(0, Math.min(w - 1, Math.floor(x))); + const ty = Math.max(0, Math.min(h - 1, Math.floor(y))); + const ref = ty * w + tx; + if (!gm.isWater(ref)) return false; + if (noCornerCutting) { + // If we're close to a corner, be conservative: require the orthogonals to be water as well. + // This is a heuristic validation; the authoritative path remains tile-valid. + const fx = x - tx; + const fy = y - ty; + const dx = fx < 0.25 ? -1 : fx > 0.75 ? 1 : 0; + const dy = fy < 0.25 ? -1 : fy > 0.75 ? 1 : 0; + if (dx !== 0 && dy !== 0) { + const ox = tx + dx; + const oy = ty + dy; + if (ox >= 0 && ox < w && oy >= 0 && oy < h) { + const orthoA = ty * w + ox; + const orthoB = oy * w + tx; + if (!gm.isWater(orthoA) || !gm.isWater(orthoB)) return false; + } + } + } + return true; + }; + + // Start point. + pushPoint(cx(waypoints[0]!), cy(waypoints[0]!)); + + for (let i = 0; i < segCount; i++) { + const p0 = waypoints[Math.max(0, i - 1)]!; + const p1 = waypoints[i]!; + const p2 = waypoints[i + 1]!; + const p3 = waypoints[Math.min(waypoints.length - 1, i + 2)]!; + + const x0 = cx(p0); + const y0 = cy(p0); + const x1 = cx(p1); + const y1 = cy(p1); + const x2 = cx(p2); + const y2 = cy(p2); + const x3 = cx(p3); + const y3 = cy(p3); + + // Skip t=0 (already pushed p1). Include samples up to t<1 and then rely on next segment / final point. + for (let s = 1; s <= samplesPerSegment; s++) { + const t = s / samplesPerSegment; + const x = catmullRom1D(x0, x1, x2, x3, t, tension); + const y = catmullRom1D(y0, y1, y2, y3, t, tension); + if (!isSampleWater(x, y)) return undefined; + pushPoint(x, y); + } + } + + return out; +} + function lineOfSightWater( gm: GameMap, from: TileRef, @@ -168,8 +296,10 @@ function snapWaypointsToLocalDepthMaxInPlace( ) { if (waypoints.length <= 2) return; - const windowSize = Math.max(4, opts.windowSize ?? 16); - const half = Math.max(1, Math.floor(windowSize / 2)); + let windowSize = Math.max(5, opts.windowSize ?? 33); + // Keep it odd so the "depth field" doesn't get a directional bias from [-k, +k-1] windows. + if ((windowSize & 1) === 0) windowSize += 1; + const half = windowSize >> 1; const w = gm.width(); const h = gm.height(); @@ -204,8 +334,8 @@ function snapWaypointsToLocalDepthMaxInPlace( const x0 = Math.max(0, cx - half); const y0 = Math.max(0, cy - half); - const x1 = Math.min(w - 1, cx + (windowSize - half - 1)); - const y1 = Math.min(h - 1, cy + (windowSize - half - 1)); + const x1 = Math.min(w - 1, cx + half); + const y1 = Math.min(h - 1, cy + half); const ww = x1 - x0 + 1; const wh = y1 - y0 + 1; const n = ww * wh; @@ -265,7 +395,6 @@ function snapWaypointsToLocalDepthMaxInPlace( const t = row + (x0 + lx); if (!gm.isWater(t)) continue; const depth = depthScratchDist[base + lx]!; - // Must preserve the sparse-path invariant: segments remain LOS-water. if ( !lineOfSightWater(gm, prev, t, noCornerCutting) || !lineOfSightWater(gm, t, next, noCornerCutting) @@ -276,10 +405,7 @@ function snapWaypointsToLocalDepthMaxInPlace( const dy = (y0 + ly) - cy; const d2 = dx * dx + dy * dy; - if ( - depth > bestDepth || - (depth === bestDepth && d2 < bestDist2) - ) { + if (depth > bestDepth || (depth === bestDepth && d2 < bestDist2)) { bestDepth = depth; bestDist2 = d2; bestTile = t; @@ -363,6 +489,7 @@ export function rubberBandWaterPath( waterPath: readonly TileRef[], bfsOpts: MultiSourceAnyTargetBFSOptions, offshore?: OffshoreCleanupOptions, + spline?: WaypointSplineOptions, ): RubberBandPathResult { if (waterPath.length <= 2) { return { waypoints: [...waterPath], path: [...waterPath] }; @@ -381,6 +508,12 @@ export function rubberBandWaterPath( snapWaypointsToLocalDepthMaxInPlace(gm, waypoints, noCornerCutting, offshore); } + const splineOpts: WaypointSplineOptions = spline ?? {}; + const splineSamples = + splineOpts.enabled === false + ? undefined + : buildWaypointSplineSamples(gm, waypoints, noCornerCutting, splineOpts); + // Final: expand the waypoint polyline once into a tile-valid path. const out: TileRef[] = []; for (let k = 0; k < waypoints.length - 1; k++) { @@ -388,6 +521,6 @@ export function rubberBandWaterPath( } return out.length > 0 - ? { waypoints, path: out } + ? { waypoints, path: out, spline: splineSamples } : { waypoints: [...waterPath], path: [...waterPath] }; }