mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:10:46 +00:00
Replace mover queue with bucketed scheduler and clarify metrics
This commit is contained in:
@@ -1306,16 +1306,22 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
<span>UnitLayer Counters</span>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
tracked: ${Number(unitLayerCounters.moversTrackedTotal ?? 0)}
|
||||
sampled: ${Number(unitLayerCounters.moversSampled ?? 0)}
|
||||
drawn: ${Number(unitLayerCounters.moversDrawn ?? 0)}
|
||||
skipped: ${Number(unitLayerCounters.moversSkipped ?? 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)}
|
||||
draw:
|
||||
${Number(unitLayerCounters.drawTimeMs ?? 0).toFixed(2)}ms /
|
||||
${Number(unitLayerCounters.budgetTargetMs ?? 0).toFixed(1)}ms
|
||||
(+${Number(
|
||||
unitLayerCounters.budgetSoftOverrunMs ?? 0,
|
||||
).toFixed(1)}ms
|
||||
on-screen) avgOnDebt:
|
||||
${Number(unitLayerCounters.avgOnScreenDebt ?? 0).toFixed(2)}
|
||||
maxOnDebt:
|
||||
${Number(unitLayerCounters.maxOnScreenDebt ?? 0).toFixed(0)}
|
||||
</div>
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { colord, Colord } from "colord";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { Cell, UnitType } from "../../../core/game/Game";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { BezenhamLine } from "../../../core/utilities/Line";
|
||||
@@ -20,10 +20,6 @@ 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";
|
||||
@@ -39,13 +35,12 @@ enum Relationship {
|
||||
Enemy,
|
||||
}
|
||||
|
||||
const UNIT_DRAW_BUDGET_MS = 3;
|
||||
const UNIT_DRAW_BUDGET_MS = 1;
|
||||
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;
|
||||
const ONSCREEN_HYSTERESIS_FRAMES = 2;
|
||||
const OFFSCREEN_VERIFY_MAX_PER_FRAME = 12;
|
||||
const VIEW_PADDING_PX = 12;
|
||||
|
||||
type TransportTrailState = {
|
||||
xy: number[];
|
||||
@@ -64,13 +59,10 @@ type MoverSpriteRect = {
|
||||
|
||||
type MoverRenderState = {
|
||||
planId: number;
|
||||
lastRenderedX: number;
|
||||
lastRenderedY: number;
|
||||
lastRenderedAtMs: number;
|
||||
lastErrorPx: number;
|
||||
lastSpriteRect: MoverSpriteRect | null;
|
||||
lastOnScreen: boolean;
|
||||
queueVersion: number;
|
||||
bucket: "on" | "off";
|
||||
bucketIndex: number;
|
||||
skipDebt: number;
|
||||
lastSeenFrame: number;
|
||||
};
|
||||
@@ -92,15 +84,21 @@ export class UnitLayer implements Layer {
|
||||
private trailDirty = false;
|
||||
|
||||
private moverState = new Map<number, MoverRenderState>();
|
||||
private motionQueue = new UnitMotionRenderQueue();
|
||||
private onScreenMoverIds: number[] = [];
|
||||
private offScreenMoverIds: number[] = [];
|
||||
private onScreenCursor = 0;
|
||||
private offScreenCursor = 0;
|
||||
private renderFrame = 0;
|
||||
private lastPerfCounters: Record<string, number> = {
|
||||
moversTrackedTotal: 0,
|
||||
moversSampled: 0,
|
||||
moversDrawn: 0,
|
||||
moversSkipped: 0,
|
||||
queueSize: 0,
|
||||
budgetUsedMs: 0,
|
||||
avgDebt: 0,
|
||||
drawTimeMs: 0,
|
||||
budgetTargetMs: UNIT_DRAW_BUDGET_MS,
|
||||
budgetSoftOverrunMs: UNIT_DRAW_SOFT_OVERRUN_MS,
|
||||
avgOnScreenDebt: 0,
|
||||
maxOnScreenDebt: 0,
|
||||
};
|
||||
|
||||
private theme: Theme;
|
||||
@@ -184,7 +182,6 @@ export class UnitLayer implements Layer {
|
||||
if (unitIds.size > 0) {
|
||||
this.updateUnitsSprites(Array.from(unitIds));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -414,7 +411,7 @@ export class UnitLayer implements Layer {
|
||||
this.renderFrame++;
|
||||
const tickAlpha = this.computeTickAlpha();
|
||||
const tickFloat = this.game.ticks() + tickAlpha;
|
||||
const nowMs = performance.now();
|
||||
const viewBounds = this.currentViewBounds();
|
||||
const activeMoverIds = new Set<number>();
|
||||
|
||||
for (const [unitId, plan] of this.game.motionPlans()) {
|
||||
@@ -426,37 +423,34 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
activeMoverIds.add(unitId);
|
||||
|
||||
const onScreenHint = this.transformHandler.isOnScreen(
|
||||
new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())),
|
||||
const state = this.ensureMoverState(unitId, plan.planId);
|
||||
const maybeOnScreen = this.isPotentiallyOnScreen(
|
||||
plan,
|
||||
state,
|
||||
tickFloat,
|
||||
viewBounds,
|
||||
);
|
||||
const state = this.ensureMoverState(unitId, plan.planId, nowMs);
|
||||
state.lastSeenFrame = this.renderFrame;
|
||||
|
||||
if (!onScreenHint && state.lastOnScreen && state.lastSpriteRect) {
|
||||
this.clearMoverRect(state.lastSpriteRect);
|
||||
state.lastOnScreen = false;
|
||||
}
|
||||
this.moveMoverToBucket(unitId, state, maybeOnScreen ? "on" : "off");
|
||||
|
||||
if (
|
||||
!onScreenHint &&
|
||||
((this.renderFrame + unitId) % OFFSCREEN_REFRESH_EVERY_N_FRAMES !== 0) &&
|
||||
state.skipDebt < 2
|
||||
!maybeOnScreen &&
|
||||
state.lastOnScreen &&
|
||||
state.lastSpriteRect &&
|
||||
this.renderFrame - state.lastSeenFrame > ONSCREEN_HYSTERESIS_FRAMES
|
||||
) {
|
||||
continue;
|
||||
this.clearMoverRect(state.lastSpriteRect);
|
||||
state.lastSpriteRect = null;
|
||||
state.lastOnScreen = false;
|
||||
}
|
||||
|
||||
const entry: UnitMotionRenderQueueEntry = {
|
||||
unitId,
|
||||
version: (state.queueVersion = (state.queueVersion + 1) >>> 0),
|
||||
priority: this.computeMoverPriority(state, onScreenHint, nowMs),
|
||||
onScreenHint,
|
||||
};
|
||||
this.motionQueue.enqueue(entry);
|
||||
}
|
||||
|
||||
this.pruneMoverStates(activeMoverIds);
|
||||
|
||||
const moverPerf = this.drawQueuedMovers(tickFloat, activeMoverIds);
|
||||
const moverPerf = this.drawBucketedMovers(
|
||||
tickFloat,
|
||||
activeMoverIds,
|
||||
viewBounds,
|
||||
);
|
||||
|
||||
this.rebuildTrailCanvasIfDirty();
|
||||
|
||||
@@ -482,28 +476,38 @@ export class UnitLayer implements Layer {
|
||||
this.game.height(),
|
||||
);
|
||||
|
||||
let totalDebt = 0;
|
||||
let debtCount = 0;
|
||||
for (const unitId of activeMoverIds) {
|
||||
let totalOnScreenDebt = 0;
|
||||
let onScreenDebtCount = 0;
|
||||
let maxOnScreenDebt = 0;
|
||||
for (const unitId of this.onScreenMoverIds) {
|
||||
const state = this.moverState.get(unitId);
|
||||
if (!state) continue;
|
||||
totalDebt += state.skipDebt;
|
||||
debtCount++;
|
||||
totalOnScreenDebt += state.skipDebt;
|
||||
onScreenDebtCount++;
|
||||
if (state.skipDebt > maxOnScreenDebt) {
|
||||
maxOnScreenDebt = state.skipDebt;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastPerfCounters = {
|
||||
moversTrackedTotal:
|
||||
this.onScreenMoverIds.length + this.offScreenMoverIds.length,
|
||||
moversSampled: moverPerf.sampled,
|
||||
moversDrawn: moverPerf.drawn,
|
||||
moversSkipped: moverPerf.skipped,
|
||||
queueSize: this.motionQueue.size(),
|
||||
budgetUsedMs: moverPerf.budgetUsedMs,
|
||||
avgDebt: debtCount > 0 ? totalDebt / debtCount : 0,
|
||||
drawTimeMs: moverPerf.budgetUsedMs,
|
||||
budgetTargetMs: UNIT_DRAW_BUDGET_MS,
|
||||
budgetSoftOverrunMs: UNIT_DRAW_SOFT_OVERRUN_MS,
|
||||
avgOnScreenDebt:
|
||||
onScreenDebtCount > 0 ? totalOnScreenDebt / onScreenDebtCount : 0,
|
||||
maxOnScreenDebt,
|
||||
};
|
||||
}
|
||||
|
||||
private drawQueuedMovers(
|
||||
private drawBucketedMovers(
|
||||
tickFloat: number,
|
||||
activeMoverIds: Set<number>,
|
||||
viewBounds: { left: number; top: number; right: number; bottom: number },
|
||||
): {
|
||||
sampled: number;
|
||||
drawn: number;
|
||||
@@ -517,29 +521,114 @@ export class UnitLayer implements Layer {
|
||||
let drawn = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (;;) {
|
||||
const entry = this.motionQueue.pollValid((candidate) =>
|
||||
this.isValidQueueEntry(candidate, activeMoverIds),
|
||||
const onScreenPass = this.drawBucketPass(
|
||||
"on",
|
||||
tickFloat,
|
||||
activeMoverIds,
|
||||
drawnIds,
|
||||
frameStartMs,
|
||||
viewBounds,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
sampled += onScreenPass.sampled;
|
||||
drawn += onScreenPass.drawn;
|
||||
skipped += onScreenPass.skipped;
|
||||
|
||||
const budgetExceeded = !onScreenPass.budgetRemaining;
|
||||
const shouldVerifyOffscreen =
|
||||
!budgetExceeded &&
|
||||
this.offScreenMoverIds.length > 0 &&
|
||||
this.renderFrame % OFFSCREEN_REFRESH_EVERY_N_FRAMES === 0;
|
||||
|
||||
if (shouldVerifyOffscreen) {
|
||||
const offscreenPass = this.drawBucketPass(
|
||||
"off",
|
||||
tickFloat,
|
||||
activeMoverIds,
|
||||
drawnIds,
|
||||
frameStartMs,
|
||||
viewBounds,
|
||||
OFFSCREEN_VERIFY_MAX_PER_FRAME,
|
||||
);
|
||||
if (!entry) {
|
||||
sampled += offscreenPass.sampled;
|
||||
drawn += offscreenPass.drawn;
|
||||
skipped += offscreenPass.skipped;
|
||||
}
|
||||
|
||||
for (const unitId of activeMoverIds) {
|
||||
if (drawnIds.has(unitId)) {
|
||||
continue;
|
||||
}
|
||||
const state = this.moverState.get(unitId);
|
||||
if (state && state.bucket === "on") {
|
||||
state.skipDebt = (state.skipDebt + 1) >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sampled,
|
||||
drawn,
|
||||
skipped,
|
||||
budgetUsedMs: performance.now() - frameStartMs,
|
||||
};
|
||||
}
|
||||
|
||||
private drawBucketPass(
|
||||
bucket: "on" | "off",
|
||||
tickFloat: number,
|
||||
activeMoverIds: Set<number>,
|
||||
drawnIds: Set<number>,
|
||||
frameStartMs: number,
|
||||
viewBounds: { left: number; top: number; right: number; bottom: number },
|
||||
maxItems: number,
|
||||
): {
|
||||
sampled: number;
|
||||
drawn: number;
|
||||
skipped: number;
|
||||
budgetRemaining: boolean;
|
||||
} {
|
||||
const bucketIds =
|
||||
bucket === "on" ? this.onScreenMoverIds : this.offScreenMoverIds;
|
||||
if (bucketIds.length === 0 || maxItems <= 0) {
|
||||
return { sampled: 0, drawn: 0, skipped: 0, budgetRemaining: true };
|
||||
}
|
||||
|
||||
const startCursor =
|
||||
bucket === "on" ? this.onScreenCursor : this.offScreenCursor;
|
||||
const cap = Math.min(bucketIds.length, maxItems);
|
||||
|
||||
let sampled = 0;
|
||||
let drawn = 0;
|
||||
let skipped = 0;
|
||||
let budgetRemaining = true;
|
||||
|
||||
for (let offset = 0; offset < cap; offset++) {
|
||||
if (bucketIds.length === 0) {
|
||||
break;
|
||||
}
|
||||
const idx = (startCursor + offset) % bucketIds.length;
|
||||
const unitId = bucketIds[idx];
|
||||
|
||||
const elapsedMs = performance.now() - frameStartMs;
|
||||
const canDrawWithinTarget = elapsedMs < UNIT_DRAW_BUDGET_MS;
|
||||
const canDrawOnScreenOverrun =
|
||||
entry.onScreenHint &&
|
||||
bucket === "on" &&
|
||||
elapsedMs < UNIT_DRAW_BUDGET_MS + UNIT_DRAW_SOFT_OVERRUN_MS;
|
||||
if (!canDrawWithinTarget && !canDrawOnScreenOverrun) {
|
||||
budgetRemaining = false;
|
||||
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 (!activeMoverIds.has(unitId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unit = this.game.unit(unitId);
|
||||
const plan = this.game.motionPlans().get(unitId);
|
||||
const state = this.moverState.get(unitId);
|
||||
if (!unit || !unit.isActive() || !plan || !state) {
|
||||
this.clearMoverState(entry.unitId);
|
||||
this.clearMoverState(unitId);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -551,8 +640,11 @@ export class UnitLayer implements Layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const onScreen = this.transformHandler.isOnScreen(
|
||||
new Cell(Math.floor(sampledPos.x), Math.floor(sampledPos.y)),
|
||||
const onScreen = this.pointInView(
|
||||
sampledPos.x,
|
||||
sampledPos.y,
|
||||
viewBounds,
|
||||
VIEW_PADDING_PX,
|
||||
);
|
||||
|
||||
if (!onScreen) {
|
||||
@@ -561,9 +653,10 @@ export class UnitLayer implements Layer {
|
||||
state.lastSpriteRect = null;
|
||||
state.lastOnScreen = false;
|
||||
}
|
||||
this.moveMoverToBucket(unitId, state, "off");
|
||||
if (unit.type() === UnitType.TransportShip) {
|
||||
this.updateTransportShipTrail(
|
||||
entry.unitId,
|
||||
unitId,
|
||||
plan.planId,
|
||||
sampledPos.x,
|
||||
sampledPos.y,
|
||||
@@ -574,6 +667,7 @@ export class UnitLayer implements Layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.moveMoverToBucket(unitId, state, "on");
|
||||
if (state.lastSpriteRect) {
|
||||
this.clearMoverRect(state.lastSpriteRect);
|
||||
}
|
||||
@@ -589,23 +683,16 @@ export class UnitLayer implements Layer {
|
||||
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.lastSeenFrame = this.renderFrame;
|
||||
state.skipDebt = 0;
|
||||
drawnIds.add(entry.unitId);
|
||||
drawnIds.add(unitId);
|
||||
drawn++;
|
||||
|
||||
if (unit.type() === UnitType.TransportShip) {
|
||||
this.updateTransportShipTrail(
|
||||
entry.unitId,
|
||||
unitId,
|
||||
plan.planId,
|
||||
sampledPos.x,
|
||||
sampledPos.y,
|
||||
@@ -614,22 +701,19 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
for (const unitId of activeMoverIds) {
|
||||
if (drawnIds.has(unitId)) {
|
||||
continue;
|
||||
}
|
||||
const state = this.moverState.get(unitId);
|
||||
if (state) {
|
||||
state.skipDebt = (state.skipDebt + 1) >>> 0;
|
||||
}
|
||||
if (bucket === "on") {
|
||||
this.onScreenCursor =
|
||||
bucketIds.length > 0
|
||||
? (startCursor + Math.max(1, cap)) % bucketIds.length
|
||||
: 0;
|
||||
} else {
|
||||
this.offScreenCursor =
|
||||
bucketIds.length > 0
|
||||
? (startCursor + Math.max(1, cap)) % bucketIds.length
|
||||
: 0;
|
||||
}
|
||||
|
||||
return {
|
||||
sampled,
|
||||
drawn,
|
||||
skipped,
|
||||
budgetUsedMs: performance.now() - frameStartMs,
|
||||
};
|
||||
return { sampled, drawn, skipped, budgetRemaining };
|
||||
}
|
||||
|
||||
onAlternativeViewEvent(event: AlternateViewEvent) {
|
||||
@@ -663,7 +747,10 @@ export class UnitLayer implements Layer {
|
||||
|
||||
this.gridMoverUnitIds = new Set<number>(this.game.motionPlans().keys());
|
||||
this.moverState.clear();
|
||||
this.motionQueue.clear();
|
||||
this.onScreenMoverIds = [];
|
||||
this.offScreenMoverIds = [];
|
||||
this.onScreenCursor = 0;
|
||||
this.offScreenCursor = 0;
|
||||
this.trailDirty = true;
|
||||
|
||||
this.redrawStaticSprites();
|
||||
@@ -702,26 +789,156 @@ export class UnitLayer implements Layer {
|
||||
return this.lastPerfCounters;
|
||||
}
|
||||
|
||||
private ensureMoverState(
|
||||
unitId: number,
|
||||
planId: number,
|
||||
nowMs: number,
|
||||
): MoverRenderState {
|
||||
private currentViewBounds(): {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
} {
|
||||
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
return {
|
||||
left: topLeft.x,
|
||||
top: topLeft.y,
|
||||
right: bottomRight.x,
|
||||
bottom: bottomRight.y,
|
||||
};
|
||||
}
|
||||
|
||||
private pointInView(
|
||||
x: number,
|
||||
y: number,
|
||||
viewBounds: { left: number; top: number; right: number; bottom: number },
|
||||
pad: number = 0,
|
||||
): boolean {
|
||||
return (
|
||||
x >= viewBounds.left - pad &&
|
||||
x <= viewBounds.right + pad &&
|
||||
y >= viewBounds.top - pad &&
|
||||
y <= viewBounds.bottom + pad
|
||||
);
|
||||
}
|
||||
|
||||
private isPotentiallyOnScreen(
|
||||
plan: {
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
segCumSteps: Uint32Array;
|
||||
},
|
||||
state: MoverRenderState,
|
||||
tickFloat: number,
|
||||
viewBounds: { left: number; top: number; right: number; bottom: number },
|
||||
): boolean {
|
||||
if (
|
||||
state.lastOnScreen &&
|
||||
this.renderFrame - state.lastSeenFrame <= ONSCREEN_HYSTERESIS_FRAMES
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const segment = this.currentSegmentEndpoints(plan, tickFloat);
|
||||
if (!segment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pointInView(segment.x0, segment.y0, viewBounds, VIEW_PADDING_PX) ||
|
||||
this.pointInView(segment.x1, segment.y1, viewBounds, VIEW_PADDING_PX)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const segLeft = Math.min(segment.x0, segment.x1) - VIEW_PADDING_PX;
|
||||
const segRight = Math.max(segment.x0, segment.x1) + VIEW_PADDING_PX;
|
||||
const segTop = Math.min(segment.y0, segment.y1) - VIEW_PADDING_PX;
|
||||
const segBottom = Math.max(segment.y0, segment.y1) + VIEW_PADDING_PX;
|
||||
|
||||
return !(
|
||||
segRight < viewBounds.left ||
|
||||
segLeft > viewBounds.right ||
|
||||
segBottom < viewBounds.top ||
|
||||
segTop > viewBounds.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private currentSegmentEndpoints(
|
||||
plan: {
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
segCumSteps: Uint32Array;
|
||||
},
|
||||
tickFloat: number,
|
||||
): { x0: number; y0: number; x1: number; y1: number } | null {
|
||||
const points = plan.points;
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (points.length === 1 || plan.segmentSteps.length === 0) {
|
||||
const tile = points[0] as TileRef;
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
return { x0: x, y0: y, x1: x, y1: y };
|
||||
}
|
||||
|
||||
const segCum = plan.segCumSteps;
|
||||
const totalSteps = segCum[segCum.length - 1] >>> 0;
|
||||
if (totalSteps === 0) {
|
||||
const tile = points[points.length - 1] as TileRef;
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
return { x0: x, y0: y, x1: x, y1: y };
|
||||
}
|
||||
|
||||
const ticksPerStep = Math.max(1, plan.ticksPerStep);
|
||||
const stepFloat = (tickFloat - plan.startTick) / ticksPerStep;
|
||||
let seg = 0;
|
||||
if (stepFloat >= totalSteps) {
|
||||
seg = Math.max(0, plan.segmentSteps.length - 1);
|
||||
} else if (stepFloat > 0) {
|
||||
let lo = 0;
|
||||
let hi = plan.segmentSteps.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const start = segCum[mid] >>> 0;
|
||||
const end = segCum[mid + 1] >>> 0;
|
||||
if (stepFloat < start) {
|
||||
hi = mid - 1;
|
||||
} else if (stepFloat >= end) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
seg = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const p0 = points[seg] as TileRef;
|
||||
const p1 = points[Math.min(points.length - 1, seg + 1)] as TileRef;
|
||||
return {
|
||||
x0: this.game.x(p0),
|
||||
y0: this.game.y(p0),
|
||||
x1: this.game.x(p1),
|
||||
y1: this.game.y(p1),
|
||||
};
|
||||
}
|
||||
|
||||
private ensureMoverState(unitId: number, planId: 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,
|
||||
bucket: "off",
|
||||
bucketIndex: -1,
|
||||
skipDebt: 0,
|
||||
lastSeenFrame: this.renderFrame,
|
||||
lastSeenFrame: -1,
|
||||
};
|
||||
this.moverState.set(unitId, state);
|
||||
this.moveMoverToBucket(unitId, state, "off");
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -730,40 +947,16 @@ export class UnitLayer implements Layer {
|
||||
this.clearMoverRect(existing.lastSpriteRect);
|
||||
}
|
||||
existing.planId = planId;
|
||||
existing.lastErrorPx = 0;
|
||||
existing.lastOnScreen = false;
|
||||
existing.lastSpriteRect = null;
|
||||
existing.skipDebt = 0;
|
||||
existing.lastSeenFrame = -1;
|
||||
this.moveMoverToBucket(unitId, existing, "off");
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -772,6 +965,7 @@ export class UnitLayer implements Layer {
|
||||
if (state.lastSpriteRect) {
|
||||
this.clearMoverRect(state.lastSpriteRect);
|
||||
}
|
||||
this.removeFromBucket(unitId, state);
|
||||
this.moverState.delete(unitId);
|
||||
}
|
||||
}
|
||||
@@ -781,9 +975,65 @@ export class UnitLayer implements Layer {
|
||||
if (state?.lastSpriteRect) {
|
||||
this.clearMoverRect(state.lastSpriteRect);
|
||||
}
|
||||
if (state) {
|
||||
this.removeFromBucket(unitId, state);
|
||||
}
|
||||
this.moverState.delete(unitId);
|
||||
}
|
||||
|
||||
private moveMoverToBucket(
|
||||
unitId: number,
|
||||
state: MoverRenderState,
|
||||
target: "on" | "off",
|
||||
): void {
|
||||
if (state.bucket === target && state.bucketIndex >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeFromBucket(unitId, state);
|
||||
|
||||
const targetBucket =
|
||||
target === "on" ? this.onScreenMoverIds : this.offScreenMoverIds;
|
||||
state.bucket = target;
|
||||
state.bucketIndex = targetBucket.length;
|
||||
targetBucket.push(unitId);
|
||||
}
|
||||
|
||||
private removeFromBucket(unitId: number, state: MoverRenderState): void {
|
||||
if (state.bucketIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketIds =
|
||||
state.bucket === "on" ? this.onScreenMoverIds : this.offScreenMoverIds;
|
||||
const idx = state.bucketIndex;
|
||||
const lastIdx = bucketIds.length - 1;
|
||||
if (idx < 0 || idx > lastIdx) {
|
||||
state.bucketIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const swappedUnitId = bucketIds[lastIdx];
|
||||
bucketIds[idx] = swappedUnitId;
|
||||
bucketIds.pop();
|
||||
|
||||
if (idx !== lastIdx) {
|
||||
const swappedState = this.moverState.get(swappedUnitId);
|
||||
if (swappedState) {
|
||||
swappedState.bucketIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
state.bucketIndex = -1;
|
||||
|
||||
if (state.bucket === "on" && this.onScreenCursor >= bucketIds.length) {
|
||||
this.onScreenCursor = 0;
|
||||
}
|
||||
if (state.bucket === "off" && this.offScreenCursor >= bucketIds.length) {
|
||||
this.offScreenCursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private clearMoverRect(rect: MoverSpriteRect): void {
|
||||
this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h);
|
||||
}
|
||||
@@ -1235,13 +1485,7 @@ export class UnitLayer implements Layer {
|
||||
const drawY = y - sprite.height / 2;
|
||||
const outX = roundCoords ? Math.round(drawX) : drawX;
|
||||
const outY = roundCoords ? Math.round(drawY) : drawY;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
outX,
|
||||
outY,
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
ctx.drawImage(sprite, outX, outY, sprite.width, sprite.width);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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