From 2536f894cc2c838252f8665ad0ebf4cf4e649d57 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:32:18 +0100 Subject: [PATCH] hook into SmoothingWaterTransformer --- src/core/execution/TradeShipExecution.ts | 28 ++++--- src/core/execution/TransportShipExecution.ts | 54 +++++++------ src/core/pathfinding/PathFinderStepper.ts | 58 ++++++++++++++ .../transformers/ComponentCheckTransformer.ts | 45 ++++++++++- .../transformers/MiniMapTransformer.ts | 75 +++++++++++++++++- .../transformers/ShoreCoercingTransformer.ts | 44 ++++++++++- .../transformers/SmoothingWaterTransformer.ts | 78 ++++++++++++++++--- src/core/pathfinding/types.ts | 12 +++ 8 files changed, 347 insertions(+), 47 deletions(-) diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 01e1534e7..647b46d3c 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -115,18 +115,22 @@ export class TradeShipExecution implements Execution { if (dst !== this.motionPlanDst) { this.motionPlanId++; const from = result.node; - const densePath = this.pathFinder.findPath(from, dst); - const segPlan = (densePath && - densePathToLosKeypointSegments( - densePath, - this.mg.map(), - (t) => - this.mg.isWater(t) || - (this.mg.isLand(t) && this.mg.isShoreline(t)), - )) ?? { - points: Uint32Array.from([from]), - segmentSteps: new Uint32Array(0), - }; + const segPlan = this.pathFinder.planSegments?.(from, dst) ?? + (() => { + const densePath = this.pathFinder.findPath(from, dst); + return densePath + ? densePathToLosKeypointSegments( + densePath, + this.mg.map(), + (t) => + this.mg.isWater(t) || + (this.mg.isLand(t) && this.mg.isShoreline(t)), + ) + : null; + })() ?? { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; this.mg.recordMotionPlan({ kind: "grid_segments", diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 37c407ccc..0e872c019 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -116,17 +116,22 @@ export class TransportShipExecution implements Execution { targetTile: this.dst, }); - const densePath = this.pathFinder.findPath(this.src, this.dst); - const segPlan = (densePath && - densePathToLosKeypointSegments( - densePath, - this.mg.map(), - (t) => - this.mg.isWater(t) || (this.mg.isLand(t) && this.mg.isShoreline(t)), - )) ?? { - points: Uint32Array.from([this.src]), - segmentSteps: new Uint32Array(0), - }; + const segPlan = this.pathFinder.planSegments?.(this.src, this.dst) ?? + (() => { + const densePath = this.pathFinder.findPath(this.src, this.dst); + return densePath + ? densePathToLosKeypointSegments( + densePath, + this.mg.map(), + (t) => + this.mg.isWater(t) || + (this.mg.isLand(t) && this.mg.isShoreline(t)), + ) + : null; + })() ?? { + points: Uint32Array.from([this.src]), + segmentSteps: new Uint32Array(0), + }; const motionPlan: MotionPlanRecord = { kind: "grid_segments", @@ -276,17 +281,22 @@ export class TransportShipExecution implements Execution { if (this.dst !== null && this.dst !== this.motionPlanDst) { this.motionPlanId++; const from = this.boat.tile(); - const densePath = this.pathFinder.findPath(from, this.dst); - const segPlan = (densePath && - densePathToLosKeypointSegments( - densePath, - this.mg.map(), - (t) => - this.mg.isWater(t) || (this.mg.isLand(t) && this.mg.isShoreline(t)), - )) ?? { - points: Uint32Array.from([from]), - segmentSteps: new Uint32Array(0), - }; + const segPlan = this.pathFinder.planSegments?.(from, this.dst) ?? + (() => { + const densePath = this.pathFinder.findPath(from, this.dst); + return densePath + ? densePathToLosKeypointSegments( + densePath, + this.mg.map(), + (t) => + this.mg.isWater(t) || + (this.mg.isLand(t) && this.mg.isShoreline(t)), + ) + : null; + })() ?? { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; this.mg.recordMotionPlan({ kind: "grid_segments", diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts index 4b8081fdc..ecf70b6ca 100644 --- a/src/core/pathfinding/PathFinderStepper.ts +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -2,6 +2,7 @@ import { PathFinder, PathResult, PathStatus, + SegmentPlan, SteppingPathFinder, } from "./types"; @@ -116,4 +117,61 @@ export class PathFinderStepper implements SteppingPathFinder { return this.finder.findPath(from, to); } + + planSegments(from: T | T[], to: T): SegmentPlan | null { + if (!this.finder.planSegments) { + return null; + } + + // If called with multi-source, don't try to prime the step cache (next() uses single-source). + if (Array.isArray(from)) { + // Still compute a path first so inner transformers can cache their segment plan off findPath(). + this.finder.findPath(from, to); + return this.finder.planSegments(from, to); + } + + // Mirror next() pre-check behavior. + if (this.config.preCheck) { + const result = this.config.preCheck(from, to); + if (result && result.status === PathStatus.NOT_FOUND) { + return null; + } + } + + if (this.config.equals(from, to)) { + if (typeof (from as any) !== "number") { + return null; + } + return { + points: Uint32Array.from([from as any]), + segmentSteps: new Uint32Array(0), + }; + } + + if (this.lastTo === null || !this.config.equals(this.lastTo, to)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } + + if (this.path === null) { + try { + this.path = this.finder.findPath(from, to); + } catch (err) { + console.error("PathFinder threw an error during findPath", err); + return null; + } + + if (this.path === null) { + return null; + } + + this.pathIndex = 0; + if (this.path.length > 0 && this.config.equals(this.path[0], from)) { + this.pathIndex = 1; + } + } + + return this.finder.planSegments(from, to); + } } diff --git a/src/core/pathfinding/transformers/ComponentCheckTransformer.ts b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts index 2d1d4d685..833f1cd86 100644 --- a/src/core/pathfinding/transformers/ComponentCheckTransformer.ts +++ b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts @@ -9,6 +9,12 @@ import { PathFinder } from "../types"; * Avoids running expensive pathfinding when no path exists. */ export class ComponentCheckTransformer implements PathFinder { + private lastPlanFrom: T | T[] | null = null; + private lastPlanTo: T | null = null; + private lastPlan = null as ReturnType< + NonNullable["planSegments"]> + >; + constructor( private inner: PathFinder, private getComponent: (t: T) => number, @@ -30,6 +36,43 @@ export class ComponentCheckTransformer implements PathFinder { // Delegate with only valid sources const delegateFrom = validSources.length === 1 ? validSources[0] : validSources; - return this.inner.findPath(delegateFrom, to); + const path = this.inner.findPath(delegateFrom, to); + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = this.inner.planSegments?.(delegateFrom, to) ?? null; + return path; + } + + planSegments(from: T | T[], to: T) { + if ( + this.lastPlanTo === to && + this.lastPlanFrom === from && + this.lastPlan !== null + ) { + return this.lastPlan; + } + + const toComponent = this.getComponent(to); + const fromArray = Array.isArray(from) ? from : [from]; + const validSources = fromArray.filter( + (f) => this.getComponent(f) === toComponent, + ); + + if (validSources.length === 0) { + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = null; + return null; + } + + const delegateFrom = + validSources.length === 1 ? validSources[0] : validSources; + + // Ensure inner has a fresh cached plan (if any) for these args. + this.inner.findPath(delegateFrom, to); + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = this.inner.planSegments?.(delegateFrom, to) ?? null; + return this.lastPlan; } } diff --git a/src/core/pathfinding/transformers/MiniMapTransformer.ts b/src/core/pathfinding/transformers/MiniMapTransformer.ts index 885368716..f1b7dd320 100644 --- a/src/core/pathfinding/transformers/MiniMapTransformer.ts +++ b/src/core/pathfinding/transformers/MiniMapTransformer.ts @@ -1,8 +1,12 @@ import { Cell } from "../../game/Game"; import { GameMap, TileRef } from "../../game/GameMap"; -import { PathFinder } from "../types"; +import { PathFinder, SegmentPlan } from "../types"; export class MiniMapTransformer implements PathFinder { + private lastPlanFrom: TileRef | TileRef[] | null = null; + private lastPlanTo: TileRef | null = null; + private lastPlan: SegmentPlan | null = null; + constructor( private inner: PathFinder, private map: GameMap, @@ -29,6 +33,9 @@ export class MiniMapTransformer implements PathFinder { // Search on minimap const path = this.inner.findPath(miniFrom, miniTo); if (!path || path.length === 0) { + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = null; return null; } @@ -60,9 +67,75 @@ export class MiniMapTransformer implements PathFinder { const cellTo = new Cell(this.map.x(to), this.map.y(to)); const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom); + const miniPlan = this.inner.planSegments?.(miniFrom, miniTo) ?? null; + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = miniPlan + ? this.upscaleSegmentPlan(miniPlan, cellFrom, cellTo) + : null; + return upscaled.map((c) => this.map.ref(c.x, c.y)); } + planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null { + if (this.lastPlanFrom === from && this.lastPlanTo === to) { + return this.lastPlan; + } + + this.findPath(from, to); + return this.lastPlan; + } + + private upscaleSegmentPlan( + plan: SegmentPlan, + cellFrom: Cell | undefined, + cellTo: Cell, + scaleFactor: number = 2, + ): SegmentPlan { + const dstRef = this.map.ref(cellTo.x, cellTo.y); + + const points: number[] = []; + for (let i = 0; i < plan.points.length; i++) { + const miniRef = plan.points[i] as unknown as TileRef; + const x = this.miniMap.x(miniRef) * scaleFactor; + const y = this.miniMap.y(miniRef) * scaleFactor; + points.push(this.map.ref(x, y) >>> 0); + } + + const steps: number[] = new Array(plan.segmentSteps.length); + for (let i = 0; i < plan.segmentSteps.length; i++) { + steps[i] = (plan.segmentSteps[i] * scaleFactor) >>> 0; + } + + if (cellFrom !== undefined && points.length > 0) { + const srcRef = this.map.ref(cellFrom.x, cellFrom.y); + if (points[0] !== srcRef >>> 0) { + const a = srcRef; + const b = points[0] as TileRef; + const dx = this.map.x(b) - this.map.x(a); + const dy = this.map.y(b) - this.map.y(a); + const segSteps = Math.max(Math.abs(dx), Math.abs(dy)) || 1; + points.unshift(srcRef >>> 0); + steps.unshift(segSteps >>> 0); + } + } + + if (points.length > 0 && points[points.length - 1] !== dstRef >>> 0) { + const a = points[points.length - 1] as TileRef; + const b = dstRef; + const dx = this.map.x(b) - this.map.x(a); + const dy = this.map.y(b) - this.map.y(a); + const segSteps = Math.max(Math.abs(dx), Math.abs(dy)) || 1; + points.push(dstRef >>> 0); + steps.push(segSteps >>> 0); + } + + return { + points: Uint32Array.from(points), + segmentSteps: Uint32Array.from(steps), + }; + } + private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] { const scaledPath = path.map( (point) => new Cell(point.x * scaleFactor, point.y * scaleFactor), diff --git a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts index d0e8dbe25..e872c0e67 100644 --- a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts +++ b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts @@ -1,5 +1,5 @@ import { GameMap, TileRef } from "../../game/GameMap"; -import { PathFinder } from "../types"; +import { PathFinder, SegmentPlan } from "../types"; /** * Wraps a PathFinder to handle shore tiles. @@ -7,6 +7,10 @@ import { PathFinder } from "../types"; * then fixes the path extremes to include the original shore tiles. */ export class ShoreCoercingTransformer implements PathFinder { + private lastPlanFrom: TileRef | TileRef[] | null = null; + private lastPlanTo: TileRef | null = null; + private lastPlan: SegmentPlan | null = null; + constructor( private inner: PathFinder, private map: GameMap, @@ -37,13 +41,28 @@ export class ShoreCoercingTransformer implements PathFinder { const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom; const path = this.inner.findPath(fromTiles, coercedTo.water); if (!path || path.length === 0) { + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = null; return null; } + const innerPlan = this.inner.planSegments?.(fromTiles, coercedTo.water); + const planPoints: number[] | null = innerPlan + ? Array.from(innerPlan.points) + : null; + const planSteps: number[] | null = innerPlan + ? Array.from(innerPlan.segmentSteps) + : null; + // Restore original start shore tile const originalShore = waterToOriginal.get(path[0]); if (originalShore !== undefined && originalShore !== null) { path.unshift(originalShore); + if (planPoints && planSteps) { + planPoints.unshift(originalShore >>> 0); + planSteps.unshift(1); + } } // Append original to if different @@ -52,11 +71,34 @@ export class ShoreCoercingTransformer implements PathFinder { path[path.length - 1] !== coercedTo.original ) { path.push(coercedTo.original); + if (planPoints && planSteps) { + planPoints.push(coercedTo.original >>> 0); + planSteps.push(1); + } } + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = + planPoints && planSteps + ? { + points: Uint32Array.from(planPoints), + segmentSteps: Uint32Array.from(planSteps), + } + : null; + return path; } + planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null { + if (this.lastPlanFrom === from && this.lastPlanTo === to) { + return this.lastPlan; + } + + this.findPath(from, to); + return this.lastPlan; + } + /** * Coerce a tile to water for pathfinding. * If tile is already water, returns it unchanged. diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 549e047b5..00565e62c 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -4,7 +4,7 @@ import { AStarWaterBounded, SearchBounds, } from "../algorithms/AStar.WaterBounded"; -import { PathFinder } from "../types"; +import { PathFinder, SegmentPlan } from "../types"; const ENDPOINT_REFINEMENT_TILES = 50; const LOCAL_ASTAR_MAX_AREA = 100 * 100; @@ -23,6 +23,9 @@ export class SmoothingWaterTransformer implements PathFinder { private readonly localAStar: AStarWaterBounded; private readonly terrain: Uint8Array; private readonly isTraversable: (tile: TileRef) => boolean; + private lastPlanFrom: TileRef | TileRef[] | null = null; + private lastPlanTo: TileRef | null = null; + private lastPlan: SegmentPlan | null = null; constructor( private inner: PathFinder, @@ -38,14 +41,43 @@ export class SmoothingWaterTransformer implements PathFinder { findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { const path = this.inner.findPath(from, to); - return DebugSpan.wrap("smoothingTransformer", () => - path ? this.smooth(path) : null, - ); + if (!path) { + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = null; + return null; + } + + return DebugSpan.wrap("smoothingTransformer", () => { + const { dense, plan } = this.smoothWithPlan(path); + this.lastPlanFrom = from; + this.lastPlanTo = to; + this.lastPlan = plan; + return dense; + }); } - private smooth(path: TileRef[]): TileRef[] { + planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null { + if (this.lastPlanFrom === from && this.lastPlanTo === to) { + return this.lastPlan; + } + + this.findPath(from, to); + return this.lastPlan; + } + + private smoothWithPlan(path: TileRef[]): { + dense: TileRef[]; + plan: SegmentPlan; + } { if (path.length <= 2) { - return path; + const points = + path.length === 2 + ? Uint32Array.from([path[0] >>> 0, path[1] >>> 0]) + : Uint32Array.from([path[0] >>> 0]); + const segmentSteps = + path.length === 2 ? Uint32Array.from([1]) : new Uint32Array(0); + return { dense: path, plan: { points, segmentSteps } }; } // Pass 1: LOS smoothing with binary search @@ -59,15 +91,29 @@ export class SmoothingWaterTransformer implements PathFinder { ); // Pass 3: LOS smoothing again, farther from the shore - smoothed = DebugSpan.wrap("smoother:los2", () => - this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), + const capture = { points: [] as number[], segmentSteps: [] as number[] }; + const dense = DebugSpan.wrap("smoother:los2", () => + this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2, capture), ); - return smoothed; + return { + dense, + plan: { + points: Uint32Array.from(capture.points), + segmentSteps: Uint32Array.from(capture.segmentSteps), + }, + }; } - private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { + private losSmooth( + path: TileRef[], + minMagnitude: number, + capture?: { points: number[]; segmentSteps: number[] }, + ): TileRef[] { const result: TileRef[] = [path[0]]; + if (capture) { + capture.points.push(path[0] >>> 0); + } let current = 0; while (current < path.length - 1) { @@ -87,14 +133,26 @@ export class SmoothingWaterTransformer implements PathFinder { } // Trace the path to farthest visible point + let segSteps = 1; if (farthest > current + 1) { const trace = this.tracePath(path[current], path[farthest]); if (trace) { + segSteps = trace.length - 1; // Add all intermediate tiles except the last (will be added in next iteration or at end) for (let i = 1; i < trace.length - 1; i++) { result.push(trace[i]); } } + if (!trace) { + segSteps = (farthest - current) >>> 0; + } + } else if (farthest > current) { + segSteps = 1; + } + + if (capture) { + capture.points.push(path[farthest] >>> 0); + capture.segmentSteps.push(segSteps >>> 0); } current = farthest; diff --git a/src/core/pathfinding/types.ts b/src/core/pathfinding/types.ts index c844b9df0..558c3dfed 100644 --- a/src/core/pathfinding/types.ts +++ b/src/core/pathfinding/types.ts @@ -20,8 +20,20 @@ export type PathResult = */ export interface PathFinder { findPath(from: T | T[], to: T): T[] | null; + /** + * Optional: returns a sparse keypoint polyline with per-segment step counts. + * Only implemented for TileRef-style (number) pathfinders. + * + * `points.length === segmentSteps.length + 1` when present. + */ + planSegments?(from: T | T[], to: T): SegmentPlan | null; } +export type SegmentPlan = { + points: Uint32Array; + segmentSteps: Uint32Array; +}; + /** * SteppingPathFinder - PathFinder with stepping support. * Used by execution classes that need incremental path traversal.