From 2cda35fb40e60b572ebb6452d21b4e50e6a8dc5f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:06:48 +0100 Subject: [PATCH] Optimize mover rendering and segment plan pipeline --- mover-optim-status.md | 83 +++ src/client/graphics/GameRenderer.ts | 14 + src/client/graphics/layers/Layer.ts | 1 + .../graphics/layers/PerformanceOverlay.ts | 30 + src/client/graphics/layers/TrailLifecycle.ts | 26 + src/client/graphics/layers/UnitLayer.ts | 630 +++++++++++++----- .../graphics/layers/UnitMotionRenderQueue.ts | 43 ++ src/core/execution/TradeShipExecution.ts | 37 +- src/core/execution/TransportShipExecution.ts | 58 +- src/core/game/MotionPlans.ts | 89 --- src/core/pathfinding/PathFinder.ts | 1 + src/core/pathfinding/PathFinderStepper.ts | 60 +- .../transformers/MiniMapTransformer.ts | 58 +- tests/MiniMapTransformerPlanSegments.test.ts | 49 ++ tests/PathFinderStepperPriming.test.ts | 4 +- tests/UnitLayerTrailLifecycle.test.ts | 42 ++ tests/UnitMotionRenderQueue.test.ts | 53 ++ 17 files changed, 924 insertions(+), 354 deletions(-) create mode 100644 mover-optim-status.md create mode 100644 src/client/graphics/layers/TrailLifecycle.ts create mode 100644 src/client/graphics/layers/UnitMotionRenderQueue.ts create mode 100644 tests/UnitLayerTrailLifecycle.test.ts create mode 100644 tests/UnitMotionRenderQueue.test.ts diff --git a/mover-optim-status.md b/mover-optim-status.md new file mode 100644 index 000000000..afcaa0af2 --- /dev/null +++ b/mover-optim-status.md @@ -0,0 +1,83 @@ +# Mover Rendering Optimization Status + +## Goal + Scope Snapshot +- Goal: stabilize mover rendering under load and remove dense motion-plan fallback work in runtime ship execution. +- Scope: pathfinding motion plan pipeline (`PathFinding`, `PathFinderStepper`, `MiniMapTransformer`), ship executions, `UnitLayer` rendering/trails, perf overlay counters, and targeted tests. + +## Decision Log +- Motion smoothing remains linear segment interpolation (no Bézier). +- Budget model is soft: 3ms target + on-screen overrun allowance. +- Rendering model uses persistent canvases (static units + dynamic movers + trails). +- Dense runtime fallback generation in transport/trade executions is removed. +- Perf instrumentation is added to the in-game performance overlay. + +## Change Entries +### ID 1 +- Files changed: `mover-optim-status.md` +- What changed: Created the tracking document with required sections and format. +- Why changed: Plan requires a live engineering log documenting each change batch and rationale. +- Behavior impact: None. +- Perf impact expected: None. +- Validation done: File structure reviewed against requested format. + +### ID 2 +- Files changed: `src/core/pathfinding/PathFinder.ts`, `src/core/pathfinding/PathFinderStepper.ts`, `src/core/pathfinding/transformers/MiniMapTransformer.ts`, `src/core/execution/TransportShipExecution.ts`, `src/core/execution/TradeShipExecution.ts`, `src/core/game/MotionPlans.ts` +- What changed: Enabled smoothing in `WaterSimple` path pipeline, made `PathFinderStepper.findPath()` prime step cache, added collinear segment compression in `MiniMapTransformer` segment upscaling, removed dense LOS fallback usage from trade/transport ship plan emission, and removed now-unused dense LOS fallback helper from `MotionPlans`. +- Why changed: Remove duplicated path work, guarantee segment-plan availability in runtime water path configurations, reduce jagged keypoint verbosity at minimap boundary, and eliminate dense-to-sparse recomputation in ship execution loops. +- Behavior impact: Trade/transport motion plan emission now relies on pathfinder-native `planSegments` with defensive single-point fallback only if unexpectedly unavailable. +- Perf impact expected: Fewer redundant `findPath` calls, reduced per-plan payload complexity after compression, and less runtime planning overhead in ship executions. +- Validation done: Pending targeted tests and type-check run. + +### ID 3 +- Files changed: `src/client/graphics/layers/UnitLayer.ts`, `src/client/graphics/layers/UnitMotionRenderQueue.ts` +- What changed: Reworked mover rendering to persistent dynamic-canvas drawing with a versioned priority queue scheduler; introduced soft 3ms budget (+on-screen overrun), off-screen throttling cadence, and per-unit mover state (plan/version/error/debt/rect); unified trail rendering onto a single trail canvas rebuilt from transport+nuke trail stores; switched nuke trail storage to unit-id keyed maps with explicit dirty/rebuild lifecycle. +- Why changed: Prevent frame-local disappearance when budget is exhausted, prioritize visible movers deterministically, and simplify/repair trail lifecycle consistency. +- Behavior impact: Motion-planned units now persist visually between frames even when skipped by budget; transport trails remain until despawn; nuke trail cleanup is driven by tracked unit ids. +- Perf impact expected: Reduced redraw churn (targeted rect clears), bounded per-frame mover work, and fewer full-context draw operations. +- Validation done: Pending targeted tests and runtime checks. + +### ID 4 +- Files changed: `src/client/graphics/layers/Layer.ts`, `src/client/graphics/GameRenderer.ts`, `src/client/graphics/layers/PerformanceOverlay.ts`, `src/client/graphics/layers/UnitLayer.ts` +- What changed: Added optional layer perf-counter API, wired renderer tick loop to collect and forward counters, added `PerformanceOverlay.updateLayerCounters(...)` and a UnitLayer counters panel, and exposed UnitLayer queue/budget counters via `getPerfCounters()`. +- Why changed: Provide visibility into whether the new mover scheduler respects budget and where skips/debt accumulate. +- Behavior impact: Performance overlay can now show live UnitLayer operational counters when visible. +- Perf impact expected: Negligible overhead; counters are lightweight numeric snapshots. +- Validation done: Pending targeted tests and smoke run. + +### ID 5 +- Files changed: `tests/PathFinderStepperPriming.test.ts`, `tests/MiniMapTransformerPlanSegments.test.ts`, `tests/UnitMotionRenderQueue.test.ts`, `tests/UnitLayerTrailLifecycle.test.ts`, `src/client/graphics/layers/TrailLifecycle.ts`, `src/client/graphics/layers/UnitLayer.ts` +- What changed: Updated stepper priming expectation, added minimap segment-compression invariant test, added queue ordering/stale-entry tests, and added trail lifecycle pruning tests via a new pure helper used by `UnitLayer`. +- Why changed: Cover the new runtime behavior with focused tests and keep trail cleanup logic testable without DOM canvas harness complexity. +- Behavior impact: No runtime feature change beyond factoring trail cleanup into a helper. +- Perf impact expected: None in production; helper is linear-time over existing trail maps. +- Validation done: Pending execution of targeted vitest files. + +### ID 6 +- Files changed: `mover-optim-status.md` +- What changed: Recorded targeted and broader validation runs with pass status. +- Why changed: Close the loop on implementation quality and keep audit trail in a single status document. +- Behavior impact: None. +- Perf impact expected: None. +- Validation done: + - `npx vitest run tests/PathFinderStepperPriming.test.ts tests/MiniMapTransformerPlanSegments.test.ts tests/UnitMotionRenderQueue.test.ts tests/UnitLayerTrailLifecycle.test.ts` ✅ + - `npx vitest run tests/MotionPlansSegments.test.ts tests/SmoothingWaterTransformerPlanSegments.test.ts tests/MiniMapTransformerPlanSegments.test.ts tests/PathFinderStepperPriming.test.ts tests/UnitMotionRenderQueue.test.ts tests/UnitLayerTrailLifecycle.test.ts` ✅ + +### ID 7 +- Files changed: `mover-optim-status.md` +- What changed: Added build/type-check validation outcome. +- Why changed: Confirm no TypeScript or build regressions in production code paths, including `UnitLayer` and overlay integration. +- Behavior impact: None. +- Perf impact expected: None. +- Validation done: + - `npm run build-dev` ✅ (`tsc --noEmit` + vite build) + - Existing non-blocking build warnings noted (pre-existing JSON import-attributes consistency warnings, chunk-size warnings). + +## Validation Log +- Targeted mover/path tests passed (7/7). +- Broader related motion-plan/pathfinding subset passed (10/10). +- Note: vitest required escalated execution in this environment due `esbuild` spawn permissions (`EPERM` without escalation). +- Type-check + development build passed via `npm run build-dev`. + +## Open Risks / Follow-ups +- Large `UnitLayer` refactor has integration risk (canvas composition + trail lifecycle + budgeting). +- Need targeted tests to cover queue semantics and path compression invariants. diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 8956214d6..ea7748f8b 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -484,9 +484,15 @@ export class GameRenderer { } const tickLayerDurations: Record = {}; + const layerCounters: Record> = {}; for (const layer of this.layers) { if (!layer.tick) { + const counters = layer.getPerfCounters?.(); + if (counters && Object.keys(counters).length > 0) { + const label = layer.constructor?.name ?? "UnknownLayer"; + layerCounters[label] = counters; + } continue; } @@ -510,8 +516,16 @@ export class GameRenderer { const label = layer.constructor?.name ?? "UnknownLayer"; tickLayerDurations[label] = (tickLayerDurations[label] ?? 0) + duration; } + + const counters = layer.getPerfCounters?.(); + if (counters && Object.keys(counters).length > 0) { + const label = layer.constructor?.name ?? "UnknownLayer"; + layerCounters[label] = counters; + } } + this.performanceOverlay.updateLayerCounters(layerCounters); + if (shouldProfileTick) { this.performanceOverlay.updateTickLayerMetrics(tickLayerDurations); } diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 456648f79..9cb6b91c2 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -4,6 +4,7 @@ export interface Layer { // Optional hint to throttle expensive ticks by wall-clock. // If omitted or <= 0, the layer ticks whenever GameRenderer ticks. getTickIntervalMs?: () => number; + getPerfCounters?: () => Record; renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ec08d024b..cd841752c 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -130,6 +130,9 @@ export class PerformanceOverlay extends LitElement implements Layer { @state() private renderLastTickLayerDurations: Record = {}; + @state() + private layerCounters: Record> = {}; + // Smoothed per-layer render-per-tick timings (EMA over recent ticks) private renderPerTickLayerStats: Map< string, @@ -730,6 +733,7 @@ export class PerformanceOverlay extends LitElement implements Layer { this.renderLastTickFrameCount = 0; this.renderLastTickLayerTotalMs = 0; this.renderLastTickLayerDurations = {}; + this.layerCounters = {}; this.renderPerTickLayerStats.clear(); this.renderLayersExpanded = false; this.tickLayersExpanded = false; @@ -900,6 +904,11 @@ export class PerformanceOverlay extends LitElement implements Layer { }); } + updateLayerCounters(counters: Record>) { + if (!this.isVisible) return; + this.layerCounters = counters; + } + updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { if (!this.isVisible) return; @@ -1019,6 +1028,7 @@ export class PerformanceOverlay extends LitElement implements Layer { tickLayers: PerformanceOverlay.computeLayerBreakdown( this.tickLayerStats, ).map((layer) => ({ ...layer })), + layerCounters: { ...this.layerCounters }, }; } @@ -1102,6 +1112,7 @@ export class PerformanceOverlay extends LitElement implements Layer { const renderLayersToShow = renderLayerBreakdown.slice(0, 10); const tickLayersToShow = tickLayerBreakdown.slice(0, 10); + const unitLayerCounters = this.layerCounters.UnitLayer ?? null; const maxLayerAvg = renderLayersToShow.length > 0 @@ -1289,6 +1300,25 @@ export class PerformanceOverlay extends LitElement implements Layer { : html``} ` : html``} + ${unitLayerCounters + ? html`
+
+ UnitLayer Counters +
+
+ sampled: ${Number(unitLayerCounters.moversSampled ?? 0)} + drawn: ${Number(unitLayerCounters.moversDrawn ?? 0)} + skipped: ${Number(unitLayerCounters.moversSkipped ?? 0)} +
+
+ queue: ${Number(unitLayerCounters.queueSize ?? 0)} + budget: ${Number(unitLayerCounters.budgetUsedMs ?? 0).toFixed( + 2, + )}ms + avgDebt: ${Number(unitLayerCounters.avgDebt ?? 0).toFixed(2)} +
+
` + : html``} `; diff --git a/src/client/graphics/layers/TrailLifecycle.ts b/src/client/graphics/layers/TrailLifecycle.ts new file mode 100644 index 000000000..2fb0f5b91 --- /dev/null +++ b/src/client/graphics/layers/TrailLifecycle.ts @@ -0,0 +1,26 @@ +export function pruneInactiveTrails( + nukeTrails: Map, + transportTrails: Map, + isActive: (unitId: number) => boolean, +): { removedNukes: number; removedTransport: number } { + let removedNukes = 0; + let removedTransport = 0; + + for (const unitId of nukeTrails.keys()) { + if (isActive(unitId)) { + continue; + } + nukeTrails.delete(unitId); + removedNukes++; + } + + for (const unitId of transportTrails.keys()) { + if (isActive(unitId)) { + continue; + } + transportTrails.delete(unitId); + removedTransport++; + } + + return { removedNukes, removedTransport }; +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index a68a9e34b..175ec71fd 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -16,6 +16,11 @@ import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { sampleGridSegmentPlan } from "./SegmentMotionSample"; +import { + UnitMotionRenderQueue, + UnitMotionRenderQueueEntry, +} from "./UnitMotionRenderQueue"; +import { pruneInactiveTrails } from "./TrailLifecycle"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { @@ -30,30 +35,69 @@ enum Relationship { Enemy, } +const UNIT_DRAW_BUDGET_MS = 3; +const UNIT_DRAW_SOFT_OVERRUN_MS = 1; +const OFFSCREEN_REFRESH_EVERY_N_FRAMES = 6; +const MOVER_ONSCREEN_BOOST = 1_000_000_000; +const MOVER_AGE_WEIGHT = 1; +const MOVER_ERROR_WEIGHT = 2; +const MOVER_DEBT_WEIGHT = 8; + +type TransportTrailState = { + xy: number[]; + planId: number; + lastX: number; + lastY: number; + lastOnScreen: boolean; +}; + +type MoverSpriteRect = { + x: number; + y: number; + w: number; + h: number; +}; + +type MoverRenderState = { + planId: number; + lastRenderedX: number; + lastRenderedY: number; + lastRenderedAtMs: number; + lastErrorPx: number; + lastSpriteRect: MoverSpriteRect | null; + lastOnScreen: boolean; + queueVersion: number; + skipDebt: number; + lastSeenFrame: number; +}; + export class UnitLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; - private unitTrailCanvas: HTMLCanvasElement; - private unitTrailContext: CanvasRenderingContext2D; - private transportShipTrailCanvas: HTMLCanvasElement; - private transportShipTrailContext: CanvasRenderingContext2D; + private dynamicMoverCanvas: HTMLCanvasElement; + private dynamicMoverContext: CanvasRenderingContext2D; + private trailCanvas: HTMLCanvasElement; + private trailContext: CanvasRenderingContext2D; // Pixel trails (currently only used for nukes). - private unitToTrail = new Map(); + private unitToTrail = new Map(); private gridMoverUnitIds = new Set(); - private transportShipTrails = new Map< - number, - { - xy: number[]; - planId: number; - lastX: number; - lastY: number; - lastOnScreen: boolean; - } - >(); - private transportShipTrailDirty = false; + private transportShipTrails = new Map(); + private trailDirty = false; + + private moverState = new Map(); + private motionQueue = new UnitMotionRenderQueue(); + private renderFrame = 0; + private lastPerfCounters: Record = { + moversSampled: 0, + moversDrawn: 0, + moversSkipped: 0, + queueSize: 0, + budgetUsedMs: 0, + avgDebt: 0, + }; private theme: Theme; @@ -83,14 +127,16 @@ export class UnitLayer implements Layer { } tick() { - // Cleanup trails for nukes that were removed without a final inactive update event. - // These trails are stored outside of the normal unit sprite lifecycle. - const trailUnits = Array.from(this.unitToTrail.keys()); - for (const unit of trailUnits) { - const current = this.game.unit(unit.id()); - if (!current || !current.isActive()) { - this.clearTrail(unit); - } + const trailPrune = pruneInactiveTrails( + this.unitToTrail, + this.transportShipTrails, + (unitId) => { + const current = this.game.unit(unitId); + return !!current && current.isActive(); + }, + ); + if (trailPrune.removedNukes > 0 || trailPrune.removedTransport > 0) { + this.trailDirty = true; } const gridMoverUnitIds = new Set(); @@ -104,8 +150,8 @@ export class UnitLayer implements Layer { ); if (moverSetChanged) { this.gridMoverUnitIds = gridMoverUnitIds; + this.pruneMoverStates(gridMoverUnitIds); this.redrawStaticSprites(); - return; } const updatedUnitIds = @@ -131,6 +177,7 @@ export class UnitLayer implements Layer { if (unitIds.size > 0) { this.updateUnitsSprites(Array.from(unitIds)); } + } init() { @@ -265,93 +312,57 @@ export class UnitLayer implements Layer { } renderLayer(context: CanvasRenderingContext2D) { - const moversToDraw: Array<{ unit: UnitView; x: number; y: number }> = []; - + this.renderFrame++; const tickAlpha = this.computeTickAlpha(); const tickFloat = this.game.ticks() + tickAlpha; + const nowMs = performance.now(); + const activeMoverIds = new Set(); for (const [unitId, plan] of this.game.motionPlans()) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { - if (this.transportShipTrails.delete(unitId)) { - this.transportShipTrailDirty = true; - } + this.clearMoverState(unitId); + if (this.transportShipTrails.delete(unitId)) this.trailDirty = true; continue; } + activeMoverIds.add(unitId); - const sampled = sampleGridSegmentPlan(this.game, plan, tickFloat); - if (!sampled) { - continue; - } - - const onScreen = this.transformHandler.isOnScreen( - new Cell(Math.floor(sampled.x), Math.floor(sampled.y)), + const onScreenHint = this.transformHandler.isOnScreen( + new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())), ); + const state = this.ensureMoverState(unitId, plan.planId, nowMs); + state.lastSeenFrame = this.renderFrame; - if (unit.type() === UnitType.TransportShip) { - const existing = this.transportShipTrails.get(unitId); - if (!existing || existing.planId !== plan.planId) { - const xy: number[] = onScreen ? [sampled.x, sampled.y] : []; - this.transportShipTrails.set(unitId, { - xy, - planId: plan.planId, - lastX: sampled.x, - lastY: sampled.y, - lastOnScreen: onScreen, - }); - if (onScreen) { - this.transportShipTrailDirty = true; - } - } else { - if ( - onScreen && - (existing.lastX !== sampled.x || existing.lastY !== sampled.y) - ) { - if (!existing.lastOnScreen && existing.xy.length > 0) { - existing.xy.push(Number.NaN, Number.NaN); - } - existing.xy.push(sampled.x, sampled.y); - this.transportShipTrailDirty = true; - } else if (onScreen && existing.xy.length === 0) { - existing.xy.push(sampled.x, sampled.y); - this.transportShipTrailDirty = true; - } + if (!onScreenHint && state.lastOnScreen && state.lastSpriteRect) { + this.clearMoverRect(state.lastSpriteRect); + state.lastOnScreen = false; + } - existing.lastX = sampled.x; - existing.lastY = sampled.y; - existing.lastOnScreen = onScreen; - } - - if (onScreen) { - moversToDraw.push({ unit, x: sampled.x, y: sampled.y }); - } + if ( + !onScreenHint && + ((this.renderFrame + unitId) % OFFSCREEN_REFRESH_EVERY_N_FRAMES !== 0) && + state.skipDebt < 2 + ) { continue; } - if (onScreen) { - moversToDraw.push({ unit, x: sampled.x, y: sampled.y }); - } + const entry: UnitMotionRenderQueueEntry = { + unitId, + version: (state.queueVersion = (state.queueVersion + 1) >>> 0), + priority: this.computeMoverPriority(state, onScreenHint, nowMs), + onScreenHint, + }; + this.motionQueue.enqueue(entry); } - // Remove transport-ship trails when the unit is gone (no fade during movement). - for (const unitId of this.transportShipTrails.keys()) { - const unit = this.game.unit(unitId); - if (!unit || !unit.isActive()) { - this.transportShipTrails.delete(unitId); - this.transportShipTrailDirty = true; - } - } - this.rebuildTransportShipTrailCanvasIfDirty(); + this.pruneMoverStates(activeMoverIds); + + const moverPerf = this.drawQueuedMovers(tickFloat, activeMoverIds); + + this.rebuildTrailCanvasIfDirty(); context.drawImage( - this.unitTrailCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - context.drawImage( - this.transportShipTrailCanvas, + this.trailCanvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), @@ -364,16 +375,162 @@ export class UnitLayer implements Layer { this.game.width(), this.game.height(), ); + context.drawImage( + this.dynamicMoverCanvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); - for (const mover of moversToDraw) { - this.drawSpriteAt( - mover.unit, - mover.x - this.game.width() / 2, - mover.y - this.game.height() / 2, - context, + let totalDebt = 0; + let debtCount = 0; + for (const unitId of activeMoverIds) { + const state = this.moverState.get(unitId); + if (!state) continue; + totalDebt += state.skipDebt; + debtCount++; + } + + this.lastPerfCounters = { + moversSampled: moverPerf.sampled, + moversDrawn: moverPerf.drawn, + moversSkipped: moverPerf.skipped, + queueSize: this.motionQueue.size(), + budgetUsedMs: moverPerf.budgetUsedMs, + avgDebt: debtCount > 0 ? totalDebt / debtCount : 0, + }; + } + + private drawQueuedMovers( + tickFloat: number, + activeMoverIds: Set, + ): { + sampled: number; + drawn: number; + skipped: number; + budgetUsedMs: number; + } { + const frameStartMs = performance.now(); + const drawnIds = new Set(); + + let sampled = 0; + let drawn = 0; + let skipped = 0; + + for (;;) { + const entry = this.motionQueue.pollValid((candidate) => + this.isValidQueueEntry(candidate, activeMoverIds), + ); + if (!entry) { + break; + } + + const elapsedMs = performance.now() - frameStartMs; + const canDrawWithinTarget = elapsedMs < UNIT_DRAW_BUDGET_MS; + const canDrawOnScreenOverrun = + entry.onScreenHint && + elapsedMs < UNIT_DRAW_BUDGET_MS + UNIT_DRAW_SOFT_OVERRUN_MS; + if (!canDrawWithinTarget && !canDrawOnScreenOverrun) { + skipped++; + break; + } + + const unit = this.game.unit(entry.unitId); + const plan = this.game.motionPlans().get(entry.unitId); + const state = this.moverState.get(entry.unitId); + if (!unit || !unit.isActive() || !plan || !state) { + this.clearMoverState(entry.unitId); + skipped++; + continue; + } + + sampled++; + const sampledPos = sampleGridSegmentPlan(this.game, plan, tickFloat); + if (!sampledPos) { + skipped++; + continue; + } + + const onScreen = this.transformHandler.isOnScreen( + new Cell(Math.floor(sampledPos.x), Math.floor(sampledPos.y)), + ); + + if (!onScreen) { + if (state.lastOnScreen && state.lastSpriteRect) { + this.clearMoverRect(state.lastSpriteRect); + state.lastSpriteRect = null; + state.lastOnScreen = false; + } + if (unit.type() === UnitType.TransportShip) { + this.updateTransportShipTrail( + entry.unitId, + plan.planId, + sampledPos.x, + sampledPos.y, + false, + ); + } + skipped++; + continue; + } + + if (state.lastSpriteRect) { + this.clearMoverRect(state.lastSpriteRect); + } + const rect = this.drawSpriteAt( + unit, + sampledPos.x, + sampledPos.y, + this.dynamicMoverContext, false, ); + if (!rect) { + skipped++; + continue; + } + + const errorPx = Math.hypot( + sampledPos.x - state.lastRenderedX, + sampledPos.y - state.lastRenderedY, + ); + state.lastErrorPx = errorPx; + state.lastRenderedX = sampledPos.x; + state.lastRenderedY = sampledPos.y; + state.lastRenderedAtMs = performance.now(); + state.lastSpriteRect = rect; + state.lastOnScreen = true; + state.skipDebt = 0; + drawnIds.add(entry.unitId); + drawn++; + + if (unit.type() === UnitType.TransportShip) { + this.updateTransportShipTrail( + entry.unitId, + plan.planId, + sampledPos.x, + sampledPos.y, + true, + ); + } } + + for (const unitId of activeMoverIds) { + if (drawnIds.has(unitId)) { + continue; + } + const state = this.moverState.get(unitId); + if (state) { + state.skipDebt = (state.skipDebt + 1) >>> 0; + } + } + + return { + sampled, + drawn, + skipped, + budgetUsedMs: performance.now() - frameStartMs, + }; } onAlternativeViewEvent(event: AlternateViewEvent) { @@ -387,42 +544,30 @@ export class UnitLayer implements Layer { if (context === null) throw new Error("2d context not supported"); this.context = context; - this.unitTrailCanvas = document.createElement("canvas"); - const unitTrailContext = this.unitTrailCanvas.getContext("2d"); - if (unitTrailContext === null) throw new Error("2d context not supported"); - this.unitTrailContext = unitTrailContext; - - this.transportShipTrailCanvas = document.createElement("canvas"); - const transportTrailContext = - this.transportShipTrailCanvas.getContext("2d"); - if (transportTrailContext === null) + this.dynamicMoverCanvas = document.createElement("canvas"); + const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); + if (dynamicMoverContext === null) throw new Error("2d context not supported"); - this.transportShipTrailContext = transportTrailContext; + this.dynamicMoverContext = dynamicMoverContext; + + this.trailCanvas = document.createElement("canvas"); + const trailContext = this.trailCanvas.getContext("2d"); + if (trailContext === null) throw new Error("2d context not supported"); + this.trailContext = trailContext; this.canvas.width = this.game.width(); this.canvas.height = this.game.height(); - this.unitTrailCanvas.width = this.game.width(); - this.unitTrailCanvas.height = this.game.height(); - this.transportShipTrailCanvas.width = this.game.width(); - this.transportShipTrailCanvas.height = this.game.height(); + this.dynamicMoverCanvas.width = this.game.width(); + this.dynamicMoverCanvas.height = this.game.height(); + this.trailCanvas.width = this.game.width(); + this.trailCanvas.height = this.game.height(); this.gridMoverUnitIds = new Set(this.game.motionPlans().keys()); - this.transportShipTrailDirty = true; + this.moverState.clear(); + this.motionQueue.clear(); + this.trailDirty = true; this.redrawStaticSprites(); - - this.unitToTrail.forEach((trail, unit) => { - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - this.relationship(unit), - unit.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - }); } private setsEqual(a: Set, b: Set): boolean { @@ -454,15 +599,162 @@ export class UnitLayer implements Layer { return Math.max(0, Math.min(1, alpha)); } - private rebuildTransportShipTrailCanvasIfDirty(): void { - if (!this.transportShipTrailDirty) { + getPerfCounters(): Record { + return this.lastPerfCounters; + } + + private ensureMoverState( + unitId: number, + planId: number, + nowMs: number, + ): MoverRenderState { + const existing = this.moverState.get(unitId); + if (!existing) { + const state: MoverRenderState = { + planId, + lastRenderedX: 0, + lastRenderedY: 0, + lastRenderedAtMs: nowMs, + lastErrorPx: 0, + lastSpriteRect: null, + lastOnScreen: false, + queueVersion: 0, + skipDebt: 0, + lastSeenFrame: this.renderFrame, + }; + this.moverState.set(unitId, state); + return state; + } + + if (existing.planId !== planId) { + if (existing.lastSpriteRect) { + this.clearMoverRect(existing.lastSpriteRect); + } + existing.planId = planId; + existing.lastErrorPx = 0; + existing.lastOnScreen = false; + existing.lastSpriteRect = null; + existing.skipDebt = 0; + } + + return existing; + } + + private computeMoverPriority( + state: MoverRenderState, + onScreenHint: boolean, + nowMs: number, + ): number { + const ageMs = Math.max(0, nowMs - state.lastRenderedAtMs); + return ( + (onScreenHint ? MOVER_ONSCREEN_BOOST : 0) + + ageMs * MOVER_AGE_WEIGHT + + state.lastErrorPx * MOVER_ERROR_WEIGHT + + state.skipDebt * MOVER_DEBT_WEIGHT + ); + } + + private isValidQueueEntry( + entry: UnitMotionRenderQueueEntry, + activeMoverIds: Set, + ): boolean { + if (!activeMoverIds.has(entry.unitId)) { + return false; + } + const state = this.moverState.get(entry.unitId); + return state !== undefined && state.queueVersion === entry.version; + } + + private pruneMoverStates(activeMoverIds: Set): void { + for (const [unitId, state] of this.moverState) { + if (activeMoverIds.has(unitId)) { + continue; + } + if (state.lastSpriteRect) { + this.clearMoverRect(state.lastSpriteRect); + } + this.moverState.delete(unitId); + } + } + + private clearMoverState(unitId: number): void { + const state = this.moverState.get(unitId); + if (state?.lastSpriteRect) { + this.clearMoverRect(state.lastSpriteRect); + } + this.moverState.delete(unitId); + } + + private clearMoverRect(rect: MoverSpriteRect): void { + this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h); + } + + private updateTransportShipTrail( + unitId: number, + planId: number, + x: number, + y: number, + onScreen: boolean, + ): void { + const existing = this.transportShipTrails.get(unitId); + if (!existing || existing.planId !== planId) { + const xy: number[] = onScreen ? [x, y] : []; + this.transportShipTrails.set(unitId, { + xy, + planId, + lastX: x, + lastY: y, + lastOnScreen: onScreen, + }); + if (onScreen) { + this.trailDirty = true; + } return; } - this.transportShipTrailDirty = false; - const ctx = this.transportShipTrailContext; + if (onScreen && (existing.lastX !== x || existing.lastY !== y)) { + if (!existing.lastOnScreen && existing.xy.length > 0) { + existing.xy.push(Number.NaN, Number.NaN); + } + existing.xy.push(x, y); + this.trailDirty = true; + } else if (onScreen && existing.xy.length === 0) { + existing.xy.push(x, y); + this.trailDirty = true; + } + + existing.lastX = x; + existing.lastY = y; + existing.lastOnScreen = onScreen; + } + + private rebuildTrailCanvasIfDirty(): void { + if (!this.trailDirty) { + return; + } + this.trailDirty = false; + + const ctx = this.trailContext; ctx.clearRect(0, 0, this.game.width(), this.game.height()); + for (const [unitId, trail] of this.unitToTrail) { + const unit = this.game.unit(unitId); + if (!unit || !unit.isActive()) { + continue; + } + const rel = this.relationship(unit); + for (const tile of trail) { + this.paintCell( + this.game.x(tile), + this.game.y(tile), + rel, + unit.owner().territoryColor(), + 150, + ctx, + ); + } + } + for (const [unitId, trail] of this.transportShipTrails) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { @@ -661,55 +953,20 @@ export class UnitLayer implements Layer { this.drawSprite(unit); } - private drawTrail(trail: number[], color: Colord, rel: Relationship) { - // Paint new trail - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - color, - 150, - this.unitTrailContext, - ); - } - } - - private clearTrail(unit: UnitView) { - const trail = this.unitToTrail.get(unit) ?? []; - const rel = this.relationship(unit); - for (const t of trail) { - this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext); - } - this.unitToTrail.delete(unit); - - // Repaint overlapping trails - const trailSet = new Set(trail); - for (const [other, trail] of this.unitToTrail) { - for (const t of trail) { - if (trailSet.has(t)) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - other.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - } + private clearTrail(unitId: number) { + if (this.unitToTrail.delete(unitId)) { + this.trailDirty = true; } } private handleNuke(unit: UnitView) { - const rel = this.relationship(unit); + const unitId = unit.id(); - if (!this.unitToTrail.has(unit)) { - this.unitToTrail.set(unit, []); + if (!this.unitToTrail.has(unitId)) { + this.unitToTrail.set(unitId, []); } - let newTrailSize = 1; - const trail = this.unitToTrail.get(unit) ?? []; + const trail = this.unitToTrail.get(unitId) ?? []; // It can move faster than 1 pixel, draw a line for the trail or else it will be dotted if (trail.length >= 1) { const cur = { @@ -726,19 +983,14 @@ export class UnitLayer implements Layer { trail.push(this.game.ref(point.x, point.y)); point = line.increment(); } - newTrailSize = line.size(); } else { trail.push(unit.lastTile()); } - this.drawTrail( - trail.slice(-newTrailSize), - unit.owner().territoryColor(), - rel, - ); + this.trailDirty = true; this.drawSprite(unit); if (!unit.isActive()) { - this.clearTrail(unit); + this.clearTrail(unitId); } } @@ -813,7 +1065,7 @@ export class UnitLayer implements Layer { ctx: CanvasRenderingContext2D = this.context, roundCoords: boolean = true, customTerritoryColor?: Colord, - ) { + ): MoverSpriteRect | null { let alternateViewColor: Colord | null = null; if (this.alternateView) { @@ -839,7 +1091,7 @@ export class UnitLayer implements Layer { ); if (!unit.isActive()) { - return; + return null; } const targetable = unit.targetable(); @@ -850,15 +1102,25 @@ export class UnitLayer implements Layer { const drawX = x - sprite.width / 2; const drawY = y - sprite.height / 2; + const outX = roundCoords ? Math.round(drawX) : drawX; + const outY = roundCoords ? Math.round(drawY) : drawY; ctx.drawImage( sprite, - roundCoords ? Math.round(drawX) : drawX, - roundCoords ? Math.round(drawY) : drawY, + outX, + outY, sprite.width, sprite.width, ); ctx.restore(); + + const pad = 1; + return { + x: outX - pad, + y: outY - pad, + w: sprite.width + pad * 2, + h: sprite.width + pad * 2, + }; } private drawSprite(unit: UnitView, customTerritoryColor?: Colord) { diff --git a/src/client/graphics/layers/UnitMotionRenderQueue.ts b/src/client/graphics/layers/UnitMotionRenderQueue.ts new file mode 100644 index 000000000..63b0548c1 --- /dev/null +++ b/src/client/graphics/layers/UnitMotionRenderQueue.ts @@ -0,0 +1,43 @@ +import FastPriorityQueue from "fastpriorityqueue"; + +export type UnitMotionRenderQueueEntry = { + unitId: number; + version: number; + priority: number; + onScreenHint: boolean; +}; + +export class UnitMotionRenderQueue { + private queue = new FastPriorityQueue( + (a, b) => a.priority > b.priority, + ); + + enqueue(entry: UnitMotionRenderQueueEntry): void { + this.queue.add(entry); + } + + pollValid( + isValid: (entry: UnitMotionRenderQueueEntry) => boolean, + ): UnitMotionRenderQueueEntry | null { + while (!this.queue.isEmpty()) { + const entry = this.queue.poll(); + if (!entry) { + break; + } + if (isValid(entry)) { + return entry; + } + } + return null; + } + + size(): number { + return this.queue.size; + } + + clear(): void { + this.queue = new FastPriorityQueue( + (a, b) => a.priority > b.priority, + ); + } +} diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 647b46d3c..c55f3f8a9 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -8,7 +8,6 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { densePathToLosKeypointSegments } from "../game/MotionPlans"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { distSortUnit } from "../Util"; @@ -115,22 +114,7 @@ export class TradeShipExecution implements Execution { if (dst !== this.motionPlanDst) { this.motionPlanId++; const from = result.node; - 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), - }; + const segPlan = this.safeSegmentPlan(from, dst); this.mg.recordMotionPlan({ kind: "grid_segments", @@ -226,4 +210,23 @@ export class TradeShipExecution implements Execution { dstPort(): TileRef { return this._dstPort.tile(); } + + private safeSegmentPlan(from: TileRef, to: TileRef): { + points: Uint32Array; + segmentSteps: Uint32Array; + } { + const segPlan = this.pathFinder.planSegments?.(from, to); + if (segPlan) { + return segPlan; + } + + const map = this.mg.map(); + console.warn( + `TradeShipExecution: missing segment plan from (${map.x(from)},${map.y(from)}) to (${map.x(to)},${map.y(to)}); using defensive single-point fallback`, + ); + return { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; + } } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 0e872c019..6b1bb08c2 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -9,10 +9,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { - densePathToLosKeypointSegments, - MotionPlanRecord, -} from "../game/MotionPlans"; +import { MotionPlanRecord } from "../game/MotionPlans"; import { targetTransportTile } from "../game/TransportShipUtils"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; @@ -116,22 +113,7 @@ export class TransportShipExecution implements Execution { targetTile: this.dst, }); - 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 segPlan = this.safeSegmentPlan(this.src, this.dst); const motionPlan: MotionPlanRecord = { kind: "grid_segments", @@ -281,22 +263,7 @@ export class TransportShipExecution implements Execution { if (this.dst !== null && this.dst !== this.motionPlanDst) { this.motionPlanId++; const from = this.boat.tile(); - 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), - }; + const segPlan = this.safeSegmentPlan(from, this.dst); this.mg.recordMotionPlan({ kind: "grid_segments", @@ -318,4 +285,23 @@ export class TransportShipExecution implements Execution { isActive(): boolean { return this.active; } + + private safeSegmentPlan(from: TileRef, to: TileRef): { + points: Uint32Array; + segmentSteps: Uint32Array; + } { + const segPlan = this.pathFinder.planSegments?.(from, to); + if (segPlan) { + return segPlan; + } + + const map = this.mg.map(); + console.warn( + `TransportShipExecution: missing segment plan from (${map.x(from)},${map.y(from)}) to (${map.x(to)},${map.y(to)}); using defensive single-point fallback`, + ); + return { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; + } } diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index 4154d7447..a55d13f35 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -1,4 +1,3 @@ -import type { GameMap } from "./GameMap"; import { TileRef } from "./GameMap"; export enum PackedMotionPlanKind { @@ -292,91 +291,3 @@ export function densePathToKeypointSegments(path: ArrayLike): { segmentSteps: Uint32Array.from(segmentSteps), }; } - -function canTraverseDda( - map: GameMap, - from: TileRef, - to: TileRef, - isTraversable: (t: TileRef) => boolean, -): boolean { - const x0 = map.x(from); - const y0 = map.y(from); - const x1 = map.x(to); - const y1 = map.y(to); - - const dx = x1 - x0; - const dy = y1 - y0; - const steps = Math.max(Math.abs(dx), Math.abs(dy)); - if (steps === 0) { - return isTraversable(from); - } - - for (let t = 0; t <= steps; t++) { - const x = Math.round(x0 + (dx * t) / steps); - const y = Math.round(y0 + (dy * t) / steps); - if (!map.isValidCoord(x, y)) { - return false; - } - const ref = map.ref(x, y); - if (!isTraversable(ref)) { - return false; - } - } - - return true; -} - -export function densePathToLosKeypointSegments( - path: readonly TileRef[] | Uint32Array, - map: GameMap, - isTraversable: (t: TileRef) => boolean, -): { points: Uint32Array; segmentSteps: Uint32Array } | null { - const len = path.length >>> 0; - if (len === 0) { - return null; - } - - const first = (path[0] ?? 0) as TileRef; - if (len === 1) { - return { - points: Uint32Array.from([first >>> 0]), - segmentSteps: new Uint32Array(0), - }; - } - - const points: number[] = [first >>> 0]; - const segmentSteps: number[] = []; - - let i = 0; - while (i < len - 1) { - let best = i + 1; - let lo = i + 1; - let hi = len - 1; - - // Binary search for farthest "visible" point along the existing path. - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - const ok = canTraverseDda( - map, - path[i] as TileRef, - path[mid] as TileRef, - isTraversable, - ); - if (ok) { - best = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - - points.push((path[best] as TileRef) >>> 0); - segmentSteps.push(best - i); - i = best; - } - - return { - points: Uint32Array.from(points), - segmentSteps: Uint32Array.from(segmentSteps), - }; -} diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index f77776c36..2f59286e4 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -57,6 +57,7 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) + .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) .buildWithStepper(tileStepperConfig(game)); diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts index ecf70b6ca..1d0dad1fa 100644 --- a/src/core/pathfinding/PathFinderStepper.ts +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -111,11 +111,42 @@ export class PathFinderStepper implements SteppingPathFinder { }); if (allFailed) { + if (!Array.isArray(from)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } return null; } } - return this.finder.findPath(from, to); + const isSingleSource = !Array.isArray(from); + if (isSingleSource) { + if (this.lastTo === null || !this.config.equals(this.lastTo, to)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } + } + + const path = this.finder.findPath(from, to); + + if (isSingleSource) { + if (path === null) { + this.path = null; + this.pathIndex = 0; + return null; + } + + this.path = path; + this.pathIndex = 0; + if (path.length > 0 && this.config.equals(path[0], from)) { + this.pathIndex = 1; + } + this.lastTo = to; + } + + return path; } planSegments(from: T | T[], to: T): SegmentPlan | null { @@ -126,7 +157,7 @@ export class PathFinderStepper implements SteppingPathFinder { // 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); + this.findPath(from, to); return this.finder.planSegments(from, to); } @@ -148,28 +179,9 @@ export class PathFinderStepper implements SteppingPathFinder { }; } - 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; - } + const path = this.findPath(from, to); + if (path === null) { + return null; } return this.finder.planSegments(from, to); diff --git a/src/core/pathfinding/transformers/MiniMapTransformer.ts b/src/core/pathfinding/transformers/MiniMapTransformer.ts index f1b7dd320..f4682c5a5 100644 --- a/src/core/pathfinding/transformers/MiniMapTransformer.ts +++ b/src/core/pathfinding/transformers/MiniMapTransformer.ts @@ -130,9 +130,63 @@ export class MiniMapTransformer implements PathFinder { steps.push(segSteps >>> 0); } + const compressed = this.compressCollinearSegments(points, steps); + return { - points: Uint32Array.from(points), - segmentSteps: Uint32Array.from(steps), + points: Uint32Array.from(compressed.points), + segmentSteps: Uint32Array.from(compressed.segmentSteps), + }; + } + + private compressCollinearSegments( + points: number[], + segmentSteps: number[], + ): { points: number[]; segmentSteps: number[] } { + if (points.length <= 2 || segmentSteps.length <= 1) { + return { points, segmentSteps }; + } + + const outPoints: number[] = [points[0] >>> 0]; + const outSteps: number[] = []; + + let runSteps = segmentSteps[0] >>> 0; + let runDir = this.segmentDirection(points[0] as TileRef, points[1] as TileRef); + + for (let i = 1; i < segmentSteps.length; i++) { + const segDir = this.segmentDirection( + points[i] as TileRef, + points[i + 1] as TileRef, + ); + + if (segDir.dx === runDir.dx && segDir.dy === runDir.dy) { + runSteps = (runSteps + (segmentSteps[i] >>> 0)) >>> 0; + continue; + } + + outPoints.push(points[i] >>> 0); + outSteps.push(runSteps >>> 0); + runDir = segDir; + runSteps = segmentSteps[i] >>> 0; + } + + outPoints.push(points[points.length - 1] >>> 0); + outSteps.push(runSteps >>> 0); + + return { + points: outPoints, + segmentSteps: outSteps, + }; + } + + private segmentDirection( + from: TileRef, + to: TileRef, + ): { dx: number; dy: number } { + const dx = this.map.x(to) - this.map.x(from); + const dy = this.map.y(to) - this.map.y(from); + return { + dx: Math.sign(dx), + dy: Math.sign(dy), }; } diff --git a/tests/MiniMapTransformerPlanSegments.test.ts b/tests/MiniMapTransformerPlanSegments.test.ts index 50ba1a0f6..6c869e477 100644 --- a/tests/MiniMapTransformerPlanSegments.test.ts +++ b/tests/MiniMapTransformerPlanSegments.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { GameMapImpl } from "../src/core/game/GameMap"; import { densePathToKeypointSegments } from "../src/core/game/MotionPlans"; +import { MiniMapTransformer } from "../src/core/pathfinding/transformers/MiniMapTransformer"; function makeMap(width: number, height: number): GameMapImpl { return new GameMapImpl(width, height, new Uint8Array(width * height), 0); @@ -58,3 +59,51 @@ describe("densePathToKeypointSegments", () => { expect(expanded).toEqual(dense.map((t) => t >>> 0)); }); }); + +describe("MiniMapTransformer planSegments compression", () => { + it("preserves endpoints and total steps while merging collinear runs", () => { + const map = makeMap(10, 10); + const miniMap = makeMap(5, 5); + + const miniPath = [ + miniMap.ref(0, 0), + miniMap.ref(1, 0), + miniMap.ref(2, 0), + miniMap.ref(2, 1), + miniMap.ref(2, 2), + ]; + + const inner = { + findPath() { + return miniPath.slice(); + }, + planSegments() { + return { + points: Uint32Array.from(miniPath), + segmentSteps: Uint32Array.from([1, 1, 1, 1]), + }; + }, + }; + + const transformer = new MiniMapTransformer(inner as any, map, miniMap); + const from = map.ref(0, 0); + const to = map.ref(4, 4); + + const dense = transformer.findPath(from, to); + expect(dense).not.toBeNull(); + + const plan = transformer.planSegments(from, to); + expect(plan).not.toBeNull(); + if (!plan) return; + + expect(Array.from(plan.points)).toEqual([ + from >>> 0, + map.ref(4, 0) >>> 0, + to >>> 0, + ]); + expect(Array.from(plan.segmentSteps)).toEqual([4, 4]); + + const totalSteps = Array.from(plan.segmentSteps).reduce((a, b) => a + b, 0); + expect(totalSteps).toBe(8); + }); +}); diff --git a/tests/PathFinderStepperPriming.test.ts b/tests/PathFinderStepperPriming.test.ts index a63464c50..3e2a23385 100644 --- a/tests/PathFinderStepperPriming.test.ts +++ b/tests/PathFinderStepperPriming.test.ts @@ -3,7 +3,7 @@ import { PathFinderStepper } from "../src/core/pathfinding/PathFinderStepper"; import { PathStatus } from "../src/core/pathfinding/types"; describe("PathFinderStepper cache priming", () => { - it("does not prime next() cache via findPath()", () => { + it("primes next() cache via findPath()", () => { let calls = 0; const finder = { findPath(from: number | number[], to: number) { @@ -29,6 +29,6 @@ describe("PathFinderStepper cache priming", () => { if (r1.status === PathStatus.NEXT) { expect(r1.node).toBe(to); } - expect(calls).toBe(2); + expect(calls).toBe(1); }); }); diff --git a/tests/UnitLayerTrailLifecycle.test.ts b/tests/UnitLayerTrailLifecycle.test.ts new file mode 100644 index 000000000..728552596 --- /dev/null +++ b/tests/UnitLayerTrailLifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pruneInactiveTrails } from "../src/client/graphics/layers/TrailLifecycle"; + +describe("UnitLayer trail lifecycle helpers", () => { + it("removes transport and nuke trails for inactive units", () => { + const nukeTrails = new Map([ + [10, [1, 2, 3]], + [11, [4, 5]], + ]); + const transportTrails = new Map([ + [10, { xy: [1, 1, 2, 2] }], + [12, { xy: [5, 5, 6, 6] }], + ]); + + const result = pruneInactiveTrails( + nukeTrails, + transportTrails, + (unitId) => unitId === 11, + ); + + expect(result).toEqual({ removedNukes: 1, removedTransport: 2 }); + expect(Array.from(nukeTrails.keys())).toEqual([11]); + expect(transportTrails.size).toBe(0); + }); + + it("keeps all trails when units are active", () => { + const nukeTrails = new Map([[1, [1]]]); + const transportTrails = new Map([ + [2, { xy: [0, 0, 1, 1] }], + ]); + + const result = pruneInactiveTrails( + nukeTrails, + transportTrails, + () => true, + ); + + expect(result).toEqual({ removedNukes: 0, removedTransport: 0 }); + expect(nukeTrails.size).toBe(1); + expect(transportTrails.size).toBe(1); + }); +}); diff --git a/tests/UnitMotionRenderQueue.test.ts b/tests/UnitMotionRenderQueue.test.ts new file mode 100644 index 000000000..8c963e33d --- /dev/null +++ b/tests/UnitMotionRenderQueue.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + UnitMotionRenderQueue, + UnitMotionRenderQueueEntry, +} from "../src/client/graphics/layers/UnitMotionRenderQueue"; + +describe("UnitMotionRenderQueue", () => { + it("returns highest-priority entry first", () => { + const queue = new UnitMotionRenderQueue(); + queue.enqueue({ + unitId: 1, + version: 1, + priority: 10, + onScreenHint: false, + }); + queue.enqueue({ + unitId: 2, + version: 1, + priority: 20, + onScreenHint: true, + }); + + const first = queue.pollValid(() => true); + expect(first?.unitId).toBe(2); + }); + + it("skips stale entries when validator rejects old versions", () => { + const queue = new UnitMotionRenderQueue(); + const latestVersion = new Map([[42, 2]]); + + const stale: UnitMotionRenderQueueEntry = { + unitId: 42, + version: 1, + priority: 100, + onScreenHint: true, + }; + const fresh: UnitMotionRenderQueueEntry = { + unitId: 42, + version: 2, + priority: 50, + onScreenHint: true, + }; + + queue.enqueue(stale); + queue.enqueue(fresh); + + const picked = queue.pollValid((entry) => { + return latestVersion.get(entry.unitId) === entry.version; + }); + + expect(picked).toEqual(fresh); + }); +});