Files
OpenFrontIO/src/core/execution/TrainExecution.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

275 lines
7.0 KiB
TypeScript

import {
Execution,
Game,
Player,
TrainType,
Unit,
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";
export class TrainExecution implements Execution {
private active = true;
private mg: Game | null = null;
private train: Unit | null = null; // primary unit
private cars: Unit[] = []; // stored back to front
private hasCargo: boolean = false;
private currentTile: number = 0;
private spacing = 2;
private usedTiles: TileRef[] = []; // used for cars behind
private stations: TrainStation[] = [];
private currentRailroad: OrientedRailroad | null = null;
private speed: number = 2;
constructor(
private railNetwork: RailNetwork,
private player: Player,
private source: TrainStation,
private destination: TrainStation,
private numCars: number,
) {}
public owner(): Player {
return this.player;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const stations = this.railNetwork.findStationsPath(
this.source,
this.destination,
);
if (!stations || stations.length <= 1) {
this.active = false;
return;
}
this.stations = stations;
const railroad = getOrientedRailroad(this.stations[0], this.stations[1]);
if (railroad) {
this.currentRailroad = railroad;
} else {
this.active = false;
return;
}
const spawn = this.player.canBuild(UnitType.Train, this.stations[0].tile());
if (spawn === false) {
console.warn(`cannot build train`);
this.active = false;
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 {
if (this.train === null) {
throw new Error("Not initialized");
}
if (!this.train.isActive() || !this.activeSourceOrDestination()) {
this.deleteTrain();
return;
}
const tile = this.getNextTile();
if (tile) {
this.updateCarsPositions(tile);
} else {
this.targetReached();
this.deleteTrain();
}
}
loadCargo() {
if (this.hasCargo || this.train === null) {
return;
}
this.hasCargo = true;
// Starts at 1: don't load tail engine
for (let i = 1; i < this.cars.length; i++) {
this.cars[i].setLoaded(true);
}
}
private targetReached() {
if (this.train === null) {
return;
}
this.train.setReachedTarget();
this.cars.forEach((car: Unit) => {
car.setReachedTarget();
});
}
private createTrainUnits(tile: TileRef): Unit {
const train = this.player.buildUnit(UnitType.Train, tile, {
targetUnit: this.destination.unit,
trainType: TrainType.Engine,
});
// Tail is also an engine, just for cosmetics
this.cars.push(
this.player.buildUnit(UnitType.Train, tile, {
targetUnit: this.destination.unit,
trainType: TrainType.TailEngine,
}),
);
for (let i = 0; i < this.numCars; i++) {
this.cars.push(
this.player.buildUnit(UnitType.Train, tile, {
trainType: TrainType.Carriage,
loaded: this.hasCargo,
}),
);
}
return train;
}
private deleteTrain() {
this.active = false;
if (this.train?.isActive()) {
this.train.delete(false);
}
for (const car of this.cars) {
if (car.isActive()) {
car.delete(false);
}
}
}
private activeSourceOrDestination(): boolean {
return (
this.stations.length > 1 &&
this.stations[1].isActive() &&
this.stations[0].isActive()
);
}
/**
* Save the tiles the train go through so the cars can reuse them
* Don't simply save the tiles the engine uses, otherwise the spacing will be dictated by the train speed
*/
private saveTraversedTiles(from: number, speed: number) {
if (!this.currentRailroad) {
return;
}
let tileToSave: number = from;
for (
let i = 0;
i < speed && tileToSave < this.currentRailroad.getTiles().length;
i++
) {
this.saveTile(this.currentRailroad.getTiles()[tileToSave]);
tileToSave = tileToSave + 1;
}
}
private saveTile(tile: TileRef) {
this.usedTiles.push(tile);
if (this.usedTiles.length > this.cars.length * this.spacing + 3) {
this.usedTiles.shift();
}
}
private updateCarsPositions(newTile: TileRef) {
if (this.cars.length > 0) {
for (let i = this.cars.length - 1; i >= 0; --i) {
const carTileIndex = (i + 1) * this.spacing + 2;
if (this.usedTiles.length > carTileIndex) {
this.cars[i].move(this.usedTiles[carTileIndex]);
}
}
}
if (this.train !== null) {
this.train.move(newTile);
}
}
private nextStation() {
if (this.stations.length > 2) {
this.stations.shift();
const railRoad = getOrientedRailroad(this.stations[0], this.stations[1]);
if (railRoad) {
this.currentRailroad = railRoad;
return true;
}
}
return false;
}
private canTradeWithDestination() {
return (
this.stations.length > 1 && this.stations[1].tradeAvailable(this.player)
);
}
private getNextTile(): TileRef | null {
if (this.currentRailroad === null || !this.canTradeWithDestination()) {
return null;
}
this.saveTraversedTiles(this.currentTile, this.speed);
this.currentTile = this.currentTile + this.speed;
const leftOver = this.currentTile - this.currentRailroad.getTiles().length;
if (leftOver >= 0) {
// Station reached, pick the next station
this.stationReached();
if (!this.nextStation()) {
return null; // Destination reached (or no valid connection)
}
this.currentTile = leftOver;
this.saveTraversedTiles(0, leftOver);
}
return this.currentRailroad.getTiles()[this.currentTile];
}
private stationReached() {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
this.stations[1].onTrainStop(this);
return;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}