Optimize transport trail processing and raster helpers

This commit is contained in:
scamiv
2026-02-27 22:01:51 +01:00
parent 04155c234b
commit caf8f646ed
4 changed files with 460 additions and 100 deletions
@@ -0,0 +1,169 @@
import { TileRef } from "../../../core/game/GameMap";
type TrailGameView = {
x(ref: TileRef): number;
y(ref: TileRef): number;
};
export type SegmentTrailPlanView = {
startTick: number;
ticksPerStep: number;
points: Uint32Array;
segmentSteps: Uint32Array;
segCumSteps: Uint32Array;
};
export function totalTrailSteps(plan: {
segCumSteps: Uint32Array;
}): number {
return plan.segCumSteps.length === 0
? 0
: plan.segCumSteps[plan.segCumSteps.length - 1] >>> 0;
}
export function stepAtTick(
plan: SegmentTrailPlanView,
tick: number,
): number {
const total = totalTrailSteps(plan);
if (total <= 0) {
return 0;
}
const dt = tick - plan.startTick;
if (dt <= 0) {
return 0;
}
const ticksPerStep = Math.max(1, plan.ticksPerStep);
const step = Math.floor(dt / ticksPerStep);
return Math.max(0, Math.min(total, step));
}
export function locateSegment(
segCumSteps: Uint32Array,
segmentCount: number,
step: number,
): number {
if (segmentCount <= 0) {
return 0;
}
const total =
segCumSteps.length === 0
? 0
: segCumSteps[segCumSteps.length - 1] >>> 0;
if (total <= 0) {
return 0;
}
if (step >= total) {
return Math.max(0, segmentCount - 1);
}
let lo = 0;
let hi = segmentCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const start = segCumSteps[mid] >>> 0;
const end = segCumSteps[mid + 1] >>> 0;
if (step < start) {
hi = mid - 1;
} else if (step >= end) {
lo = mid + 1;
} else {
return mid;
}
}
return Math.max(0, Math.min(segmentCount - 1, lo));
}
export function positionAtStep(
game: TrailGameView,
plan: SegmentTrailPlanView,
step: number,
): { x: number; y: number } | null {
const points = plan.points;
if (points.length === 0) {
return null;
}
if (points.length === 1 || plan.segmentSteps.length === 0) {
const t = points[points.length - 1] as TileRef;
return { x: game.x(t), y: game.y(t) };
}
const total = totalTrailSteps(plan);
const idx = Math.max(0, Math.min(total, step));
if (idx >= total) {
const t = points[points.length - 1] as TileRef;
return { x: game.x(t), y: game.y(t) };
}
const segmentCount = plan.segmentSteps.length;
const seg = locateSegment(plan.segCumSteps, segmentCount, idx);
const segStart = plan.segCumSteps[seg] >>> 0;
const steps = Math.max(1, plan.segmentSteps[seg] >>> 0);
const p0 = points[seg] as TileRef;
const p1 = points[Math.min(points.length - 1, seg + 1)] as TileRef;
const x0 = game.x(p0);
const y0 = game.y(p0);
const x1 = game.x(p1);
const y1 = game.y(p1);
const local = idx - segStart;
return {
x: x0 + ((x1 - x0) * local) / steps,
y: y0 + ((y1 - y0) * local) / steps,
};
}
export function strokeStepInterval(
ctx: CanvasRenderingContext2D,
game: TrailGameView,
plan: SegmentTrailPlanView,
fromStep: number,
toStep: number,
): boolean {
const total = totalTrailSteps(plan);
if (total <= 0) {
return false;
}
const from = Math.max(0, Math.min(total, fromStep));
const to = Math.max(0, Math.min(total, toStep));
if (to <= from) {
return false;
}
const start = positionAtStep(game, plan, from);
const end = positionAtStep(game, plan, to);
if (!start || !end) {
return false;
}
const segmentCount = plan.segmentSteps.length;
if (segmentCount === 0) {
return false;
}
const fromSeg = locateSegment(plan.segCumSteps, segmentCount, from);
const toSeg = locateSegment(plan.segCumSteps, segmentCount, to);
ctx.beginPath();
ctx.moveTo(start.x, start.y);
if (fromSeg === toSeg) {
ctx.lineTo(end.x, end.y);
ctx.stroke();
return true;
}
const fromBoundaryRef = plan.points[Math.min(plan.points.length - 1, fromSeg + 1)] as TileRef;
ctx.lineTo(game.x(fromBoundaryRef), game.y(fromBoundaryRef));
for (let seg = fromSeg + 1; seg < toSeg; seg++) {
const boundaryRef = plan.points[Math.min(plan.points.length - 1, seg + 1)] as TileRef;
ctx.lineTo(game.x(boundaryRef), game.y(boundaryRef));
}
ctx.lineTo(end.x, end.y);
ctx.stroke();
return true;
}
+137 -95
View File
@@ -16,6 +16,11 @@ import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { sampleGridSegmentPlan } from "./SegmentMotionSample";
import {
SegmentTrailPlanView,
stepAtTick,
strokeStepInterval,
} from "./SegmentTrailRaster";
import { pruneInactiveTrails } from "./TrailLifecycle";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -57,13 +62,25 @@ const TRADE_SHIP_MASK = [
] as const;
type TransportTrailState = {
xy: number[];
planId: number;
lastX: number;
lastY: number;
activePlanId: number;
epochs: TransportTrailEpoch[];
lastOnScreen: boolean;
};
type TransportTrailEpoch = SegmentTrailPlanView & {
planId: number;
targetStep: number;
drawnStep: number;
sealed: boolean;
};
type ActiveTransportTrailPlan = {
unitId: number;
unit: UnitView;
plan: SegmentTrailPlanView & { planId: number };
maybeOnScreen: boolean;
};
type MoverSpriteRect = {
x: number;
y: number;
@@ -348,6 +365,7 @@ export class UnitLayer implements Layer {
const tickFloat = this.game.ticks() + tickAlpha;
const viewBounds = this.currentViewBounds();
const activeMoverIds = new Set<number>();
const activeTransportTrailPlans: ActiveTransportTrailPlan[] = [];
for (const [unitId, plan] of this.game.motionPlans()) {
const unit = this.game.unit(unitId);
@@ -365,6 +383,14 @@ export class UnitLayer implements Layer {
tickFloat,
viewBounds,
);
if (unit.type() === UnitType.TransportShip) {
activeTransportTrailPlans.push({
unitId,
unit,
plan,
maybeOnScreen,
});
}
this.moveMoverToBucket(unitId, state, maybeOnScreen ? "on" : "off");
if (
@@ -387,6 +413,10 @@ export class UnitLayer implements Layer {
viewBounds,
);
this.advanceAndDrawTransportTrails(
this.game.ticks(),
activeTransportTrailPlans,
);
this.rebuildTrailCanvasIfDirty();
context.drawImage(
@@ -613,22 +643,12 @@ export class UnitLayer implements Layer {
state.lastOnScreen = false;
}
this.moveMoverToBucket(unitId, state, "off");
if (unit.type() === UnitType.TransportShip) {
this.updateTransportShipTrail(
unitId,
plan.planId,
sampledCurrent.x,
sampledCurrent.y,
false,
);
}
skipped++;
processed.add(unitId);
continue;
}
this.moveMoverToBucket(unitId, state, "on");
let trailHandledInGroup = false;
const conflictIds = this.detectMoverConflicts(
unitId,
state.lastSpriteRect,
@@ -648,7 +668,6 @@ export class UnitLayer implements Layer {
sampled += Math.max(0, groupResult.sampled - 1);
drawn += groupResult.drawn;
skipped += groupResult.skipped;
trailHandledInGroup = true;
} else {
if (state.lastSpriteRect) {
this.spatialRemove(spatial, unitId, state.lastSpriteRect);
@@ -677,16 +696,6 @@ export class UnitLayer implements Layer {
processed.add(unitId);
this.spatialAdd(spatial, unitId, rect);
}
if (!trailHandledInGroup && unit.type() === UnitType.TransportShip) {
this.updateTransportShipTrail(
unitId,
plan.planId,
sampledCurrent.x,
sampledCurrent.y,
true,
);
}
}
if (bucket === "on") {
@@ -847,15 +856,6 @@ export class UnitLayer implements Layer {
state.lastOnScreen = false;
}
this.moveMoverToBucket(id, state, "off");
if (unit.type() === UnitType.TransportShip) {
this.updateTransportShipTrail(
id,
plan.planId,
current.x,
current.y,
false,
);
}
processed.add(id);
skipped++;
continue;
@@ -892,8 +892,7 @@ export class UnitLayer implements Layer {
let drawn = 0;
for (const sampledCurrent of sampledGroup) {
const state = this.moverState.get(sampledCurrent.unitId);
const plan = this.game.motionPlans().get(sampledCurrent.unitId);
if (!state || !plan) {
if (!state) {
skipped++;
continue;
}
@@ -917,16 +916,6 @@ export class UnitLayer implements Layer {
state.skipDebt = 0;
this.spatialAdd(spatial, sampledCurrent.unitId, rect);
if (sampledCurrent.unit.type() === UnitType.TransportShip) {
this.updateTransportShipTrail(
sampledCurrent.unitId,
plan.planId,
sampledCurrent.x,
sampledCurrent.y,
true,
);
}
drawnIds.add(sampledCurrent.unitId);
processed.add(sampledCurrent.unitId);
drawn++;
@@ -1384,43 +1373,112 @@ export class UnitLayer implements Layer {
this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h);
}
private updateTransportShipTrail(
unitId: number,
planId: number,
x: number,
y: number,
onScreen: boolean,
private advanceAndDrawTransportTrails(
currentTick: number,
activePlans: readonly ActiveTransportTrailPlan[],
): 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,
});
for (const { unitId, unit, plan, maybeOnScreen } of activePlans) {
const state = this.ensureTransportTrailState(unitId, plan, currentTick);
const moverState = this.moverState.get(unitId);
const onScreen = moverState ? moverState.bucket === "on" : maybeOnScreen;
if (onScreen) {
this.trailDirty = true;
this.drawPendingTransportTrailEpochs(unit, state);
}
return;
state.lastOnScreen = onScreen;
}
}
private ensureTransportTrailState(
unitId: number,
plan: SegmentTrailPlanView & { planId: number },
currentTick: number,
): TransportTrailState {
let state = this.transportShipTrails.get(unitId);
if (!state) {
state = {
activePlanId: plan.planId,
epochs: [],
lastOnScreen: false,
};
this.transportShipTrails.set(unitId, state);
}
if (onScreen && (existing.lastX !== x || existing.lastY !== y)) {
if (!existing.lastOnScreen && existing.xy.length > 0) {
existing.xy.push(Number.NaN, Number.NaN);
let activeEpoch = state.epochs[state.epochs.length - 1];
if (
!activeEpoch ||
state.activePlanId !== plan.planId ||
activeEpoch.planId !== plan.planId
) {
if (activeEpoch && !activeEpoch.sealed) {
activeEpoch.targetStep = stepAtTick(activeEpoch, currentTick);
if (activeEpoch.drawnStep > activeEpoch.targetStep) {
activeEpoch.drawnStep = activeEpoch.targetStep;
}
activeEpoch.sealed = true;
}
existing.xy.push(x, y);
this.trailDirty = true;
} else if (onScreen && existing.xy.length === 0) {
existing.xy.push(x, y);
this.trailDirty = true;
activeEpoch = this.createTransportTrailEpoch(plan, currentTick);
state.epochs.push(activeEpoch);
state.activePlanId = plan.planId;
return state;
}
existing.lastX = x;
existing.lastY = y;
existing.lastOnScreen = onScreen;
activeEpoch.points = plan.points;
activeEpoch.segmentSteps = plan.segmentSteps;
activeEpoch.segCumSteps = plan.segCumSteps;
activeEpoch.startTick = plan.startTick;
activeEpoch.ticksPerStep = plan.ticksPerStep;
activeEpoch.targetStep = stepAtTick(activeEpoch, currentTick);
return state;
}
private createTransportTrailEpoch(
plan: SegmentTrailPlanView & { planId: number },
currentTick: number,
): TransportTrailEpoch {
return {
planId: plan.planId,
startTick: plan.startTick,
ticksPerStep: plan.ticksPerStep,
points: plan.points,
segmentSteps: plan.segmentSteps,
segCumSteps: plan.segCumSteps,
targetStep: stepAtTick(plan, currentTick),
drawnStep: 0,
sealed: false,
};
}
private drawPendingTransportTrailEpochs(
unit: UnitView,
state: TransportTrailState,
): void {
const ctx = this.trailContext;
const strokeStyle = this.motionTrailColor(unit);
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 1.0;
ctx.strokeStyle = strokeStyle;
for (const epoch of state.epochs) {
if (epoch.targetStep <= epoch.drawnStep) {
continue;
}
const drew = strokeStepInterval(
ctx,
this.game,
epoch,
epoch.drawnStep,
epoch.targetStep,
);
if (drew) {
epoch.drawnStep = epoch.targetStep;
}
}
ctx.restore();
}
private rebuildTrailCanvasIfDirty(): void {
@@ -1450,39 +1508,23 @@ export class UnitLayer implements Layer {
}
}
for (const [unitId, trail] of this.transportShipTrails) {
for (const [unitId, trailState] of this.transportShipTrails) {
const unit = this.game.unit(unitId);
if (!unit || !unit.isActive()) {
continue;
}
if (trail.xy.length < 4) {
continue;
}
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 1.0;
ctx.strokeStyle = this.motionTrailColor(unit);
ctx.beginPath();
let needMove = true;
for (let i = 0; i < trail.xy.length; i += 2) {
const x = trail.xy[i];
const y = trail.xy[i + 1];
if (!Number.isFinite(x) || !Number.isFinite(y)) {
needMove = true;
for (const epoch of trailState.epochs) {
if (epoch.drawnStep <= 0) {
continue;
}
if (needMove) {
ctx.moveTo(x, y);
needMove = false;
} else {
ctx.lineTo(x, y);
}
strokeStepInterval(ctx, this.game, epoch, 0, epoch.drawnStep);
}
ctx.stroke();
ctx.restore();
}
}
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
locateSegment,
positionAtStep,
stepAtTick,
strokeStepInterval,
} from "../src/client/graphics/layers/SegmentTrailRaster";
function makeGame() {
return {
x(ref: number): number {
return ref % 10;
},
y(ref: number): number {
return Math.floor(ref / 10);
},
};
}
function makePlan() {
return {
startTick: 10,
ticksPerStep: 2,
points: Uint32Array.from([0, 3, 33]), // (0,0)->(3,0)->(3,3)
segmentSteps: Uint32Array.from([3, 3]),
segCumSteps: Uint32Array.from([0, 3, 6]),
};
}
function makeMockCtx() {
const ops: Array<{ op: string; x?: number; y?: number }> = [];
const ctx = {
beginPath() {
ops.push({ op: "beginPath" });
},
moveTo(x: number, y: number) {
ops.push({ op: "moveTo", x, y });
},
lineTo(x: number, y: number) {
ops.push({ op: "lineTo", x, y });
},
stroke() {
ops.push({ op: "stroke" });
},
} as unknown as CanvasRenderingContext2D;
return { ctx, ops };
}
describe("SegmentTrailRaster", () => {
it("stepAtTick clamps before start and after end", () => {
const plan = makePlan();
expect(stepAtTick(plan, 8)).toBe(0);
expect(stepAtTick(plan, 10)).toBe(0);
expect(stepAtTick(plan, 12)).toBe(1);
expect(stepAtTick(plan, 100)).toBe(6);
});
it("locateSegment handles boundaries with end-exclusive segments", () => {
const plan = makePlan();
expect(locateSegment(plan.segCumSteps, 2, 0)).toBe(0);
expect(locateSegment(plan.segCumSteps, 2, 2)).toBe(0);
expect(locateSegment(plan.segCumSteps, 2, 3)).toBe(1);
expect(locateSegment(plan.segCumSteps, 2, 6)).toBe(1);
});
it("positionAtStep matches expected piecewise interpolation", () => {
const plan = makePlan();
const game = makeGame();
expect(positionAtStep(game, plan, 2)).toEqual({ x: 2, y: 0 });
expect(positionAtStep(game, plan, 4)).toEqual({ x: 3, y: 1 });
expect(positionAtStep(game, plan, 6)).toEqual({ x: 3, y: 3 });
});
it("strokeStepInterval draws same-segment interval including first step", () => {
const { ctx, ops } = makeMockCtx();
const plan = makePlan();
const game = makeGame();
const drew = strokeStepInterval(ctx, game, plan, 0, 1);
expect(drew).toBe(true);
expect(ops).toEqual([
{ op: "beginPath" },
{ op: "moveTo", x: 0, y: 0 },
{ op: "lineTo", x: 1, y: 0 },
{ op: "stroke" },
]);
});
it("strokeStepInterval crosses corners without skipping boundaries", () => {
const { ctx, ops } = makeMockCtx();
const plan = makePlan();
const game = makeGame();
const drew = strokeStepInterval(ctx, game, plan, 2, 5);
expect(drew).toBe(true);
expect(ops).toEqual([
{ op: "beginPath" },
{ op: "moveTo", x: 2, y: 0 },
{ op: "lineTo", x: 3, y: 0 },
{ op: "lineTo", x: 3, y: 2 },
{ op: "stroke" },
]);
});
it("strokeStepInterval no-ops for empty deltas", () => {
const { ctx, ops } = makeMockCtx();
const plan = makePlan();
const game = makeGame();
expect(strokeStepInterval(ctx, game, plan, 4, 4)).toBe(false);
expect(ops).toEqual([]);
});
it("supports replan-style epoch replay by drawing multiple intervals", () => {
const { ctx, ops } = makeMockCtx();
const game = makeGame();
const epochA = {
startTick: 0,
ticksPerStep: 1,
points: Uint32Array.from([0, 3]),
segmentSteps: Uint32Array.from([3]),
segCumSteps: Uint32Array.from([0, 3]),
};
const epochB = {
startTick: 3,
ticksPerStep: 1,
points: Uint32Array.from([3, 33]),
segmentSteps: Uint32Array.from([3]),
segCumSteps: Uint32Array.from([0, 3]),
};
expect(strokeStepInterval(ctx, game, epochA, 0, 3)).toBe(true);
expect(strokeStepInterval(ctx, game, epochB, 0, 2)).toBe(true);
expect(ops).toEqual([
{ op: "beginPath" },
{ op: "moveTo", x: 0, y: 0 },
{ op: "lineTo", x: 3, y: 0 },
{ op: "stroke" },
{ op: "beginPath" },
{ op: "moveTo", x: 3, y: 0 },
{ op: "lineTo", x: 3, y: 2 },
{ op: "stroke" },
]);
});
});
+11 -5
View File
@@ -7,9 +7,12 @@ describe("UnitLayer trail lifecycle helpers", () => {
[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 transportTrails = new Map<
number,
{ activePlanId: number; epochs: unknown[]; lastOnScreen: boolean }
>([
[10, { activePlanId: 1, epochs: [{}], lastOnScreen: true }],
[12, { activePlanId: 2, epochs: [{}], lastOnScreen: false }],
]);
const result = pruneInactiveTrails(
@@ -25,8 +28,11 @@ describe("UnitLayer trail lifecycle helpers", () => {
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 transportTrails = new Map<
number,
{ activePlanId: number; epochs: unknown[]; lastOnScreen: boolean }
>([
[2, { activePlanId: 1, epochs: [{}], lastOnScreen: true }],
]);
const result = pruneInactiveTrails(