Add renderer status panel

This commit is contained in:
scamiv
2026-05-26 23:00:46 +02:00
parent d8841fd1ed
commit 2beb449fb4
6 changed files with 578 additions and 4 deletions
+1
View File
@@ -343,6 +343,7 @@
<chat-modal></chat-modal>
<multi-tab-modal></multi-tab-modal>
<game-left-sidebar></game-left-sidebar>
<renderer-status-panel></renderer-status-panel>
<performance-overlay></performance-overlay>
<webgpu-debug-overlay></webgpu-debug-overlay>
<player-info-overlay></player-info-overlay>
+11
View File
@@ -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),
@@ -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<TerritoryRendererStatus>).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`
<div
class="panel ${this.isDragging ? "dragging" : ""}"
style=${this.panelStyle()}
@pointerdown=${this.stopPointerEvent}
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<span>Renderer</span>
</div>
<div class="body">
<div class="row">
<div class="label">Active</div>
<div class="value active">
<span class="dot"></span>
${this.activeRenderer
? this.rendererLabel(this.activeRenderer)
: "Pending"}
</div>
</div>
<div class="row">
<label class="label" for="renderer-select">Saved</label>
<select
id="renderer-select"
.value=${this.preference}
@change=${this.changeRenderer}
>
${TERRITORY_RENDERER_OPTIONS.map(
(option) =>
html`<option value=${option}>
${this.rendererLabel(option)}
</option>`,
)}
</select>
</div>
${note ? html`<div class="note">${note}</div>` : null}
</div>
</div>
`;
}
}
@@ -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<boolean>;
}
export interface TerritoryRendererStatus {
active: TerritoryRendererId | null;
preference: TerritoryRendererPreference;
failedBackends: TerritoryRendererId[];
message: string | null;
}
export interface TerritoryBackendCandidate {
readonly id: TerritoryRendererId;
init?: () => void | Promise<void>;
+28 -2
View File
@@ -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 }),
);
}
}
@@ -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`
<div class="overlay">
<div class="title">
<div
class="overlay ${this.isDragging ? "dragging" : ""}"
style=${this.overlayStyle()}
@pointerdown=${this.stopPointerEvent}
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<div>WebGPU Debug</div>
</div>