Optimize transport trail processing and raster helpers

This commit is contained in:
scamiv
2026-02-27 22:01:51 +01:00
parent 369cd0673d
commit 69a94584ff
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();
}
}