From 4a8d4610c7eeea2d311258f4bd9e68311f251f99 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:03:28 +0100 Subject: [PATCH] perf(core): plan-driven ships via packedMotionPlans Send movement intent as packedMotionPlans for trade/transport ships and stop emitting per-tick movement UnitUpdates for plan-driven unit ids. --- src/client/graphics/layers/UnitLayer.ts | 24 +++- src/core/GameRunner.ts | 2 + src/core/execution/TradeShipExecution.ts | 40 ++++++- src/core/execution/TransportShipExecution.ts | 39 ++++++ src/core/game/Game.ts | 3 + src/core/game/GameImpl.ts | 26 ++++ src/core/game/GameUpdates.ts | 7 ++ src/core/game/GameView.ts | 110 +++++++++++++++++ src/core/game/MotionPlans.ts | 118 +++++++++++++++++++ src/core/game/UnitImpl.ts | 9 +- src/core/worker/Worker.worker.ts | 8 +- 11 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 src/core/game/MotionPlans.ts diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 23a7e339c..63c941e37 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 = Array.from(this.game.motionPlans().keys()); + + 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 e8c46803d..ba703be59 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -169,10 +169,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..b1771c46f 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -8,6 +8,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import { MotionPlanRecord } from "../game/MotionPlans"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { distSortUnit } from "../Util"; @@ -19,6 +20,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, @@ -32,6 +35,7 @@ export class TradeShipExecution implements Execution { } tick(ticks: number): void { + let spawnedThisTick = false; if (this.tradeShip === undefined) { const spawn = this.origOwner.canBuild( UnitType.TradeShip, @@ -47,6 +51,18 @@ export class TradeShipExecution implements Execution { lastSetSafeFromPirates: ticks, }); this.mg.stats().boatSendTrade(this.origOwner, this._dstPort.owner()); + spawnedThisTick = true; + + const placeholderPlan: MotionPlanRecord = { + kind: "grid", + unitId: this.tradeShip.id(), + planId: this.motionPlanId, + startTick: ticks + 1, + ticksPerStep: 1, + path: [spawn], + }; + this.mg.recordMotionPlan(placeholderPlan); + this.motionPlanDst = this._dstPort.tile(); } if (!this.tradeShip.isActive()) { @@ -93,6 +109,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(); } } @@ -106,8 +124,6 @@ export class TradeShipExecution implements Execution { switch (result.status) { case PathStatus.PENDING: - // Fire unit event to rerender. - this.tradeShip.move(curTile); break; case PathStatus.NEXT: // Update safeFromPirates status @@ -128,6 +144,26 @@ export class TradeShipExecution implements Execution { this.active = false; break; } + + const dst = this._dstPort.tile(); + if (spawnedThisTick || dst !== this.motionPlanDst) { + this.motionPlanId++; + const from = this.tradeShip.tile(); + 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; + } } private complete() { diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index fd02d3b84..c1f127ffe 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"; @@ -30,6 +31,8 @@ export class TransportShipExecution implements Execution { private dst: TileRef | null; private src: TileRef | null; private boat: Unit; + private motionPlanId = 1; + private motionPlanDst: TileRef | null = null; private originalOwner: Player; @@ -109,6 +112,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( @@ -249,6 +268,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 + 1, + ticksPerStep: this.ticksPerMove, + path: fullPath, + }); + this.motionPlanDst = this.dst; + } } owner(): Player { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 11722e142..0fa214165 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"; @@ -767,6 +768,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 6ff6c5081..793a1f0e9 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -41,6 +41,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"; @@ -84,6 +85,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[]; @@ -430,6 +433,29 @@ export class GameImpl implements Game { return packed; } + recordMotionPlan(record: MotionPlanRecord): void { + switch (record.kind) { + case "grid": + this.planDrivenUnitIds.add(record.unitId); + break; + } + this.motionPlanRecords.push(record); + } + + isUnitPlanDriven(unitId: number): boolean { + return this.planDrivenUnitIds.has(unitId); + } + + 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) => { 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..ff0aec83b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -34,6 +34,11 @@ import { PlayerUpdate, UnitUpdate, } from "./GameUpdates"; +import { + MOTION_PLANS_SCHEMA_VERSION, + MotionPlanRecord, + unpackMotionPlans, +} from "./MotionPlans"; import { TerrainMapData } from "./TerrainMapLoader"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; @@ -83,6 +88,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; } @@ -592,6 +608,15 @@ 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 toDelete = new Set(); @@ -637,6 +662,18 @@ 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; + } + public isCatchingUp(): boolean { return (this.lastUpdate?.pendingTurns ?? 0) > 1; } @@ -656,6 +693,15 @@ export class GameView implements GameMap { this.updatedTiles.push(tile); } + if (gu.packedMotionPlans) { + const { schemaVersion, records } = unpackMotionPlans( + gu.packedMotionPlans, + ); + if (schemaVersion === MOTION_PLANS_SCHEMA_VERSION) { + this.applyMotionPlanRecords(records); + } + } + if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } @@ -704,8 +750,72 @@ export class GameView implements GameMap { if (!unit.isActive()) { // Wait until next tick to delete the unit. this.toDelete.add(unit.id()); + this.unitMotionPlans.delete(unit.id()); } }); + + this.advanceMotionPlannedUnits(gu.tick); + } + + private advanceMotionPlannedUnits(currentTick: Tick): void { + for (const [unitId, plan] of this.unitMotionPlans) { + const unit = this._units.get(unitId); + if (!unit || !unit.isActive()) { + this.unitMotionPlans.delete(unitId); + continue; + } + + const oldTile = unit.tile(); + const newTile = this.motionTileAtTick(plan, currentTick); + unit.applyDerivedPosition(newTile); + + if (newTile !== oldTile) { + this.unitGrid.updateUnitCell(unit); + } + } + } + + private motionTileAtTick( + plan: { startTick: number; ticksPerStep: number; path: Uint32Array }, + tick: Tick, + ): TileRef { + if (plan.path.length < 1) { + throw new Error("motion plan path must be non-empty"); + } + const dt = tick - plan.startTick; + const stepIndex = + dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); + const idx = Math.max(0, Math.min(plan.path.length - 1, stepIndex)); + return plan.path[idx] as TileRef; + } + + 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, + }); + break; + } + } + } } recentlyUpdatedTiles(): TileRef[] { diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts new file mode 100644 index 000000000..d361bbe10 --- /dev/null +++ b/src/core/game/MotionPlans.ts @@ -0,0 +1,118 @@ +import { TileRef } from "./GameMap"; + +export const MOTION_PLANS_SCHEMA_VERSION = 3; + +export enum PackedMotionPlanKind { + GridPathSet = 1, +} + +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 type MotionPlanRecord = GridPathPlan; + +export function packMotionPlans( + records: readonly MotionPlanRecord[], +): Uint32Array { + const out: number[] = [MOTION_PLANS_SCHEMA_VERSION, records.length]; + + for (const record of records) { + switch (record.kind) { + case "grid": { + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + const pathLen = path.length >>> 0; + const wordCount = 2 + 5 + pathLen; + out.push( + PackedMotionPlanKind.GridPathSet, + wordCount, + record.unitId >>> 0, + record.planId >>> 0, + record.startTick >>> 0, + record.ticksPerStep >>> 0, + pathLen, + ); + for (let i = 0; i < path.length; i++) { + out.push(path[i] >>> 0); + } + break; + } + } + } + + return new Uint32Array(out); +} + +export function unpackMotionPlans(packed: Uint32Array): { + schemaVersion: number; + records: MotionPlanRecord[]; +} { + if (packed.length < 2) { + return { schemaVersion: 0, records: [] }; + } + + const schemaVersion = packed[0] >>> 0; + const recordCount = packed[1] >>> 0; + + const records: MotionPlanRecord[] = []; + let offset = 2; + + 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; + } + default: + // Unknown kind: skip. + break; + } + + offset += wordCount; + } + + return { schemaVersion, records }; +} diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 79b7c1ed8..930cc6142 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -160,7 +160,9 @@ export class UnitImpl implements Unit { this._lastTile = this._tile; this._tile = tile; this.mg.updateUnitTile(this); - this.mg.addUpdate(this.toUpdate()); + if (!this.mg.isUnitPlanDriven(this._id)) { + this.mg.addUpdate(this.toUpdate()); + } } setTroops(troops: number): void { @@ -336,7 +338,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/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);