hook into SmoothingWaterTransformer

This commit is contained in:
scamiv
2026-02-27 02:32:18 +01:00
parent 221406f212
commit 2429933bee
8 changed files with 347 additions and 47 deletions
+16 -12
View File
@@ -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",
+32 -22
View File
@@ -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",
+58
View File
@@ -2,6 +2,7 @@ import {
PathFinder,
PathResult,
PathStatus,
SegmentPlan,
SteppingPathFinder,
} from "./types";
@@ -116,4 +117,61 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
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);
}
}
@@ -9,6 +9,12 @@ import { PathFinder } from "../types";
* Avoids running expensive pathfinding when no path exists.
*/
export class ComponentCheckTransformer<T> implements PathFinder<T> {
private lastPlanFrom: T | T[] | null = null;
private lastPlanTo: T | null = null;
private lastPlan = null as ReturnType<
NonNullable<PathFinder<T>["planSegments"]>
>;
constructor(
private inner: PathFinder<T>,
private getComponent: (t: T) => number,
@@ -30,6 +36,43 @@ export class ComponentCheckTransformer<T> implements PathFinder<T> {
// 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;
}
}
@@ -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<number> {
private lastPlanFrom: TileRef | TileRef[] | null = null;
private lastPlanTo: TileRef | null = null;
private lastPlan: SegmentPlan | null = null;
constructor(
private inner: PathFinder<number>,
private map: GameMap,
@@ -29,6 +33,9 @@ export class MiniMapTransformer implements PathFinder<number> {
// 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<number> {
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),
@@ -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<number> {
private lastPlanFrom: TileRef | TileRef[] | null = null;
private lastPlanTo: TileRef | null = null;
private lastPlan: SegmentPlan | null = null;
constructor(
private inner: PathFinder<number>,
private map: GameMap,
@@ -37,13 +41,28 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
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<number> {
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.
@@ -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<TileRef> {
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<TileRef>,
@@ -38,14 +41,43 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
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<TileRef> {
);
// 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<TileRef> {
}
// 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;
+12
View File
@@ -20,8 +20,20 @@ export type PathResult<T> =
*/
export interface PathFinder<T> {
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.