Optimize mover rendering and segment plan pipeline

This commit is contained in:
scamiv
2026-02-27 15:06:48 +01:00
parent 83a8dc00e4
commit 0a96ab8e30
17 changed files with 939 additions and 377 deletions
+14
View File
@@ -499,9 +499,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;
}
@@ -525,8 +531,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);
}
+1
View File
@@ -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;
@@ -134,6 +134,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,
@@ -728,6 +731,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;
@@ -898,6 +902,11 @@ export class PerformanceOverlay extends LitElement implements Layer {
});
}
updateLayerCounters(counters: Record<string, Record<string, number>>) {
if (!this.isVisible) return;
this.layerCounters = counters;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
if (!this.isVisible) return;
@@ -1017,6 +1026,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
tickLayers: PerformanceOverlay.computeLayerBreakdown(
this.tickLayerStats,
).map((layer) => ({ ...layer })),
layerCounters: { ...this.layerCounters },
};
}
@@ -1092,14 +1102,17 @@ export class PerformanceOverlay extends LitElement implements Layer {
: this.uiText.copyClipboard;
const renderLayerBreakdown = this.renderLayersExpanded
? PerformanceOverlay.computeLayerBreakdown(this.layerStats)
: [];
const renderLayerBreakdown =
this.renderLayersExpanded || this.layerCounters.UnitLayer
? PerformanceOverlay.computeLayerBreakdown(this.layerStats)
: [];
const tickLayerBreakdown = this.tickLayersExpanded
? PerformanceOverlay.computeLayerBreakdown(this.tickLayerStats)
: [];
const renderLayersToShow = renderLayerBreakdown.slice(0, 10);
const tickLayersToShow = tickLayerBreakdown.slice(0, 10);
const unitLayerCounters = this.layerCounters.UnitLayer ?? null;
const maxLayerAvg =
renderLayersToShow.length > 0
@@ -1287,6 +1300,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 };
}
+446 -204
View File
@@ -20,6 +20,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 {
@@ -34,31 +39,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 pendingTrailClears: UnitView[] = [];
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;
@@ -91,14 +134,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>();
@@ -112,8 +157,8 @@ export class UnitLayer implements Layer {
);
if (moverSetChanged) {
this.gridMoverUnitIds = gridMoverUnitIds;
this.pruneMoverStates(gridMoverUnitIds);
this.redrawStaticSprites();
return;
}
const updatedUnitIds =
@@ -139,6 +184,7 @@ export class UnitLayer implements Layer {
if (unitIds.size > 0) {
this.updateUnitsSprites(Array.from(unitIds));
}
}
init() {
@@ -365,93 +411,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(),
@@ -464,16 +474,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) {
@@ -487,42 +643,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 {
@@ -554,15 +698,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()) {
@@ -642,7 +933,6 @@ export class UnitLayer implements Layer {
// otherwise the sprite of a unit can be drawn on top of another unit
this.clearUnitsCells(unitsToUpdate);
this.drawUnitsCells(unitsToUpdate);
this.flushTrailClears();
}
}
@@ -794,69 +1084,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 flushTrailClears() {
if (this.pendingTrailClears.length === 0) return;
const clearedTiles = new Set<TileRef>();
for (const unit of this.pendingTrailClears) {
const trail = this.unitToTrail.get(unit);
if (trail) {
for (const t of trail) {
if (!clearedTiles.has(t)) {
this.clearCell(
this.game.x(t),
this.game.y(t),
this.unitTrailContext,
);
clearedTiles.add(t);
}
}
this.unitToTrail.delete(unit);
}
}
this.pendingTrailClears = [];
// Single repaint pass for all remaining units
for (const [other, trail] of this.unitToTrail) {
const rel = this.relationship(other);
for (const t of trail) {
if (clearedTiles.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 = {
@@ -873,19 +1114,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.pendingTrailClears.push(unit);
this.clearTrail(unitId);
}
}
@@ -916,10 +1152,6 @@ export class UnitLayer implements Layer {
private handleBoatEvent(unit: UnitView) {
this.drawSprite(unit);
if (!unit.isActive()) {
this.pendingTrailClears.push(unit);
}
}
paintCell(
@@ -964,7 +1196,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) {
@@ -990,7 +1222,7 @@ export class UnitLayer implements Layer {
);
if (!unit.isActive()) {
return;
return null;
}
const targetable = unit.targetable();
@@ -1001,15 +1233,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,
);
}
}
+20 -17
View File
@@ -8,7 +8,6 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { densePathToLosKeypointSegments } from "../game/MotionPlans";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { findClosestBy } from "../Util";
@@ -131,22 +130,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",
@@ -244,4 +228,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),
};
}
}
+22 -36
View File
@@ -10,10 +10,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 { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
@@ -131,22 +128,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",
@@ -311,22 +293,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",
@@ -357,4 +324,23 @@ export class TransportShipExecution implements Execution {
request.reject();
}
}
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),
};
}
}
-89
View File
@@ -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),
};
}
+12 -1
View File
@@ -15,7 +15,12 @@ import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransfor
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer";
import { PathResult, PathStatus, SteppingPathFinder } from "./types";
import {
PathResult,
PathStatus,
SegmentPlan,
SteppingPathFinder,
} from "./types";
/**
* Pathfinders that work with GameMap - usable in both simulation and UI layers
@@ -57,6 +62,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));
@@ -159,6 +165,11 @@ export class WaterPathFinder implements SteppingPathFinder<TileRef> {
return this.inner.findPath(from, to);
}
planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null {
this.ensureFresh();
return this.inner.planSegments?.(from, to) ?? null;
}
invalidate(): void {
this.inner.invalidate();
}
+36 -24
View File
@@ -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),
};
}