Restore smooth nuke motion and terrain updates

This commit is contained in:
scamiv
2026-05-27 15:34:02 +02:00
parent 91130bc89c
commit 334fc69590
15 changed files with 439 additions and 89 deletions
@@ -89,6 +89,31 @@ export class RendererStatusPanel extends LitElement implements Layer {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.titleActions {
display: flex;
align-items: center;
gap: 6px;
}
button {
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
padding: 4px 7px;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
}
button:disabled {
opacity: 0.45;
cursor: default;
}
.panel.dragging .title {
cursor: grabbing;
}
@@ -204,6 +229,32 @@ export class RendererStatusPanel extends LitElement implements Layer {
this.preference = this.userSettings.territoryRenderer();
}
private rendererSettingsTarget(): "webgl" | "webgpu" | null {
if (this.activeRenderer === "webgl" || this.activeRenderer === "webgpu") {
return this.activeRenderer;
}
if (
this.activeRenderer === null &&
(this.preference === "webgl" || this.preference === "webgpu")
) {
return this.preference;
}
return null;
}
private openRendererSettings(event: Event) {
event.preventDefault();
event.stopPropagation();
const renderer = this.rendererSettingsTarget();
if (renderer === "webgl") {
this.userSettings.setWebgpuDebug(false);
this.userSettings.setWebglDebug(true);
} else if (renderer === "webgpu") {
this.userSettings.setWebglDebug(false);
this.userSettings.setWebgpuDebug(true);
}
}
private rendererLabel(id: TerritoryRendererId | TerritoryRendererPreference) {
if (id === "webgpu") return "WebGPU";
if (id === "webgl") return "WebGL";
@@ -341,6 +392,7 @@ export class RendererStatusPanel extends LitElement implements Layer {
}
const note = this.statusNote();
const canOpenSettings = this.rendererSettingsTarget() !== null;
return html`
<div
class="panel ${this.isDragging ? "dragging" : ""}"
@@ -349,6 +401,16 @@ export class RendererStatusPanel extends LitElement implements Layer {
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<span>Renderer</span>
<div class="titleActions">
<button
type="button"
?disabled=${!canOpenSettings}
@pointerdown=${this.stopPointerEvent}
@click=${this.openRendererSettings}
>
settings
</button>
</div>
</div>
<div class="body">
<div class="row">
+60 -45
View File
@@ -60,28 +60,22 @@ const TRANSPORT_SHIP_MASK = [
".BTB.",
"..B..",
] as const;
const TRADE_SHIP_MASK = [
"..T..",
".TBT.",
"TBBBT",
".TBT.",
"..T..",
] as const;
const TRADE_SHIP_MASK = ["..T..", ".TBT.", "TBBBT", ".TBT.", "..T.."] as const;
type TransportTrailState = {
type MotionTrailState = {
activePlanId: number;
epochs: TransportTrailEpoch[];
epochs: MotionTrailEpoch[];
lastOnScreen: boolean;
};
type TransportTrailEpoch = SegmentTrailPlanView & {
type MotionTrailEpoch = SegmentTrailPlanView & {
planId: number;
targetStep: number;
drawnStep: number;
sealed: boolean;
};
type ActiveTransportTrailPlan = {
type ActiveMotionTrailPlan = {
unitId: number;
unit: UnitView;
plan: SegmentTrailPlanView & { planId: number };
@@ -134,7 +128,7 @@ export class UnitLayer implements Layer {
private gridMoverUnitIds = new Set<number>();
private transportShipTrails = new Map<number, TransportTrailState>();
private segmentTrails = new Map<number, MotionTrailState>();
private trailDirty = false;
private moverState = new Map<number, MoverRenderState>();
@@ -201,7 +195,7 @@ export class UnitLayer implements Layer {
tick() {
const trailPrune = pruneInactiveTrails(
this.unitToTrail,
this.transportShipTrails,
this.segmentTrails,
(unitId) => {
const current = this.game.unit(unitId);
return !!current && current.isActive();
@@ -482,13 +476,13 @@ export class UnitLayer implements Layer {
const tickFloat = this.game.ticks() + tickAlpha;
const viewBounds = this.currentViewBounds();
const activeMoverIds = new Set<number>();
const activeTransportTrailPlans: ActiveTransportTrailPlan[] = [];
const activeMotionTrailPlans: ActiveMotionTrailPlan[] = [];
for (const [unitId, plan] of this.game.motionPlans()) {
const unit = this.game.unit(unitId);
if (!unit || !unit.isActive()) {
this.clearMoverState(unitId);
if (this.transportShipTrails.delete(unitId)) this.trailDirty = true;
if (this.segmentTrails.delete(unitId)) this.trailDirty = true;
continue;
}
activeMoverIds.add(unitId);
@@ -500,8 +494,8 @@ export class UnitLayer implements Layer {
tickFloat,
viewBounds,
);
if (unit.type() === UnitType.TransportShip) {
activeTransportTrailPlans.push({
if (this.shouldDrawSegmentTrail(unit)) {
activeMotionTrailPlans.push({
unitId,
unit,
plan,
@@ -530,10 +524,7 @@ export class UnitLayer implements Layer {
viewBounds,
);
this.advanceAndDrawTransportTrails(
this.game.ticks(),
activeTransportTrailPlans,
);
this.advanceAndDrawSegmentTrails(this.game.ticks(), activeMotionTrailPlans);
this.rebuildTrailCanvasIfDirty();
context.drawImage(
@@ -960,7 +951,9 @@ export class UnitLayer implements Layer {
continue;
}
const candidateRects: MoverSpriteRect[] = [candidateState.lastSpriteRect];
const candidateRects: MoverSpriteRect[] = [
candidateState.lastSpriteRect,
];
const candidateSample = this.getConflictSample(
candidateId,
tickFloat,
@@ -998,7 +991,13 @@ export class UnitLayer implements Layer {
return null;
}
return this.getMoverSample(unitId, unit, plan.planId, tickFloat, sampledCache);
return this.getMoverSample(
unitId,
unit,
plan.planId,
tickFloat,
sampledCache,
);
}
private anyRectsOverlap(
@@ -1227,10 +1226,7 @@ export class UnitLayer implements Layer {
private rectsOverlap(a: MoverSpriteRect, b: MoverSpriteRect): boolean {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
);
}
@@ -1304,7 +1300,8 @@ export class UnitLayer implements Layer {
while (
idx > 0 &&
zoom < DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS
zoom <
DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS
) {
idx--;
}
@@ -1344,7 +1341,8 @@ export class UnitLayer implements Layer {
this.lastDynamicMoverCanvasRescaleMs =
this.rebuildDynamicMoverCanvas(targetScale);
this.totalDynamicMoverCanvasRescaleMs += this.lastDynamicMoverCanvasRescaleMs;
this.totalDynamicMoverCanvasRescaleMs +=
this.lastDynamicMoverCanvasRescaleMs;
this.dynamicMoverCanvasRescaleCount++;
this.dynamicMoverCanvasScale = targetScale;
this.lastDynamicMoverCanvasScaleChangeAtMs = nowMs;
@@ -1371,8 +1369,14 @@ export class UnitLayer implements Layer {
const oldHeight = oldCanvas.height;
this.dynamicMoverCanvas = document.createElement("canvas");
this.dynamicMoverCanvas.width = Math.max(1, this.game.width() * targetScale);
this.dynamicMoverCanvas.height = Math.max(1, this.game.height() * targetScale);
this.dynamicMoverCanvas.width = Math.max(
1,
this.game.width() * targetScale,
);
this.dynamicMoverCanvas.height = Math.max(
1,
this.game.height() * targetScale,
);
const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d");
if (dynamicMoverContext === null) {
throw new Error("2d context not supported");
@@ -1681,35 +1685,35 @@ export class UnitLayer implements Layer {
this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h);
}
private advanceAndDrawTransportTrails(
private advanceAndDrawSegmentTrails(
currentTick: number,
activePlans: readonly ActiveTransportTrailPlan[],
activePlans: readonly ActiveMotionTrailPlan[],
): void {
for (const { unitId, unit, plan, maybeOnScreen } of activePlans) {
const state = this.ensureTransportTrailState(unitId, plan, currentTick);
const state = this.ensureSegmentTrailState(unitId, plan, currentTick);
const moverState = this.moverState.get(unitId);
const onScreen = moverState ? moverState.bucket === "on" : maybeOnScreen;
if (onScreen) {
this.drawPendingTransportTrailEpochs(unit, state);
this.drawPendingSegmentTrailEpochs(unit, state);
}
state.lastOnScreen = onScreen;
}
}
private ensureTransportTrailState(
private ensureSegmentTrailState(
unitId: number,
plan: SegmentTrailPlanView & { planId: number },
currentTick: number,
): TransportTrailState {
let state = this.transportShipTrails.get(unitId);
): MotionTrailState {
let state = this.segmentTrails.get(unitId);
if (!state) {
state = {
activePlanId: plan.planId,
epochs: [],
lastOnScreen: false,
};
this.transportShipTrails.set(unitId, state);
this.segmentTrails.set(unitId, state);
}
let activeEpoch = state.epochs[state.epochs.length - 1];
@@ -1726,7 +1730,7 @@ export class UnitLayer implements Layer {
activeEpoch.sealed = true;
}
activeEpoch = this.createTransportTrailEpoch(plan, currentTick);
activeEpoch = this.createSegmentTrailEpoch(plan, currentTick);
state.epochs.push(activeEpoch);
state.activePlanId = plan.planId;
return state;
@@ -1741,10 +1745,10 @@ export class UnitLayer implements Layer {
return state;
}
private createTransportTrailEpoch(
private createSegmentTrailEpoch(
plan: SegmentTrailPlanView & { planId: number },
currentTick: number,
): TransportTrailEpoch {
): MotionTrailEpoch {
return {
planId: plan.planId,
startTick: plan.startTick,
@@ -1758,9 +1762,9 @@ export class UnitLayer implements Layer {
};
}
private drawPendingTransportTrailEpochs(
private drawPendingSegmentTrailEpochs(
unit: UnitView,
state: TransportTrailState,
state: MotionTrailState,
): void {
const ctx = this.trailContext;
const strokeStyle = this.motionTrailColor(unit);
@@ -1816,7 +1820,7 @@ export class UnitLayer implements Layer {
}
}
for (const [unitId, trailState] of this.transportShipTrails) {
for (const [unitId, trailState] of this.segmentTrails) {
const unit = this.game.unit(unitId);
if (!unit || !unit.isActive()) {
continue;
@@ -1837,6 +1841,17 @@ export class UnitLayer implements Layer {
}
}
private shouldDrawSegmentTrail(unit: UnitView): boolean {
const type = unit.type();
return (
type === UnitType.TransportShip ||
type === UnitType.AtomBomb ||
type === UnitType.HydrogenBomb ||
type === UnitType.MIRV ||
type === UnitType.MIRVWarhead
);
}
private relationshipForAlternateView(unit: UnitView): Relationship {
let rel = this.relationship(unit);
const dstPortId = unit.targetUnitId();
@@ -467,10 +467,41 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
root.style.touchAction = "none";
const title = document.createElement("div");
title.textContent = "Territory smoothing";
title.style.fontWeight = "700";
title.style.marginBottom = "6px";
title.style.cursor = "move";
title.style.display = "flex";
title.style.alignItems = "center";
title.style.justifyContent = "space-between";
title.style.gap = "10px";
const titleText = document.createElement("span");
titleText.textContent = "Territory smoothing";
title.appendChild(titleText);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.textContent = "x";
closeButton.title = "Close";
closeButton.style.width = "22px";
closeButton.style.height = "22px";
closeButton.style.border = "1px solid rgba(255,255,255,0.18)";
closeButton.style.borderRadius = "5px";
closeButton.style.background = "rgba(255,255,255,0.08)";
closeButton.style.color = "rgba(255,255,255,0.88)";
closeButton.style.font = "12px monospace";
closeButton.style.lineHeight = "1";
closeButton.style.cursor = "pointer";
closeButton.addEventListener("pointerdown", (e) => {
e.preventDefault();
e.stopPropagation();
});
closeButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.userSettings.setWebglDebug(false);
});
title.appendChild(closeButton);
root.appendChild(title);
// Restore last position (if any)
@@ -106,6 +106,22 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
cursor: grabbing;
}
.closeButton {
width: 22px;
height: 22px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 5px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
font: inherit;
line-height: 1;
cursor: pointer;
}
.closeButton:hover {
background: rgba(255, 255, 255, 0.16);
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -228,7 +244,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
}
private selectedShaderId() {
const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 0);
const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 1);
return territoryShaderIdFromInt(selected);
}
@@ -241,7 +257,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
}
private selectedTerrainShaderId() {
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 2);
return terrainShaderIdFromInt(selected);
}
@@ -266,7 +282,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
}
private selectedPostSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 1);
return territoryPostSmoothingIdFromInt(selected);
}
@@ -403,6 +419,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
event.stopPropagation();
}
private closeOverlay(event: Event) {
event.preventDefault();
event.stopPropagation();
this.userSettings.setWebgpuDebug(false);
}
private handleDragPointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
@@ -486,6 +508,15 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<div>WebGPU Debug</div>
<button
class="closeButton"
type="button"
title="Close"
@pointerdown=${this.stopPointerEvent}
@click=${this.closeOverlay}
>
x
</button>
</div>
<div class="metrics">
@@ -121,6 +121,11 @@ export class WebGPUTerritoryBackend implements TerritoryBackend {
this.markTile(updatedTiles[i]);
}
const updatedTerrainTiles = this.game.recentlyUpdatedTerrainTiles();
if (updatedTerrainTiles.length > 0) {
this.territoryRenderer?.updateTerrainDataTiles(updatedTerrainTiles);
}
// After collecting pending updates and handling palette/theme changes,
// invoke the renderer's tick() to process compute passes. This ensures
// compute shaders run at the simulation rate rather than every frame.
@@ -470,6 +470,16 @@ export class TerritoryRenderer {
}
}
updateTerrainDataTiles(tiles: readonly number[]): void {
if (!this.resources || !this.device || tiles.length === 0) {
return;
}
this.resources.uploadTerrainDataTiles(tiles);
if (this.terrainComputePass) {
this.terrainComputePass.markDirty();
}
}
/**
* Immediately execute terrain compute pass (for theme changes).
* This ensures terrain is recomputed before the next render.
@@ -1,5 +1,6 @@
import { Theme } from "../../../../core/configuration/Config";
import { UnitType } from "../../../../core/game/Game";
import type { TileRef } from "../../../../core/game/GameMap";
import { GameView } from "../../../../core/game/GameView";
/**
@@ -606,6 +607,53 @@ export class GroundTruthData {
}
}
uploadTerrainDataTiles(tiles: readonly TileRef[]): void {
if (tiles.length === 0) {
return;
}
if (this.needsTerrainDataUpload) {
this.uploadTerrainData();
return;
}
const rowBounds = new Map<number, { minX: number; maxX: number }>();
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i] >>> 0;
if (tile >= this.terrainData.length) {
continue;
}
const y = Math.floor(tile / this.mapWidth);
if (y < 0 || y >= this.mapHeight) {
continue;
}
const x = tile - y * this.mapWidth;
const existing = rowBounds.get(y);
if (existing) {
existing.minX = Math.min(existing.minX, x);
existing.maxX = Math.max(existing.maxX, x);
} else {
rowBounds.set(y, { minX: x, maxX: x });
}
}
for (const [y, bounds] of rowBounds) {
const width = bounds.maxX - bounds.minX + 1;
const paddedBytesPerRow = align(width, 256);
const row = new Uint8Array(paddedBytesPerRow);
const start = y * this.mapWidth + bounds.minX;
row.set(this.terrainData.subarray(start, start + width), 0);
this.device.queue.writeTexture(
{
texture: this.terrainDataTexture,
origin: { x: bounds.minX, y },
},
row,
{ bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 },
{ width, height: 1, depthOrArrayLayers: 1 },
);
}
}
uploadTerrainParams(): void {
if (!this.needsTerrainParamsUpload) {
return;
@@ -180,7 +180,7 @@ export function terrainShaderIntFromId(id: TerrainShaderId): number {
export function readTerrainShaderId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerrainShaderId {
return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0));
return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 2));
}
export function buildTerrainShaderParams(
@@ -82,7 +82,7 @@ export function readTerritoryPostSmoothingId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerritoryPostSmoothingId {
return territoryPostSmoothingIdFromInt(
userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0),
userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 1),
);
}
@@ -189,15 +189,6 @@ export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.defendedThreshold",
label: "Defended Threshold",
defaultValue: 0.01,
min: 0.01,
max: 1,
step: 0.01,
},
],
},
];
@@ -223,7 +214,7 @@ export function territoryShaderIntFromId(id: TerritoryShaderId): number {
export function readTerritoryShaderId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerritoryShaderId {
return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0));
return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 1));
}
export function buildTerritoryShaderParams(
@@ -280,10 +271,7 @@ export function buildTerritoryShaderParams(
"settings.webgpu.territory.retro.defendedPatternStrength",
0.5,
);
const defendedThreshold = userSettings.getFloat(
"settings.webgpu.territory.retro.defendedThreshold",
0.01,
);
const defendedThreshold = 0.01;
let flags = 0;
if (colorByRelations) flags |= 1 << 0;
+13
View File
@@ -10,6 +10,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import type { MotionPlanRecord } from "../game/MotionPlans";
import { UniversalPathFinding } from "../pathfinding/PathFinder";
import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola";
import { PathStatus } from "../pathfinding/types";
@@ -188,6 +189,18 @@ export class NukeExecution implements Execution {
targetTile: this.dst,
trajectory: this.getTrajectory(this.dst),
});
const motionPlan: MotionPlanRecord = {
kind: "parabola",
unitId: this.nuke.id(),
planId: 1,
startTick: ticks + 1 + this.waitTicks,
src: spawn,
dst: this.dst,
increment: this.speed,
distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead,
directionUp: this.rocketDirectionUp,
};
this.mg.recordMotionPlan(motionPlan);
if (this.nuke.type() !== UnitType.MIRVWarhead) {
this.maybeBreakAlliances();
}
+3
View File
@@ -486,6 +486,9 @@ export class GameImpl implements Game {
case "grid_segments":
this.planDrivenUnitIds.add(record.unitId);
break;
case "parabola":
this.planDrivenUnitIds.add(record.unitId);
break;
case "train":
this.planDrivenUnitIds.add(record.engineUnitId);
for (const unitId of record.carUnitIds) {
+73 -23
View File
@@ -2,6 +2,8 @@ import { Colord, colord } from "colord";
import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { UniversalPathFinding } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName, formatPlayerDisplayName } from "../Util";
@@ -964,7 +966,7 @@ export class GameView implements GameMap {
}
}
} else {
while (seg + 1 < segmentCount && idx >= (segCumSteps[seg + 1] >>> 0)) {
while (seg + 1 < segmentCount && idx >= segCumSteps[seg + 1] >>> 0) {
seg++;
}
}
@@ -1124,15 +1126,62 @@ export class GameView implements GameMap {
}
}
private setGridSegmentMotionPlan(record: {
unitId: number;
planId: number;
startTick: number;
ticksPerStep: number;
points: readonly TileRef[] | Uint32Array;
segmentSteps: readonly number[] | Uint32Array;
}): boolean {
if (
record.ticksPerStep < 1 ||
record.points.length < 1 ||
record.segmentSteps.length !== Math.max(0, record.points.length - 1)
) {
return false;
}
const existing = this.unitMotionPlans.get(record.unitId);
if (existing && record.planId <= existing.planId) {
return false;
}
const points =
record.points instanceof Uint32Array
? record.points
: Uint32Array.from(record.points);
const segmentSteps =
record.segmentSteps instanceof Uint32Array
? record.segmentSteps
: Uint32Array.from(record.segmentSteps);
const segCumSteps = new Uint32Array(segmentSteps.length + 1);
for (let i = 0; i < segmentSteps.length; i++) {
segCumSteps[i + 1] = (segCumSteps[i] + (segmentSteps[i] >>> 0)) >>> 0;
}
this.unitMotionPlans.set(record.unitId, {
planId: record.planId,
startTick: record.startTick,
ticksPerStep: record.ticksPerStep,
points,
segmentSteps,
segCumSteps,
lastSegIdx: 0,
});
this.markMotionPlannedUnitIdsDirty();
return true;
}
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
for (const record of records) {
switch (record.kind) {
case "grid_segments": {
if (
record.ticksPerStep < 1 ||
record.points.length < 1 ||
record.segmentSteps.length !== Math.max(0, record.points.length - 1)
) {
this.setGridSegmentMotionPlan(record);
break;
}
case "parabola": {
if (record.increment < 1) {
break;
}
const existing = this.unitMotionPlans.get(record.unitId);
@@ -1140,31 +1189,32 @@ export class GameView implements GameMap {
break;
}
const points =
record.points instanceof Uint32Array
? record.points
: Uint32Array.from(record.points);
const segmentSteps =
record.segmentSteps instanceof Uint32Array
? record.segmentSteps
: Uint32Array.from(record.segmentSteps);
const pf = UniversalPathFinding.Parabola(this._map, {
increment: record.increment,
distanceBasedHeight: record.distanceBasedHeight,
directionUp: record.directionUp,
});
const segCumSteps = new Uint32Array(segmentSteps.length + 1);
for (let i = 0; i < segmentSteps.length; i++) {
segCumSteps[i + 1] =
(segCumSteps[i] + (segmentSteps[i] >>> 0)) >>> 0;
const points: TileRef[] = [record.src];
for (let i = 0; i < 20000; i++) {
const step = pf.next(record.src, record.dst, record.increment);
if (step.status === PathStatus.NEXT) {
points.push(step.node);
continue;
}
break;
}
this.unitMotionPlans.set(record.unitId, {
const segmentSteps = new Uint32Array(Math.max(0, points.length - 1));
segmentSteps.fill(1);
this.setGridSegmentMotionPlan({
unitId: record.unitId,
planId: record.planId,
startTick: record.startTick,
ticksPerStep: record.ticksPerStep,
ticksPerStep: 1,
points,
segmentSteps,
segCumSteps,
lastSegIdx: 0,
});
this.markMotionPlannedUnitIdsDirty();
break;
}
case "train": {
+65 -1
View File
@@ -3,6 +3,7 @@ import { TileRef } from "./GameMap";
export enum PackedMotionPlanKind {
TrainRailPathSet = 2,
GridPathKeypointSegments = 3,
ParabolaSet = 4,
}
export interface GridKeypointSegmentPlan {
@@ -32,7 +33,22 @@ export interface TrainRailPathPlan {
path: readonly TileRef[] | Uint32Array;
}
export type MotionPlanRecord = GridKeypointSegmentPlan | TrainRailPathPlan;
export interface ParabolaPlan {
kind: "parabola";
unitId: number;
planId: number;
startTick: number;
src: TileRef;
dst: TileRef;
increment: number;
distanceBasedHeight: boolean;
directionUp: boolean;
}
export type MotionPlanRecord =
| GridKeypointSegmentPlan
| TrainRailPathPlan
| ParabolaPlan;
export function packMotionPlans(
records: readonly MotionPlanRecord[],
@@ -51,6 +67,10 @@ export function packMotionPlans(
totalWords += 2 + 7 + carCount + pathLen;
break;
}
case "parabola": {
totalWords += 2 + 7;
break;
}
}
}
@@ -115,6 +135,22 @@ export function packMotionPlans(
}
break;
}
case "parabola": {
const flags =
(record.distanceBasedHeight ? 1 : 0) | (record.directionUp ? 2 : 0);
const wordCount = 2 + 7;
out[offset++] = PackedMotionPlanKind.ParabolaSet;
out[offset++] = wordCount >>> 0;
out[offset++] = record.unitId >>> 0;
out[offset++] = record.planId >>> 0;
out[offset++] = record.startTick >>> 0;
out[offset++] = record.src >>> 0;
out[offset++] = record.dst >>> 0;
out[offset++] = record.increment >>> 0;
out[offset++] = flags >>> 0;
break;
}
}
}
@@ -219,6 +255,34 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
});
break;
}
case PackedMotionPlanKind.ParabolaSet: {
if (wordCount !== 2 + 7) {
break;
}
const unitId = packed[offset + 2] >>> 0;
const planId = packed[offset + 3] >>> 0;
const startTick = packed[offset + 4] >>> 0;
const src = packed[offset + 5] as TileRef;
const dst = packed[offset + 6] as TileRef;
const increment = packed[offset + 7] >>> 0;
const flags = packed[offset + 8] >>> 0;
if (increment < 1) {
break;
}
records.push({
kind: "parabola",
unitId,
planId,
startTick,
src,
dst,
increment,
distanceBasedHeight: (flags & 1) !== 0,
directionUp: (flags & 2) !== 0,
});
break;
}
default:
// Unknown kind: skip.
break;
+30
View File
@@ -31,6 +31,36 @@ describe("MotionPlans grid_segments", () => {
expect(Array.from(r.segmentSteps)).toEqual([5, 5]);
});
it("packs/unpacks parabola records", () => {
const packed = packMotionPlans([
{
kind: "parabola",
unitId: 44,
planId: 3,
startTick: 99,
src: 10,
dst: 20,
increment: 7,
distanceBasedHeight: true,
directionUp: false,
},
]);
const records = unpackMotionPlans(packed);
expect(records).toHaveLength(1);
const r = records[0];
expect(r.kind).toBe("parabola");
if (r.kind !== "parabola") throw new Error("type guard");
expect(r.unitId).toBe(44);
expect(r.planId).toBe(3);
expect(r.startTick).toBe(99);
expect(r.src).toBe(10);
expect(r.dst).toBe(20);
expect(r.increment).toBe(7);
expect(r.distanceBasedHeight).toBe(true);
expect(r.directionUp).toBe(false);
});
it("skips unknown kinds using wordCount", () => {
const gridPacked = packMotionPlans([
{