From 2beb449fb47aa697325be8bbc2f064bc62d7f683 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 26 May 2026 23:00:46 +0200 Subject: [PATCH] Add renderer status panel --- index.html | 1 + src/client/graphics/GameRenderer.ts | 11 + .../graphics/layers/RendererStatusPanel.ts | 383 ++++++++++++++++++ .../graphics/layers/TerritoryBackend.ts | 9 + src/client/graphics/layers/TerritoryLayer.ts | 30 +- .../graphics/layers/WebGPUDebugOverlay.ts | 148 ++++++- 6 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 src/client/graphics/layers/RendererStatusPanel.ts diff --git a/index.html b/index.html index 7636f6207..c5c1a622e 100644 --- a/index.html +++ b/index.html @@ -343,6 +343,7 @@ + diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 753fa2fb2..a7d630420 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -33,6 +33,7 @@ import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { RailroadLayer } from "./layers/RailroadLayer"; +import { RendererStatusPanel } from "./layers/RendererStatusPanel"; import { ReplayPanel } from "./layers/ReplayPanel"; import { SAMRadiusLayer } from "./layers/SAMRadiusLayer"; import { SettingsModal } from "./layers/SettingsModal"; @@ -252,6 +253,15 @@ export function createRenderer( webgpuDebugOverlay.userSettings = userSettings; webgpuDebugOverlay.requestUpdate(); + const rendererStatusPanel = document.querySelector( + "renderer-status-panel", + ) as RendererStatusPanel; + if (!(rendererStatusPanel instanceof RendererStatusPanel)) { + console.error("renderer status panel not found"); + } + rendererStatusPanel.userSettings = userSettings; + rendererStatusPanel.requestUpdate(); + const alertFrame = document.querySelector("alert-frame") as AlertFrame; if (!(alertFrame instanceof AlertFrame)) { console.error("alert frame not found"); @@ -285,6 +295,7 @@ 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[] = [ + rendererStatusPanel, new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, eventBus, transformHandler, uiState), new CoordinateGridLayer(game, eventBus, transformHandler), diff --git a/src/client/graphics/layers/RendererStatusPanel.ts b/src/client/graphics/layers/RendererStatusPanel.ts new file mode 100644 index 000000000..63fb077f0 --- /dev/null +++ b/src/client/graphics/layers/RendererStatusPanel.ts @@ -0,0 +1,383 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + TERRITORY_RENDERER_KEY, + USER_SETTINGS_CHANGED_EVENT, + UserSettings, +} from "../../../core/game/UserSettings"; +import { Layer } from "./Layer"; +import { + TERRITORY_RENDERER_OPTIONS, + TERRITORY_RENDERER_STATUS_EVENT, + TerritoryRendererId, + TerritoryRendererPreference, + TerritoryRendererStatus, +} from "./TerritoryBackend"; + +@customElement("renderer-status-panel") +export class RendererStatusPanel extends LitElement implements Layer { + @property({ type: Object }) + public userSettings!: UserSettings; + + @state() + private activeRenderer: TerritoryRendererId | null = null; + + @state() + private preference: TerritoryRendererPreference = "auto"; + + @state() + private failedBackends: TerritoryRendererId[] = []; + + @state() + private message: string | null = null; + + @state() + private position: { x: number; y: number } | null = null; + + @state() + private isDragging = false; + + private dragState: { + pointerId: number; + offsetX: number; + offsetY: number; + } | null = null; + + private readonly positionStorageKey = "rendererStatusPanel.position.v1"; + + static styles = css` + .panel { + position: fixed; + left: 16px; + bottom: 16px; + z-index: 9998; + width: min(280px, calc(100vw - 32px)); + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + background: rgba(13, 16, 20, 0.86); + color: rgba(255, 255, 255, 0.92); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 12px; + line-height: 1.35; + pointer-events: auto; + user-select: none; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(10px); + } + + .panel.dragging { + opacity: 0.72; + } + + .title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px 6px; + cursor: grab; + touch-action: none; + font-weight: 700; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .panel.dragging .title { + cursor: grabbing; + } + + .body { + display: grid; + gap: 7px; + padding: 8px 10px 10px; + } + + .row { + display: grid; + grid-template-columns: 72px 1fr; + align-items: center; + gap: 8px; + } + + .label { + color: rgba(255, 255, 255, 0.62); + } + + .value { + min-width: 0; + color: rgba(255, 255, 255, 0.94); + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .active { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: rgb(67, 214, 142); + box-shadow: 0 0 0 3px rgba(67, 214, 142, 0.16); + } + + select { + width: 100%; + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(0, 0, 0, 0.38); + color: rgba(255, 255, 255, 0.94); + padding: 5px 7px; + font: inherit; + outline: none; + } + + .note { + color: rgba(255, 255, 255, 0.66); + overflow-wrap: anywhere; + } + `; + + init() { + this.preference = this.userSettings.territoryRenderer(); + this.restorePosition(); + globalThis.addEventListener( + TERRITORY_RENDERER_STATUS_EVENT, + this.handleRendererStatus, + ); + globalThis.addEventListener( + `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`, + this.handlePreferenceChanged, + ); + this.requestUpdate(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.endDrag(); + globalThis.removeEventListener( + TERRITORY_RENDERER_STATUS_EVENT, + this.handleRendererStatus, + ); + globalThis.removeEventListener( + `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`, + this.handlePreferenceChanged, + ); + } + + private readonly handleRendererStatus = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) { + return; + } + + this.activeRenderer = detail.active; + this.preference = detail.preference; + this.failedBackends = detail.failedBackends; + this.message = detail.message; + }; + + private readonly handlePreferenceChanged = () => { + if (!this.userSettings) { + return; + } + this.preference = this.userSettings.territoryRenderer(); + this.message = null; + }; + + private changeRenderer(event: Event) { + const value = (event.target as HTMLSelectElement).value; + this.userSettings.setTerritoryRenderer(value); + this.preference = this.userSettings.territoryRenderer(); + } + + private rendererLabel(id: TerritoryRendererId | TerritoryRendererPreference) { + if (id === "webgpu") return "WebGPU"; + if (id === "webgl") return "WebGL"; + if (id === "classic") return "Classic"; + return "Auto"; + } + + private statusNote() { + if (this.failedBackends.length > 0) { + return `Skipped this cycle: ${this.failedBackends + .map((id) => this.rendererLabel(id)) + .join(", ")}`; + } + if ( + this.activeRenderer && + this.preference !== "auto" && + this.activeRenderer !== this.preference + ) { + return `Fallback from ${this.rendererLabel(this.preference)}`; + } + return this.message; + } + + private restorePosition() { + try { + const raw = localStorage.getItem(this.positionStorageKey); + if (!raw) { + return; + } + const parsed = JSON.parse(raw) as { x: unknown; y: unknown }; + if ( + typeof parsed.x === "number" && + typeof parsed.y === "number" && + Number.isFinite(parsed.x) && + Number.isFinite(parsed.y) + ) { + this.position = this.clampPosition(parsed.x, parsed.y); + } + } catch { + // Keep the default docked position. + } + } + + private savePosition() { + if (!this.position) { + return; + } + try { + localStorage.setItem( + this.positionStorageKey, + JSON.stringify(this.position), + ); + } catch { + // Position persistence is best-effort. + } + } + + private clampPosition(x: number, y: number) { + const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null; + const width = panel?.offsetWidth ?? 280; + const height = panel?.offsetHeight ?? 120; + const margin = 8; + return { + x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)), + y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)), + }; + } + + private panelStyle() { + if (!this.position) { + return ""; + } + return `left: ${this.position.x}px; top: ${this.position.y}px; bottom: auto;`; + } + + private stopPointerEvent(event: PointerEvent) { + event.stopPropagation(); + } + + private handleDragPointerDown(event: PointerEvent) { + event.preventDefault(); + event.stopPropagation(); + + const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null; + if (!panel) { + return; + } + const rect = panel.getBoundingClientRect(); + this.position = { x: rect.left, y: rect.top }; + this.isDragging = true; + this.dragState = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + + globalThis.addEventListener("pointermove", this.handleDragPointerMove); + globalThis.addEventListener("pointerup", this.handleDragPointerUp); + globalThis.addEventListener("pointercancel", this.handleDragPointerUp); + } + + private readonly handleDragPointerMove = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.position = this.clampPosition( + event.clientX - this.dragState.offsetX, + event.clientY - this.dragState.offsetY, + ); + }; + + private readonly handleDragPointerUp = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.savePosition(); + this.endDrag(); + }; + + private endDrag() { + globalThis.removeEventListener("pointermove", this.handleDragPointerMove); + globalThis.removeEventListener("pointerup", this.handleDragPointerUp); + globalThis.removeEventListener("pointercancel", this.handleDragPointerUp); + this.dragState = null; + this.isDragging = false; + } + + render() { + if (!this.userSettings) { + return null; + } + + const note = this.statusNote(); + return html` +
+
+ Renderer +
+
+
+
Active
+
+ + ${this.activeRenderer + ? this.rendererLabel(this.activeRenderer) + : "Pending"} +
+
+
+ + +
+ ${note ? html`
${note}
` : null} +
+
+ `; + } +} diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts index 7b02694ca..1b466d269 100644 --- a/src/client/graphics/layers/TerritoryBackend.ts +++ b/src/client/graphics/layers/TerritoryBackend.ts @@ -2,6 +2,8 @@ import { Layer } from "./Layer"; export type TerritoryRendererId = "classic" | "webgl" | "webgpu"; export type TerritoryRendererPreference = "auto" | TerritoryRendererId; +export const TERRITORY_RENDERER_STATUS_EVENT = + "event:territory-renderer-status"; export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [ "auto", @@ -17,6 +19,13 @@ export interface TerritoryBackend extends Layer { whenReady?: () => Promise; } +export interface TerritoryRendererStatus { + active: TerritoryRendererId | null; + preference: TerritoryRendererPreference; + failedBackends: TerritoryRendererId[]; + message: string | null; +} + export interface TerritoryBackendCandidate { readonly id: TerritoryRendererId; init?: () => void | Promise; diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 60833078b..1f2bf25e9 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -8,8 +8,10 @@ import { import { TransformHandler } from "../TransformHandler"; import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend"; import { + TERRITORY_RENDERER_STATUS_EVENT, TerritoryBackend, TerritoryRendererId, + TerritoryRendererStatus, selectTerritoryBackend, territoryRendererOrder, } from "./TerritoryBackend"; @@ -25,6 +27,7 @@ export class TerritoryLayer implements TerritoryBackend { private initialized = false; private readonly settingsChanged = () => { this.failedBackends.clear(); + this.publishStatus("Retrying renderer selection"); void this.selectConfiguredBackend(); }; @@ -51,7 +54,10 @@ export class TerritoryLayer implements TerritoryBackend { ); // Keep the map visible while accelerated renderers initialize. - this.activateBackend(this.createBackend("classic")); + this.activateBackend( + this.createBackend("classic"), + "Using Classic while accelerated renderer initializes", + ); void this.selectConfiguredBackend(); } @@ -122,6 +128,8 @@ export class TerritoryLayer implements TerritoryBackend { if (selection.backend !== null) { this.activateBackend(selection.backend); + } else { + this.publishStatus("No territory renderer is currently available"); } } @@ -161,7 +169,10 @@ export class TerritoryLayer implements TerritoryBackend { } } - private activateBackend(backend: TerritoryBackend) { + private activateBackend( + backend: TerritoryBackend, + message: string | null = null, + ) { if (this.activeBackend === backend) { return; } @@ -169,6 +180,7 @@ export class TerritoryLayer implements TerritoryBackend { this.activeBackend = backend; previous?.dispose?.(); console.info(`[TerritoryLayer] active renderer: ${backend.id}`); + this.publishStatus(message); } private runActive( @@ -196,6 +208,7 @@ export class TerritoryLayer implements TerritoryBackend { if (backend.id !== "classic") { this.failedBackends.add(backend.id); } + this.publishStatus(`${backend.id} failed: ${reason}`); if (this.activeBackend === backend) { this.activeBackend = null; backend.dispose?.(); @@ -241,4 +254,17 @@ export class TerritoryLayer implements TerritoryBackend { context.fillRect(0, 0, context.canvas.width, context.canvas.height); context.restore(); } + + private publishStatus(message: string | null = null) { + const detail: TerritoryRendererStatus = { + active: this.activeBackend?.id ?? null, + preference: this.userSettings.territoryRenderer(), + failedBackends: Array.from(this.failedBackends), + message, + }; + + globalThis.dispatchEvent?.( + new CustomEvent(TERRITORY_RENDERER_STATUS_EVENT, { detail }), + ); + } } diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index 9aae56bf0..6c9275d57 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -48,7 +48,19 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { @state() private tickComputeMs: number = 0; + @state() + private position: { x: number; y: number } | null = null; + + @state() + private isDragging = false; + private frameTimes: number[] = []; + private dragState: { + pointerId: number; + offsetX: number; + offsetY: number; + } | null = null; + private readonly positionStorageKey = "webgpuDebugOverlay.position.v1"; static styles = css` .overlay { @@ -71,6 +83,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { user-select: none; } + .overlay.dragging { + opacity: 0.72; + } + .title { font-weight: 700; margin-bottom: 8px; @@ -78,6 +94,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { align-items: center; justify-content: space-between; gap: 8px; + cursor: grab; + touch-action: none; + } + + .overlay.dragging .title { + cursor: grabbing; } .metrics { @@ -154,6 +176,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { `; init() { + this.restorePosition(); this.eventBus.on(WebGPUComputeMetricsEvent, (e) => { if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) { this.tickComputeMs = e.computeMs; @@ -163,6 +186,11 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { this.requestUpdate(); } + disconnectedCallback(): void { + super.disconnectedCallback(); + this.endDrag(); + } + updateFrameMetrics(frameDurationMs: number): void { if (!this.userSettings || !this.userSettings.webgpuDebug()) { return; @@ -301,6 +329,118 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { `; } + private restorePosition() { + try { + const raw = localStorage.getItem(this.positionStorageKey); + if (!raw) { + return; + } + const parsed = JSON.parse(raw) as { x: unknown; y: unknown }; + if ( + typeof parsed.x === "number" && + typeof parsed.y === "number" && + Number.isFinite(parsed.x) && + Number.isFinite(parsed.y) + ) { + this.position = this.clampPosition(parsed.x, parsed.y); + } + } catch { + // Keep the default position. + } + } + + private savePosition() { + if (!this.position) { + return; + } + try { + localStorage.setItem( + this.positionStorageKey, + JSON.stringify(this.position), + ); + } catch { + // Position persistence is best-effort. + } + } + + private clampPosition(x: number, y: number) { + const overlay = this.renderRoot.querySelector( + ".overlay", + ) as HTMLElement | null; + const width = overlay?.offsetWidth ?? 340; + const height = overlay?.offsetHeight ?? 420; + const margin = 8; + return { + x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)), + y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)), + }; + } + + private overlayStyle() { + if (!this.position) { + return ""; + } + return `left: ${this.position.x}px; top: ${this.position.y}px;`; + } + + private stopPointerEvent(event: PointerEvent) { + event.stopPropagation(); + } + + private handleDragPointerDown(event: PointerEvent) { + event.preventDefault(); + event.stopPropagation(); + + const overlay = this.renderRoot.querySelector( + ".overlay", + ) as HTMLElement | null; + if (!overlay) { + return; + } + const rect = overlay.getBoundingClientRect(); + this.position = { x: rect.left, y: rect.top }; + this.isDragging = true; + this.dragState = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + + globalThis.addEventListener("pointermove", this.handleDragPointerMove); + globalThis.addEventListener("pointerup", this.handleDragPointerUp); + globalThis.addEventListener("pointercancel", this.handleDragPointerUp); + } + + private readonly handleDragPointerMove = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.position = this.clampPosition( + event.clientX - this.dragState.offsetX, + event.clientY - this.dragState.offsetY, + ); + }; + + private readonly handleDragPointerUp = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.savePosition(); + this.endDrag(); + }; + + private endDrag() { + globalThis.removeEventListener("pointermove", this.handleDragPointerMove); + globalThis.removeEventListener("pointerup", this.handleDragPointerUp); + globalThis.removeEventListener("pointercancel", this.handleDragPointerUp); + this.dragState = null; + this.isDragging = false; + } + render() { if (!this.userSettings || !this.userSettings.webgpuDebug()) { return null; @@ -323,8 +463,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { TERRITORY_POST_SMOOTHING[0]; return html` -
-
+
+
WebGPU Debug