diff --git a/index.html b/index.html
index 800aaec144..7ccf8cb14d 100644
--- a/index.html
+++ b/index.html
@@ -274,6 +274,7 @@
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 6f06528dc1..e4ed2b701b 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -526,6 +526,8 @@
"attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)",
"territory_patterns_label": "🏳️ Territory Skins",
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
+ "territory_border_mode_label": "Territory Borders",
+ "territory_border_mode_desc": "Select border rendering style (visual only)",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index ef0981bebf..2d1e06639b 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -9,6 +9,7 @@ import {
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
+ Turn,
} from "../core/Schemas";
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
@@ -35,7 +36,9 @@ import {
InputHandler,
MouseMoveEvent,
MouseUpEvent,
+ SetWorkerDebugEvent,
TickMetricsEvent,
+ WorkerMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@@ -226,6 +229,12 @@ async function createClientGame(
lobbyConfig.clientID,
);
await worker.initialize();
+ worker.onWorkerMetrics((metrics) => {
+ eventBus.emit(new WorkerMetricsEvent(metrics));
+ });
+ eventBus.on(SetWorkerDebugEvent, (event: SetWorkerDebugEvent) => {
+ worker.setWorkerDebug(event.config);
+ });
const gameView = new GameView(
worker,
config,
@@ -386,15 +395,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);
@@ -436,20 +436,43 @@ export class ClientGameRunner {
goToPlayer();
}
- for (const turn of message.turns) {
+ const normalizeTurn = (turn: Turn): Turn =>
+ this.gameView.config().isReplay()
+ ? {
+ ...turn,
+ intents: turn.intents.filter((i) => i.type !== "toggle_pause"),
+ }
+ : turn;
+
+ // Firefox in particular suffers from a storm of thousands of tiny
+ // postMessage() calls on reconnect. Batch turns to keep the worker
+ // event loop responsive for render_frame and sim scheduling.
+ const batchSize = 256;
+ let batch: Turn[] = [];
+ const flush = () => {
+ if (batch.length === 0) return;
+ this.worker.sendTurnBatch(batch);
+ batch = [];
+ };
+
+ for (const rawTurn of message.turns as Turn[]) {
+ const turn = normalizeTurn(rawTurn);
if (turn.turnNumber < this.turnsSeen) {
continue;
}
while (turn.turnNumber - 1 > this.turnsSeen) {
- this.worker.sendTurn({
+ batch.push({
turnNumber: this.turnsSeen,
intents: [],
});
this.turnsSeen++;
+ if (batch.length >= batchSize) flush();
}
- this.worker.sendTurn(turn);
+ batch.push(turn);
this.turnsSeen++;
+ if (batch.length >= batchSize) flush();
}
+ flush();
}
if (message.type === "desync") {
if (this.lobby.gameStartInfo === undefined) {
@@ -543,11 +566,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()) {
@@ -561,12 +592,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);
}
@@ -677,12 +718,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/InputHandler.ts b/src/client/InputHandler.ts
index 45d1188a3b..be6b9c14f0 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -2,6 +2,7 @@ import { EventBus, GameEvent } from "../core/EventBus";
import { UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
+import type { WorkerMetricsMessage } from "../core/worker/WorkerMessages";
import { UIState } from "./graphics/UIState";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
@@ -81,6 +82,20 @@ export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
+export class SetWorkerDebugEvent implements GameEvent {
+ constructor(
+ public readonly config: {
+ enabled: boolean;
+ intervalMs?: number;
+ includeTrace?: boolean;
+ },
+ ) {}
+}
+
+export class WorkerMetricsEvent implements GameEvent {
+ constructor(public readonly metrics: WorkerMetricsMessage) {}
+}
+
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureTypes: UnitType[] | null) {}
}
@@ -136,6 +151,10 @@ export class TickMetricsEvent implements GameEvent {
) {}
}
+export class WebGPUComputeMetricsEvent implements GameEvent {
+ constructor(public readonly computeMs: number) {}
+}
+
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 70f4251f80..4e27a42c37 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -5,6 +5,7 @@ import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
+import "./components/baseComponents/setting/SettingSelect";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
diff --git a/src/client/components/baseComponents/setting/SettingSelect.ts b/src/client/components/baseComponents/setting/SettingSelect.ts
new file mode 100644
index 0000000000..0dbbe41f6f
--- /dev/null
+++ b/src/client/components/baseComponents/setting/SettingSelect.ts
@@ -0,0 +1,62 @@
+import { LitElement, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+export type SettingSelectOption = { value: string; label: string };
+
+@customElement("setting-select")
+export class SettingSelect extends LitElement {
+ @property() label = "Setting";
+ @property() description = "";
+ @property() id = "";
+ @property() value = "";
+ @property({ attribute: false }) options: SettingSelectOption[] = [];
+ @property({ type: Boolean }) easter = false;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ private handleChange(e: Event) {
+ const select = e.target as HTMLSelectElement;
+ this.value = select.value;
+ this.dispatchEvent(
+ new CustomEvent("change", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ render() {
+ const rainbowClass = this.easter
+ ? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
+ : "";
+
+ return html`
+
+
+
+ ${this.label}
+
+
+ ${this.description}
+
+
+
+
+
+ `;
+ }
+}
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 0a0e8b5cf8..2fc66d3ec3 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -38,11 +38,11 @@ import { SpawnVideoAd } from "./layers/SpawnVideoReward";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
-import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
+import { WebGPUDebugOverlay } from "./layers/WebGPUDebugOverlay";
import { WinModal } from "./layers/WinModal";
export function createRenderer(
@@ -224,6 +224,16 @@ export function createRenderer(
performanceOverlay.eventBus = eventBus;
performanceOverlay.userSettings = userSettings;
+ const webgpuDebugOverlay = document.querySelector(
+ "webgpu-debug-overlay",
+ ) as WebGPUDebugOverlay;
+ if (!(webgpuDebugOverlay instanceof WebGPUDebugOverlay)) {
+ console.error("webgpu debug overlay not found");
+ }
+ webgpuDebugOverlay.eventBus = eventBus;
+ webgpuDebugOverlay.userSettings = userSettings;
+ webgpuDebugOverlay.requestUpdate();
+
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
@@ -263,7 +273,6 @@ export function createRenderer(
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
- new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler),
structureLayer,
@@ -306,6 +315,7 @@ export function createRenderer(
spawnVideoAd,
alertFrame,
performanceOverlay,
+ webgpuDebugOverlay,
];
return new GameRenderer(
@@ -316,6 +326,7 @@ export function createRenderer(
uiState,
layers,
performanceOverlay,
+ webgpuDebugOverlay,
);
}
@@ -331,8 +342,10 @@ export class GameRenderer {
public uiState: UIState,
private layers: Layer[],
private performanceOverlay: PerformanceOverlay,
+ private webgpuDebugOverlay: WebGPUDebugOverlay,
) {
- const context = canvas.getContext("2d", { alpha: false });
+ // Keep the main canvas transparent; the WebGPU territory canvas renders the background.
+ const context = canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
}
@@ -380,13 +393,8 @@ export class GameRenderer {
renderGame() {
FrameProfiler.clear();
const start = performance.now();
- // Set background
- this.context.fillStyle = this.game
- .config()
- .theme()
- .backgroundColor()
- .toHex();
- this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
+ // Clear overlay canvas to transparent; the territory WebGPU canvas draws the base.
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
const handleTransformState = (
needsTransform: boolean,
@@ -424,6 +432,7 @@ export class GameRenderer {
const layerDurations = FrameProfiler.consume();
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
+ this.webgpuDebugOverlay.updateFrameMetrics(duration);
if (duration > 50) {
console.warn(
diff --git a/src/client/graphics/HoverInfo.ts b/src/client/graphics/HoverInfo.ts
new file mode 100644
index 0000000000..c99f25777f
--- /dev/null
+++ b/src/client/graphics/HoverInfo.ts
@@ -0,0 +1,73 @@
+import { UnitType } from "../../core/game/Game";
+import { TileRef } from "../../core/game/GameMap";
+import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
+
+export type HoverInfo = {
+ player: PlayerView | null;
+ unit: UnitView | null;
+ isWilderness: boolean;
+ isIrradiatedWilderness: boolean;
+};
+
+function euclideanDistWorld(
+ coord: { x: number; y: number },
+ tileRef: TileRef,
+ game: GameView,
+): number {
+ const x = game.x(tileRef);
+ const y = game.y(tileRef);
+ const dx = coord.x - x;
+ const dy = coord.y - y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
+ return (a: UnitView, b: UnitView) => {
+ const distA = euclideanDistWorld(coord, a.tile(), game);
+ const distB = euclideanDistWorld(coord, b.tile(), game);
+ return distA - distB;
+ };
+}
+
+export function getHoverInfo(
+ game: GameView,
+ worldCoord: { x: number; y: number },
+): HoverInfo {
+ const info: HoverInfo = {
+ player: null,
+ unit: null,
+ isWilderness: false,
+ isIrradiatedWilderness: false,
+ };
+
+ if (!game.isValidCoord(worldCoord.x, worldCoord.y)) {
+ return info;
+ }
+
+ const tile = game.ref(worldCoord.x, worldCoord.y);
+ const owner = game.owner(tile);
+
+ if (owner && owner.isPlayer()) {
+ info.player = owner as PlayerView;
+ return info;
+ }
+
+ if (owner && !owner.isPlayer() && game.isLand(tile)) {
+ info.isIrradiatedWilderness = game.hasFallout(tile);
+ info.isWilderness = !info.isIrradiatedWilderness;
+ return info;
+ }
+
+ if (!game.isLand(tile)) {
+ const units = game
+ .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
+ .filter((u) => euclideanDistWorld(worldCoord, u.tile(), game) < 50)
+ .sort(distSortUnitWorld(worldCoord, game));
+
+ if (units.length > 0) {
+ info.unit = units[0];
+ }
+ }
+
+ return info;
+}
diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts
index d5e86315a5..8962724801 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/TransformHandler.ts b/src/client/graphics/TransformHandler.ts
index 6cf43cd774..a472c691d9 100644
--- a/src/client/graphics/TransformHandler.ts
+++ b/src/client/graphics/TransformHandler.ts
@@ -45,6 +45,14 @@ export class TransformHandler {
return this._boundingRect;
}
+ getOffsetX(): number {
+ return this.offsetX;
+ }
+
+ getOffsetY(): number {
+ return this.offsetY;
+ }
+
width(): number {
return this.boundingRect().width;
}
diff --git a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts
new file mode 100644
index 0000000000..474627fb9c
--- /dev/null
+++ b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts
@@ -0,0 +1,434 @@
+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,
+ ViewSize,
+ ViewTransform,
+} 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 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 renderInFlight = false;
+ private renderSeq = 0;
+ private renderCooldownUntilMs = 0;
+
+ 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.renderInFlight = false;
+ 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 {
+ this.viewSize = {
+ width: Math.max(1, Math.floor(width)),
+ height: Math.max(1, Math.floor(height)),
+ };
+ }
+
+ setViewTransform(scale: number, offsetX: number, offsetY: number): void {
+ this.viewTransform = { scale, offsetX, offsetY };
+ }
+
+ 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 {
+ // No-op: worker renderer ticks from worker-side game_update.
+ }
+
+ render(): void {
+ if (this.failed) {
+ return;
+ }
+ if (performance.now() < this.renderCooldownUntilMs) {
+ return;
+ }
+ if (this.renderInFlight) {
+ return;
+ }
+
+ this.renderInFlight = true;
+ const renderId = `render_${++this.renderSeq}`;
+ const sentAtWallMs = Date.now();
+
+ const message: RenderFrameMessage = { type: "render_frame" };
+ message.id = renderId;
+ message.sentAtWallMs = sentAtWallMs;
+
+ 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;
+ }
+
+ const worker = this.worker;
+ if (worker) {
+ const timeout = setTimeout(() => {
+ if (!this.renderInFlight) {
+ worker.removeMessageHandler(renderId);
+ return;
+ }
+
+ console.warn(`render_done timeout (${renderId})`);
+ worker.removeMessageHandler(renderId);
+
+ this.renderInFlight = false;
+ this.renderCooldownUntilMs = performance.now() + 250;
+ this.lastSentViewSize = null;
+ this.lastSentViewTransform = null;
+ }, 15000);
+
+ worker.addMessageHandler(renderId, (m: any) => {
+ if (m?.type !== "render_done") {
+ return;
+ }
+ clearTimeout(timeout);
+ const startedAt = typeof m.startedAt === "number" ? m.startedAt : NaN;
+ const endedAt = typeof m.endedAt === "number" ? m.endedAt : NaN;
+ const startedAtWallMs =
+ typeof m.startedAtWallMs === "number" ? m.startedAtWallMs : NaN;
+ const endedAtWallMs =
+ typeof m.endedAtWallMs === "number" ? m.endedAtWallMs : NaN;
+ const echoedSentAtWallMs =
+ typeof m.sentAtWallMs === "number" ? m.sentAtWallMs : sentAtWallMs;
+ if (
+ Number.isFinite(startedAt) &&
+ Number.isFinite(endedAt) &&
+ Number.isFinite(startedAtWallMs) &&
+ Number.isFinite(endedAtWallMs) &&
+ Number.isFinite(echoedSentAtWallMs)
+ ) {
+ const queueMs = startedAtWallMs - echoedSentAtWallMs;
+ const renderMs = endedAt - startedAt;
+ const totalMs = endedAtWallMs - echoedSentAtWallMs;
+ const breakdown =
+ typeof m.renderCpuMs === "number" ||
+ typeof m.renderGpuWaitMs === "number" ||
+ typeof m.renderWaitPrevGpuMs === "number" ||
+ typeof m.renderGetTextureMs === "number"
+ ? {
+ waitPrevGpuMs:
+ typeof m.renderWaitPrevGpuMs === "number"
+ ? Math.round(m.renderWaitPrevGpuMs)
+ : undefined,
+ waitPrevGpuTimedOut:
+ typeof m.renderWaitPrevGpuTimedOut === "boolean"
+ ? m.renderWaitPrevGpuTimedOut
+ : undefined,
+ cpuMs:
+ typeof m.renderCpuMs === "number"
+ ? Math.round(m.renderCpuMs)
+ : undefined,
+ getTextureMs:
+ typeof m.renderGetTextureMs === "number"
+ ? Math.round(m.renderGetTextureMs)
+ : undefined,
+ gpuWaitMs:
+ typeof m.renderGpuWaitMs === "number"
+ ? Math.round(m.renderGpuWaitMs)
+ : undefined,
+ gpuWaitTimedOut:
+ typeof m.renderGpuWaitTimedOut === "boolean"
+ ? m.renderGpuWaitTimedOut
+ : undefined,
+ }
+ : undefined;
+ if (totalMs > 1000 || queueMs > 1000 || renderMs > 1000) {
+ console.warn("worker render timing", {
+ id: renderId,
+ queueMs: Math.round(queueMs),
+ renderMs: Math.round(renderMs),
+ totalMs: Math.round(totalMs),
+ breakdown,
+ });
+ }
+ }
+ this.renderInFlight = false;
+ });
+ } else {
+ this.renderInFlight = false;
+ return;
+ }
+
+ this.sendToWorker(message);
+ }
+}
diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts
index ef3547e41b..17ed8a5fba 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 989b5aa797..8d81054377 100644
--- a/src/client/graphics/layers/MainRadialMenu.ts
+++ b/src/client/graphics/layers/MainRadialMenu.ts
@@ -114,8 +114,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/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index ad27aeaa53..4f8150e452 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -2,9 +2,12 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
+import type { WorkerMetricsMessage } from "../../../core/worker/WorkerMessages";
import {
+ SetWorkerDebugEvent,
TickMetricsEvent,
TogglePerformanceOverlayEvent,
+ WorkerMetricsEvent,
} from "../../InputHandler";
import { translateText } from "../../Utils";
import { FrameProfiler } from "../FrameProfiler";
@@ -42,6 +45,18 @@ export class PerformanceOverlay extends LitElement implements Layer {
@state()
private isVisible: boolean = false;
+ @state()
+ private workerMetrics: WorkerMetricsMessage | null = null;
+
+ @state()
+ private workerMetricsAgeMs: number = 0;
+
+ @state()
+ private workerIncludeTrace: boolean = false;
+
+ @state()
+ private workerIntervalMs: number = 1000;
+
@state()
private isDragging: boolean = false;
@@ -60,6 +75,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
private tickExecutionTimes: number[] = [];
private tickDelayTimes: number[] = [];
+ private lastWorkerMetricsWallMs: number = 0;
private copyStatusTimeoutId: ReturnType | null = null;
@@ -232,11 +248,24 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
});
+ this.eventBus.on(WorkerMetricsEvent, (event: WorkerMetricsEvent) => {
+ this.workerMetrics = event.metrics;
+ this.lastWorkerMetricsWallMs = Date.now();
+ this.workerMetricsAgeMs = 0;
+ this.requestUpdate();
+ });
}
setVisible(visible: boolean) {
this.isVisible = visible;
FrameProfiler.setEnabled(visible);
+ this.eventBus.emit(
+ new SetWorkerDebugEvent({
+ enabled: visible,
+ intervalMs: this.workerIntervalMs,
+ includeTrace: this.workerIncludeTrace,
+ }),
+ );
}
private handleClose() {
@@ -326,10 +355,21 @@ export class PerformanceOverlay extends LitElement implements Layer {
// Update FrameProfiler enabled state when visibility changes
if (wasVisible !== this.isVisible) {
FrameProfiler.setEnabled(this.isVisible);
+ this.eventBus.emit(
+ new SetWorkerDebugEvent({
+ enabled: this.isVisible,
+ intervalMs: this.workerIntervalMs,
+ includeTrace: this.workerIncludeTrace,
+ }),
+ );
}
if (!this.isVisible) return;
+ if (this.lastWorkerMetricsWallMs > 0) {
+ this.workerMetricsAgeMs = Date.now() - this.lastWorkerMetricsWallMs;
+ }
+
const now = performance.now();
// Initialize timing on first call
@@ -486,10 +526,220 @@ export class PerformanceOverlay extends LitElement implements Layer {
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
+ worker: {
+ enabled: this.isVisible,
+ includeTrace: this.workerIncludeTrace,
+ intervalMs: this.workerIntervalMs,
+ lastMetricsAgeMs: this.workerMetricsAgeMs,
+ metrics: this.workerMetrics,
+ },
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
+ private getWorkerKeyStats(metrics: WorkerMetricsMessage | null): {
+ intervalMs: number;
+ loopLagAvg: number;
+ loopLagMax: number;
+ simDelayAvg: number;
+ simDelayMax: number;
+ simExecAvg: number;
+ simExecMax: number;
+ rfQueueAvg: number | null;
+ rfQueueMax: number | null;
+ rfHandlerAvg: number | null;
+ rfHandlerMax: number | null;
+ renderSubmittedCount: number | null;
+ renderNoopCount: number | null;
+ renderCpuTotalAvg: number | null;
+ renderCpuTotalMax: number | null;
+ renderGetTextureAvg: number | null;
+ renderGetTextureMax: number | null;
+ renderFrameComputeAvg: number | null;
+ renderFrameComputeMax: number | null;
+ renderTerritoryPassAvg: number | null;
+ renderTerritoryPassMax: number | null;
+ renderTemporalResolveAvg: number | null;
+ renderTemporalResolveMax: number | null;
+ renderSubmitAvg: number | null;
+ renderSubmitMax: number | null;
+ traceLines: string[];
+ topMsgs: Array<{
+ type: string;
+ count: number;
+ queueAvg: number | null;
+ queueMax: number | null;
+ handlerAvg: number | null;
+ handlerMax: number | null;
+ }>;
+ } {
+ if (!metrics) {
+ return {
+ intervalMs: 0,
+ loopLagAvg: 0,
+ loopLagMax: 0,
+ simDelayAvg: 0,
+ simDelayMax: 0,
+ simExecAvg: 0,
+ simExecMax: 0,
+ rfQueueAvg: null,
+ rfQueueMax: null,
+ rfHandlerAvg: null,
+ rfHandlerMax: null,
+ renderSubmittedCount: null,
+ renderNoopCount: null,
+ renderCpuTotalAvg: null,
+ renderCpuTotalMax: null,
+ renderGetTextureAvg: null,
+ renderGetTextureMax: null,
+ renderFrameComputeAvg: null,
+ renderFrameComputeMax: null,
+ renderTerritoryPassAvg: null,
+ renderTerritoryPassMax: null,
+ renderTemporalResolveAvg: null,
+ renderTemporalResolveMax: null,
+ renderSubmitAvg: null,
+ renderSubmitMax: null,
+ traceLines: [],
+ topMsgs: [],
+ };
+ }
+
+ const rfQueueAvg = metrics.msgQueueMsAvg?.["render_frame"];
+ const rfQueueMax = metrics.msgQueueMsMax?.["render_frame"];
+ const rfHandlerAvg = metrics.msgHandlerMsAvg?.["render_frame"];
+ const rfHandlerMax = metrics.msgHandlerMsMax?.["render_frame"];
+ const traceLines =
+ metrics.trace && metrics.trace.length > 0 ? metrics.trace.slice(-5) : [];
+
+ const topMsgs = Object.entries(metrics.msgCounts ?? {})
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 6)
+ .map(([type, count]) => ({
+ type,
+ count,
+ queueAvg:
+ typeof metrics.msgQueueMsAvg?.[type] === "number"
+ ? metrics.msgQueueMsAvg[type]
+ : null,
+ queueMax:
+ typeof metrics.msgQueueMsMax?.[type] === "number"
+ ? metrics.msgQueueMsMax[type]
+ : null,
+ handlerAvg:
+ typeof metrics.msgHandlerMsAvg?.[type] === "number"
+ ? metrics.msgHandlerMsAvg[type]
+ : null,
+ handlerMax:
+ typeof metrics.msgHandlerMsMax?.[type] === "number"
+ ? metrics.msgHandlerMsMax[type]
+ : null,
+ }));
+
+ return {
+ intervalMs: metrics.intervalMs,
+ loopLagAvg: metrics.eventLoopLagMsAvg,
+ loopLagMax: metrics.eventLoopLagMsMax,
+ simDelayAvg: metrics.simPumpDelayMsAvg,
+ simDelayMax: metrics.simPumpDelayMsMax,
+ simExecAvg: metrics.simPumpExecMsAvg,
+ simExecMax: metrics.simPumpExecMsMax,
+ rfQueueAvg: typeof rfQueueAvg === "number" ? rfQueueAvg : null,
+ rfQueueMax: typeof rfQueueMax === "number" ? rfQueueMax : null,
+ rfHandlerAvg: typeof rfHandlerAvg === "number" ? rfHandlerAvg : null,
+ rfHandlerMax: typeof rfHandlerMax === "number" ? rfHandlerMax : null,
+ renderSubmittedCount:
+ typeof metrics.renderSubmittedCount === "number"
+ ? metrics.renderSubmittedCount
+ : null,
+ renderNoopCount:
+ typeof metrics.renderNoopCount === "number"
+ ? metrics.renderNoopCount
+ : null,
+ renderCpuTotalAvg:
+ typeof metrics.renderCpuTotalMsAvg === "number"
+ ? metrics.renderCpuTotalMsAvg
+ : null,
+ renderCpuTotalMax:
+ typeof metrics.renderCpuTotalMsMax === "number"
+ ? metrics.renderCpuTotalMsMax
+ : null,
+ renderGetTextureAvg:
+ typeof metrics.renderGetTextureMsAvg === "number"
+ ? metrics.renderGetTextureMsAvg
+ : null,
+ renderGetTextureMax:
+ typeof metrics.renderGetTextureMsMax === "number"
+ ? metrics.renderGetTextureMsMax
+ : null,
+ renderFrameComputeAvg:
+ typeof metrics.renderFrameComputeMsAvg === "number"
+ ? metrics.renderFrameComputeMsAvg
+ : null,
+ renderFrameComputeMax:
+ typeof metrics.renderFrameComputeMsMax === "number"
+ ? metrics.renderFrameComputeMsMax
+ : null,
+ renderTerritoryPassAvg:
+ typeof metrics.renderTerritoryPassMsAvg === "number"
+ ? metrics.renderTerritoryPassMsAvg
+ : null,
+ renderTerritoryPassMax:
+ typeof metrics.renderTerritoryPassMsMax === "number"
+ ? metrics.renderTerritoryPassMsMax
+ : null,
+ renderTemporalResolveAvg:
+ typeof metrics.renderTemporalResolveMsAvg === "number"
+ ? metrics.renderTemporalResolveMsAvg
+ : null,
+ renderTemporalResolveMax:
+ typeof metrics.renderTemporalResolveMsMax === "number"
+ ? metrics.renderTemporalResolveMsMax
+ : null,
+ renderSubmitAvg:
+ typeof metrics.renderSubmitMsAvg === "number"
+ ? metrics.renderSubmitMsAvg
+ : null,
+ renderSubmitMax:
+ typeof metrics.renderSubmitMsMax === "number"
+ ? metrics.renderSubmitMsMax
+ : null,
+ traceLines,
+ topMsgs,
+ };
+ }
+
+ private formatMs(v: number | null | undefined, digits: number = 1): string {
+ if (v === null || v === undefined || !Number.isFinite(v)) return "—";
+ return `${v.toFixed(digits)}ms`;
+ }
+
+ private onWorkerTraceToggle(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.workerIncludeTrace = !!target.checked;
+ this.eventBus.emit(
+ new SetWorkerDebugEvent({
+ enabled: this.isVisible,
+ intervalMs: this.workerIntervalMs,
+ includeTrace: this.workerIncludeTrace,
+ }),
+ );
+ }
+
+ private onWorkerIntervalChange(e: Event) {
+ const target = e.target as HTMLSelectElement;
+ const ms = Number.parseInt(target.value, 10);
+ if (!Number.isFinite(ms) || ms <= 0) return;
+ this.workerIntervalMs = ms;
+ this.eventBus.emit(
+ new SetWorkerDebugEvent({
+ enabled: this.isVisible,
+ intervalMs: this.workerIntervalMs,
+ includeTrace: this.workerIncludeTrace,
+ }),
+ );
+ }
+
private clearCopyStatusTimeout() {
if (this.copyStatusTimeoutId !== null) {
clearTimeout(this.copyStatusTimeoutId);
@@ -550,6 +800,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
? Math.max(...this.layerBreakdown.map((l) => l.avg))
: 1;
+ const worker = this.getWorkerKeyStats(this.workerMetrics);
+
return html`
${this.tickDelayAvg.toFixed(2)}ms
(max: ${this.tickDelayMax}ms)
+
+
Worker
+
+ metrics age
+ ${this.formatMs(this.workerMetricsAgeMs, 0)}
+
+
+ metrics interval (worker)
+ ${this.formatMs(worker.intervalMs, 0)}
+
+
+ event loop lag (avg / max)
+ ${this.formatMs(worker.loopLagAvg)} /
+ ${this.formatMs(worker.loopLagMax, 0)}
+
+
+ sim pump delay (avg / max)
+ ${this.formatMs(worker.simDelayAvg)} /
+ ${this.formatMs(worker.simDelayMax, 0)}
+
+
+ sim pump exec (avg / max)
+ ${this.formatMs(worker.simExecAvg)} /
+ ${this.formatMs(worker.simExecMax, 0)}
+
+
+ render_frame queue (avg / max)
+ ${this.formatMs(worker.rfQueueAvg, 0)} /
+ ${this.formatMs(worker.rfQueueMax, 0)}
+
+
+ render_frame handler (avg / max)
+ ${this.formatMs(worker.rfHandlerAvg, 0)} /
+ ${this.formatMs(worker.rfHandlerMax, 0)}
+
+ ${worker.renderSubmittedCount !== null ||
+ worker.renderNoopCount !== null
+ ? html`
+ render submits / noops
+ ${worker.renderSubmittedCount ?? 0} /
+ ${worker.renderNoopCount ?? 0}
+
`
+ : html``}
+ ${worker.renderCpuTotalAvg !== null
+ ? html`
+ render CPU breakdown (avg/max, submitted frames)
+
+
+ cpu total
+ ${this.formatMs(worker.renderCpuTotalAvg)} /
+ ${this.formatMs(worker.renderCpuTotalMax, 0)}
+
+
+ getCurrentTexture
+ ${this.formatMs(worker.renderGetTextureAvg)} /
+ ${this.formatMs(worker.renderGetTextureMax, 0)}
+
+
+ frame compute
+ ${this.formatMs(worker.renderFrameComputeAvg)} /
+ ${this.formatMs(worker.renderFrameComputeMax, 0)}
+
+
+ territory pass
+ ${this.formatMs(worker.renderTerritoryPassAvg)} /
+ ${this.formatMs(worker.renderTerritoryPassMax, 0)}
+
+
+ temporal resolve
+ ${this.formatMs(worker.renderTemporalResolveAvg)} /
+ ${this.formatMs(worker.renderTemporalResolveMax, 0)}
+
+
+ submit
+ ${this.formatMs(worker.renderSubmitAvg)} /
+ ${this.formatMs(worker.renderSubmitMax, 0)}
+
`
+ : html``}
+ ${worker.topMsgs.length
+ ? html`
+ top msgs (count | queue avg/max | handler avg/max)
+
+ ${worker.topMsgs.map(
+ (m) =>
+ html`
+ ${m.type} (${m.count})
+
+ ${this.formatMs(m.queueAvg, 0)}/${this.formatMs(
+ m.queueMax,
+ 0,
+ )}
+ |
+ ${this.formatMs(m.handlerAvg, 0)}/${this.formatMs(
+ m.handlerMax,
+ 0,
+ )}
+
+
`,
+ )}`
+ : html``}
+
+ trace
+
+
+
+
+
+ ${worker.traceLines.length
+ ? html`
`
+ : html``}
+
${this.layerBreakdown.length
? html`
diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts
index b8e153a70a..d94c5cfb16 100644
--- a/src/client/graphics/layers/PlayerInfoOverlay.ts
+++ b/src/client/graphics/layers/PlayerInfoOverlay.ts
@@ -7,10 +7,8 @@ import {
PlayerProfile,
PlayerType,
Relation,
- Unit,
UnitType,
} from "../../../core/game/Game";
-import { TileRef } from "../../../core/game/GameMap";
import { AllianceView } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
@@ -34,26 +32,6 @@ import portIcon from "/images/PortIcon.svg?url";
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import soldierIcon from "/images/SoldierIcon.svg?url";
-function euclideanDistWorld(
- coord: { x: number; y: number },
- tileRef: TileRef,
- game: GameView,
-): number {
- const x = game.x(tileRef);
- const y = game.y(tileRef);
- const dx = coord.x - x;
- const dy = coord.y - y;
- return Math.sqrt(dx * dx + dy * dy);
-}
-
-function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
- return (a: Unit | UnitView, b: Unit | UnitView) => {
- const distA = euclideanDistWorld(coord, a.tile(), game);
- const distB = euclideanDistWorld(coord, b.tile(), game);
- return distA - distB;
- };
-}
-
@customElement("player-info-overlay")
export class PlayerInfoOverlay extends LitElement implements Layer {
@property({ type: Object })
@@ -74,6 +52,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@state()
private unit: UnitView | null = null;
+ @state()
+ private isWilderness: boolean = false;
+
+ @state()
+ private isIrradiatedWilderness: boolean = false;
+
@state()
private _isInfoVisible: boolean = false;
@@ -81,6 +65,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private lastMouseUpdate = 0;
+ private showDetails = true;
+ private hoverSeq = 0;
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
@@ -105,6 +91,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.setVisible(false);
this.unit = null;
this.player = null;
+ this.isWilderness = false;
+ this.isIrradiatedWilderness = false;
}
public maybeShow(x: number, y: number) {
@@ -115,26 +103,65 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
const tile = this.game.ref(worldCoord.x, worldCoord.y);
- if (!tile) return;
- const owner = this.game.owner(tile);
+ // 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;
+ }
- if (owner && owner.isPlayer()) {
- this.player = owner as PlayerView;
- this.player.profile().then((p) => {
- this.playerProfile = p;
+ // 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);
});
+
+ if (units.length > 0) {
+ this.unit = units[0];
this.setVisible(true);
- } else if (!this.game.isLand(tile)) {
- const units = this.game
- .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
- .filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
- .sort(distSortUnitWorld(worldCoord, this.game));
-
- if (units.length > 0) {
- this.unit = units[0];
- this.setVisible(true);
- }
}
}
@@ -458,6 +485,15 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
+ ${this.isWilderness || this.isIrradiatedWilderness
+ ? html`
+ ${translateText(
+ this.isIrradiatedWilderness
+ ? "player_info_overlay.irradiated_wilderness_title"
+ : "player_info_overlay.wilderness_title",
+ )}
+
`
+ : ""}
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index 674b83e155..0000a49f07 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -62,6 +62,7 @@ export class PlayerPanel extends LitElement implements Layer {
private tile: TileRef | null = null;
private _profileForPlayerId: number | null = null;
private kickedPlayerIDs = new Set
();
+ @state() private otherPlayer: PlayerView | null = null;
@state() private sendTarget: PlayerView | null = null;
@state() private sendMode: "troops" | "gold" | "none" = "none";
@@ -107,15 +108,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
@@ -146,6 +161,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.moderationTarget = null;
this.isVisible = true;
this.requestUpdate();
@@ -159,6 +177,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.moderationTarget = null;
@@ -170,6 +189,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.moderationTarget = null;
this.requestUpdate();
}
@@ -859,13 +883,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 aeafdf8a10..3af390ccde 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -464,10 +464,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;
}
@@ -564,7 +563,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;
@@ -573,7 +571,7 @@ export const centerButtonElement: CenterButtonElement = {
if (params.game.config().isRandomSpawn()) {
return true;
}
- if (tileOwner.isPlayer()) {
+ if (params.selected) {
return true;
}
return false;
@@ -619,10 +617,8 @@ export const rootMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
- 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/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts
index 0a5b184bf2..83143978cc 100644
--- a/src/client/graphics/layers/SettingsModal.ts
+++ b/src/client/graphics/layers/SettingsModal.ts
@@ -168,6 +168,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
+ private onToggleWebgpuDebugOverlayButtonClick() {
+ this.userSettings.toggleWebgpuDebug();
+ this.requestUpdate();
+ }
+
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
@@ -498,6 +503,29 @@ export class SettingsModal extends LitElement implements Layer {
+
+