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.
This commit is contained in:
scamiv
2026-02-24 02:03:28 +01:00
parent 4b917c4153
commit 4a8d4610c7
11 changed files with 377 additions and 9 deletions
+20 -4
View File
@@ -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<number>(updatedUnitIds);
for (const id of motionPlanUnitIds) {
unitIds.add(id);
}
this.updateUnitsSprites(Array.from(unitIds));
}
init() {
+2
View File
@@ -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,
+38 -2
View File
@@ -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<TileRef>;
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() {
@@ -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 {
+3
View File
@@ -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;
+26
View File
@@ -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<number>();
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) => {
+7
View File
@@ -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<string, NameViewData>;
tickExecutionDuration?: number;
pendingTurns?: number;
+110
View File
@@ -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<number>();
@@ -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[] {
+118
View File
@@ -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 };
}
+7 -2
View File
@@ -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 {
+7 -1
View File
@@ -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);