mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:20:46 +00:00
Optimize transport trail processing and raster helpers
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user