From 79292fe8f99b9beaa28c86aeecc696abed117e97 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:17:34 +0100 Subject: [PATCH] Train motion plans --- src/client/graphics/layers/UnitLayer.ts | 2 +- src/core/execution/TrainExecution.ts | 31 +++++ src/core/game/GameImpl.ts | 6 + src/core/game/GameView.ts | 178 ++++++++++++++++++++++++ src/core/game/MotionPlans.ts | 91 +++++++++++- 5 files changed, 305 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 63c941e37..e5d72f0e8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -70,7 +70,7 @@ export class UnitLayer implements Layer { .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; - const motionPlanUnitIds = Array.from(this.game.motionPlans().keys()); + const motionPlanUnitIds = this.game.motionPlannedUnitIds(); if (updatedUnitIds.length === 0) { this.updateUnitsSprites(motionPlanUnitIds); diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 4872885e8..949c6dffc 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -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 { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 793a1f0e9..af289638d 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -438,6 +438,12 @@ export class GameImpl implements Game { case "grid": this.planDrivenUnitIds.add(record.unitId); break; + case "train": + this.planDrivenUnitIds.add(record.engineUnitId); + for (const unitId of record.carUnitIds) { + this.planDrivenUnitIds.add(unitId); + } + break; } this.motionPlanRecords.push(record); } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index ff0aec83b..a962672c9 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -598,6 +598,20 @@ export class PlayerView { } } +type TrainPlanState = { + planId: number; + startTick: number; + speed: number; + spacing: number; + carUnitIds: Uint32Array; + path: Uint32Array; + cursor: number; + usedTilesBuf: Uint32Array; + usedHead: number; + usedLen: number; + lastAdvancedTick: Tick; +}; + export class GameView implements GameMap { private lastUpdate: GameUpdateViewData | null; private smallIDToID = new Map(); @@ -617,6 +631,8 @@ export class GameView implements GameMap { path: Uint32Array; } >(); + private trainMotionPlans = new Map(); + private trainUnitToEngine = new Map(); private toDelete = new Set(); @@ -674,6 +690,21 @@ export class GameView implements GameMap { return this.unitMotionPlans; } + public motionPlannedUnitIds(): number[] { + const out: number[] = []; + for (const unitId of this.unitMotionPlans.keys()) { + out.push(unitId); + } + for (const [engineId, plan] of this.trainMotionPlans) { + out.push(engineId); + for (let i = 0; i < plan.carUnitIds.length; i++) { + const id = plan.carUnitIds[i] >>> 0; + if (id !== 0) out.push(id); + } + } + return out; + } + public isCatchingUp(): boolean { return (this.lastUpdate?.pendingTurns ?? 0) > 1; } @@ -751,6 +782,7 @@ export class GameView implements GameMap { // Wait until next tick to delete the unit. this.toDelete.add(unit.id()); this.unitMotionPlans.delete(unit.id()); + this.clearTrainPlanForUnit(unit.id()); } }); @@ -773,6 +805,106 @@ export class GameView implements GameMap { this.unitGrid.updateUnitCell(unit); } } + + this.advanceTrainMotionPlannedUnits(currentTick); + } + + private clearTrainPlanForUnit(unitId: number): void { + const engineId = + this.trainUnitToEngine.get(unitId) ?? + (this.trainMotionPlans.has(unitId) ? unitId : null); + if (engineId === null) { + return; + } + const plan = this.trainMotionPlans.get(engineId); + if (!plan) { + this.trainUnitToEngine.delete(unitId); + return; + } + this.trainMotionPlans.delete(engineId); + this.trainUnitToEngine.delete(engineId); + for (let i = 0; i < plan.carUnitIds.length; i++) { + const id = plan.carUnitIds[i] >>> 0; + if (id !== 0) this.trainUnitToEngine.delete(id); + } + } + + private advanceTrainMotionPlannedUnits(currentTick: Tick): void { + const staleEngineIds: number[] = []; + for (const [engineId, plan] of this.trainMotionPlans) { + const engine = this._units.get(engineId); + if (!engine || !engine.isActive()) { + staleEngineIds.push(engineId); + continue; + } + + const steps = currentTick - plan.lastAdvancedTick; + if (steps <= 0) { + continue; + } + + const path = plan.path; + const cap = plan.usedTilesBuf.length; + + const pushUsed = (tile: TileRef) => { + if (cap === 0) return; + if (plan.usedLen < cap) { + const idx = (plan.usedHead + plan.usedLen) % cap; + plan.usedTilesBuf[idx] = tile >>> 0; + plan.usedLen++; + } else { + plan.usedTilesBuf[plan.usedHead] = tile >>> 0; + plan.usedHead = (plan.usedHead + 1) % cap; + plan.usedLen = cap; + } + }; + + const usedGet = (index: number): TileRef | null => { + if (index < 0 || index >= plan.usedLen || cap === 0) return null; + const idx = (plan.usedHead + index) % cap; + return plan.usedTilesBuf[idx] as TileRef; + }; + + for (let step = 0; step < steps; step++) { + const cursor = plan.cursor; + for (let i = 0; i < plan.speed && cursor + i < path.length; i++) { + pushUsed(path[cursor + i] as TileRef); + } + + plan.cursor = Math.min(path.length - 1, cursor + plan.speed); + + for (let i = plan.carUnitIds.length - 1; i >= 0; --i) { + const carId = plan.carUnitIds[i] >>> 0; + if (carId === 0) continue; + const car = this._units.get(carId); + if (!car || !car.isActive()) { + continue; + } + const carTileIndex = (i + 1) * plan.spacing + 2; + const tile = usedGet(carTileIndex); + if (tile !== null) { + const oldTile = car.tile(); + car.applyDerivedPosition(tile); + if (tile !== oldTile) { + this.unitGrid.updateUnitCell(car); + } + } + } + + const newEngineTile = path[plan.cursor] as TileRef; + const oldEngineTile = engine.tile(); + engine.applyDerivedPosition(newEngineTile); + if (newEngineTile !== oldEngineTile) { + this.unitGrid.updateUnitCell(engine); + } + } + + plan.lastAdvancedTick = currentTick; + } + + for (const engineId of staleEngineIds) { + this.clearTrainPlanForUnit(engineId); + } } private motionTileAtTick( @@ -814,6 +946,52 @@ export class GameView implements GameMap { }); break; } + case "train": { + if (record.speed < 1 || record.path.length < 1) { + break; + } + const existing = this.trainMotionPlans.get(record.engineUnitId); + if (existing && record.planId <= existing.planId) { + break; + } + if (existing) { + this.clearTrainPlanForUnit(record.engineUnitId); + } + + const carUnitIds = + record.carUnitIds instanceof Uint32Array + ? record.carUnitIds + : Uint32Array.from(record.carUnitIds); + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + + const usedCap = carUnitIds.length * record.spacing + 3; + const usedTilesBuf = new Uint32Array(Math.max(0, usedCap)); + + this.trainMotionPlans.set(record.engineUnitId, { + planId: record.planId, + startTick: record.startTick, + speed: record.speed, + spacing: record.spacing, + carUnitIds, + path, + cursor: 0, + usedTilesBuf, + usedHead: 0, + usedLen: 0, + lastAdvancedTick: record.startTick, + }); + + this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId); + for (let i = 0; i < carUnitIds.length; i++) { + const carId = carUnitIds[i] >>> 0; + if (carId !== 0) + this.trainUnitToEngine.set(carId, record.engineUnitId); + } + break; + } } } } diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index d361bbe10..ae0e3c292 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -1,9 +1,10 @@ import { TileRef } from "./GameMap"; -export const MOTION_PLANS_SCHEMA_VERSION = 3; +export const MOTION_PLANS_SCHEMA_VERSION = 4; export enum PackedMotionPlanKind { GridPathSet = 1, + TrainRailPathSet = 2, } export interface GridPathPlan { @@ -18,7 +19,24 @@ export interface GridPathPlan { path: readonly TileRef[] | Uint32Array; } -export type MotionPlanRecord = GridPathPlan; +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[], @@ -48,6 +66,39 @@ export function packMotionPlans( } break; } + case "train": { + const carUnitIds = + record.carUnitIds instanceof Uint32Array + ? record.carUnitIds + : Uint32Array.from(record.carUnitIds); + const carCount = carUnitIds.length >>> 0; + + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + const pathLen = path.length >>> 0; + + const wordCount = 2 + 7 + carCount + pathLen; + out.push( + PackedMotionPlanKind.TrainRailPathSet, + wordCount, + record.engineUnitId >>> 0, + record.planId >>> 0, + record.startTick >>> 0, + record.speed >>> 0, + record.spacing >>> 0, + carCount, + pathLen, + ); + for (let i = 0; i < carUnitIds.length; i++) { + out.push(carUnitIds[i] >>> 0); + } + for (let i = 0; i < path.length; i++) { + out.push(path[i] >>> 0); + } + break; + } } } @@ -106,6 +157,42 @@ export function unpackMotionPlans(packed: Uint32Array): { }); 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;