diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index d1e191162..ed2db256d 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -44,6 +44,7 @@ import { TerritoryLayer } from "./layers/TerritoryLayer"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; +import { MotionPlanLayer } from "./layers/MotionPlanLayer"; import { WinModal } from "./layers/WinModal"; export function createRenderer( @@ -284,6 +285,7 @@ export function createRenderer( new RailroadLayer(game, eventBus, transformHandler, uiState), structureLayer, samRadiusLayer, + new MotionPlanLayer(game, eventBus), new UnitLayer(game, eventBus, transformHandler), new FxLayer(game, eventBus, transformHandler), new UILayer(game, eventBus, transformHandler), diff --git a/src/client/graphics/layers/MotionPlanLayer.ts b/src/client/graphics/layers/MotionPlanLayer.ts new file mode 100644 index 000000000..eac99264a --- /dev/null +++ b/src/client/graphics/layers/MotionPlanLayer.ts @@ -0,0 +1,204 @@ +import { colord, Colord } from "colord"; +import { EventBus } from "../../../core/EventBus"; +import { Theme } from "../../../core/configuration/Config"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { AlternateViewEvent, UnitSelectionEvent } from "../../InputHandler"; +import { getColoredSprite, isSpriteReady } from "../SpriteLoader"; +import { Layer } from "./Layer"; + +const TICK_MS = 100; + +enum Relationship { + Self, + Ally, + Enemy, +} + +type StoredMotionPlan = { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; +}; + +export class MotionPlanLayer implements Layer { + private theme: Theme; + private alternateView = false; + private selectedUnitId: number | null = null; + + private lastTickAtMs = performance.now(); + + constructor( + private game: GameView, + private eventBus: EventBus, + ) { + this.theme = game.config().theme(); + } + + shouldTransform(): boolean { + return true; + } + + init(): void { + this.eventBus.on(AlternateViewEvent, (e) => { + this.alternateView = e.alternateView; + }); + this.eventBus.on(UnitSelectionEvent, (e) => { + this.selectedUnitId = e.isSelected ? (e.unit?.id() ?? null) : null; + }); + } + + tick(): void { + this.lastTickAtMs = performance.now(); + } + + renderLayer(context: CanvasRenderingContext2D): void { + const now = performance.now(); + const alpha = this.game.isCatchingUp() + ? 1 + : Math.max(0, Math.min(1, (now - this.lastTickAtMs) / TICK_MS)); + const tRender = (this.game.ticks() - 1) + alpha; + + for (const [unitId, plan] of this.game.motionPlans()) { + const unit = this.game.unit(unitId); + if (!unit || !unit.isActive()) { + continue; + } + if (!isSpriteReady(unit)) { + continue; + } + + const pos = this.positionAtTime(plan, tRender); + if (!pos) { + continue; + } + + const isSelected = this.selectedUnitId === unitId; + this.drawUnitSprite(context, unit, pos.x, pos.y, isSelected); + } + } + + private positionAtTime( + plan: StoredMotionPlan, + t: number, + ): { x: number; y: number } | null { + if (plan.path.length < 1) { + return null; + } + + const baseTick = Math.floor(t); + const frac = t - baseTick; + + const tileA = this.tileAtTick(plan, baseTick); + const tileB = this.tileAtTick(plan, baseTick + 1); + + const xA = this.game.x(tileA); + const yA = this.game.y(tileA); + const xB = this.game.x(tileB); + const yB = this.game.y(tileB); + + return { + x: xA + (xB - xA) * frac, + y: yA + (yB - yA) * frac, + }; + } + + private tileAtTick(plan: StoredMotionPlan, tick: number): TileRef { + 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 relationship(unit: UnitView): Relationship { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { + return Relationship.Enemy; + } + if (myPlayer === unit.owner()) { + return Relationship.Self; + } + if (myPlayer.isFriendly(unit.owner())) { + return Relationship.Ally; + } + return Relationship.Enemy; + } + + private drawUnitSprite( + context: CanvasRenderingContext2D, + unit: UnitView, + x: number, + y: number, + isSelected: boolean, + ): void { + let alternateViewColor: Colord | null = null; + + if (this.alternateView) { + let rel = this.relationship(unit); + const dstPortId = unit.targetUnitId(); + if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) { + const target = this.game.unit(dstPortId)?.owner(); + const myPlayer = this.game.myPlayer(); + if (myPlayer !== null && target !== undefined) { + if (myPlayer === target) { + rel = Relationship.Self; + } else if (myPlayer.isFriendly(target)) { + rel = Relationship.Ally; + } + } + } + switch (rel) { + case Relationship.Self: + alternateViewColor = this.theme.selfColor(); + break; + case Relationship.Ally: + alternateViewColor = this.theme.allyColor(); + break; + case Relationship.Enemy: + alternateViewColor = this.theme.enemyColor(); + break; + } + } + + const sprite = getColoredSprite( + unit, + this.theme, + alternateViewColor ?? undefined, + alternateViewColor ?? undefined, + ); + + const mapX = x - this.game.width() / 2; + const mapY = y - this.game.height() / 2; + + const targetable = unit.targetable(); + if (!targetable) { + context.save(); + context.globalAlpha = 0.5; + } + + context.drawImage( + sprite, + Math.round(mapX - sprite.width / 2), + Math.round(mapY - sprite.height / 2), + sprite.width, + sprite.width, + ); + + if (!targetable) { + context.restore(); + } + + if (isSelected) { + context.save(); + context.strokeStyle = colord("white").alpha(0.9).toRgbString(); + context.lineWidth = 2; + context.beginPath(); + context.arc(mapX, mapY, Math.max(sprite.width, 18) / 2 + 4, 0, Math.PI * 2); + context.stroke(); + context.restore(); + } + } +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 23a7e339c..5deb1b57b 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -545,6 +545,10 @@ export class UnitLayer implements Layer { } drawSprite(unit: UnitView, customTerritoryColor?: Colord) { + if (this.game.hasMotionPlan(unit.id())) { + return; + } + const x = this.game.x(unit.tile()); const y = this.game.y(unit.tile()); 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/NukeExecution.ts b/src/core/execution/NukeExecution.ts index fc1743f26..e8fc172e6 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -16,6 +16,7 @@ import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; import { listNukeBreakAlliance } from "./Util"; +import { MotionPlanRecord } from "../game/MotionPlans"; const SPRITE_RADIUS = 16; @@ -139,6 +140,19 @@ export class NukeExecution implements Execution { targetTile: this.dst, trajectory: this.getTrajectory(this.dst), }); + + const motionPlan: MotionPlanRecord = { + kind: "parabola", + unitId: this.nuke.id(), + planId: 1, + startTick: ticks + 1 + this.waitTicks, + src: spawn, + dst: this.dst, + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }; + this.mg.recordMotionPlan(motionPlan); if (this.nuke.type() !== UnitType.MIRVWarhead) { this.maybeBreakAlliances(); } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 23a905940..a41f3797a 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -11,6 +11,7 @@ import { TileRef } from "../game/GameMap"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { distSortUnit } from "../Util"; +import { MotionPlanRecord } from "../game/MotionPlans"; export class TradeShipExecution implements Execution { private active = true; @@ -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()) { @@ -106,8 +122,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 +142,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..3a3aa9fd3 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -13,6 +13,7 @@ import { targetTransportTile } from "../game/TransportShipUtils"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; +import { MotionPlanRecord } from "../game/MotionPlans"; const malusForRetreat = 25; @@ -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,25 @@ 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..e05c3e0a8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -13,6 +13,7 @@ import { import { RailNetwork } from "./RailNetwork"; import { Stats } from "./Stats"; import { UnitPredicate } from "./UnitGrid"; +import type { MotionPlanRecord } from "./MotionPlans"; function isEnumValue>( enumObj: T, @@ -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..ed7b84ccf 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -49,6 +49,7 @@ import { StatsImpl } from "./StatsImpl"; import { assignTeams } from "./TeamAssignment"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; +import { MotionPlanRecord, packMotionPlans } from "./MotionPlans"; export function createGame( humans: PlayerInfo[], @@ -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[]; @@ -232,6 +235,12 @@ export class GameImpl implements Game { } addUpdate(update: GameUpdate) { + if (update.type === GameUpdateType.Unit) { + const unitUpdate = update as any as { id: number; isActive: boolean }; + if (unitUpdate.isActive === false) { + this.planDrivenUnitIds.delete(unitUpdate.id); + } + } (this.updates[update.type] as GameUpdate[]).push(update); } @@ -430,6 +439,36 @@ export class GameImpl implements Game { return packed; } + recordMotionPlan(record: MotionPlanRecord): void { + switch (record.kind) { + case "grid": + case "parabola": + this.planDrivenUnitIds.add(record.unitId); + break; + case "clear": + this.planDrivenUnitIds.delete(record.unitId); + break; + case "reset_all": + this.planDrivenUnitIds.clear(); + 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..7401f244b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -38,6 +38,13 @@ import { TerrainMapData } from "./TerrainMapLoader"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; import { UserSettings } from "./UserSettings"; +import { + MOTION_PLANS_SCHEMA_VERSION, + MotionPlanRecord, + unpackMotionPlans, +} from "./MotionPlans"; +import { UniversalPathFinding } from "../pathfinding/PathFinder"; +import { PathStatus } from "../pathfinding/types"; const userSettings: UserSettings = new UserSettings(); @@ -83,6 +90,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 +610,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 +664,22 @@ export class GameView implements GameMap { return this.lastUpdate?.updates ?? null; } + public hasMotionPlan(unitId: number): boolean { + return this.unitMotionPlans.has(unitId); + } + + 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 +699,13 @@ 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 +754,115 @@ 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 "reset_all": + this.unitMotionPlans.clear(); + break; + case "clear": { + const existing = this.unitMotionPlans.get(record.unitId); + if (!existing) { + break; + } + if (record.planId === 0 || existing.planId === record.planId) { + this.unitMotionPlans.delete(record.unitId); + } + break; + } + 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 as unknown) instanceof Uint32Array + ? (record.path as unknown as Uint32Array) + : Uint32Array.from(record.path as readonly number[]); + + this.unitMotionPlans.set(record.unitId, { + planId: record.planId, + startTick: record.startTick, + ticksPerStep: record.ticksPerStep, + path, + }); + break; + } + case "parabola": { + const existing = this.unitMotionPlans.get(record.unitId); + if (existing && record.planId <= existing.planId) { + break; + } + + const pf = UniversalPathFinding.Parabola(this._map, { + increment: record.increment, + distanceBasedHeight: record.distanceBasedHeight, + directionUp: record.directionUp, + }); + + const tiles: number[] = [record.src]; + for (let i = 0; i < 20000; i++) { + const step = pf.next(record.src, record.dst, record.increment); + if (step.status === PathStatus.NEXT) { + tiles.push(step.node); + continue; + } + break; + } + + this.unitMotionPlans.set(record.unitId, { + planId: record.planId, + startTick: record.startTick, + ticksPerStep: 1, + path: Uint32Array.from(tiles), + }); + break; + } + } + } } recentlyUpdatedTiles(): TileRef[] { diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts new file mode 100644 index 000000000..092fa6afe --- /dev/null +++ b/src/core/game/MotionPlans.ts @@ -0,0 +1,222 @@ +import { TileRef } from "./GameMap"; + +export const MOTION_PLANS_SCHEMA_VERSION = 1; + +export enum PackedMotionPlanKind { + GridPathSet = 1, + ParabolaSet = 2, + ClearUnitPlan = 3, + ResetAllPlans = 4, +} + +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[]; + flags?: number; +} + +export interface ParabolaPlan { + kind: "parabola"; + unitId: number; + planId: number; + startTick: number; + src: TileRef; + dst: TileRef; + increment: number; + distanceBasedHeight: boolean; + directionUp: boolean; +} + +export interface ClearUnitPlanRecord { + kind: "clear"; + unitId: number; + /** + * Clear only if the current planId matches. `0` means clear unconditionally. + */ + planId: number; +} + +export interface ResetAllPlansRecord { + kind: "reset_all"; +} + +export type MotionPlanRecord = + | GridPathPlan + | ParabolaPlan + | ClearUnitPlanRecord + | ResetAllPlansRecord; + +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 flags = record.flags ?? 0; + const pathLen = record.path.length >>> 0; + const wordCount = 2 + 6 + pathLen; + out.push( + PackedMotionPlanKind.GridPathSet, + wordCount, + record.unitId >>> 0, + record.planId >>> 0, + record.startTick >>> 0, + record.ticksPerStep >>> 0, + flags >>> 0, + pathLen, + ); + for (let i = 0; i < record.path.length; i++) { + out.push(record.path[i] >>> 0); + } + break; + } + case "parabola": { + const flags = + (record.distanceBasedHeight ? 1 : 0) | + (record.directionUp ? 2 : 0); + const wordCount = 2 + 7; + out.push( + PackedMotionPlanKind.ParabolaSet, + wordCount, + record.unitId >>> 0, + record.planId >>> 0, + record.startTick >>> 0, + record.src >>> 0, + record.dst >>> 0, + record.increment >>> 0, + flags >>> 0, + ); + break; + } + case "clear": { + const wordCount = 2 + 2; + out.push( + PackedMotionPlanKind.ClearUnitPlan, + wordCount, + record.unitId >>> 0, + record.planId >>> 0, + ); + break; + } + case "reset_all": { + out.push(PackedMotionPlanKind.ResetAllPlans, 2); + 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 + 6) { + 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 flags = packed[offset + 6] >>> 0; + const pathLen = packed[offset + 7] >>> 0; + + const expectedWordCount = 2 + 6 + pathLen; + if (expectedWordCount !== wordCount) { + break; + } + + const pathStart = offset + 8; + const pathEnd = pathStart + pathLen; + const path = packed.slice(pathStart, pathEnd) as unknown as TileRef[]; + + records.push({ + kind: "grid", + unitId, + planId, + startTick, + ticksPerStep, + flags, + path, + }); + break; + } + case PackedMotionPlanKind.ParabolaSet: { + if (wordCount !== 2 + 7) { + break; + } + const unitId = packed[offset + 2] >>> 0; + const planId = packed[offset + 3] >>> 0; + const startTick = packed[offset + 4] >>> 0; + const src = packed[offset + 5] as TileRef; + const dst = packed[offset + 6] as TileRef; + const increment = packed[offset + 7] >>> 0; + const flags = packed[offset + 8] >>> 0; + + records.push({ + kind: "parabola", + unitId, + planId, + startTick, + src, + dst, + increment, + distanceBasedHeight: (flags & 1) !== 0, + directionUp: (flags & 2) !== 0, + }); + break; + } + case PackedMotionPlanKind.ClearUnitPlan: { + if (wordCount !== 2 + 2) { + break; + } + const unitId = packed[offset + 2] >>> 0; + const planId = packed[offset + 3] >>> 0; + records.push({ kind: "clear", unitId, planId }); + break; + } + case PackedMotionPlanKind.ResetAllPlans: { + if (wordCount !== 2) { + break; + } + records.push({ kind: "reset_all" }); + 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..1d9209844 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 { @@ -402,7 +407,12 @@ export class UnitImpl implements Unit { } setTargetUnit(target: Unit | undefined): void { - this._targetUnit = target; + if (this._targetUnit !== target) { + this._targetUnit = target; + if (this.mg.isUnitPlanDriven(this._id)) { + this.mg.addUpdate(this.toUpdate()); + } + } } targetUnit(): Unit | undefined { 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);