Files
OpenFrontIO/src/core/game/MotionPlans.ts
T
scamiv c911bfb2d8 Packed unit updates / MotionPlans (#3292)
## Description:

Reduce per-step `Unit` update traffic by shipping packed motion plans
and letting the client advance plan-driven units locally.

Changes:
- Add packed motion plan records (`packedMotionPlans?: Uint32Array`) to
game updates and transfer the buffer worker -> main.
- Introduce `src/core/game/MotionPlans.ts` (schema + pack/unpack) for
grid + train motion plans.
- Extend `Game` with `recordMotionPlan(...)` and
`drainPackedMotionPlans()`, and implement buffering/packing in
`GameImpl`.
- Treat units with motion plans as “plan-driven”: suppress per-tile
`Unit` updates on `move()` and advance positions client-side.
- Emit motion plans from executions:
- `TradeShipExecution`: record/update grid motion plans and `touch()`
when changing target after capture.
- `TransportShipExecution`: record initial plan and update it when
destination changes.
  - `TrainExecution`: record a train plan on init (engine + cars).
- Client: apply motion plans in `GameView` and ensure `UnitLayer`
updates sprites for motion-planned units even when no `Unit` updates
arrived.

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
2026-02-27 20:54:42 -08:00

213 lines
5.7 KiB
TypeScript

import { TileRef } from "./GameMap";
export enum PackedMotionPlanKind {
GridPathSet = 1,
TrainRailPathSet = 2,
}
export interface GridPathPlan {
kind: "grid";
unitId: number;
planId: number;
startTick: number;
ticksPerStep: number;
/**
* TileRef path where `path[0]` is the unit tile at `startTick`.
*/
path: readonly TileRef[] | Uint32Array;
}
export interface TrainRailPathPlan {
kind: "train";
engineUnitId: number;
/**
* TrainExecution `cars[]` order (tail engine + carriages).
*/
carUnitIds: readonly number[] | Uint32Array;
planId: number;
startTick: number;
speed: number;
spacing: number;
/**
* Concatenated rail tile path across all segments, without de-duplicating at stations.
*/
path: readonly TileRef[] | Uint32Array;
}
export type MotionPlanRecord = GridPathPlan | TrainRailPathPlan;
export function packMotionPlans(
records: readonly MotionPlanRecord[],
): Uint32Array {
let totalWords = 1;
for (const record of records) {
switch (record.kind) {
case "grid": {
const pathLen = (record.path.length >>> 0) as number;
totalWords += 2 + 5 + pathLen;
break;
}
case "train": {
const carCount = (record.carUnitIds.length >>> 0) as number;
const pathLen = (record.path.length >>> 0) as number;
totalWords += 2 + 7 + carCount + pathLen;
break;
}
}
}
const out = new Uint32Array(totalWords);
out[0] = records.length >>> 0;
let offset = 1;
for (const record of records) {
switch (record.kind) {
case "grid": {
const path = record.path as ArrayLike<number>;
const pathLen = path.length >>> 0;
const wordCount = 2 + 5 + pathLen;
out[offset++] = PackedMotionPlanKind.GridPathSet;
out[offset++] = wordCount >>> 0;
out[offset++] = record.unitId >>> 0;
out[offset++] = record.planId >>> 0;
out[offset++] = record.startTick >>> 0;
out[offset++] = record.ticksPerStep >>> 0;
out[offset++] = pathLen >>> 0;
for (let i = 0; i < pathLen; i++) {
out[offset++] = path[i] >>> 0;
}
break;
}
case "train": {
const carUnitIds = record.carUnitIds as ArrayLike<number>;
const carCount = carUnitIds.length >>> 0;
const path = record.path as ArrayLike<number>;
const pathLen = path.length >>> 0;
const wordCount = 2 + 7 + carCount + pathLen;
out[offset++] = PackedMotionPlanKind.TrainRailPathSet;
out[offset++] = wordCount >>> 0;
out[offset++] = record.engineUnitId >>> 0;
out[offset++] = record.planId >>> 0;
out[offset++] = record.startTick >>> 0;
out[offset++] = record.speed >>> 0;
out[offset++] = record.spacing >>> 0;
out[offset++] = carCount >>> 0;
out[offset++] = pathLen >>> 0;
for (let i = 0; i < carCount; i++) {
out[offset++] = carUnitIds[i] >>> 0;
}
for (let i = 0; i < pathLen; i++) {
out[offset++] = path[i] >>> 0;
}
break;
}
}
}
if (offset !== out.length) {
throw new Error(
`packMotionPlans size mismatch: wrote ${offset}, expected ${out.length}`,
);
}
return out;
}
export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
if (packed.length < 1) {
return [];
}
const recordCount = packed[0] >>> 0;
const records: MotionPlanRecord[] = [];
let offset = 1;
for (let i = 0; i < recordCount && offset + 1 < packed.length; i++) {
const kind = packed[offset] >>> 0;
const wordCount = packed[offset + 1] >>> 0;
if (wordCount < 2 || offset + wordCount > packed.length) {
break;
}
switch (kind) {
case PackedMotionPlanKind.GridPathSet: {
if (wordCount < 2 + 5) {
break;
}
const unitId = packed[offset + 2] >>> 0;
const planId = packed[offset + 3] >>> 0;
const startTick = packed[offset + 4] >>> 0;
const ticksPerStep = packed[offset + 5] >>> 0;
const pathLen = packed[offset + 6] >>> 0;
const expectedWordCount = 2 + 5 + pathLen;
if (expectedWordCount !== wordCount) {
break;
}
const pathStart = offset + 7;
const pathEnd = pathStart + pathLen;
const path = packed.slice(pathStart, pathEnd);
records.push({
kind: "grid",
unitId,
planId,
startTick,
ticksPerStep,
path,
});
break;
}
case PackedMotionPlanKind.TrainRailPathSet: {
if (wordCount < 2 + 7) {
break;
}
const engineUnitId = packed[offset + 2] >>> 0;
const planId = packed[offset + 3] >>> 0;
const startTick = packed[offset + 4] >>> 0;
const speed = packed[offset + 5] >>> 0;
const spacing = packed[offset + 6] >>> 0;
const carCount = packed[offset + 7] >>> 0;
const pathLen = packed[offset + 8] >>> 0;
const expectedWordCount = 2 + 7 + carCount + pathLen;
if (expectedWordCount !== wordCount) {
break;
}
const carStart = offset + 9;
const carEnd = carStart + carCount;
const pathStart = carEnd;
const pathEnd = pathStart + pathLen;
const carUnitIds = packed.slice(carStart, carEnd);
const path = packed.slice(pathStart, pathEnd);
records.push({
kind: "train",
engineUnitId,
carUnitIds,
planId,
startTick,
speed,
spacing,
path,
});
break;
}
default:
// Unknown kind: skip.
break;
}
offset += wordCount;
}
return records;
}