();
- 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();
diff --git a/src/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts
index d714566d6..4259d32b3 100644
--- a/src/client/graphics/layers/WebGLTerritoryBackend.ts
+++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts
@@ -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)
diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts
index 961cd61ce..97c7120f0 100644
--- a/src/client/graphics/layers/WebGPUDebugOverlay.ts
+++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts
@@ -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 {
>
diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
index 88d7b48de..9ca781e7f 100644
--- a/src/client/graphics/layers/WebGPUTerritoryBackend.ts
+++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
@@ -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.
diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts
index 5e6d0d0bb..d37f84299 100644
--- a/src/client/graphics/webgpu/TerritoryRenderer.ts
+++ b/src/client/graphics/webgpu/TerritoryRenderer.ts
@@ -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.
diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts
index 00daa40dc..d7aad9a71 100644
--- a/src/client/graphics/webgpu/core/GroundTruthData.ts
+++ b/src/client/graphics/webgpu/core/GroundTruthData.ts
@@ -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();
+ 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;
diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
index 9d61cc2cd..1258b4787 100644
--- a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
@@ -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(
diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
index be5a76a8e..2c8655761 100644
--- a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
@@ -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),
);
}
diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
index ee78cdf1c..a32140944 100644
--- a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
@@ -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;
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index 6e3e779f4..69c904de9 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -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();
}
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 8a413e19e..816ba4e3b 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -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) {
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 21b494036..98658dfd2 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -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": {
diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts
index bc6e9a6a1..de9bc59e9 100644
--- a/src/core/game/MotionPlans.ts
+++ b/src/core/game/MotionPlans.ts
@@ -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;
diff --git a/tests/MotionPlansSegments.test.ts b/tests/MotionPlansSegments.test.ts
index 1f6628025..f7fe42af3 100644
--- a/tests/MotionPlansSegments.test.ts
+++ b/tests/MotionPlansSegments.test.ts
@@ -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([
{