diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 23a7e339c..e5d72f0e8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -65,11 +65,27 @@ export class UnitLayer implements Layer { } tick() { - const unitIds = this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => unit.id); + const updatedUnitIds = + this.game + .updatesSinceLastTick() + ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; - this.updateUnitsSprites(unitIds ?? []); + const motionPlanUnitIds = this.game.motionPlannedUnitIds(); + + if (updatedUnitIds.length === 0) { + this.updateUnitsSprites(motionPlanUnitIds); + return; + } + if (motionPlanUnitIds.length === 0) { + this.updateUnitsSprites(updatedUnitIds); + return; + } + + const unitIds = new Set(updatedUnitIds); + for (const id of motionPlanUnitIds) { + unitIds.add(id); + } + this.updateUnitsSprites(Array.from(unitIds)); } init() { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 7453b03af..3f9fd20ce 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -170,10 +170,12 @@ export class GameRunner { } const packedTileUpdates = this.game.drainPackedTileUpdates(); + const packedMotionPlans = this.game.drainPackedMotionPlans(); this.callBack({ tick: this.game.ticks(), packedTileUpdates, + ...(packedMotionPlans ? { packedMotionPlans } : {}), updates: updates, playerNameViewData: this.playerViewData, tickExecutionDuration: tickExecutionDuration, diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 23a905940..2c6b80e59 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -19,6 +19,8 @@ export class TradeShipExecution implements Execution { private wasCaptured = false; private pathFinder: SteppingPathFinder; 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; } } 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/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index b5ccdf464..3504e691c 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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 { diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 189ad9924..70bfb654c 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index fdd27a63d..c44705d37 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -10,6 +10,7 @@ import { PlayerUpdate, UnitUpdate, } from "./GameUpdates"; +import { MotionPlanRecord } from "./MotionPlans"; import { RailNetwork } from "./RailNetwork"; import { Stats } from "./Stats"; import { UnitPredicate } from "./UnitGrid"; @@ -777,6 +778,8 @@ export interface Game extends GameMap { inSpawnPhase(): boolean; executeNextTick(): GameUpdates; drainPackedTileUpdates(): Uint32Array; + recordMotionPlan(record: MotionPlanRecord): void; + drainPackedMotionPlans(): Uint32Array | null; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; getWinner(): Player | Team | null; config(): Config; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 202ed12d4..ab2a179f5 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -43,6 +43,7 @@ import { } from "./Game"; import { GameMap, TileRef } from "./GameMap"; import { GameUpdate, GameUpdateType } from "./GameUpdates"; +import { MotionPlanRecord, packMotionPlans } from "./MotionPlans"; import { PlayerImpl } from "./PlayerImpl"; import { RailNetwork } from "./RailNetwork"; import { createRailNetwork } from "./RailNetworkImpl"; @@ -95,6 +96,8 @@ export class GameImpl implements Game { private updates: GameUpdates = createGameUpdatesMap(); private tileUpdatePairs: number[] = []; + private motionPlanRecords: MotionPlanRecord[] = []; + private planDrivenUnitIds = new Set(); private unitGrid: UnitGrid; private playerTeams: Team[] = []; @@ -444,6 +447,46 @@ export class GameImpl implements Game { return packed; } + recordMotionPlan(record: MotionPlanRecord): void { + switch (record.kind) { + 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); + } + + private isUnitPlanDriven(unitId: number): boolean { + return this.planDrivenUnitIds.has(unitId); + } + + maybeAddUnitUpdate(unit: Unit): void { + if (!this.isUnitPlanDriven(unit.id())) { + this.addUpdate(unit.toUpdate()); + } + } + + onUnitMoved(unit: Unit): void { + this.updateUnitTile(unit); + this.maybeAddUnitUpdate(unit); + } + + drainPackedMotionPlans(): Uint32Array | null { + const records = this.motionPlanRecords; + if (records.length === 0) { + return null; + } + const packed = packMotionPlans(records); + records.length = 0; + return packed; + } + private hash(): number { let hash = 1; this._players.forEach((p) => { @@ -887,6 +930,7 @@ export class GameImpl implements Game { } removeUnit(u: Unit) { this.unitGrid.removeUnit(u); + this.planDrivenUnitIds.delete(u.id()); if (u.hasTrainStation()) { this._railNetwork.removeStation(u); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 567f7ad14..a85912bda 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -24,6 +24,13 @@ export interface GameUpdateViewData { * state (`uint16`) stored in a `uint32` lane. */ packedTileUpdates: Uint32Array; + /** + * Optional packed motion plan records. + * + * When present, this buffer is expected to be transferred worker -> main + * (similar to `packedTileUpdates`) to avoid structured-clone copies. + */ + packedMotionPlans?: Uint32Array; playerNameViewData: Record; tickExecutionDuration?: number; pendingTurns?: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 717e09056..f08b8178b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -34,6 +34,7 @@ import { PlayerUpdate, UnitUpdate, } from "./GameUpdates"; +import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans"; import { TerrainMapData } from "./TerrainMapLoader"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; @@ -83,6 +84,17 @@ export class UnitView { this.data = data; } + applyDerivedPosition(pos: TileRef) { + const prev = this.data.pos; + this.lastPos.push(pos); + this._wasUpdated = true; + this.data = { + ...this.data, + lastPos: prev, + pos, + }; + } + id(): number { return this.data.id; } @@ -582,6 +594,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(); @@ -592,6 +618,17 @@ export class GameView implements GameMap { private _myPlayer: PlayerView | null = null; private unitGrid: UnitGrid; + private unitMotionPlans = new Map< + number, + { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; + } + >(); + private trainMotionPlans = new Map(); + private trainUnitToEngine = new Map(); private toDelete = new Set(); @@ -637,6 +674,51 @@ export class GameView implements GameMap { return this.lastUpdate?.updates ?? null; } + public motionPlans(): ReadonlyMap< + number, + { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; + } + > { + return this.unitMotionPlans; + } + + private motionPlannedUnitIdsCache: number[] = []; + private motionPlannedUnitIdsDirty = true; + + private markMotionPlannedUnitIdsDirty(): void { + this.motionPlannedUnitIdsDirty = true; + } + + private rebuildMotionPlannedUnitIdsCacheIfDirty(): void { + if (!this.motionPlannedUnitIdsDirty) { + return; + } + this.motionPlannedUnitIdsDirty = false; + + const out = this.motionPlannedUnitIdsCache; + out.length = 0; + + 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); + } + } + } + + public motionPlannedUnitIds(): number[] { + this.rebuildMotionPlannedUnitIdsCacheIfDirty(); + return this.motionPlannedUnitIdsCache; + } + public isCatchingUp(): boolean { return (this.lastUpdate?.pendingTurns ?? 0) > 1; } @@ -656,6 +738,11 @@ export class GameView implements GameMap { this.updatedTiles.push(tile); } + if (gu.packedMotionPlans) { + const records = unpackMotionPlans(gu.packedMotionPlans); + this.applyMotionPlanRecords(records); + } + if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } @@ -704,8 +791,244 @@ export class GameView implements GameMap { if (!unit.isActive()) { // Wait until next tick to delete the unit. this.toDelete.add(unit.id()); + if (this.unitMotionPlans.delete(unit.id())) { + this.markMotionPlannedUnitIdsDirty(); + } + this.clearTrainPlanForUnit(unit.id()); } }); + + this.advanceMotionPlannedUnits(gu.tick); + this.rebuildMotionPlannedUnitIdsCacheIfDirty(); + } + + private advanceMotionPlannedUnits(currentTick: Tick): void { + for (const [unitId, plan] of this.unitMotionPlans) { + const unit = this._units.get(unitId); + if (!unit || !unit.isActive()) { + if (this.unitMotionPlans.delete(unitId)) { + this.markMotionPlannedUnitIdsDirty(); + } + continue; + } + + const oldTile = unit.tile(); + const dt = currentTick - plan.startTick; + const stepIndex = + dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); + const lastIndex = plan.path.length - 1; + const idx = Math.max(0, Math.min(lastIndex, stepIndex)); + const newTile = plan.path[idx] as TileRef; + + if (newTile !== oldTile) { + unit.applyDerivedPosition(newTile); + this.unitGrid.updateUnitCell(unit); + continue; + } + + // Once a plan is past its final step, `newTile` remains clamped to the last path tile. + // Drop finished plans to avoid repeatedly marking static units as updated each tick. + if (dt > 0 && stepIndex >= lastIndex) { + if (this.unitMotionPlans.delete(unitId)) { + this.markMotionPlannedUnitIdsDirty(); + } + } + } + + 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; + } + if (this.trainMotionPlans.delete(engineId)) { + this.markMotionPlannedUnitIdsDirty(); + } + 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 lastIndex = path.length - 1; + 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; + }; + + let didMove = false; + for (let step = 0; step < steps; step++) { + const cursor = plan.cursor; + if (cursor >= lastIndex) { + break; + } + for (let i = 0; i < plan.speed && cursor + i < path.length; i++) { + pushUsed(path[cursor + i] as TileRef); + } + + plan.cursor = Math.min(lastIndex, 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(); + if (tile !== oldTile) { + car.applyDerivedPosition(tile); + this.unitGrid.updateUnitCell(car); + didMove = true; + } + } + } + + const newEngineTile = path[plan.cursor] as TileRef; + const oldEngineTile = engine.tile(); + if (newEngineTile !== oldEngineTile) { + engine.applyDerivedPosition(newEngineTile); + this.unitGrid.updateUnitCell(engine); + didMove = true; + } + } + + plan.lastAdvancedTick = currentTick; + + // Preserve the final-step redraw (plan remains for the tick where motion ends), + // then clear once the train has settled and no longer moves. + // Note: trains are currently deleted at the end of TrainExecution, and the ensuing + // `Unit` update (isActive=false) also clears any associated motion plan records. + // This expiry is defensive to avoid keeping stale plans around if that behavior changes. + if (!didMove && plan.cursor >= lastIndex) { + staleEngineIds.push(engineId); + } + } + + for (const engineId of staleEngineIds) { + this.clearTrainPlanForUnit(engineId); + } + } + + private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void { + for (const record of records) { + switch (record.kind) { + case "grid": { + if (record.ticksPerStep < 1 || record.path.length < 1) { + break; + } + const existing = this.unitMotionPlans.get(record.unitId); + if (existing && record.planId <= existing.planId) { + break; + } + + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + + this.unitMotionPlans.set(record.unitId, { + planId: record.planId, + startTick: record.startTick, + ticksPerStep: record.ticksPerStep, + path, + }); + this.markMotionPlannedUnitIdsDirty(); + 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.markMotionPlannedUnitIdsDirty(); + + 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; + } + } + } } recentlyUpdatedTiles(): TileRef[] { diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts new file mode 100644 index 000000000..4417d0212 --- /dev/null +++ b/src/core/game/MotionPlans.ts @@ -0,0 +1,212 @@ +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; + 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; + const carCount = carUnitIds.length >>> 0; + + const path = record.path as ArrayLike; + 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; +} diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 79b7c1ed8..161e4aa7f 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -159,8 +159,7 @@ export class UnitImpl implements Unit { } this._lastTile = this._tile; this._tile = tile; - this.mg.updateUnitTile(this); - this.mg.addUpdate(this.toUpdate()); + this.mg.onUnitMoved(this); } setTroops(troops: number): void { @@ -336,7 +335,10 @@ export class UnitImpl implements Unit { if (this.type() !== UnitType.TransportShip) { throw new Error(`Cannot retreat ${this.type()}`); } - this._retreating = true; + if (!this._retreating) { + this._retreating = true; + this.mg.addUpdate(this.toUpdate()); + } } isUnderConstruction(): boolean { diff --git a/src/core/pathfinding/types.ts b/src/core/pathfinding/types.ts index 89e746957..c844b9df0 100644 --- a/src/core/pathfinding/types.ts +++ b/src/core/pathfinding/types.ts @@ -4,14 +4,12 @@ */ export enum PathStatus { - NEXT, - PENDING, - COMPLETE, - NOT_FOUND, + NEXT = 0, + COMPLETE = 2, + NOT_FOUND = 3, } export type PathResult = - | { status: PathStatus.PENDING } | { status: PathStatus.NEXT; node: T } | { status: PathStatus.COMPLETE; node: T } | { status: PathStatus.NOT_FOUND }; diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 55c37dda1..808b28088 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -33,7 +33,13 @@ function sendMessage(message: WorkerMessage) { if (message.type === "game_update") { // Transfer the packed tile updates buffer to avoid structured-clone copies and // reduce worker-side memory churn during long runs / catch-up. - ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]); + const transfers: Transferable[] = [ + message.gameUpdate.packedTileUpdates.buffer, + ]; + if (message.gameUpdate.packedMotionPlans) { + transfers.push(message.gameUpdate.packedMotionPlans.buffer); + } + ctx.postMessage(message, transfers); return; } ctx.postMessage(message); diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts index 3d3d5bb03..81cff0b02 100644 --- a/tests/core/executions/TradeShipExecution.test.ts +++ b/tests/core/executions/TradeShipExecution.test.ts @@ -74,9 +74,11 @@ describe("TradeShipExecution", () => { tradeShip = { isActive: vi.fn(() => true), owner: vi.fn(() => origOwner), + id: vi.fn(() => 123), move: vi.fn(), setTargetUnit: vi.fn(), setSafeFromPirates: vi.fn(), + touch: vi.fn(), delete: vi.fn(), tile: vi.fn(() => 2001), } as any; @@ -85,6 +87,7 @@ describe("TradeShipExecution", () => { tradeShipExecution.init(game, 0); tradeShipExecution["pathFinder"] = { next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })), + findPath: vi.fn((from: number) => [from]), } as any; tradeShipExecution["tradeShip"] = tradeShip; }); @@ -116,6 +119,7 @@ describe("TradeShipExecution", () => { it("should complete trade and award gold", () => { tradeShipExecution["pathFinder"] = { next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })), + findPath: vi.fn((from: number) => [from]), } as any; tradeShipExecution.tick(1); expect(tradeShip.delete).toHaveBeenCalledWith(false);