mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:10:46 +00:00
Optimize mover rendering and segment plan pipeline
This commit is contained in:
@@ -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.
|
||||
@@ -482,9 +482,15 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
const tickLayerDurations: Record<string, number> = {};
|
||||
const layerCounters: Record<string, Record<string, number>> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -508,8 +514,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);
|
||||
}
|
||||
|
||||
@@ -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<string, number>;
|
||||
renderLayer?: (context: CanvasRenderingContext2D) => void;
|
||||
shouldTransform?: () => boolean;
|
||||
redraw?: () => void;
|
||||
|
||||
@@ -141,6 +141,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
@state()
|
||||
private renderLastTickLayerDurations: Record<string, number> = {};
|
||||
|
||||
@state()
|
||||
private layerCounters: Record<string, Record<string, number>> = {};
|
||||
|
||||
// Smoothed per-layer render-per-tick timings (EMA over recent ticks)
|
||||
private renderPerTickLayerStats: Map<
|
||||
string,
|
||||
@@ -638,6 +641,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;
|
||||
@@ -832,6 +836,11 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
this.tickLayerBreakdown = breakdown;
|
||||
}
|
||||
|
||||
updateLayerCounters(counters: Record<string, Record<string, number>>) {
|
||||
if (!this.isVisible) return;
|
||||
this.layerCounters = counters;
|
||||
}
|
||||
|
||||
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
|
||||
if (!this.isVisible) return;
|
||||
|
||||
@@ -947,6 +956,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
},
|
||||
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
|
||||
tickLayers: this.tickLayerBreakdown.map((layer) => ({ ...layer })),
|
||||
layerCounters: { ...this.layerCounters },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1022,6 +1032,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
|
||||
const renderLayersToShow = this.layerBreakdown.slice(0, 10);
|
||||
const tickLayersToShow = this.tickLayerBreakdown.slice(0, 10);
|
||||
const unitLayerCounters = this.layerCounters.UnitLayer ?? null;
|
||||
|
||||
const maxLayerAvg =
|
||||
renderLayersToShow.length > 0
|
||||
@@ -1219,6 +1230,25 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
: html``}
|
||||
</div>`
|
||||
: html``}
|
||||
${unitLayerCounters
|
||||
? html`<div class="layers-section">
|
||||
<div class="performance-line section-header">
|
||||
<span>UnitLayer Counters</span>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
sampled: ${Number(unitLayerCounters.moversSampled ?? 0)}
|
||||
drawn: ${Number(unitLayerCounters.moversDrawn ?? 0)}
|
||||
skipped: ${Number(unitLayerCounters.moversSkipped ?? 0)}
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
queue: ${Number(unitLayerCounters.queueSize ?? 0)}
|
||||
budget: ${Number(unitLayerCounters.budgetUsedMs ?? 0).toFixed(
|
||||
2,
|
||||
)}ms
|
||||
avgDebt: ${Number(unitLayerCounters.avgDebt ?? 0).toFixed(2)}
|
||||
</div>
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function pruneInactiveTrails<TNuke, TTransport>(
|
||||
nukeTrails: Map<number, TNuke>,
|
||||
transportTrails: Map<number, TTransport>,
|
||||
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 };
|
||||
}
|
||||
@@ -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<UnitView, TileRef[]>();
|
||||
private unitToTrail = new Map<number, TileRef[]>();
|
||||
|
||||
private gridMoverUnitIds = new Set<number>();
|
||||
|
||||
private transportShipTrails = new Map<
|
||||
number,
|
||||
{
|
||||
xy: number[];
|
||||
planId: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
lastOnScreen: boolean;
|
||||
}
|
||||
>();
|
||||
private transportShipTrailDirty = false;
|
||||
private transportShipTrails = new Map<number, TransportTrailState>();
|
||||
private trailDirty = false;
|
||||
|
||||
private moverState = new Map<number, MoverRenderState>();
|
||||
private motionQueue = new UnitMotionRenderQueue();
|
||||
private renderFrame = 0;
|
||||
private lastPerfCounters: Record<string, number> = {
|
||||
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<number>();
|
||||
@@ -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<number>();
|
||||
|
||||
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<number>,
|
||||
): {
|
||||
sampled: number;
|
||||
drawn: number;
|
||||
skipped: number;
|
||||
budgetUsedMs: number;
|
||||
} {
|
||||
const frameStartMs = performance.now();
|
||||
const drawnIds = new Set<number>();
|
||||
|
||||
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<number>(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<number>, b: Set<number>): 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<string, number> {
|
||||
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<number>,
|
||||
): 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<number>): 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) {
|
||||
|
||||
@@ -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<UnitMotionRenderQueueEntry>(
|
||||
(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<UnitMotionRenderQueueEntry>(
|
||||
(a, b) => a.priority > b.priority,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>): {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -111,11 +111,42 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
});
|
||||
|
||||
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<T> implements SteppingPathFinder<T> {
|
||||
// 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<T> implements SteppingPathFinder<T> {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -130,9 +130,63 @@ export class MiniMapTransformer implements PathFinder<number> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number, number[]>([
|
||||
[10, [1, 2, 3]],
|
||||
[11, [4, 5]],
|
||||
]);
|
||||
const transportTrails = new Map<number, { xy: number[] }>([
|
||||
[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<number, number[]>([[1, [1]]]);
|
||||
const transportTrails = new Map<number, { xy: number[] }>([
|
||||
[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);
|
||||
});
|
||||
});
|
||||
@@ -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<number, number>([[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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user