diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts index b2fac5880..b4a85333a 100644 --- a/src/client/graphics/webgpu/TerritoryRendererProxy.ts +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -32,6 +32,7 @@ export class TerritoryRendererProxy { private offscreenCanvas: OffscreenCanvas | null = null; private worker: WorkerClient | null = null; private ready = false; + private failed = false; private initPromise: Promise | null = null; private pendingMessages: any[] = []; @@ -82,7 +83,12 @@ export class TerritoryRendererProxy { private startInit(): void { if (this.initPromise) return; - this.initPromise = this.init(); + this.initPromise = this.init().catch((err) => { + this.failed = true; + this.pendingMessages = []; + console.error("Worker territory renderer init failed:", err); + throw err; + }); } private async init(): Promise { @@ -120,6 +126,13 @@ export class TerritoryRendererProxy { if (message.type === "renderer_ready" && message.id === messageId) { clearTimeout(timeout); this.worker?.removeMessageHandler(messageId); + if (message.ok === false) { + reject( + new Error(message.error ?? "Renderer initialization failed"), + ); + return; + } + this.ready = true; // Send any pending messages for (const msg of this.pendingMessages) { @@ -138,6 +151,9 @@ export class TerritoryRendererProxy { if (!this.worker) { return; } + if (this.failed) { + return; + } if (!this.ready) { this.pendingMessages.push(message); return; diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts index 1c8fa0875..dba168ed7 100644 --- a/src/core/worker/GameViewAdapter.ts +++ b/src/core/worker/GameViewAdapter.ts @@ -1,5 +1,6 @@ import { Theme } from "../configuration/Config"; import { Game, UnitType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; import { GameUpdateViewData } from "../game/GameUpdates"; import { GameView } from "../game/GameView"; import { TerrainMapData } from "../game/TerrainMapLoader"; @@ -10,8 +11,6 @@ import { TerrainMapData } from "../game/TerrainMapLoader"; * without requiring the full GameView infrastructure. */ export class GameViewAdapter implements Partial { - private tileStateCache: Uint16Array | null = null; - private terrainDataCache: Uint8Array | null = null; private lastUpdate: GameUpdateViewData | null = null; constructor( @@ -26,9 +25,6 @@ export class GameViewAdapter implements Partial { */ update(gu: GameUpdateViewData): void { this.lastUpdate = gu; - // Invalidate caches when updated - this.tileStateCache = null; - this.terrainDataCache = null; } config() { @@ -43,11 +39,11 @@ export class GameViewAdapter implements Partial { return this.game.height(); } - x(tile: bigint): number { + x(tile: TileRef): number { return this.game.x(tile); } - y(tile: bigint): number { + y(tile: TileRef): number { return this.game.y(tile); } @@ -56,57 +52,20 @@ export class GameViewAdapter implements Partial { } /** - * Build tile state view from game. - * Cached until next update. + * Return the authoritative tile state view. + * + * Important: this must be the live backing buffer, because GPU update passes + * read from it when individual tiles are marked dirty. */ tileStateView(): Uint16Array { - if (this.tileStateCache) { - return this.tileStateCache; - } - // Build tile state from game - const width = this.game.width(); - const height = this.game.height(); - const state = new Uint16Array(width * height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const tile = this.game.ref(x, y); - const owner = this.game.owner(tile); - const ownerId = owner ? owner.smallID() : 0; - const terrain = this.game.terrain(tile); - const terrainType = terrain.type(); - const terrainMag = terrain.magnitude(); - // Pack state: ownerId (12 bits) | terrainType (2 bits) | terrainMag (2 bits) - state[y * width + x] = - (ownerId & 0xfff) | - ((terrainType & 0x3) << 12) | - ((terrainMag & 0x3) << 14); - } - } - this.tileStateCache = state; - return state; + return this.game.tileStateView(); } /** - * Build terrain data view from game. - * Cached until next update. + * Return the immutable terrain data view. */ terrainDataView(): Uint8Array { - if (this.terrainDataCache) { - return this.terrainDataCache; - } - // Build terrain data from game - const width = this.game.width(); - const height = this.game.height(); - const terrainData = new Uint8Array(width * height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const tile = this.game.ref(x, y); - const terrain = this.game.terrain(tile); - terrainData[y * width + x] = terrain.type(); - } - } - this.terrainDataCache = terrainData; - return terrainData; + return this.game.terrainDataView(); } /** @@ -114,16 +73,16 @@ export class GameViewAdapter implements Partial { * Computes colors from theme directly (no PlayerView needed). */ playerViews(): any[] { - const theme = this.game.config().theme(); - return this.game.players().map((p) => { - // Get default colors from theme - const defaultTerritoryColor = theme.territoryColor(p as any); + const theme = this.theme; + return this.game.players().map((player) => { + const defaultTerritoryColor = theme.territoryColor(player as any); const defaultBorderColor = theme.borderColor(defaultTerritoryColor); const territoryRgb = defaultTerritoryColor.toRgb(); const borderRgb = defaultBorderColor.toRgb(); - return { - smallID: () => p.smallID(), + const view = { + player, + smallID: () => player.smallID(), territoryColor: () => ({ rgba: { r: Math.round(territoryRgb.r), @@ -140,7 +99,22 @@ export class GameViewAdapter implements Partial { 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; }); } @@ -156,10 +130,16 @@ export class GameViewAdapter implements Partial { /** * Get recently updated tiles from last game update. */ - recentlyUpdatedTiles(): bigint[] { + recentlyUpdatedTiles(): TileRef[] { if (!this.lastUpdate) { return []; } - return Array.from(this.lastUpdate.packedTileUpdates); + // packedTileUpdates encode [tileRef << 16 | state] as bigint. + const packed = this.lastUpdate.packedTileUpdates; + const out: TileRef[] = new Array(packed.length); + for (let i = 0; i < packed.length; i++) { + out[i] = Number(packed[i] >> 16n); + } + return out; } } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index f1eb5e755..8d8ad642a 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -214,14 +214,17 @@ ctx.addEventListener("message", async (e: MessageEvent) => { sendMessage({ type: "renderer_ready", id: message.id, + ok: true, } as RendererReadyMessage); } catch (error) { console.error("Failed to initialize renderer:", error); sendMessage({ type: "renderer_ready", id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), } as RendererReadyMessage); - throw error; + renderer = null; } break; diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index a5ea358dd..02cd537da 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -78,7 +78,11 @@ export class WorkerClient { * Post a message to the worker with optional transferables. */ postMessage(message: any, transfer?: Transferable[]): void { - this.worker.postMessage(message, transfer); + if (transfer && transfer.length > 0) { + this.worker.postMessage(message, transfer); + return; + } + this.worker.postMessage(message); } initialize(): Promise { diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 158bb171d..4aa752836 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -204,6 +204,8 @@ export interface RenderFrameMessage extends BaseWorkerMessage { // Renderer messages from worker to main thread export interface RendererReadyMessage extends BaseWorkerMessage { type: "renderer_ready"; + ok: boolean; + error?: string; } export interface RendererMetricsMessage extends BaseWorkerMessage { diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts index cf137b3bc..9b960b784 100644 --- a/src/core/worker/WorkerTerritoryRenderer.ts +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -24,6 +24,7 @@ import { TerritoryRenderPass } from "../../client/graphics/webgpu/render/Territo */ export class WorkerTerritoryRenderer { private device: WebGPUDevice | null = null; + private canvas: OffscreenCanvas | null = null; private resources: GroundTruthData | null = null; private gameViewAdapter: GameViewAdapter | null = null; private ready = false; @@ -70,6 +71,7 @@ export class WorkerTerritoryRenderer { mapData: TerrainMapData, theme: Theme, ): Promise { + this.canvas = offscreenCanvas; const game = gameRunner.game; this.defensePostRange = game.config().defensePostRange(); @@ -218,10 +220,20 @@ export class WorkerTerritoryRenderer { const nextWidth = Math.max(1, Math.floor(width)); const nextHeight = Math.max(1, Math.floor(height)); - // OffscreenCanvas doesn't have width/height properties we can set - // The size is managed by the main thread canvas + let sizeChanged = true; + if (this.canvas) { + sizeChanged = + nextWidth !== this.canvas.width || nextHeight !== this.canvas.height; + if (sizeChanged) { + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + } + } + this.resources.setViewSize(nextWidth, nextHeight); - this.device.reconfigure(); + if (sizeChanged) { + this.device.reconfigure(); + } if (this.postSmoothingEnabled && this.resources) { this.resources.ensurePostSmoothingTextures( @@ -229,7 +241,6 @@ export class WorkerTerritoryRenderer { nextHeight, this.device.canvasFormat, ); - this.resources.invalidateHistory(); } } @@ -579,9 +590,8 @@ export class WorkerTerritoryRenderer { } if (pass === this.territoryRenderPass && this.postSmoothingEnabled) { if (!this.resources.getCurrentColorTexture()) { - // Use view size from resources (stored via setViewSize) - const viewWidth = (this.resources as any).viewWidth ?? 1; - const viewHeight = (this.resources as any).viewHeight ?? 1; + const viewWidth = this.canvas?.width ?? 1; + const viewHeight = this.canvas?.height ?? 1; this.resources.ensurePostSmoothingTextures( viewWidth, viewHeight,