From 636fe2e68abcf5bf175a70c8335d22a925c3701b Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:34:15 +0100 Subject: [PATCH] this isnt getting good soon --- src/client/ClientGameRunner.ts | 56 ++- src/client/graphics/PlayerIcons.ts | 9 +- .../canvas2d/Canvas2DRendererProxy.ts | 310 ++++++++++++++ src/client/graphics/layers/EmojiTable.ts | 49 ++- src/client/graphics/layers/MainRadialMenu.ts | 13 +- .../graphics/layers/PlayerInfoOverlay.ts | 75 +++- src/client/graphics/layers/PlayerPanel.ts | 54 ++- .../graphics/layers/RadialMenuElements.ts | 10 +- src/client/graphics/layers/TerritoryLayer.ts | 167 +++++--- .../graphics/layers/WebGPUDebugOverlay.ts | 32 +- .../graphics/webgpu/TerritoryRenderer.ts | 6 + .../graphics/webgpu/TerritoryRendererProxy.ts | 98 ++++- .../graphics/webgpu/core/GroundTruthData.ts | 116 ++++-- src/core/GameRunner.ts | 16 +- src/core/game/UserSettings.ts | 19 + src/core/worker/DirtyTileQueue.ts | 80 ++++ src/core/worker/GameViewAdapter.ts | 53 ++- src/core/worker/Worker.worker.ts | 142 ++++++- src/core/worker/WorkerCanvas2DRenderer.ts | 381 ++++++++++++++++++ src/core/worker/WorkerClient.ts | 25 +- src/core/worker/WorkerMessages.ts | 40 ++ src/core/worker/WorkerTerritoryRenderer.ts | 66 ++- 22 files changed, 1633 insertions(+), 184 deletions(-) create mode 100644 src/client/graphics/canvas2d/Canvas2DRendererProxy.ts create mode 100644 src/core/worker/DirtyTileQueue.ts create mode 100644 src/core/worker/WorkerCanvas2DRenderer.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index bd377d35d..652e86ccc 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -538,11 +538,19 @@ export class ClientGameRunner { const tile = this.gameView.ref(cell.x, cell.y); if ( this.gameView.isLand(tile) && - !this.gameView.hasOwner(tile) && this.gameView.inSpawnPhase() && !this.gameView.config().isRandomSpawn() ) { - this.eventBus.emit(new SendSpawnIntentEvent(tile)); + // Main thread no longer maintains authoritative tile ownership. Query the + // worker for spawn validation. + this.worker + .tileContext(tile) + .then((ctx) => { + if (!ctx.hasOwner) { + this.eventBus.emit(new SendSpawnIntentEvent(tile)); + } + }) + .catch((err) => console.warn("tileContext spawn lookup failed:", err)); return; } if (this.gameView.inSpawnPhase()) { @@ -556,12 +564,22 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + this.worker + .tileContext(tile) + .then((ctx) => { + if (!this.myPlayer) { + return; + } + this.eventBus.emit( + new SendAttackIntentEvent( + ctx.ownerId ?? null, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); + }) + .catch((err) => + console.warn("tileContext attack lookup failed:", err), + ); } else if (this.canAutoBoat(actions, tile)) { this.sendBoatAttackIntent(tile); } @@ -672,12 +690,22 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + this.worker + .tileContext(tile) + .then((ctx) => { + if (!this.myPlayer) { + return; + } + this.eventBus.emit( + new SendAttackIntentEvent( + ctx.ownerId ?? null, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); + }) + .catch((err) => + console.warn("tileContext attack lookup failed:", err), + ); } }); } diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index d5e86315a..896272480 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -140,12 +140,9 @@ export function getPlayerIcons( return isSendingNuke && notMyPlayer && unit.isActive(); }); - const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => { - const detonationDst = unit.targetTile(); - if (!detonationDst || !myPlayer) return false; - const targetId = game.owner(detonationDst).id(); - return targetId === myPlayer.id(); - }); + // Main thread does not maintain authoritative tile ownership; treat this icon + // as informational only (no "targeted at me" specialization here). + const isMyPlayerTarget = false; if (nukesSentByOtherPlayer.length > 0) { const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon; diff --git a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts new file mode 100644 index 000000000..c1a1a25fe --- /dev/null +++ b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts @@ -0,0 +1,310 @@ +import { createCanvas } from "src/client/Utils"; +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { WorkerClient } from "../../../core/worker/WorkerClient"; +import { + InitRendererMessage, + MarkAllDirtyMessage, + MarkTileMessage, + RefreshPaletteMessage, + RefreshTerrainMessage, + RenderFrameMessage, + SetAlternativeViewMessage, + SetHighlightedOwnerMessage, + SetPaletteMessage, + SetPatternsEnabledMessage, + SetShaderSettingsMessage, + SetViewSizeMessage, + SetViewTransformMessage, + TickRendererMessage, +} from "../../../core/worker/WorkerMessages"; + +export interface Canvas2DCreateResult { + renderer: Canvas2DRendererProxy | null; + reason?: string; +} + +export class Canvas2DRendererProxy { + public readonly canvas: HTMLCanvasElement; + private offscreenCanvas: OffscreenCanvas | null = null; + private worker: WorkerClient | null = null; + private ready = false; + private failed = false; + private initPromise: Promise | null = null; + private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = createCanvas(); + this.canvas.style.pointerEvents = "none"; + this.canvas.width = 1; + this.canvas.height = 1; + } + + static create( + game: GameView, + theme: Theme, + worker: WorkerClient, + ): Canvas2DCreateResult { + if (typeof OffscreenCanvas === "undefined") { + return { + renderer: null, + reason: + "OffscreenCanvas not supported; Canvas2D worker renderer disabled.", + }; + } + if ( + typeof HTMLCanvasElement.prototype.transferControlToOffscreen !== + "function" + ) { + return { + renderer: null, + reason: + "transferControlToOffscreen not supported; Canvas2D worker renderer disabled.", + }; + } + + const renderer = new Canvas2DRendererProxy(game, theme); + renderer.worker = worker; + renderer.startInit(); + return { renderer }; + } + + private startInit(): void { + if (this.initPromise) return; + this.initPromise = this.init().catch((err) => { + this.failed = true; + this.pendingMessages = []; + console.error("Worker canvas2d renderer init failed:", err); + throw err; + }); + } + + private async init(): Promise { + if (!this.worker) { + throw new Error("Worker not set"); + } + + this.offscreenCanvas = this.canvas.transferControlToOffscreen(); + + const themeAny = this.theme as any; + const darkMode = themeAny.darkShore !== undefined; + + const messageId = `init_renderer_canvas2d_${Date.now()}`; + const initMessage: InitRendererMessage = { + type: "init_renderer", + id: messageId, + offscreenCanvas: this.offscreenCanvas, + darkMode: darkMode, + backend: "canvas2d", + }; + + this.worker.postMessage(initMessage, [this.offscreenCanvas]); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.worker?.removeMessageHandler(messageId); + reject(new Error("Renderer initialization timeout")); + }, 10000); + + const handler = (message: any) => { + 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; + for (const pending of this.pendingMessages) { + if (pending.transferables) { + this.worker?.postMessage(pending.message, pending.transferables); + } else { + this.sendToWorker(pending.message); + } + } + this.pendingMessages = []; + resolve(); + } + }; + + this.worker?.addMessageHandler(messageId, handler); + }); + } + + private sendToWorker(message: any): void { + if (!this.worker) return; + if (this.failed) return; + if (!this.ready) { + this.pendingMessages.push({ message }); + return; + } + this.worker.postMessage(message); + } + + private sendToWorkerWithTransfer(message: any, transferables: any[]): void { + if (!this.worker) return; + if (this.failed) return; + if (!this.ready) { + this.pendingMessages.push({ message, transferables }); + return; + } + this.worker.postMessage(message, transferables); + } + + setViewSize(width: number, height: number): void { + const message: SetViewSizeMessage = { + type: "set_view_size", + width, + 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); + } + + setAlternativeView(enabled: boolean): void { + const message: SetAlternativeViewMessage = { + type: "set_alternative_view", + enabled, + }; + this.sendToWorker(message); + } + + setPatternsEnabled(enabled: boolean): void { + const message: SetPatternsEnabledMessage = { + type: "set_patterns_enabled", + enabled, + }; + this.sendToWorker(message); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + const message: SetHighlightedOwnerMessage = { + type: "set_highlighted_owner", + ownerSmallId, + }; + this.sendToWorker(message); + } + + // Shader controls are ignored by the Canvas2D backend but kept for API parity. + setTerritoryShader(_shaderPath: string): void {} + setTerrainShader(_shaderPath: string): void {} + setTerritoryShaderParams( + _params0: Float32Array | number[], + _params1: Float32Array | number[], + ): void {} + setTerrainShaderParams( + _params0: Float32Array | number[], + _params1: Float32Array | number[], + ): void {} + setPreSmoothing( + _enabled: boolean, + _shaderPath: string, + _params0: Float32Array | number[], + ): void {} + setPostSmoothing( + _enabled: boolean, + _shaderPath: string, + _params0: Float32Array | number[], + ): void {} + setShaderSettings(_settings: SetShaderSettingsMessage): void {} + + markTile(tile: TileRef): void { + const message: MarkTileMessage = { type: "mark_tile", tile }; + this.sendToWorker(message); + } + + markAllDirty(): void { + const message: MarkAllDirtyMessage = { type: "mark_all_dirty" }; + this.sendToWorker(message); + } + + markDefensePostsDirty(): void { + this.markAllDirty(); + } + + refreshPalette(): void { + if (!this.worker) return; + + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + + const RESERVED = 10; + const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + // Fallout slot (index 0) + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tc = player.territoryColor().toRgb(); + row0[idx] = toByte(tc.r); + row0[idx + 1] = toByte(tc.g); + row0[idx + 2] = toByte(tc.b); + row0[idx + 3] = 255; + + const bc = player.borderColor().toRgb(); + row1[idx] = toByte(bc.r); + row1[idx + 1] = toByte(bc.g); + row1[idx + 2] = toByte(bc.b); + row1[idx + 3] = 255; + } + + const message: SetPaletteMessage = { + type: "set_palette", + paletteWidth, + maxSmallId, + row0, + row1, + }; + this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]); + + const fallback: RefreshPaletteMessage = { type: "refresh_palette" }; + this.sendToWorker(fallback); + } + + refreshTerrain(): void { + const message: RefreshTerrainMessage = { type: "refresh_terrain" }; + this.sendToWorker(message); + } + + tick(): void { + const message: TickRendererMessage = { type: "tick_renderer" }; + this.sendToWorker(message); + } + + render(): void { + const message: RenderFrameMessage = { type: "render_frame" }; + this.sendToWorker(message); + } +} diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index ef3547e41..17ed8a5fb 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; import { SendEmojiIntentEvent } from "../../Transport"; @@ -24,28 +23,36 @@ export class EmojiTable extends LitElement { } const tile = this.game.ref(cell.x, cell.y); - if (!this.game.hasOwner(tile)) { - return; - } + this.game.worker.tileContext(tile).then((ctx) => { + if (!ctx.ownerId) { + return; + } - const targetPlayer = this.game.owner(tile); - // maybe redundant due to owner check but better safe than sorry - if (targetPlayer instanceof TerraNulliusImpl) { - return; - } + let targetPlayer: PlayerView | null = null; + try { + const maybe = this.game.player(ctx.ownerId); + targetPlayer = + maybe && maybe.isPlayer() ? (maybe as PlayerView) : null; + } catch { + targetPlayer = null; + } + if (!targetPlayer) { + return; + } - this.showTable((emoji) => { - const recipient = - targetPlayer === this.game.myPlayer() - ? AllPlayers - : (targetPlayer as PlayerView); - eventBus.emit( - new SendEmojiIntentEvent( - recipient, - flattenedEmojiTable.indexOf(emoji as Emoji), - ), - ); - this.hideTable(); + this.showTable((emoji) => { + const recipient = + targetPlayer === this.game.myPlayer() + ? AllPlayers + : (targetPlayer as PlayerView); + eventBus.emit( + new SendEmojiIntentEvent( + recipient, + flattenedEmojiTable.indexOf(emoji as Emoji), + ), + ); + this.hideTable(); + }); }); }); eventBus.on(CloseViewEvent, (e) => { diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..2754dc258 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -110,8 +110,17 @@ export class MainRadialMenu extends LitElement implements Layer { ) { this.buildMenu.playerActions = actions; - const tileOwner = this.game.owner(tile); - const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + let recipient: PlayerView | null = null; + try { + const ctx = await this.game.worker.tileContext(tile); + if (ctx.ownerId) { + const maybe = this.game.player(ctx.ownerId); + recipient = maybe && maybe.isPlayer() ? (maybe as PlayerView) : null; + } + } catch { + // Best-effort; the menu can still operate from PlayerActions alone. + recipient = null; + } if (myPlayer && recipient) { this.chatIntegration.setupChatModal(myPlayer, recipient); diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 2dbf7f28d..3a4658129 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -18,7 +18,6 @@ import { renderTroops, translateText, } from "../../Utils"; -import { getHoverInfo } from "../HoverInfo"; import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -66,6 +65,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private lastMouseUpdate = 0; private showDetails = true; + private hoverSeq = 0; init() { this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => @@ -98,20 +98,69 @@ export class PlayerInfoOverlay extends LitElement implements Layer { public maybeShow(x: number, y: number) { this.hide(); const worldCoord = this.transform.screenToWorldCoordinates(x, y); - const info = getHoverInfo(this.game, worldCoord); + if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) { + return; + } - if (info.player) { - this.player = info.player; - this.player.profile().then((p) => { - this.playerProfile = p; + const tile = this.game.ref(worldCoord.x, worldCoord.y); + + // Land hover info requires tile ownership/fallout, which is authoritative in + // the worker (main thread does not maintain a full tile mirror). + if (this.game.isLand(tile)) { + const seq = ++this.hoverSeq; + this.game.worker + .tileContext(tile) + .then((ctx) => { + if (!this._isActive || seq !== this.hoverSeq) { + return; + } + + if (ctx.ownerId) { + try { + const owner = this.game.player(ctx.ownerId); + if (owner && owner.isPlayer()) { + this.player = owner; + this.player.profile().then((p) => { + if (this._isActive && seq === this.hoverSeq) { + this.playerProfile = p; + } + }); + this.setVisible(true); + } + } catch { + // ignore + } + return; + } + + this.isIrradiatedWilderness = ctx.hasFallout; + this.isWilderness = !ctx.hasFallout; + this.setVisible(true); + }) + .catch(() => { + // ignore hover failures + }); + return; + } + + // Water hover info can be derived from unit view data (already on main). + const units = this.game + .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) + .filter((u) => { + const dx = worldCoord.x - this.game.x(u.tile()); + const dy = worldCoord.y - this.game.y(u.tile()); + return Math.sqrt(dx * dx + dy * dy) < 50; + }) + .sort((a, b) => { + const dxA = worldCoord.x - this.game.x(a.tile()); + const dyA = worldCoord.y - this.game.y(a.tile()); + const dxB = worldCoord.x - this.game.x(b.tile()); + const dyB = worldCoord.y - this.game.y(b.tile()); + return dxA * dxA + dyA * dyA - (dxB * dxB + dyB * dyB); }); - this.setVisible(true); - } else if (info.isWilderness || info.isIrradiatedWilderness) { - this.isWilderness = info.isWilderness; - this.isIrradiatedWilderness = info.isIrradiatedWilderness; - this.setVisible(true); - } else if (info.unit) { - this.unit = info.unit; + + if (units.length > 0) { + this.unit = units[0]; this.setVisible(true); } } diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 20883f88e..cc37165bb 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -59,6 +59,7 @@ export class PlayerPanel extends LitElement implements Layer { private actions: PlayerActions | null = null; private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; + @state() private otherPlayer: PlayerView | null = null; @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -103,15 +104,29 @@ export class PlayerPanel extends LitElement implements Layer { async tick() { if (this.isVisible && this.tile) { - const owner = this.g.owner(this.tile); - if (owner && owner.isPlayer()) { - const pv = owner as PlayerView; - const id = pv.id(); + const tile = this.tile; + try { + const ctx = await this.g.worker.tileContext(tile); + if (!ctx.ownerId) { + this.hide(); + return; + } + const owner = this.g.player(ctx.ownerId); + if (!owner || !owner.isPlayer()) { + this.hide(); + return; + } + + this.otherPlayer = owner as PlayerView; + + const id = this.otherPlayer.id(); // fetch only if we don't have it or the player changed if (this._profileForPlayerId !== Number(id)) { - this.otherProfile = await pv.profile(); + this.otherProfile = await this.otherPlayer.profile(); this._profileForPlayerId = Number(id); } + } catch { + // If tile context fails (rare), keep the panel as-is. } // Refresh actions & alliance expiry @@ -142,6 +157,9 @@ export class PlayerPanel extends LitElement implements Layer { public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; + this.otherPlayer = null; + this.otherProfile = null; + this._profileForPlayerId = null; this.isVisible = true; this.requestUpdate(); } @@ -154,6 +172,7 @@ export class PlayerPanel extends LitElement implements Layer { this.suppressNextHide = true; this.actions = actions; this.tile = tile; + this.otherPlayer = target; this.sendTarget = target; this.sendMode = "gold"; this.isVisible = true; @@ -164,6 +183,11 @@ export class PlayerPanel extends LitElement implements Layer { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; + this.tile = null; + this.actions = null; + this.otherPlayer = null; + this.otherProfile = null; + this._profileForPlayerId = null; this.requestUpdate(); } @@ -815,13 +839,21 @@ export class PlayerPanel extends LitElement implements Layer { if (!my) return html``; if (!this.tile) return html``; - const owner = this.g.owner(this.tile); - if (!owner || !owner.isPlayer()) { - this.hide(); - console.warn("Tile is not owned by a player"); - return html``; + const other = this.otherPlayer; + if (!other) { + return html` +
+
+ ${translateText("loading")}… +
+
+ `; } - const other = owner as PlayerView; const myGoldNum = my.gold(); const myTroopsNum = Number(my.troops()); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 1dcf3eb90..af7e8e9a7 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -456,10 +456,9 @@ export const deleteUnitElement: MenuElement = { name: "delete", cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(), disabled: (params: MenuElementParams) => { - const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); - if (!tileOwner.isPlayer() || tileOwner.id() !== params.myPlayer.id()) { + if (!params.selected || params.selected.id() !== params.myPlayer.id()) { return true; } @@ -556,7 +555,6 @@ export const boatMenuElement: MenuElement = { export const centerButtonElement: CenterButtonElement = { disabled: (params: MenuElementParams): boolean => { - const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); if (!isLand) { return true; @@ -565,7 +563,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.config().isRandomSpawn()) { return true; } - if (tileOwner.isPlayer()) { + if (params.selected) { return true; } return false; @@ -614,10 +612,8 @@ export const rootMenuElement: MenuElement = { ally = allyBreakElement; } - const tileOwner = params.game.owner(params.tile); const isOwnTerritory = - tileOwner.isPlayer() && - (tileOwner as PlayerView).id() === params.myPlayer.id(); + params.selected !== null && params.selected.id() === params.myPlayer.id(); const menuItems: (MenuElement | null)[] = [ infoMenuElement, diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index eb354aa66..a7b2971ce 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -4,11 +4,8 @@ import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; -import { - AlternateViewEvent, - MouseOverEvent, - WebGPUComputeMetricsEvent, -} from "../../InputHandler"; +import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler"; +import { Canvas2DRendererProxy } from "../canvas2d/Canvas2DRendererProxy"; import { FrameProfiler } from "../FrameProfiler"; import { TransformHandler } from "../TransformHandler"; import { @@ -43,11 +40,15 @@ export class TerritoryLayer implements Layer { private theme: Theme; - private territoryRenderer: TerritoryRenderer | TerritoryRendererProxy | null = - null; + private territoryRenderer: + | TerritoryRenderer + | TerritoryRendererProxy + | Canvas2DRendererProxy + | null = null; private alternativeView = false; private lastPaletteSignature: string | null = null; + private lastPatternsEnabled: boolean | null = null; private lastDefensePostsSignature: string | null = null; private lastTerrainShaderSignature: string | null = null; private lastTerritoryShaderSignature: string | null = null; @@ -57,6 +58,7 @@ export class TerritoryLayer implements Layer { private lastMousePosition: { x: number; y: number } | null = null; private hoveredOwnerSmallId: number | null = null; private lastHoverUpdateMs = 0; + private hoverRequestSeq = 0; constructor( private game: GameView, @@ -98,20 +100,9 @@ export class TerritoryLayer implements Layer { this.applyTerritoryShaderSettings(); this.applyTerritorySmoothingSettings(); - const updatedTiles = this.game.recentlyUpdatedTiles(); - for (let i = 0; i < updatedTiles.length; i++) { - this.markTile(updatedTiles[i]); - } - - // After collecting pending updates and handling palette/theme changes, - // invoke the renderer's tick() to process compute passes. This ensures - // compute shaders run at the simulation rate rather than every frame. - if (this.territoryRenderer) { - const start = performance.now(); - this.territoryRenderer.tick(); - const computeMs = performance.now() - start; - this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs)); - } + // Renderer tick and dirty-tile marking are driven in the worker from + // simulation-derived tile updates (tileUpdateSink). The main thread only + // drives render frames + view transforms. FrameProfiler.end("TerritoryLayer:tick", tickProfile); } @@ -121,17 +112,33 @@ export class TerritoryLayer implements Layer { } private configureRenderer() { - // Use proxy to render in worker thread - const { renderer, reason } = TerritoryRendererProxy.create( - this.game, - this.theme, - this.game.worker, - ); + const backend = this.userSettings.backgroundRenderer(); + const create = (b: "webgpu" | "canvas2d") => + b === "canvas2d" + ? Canvas2DRendererProxy.create(this.game, this.theme, this.game.worker) + : TerritoryRendererProxy.create( + this.game, + this.theme, + this.game.worker, + ); + + let { renderer, reason } = create(backend); + if (!renderer && backend === "webgpu") { + // Graceful fallback: allow the game to run even without WebGPU. + console.warn( + `WebGPU renderer unavailable (${reason ?? "unknown"}); falling back to Canvas2D worker renderer.`, + ); + ({ renderer, reason } = create("canvas2d")); + } + if (!renderer) { - throw new Error(reason ?? "WebGPU is required for territory rendering."); + throw new Error(reason ?? "No supported background renderer available."); } this.territoryRenderer = renderer; + const patternsEnabled = this.userSettings.territoryPatterns(); + this.lastPatternsEnabled = patternsEnabled; + this.territoryRenderer.setPatternsEnabled(patternsEnabled); this.territoryRenderer.setAlternativeView(this.alternativeView); this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); this.applyTerrainShaderSettings(true); @@ -148,7 +155,7 @@ export class TerritoryLayer implements Layer { // Run an initial tick to upload state and build the colour texture. Without // this, the first render call may occur before the initial compute pass // has been executed, resulting in undefined colours. - this.territoryRenderer.tick(); + // Note: compute passes are ticked in the worker at simulation cadence. } renderLayer(context: CanvasRenderingContext2D) { @@ -165,6 +172,9 @@ export class TerritoryLayer implements Layer { } // Apply user settings even while the game is paused (settings modal). + this.refreshPaletteIfNeeded(); + this.refreshDefensePostsIfNeeded(); + this.applyTerrainShaderSettings(); this.applyTerritoryShaderSettings(); this.applyTerritorySmoothingSettings(); @@ -288,41 +298,96 @@ export class TerritoryLayer implements Layer { } this.lastHoverUpdateMs = now; - let nextOwnerSmallId: number | null = null; - if (this.lastMousePosition) { - const cell = this.transformHandler.screenToWorldCoordinates( - this.lastMousePosition.x, - this.lastMousePosition.y, - ); - if (this.game.isValidCoord(cell.x, cell.y)) { - const tile = this.game.ref(cell.x, cell.y); - const owner = this.game.owner(tile); - if (owner && owner.isPlayer()) { - nextOwnerSmallId = owner.smallID(); - } + if (!this.lastMousePosition) { + if (this.hoveredOwnerSmallId !== null) { + this.hoveredOwnerSmallId = null; + this.territoryRenderer.setHighlightedOwnerId(null); } - } - - if (nextOwnerSmallId === this.hoveredOwnerSmallId) { return; } - this.hoveredOwnerSmallId = nextOwnerSmallId; - this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId); + + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (!this.game.isValidCoord(cell.x, cell.y)) { + if (this.hoveredOwnerSmallId !== null) { + this.hoveredOwnerSmallId = null; + this.territoryRenderer.setHighlightedOwnerId(null); + } + return; + } + + const tile = this.game.ref(cell.x, cell.y); + const seq = ++this.hoverRequestSeq; + this.game.worker + .tileContext(tile) + .then((ctx) => { + if (seq !== this.hoverRequestSeq) { + return; + } + const nextOwnerSmallId = ctx.ownerSmallId; + if (nextOwnerSmallId === this.hoveredOwnerSmallId) { + return; + } + this.hoveredOwnerSmallId = nextOwnerSmallId; + this.territoryRenderer?.setHighlightedOwnerId(nextOwnerSmallId); + }) + .catch((err) => { + // Don't spam; hover is best-effort. + console.warn("tileContext hover lookup failed:", err); + }); } private computePaletteSignature(): string { - let maxSmallId = 0; - for (const player of this.game.playerViews()) { - maxSmallId = Math.max(maxSmallId, player.smallID()); - } const patternsEnabled = this.userSettings.territoryPatterns(); - return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; + const players = this.game.playerViews(); + + const fnvByte = (hash: number, byte: number): number => + Math.imul(hash ^ (byte & 0xff), 16777619) >>> 0; + + const fnv32 = (hash: number, value: number): number => { + hash = fnvByte(hash, value); + hash = fnvByte(hash, value >>> 8); + hash = fnvByte(hash, value >>> 16); + hash = fnvByte(hash, value >>> 24); + return hash; + }; + + let hash = patternsEnabled ? 2166136261 : 2166136262; + hash = fnv32(hash, players.length); + + let maxSmallId = 0; + for (const player of players) { + const id = player.smallID(); + maxSmallId = Math.max(maxSmallId, id); + + hash = fnv32(hash, id); + + const tc = player.territoryColor().rgba; + hash = fnvByte(hash, tc.r); + hash = fnvByte(hash, tc.g); + hash = fnvByte(hash, tc.b); + + const bc = player.borderColor().rgba; + hash = fnvByte(hash, bc.r); + hash = fnvByte(hash, bc.g); + hash = fnvByte(hash, bc.b); + } + hash = fnv32(hash, maxSmallId); + + return `${hash}`; } private refreshPaletteIfNeeded() { if (!this.territoryRenderer) { return; } + const patternsEnabled = this.userSettings.territoryPatterns(); + if (patternsEnabled !== this.lastPatternsEnabled) { + this.lastPatternsEnabled = patternsEnabled; + this.territoryRenderer.setPatternsEnabled(patternsEnabled); + } const signature = this.computePaletteSignature(); if (signature !== this.lastPaletteSignature) { this.lastPaletteSignature = signature; diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index 9aae56bf0..4a593b471 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -3,7 +3,10 @@ import { customElement, property, state } from "lit/decorators.js"; import { live } from "lit/directives/live.js"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; -import { WebGPUComputeMetricsEvent } from "../../InputHandler"; +import { + RefreshGraphicsEvent, + WebGPUComputeMetricsEvent, +} from "../../InputHandler"; import { TERRAIN_SHADER_KEY, TERRAIN_SHADERS, @@ -306,6 +309,8 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { return null; } + const backgroundRenderer = this.userSettings.backgroundRenderer(); + const shaderId = this.selectedShaderId(); const shader = TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0]; @@ -328,6 +333,31 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
WebGPU Debug
+
Renderer
+ +
+
Background
+ +
+
tick ms compute
diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index c3b0f84e6..5525ddbf7 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -271,6 +271,12 @@ export class TerritoryRenderer { this.resources.setAlternativeView(enabled); } + // Worker renderer needs this; on main thread this is currently a no-op beyond + // forcing a palette refresh (PlayerView colors are not recomputed dynamically). + setPatternsEnabled(_enabled: boolean): void { + this.refreshPalette(); + } + setHighlightedOwnerId(ownerSmallId: number | null): void { if (!this.resources) { return; diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts index b4a85333a..eb2563043 100644 --- a/src/client/graphics/webgpu/TerritoryRendererProxy.ts +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -12,6 +12,8 @@ import { RenderFrameMessage, SetAlternativeViewMessage, SetHighlightedOwnerMessage, + SetPaletteMessage, + SetPatternsEnabledMessage, SetShaderSettingsMessage, SetViewSizeMessage, SetViewTransformMessage, @@ -34,7 +36,7 @@ export class TerritoryRendererProxy { private ready = false; private failed = false; private initPromise: Promise | null = null; - private pendingMessages: any[] = []; + private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; private constructor( private readonly game: GameView, @@ -110,6 +112,7 @@ export class TerritoryRendererProxy { id: messageId, offscreenCanvas: this.offscreenCanvas, darkMode: darkMode, + backend: "webgpu", }; // Transfer the offscreen canvas @@ -135,8 +138,12 @@ export class TerritoryRendererProxy { this.ready = true; // Send any pending messages - for (const msg of this.pendingMessages) { - this.sendToWorker(msg); + for (const pending of this.pendingMessages) { + if (pending.transferables) { + this.worker?.postMessage(pending.message, pending.transferables); + } else { + this.sendToWorker(pending.message); + } } this.pendingMessages = []; resolve(); @@ -155,12 +162,26 @@ export class TerritoryRendererProxy { return; } if (!this.ready) { - this.pendingMessages.push(message); + this.pendingMessages.push({ message }); return; } this.worker.postMessage(message); } + private sendToWorkerWithTransfer(message: any, transferables: any[]): void { + if (!this.worker) { + return; + } + if (this.failed) { + return; + } + if (!this.ready) { + this.pendingMessages.push({ message, transferables }); + return; + } + this.worker.postMessage(message, transferables); + } + setViewSize(width: number, height: number): void { const message: SetViewSizeMessage = { type: "set_view_size", @@ -188,6 +209,14 @@ export class TerritoryRendererProxy { this.sendToWorker(message); } + setPatternsEnabled(enabled: boolean): void { + const message: SetPatternsEnabledMessage = { + type: "set_patterns_enabled", + enabled, + }; + this.sendToWorker(message); + } + setHighlightedOwnerId(ownerSmallId: number | null): void { const message: SetHighlightedOwnerMessage = { type: "set_highlighted_owner", @@ -284,10 +313,65 @@ export class TerritoryRendererProxy { } refreshPalette(): void { - const message: RefreshPaletteMessage = { - type: "refresh_palette", + if (!this.worker) { + return; + } + + // Build palette on the main thread to avoid order-dependent color allocator + // divergence between main and worker. + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + + const RESERVED = 10; + const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + // Fallout slot (index 0) + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tc = player.territoryColor().toRgb(); + row0[idx] = toByte(tc.r); + row0[idx + 1] = toByte(tc.g); + row0[idx + 2] = toByte(tc.b); + row0[idx + 3] = 255; + + const bc = player.borderColor().toRgb(); + row1[idx] = toByte(bc.r); + row1[idx + 1] = toByte(bc.g); + row1[idx + 2] = toByte(bc.b); + row1[idx + 3] = 255; + } + + const message: SetPaletteMessage = { + type: "set_palette", + paletteWidth, + maxSmallId, + row0, + row1, }; - this.sendToWorker(message); + + // Transfer buffers to avoid copies; arrays are rebuilt when needed. + this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]); + + // Back-compat: also mark palette dirty in worker for older code paths. + const fallback: RefreshPaletteMessage = { type: "refresh_palette" }; + this.sendToWorker(fallback); } markDefensePostsDirty(): void { diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 00daa40dc..ab36a30d7 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -75,6 +75,12 @@ export class GroundTruthData { private tickCount = 0; private readonly tickEmaAlpha = 0.2; private paletteWidth = 1; + private paletteOverride: { + paletteWidth: number; + maxSmallId: number; + row0: Uint8Array; + row1: Uint8Array; + } | null = null; private needsDefensePostsUpload = true; private defensePostsTotalCount = 0; private defendedDirtyTilesCount = 0; @@ -715,12 +721,65 @@ export class GroundTruthData { this.needsPaletteUpload = false; let maxSmallId = 0; - for (const player of this.game.playerViews()) { - maxSmallId = Math.max(maxSmallId, player.smallID()); + let nextPaletteWidth = 0; + let row0: Uint8Array | null = null; + let row1: Uint8Array | null = null; + + if (this.paletteOverride) { + maxSmallId = this.paletteOverride.maxSmallId; + nextPaletteWidth = this.paletteOverride.paletteWidth; + + const expectedRowStride = nextPaletteWidth * 4; + if ( + this.paletteOverride.row0.length === expectedRowStride && + this.paletteOverride.row1.length === expectedRowStride + ) { + row0 = this.paletteOverride.row0; + row1 = this.paletteOverride.row1; + } else { + // Malformed; fall back to local generation. + this.paletteOverride = null; + } } + + if (!row0 || !row1) { + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + nextPaletteWidth = + GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); + + const rowStride = nextPaletteWidth * 4; + row0 = new Uint8Array(rowStride); + row1 = new Uint8Array(rowStride); + + // Store special colors in reserved slots (0-9) + const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4; + row0[falloutIdx] = 120; + row0[falloutIdx + 1] = 255; + row0[falloutIdx + 2] = 71; + row0[falloutIdx + 3] = 255; + + // Store player colors starting at index 10 + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const rgba = player.territoryColor().rgba; + const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4; + row0[idx] = rgba.r; + row0[idx + 1] = rgba.g; + row0[idx + 2] = rgba.b; + row0[idx + 3] = 255; + + const borderRgba = player.borderColor().rgba; + row1[idx] = borderRgba.r; + row1[idx + 1] = borderRgba.g; + row1[idx + 2] = borderRgba.b; + row1[idx + 3] = 255; + } + } + this.paletteMaxSmallId = maxSmallId; - const nextPaletteWidth = - GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); let textureRecreated = false; if (nextPaletteWidth !== this.paletteWidth) { @@ -738,32 +797,10 @@ export class GroundTruthData { } const rowStride = this.paletteWidth * 4; - const row0 = new Uint8Array(rowStride); - const row1 = new Uint8Array(rowStride); - - // Store special colors in reserved slots (0-9) - const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4; - row0[falloutIdx] = 120; - row0[falloutIdx + 1] = 255; - row0[falloutIdx + 2] = 71; - row0[falloutIdx + 3] = 255; - - // Store player colors starting at index 10 - for (const player of this.game.playerViews()) { - const id = player.smallID(); - if (id <= 0) continue; - const rgba = player.territoryColor().rgba; - const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4; - row0[idx] = rgba.r; - row0[idx + 1] = rgba.g; - row0[idx + 2] = rgba.b; - row0[idx + 3] = 255; - - const borderRgba = player.borderColor().rgba; - row1[idx] = borderRgba.r; - row1[idx + 1] = borderRgba.g; - row1[idx + 2] = borderRgba.b; - row1[idx + 3] = 255; + if (row0.length !== rowStride || row1.length !== rowStride) { + throw new Error( + `Palette row size mismatch: expected ${rowStride} bytes, got ${row0.length}/${row1.length}`, + ); } const bytesPerRow = align(rowStride, 256); @@ -1250,10 +1287,29 @@ export class GroundTruthData { this.needsPaletteUpload = true; } + setPaletteOverride( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + this.paletteOverride = { paletteWidth, maxSmallId, row0, row1 }; + this.needsPaletteUpload = true; + } + markDefensePostsDirty(): void { this.needsDefensePostsUpload = true; } + markStateDirty(): void { + this.needsStateUpload = true; + } + + markDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = true; + this.defendedDirtyTilesCount = 0; + } + getState(): Uint16Array { return this.state; } diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 5e45612ea..b2ad4e20f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -86,6 +86,7 @@ export class GameRunner { private isExecuting = false; private playerViewData: Record = {}; + public tileUpdateSink?: (tile: TileRef) => void; constructor( public game: Game, @@ -166,12 +167,23 @@ export class GameRunner { } // Many tiles are updated to pack it into an array - const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update); + const tileUpdates = updates[GameUpdateType.Tile]; + 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); + } + packedTileUpdates = new BigUint64Array(0); + } else { + packedTileUpdates = new BigUint64Array(tileUpdates.map((u) => u.update)); + } updates[GameUpdateType.Tile] = []; this.callBack({ tick: this.game.ticks(), - packedTileUpdates: new BigUint64Array(packedTileUpdates), + packedTileUpdates, updates: updates, playerNameViewData: this.playerViewData, tickExecutionDuration: tickExecutionDuration, diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 83d7e7710..8536345d0 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -4,6 +4,16 @@ import { PlayerPattern } from "../Schemas"; const PATTERN_KEY = "territoryPattern"; export class UserSettings { + getString(key: string, defaultValue: string): string { + const value = localStorage.getItem(key); + if (value === null) return defaultValue; + return value; + } + + setString(key: string, value: string): void { + localStorage.setItem(key, value); + } + get(key: string, defaultValue: boolean): boolean { const value = localStorage.getItem(key); if (!value) return defaultValue; @@ -59,6 +69,15 @@ export class UserSettings { return this.get("settings.webgpuDebug", true); } + backgroundRenderer(): "webgpu" | "canvas2d" { + const raw = this.getString("settings.backgroundRenderer", "webgpu"); + return raw === "canvas2d" ? "canvas2d" : "webgpu"; + } + + setBackgroundRenderer(renderer: "webgpu" | "canvas2d"): void { + this.setString("settings.backgroundRenderer", renderer); + } + alertFrame() { return this.get("settings.alertFrame", true); } diff --git a/src/core/worker/DirtyTileQueue.ts b/src/core/worker/DirtyTileQueue.ts new file mode 100644 index 000000000..c9bb886ca --- /dev/null +++ b/src/core/worker/DirtyTileQueue.ts @@ -0,0 +1,80 @@ +import { TileRef } from "../game/GameMap"; + +/** + * Worker-local deduping dirty-tile queue. + * + * Mirrors the SAB branch "dirtyFlags + ring buffer" idea, but without Atomics + * (single-threaded within the worker). + */ +export class DirtyTileQueue { + private readonly dirtyFlags: Uint8Array; + private readonly queue: Uint32Array; + private head = 0; + private tail = 0; + private size = 0; + + constructor( + numTiles: number, + private readonly capacity: number, + ) { + this.dirtyFlags = new Uint8Array(numTiles); + this.queue = new Uint32Array(capacity); + } + + /** + * Mark a tile dirty (idempotent until drained). + * + * Returns `false` if the queue overflows. + */ + mark(tile: TileRef): boolean { + const idx = tile as unknown as number; + if (idx < 0 || idx >= this.dirtyFlags.length) { + return true; + } + if (this.dirtyFlags[idx] === 1) { + return true; + } + if (this.size >= this.capacity) { + return false; + } + this.dirtyFlags[idx] = 1; + this.queue[this.tail] = idx >>> 0; + this.tail = (this.tail + 1) % this.capacity; + this.size++; + return true; + } + + /** + * Drain up to `maxCount` dirty tiles. + * + * Clears the dirty flag for each returned tile. + */ + drain(maxCount: number): TileRef[] { + const count = Math.min(maxCount, this.size); + if (count === 0) { + return []; + } + const out: TileRef[] = new Array(count); + for (let i = 0; i < count; i++) { + const idx = this.queue[this.head]; + this.head = (this.head + 1) % this.capacity; + this.size--; + this.dirtyFlags[idx] = 0; + out[i] = idx as unknown as TileRef; + } + return out; + } + + clear(): void { + if (this.size > 0) { + this.dirtyFlags.fill(0); + } + this.head = 0; + this.tail = 0; + this.size = 0; + } + + pendingCount(): number { + return this.size; + } +} diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts index dba168ed7..7fc3eba3c 100644 --- a/src/core/worker/GameViewAdapter.ts +++ b/src/core/worker/GameViewAdapter.ts @@ -1,9 +1,11 @@ +import { Colord, colord } from "colord"; 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"; +import { ClientID, PlayerCosmetics } from "../Schemas"; /** * Adapter that makes Game work as GameView for rendering purposes. @@ -12,13 +14,20 @@ import { TerrainMapData } from "../game/TerrainMapLoader"; */ export class GameViewAdapter implements Partial { private lastUpdate: GameUpdateViewData | null = null; + private patternsEnabled = false; constructor( private game: Game, private mapData: TerrainMapData, private theme: Theme, + private readonly myClientId: ClientID | null, + private readonly cosmeticsByClientID: Map, ) {} + setPatternsEnabled(enabled: boolean): void { + this.patternsEnabled = enabled; + } + /** * Update adapter with latest game update data. * Invalidates caches so they're recomputed on next access. @@ -70,15 +79,53 @@ export class GameViewAdapter implements Partial { /** * Convert Game players to PlayerView-like objects for rendering. - * Computes colors from theme directly (no PlayerView needed). + * + * Important: this must match the *main-thread* PlayerView color selection, + * 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 territoryRgb = defaultTerritoryColor.toRgb(); - const borderRgb = defaultBorderColor.toRgb(); + + 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, diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 8d8ad642a..d74d8ab2d 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -3,10 +3,13 @@ import { Theme } from "../configuration/Config"; import { PastelTheme } from "../configuration/PastelTheme"; import { PastelThemeDark } from "../configuration/PastelThemeDark"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; +import { PlayerID } from "../game/Game"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader"; import { createGameRunner, GameRunner } from "../GameRunner"; -import { GameStartInfo } from "../Schemas"; +import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas"; +import { DirtyTileQueue } from "./DirtyTileQueue"; +import { WorkerCanvas2DRenderer } from "./WorkerCanvas2DRenderer"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -15,6 +18,7 @@ import { PlayerBorderTilesResultMessage, PlayerProfileResultMessage, RendererReadyMessage, + TileContextResultMessage, TransportShipSpawnResultMessage, WorkerMessage, } from "./WorkerMessages"; @@ -23,19 +27,37 @@ import { WorkerTerritoryRenderer } from "./WorkerTerritoryRenderer"; const ctx: Worker = self as any; let gameRunner: Promise | null = null; let gameStartInfo: GameStartInfo | null = null; +let myClientID: ClientID | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); -let renderer: WorkerTerritoryRenderer | null = null; +let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null; let mapData: TerrainMapData | null = null; +let dirtyTiles: DirtyTileQueue | null = null; +let dirtyTilesOverflow = false; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate if (!("updates" in gu)) { return; } - // Update renderer with game update - if (renderer) { - renderer.updateGameView(gu); + + // Flush simulation-derived dirty tiles into the renderer before running + // compute passes for this tick. + if (renderer && dirtyTiles) { + if (dirtyTilesOverflow) { + dirtyTilesOverflow = false; + dirtyTiles.clear(); + renderer.markAllDirty(); + } else { + const tiles = dirtyTiles.drain(dirtyTiles.pendingCount()); + for (const tile of tiles) { + renderer.markTile(tile); + } + } + + // Run compute passes at simulation tick cadence (not at render FPS). + renderer.tick(); } + sendMessage({ type: "game_update", gameUpdate: gu, @@ -56,12 +78,31 @@ ctx.addEventListener("message", async (e: MessageEvent) => { case "init": try { gameStartInfo = message.gameStartInfo; + myClientID = message.clientID; gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, ).then((gr) => { + const numTiles = gr.game.width() * gr.game.height(); + // Capacity is bounded; on overflow we fall back to markAllDirty(). + dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles)); + dirtyTilesOverflow = false; + + gr.tileUpdateSink = (tile) => { + if (!dirtyTiles) { + return; + } + const mark = (t: any) => { + if (!dirtyTiles!.mark(t)) { + dirtyTilesOverflow = true; + } + }; + mark(tile); + gr.game.forEachNeighbor(tile, (n) => mark(n)); + }; + sendMessage({ type: "initialized", id: message.id, @@ -88,6 +129,37 @@ ctx.addEventListener("message", async (e: MessageEvent) => { } break; + case "tile_context": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + try { + const gr = await gameRunner; + const tile = message.tile; + const hasOwner = gr.game.hasOwner(tile); + const ownerSmallId = hasOwner ? gr.game.ownerID(tile) : null; + let ownerId: PlayerID | null = null; + if (hasOwner) { + const owner = gr.game.owner(tile); + ownerId = owner && owner.isPlayer() ? owner.id() : null; + } + sendMessage({ + type: "tile_context_result", + id: message.id, + result: { + hasOwner, + ownerSmallId, + ownerId, + hasFallout: gr.game.hasFallout(tile), + isDefended: gr.game.isDefended(tile), + }, + } as TileContextResultMessage); + } catch (error) { + console.error("Failed to fetch tile context:", error); + throw error; + } + break; + case "player_actions": if (!gameRunner) { throw new Error("Game runner not initialized"); @@ -193,6 +265,9 @@ ctx.addEventListener("message", async (e: MessageEvent) => { } const gr = await gameRunner; + (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( @@ -207,9 +282,28 @@ ctx.addEventListener("message", async (e: MessageEvent) => { ? new PastelThemeDark() : new PastelTheme(); - renderer = new WorkerTerritoryRenderer(); + const cosmeticsByClientID = new Map(); + for (const p of gameStartInfo.players) { + cosmeticsByClientID.set( + p.clientID, + (p.cosmetics ?? {}) as PlayerCosmetics, + ); + } - await renderer.init(message.offscreenCanvas, gr, mapData, theme); + const backend = message.backend ?? "webgpu"; + renderer = + backend === "canvas2d" + ? new WorkerCanvas2DRenderer() + : new WorkerTerritoryRenderer(); + + await renderer.init( + message.offscreenCanvas, + gr, + mapData, + theme, + myClientID, + cosmeticsByClientID, + ); sendMessage({ type: "renderer_ready", @@ -228,6 +322,25 @@ ctx.addEventListener("message", async (e: MessageEvent) => { } break; + case "set_patterns_enabled": + if (renderer) { + renderer.setPatternsEnabled(message.enabled); + renderer.tick(); + } + break; + + case "set_palette": + if (renderer) { + renderer.setPaletteFromBytes( + message.paletteWidth, + message.maxSmallId, + message.row0, + message.row1, + ); + renderer.tick(); + } + break; + case "set_view_size": if (renderer) { renderer.setViewSize(message.width, message.height); @@ -258,33 +371,34 @@ ctx.addEventListener("message", async (e: MessageEvent) => { case "set_shader_settings": if (renderer) { + const r: any = renderer as any; if (message.territoryShader) { - renderer.setTerritoryShader(message.territoryShader); + r.setTerritoryShader?.(message.territoryShader); } if (message.territoryShaderParams0 && message.territoryShaderParams1) { - renderer.setTerritoryShaderParams( + r.setTerritoryShaderParams?.( message.territoryShaderParams0, message.territoryShaderParams1, ); } if (message.terrainShader) { - renderer.setTerrainShader(message.terrainShader); + r.setTerrainShader?.(message.terrainShader); } if (message.terrainShaderParams0 && message.terrainShaderParams1) { - renderer.setTerrainShaderParams( + r.setTerrainShaderParams?.( message.terrainShaderParams0, message.terrainShaderParams1, ); } if (message.preSmoothing) { - renderer.setPreSmoothing( + r.setPreSmoothing?.( message.preSmoothing.enabled, message.preSmoothing.shaderPath, message.preSmoothing.params0, ); } if (message.postSmoothing) { - renderer.setPostSmoothing( + r.setPostSmoothing?.( message.postSmoothing.enabled, message.postSmoothing.shaderPath, message.postSmoothing.params0, @@ -302,12 +416,14 @@ ctx.addEventListener("message", async (e: MessageEvent) => { case "mark_all_dirty": if (renderer) { renderer.markAllDirty(); + renderer.tick(); } break; case "refresh_palette": if (renderer) { renderer.refreshPalette(); + renderer.tick(); } break; diff --git a/src/core/worker/WorkerCanvas2DRenderer.ts b/src/core/worker/WorkerCanvas2DRenderer.ts new file mode 100644 index 000000000..cb1ae5080 --- /dev/null +++ b/src/core/worker/WorkerCanvas2DRenderer.ts @@ -0,0 +1,381 @@ +import { Theme } from "../configuration/Config"; +import { TileRef } from "../game/GameMap"; +import { TerrainMapData } from "../game/TerrainMapLoader"; +import { GameRunner } from "../GameRunner"; +import { ClientID, PlayerCosmetics } from "../Schemas"; +import { GameViewAdapter } from "./GameViewAdapter"; + +type Offscreen2D = OffscreenCanvasRenderingContext2D; + +export class WorkerCanvas2DRenderer { + private canvas: OffscreenCanvas | null = null; + private ctx: Offscreen2D | null = null; + + private rasterCanvas: OffscreenCanvas | null = null; + private rasterCtx: Offscreen2D | null = null; + private rasterImage: ImageData | null = null; + + private gameViewAdapter: GameViewAdapter | null = null; + private gameRunner: GameRunner | null = null; + private theme: Theme | null = null; + + private ready = false; + + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + + private readonly chunkSize = 64; + private chunksX = 1; + private chunksY = 1; + + private dirtyChunkFlags: Uint8Array = new Uint8Array(0); + private dirtyChunkQueue: Uint32Array = new Uint32Array(0); + private dirtyHead = 0; + private dirtyTail = 0; + private dirtyCapacity = 0; + + private paletteWidth = 1; + private paletteMaxSmallId = 0; + private paletteRow0: Uint8Array = new Uint8Array(4); + private paletteRow1: Uint8Array = new Uint8Array(4); + private hasExternalPalette = false; + + async init( + offscreenCanvas: OffscreenCanvas, + gameRunner: GameRunner, + mapData: TerrainMapData, + theme: Theme, + myClientID: ClientID | null, + cosmeticsByClientID: Map, + ): Promise { + this.canvas = offscreenCanvas; + this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D; + if (!this.ctx) { + throw new Error("Failed to get 2D context for OffscreenCanvas"); + } + + this.gameRunner = gameRunner; + this.theme = theme; + + this.gameViewAdapter = new GameViewAdapter( + gameRunner.game, + mapData, + theme, + myClientID, + cosmeticsByClientID, + ); + + const mapW = gameRunner.game.width(); + const mapH = gameRunner.game.height(); + this.rasterCanvas = new OffscreenCanvas(mapW, mapH); + this.rasterCtx = this.rasterCanvas.getContext("2d", { + alpha: true, + willReadFrequently: true, + }) as Offscreen2D; + if (!this.rasterCtx) { + throw new Error("Failed to get 2D context for raster canvas"); + } + + this.rasterImage = new ImageData(mapW, mapH); + + this.chunksX = Math.ceil(mapW / this.chunkSize); + this.chunksY = Math.ceil(mapH / this.chunkSize); + const numChunks = this.chunksX * this.chunksY; + + this.dirtyChunkFlags = new Uint8Array(numChunks); + // Chunk queue sized so markAllDirty() can enqueue every chunk. + this.dirtyCapacity = Math.max(1024, numChunks + 1); + this.dirtyChunkQueue = new Uint32Array(this.dirtyCapacity); + this.dirtyHead = 0; + this.dirtyTail = 0; + + this.ready = true; + + // First paint. + this.rebuildPaletteFromGame(); + this.markAllDirty(); + this.tick(); + } + + dispose(): void { + this.ready = false; + this.canvas = null; + this.ctx = null; + this.rasterCanvas = null; + this.rasterCtx = null; + this.rasterImage = null; + this.gameViewAdapter = null; + this.gameRunner = null; + this.theme = null; + this.dirtyChunkFlags = new Uint8Array(0); + this.dirtyChunkQueue = new Uint32Array(0); + this.dirtyHead = 0; + this.dirtyTail = 0; + this.dirtyCapacity = 0; + } + + setViewSize(width: number, height: number): void { + if (!this.canvas) return; + this.canvas.width = Math.max(1, Math.floor(width)); + this.canvas.height = Math.max(1, Math.floor(height)); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + } + + setAlternativeView(_enabled: boolean): void {} + setHighlightedOwnerId(_ownerSmallId: number | null): void {} + setPatternsEnabled(enabled: boolean): void { + this.gameViewAdapter?.setPatternsEnabled(enabled); + // Patterns affect colours; simplest is a full repaint. + if (!this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + } + this.markAllDirty(); + } + + setPaletteFromBytes( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + this.paletteWidth = paletteWidth; + this.paletteMaxSmallId = maxSmallId; + this.paletteRow0 = row0; + this.paletteRow1 = row1; + this.hasExternalPalette = true; + this.markAllDirty(); + } + + refreshPalette(): void { + if (!this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + } + this.markAllDirty(); + } + + refreshTerrain(): void { + this.markAllDirty(); + } + + markTile(tile: TileRef): void { + if (!this.ready || !this.gameRunner) return; + const x = this.gameRunner.game.x(tile); + const y = this.gameRunner.game.y(tile); + this.markChunkAt(x, y); + } + + markAllDirty(): void { + if (!this.ready) return; + this.dirtyChunkFlags.fill(0); + this.dirtyHead = 0; + this.dirtyTail = 0; + const numChunks = this.dirtyChunkFlags.length; + for (let i = 0; i < numChunks; i++) { + this.enqueueChunk(i); + } + } + + tick(): void { + if ( + !this.ready || + !this.gameRunner || + !this.theme || + !this.gameViewAdapter || + !this.rasterCtx || + !this.rasterImage + ) { + return; + } + + const mapW = this.gameRunner.game.width(); + const mapH = this.gameRunner.game.height(); + const data = this.rasterImage.data; + + const budgetMs = 6; + const start = performance.now(); + + while (this.dirtyHead !== this.dirtyTail) { + if (performance.now() - start > budgetMs) { + break; + } + + const chunkId = this.dirtyChunkQueue[this.dirtyHead]; + this.dirtyHead = (this.dirtyHead + 1) % this.dirtyCapacity; + this.dirtyChunkFlags[chunkId] = 0; + + const cx = chunkId % this.chunksX; + const cy = Math.floor(chunkId / this.chunksX); + const sx = cx * this.chunkSize; + const sy = cy * this.chunkSize; + const ex = Math.min(mapW, sx + this.chunkSize); + const ey = Math.min(mapH, sy + this.chunkSize); + + for (let y = sy; y < ey; y++) { + const row = y * mapW; + for (let x = sx; x < ex; x++) { + const tile = this.gameRunner.game.ref(x, y); + + let r = 0, + g = 0, + b = 0, + a = 255; + + if (this.gameRunner.game.hasFallout(tile)) { + const idx = 0; + r = this.paletteRow0[idx] ?? 120; + g = this.paletteRow0[idx + 1] ?? 255; + b = this.paletteRow0[idx + 2] ?? 71; + } else if (this.gameRunner.game.hasOwner(tile)) { + const ownerSmallId = this.gameRunner.game.ownerID(tile); + const slot = 10 + Math.max(0, ownerSmallId); + const idx = slot * 4; + if (idx + 2 < this.paletteRow0.length) { + r = this.paletteRow0[idx]; + g = this.paletteRow0[idx + 1]; + b = this.paletteRow0[idx + 2]; + } else { + const rgba = this.theme.terrainColor( + this.gameRunner.game, + tile, + ).rgba; + r = rgba.r; + g = rgba.g; + b = rgba.b; + a = rgba.a ?? 255; + } + } else { + const rgba = this.theme.terrainColor( + this.gameRunner.game, + tile, + ).rgba; + r = rgba.r; + g = rgba.g; + b = rgba.b; + a = rgba.a ?? 255; + } + + const p = (row + x) * 4; + data[p] = r; + data[p + 1] = g; + data[p + 2] = b; + data[p + 3] = a; + } + } + + this.rasterCtx.putImageData( + this.rasterImage, + 0, + 0, + sx, + sy, + ex - sx, + ey - sy, + ); + } + } + + render(): void { + if (!this.ready || !this.ctx || !this.gameRunner || !this.rasterCanvas) { + return; + } + + const w = (this.canvas?.width ?? 1) as number; + const h = (this.canvas?.height ?? 1) as number; + + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, w, h); + this.ctx.imageSmoothingEnabled = false; + + const scale = this.viewScale; + this.ctx.setTransform( + scale, + 0, + 0, + scale, + this.gameRunner.game.width() / 2 - this.viewOffsetX * scale, + this.gameRunner.game.height() / 2 - this.viewOffsetY * scale, + ); + + this.ctx.drawImage( + this.rasterCanvas, + -this.gameRunner.game.width() / 2, + -this.gameRunner.game.height() / 2, + ); + } + + private markChunkAt(x: number, y: number): void { + const cx = Math.floor(x / this.chunkSize); + const cy = Math.floor(y / this.chunkSize); + if (cx < 0 || cy < 0 || cx >= this.chunksX || cy >= this.chunksY) { + return; + } + const chunkId = cx + cy * this.chunksX; + this.enqueueChunk(chunkId); + } + + private enqueueChunk(chunkId: number): void { + if (this.dirtyChunkFlags[chunkId] === 1) { + return; + } + this.dirtyChunkFlags[chunkId] = 1; + this.dirtyChunkQueue[this.dirtyTail] = chunkId; + this.dirtyTail = (this.dirtyTail + 1) % this.dirtyCapacity; + if (this.dirtyTail === this.dirtyHead) { + // Overflow: fall back to repaint everything next tick. + this.markAllDirty(); + } + } + + private rebuildPaletteFromGame(): void { + if (!this.gameViewAdapter) { + return; + } + + let maxSmallId = 0; + const players = this.gameViewAdapter.playerViews(); + for (const p of players) { + maxSmallId = Math.max(maxSmallId, p.smallID()); + } + + const RESERVED = 10; + this.paletteMaxSmallId = maxSmallId; + this.paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = this.paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + for (const p of players) { + const id = p.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tr = p.territoryColor().rgba; + row0[idx] = tr.r; + row0[idx + 1] = tr.g; + row0[idx + 2] = tr.b; + row0[idx + 3] = 255; + + const br = p.borderColor().rgba; + row1[idx] = br.r; + row1[idx + 1] = br.g; + row1[idx + 2] = br.b; + row1[idx + 3] = 255; + } + + this.paletteRow0 = row0; + this.paletteRow1 = row1; + this.hasExternalPalette = false; + } +} diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 02cd537da..469b9ab84 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -9,7 +9,7 @@ import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; -import { WorkerMessage } from "./WorkerMessages"; +import { TileContext, WorkerMessage } from "./WorkerMessages"; export class WorkerClient { private worker: Worker; @@ -286,6 +286,29 @@ export class WorkerClient { }); } + tileContext(tile: TileRef): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if (message.type === "tile_context_result" && message.result) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "tile_context", + id: messageId, + tile, + }); + }); + } + cleanup() { this.worker.terminate(); this.messageHandlers.clear(); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 4aa752836..b2508e56b 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -14,6 +14,8 @@ export type WorkerMessageType = | "initialized" | "turn" | "game_update" + | "tile_context" + | "tile_context_result" | "player_actions" | "player_actions_result" | "player_profile" @@ -26,6 +28,8 @@ export type WorkerMessageType = | "transport_ship_spawn_result" | "init_renderer" | "renderer_ready" + | "set_patterns_enabled" + | "set_palette" | "set_view_size" | "set_view_transform" | "set_alternative_view" @@ -71,6 +75,24 @@ export interface GameUpdateMessage extends BaseWorkerMessage { gameUpdate: GameUpdateViewData; } +export interface TileContext { + hasOwner: boolean; + ownerSmallId: number | null; + ownerId: PlayerID | null; + hasFallout: boolean; + isDefended: boolean; +} + +export interface TileContextMessage extends BaseWorkerMessage { + type: "tile_context"; + tile: TileRef; +} + +export interface TileContextResultMessage extends BaseWorkerMessage { + type: "tile_context_result"; + result: TileContext; +} + export interface PlayerActionsMessage extends BaseWorkerMessage { type: "player_actions"; playerID: PlayerID; @@ -131,6 +153,20 @@ export interface InitRendererMessage extends BaseWorkerMessage { type: "init_renderer"; offscreenCanvas: OffscreenCanvas; darkMode: boolean; // Whether to use dark theme + backend?: "webgpu" | "canvas2d"; +} + +export interface SetPatternsEnabledMessage extends BaseWorkerMessage { + type: "set_patterns_enabled"; + enabled: boolean; +} + +export interface SetPaletteMessage extends BaseWorkerMessage { + type: "set_palette"; + paletteWidth: number; + maxSmallId: number; + row0: Uint8Array; + row1: Uint8Array; } export interface SetViewSizeMessage extends BaseWorkerMessage { @@ -218,12 +254,15 @@ export type MainThreadMessage = | HeartbeatMessage | InitMessage | TurnMessage + | TileContextMessage | PlayerActionsMessage | PlayerProfileMessage | PlayerBorderTilesMessage | AttackAveragePositionMessage | TransportShipSpawnMessage | InitRendererMessage + | SetPatternsEnabledMessage + | SetPaletteMessage | SetViewSizeMessage | SetViewTransformMessage | SetAlternativeViewMessage @@ -240,6 +279,7 @@ export type MainThreadMessage = export type WorkerMessage = | InitializedMessage | GameUpdateMessage + | TileContextResultMessage | PlayerActionsResultMessage | PlayerProfileResultMessage | PlayerBorderTilesResultMessage diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts index 9b960b784..2643944d8 100644 --- a/src/core/worker/WorkerTerritoryRenderer.ts +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -3,6 +3,7 @@ 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"; // Import rendering components from client (they should work with adapter) @@ -61,6 +62,7 @@ export class WorkerTerritoryRenderer { private preSmoothingEnabled = false; private postSmoothingEnabled = false; private defensePostRange: number; + private patternsEnabled = false; /** * Initialize renderer with offscreen canvas and game data. @@ -70,13 +72,22 @@ export class WorkerTerritoryRenderer { gameRunner: GameRunner, mapData: TerrainMapData, theme: Theme, + myClientID: ClientID | null, + cosmeticsByClientID: Map, ): Promise { this.canvas = offscreenCanvas; const game = gameRunner.game; this.defensePostRange = game.config().defensePostRange(); // Create adapter - this.gameViewAdapter = new GameViewAdapter(game, mapData, theme); + this.gameViewAdapter = new GameViewAdapter( + game, + mapData, + theme, + myClientID, + cosmeticsByClientID, + ); + this.gameViewAdapter.setPatternsEnabled(this.patternsEnabled); // Initialize WebGPU device with offscreen canvas const webgpuDevice = await WebGPUDevice.create(offscreenCanvas); @@ -265,6 +276,13 @@ export class WorkerTerritoryRenderer { this.resources.setHighlightedOwnerId(ownerSmallId); } + setPatternsEnabled(enabled: boolean): void { + this.patternsEnabled = enabled; + this.gameViewAdapter?.setPatternsEnabled(enabled); + this.resources?.markPaletteDirty(); + this.resources?.invalidateHistory(); + } + setTerritoryShader(shaderPath: string): void { this.territoryShaderPath = shaderPath; if (this.territoryRenderPass) { @@ -402,7 +420,18 @@ export class WorkerTerritoryRenderer { } markAllDirty(): void { - this.resources?.markDefensePostsDirty(); + if (!this.resources) { + return; + } + // Full sync points used when the dirty-tile pipeline overflows or when + // global settings require a complete rebuild. + this.resources.markStateDirty(); + this.resources.markDefensePostsDirty(); + this.resources.markDefendedFullRecompute(); + this.resources.markPaletteDirty(); + this.resources.invalidateHistory(); + + this.terrainComputePass?.markDirty(); } refreshPalette(): void { @@ -412,6 +441,19 @@ export class WorkerTerritoryRenderer { this.resources.markPaletteDirty(); } + setPaletteFromBytes( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + if (!this.resources) { + return; + } + this.resources.setPaletteOverride(paletteWidth, maxSmallId, row0, row1); + this.resources.invalidateHistory(); + } + markDefensePostsDirty(): void { if (!this.resources) { return; @@ -430,6 +472,26 @@ export class WorkerTerritoryRenderer { } } + dispose(): void { + this.ready = false; + this.computePasses = []; + this.computePassOrder = []; + this.frameComputePasses = []; + this.renderPasses = []; + this.renderPassOrder = []; + this.terrainComputePass = null; + this.stateUpdatePass = null; + this.defendedStrengthFullPass = null; + this.defendedStrengthPass = null; + this.visualStateSmoothingPass = null; + this.territoryRenderPass = null; + this.temporalResolvePass = null; + this.resources = null; + this.gameViewAdapter = null; + this.device = null; + this.canvas = null; + } + private computeTerrainImmediate(): void { if ( !this.ready ||