diff --git a/src/client/graphics/layers/SegmentTrailRaster.ts b/src/client/graphics/layers/SegmentTrailRaster.ts new file mode 100644 index 000000000..8ca1d1a72 --- /dev/null +++ b/src/client/graphics/layers/SegmentTrailRaster.ts @@ -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; +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 532f37789..6f5ae1871 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -20,6 +20,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"; @@ -61,13 +66,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; @@ -447,6 +464,7 @@ export class UnitLayer implements Layer { const tickFloat = this.game.ticks() + tickAlpha; const viewBounds = this.currentViewBounds(); const activeMoverIds = new Set(); + const activeTransportTrailPlans: ActiveTransportTrailPlan[] = []; for (const [unitId, plan] of this.game.motionPlans()) { const unit = this.game.unit(unitId); @@ -464,6 +482,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 ( @@ -486,6 +512,10 @@ export class UnitLayer implements Layer { viewBounds, ); + this.advanceAndDrawTransportTrails( + this.game.ticks(), + activeTransportTrailPlans, + ); this.rebuildTrailCanvasIfDirty(); context.drawImage( @@ -712,22 +742,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, @@ -747,7 +767,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); @@ -776,16 +795,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") { @@ -946,15 +955,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; @@ -991,8 +991,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; } @@ -1016,16 +1015,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++; @@ -1483,43 +1472,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 { @@ -1549,39 +1607,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(); } } diff --git a/tests/SegmentTrailRaster.test.ts b/tests/SegmentTrailRaster.test.ts new file mode 100644 index 000000000..abcd13d8d --- /dev/null +++ b/tests/SegmentTrailRaster.test.ts @@ -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" }, + ]); + }); +}); diff --git a/tests/UnitLayerTrailLifecycle.test.ts b/tests/UnitLayerTrailLifecycle.test.ts index 728552596..efd16041a 100644 --- a/tests/UnitLayerTrailLifecycle.test.ts +++ b/tests/UnitLayerTrailLifecycle.test.ts @@ -7,9 +7,12 @@ describe("UnitLayer trail lifecycle helpers", () => { [10, [1, 2, 3]], [11, [4, 5]], ]); - const transportTrails = new Map([ - [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([[1, [1]]]); - const transportTrails = new Map([ - [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(