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 { TickMetricsEvent, TogglePerformanceOverlayEvent, } from "../../InputHandler"; import { translateText } from "../../Utils"; import { FrameProfiler } from "../FrameProfiler"; import { Layer } from "./Layer"; @customElement("performance-overlay") export class PerformanceOverlay extends LitElement implements Layer { @property({ type: Object }) public eventBus!: EventBus; @property({ type: Object }) public userSettings!: UserSettings; @state() private currentFPS: number = 0; @state() private averageFPS: number = 0; @state() private frameTime: number = 0; @state() private tickExecutionAvg: number = 0; @state() private tickExecutionMax: number = 0; @state() private tickDelayAvg: number = 0; @state() private tickDelayMax: number = 0; @state() private isVisible: boolean = false; @state() private isDragging: boolean = false; @state() private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values @state() private copyStatus: "idle" | "success" | "error" = "idle"; private frameCount: number = 0; private lastTime: number = 0; private frameTimes: number[] = []; private fpsHistory: number[] = []; private lastSecondTime: number = 0; private framesThisSecond: number = 0; private dragStart: { x: number; y: number } = { x: 0, y: 0 }; private tickExecutionTimes: number[] = []; private tickDelayTimes: number[] = []; private copyStatusTimeoutId: ReturnType | null = null; // Smoothed per-layer render timings (EMA over recent frames) private layerStats: Map< string, { avg: number; max: number; last: number; total: number } > = new Map(); @state() private layerBreakdown: { name: string; avg: number; max: number; total: number; }[] = []; static styles = css` .performance-overlay { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 16px; border-radius: 4px; font-family: monospace; font-size: 12px; z-index: 9999; user-select: none; cursor: move; transition: none; min-width: 420px; } .performance-overlay.dragging { cursor: grabbing; transition: none; opacity: 0.5; } .performance-line { margin: 2px 0; } .performance-good { color: #4ade80; /* green-400 */ } .performance-warning { color: #fbbf24; /* amber-400 */ } .performance-bad { color: #f87171; /* red-400 */ } .close-button { position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 14px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .reset-button { position: absolute; top: 8px; left: 8px; height: 20px; padding: 0 6px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .copy-json-button { position: absolute; top: 8px; left: 70px; height: 20px; padding: 0 6px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .layers-section { margin-top: 4px; border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 4px; } .layer-row { display: flex; align-items: center; gap: 6px; font-size: 11px; margin-top: 2px; } .layer-name { flex: 0 0 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .layer-bar { flex: 1; height: 6px; background: rgba(148, 163, 184, 0.25); border-radius: 3px; overflow: hidden; } .layer-bar-fill { height: 100%; background: #38bdf8; border-radius: 3px; } .layer-metrics { flex: 0 0 auto; white-space: nowrap; } `; constructor() { super(); } init() { this.eventBus.on(TogglePerformanceOverlayEvent, () => { this.userSettings.togglePerformanceOverlay(); this.setVisible(this.userSettings.performanceOverlay()); }); this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => { this.updateTickMetrics( event.tickExecutionDuration, event.tickDelay, event.backlogTurns, event.ticksPerRender, event.workerTicksPerSecond, event.renderTicksPerSecond, event.tileUpdatesCount, event.ringBufferUtilization, event.ringBufferOverflows, event.ringDrainTime, ); }); } setVisible(visible: boolean) { this.isVisible = visible; FrameProfiler.setEnabled(visible); } private handleClose() { this.userSettings.togglePerformanceOverlay(); } private handleMouseDown = (e: MouseEvent) => { // Don't start dragging if clicking on close button const target = e.target as HTMLElement; if ( target.classList.contains("close-button") || target.classList.contains("reset-button") || target.classList.contains("copy-json-button") ) { return; } this.isDragging = true; this.dragStart = { x: e.clientX - this.position.x, y: e.clientY - this.position.y, }; document.addEventListener("mousemove", this.handleMouseMove); document.addEventListener("mouseup", this.handleMouseUp); e.preventDefault(); }; private handleMouseMove = (e: MouseEvent) => { if (!this.isDragging) return; const newX = e.clientX - this.dragStart.x; const newY = e.clientY - this.dragStart.y; // Convert to percentage of viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; this.position = { x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds y: Math.max(0, Math.min(viewportHeight - 100, newY)), }; this.requestUpdate(); }; private handleMouseUp = () => { this.isDragging = false; document.removeEventListener("mousemove", this.handleMouseMove); document.removeEventListener("mouseup", this.handleMouseUp); }; private handleReset = () => { // reset FPS / frame stats this.frameCount = 0; this.lastTime = 0; this.frameTimes = []; this.fpsHistory = []; this.lastSecondTime = 0; this.framesThisSecond = 0; this.currentFPS = 0; this.averageFPS = 0; this.frameTime = 0; // reset tick metrics this.tickExecutionTimes = []; this.tickDelayTimes = []; this.tickExecutionAvg = 0; this.tickExecutionMax = 0; this.tickDelayAvg = 0; this.tickDelayMax = 0; // reset layer breakdown this.layerStats.clear(); this.layerBreakdown = []; // reset tile metrics this.tileUpdatesPerRender = 0; this.tileUpdatesPeak = 0; this.ringBufferUtilization = 0; this.ringBufferOverflows = 0; this.ringDrainTime = 0; this.totalTilesUpdated = 0; this.requestUpdate(); }; updateFrameMetrics( frameDuration: number, layerDurations?: Record, ) { const wasVisible = this.isVisible; this.isVisible = this.userSettings.performanceOverlay(); // Update FrameProfiler enabled state when visibility changes if (wasVisible !== this.isVisible) { FrameProfiler.setEnabled(this.isVisible); } if (!this.isVisible) return; const now = performance.now(); // Initialize timing on first call if (this.lastTime === 0) { this.lastTime = now; this.lastSecondTime = now; return; } const deltaTime = now - this.lastTime; // Track frame times for current FPS calculation (last 60 frames) this.frameTimes.push(deltaTime); if (this.frameTimes.length > 60) { this.frameTimes.shift(); } // Calculate current FPS based on average frame time if (this.frameTimes.length > 0) { const avgFrameTime = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length; this.currentFPS = Math.round(1000 / avgFrameTime); this.frameTime = Math.round(avgFrameTime); } // Track FPS for 60-second average this.framesThisSecond++; // Update every second if (now - this.lastSecondTime >= 1000) { this.fpsHistory.push(this.framesThisSecond); if (this.fpsHistory.length > 60) { this.fpsHistory.shift(); } // Calculate 60-second average if (this.fpsHistory.length > 0) { this.averageFPS = Math.round( this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length, ); } this.framesThisSecond = 0; this.lastSecondTime = now; } this.lastTime = now; this.frameCount++; if (layerDurations) { this.updateLayerStats(layerDurations); } this.requestUpdate(); } private updateLayerStats(layerDurations: Record) { const alpha = 0.2; // smoothing factor for EMA Object.entries(layerDurations).forEach(([name, duration]) => { const existing = this.layerStats.get(name); if (!existing) { this.layerStats.set(name, { avg: duration, max: duration, last: duration, total: duration, }); } else { const avg = existing.avg + alpha * (duration - existing.avg); const max = Math.max(existing.max, duration); const total = existing.total + duration; this.layerStats.set(name, { avg, max, last: duration, total }); } }); // Derive contributors sorted by total accumulated time spent const breakdown = Array.from(this.layerStats.entries()) .map(([name, stats]) => ({ name, avg: stats.avg, max: stats.max, total: stats.total, })) .sort((a, b) => b.total - a.total); this.layerBreakdown = breakdown; } @state() private backlogTurns: number = 0; @state() private ticksPerRender: number = 0; @state() private workerTicksPerSecond: number = 0; @state() private renderTicksPerSecond: number = 0; @state() private tileUpdatesPerRender: number = 0; @state() private tileUpdatesPeak: number = 0; @state() private ringBufferUtilization: number = 0; @state() private ringBufferOverflows: number = 0; @state() private ringDrainTime: number = 0; @state() private totalTilesUpdated: number = 0; updateTickMetrics( tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, ticksPerRender?: number, workerTicksPerSecond?: number, renderTicksPerSecond?: number, tileUpdatesCount?: number, ringBufferUtilization?: number, ringBufferOverflows?: number, ringDrainTime?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; // Update tick execution duration stats if (tickExecutionDuration !== undefined) { this.tickExecutionTimes.push(tickExecutionDuration); if (this.tickExecutionTimes.length > 60) { this.tickExecutionTimes.shift(); } if (this.tickExecutionTimes.length > 0) { const avg = this.tickExecutionTimes.reduce((a, b) => a + b, 0) / this.tickExecutionTimes.length; this.tickExecutionAvg = Math.round(avg * 100) / 100; this.tickExecutionMax = Math.round( Math.max(...this.tickExecutionTimes), ); } } // Update tick delay stats if (tickDelay !== undefined) { this.tickDelayTimes.push(tickDelay); if (this.tickDelayTimes.length > 60) { this.tickDelayTimes.shift(); } if (this.tickDelayTimes.length > 0) { const avg = this.tickDelayTimes.reduce((a, b) => a + b, 0) / this.tickDelayTimes.length; this.tickDelayAvg = Math.round(avg * 100) / 100; this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes)); } } if (backlogTurns !== undefined) { this.backlogTurns = backlogTurns; } if (ticksPerRender !== undefined) { this.ticksPerRender = ticksPerRender; } if (workerTicksPerSecond !== undefined) { this.workerTicksPerSecond = workerTicksPerSecond; } if (renderTicksPerSecond !== undefined) { this.renderTicksPerSecond = renderTicksPerSecond; } if (tileUpdatesCount !== undefined) { this.tileUpdatesPerRender = tileUpdatesCount; this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount); this.totalTilesUpdated += tileUpdatesCount; } if (ringBufferUtilization !== undefined) { this.ringBufferUtilization = Math.round(ringBufferUtilization * 100) / 100; } if (ringBufferOverflows !== undefined) { // Accumulate overflows (overflows is a flag, so add 1 if set) this.ringBufferOverflows += ringBufferOverflows; } if (ringDrainTime !== undefined) { this.ringDrainTime = Math.round(ringDrainTime * 100) / 100; } this.requestUpdate(); } shouldTransform(): boolean { return false; } private getPerformanceColor(fps: number): string { if (fps >= 55) return "performance-good"; if (fps >= 30) return "performance-warning"; return "performance-bad"; } private buildPerformanceSnapshot() { return { timestamp: new Date().toISOString(), fps: { current: this.currentFPS, average60s: this.averageFPS, frameTimeMs: this.frameTime, history: [...this.fpsHistory], }, ticks: { executionAvgMs: this.tickExecutionAvg, executionMaxMs: this.tickExecutionMax, delayAvgMs: this.tickDelayAvg, delayMaxMs: this.tickDelayMax, executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, tiles: { updatesPerRender: this.tileUpdatesPerRender, peakUpdates: this.tileUpdatesPeak, ringBufferUtilization: this.ringBufferUtilization, ringBufferOverflows: this.ringBufferOverflows, ringDrainTimeMs: this.ringDrainTime, totalTilesUpdated: this.totalTilesUpdated, }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), }; } private clearCopyStatusTimeout() { if (this.copyStatusTimeoutId !== null) { clearTimeout(this.copyStatusTimeoutId); this.copyStatusTimeoutId = null; } } private scheduleCopyStatusReset() { this.clearCopyStatusTimeout(); this.copyStatusTimeoutId = setTimeout(() => { this.copyStatus = "idle"; this.copyStatusTimeoutId = null; this.requestUpdate(); }, 2000); } private async handleCopyJson() { const snapshot = this.buildPerformanceSnapshot(); const json = JSON.stringify(snapshot, null, 2); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(json); } else { const textarea = document.createElement("textarea"); textarea.value = json; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } this.copyStatus = "success"; } catch (err) { console.warn("Failed to copy performance snapshot", err); this.copyStatus = "error"; } this.scheduleCopyStatusReset(); } render() { if (!this.isVisible) { return html``; } const style = ` left: ${this.position.x}px; top: ${this.position.y}px; transform: none; `; const copyLabel = this.copyStatus === "success" ? translateText("performance_overlay.copied") : this.copyStatus === "error" ? translateText("performance_overlay.failed_copy") : translateText("performance_overlay.copy_clipboard"); const maxLayerAvg = this.layerBreakdown.length > 0 ? Math.max(...this.layerBreakdown.map((l) => l.avg)) : 1; return html` ${translateText("performance_overlay.reset")} ${copyLabel} × ${translateText("performance_overlay.fps")} ${this.currentFPS} ${translateText("performance_overlay.avg_60s")} ${this.averageFPS} ${translateText("performance_overlay.frame")} ${this.frameTime}ms ${translateText("performance_overlay.tick_exec")} ${this.tickExecutionAvg.toFixed(2)}ms (max: ${this.tickExecutionMax}ms) ${translateText("performance_overlay.tick_delay")} ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms) Worker ticks/s: ${this.workerTicksPerSecond.toFixed(1)} Render ticks/s: ${this.renderTicksPerSecond.toFixed(1)} Ticks per render: ${this.ticksPerRender} Backlog turns: ${this.backlogTurns} Tile updates/render: ${this.tileUpdatesPerRender} (peak: ${this.tileUpdatesPeak}) Ring buffer: ${this.ringBufferUtilization}% (${this.totalTilesUpdated} total, ${this.ringBufferOverflows} overflows) Ring drain time: ${this.ringDrainTime.toFixed(2)}ms ${this.layerBreakdown.length ? html` ${translateText("performance_overlay.layers_header")} ${this.layerBreakdown.map((layer) => { const width = Math.min( 100, (layer.avg / maxLayerAvg) * 100 || 0, ); return html` ${layer.name} ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms `; })} ` : html``} `; } }