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
This commit is contained in:
scamiv
2026-02-28 05:54:42 +01:00
committed by GitHub
parent 1cafc6bc25
commit c911bfb2d8
15 changed files with 726 additions and 28 deletions
+26 -7
View File
@@ -19,6 +19,8 @@ export class TradeShipExecution implements Execution {
private wasCaptured = false;
private pathFinder: SteppingPathFinder<TileRef>;
private tilesTraveled = 0;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
constructor(
private origOwner: Player,
@@ -93,6 +95,8 @@ export class TradeShipExecution implements Execution {
} else {
this._dstPort = ports[0];
this.tradeShip.setTargetUnit(this._dstPort);
// Plan-driven units don't emit per-tick unit updates, so force a sync for the new target.
this.tradeShip.touch();
}
}
@@ -102,14 +106,29 @@ export class TradeShipExecution implements Execution {
return;
}
const result = this.pathFinder.next(curTile, this._dstPort.tile());
const dst = this._dstPort.tile();
const result = this.pathFinder.next(curTile, dst);
switch (result.status) {
case PathStatus.PENDING:
// Fire unit event to rerender.
this.tradeShip.move(curTile);
break;
case PathStatus.NEXT:
if (dst !== this.motionPlanDst) {
this.motionPlanId++;
const from = result.node;
const path = this.pathFinder.findPath(from, dst) ?? [from];
if (path.length === 0 || path[0] !== from) {
path.unshift(from);
}
this.mg.recordMotionPlan({
kind: "grid",
unitId: this.tradeShip.id(),
planId: this.motionPlanId,
startTick: ticks + 1,
ticksPerStep: 1,
path,
});
this.motionPlanDst = dst;
}
// Update safeFromPirates status
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
this.tradeShip.setSafeFromPirates();
@@ -119,14 +138,14 @@ export class TradeShipExecution implements Execution {
break;
case PathStatus.COMPLETE:
this.complete();
break;
return;
case PathStatus.NOT_FOUND:
console.warn("captured trade ship cannot find route");
if (this.tradeShip.isActive()) {
this.tradeShip.delete(false);
}
this.active = false;
break;
return;
}
}
+31
View File
@@ -7,6 +7,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { RailNetwork } from "../game/RailNetwork";
import { getOrientedRailroad, OrientedRailroad } from "../game/Railroad";
import { TrainStation } from "../game/TrainStation";
@@ -63,6 +64,36 @@ export class TrainExecution implements Execution {
return;
}
this.train = this.createTrainUnits(spawn);
const carUnitIds = this.cars.map((c) => c.id());
const pathTiles: TileRef[] = [];
for (let i = 0; i + 1 < this.stations.length; i++) {
const segment = getOrientedRailroad(
this.stations[i],
this.stations[i + 1],
);
if (!segment) {
this.active = false;
return;
}
pathTiles.push(...segment.getTiles());
}
const startTile = this.train.tile();
if (pathTiles.length === 0 || pathTiles[0] !== startTile) {
pathTiles.unshift(startTile);
}
const plan: MotionPlanRecord = {
kind: "train",
engineUnitId: this.train.id(),
carUnitIds,
planId: 1,
startTick: ticks + 1,
speed: this.speed,
spacing: this.spacing,
path: pathTiles,
};
this.mg.recordMotionPlan(plan);
}
tick(ticks: number): void {
+39 -2
View File
@@ -9,6 +9,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
@@ -31,6 +32,8 @@ export class TransportShipExecution implements Execution {
private src: TileRef | null;
private retreatDst: TileRef | false | null = null;
private boat: Unit;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
private originalOwner: Player;
@@ -110,6 +113,22 @@ export class TransportShipExecution implements Execution {
targetTile: this.dst,
});
const fullPath = this.pathFinder.findPath(this.src, this.dst) ?? [this.src];
if (fullPath.length === 0 || fullPath[0] !== this.src) {
fullPath.unshift(this.src);
}
const motionPlan: MotionPlanRecord = {
kind: "grid",
unitId: this.boat.id(),
planId: this.motionPlanId,
startTick: ticks + this.ticksPerMove,
ticksPerStep: this.ticksPerMove,
path: fullPath,
};
this.mg.recordMotionPlan(motionPlan);
this.motionPlanDst = this.dst;
// Notify the target player about the incoming naval invasion
if (this.target.id() !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
@@ -229,8 +248,6 @@ export class TransportShipExecution implements Execution {
case PathStatus.NEXT:
this.boat.move(result.node);
break;
case PathStatus.PENDING:
break;
case PathStatus.NOT_FOUND: {
// TODO: add to poisoned port list
const map = this.mg.map();
@@ -244,6 +261,26 @@ export class TransportShipExecution implements Execution {
return;
}
}
if (this.dst !== null && this.dst !== this.motionPlanDst) {
this.motionPlanId++;
const fullPath = this.pathFinder.findPath(this.boat.tile(), this.dst) ?? [
this.boat.tile(),
];
if (fullPath.length === 0 || fullPath[0] !== this.boat.tile()) {
fullPath.unshift(this.boat.tile());
}
this.mg.recordMotionPlan({
kind: "grid",
unitId: this.boat.id(),
planId: this.motionPlanId,
startTick: ticks + this.ticksPerMove,
ticksPerStep: this.ticksPerMove,
path: fullPath,
});
this.motionPlanDst = this.dst;
}
}
owner(): Player {
-6
View File
@@ -190,9 +190,6 @@ export class WarshipExecution implements Execution {
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.PENDING:
this.warship.touch();
break;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
break;
@@ -221,9 +218,6 @@ export class WarshipExecution implements Execution {
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.PENDING:
this.warship.touch();
return;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
break;