From 3c888bc35482e3e7238f5fe030cbff0df3cf4fb4 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:42:25 +0100 Subject: [PATCH] Worker renderers: decouple from Game/TerrainMap, coalesce view and sim - GameViewAdapter: build from tileState/terrainData buffers and game updates (players, defense posts, embargo/alliance) instead of Game + TerrainMapData; add DefensePostUnit/PlayerLiteView and drop config(). - Worker: keep local renderTileState; tileUpdateSink receives packed bigint and updates buffer + dirty queue; no terrain map load in worker. - Proxies: send view size/transform only when changed, inline in render_frame (optional viewSize/viewTransform); remove separate set_view_size/set_view_transform messages. - Simulation: remove main-thread RAF heartbeat loop; worker uses scheduleSimPump() on heartbeat/addTurn to coalesce ticks. - GroundTruthData: take defensePostRange at construction; Territory renderer passes it through; remove runtime defensePostRange change check. - GameRunner: tileUpdateSink(packedTileUpdate: bigint); add hasPendingTurns(). --- src/client/ClientGameRunner.ts | 9 - .../canvas2d/Canvas2DRendererProxy.ts | 45 +- .../graphics/webgpu/TerritoryRenderer.ts | 1 + .../graphics/webgpu/TerritoryRendererProxy.ts | 49 +- .../graphics/webgpu/core/GroundTruthData.ts | 12 +- src/core/GameRunner.ts | 17 +- src/core/worker/GameViewAdapter.ts | 510 ++++++++++++++---- src/core/worker/Worker.worker.ts | 67 ++- src/core/worker/WorkerCanvas2DRenderer.ts | 32 +- src/core/worker/WorkerMessages.ts | 17 + src/core/worker/WorkerTerritoryRenderer.ts | 38 +- 11 files changed, 619 insertions(+), 178 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 652e86ccc..c7886a6cf 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -381,15 +381,6 @@ export class ClientGameRunner { } }); - const worker = this.worker; - const keepWorkerAlive = () => { - if (this.isActive) { - worker.sendHeartbeat(); - requestAnimationFrame(keepWorkerAlive); - } - }; - requestAnimationFrame(keepWorkerAlive); - const onconnect = () => { console.log("Connected to game server!"); this.transport.rejoinGame(this.turnsSeen); diff --git a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts index c1a1a25fe..bb07d961f 100644 --- a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts +++ b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts @@ -15,9 +15,9 @@ import { SetPaletteMessage, SetPatternsEnabledMessage, SetShaderSettingsMessage, - SetViewSizeMessage, - SetViewTransformMessage, TickRendererMessage, + ViewSize, + ViewTransform, } from "../../../core/worker/WorkerMessages"; export interface Canvas2DCreateResult { @@ -34,6 +34,11 @@ export class Canvas2DRendererProxy { private initPromise: Promise | null = null; private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; + private viewSize: ViewSize = { width: 1, height: 1 }; + private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + private lastSentViewSize: ViewSize | null = null; + private lastSentViewTransform: ViewTransform | null = null; + private constructor( private readonly game: GameView, private readonly theme: Theme, @@ -159,22 +164,14 @@ export class Canvas2DRendererProxy { } setViewSize(width: number, height: number): void { - const message: SetViewSizeMessage = { - type: "set_view_size", - width, - height, + this.viewSize = { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), }; - this.sendToWorker(message); } setViewTransform(scale: number, offsetX: number, offsetY: number): void { - const message: SetViewTransformMessage = { - type: "set_view_transform", - scale, - offsetX, - offsetY, - }; - this.sendToWorker(message); + this.viewTransform = { scale, offsetX, offsetY }; } setAlternativeView(enabled: boolean): void { @@ -305,6 +302,26 @@ export class Canvas2DRendererProxy { render(): void { const message: RenderFrameMessage = { type: "render_frame" }; + + if ( + !this.lastSentViewSize || + this.lastSentViewSize.width !== this.viewSize.width || + this.lastSentViewSize.height !== this.viewSize.height + ) { + message.viewSize = this.viewSize; + this.lastSentViewSize = this.viewSize; + } + + if ( + !this.lastSentViewTransform || + this.lastSentViewTransform.scale !== this.viewTransform.scale || + this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX || + this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY + ) { + message.viewTransform = this.viewTransform; + this.lastSentViewTransform = this.viewTransform; + } + this.sendToWorker(message); } } diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 5525ddbf7..25dad2017 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -114,6 +114,7 @@ export class TerritoryRenderer { webgpuDevice.device, this.game, this.theme, + this.defensePostRange, state, ); this.resources.setTerritoryShaderParams( diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts index eb2563043..8d5b021b7 100644 --- a/src/client/graphics/webgpu/TerritoryRendererProxy.ts +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -15,9 +15,9 @@ import { SetPaletteMessage, SetPatternsEnabledMessage, SetShaderSettingsMessage, - SetViewSizeMessage, - SetViewTransformMessage, TickRendererMessage, + ViewSize, + ViewTransform, } from "../../../core/worker/WorkerMessages"; export interface TerritoryWebGLCreateResult { @@ -38,6 +38,11 @@ export class TerritoryRendererProxy { private initPromise: Promise | null = null; private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; + private viewSize: ViewSize = { width: 1, height: 1 }; + private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + private lastSentViewSize: ViewSize | null = null; + private lastSentViewTransform: ViewTransform | null = null; + private constructor( private readonly game: GameView, private readonly theme: Theme, @@ -183,22 +188,14 @@ export class TerritoryRendererProxy { } setViewSize(width: number, height: number): void { - const message: SetViewSizeMessage = { - type: "set_view_size", - width, - height, + this.viewSize = { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), }; - this.sendToWorker(message); } setViewTransform(scale: number, offsetX: number, offsetY: number): void { - const message: SetViewTransformMessage = { - type: "set_view_transform", - scale, - offsetX, - offsetY, - }; - this.sendToWorker(message); + this.viewTransform = { scale, offsetX, offsetY }; } setAlternativeView(enabled: boolean): void { @@ -393,9 +390,27 @@ export class TerritoryRendererProxy { } render(): void { - const message: RenderFrameMessage = { - type: "render_frame", - }; + const message: RenderFrameMessage = { type: "render_frame" }; + + if ( + !this.lastSentViewSize || + this.lastSentViewSize.width !== this.viewSize.width || + this.lastSentViewSize.height !== this.viewSize.height + ) { + message.viewSize = this.viewSize; + this.lastSentViewSize = this.viewSize; + } + + if ( + !this.lastSentViewTransform || + this.lastSentViewTransform.scale !== this.viewTransform.scale || + this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX || + this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY + ) { + message.viewTransform = this.viewTransform; + this.lastSentViewTransform = this.viewTransform; + } + this.sendToWorker(message); } } diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index c12a39bad..803944884 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -86,6 +86,7 @@ export class GroundTruthData { private defendedDirtyTilesCount = 0; private needsFullDefendedStrengthRecompute = false; private lastDefensePostKeys = new Set(); + private defensePostRange = 0; private defenseCircleRange = -1; private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] @@ -122,6 +123,7 @@ export class GroundTruthData { private readonly device: GPUDevice, private readonly game: GameView, private readonly theme: Theme, + defensePostRange: number, state: Uint16Array, terrainData: Uint8Array, mapWidth: number, @@ -131,6 +133,7 @@ export class GroundTruthData { this.terrainData = terrainData; this.mapWidth = mapWidth; this.mapHeight = mapHeight; + this.defensePostRange = Math.max(0, defensePostRange | 0); const GPUBufferUsage = (globalThis as any).GPUBufferUsage; const GPUTextureUsage = (globalThis as any).GPUTextureUsage; @@ -247,12 +250,14 @@ export class GroundTruthData { device: GPUDevice, game: GameView, theme: Theme, + defensePostRange: number, state: Uint16Array, ): GroundTruthData { return new GroundTruthData( device, game, theme, + defensePostRange, state, game.terrainDataView(), game.width(), @@ -1006,7 +1011,7 @@ export class GroundTruthData { } this.needsDefensePostsUpload = false; - const range = this.game.config().defensePostRange(); + const range = this.defensePostRange; const posts = this.collectDefensePosts(); this.defensePostsTotalCount = posts.length; @@ -1062,7 +1067,7 @@ export class GroundTruthData { writeStateUpdateParamsBuffer(updateCount: number): void { this.stateUpdateParamsData[0] = updateCount >>> 0; - this.stateUpdateParamsData[1] = this.game.config().defensePostRange() >>> 0; + this.stateUpdateParamsData[1] = this.defensePostRange >>> 0; this.stateUpdateParamsData[2] = 0; this.stateUpdateParamsData[3] = 0; this.device.queue.writeBuffer( @@ -1074,8 +1079,7 @@ export class GroundTruthData { writeDefendedStrengthParamsBuffer(dirtyCount: number): void { this.defendedStrengthParamsData[0] = dirtyCount >>> 0; - this.defendedStrengthParamsData[1] = - this.game.config().defensePostRange() >>> 0; + this.defendedStrengthParamsData[1] = this.defensePostRange >>> 0; this.defendedStrengthParamsData[2] = 0; this.defendedStrengthParamsData[3] = 0; this.device.queue.writeBuffer( diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index b2ad4e20f..52af7a6a4 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -86,7 +86,14 @@ export class GameRunner { private isExecuting = false; private playerViewData: Record = {}; - public tileUpdateSink?: (tile: TileRef) => void; + /** + * Optional sink for tile state updates. When set, the runner avoids sending + * packed tile updates to the callback (to reduce transfer overhead) and + * instead forwards packed updates to the sink. + * + * Packed encoding: [tileRef << 16 | state] as bigint. + */ + public tileUpdateSink?: (packedTileUpdate: bigint) => void; constructor( public game: Game, @@ -113,6 +120,10 @@ export class GameRunner { this.turns.push(turn); } + public hasPendingTurns(): boolean { + return this.currTurn < this.turns.length; + } + public executeNextTick() { if (this.isExecuting) { return; @@ -171,9 +182,7 @@ export class GameRunner { let packedTileUpdates: BigUint64Array; if (this.tileUpdateSink) { for (const u of tileUpdates) { - // packed tile updates encode [tileRef << 16 | state] as bigint. - const tileRef = Number(u.update >> 16n) as TileRef; - this.tileUpdateSink(tileRef); + this.tileUpdateSink(u.update); } packedTileUpdates = new BigUint64Array(0); } else { diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts index 7fc3eba3c..17fae525d 100644 --- a/src/core/worker/GameViewAdapter.ts +++ b/src/core/worker/GameViewAdapter.ts @@ -1,12 +1,173 @@ import { Colord, colord } from "colord"; import { Theme } from "../configuration/Config"; -import { Game, UnitType } from "../game/Game"; +import { UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { GameUpdateViewData } from "../game/GameUpdates"; +import { + AllianceExpiredUpdate, + AllianceRequestReplyUpdate, + BrokeAllianceUpdate, + EmbargoUpdate, + GameUpdateType, + GameUpdateViewData, + PlayerUpdate, + UnitUpdate, +} from "../game/GameUpdates"; import { GameView } from "../game/GameView"; -import { TerrainMapData } from "../game/TerrainMapLoader"; import { ClientID, PlayerCosmetics } from "../Schemas"; +class DefensePostUnit { + public index = -1; + private readonly ownerView = { smallID: () => this.ownerSmallId }; + + constructor( + public readonly id: number, + private tileRef: TileRef, + private ownerSmallId: number, + ) {} + + isActive(): boolean { + return true; + } + + isUnderConstruction(): boolean { + return false; + } + + tile(): TileRef { + return this.tileRef; + } + + owner(): { smallID: () => number } { + return this.ownerView; + } + + set(tileRef: TileRef, ownerSmallId: number): void { + this.tileRef = tileRef; + this.ownerSmallId = ownerSmallId; + } +} + +class PlayerLiteView { + private readonly territoryRgba = { r: 0, g: 0, b: 0, a: 255 }; + private readonly borderRgba = { r: 0, g: 0, b: 0, a: 255 }; + private readonly territoryObj = { rgba: this.territoryRgba }; + private readonly borderObj = { rgba: this.borderRgba }; + + constructor( + private readonly adapter: GameViewAdapter, + public data: PlayerUpdate, + ) {} + + id(): string { + return this.data.id; + } + + smallID(): number { + return this.data.smallID; + } + + clientID(): ClientID | null { + return this.data.clientID; + } + + team(): any | null { + return this.data.team ?? null; + } + + type(): any { + return this.data.playerType; + } + + isPlayer(): boolean { + return true; + } + + territoryColor(): { rgba: { r: number; g: number; b: number; a: number } } { + this.ensureColors(); + return this.territoryObj; + } + + borderColor(): { rgba: { r: number; g: number; b: number; a: number } } { + this.ensureColors(); + return this.borderObj; + } + + hasEmbargoAgainst(other: PlayerLiteView): boolean { + return this.adapter.hasEmbargoPair(this.smallID(), other.smallID()); + } + + hasEmbargo(other: PlayerLiteView): boolean { + return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); + } + + isFriendly(other: PlayerLiteView): boolean { + const team = this.team(); + return ( + (team !== null && team === other.team()) || + this.adapter.hasFriendlyPair(this.smallID(), other.smallID()) + ); + } + + markColorsDirty(): void { + this.adapter.markPlayerColorsDirty(this.smallID()); + } + + private ensureColors(): void { + if (!this.adapter.consumePlayerColorsDirty(this.smallID())) { + return; + } + + const theme = this.adapter.getTheme(); + const defaultTerritoryColor = theme.territoryColor(this as any); + const defaultBorderColor = theme.borderColor(defaultTerritoryColor); + + const cosmetics = this.adapter.getCosmetics(this.clientID()); + const pattern = this.adapter.getPatternsEnabled() + ? cosmetics.pattern + : undefined; + if (pattern) { + (pattern as any).colorPalette ??= { + name: "", + primaryColor: defaultTerritoryColor.toHex(), + secondaryColor: defaultBorderColor.toHex(), + }; + } + + const territoryColor: Colord = + this.team() === null + ? colord( + cosmetics.color?.color ?? + (pattern as any)?.colorPalette?.primaryColor ?? + defaultTerritoryColor.toHex(), + ) + : defaultTerritoryColor; + + const maybeFocusedBorderColor = + this.adapter.getMyClientId() !== null && + this.clientID() === this.adapter.getMyClientId() + ? theme.focusedBorderColor() + : defaultBorderColor; + + const borderColor: Colord = colord( + (pattern as any)?.colorPalette?.secondaryColor ?? + cosmetics.color?.color ?? + maybeFocusedBorderColor.toHex(), + ); + + const tc = territoryColor.toRgb(); + this.territoryRgba.r = Math.round(tc.r); + this.territoryRgba.g = Math.round(tc.g); + this.territoryRgba.b = Math.round(tc.b); + this.territoryRgba.a = 255; + + const bc = borderColor.toRgb(); + this.borderRgba.r = Math.round(bc.r); + this.borderRgba.g = Math.round(bc.g); + this.borderRgba.b = Math.round(bc.b); + this.borderRgba.a = 255; + } +} + /** * Adapter that makes Game work as GameView for rendering purposes. * Provides the interface that GroundTruthData and rendering passes need, @@ -16,16 +177,139 @@ export class GameViewAdapter implements Partial { private lastUpdate: GameUpdateViewData | null = null; private patternsEnabled = false; + private defensePostsDirty = true; + private readonly defensePostsById = new Map(); + private readonly defensePosts: DefensePostUnit[] = []; + + private playersDirty = true; + private readonly playersBySmallId = new Map(); + private playerViewsCache: PlayerLiteView[] = []; + private playersEpoch = 1; + private playerViewsCacheEpoch = 0; + private playerColorsEpoch = 1; + private readonly playerColorsDirtyEpochBySmallId = new Map(); + private readonly embargoPairs = new Set(); + private readonly friendlyPairs = new Set(); + private readonly emptyCosmetics = {} as PlayerCosmetics; + constructor( - private game: Game, - private mapData: TerrainMapData, + private tileState: Uint16Array, + private terrainData: Uint8Array, + private readonly mapWidth: number, + private readonly mapHeight: number, private theme: Theme, private readonly myClientId: ClientID | null, private readonly cosmeticsByClientID: Map, - ) {} + ) { + void 0; + } + + getMyClientId(): ClientID | null { + return this.myClientId; + } + + getTheme(): Theme { + return this.theme; + } + + getPatternsEnabled(): boolean { + return this.patternsEnabled; + } + + getCosmetics(clientId: ClientID | null): PlayerCosmetics { + if (!clientId) { + return this.emptyCosmetics; + } + return this.cosmeticsByClientID.get(clientId) ?? this.emptyCosmetics; + } + + private static pairKey(a: number, b: number): bigint { + const lo = Math.min(a, b) >>> 0; + const hi = Math.max(a, b) >>> 0; + return (BigInt(lo) << 32n) | BigInt(hi); + } + + hasEmbargoPair(aSmallId: number, bSmallId: number): boolean { + return this.embargoPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId)); + } + + hasFriendlyPair(aSmallId: number, bSmallId: number): boolean { + return this.friendlyPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId)); + } + + markPlayerColorsDirty(smallId: number): void { + this.playerColorsDirtyEpochBySmallId.delete(smallId); + } + + consumePlayerColorsDirty(smallId: number): boolean { + const last = this.playerColorsDirtyEpochBySmallId.get(smallId) ?? 0; + if (last === this.playerColorsEpoch) { + return false; + } + this.playerColorsDirtyEpochBySmallId.set(smallId, this.playerColorsEpoch); + return true; + } + + private upsertDefensePost( + id: number, + tile: TileRef, + ownerSmallId: number, + ): void { + const existing = this.defensePostsById.get(id); + if (existing) { + if ( + existing.tile() !== tile || + existing.owner().smallID() !== ownerSmallId + ) { + existing.set(tile, ownerSmallId); + this.defensePostsDirty = true; + } + return; + } + + const unit = new DefensePostUnit(id, tile, ownerSmallId); + unit.index = this.defensePosts.length; + this.defensePosts.push(unit); + this.defensePostsById.set(id, unit); + this.defensePostsDirty = true; + } + + private removeDefensePost(id: number): void { + const existing = this.defensePostsById.get(id); + if (!existing) { + return; + } + + const idx = existing.index; + const last = this.defensePosts.pop(); + if (last && last !== existing) { + this.defensePosts[idx] = last; + last.index = idx; + } + this.defensePostsById.delete(id); + this.defensePostsDirty = true; + } + + consumeDefensePostsDirty(): boolean { + const dirty = this.defensePostsDirty; + this.defensePostsDirty = false; + return dirty; + } + + consumePlayersDirty(): boolean { + const dirty = this.playersDirty; + this.playersDirty = false; + return dirty; + } setPatternsEnabled(enabled: boolean): void { + if (this.patternsEnabled === enabled) { + return; + } this.patternsEnabled = enabled; + this.playersDirty = true; + this.playersEpoch++; + this.playerColorsEpoch++; } /** @@ -34,30 +318,140 @@ export class GameViewAdapter implements Partial { */ update(gu: GameUpdateViewData): void { this.lastUpdate = gu; - } - config() { - return this.game.config(); + const playerUpdates = (gu.updates?.[GameUpdateType.Player] ?? + []) as PlayerUpdate[]; + let playersChanged = false; + for (const p of playerUpdates) { + const small = p.smallID; + if (small <= 0) { + continue; + } + const existing = this.playersBySmallId.get(small); + if (existing) { + existing.data = p; + existing.markColorsDirty(); + } else { + this.playersBySmallId.set(small, new PlayerLiteView(this, p)); + } + playersChanged = true; + } + if (playersChanged) { + this.playersDirty = true; + this.playersEpoch++; + + // Rebuild relations snapshot from authoritative PlayerUpdate state. + // This ensures correct initial relations without relying on event history. + this.embargoPairs.clear(); + this.friendlyPairs.clear(); + + const idToSmall = new Map(); + for (const v of this.playersBySmallId.values()) { + idToSmall.set(v.data.id, v.data.smallID); + } + for (const v of this.playersBySmallId.values()) { + const a = v.data.smallID; + if (a <= 0) continue; + + for (const b of v.data.allies ?? []) { + if (typeof b === "number" && b > 0) { + this.friendlyPairs.add(GameViewAdapter.pairKey(a, b)); + } + } + + for (const otherId of v.data.embargoes ?? []) { + if (typeof otherId !== "string") continue; + const b = idToSmall.get(otherId) ?? 0; + if (b > 0) { + this.embargoPairs.add(GameViewAdapter.pairKey(a, b)); + } + } + } + } + + const embargoUpdates = (gu.updates?.[GameUpdateType.EmbargoEvent] ?? + []) as EmbargoUpdate[]; + for (const e of embargoUpdates) { + const key = GameViewAdapter.pairKey(e.playerID, e.embargoedID); + if (e.event === "start") { + this.embargoPairs.add(key); + } else { + this.embargoPairs.delete(key); + } + } + + const allianceReplies = (gu.updates?.[ + GameUpdateType.AllianceRequestReply + ] ?? []) as AllianceRequestReplyUpdate[]; + for (const e of allianceReplies) { + if (!e.accepted) { + continue; + } + this.friendlyPairs.add( + GameViewAdapter.pairKey(e.request.requestorID, e.request.recipientID), + ); + } + + const brokeAllianceUpdates = (gu.updates?.[GameUpdateType.BrokeAlliance] ?? + []) as BrokeAllianceUpdate[]; + for (const e of brokeAllianceUpdates) { + this.friendlyPairs.delete( + GameViewAdapter.pairKey(e.traitorID, e.betrayedID), + ); + } + + const expiredUpdates = (gu.updates?.[GameUpdateType.AllianceExpired] ?? + []) as AllianceExpiredUpdate[]; + for (const e of expiredUpdates) { + this.friendlyPairs.delete( + GameViewAdapter.pairKey(e.player1ID, e.player2ID), + ); + } + + const unitUpdates = (gu.updates?.[GameUpdateType.Unit] ?? + []) as UnitUpdate[]; + for (const u of unitUpdates) { + if (u.unitType !== UnitType.DefensePost) { + continue; + } + + const removed = + u.markedForDeletion !== false || + !u.isActive || + u.underConstruction === true; + if (removed) { + this.removeDefensePost(u.id); + } else { + this.upsertDefensePost(u.id, u.pos, u.ownerID); + } + } } width(): number { - return this.game.width(); + return this.mapWidth; } height(): number { - return this.game.height(); + return this.mapHeight; } x(tile: TileRef): number { - return this.game.x(tile); + return tile % this.mapWidth; } y(tile: TileRef): number { - return this.game.y(tile); + return (tile / this.mapWidth) | 0; + } + + playerBySmallID(smallId: number): any | null { + return this.playersBySmallId.get(smallId) ?? null; } units(...types: UnitType[]): any[] { - return this.game.units(...types); + if (types.length === 1 && types[0] === UnitType.DefensePost) { + return this.defensePosts; + } + return []; } /** @@ -67,14 +461,14 @@ export class GameViewAdapter implements Partial { * read from it when individual tiles are marked dirty. */ tileStateView(): Uint16Array { - return this.game.tileStateView(); + return this.tileState; } /** * Return the immutable terrain data view. */ terrainDataView(): Uint8Array { - return this.game.terrainDataView(); + return this.terrainData; } /** @@ -84,85 +478,11 @@ export class GameViewAdapter implements Partial { * otherwise the worker-rendered territory will disagree with UI. */ playerViews(): any[] { - const theme = this.theme; - return this.game.players().map((player) => { - const clientId = player.clientID(); - const cosmetics = - clientId && this.cosmeticsByClientID.has(clientId) - ? this.cosmeticsByClientID.get(clientId)! - : ({} as PlayerCosmetics); - - const defaultTerritoryColor = theme.territoryColor(player as any); - const defaultBorderColor = theme.borderColor(defaultTerritoryColor); - - const pattern = this.patternsEnabled ? cosmetics.pattern : undefined; - if (pattern) { - pattern.colorPalette ??= { - name: "", - primaryColor: defaultTerritoryColor.toHex(), - secondaryColor: defaultBorderColor.toHex(), - }; - } - - const territoryColor: Colord = - player.team() === null - ? colord( - cosmetics.color?.color ?? - pattern?.colorPalette?.primaryColor ?? - defaultTerritoryColor.toHex(), - ) - : defaultTerritoryColor; - - const maybeFocusedBorderColor = - this.myClientId !== null && clientId === this.myClientId - ? theme.focusedBorderColor() - : defaultBorderColor; - - const borderColor: Colord = colord( - pattern?.colorPalette?.secondaryColor ?? - cosmetics.color?.color ?? - maybeFocusedBorderColor.toHex(), - ); - - const territoryRgb = territoryColor.toRgb(); - const borderRgb = borderColor.toRgb(); - - const view = { - player, - smallID: () => player.smallID(), - territoryColor: () => ({ - rgba: { - r: Math.round(territoryRgb.r), - g: Math.round(territoryRgb.g), - b: Math.round(territoryRgb.b), - a: Math.round((territoryRgb.a ?? 1) * 255), - }, - }), - borderColor: () => ({ - rgba: { - r: Math.round(borderRgb.r), - g: Math.round(borderRgb.g), - b: Math.round(borderRgb.b), - a: Math.round((borderRgb.a ?? 1) * 255), - }, - }), - hasEmbargo: (other: any) => { - const otherPlayer = other?.player; - if (!otherPlayer) return false; - return ( - player.hasEmbargoAgainst(otherPlayer) || - otherPlayer.hasEmbargoAgainst(player) - ); - }, - isFriendly: (other: any) => { - const otherPlayer = other?.player; - if (!otherPlayer) return false; - return player.isFriendly(otherPlayer); - }, - }; - - return view; - }); + if (this.playerViewsCacheEpoch !== this.playersEpoch) { + this.playerViewsCache = [...this.playersBySmallId.values()]; + this.playerViewsCacheEpoch = this.playersEpoch; + } + return this.playerViewsCache; } /** diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 3f037ab12..1cec6d1cb 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -4,6 +4,7 @@ import { PastelTheme } from "../configuration/PastelTheme"; import { PastelThemeDark } from "../configuration/PastelThemeDark"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; import { PlayerID } from "../game/Game"; +import { TileRef } from "../game/GameMap"; import { AllianceExpiredUpdate, AllianceRequestReplyUpdate, @@ -13,7 +14,7 @@ import { GameUpdateType, GameUpdateViewData, } from "../game/GameUpdates"; -import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader"; + import { createGameRunner, GameRunner } from "../GameRunner"; import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas"; import { DirtyTileQueue } from "./DirtyTileQueue"; @@ -38,9 +39,29 @@ let gameStartInfo: GameStartInfo | null = null; let myClientID: ClientID | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null; -let mapData: TerrainMapData | null = null; let dirtyTiles: DirtyTileQueue | null = null; let dirtyTilesOverflow = false; +let renderTileState: Uint16Array | null = null; + +let simPumpScheduled = false; +function scheduleSimPump(): void { + if (simPumpScheduled) { + return; + } + simPumpScheduled = true; + + setTimeout(async () => { + simPumpScheduled = false; + if (!gameRunner) { + return; + } + const gr = await gameRunner; + gr.executeNextTick(); + if (gr.hasPendingTurns()) { + scheduleSimPump(); + } + }, 0); +} function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -49,7 +70,7 @@ function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { } // Keep renderer-side adapter in sync (palette/relations/etc). - (renderer as any)?.updateGameView?.(gu); + const viewUpdateDidWork = (renderer as any)?.updateGameView?.(gu) === true; // Uploading relations is expensive; only refresh when diplomacy changes, // and only for the affected player pairs. @@ -94,6 +115,9 @@ function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // compute passes for this tick. if (renderer && dirtyTiles) { let didWork = false; + if (viewUpdateDidWork) { + didWork = true; + } if (relationsChanged) { didWork = true; } @@ -134,7 +158,9 @@ ctx.addEventListener("message", async (e: MessageEvent) => { switch (message.type) { case "heartbeat": - (await gameRunner)?.executeNextTick(); + // Heartbeat is a high-frequency "wake up" signal from the main thread. + // Coalesce it and run simulation work in small slices to avoid backlog. + scheduleSimPump(); break; case "init": try { @@ -150,11 +176,18 @@ ctx.addEventListener("message", async (e: MessageEvent) => { // Capacity is bounded; on overflow we fall back to markAllDirty(). dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles)); dirtyTilesOverflow = false; + renderTileState = new Uint16Array(gr.game.tileStateView()); - gr.tileUpdateSink = (tile) => { + gr.tileUpdateSink = (packedUpdate) => { if (!dirtyTiles) { return; } + + const tile = Number(packedUpdate >> 16n) as TileRef; + const state = Number(packedUpdate & 0xffffn); + if (renderTileState) { + renderTileState[tile] = state; + } const mark = (t: any) => { if (!dirtyTiles!.mark(t)) { dirtyTilesOverflow = true; @@ -183,7 +216,8 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; - await gr.addTurn(message.turn); + gr.addTurn(message.turn); + scheduleSimPump(); } catch (error) { console.error("Failed to process turn:", error); throw error; @@ -329,14 +363,6 @@ ctx.addEventListener("message", async (e: MessageEvent) => { (renderer as any)?.dispose?.(); renderer = null; - // Load map data if not already loaded - // Use gameStartInfo.config which has the original game map info - mapData ??= await loadTerrainMap( - gameStartInfo.config.gameMap, - gameStartInfo.config.gameMapSize, - mapLoader, - ); - // Create theme based on darkMode flag from main thread // (can't access userSettings in worker, so it's passed from main thread) const theme: Theme = message.darkMode @@ -357,13 +383,14 @@ ctx.addEventListener("message", async (e: MessageEvent) => { ? new WorkerCanvas2DRenderer() : new WorkerTerritoryRenderer(); + renderTileState ??= new Uint16Array(gr.game.tileStateView()); await renderer.init( message.offscreenCanvas, gr, - mapData, theme, myClientID, cosmeticsByClientID, + renderTileState, ); sendMessage({ @@ -508,6 +535,16 @@ ctx.addEventListener("message", async (e: MessageEvent) => { case "render_frame": if (renderer) { + if ("viewSize" in message && message.viewSize) { + renderer.setViewSize(message.viewSize.width, message.viewSize.height); + } + if ("viewTransform" in message && message.viewTransform) { + renderer.setViewTransform( + message.viewTransform.scale, + message.viewTransform.offsetX, + message.viewTransform.offsetY, + ); + } renderer.render(); } break; diff --git a/src/core/worker/WorkerCanvas2DRenderer.ts b/src/core/worker/WorkerCanvas2DRenderer.ts index 2cfbd74eb..b8a8f029e 100644 --- a/src/core/worker/WorkerCanvas2DRenderer.ts +++ b/src/core/worker/WorkerCanvas2DRenderer.ts @@ -2,7 +2,7 @@ import { Theme } from "../configuration/Config"; import { PastelTheme } from "../configuration/PastelTheme"; import { PastelThemeDark } from "../configuration/PastelThemeDark"; import { TileRef } from "../game/GameMap"; -import { TerrainMapData } from "../game/TerrainMapLoader"; +import { GameUpdateViewData } from "../game/GameUpdates"; import { GameRunner } from "../GameRunner"; import { ClientID, PlayerCosmetics } from "../Schemas"; import { GameViewAdapter } from "./GameViewAdapter"; @@ -17,6 +17,7 @@ export class WorkerCanvas2DRenderer { private rasterCtx: Offscreen2D | null = null; private rasterImage: ImageData | null = null; private terrainBaseRgba: Uint8Array | null = null; + private tileState: Uint16Array | null = null; private gameViewAdapter: GameViewAdapter | null = null; private gameRunner: GameRunner | null = null; @@ -49,10 +50,10 @@ export class WorkerCanvas2DRenderer { async init( offscreenCanvas: OffscreenCanvas, gameRunner: GameRunner, - mapData: TerrainMapData, theme: Theme, myClientID: ClientID | null, cosmeticsByClientID: Map, + tileState: Uint16Array, ): Promise { this.canvas = offscreenCanvas; this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D; @@ -62,6 +63,7 @@ export class WorkerCanvas2DRenderer { this.gameRunner = gameRunner; this.theme = theme; + this.tileState = tileState; const mapW = gameRunner.game.width(); const mapH = gameRunner.game.height(); @@ -69,8 +71,10 @@ export class WorkerCanvas2DRenderer { this.mapHeight = mapH; this.gameViewAdapter = new GameViewAdapter( - gameRunner.game, - mapData, + tileState, + gameRunner.game.terrainDataView(), + gameRunner.game.width(), + gameRunner.game.height(), theme, myClientID, cosmeticsByClientID, @@ -107,6 +111,20 @@ export class WorkerCanvas2DRenderer { this.tick(); } + updateGameView(gu: GameUpdateViewData): boolean { + if (!this.gameViewAdapter) { + return false; + } + this.gameViewAdapter.update(gu); + const playersDirty = this.gameViewAdapter.consumePlayersDirty(); + if (playersDirty && !this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + this.markAllDirty(); + return true; + } + return false; + } + dispose(): void { this.ready = false; this.canvas = null; @@ -115,6 +133,7 @@ export class WorkerCanvas2DRenderer { this.rasterCtx = null; this.rasterImage = null; this.terrainBaseRgba = null; + this.tileState = null; this.gameViewAdapter = null; this.gameRunner = null; this.theme = null; @@ -217,7 +236,10 @@ export class WorkerCanvas2DRenderer { const mapH = this.mapHeight; const out = this.rasterImage.data; const base = this.terrainBaseRgba; - const state = this.gameRunner.game.tileStateView(); + const state = this.tileState; + if (!state) { + return; + } const row0 = this.paletteRow0; const maxSmallId = this.paletteMaxSmallId; diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index b2508e56b..9bea06356 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -233,8 +233,25 @@ export interface TickRendererMessage extends BaseWorkerMessage { type: "tick_renderer"; } +export interface ViewSize { + width: number; + height: number; +} + +export interface ViewTransform { + scale: number; + offsetX: number; + offsetY: number; +} + export interface RenderFrameMessage extends BaseWorkerMessage { type: "render_frame"; + /** + * Optional per-frame view state. This allows the main thread to coalesce + * high-frequency camera updates into the existing render message. + */ + viewSize?: ViewSize; + viewTransform?: ViewTransform; } // Renderer messages from worker to main thread diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts index 4af7e8795..91038c984 100644 --- a/src/core/worker/WorkerTerritoryRenderer.ts +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -1,7 +1,6 @@ import { Theme } from "../configuration/Config"; import { TileRef } from "../game/GameMap"; import { GameUpdateViewData } from "../game/GameUpdates"; -import { TerrainMapData } from "../game/TerrainMapLoader"; import { GameRunner } from "../GameRunner"; import { ClientID, PlayerCosmetics } from "../Schemas"; import { GameViewAdapter } from "./GameViewAdapter"; @@ -70,10 +69,10 @@ export class WorkerTerritoryRenderer { async init( offscreenCanvas: OffscreenCanvas, gameRunner: GameRunner, - mapData: TerrainMapData, theme: Theme, myClientID: ClientID | null, cosmeticsByClientID: Map, + tileState: Uint16Array, ): Promise { this.canvas = offscreenCanvas; const game = gameRunner.game; @@ -81,8 +80,10 @@ export class WorkerTerritoryRenderer { // Create adapter this.gameViewAdapter = new GameViewAdapter( - game, - mapData, + tileState, + game.terrainDataView(), + game.width(), + game.height(), theme, myClientID, cosmeticsByClientID, @@ -97,11 +98,12 @@ export class WorkerTerritoryRenderer { this.device = webgpuDevice; // Create ground truth data using adapter - const state = this.gameViewAdapter.tileStateView(); + const state = tileState; this.resources = GroundTruthData.create( webgpuDevice.device, this.gameViewAdapter as any, theme, + this.defensePostRange, state, ); this.resources.setTerritoryShaderParams( @@ -170,10 +172,23 @@ export class WorkerTerritoryRenderer { /** * Update game view adapter with latest game update. */ - updateGameView(gu: GameUpdateViewData): void { - if (this.gameViewAdapter) { - this.gameViewAdapter.update(gu); + updateGameView(gu: GameUpdateViewData): boolean { + if (!this.gameViewAdapter) { + return false; } + + this.gameViewAdapter.update(gu); + const defensePostsDirty = this.gameViewAdapter.consumeDefensePostsDirty(); + const playersDirty = this.gameViewAdapter.consumePlayersDirty(); + if (defensePostsDirty) { + this.resources?.markDefensePostsDirty(); + } + if (playersDirty) { + this.resources?.markPaletteDirty(); + this.resources?.markRelationsDirty(); + this.resources?.invalidateHistory(); + } + return defensePostsDirty || playersDirty; } /** @@ -536,13 +551,6 @@ export class WorkerTerritoryRenderer { this.resources.updateTickTiming(performance.now() / 1000); - if ( - this.gameViewAdapter?.config().defensePostRange() !== - this.defensePostRange - ) { - throw new Error("defensePostRange changed at runtime; unsupported."); - } - // Upload palette if needed this.resources.uploadPalette();