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"), + })}