From c5484696f7f5122e42a66b376112b8f97e160a84 Mon Sep 17 00:00:00 2001 From: "maxime.io" Date: Wed, 30 Jul 2025 21:28:38 +0200 Subject: [PATCH] Display FPS monitor overview (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Display an FPS monitor to track performance on each new feature. It only appears in the development environment, positioned at the top center to remain visible—especially on mobile. The display can be closed via a close button. I already use it to evaluate my other PR on low end device. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA aggreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: devalnor --- resources/lang/en.json | 2 + src/client/InputHandler.ts | 20 ++ src/client/UserSettingModal.ts | 16 ++ src/client/graphics/GameRenderer.ts | 15 +- src/client/graphics/layers/FPSDisplay.ts | 268 ++++++++++++++++++++ src/client/graphics/layers/OptionsMenu.ts | 11 + src/client/graphics/layers/SettingsModal.ts | 34 +++ src/client/index.html | 1 + src/core/game/UserSettings.ts | 8 + 9 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/client/graphics/layers/FPSDisplay.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 7bb19a434..51157c2e7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -292,6 +292,8 @@ "troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)", "territory_patterns_label": "🏳️ Territory Patterns", "territory_patterns_desc": "Choose whether to display territory pattern designs in game", + "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", "easter_writing_speed_desc": "Adjust how fast you pretend to code (x1–x100)", "easter_bug_count_label": "Bug Count", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 3a835e3a5..1ab1a3a67 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -72,6 +72,8 @@ export class CloseViewEvent implements GameEvent {} export class RefreshGraphicsEvent implements GameEvent {} +export class TogglePerformanceOverlayEvent implements GameEvent {} + export class ToggleStructureEvent implements GameEvent { constructor(public readonly structureType: UnitType | null) {} } @@ -183,6 +185,14 @@ export class InputHandler { let deltaX = 0; let deltaY = 0; + // Skip if shift is held down + if ( + this.activeKeys.has("ShiftLeft") || + this.activeKeys.has("ShiftRight") + ) { + return; + } + if ( this.activeKeys.has(this.keybinds.moveUp) || this.activeKeys.has("ArrowUp") @@ -258,6 +268,8 @@ export class InputHandler { this.keybinds.centerCamera, "ControlLeft", "ControlRight", + "ShiftLeft", + "ShiftRight", ].includes(e.code) ) { this.activeKeys.add(e.code); @@ -300,6 +312,14 @@ export class InputHandler { this.eventBus.emit(new CenterCameraEvent()); } + // Shift-D to toggle performance overlay + console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); + if (e.code === "KeyD" && e.shiftKey) { + e.preventDefault(); + console.log("TogglePerformanceOverlayEvent"); + this.eventBus.emit(new TogglePerformanceOverlayEvent()); + } + this.activeKeys.delete(e.code); }); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 5006c4e9a..0a85cc42c 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -176,6 +176,13 @@ export class UserSettingModal extends LitElement { console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF"); } + private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) { + const enabled = e.detail?.checked; + if (typeof enabled !== "boolean") return; + + this.userSettings.set("settings.performanceOverlay", enabled); + } + private handleKeybindChange( e: CustomEvent<{ action: string; value: string }>, ) { @@ -315,6 +322,15 @@ export class UserSettingModal extends LitElement { @change=${this.toggleTerritoryPatterns} > + + + this.renderGame()); - const duration = performance.now() - start; + + this.fpsDisplay.updateFPS(duration); + if (duration > 50) { console.warn( `tick ${this.game.ticks()} took ${duration}ms to render frame`, diff --git a/src/client/graphics/layers/FPSDisplay.ts b/src/client/graphics/layers/FPSDisplay.ts new file mode 100644 index 000000000..37b3bb3cd --- /dev/null +++ b/src/client/graphics/layers/FPSDisplay.ts @@ -0,0 +1,268 @@ +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 { TogglePerformanceOverlayEvent } from "../../InputHandler"; +import { Layer } from "./Layer"; + +@customElement("fps-display") +export class FPSDisplay 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 isVisible: boolean = false; + + @state() + private isDragging: boolean = false; + + @state() + private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values + + 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 }; + + static styles = css` + .fps-display { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + z-index: 9999; + user-select: none; + cursor: move; + transition: none; + } + + .fps-display.dragging { + cursor: grabbing; + transition: none; + opacity: 0.5; + } + + .fps-line { + margin: 2px 0; + } + + .fps-good { + color: #4ade80; /* green-400 */ + } + + .fps-warning { + color: #fbbf24; /* amber-400 */ + } + + .fps-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; + } + `; + + constructor() { + super(); + } + + init() { + this.eventBus.on(TogglePerformanceOverlayEvent, () => { + this.userSettings.togglePerformanceOverlay(); + }); + } + + setVisible(visible: boolean) { + this.isVisible = visible; + } + + private handleClose() { + this.userSettings.togglePerformanceOverlay(); + } + + private handleMouseDown = (e: MouseEvent) => { + // Don't start dragging if clicking on close button + if ((e.target as HTMLElement).classList.contains("close-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); + }; + + updateFPS(frameDuration: number) { + this.isVisible = this.userSettings.performanceOverlay(); + + 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++; + + this.requestUpdate(); + } + + shouldTransform(): boolean { + return false; + } + + private getFPSColor(fps: number): string { + if (fps >= 55) return "fps-good"; + if (fps >= 30) return "fps-warning"; + return "fps-bad"; + } + + render() { + if (!this.isVisible) { + return html``; + } + + const style = ` + left: ${this.position.x}px; + top: ${this.position.y}px; + transform: none; + `; + + return html` +
+ +
+ FPS: + ${this.currentFPS} +
+
+ Avg (60s): + ${this.averageFPS} +
+
+ Frame: + ${this.frameTime}ms +
+
+ `; + } +} diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts index 66ea8f3ae..e0ee2004f 100644 --- a/src/client/graphics/layers/OptionsMenu.ts +++ b/src/client/graphics/layers/OptionsMenu.ts @@ -137,6 +137,11 @@ export class OptionsMenu extends LitElement implements Layer { this.requestUpdate(); } + private onTogglePerformanceOverlayButtonClick() { + this.userSettings.togglePerformanceOverlay(); + this.requestUpdate(); + } + init() { console.log("init called from OptionsMenu"); this.showPauseButton = @@ -251,6 +256,12 @@ export class OptionsMenu extends LitElement implements Layer { ? "Opens menu" : "Attack"), })} + ${button({ + onClick: this.onTogglePerformanceOverlayButtonClick, + title: "Performance Overlay", + children: + "🚀: " + (this.userSettings.performanceOverlay() ? "On" : "Off"), + })}