mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 23:06:06 +00:00
Display FPS monitor overview (#1573)
## 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. <img width="1126" height="845" alt="image" src="https://github.com/user-attachments/assets/a7197572-6aea-47df-9dd2-e84947c7aee0" /> ## 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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 📱 Performance Overlay -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.performance_overlay_label")}"
|
||||
description="${translateText("user_setting.performance_overlay_desc")}"
|
||||
id="performance-overlay-toggle"
|
||||
.checked=${this.userSettings.performanceOverlay()}
|
||||
@change=${this.togglePerformanceOverlay}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- ⚔️ Attack Ratio -->
|
||||
<setting-slider
|
||||
label="${translateText("user_setting.attack_ratio_label")}"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ChatModal } from "./layers/ChatModal";
|
||||
import { ControlPanel } from "./layers/ControlPanel";
|
||||
import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { FPSDisplay } from "./layers/FPSDisplay";
|
||||
import { FxLayer } from "./layers/FxLayer";
|
||||
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
||||
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
||||
@@ -202,6 +203,13 @@ export function createRenderer(
|
||||
|
||||
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
|
||||
|
||||
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
|
||||
if (!(fpsDisplay instanceof FPSDisplay)) {
|
||||
console.error("fps display not found");
|
||||
}
|
||||
fpsDisplay.eventBus = eventBus;
|
||||
fpsDisplay.userSettings = userSettings;
|
||||
|
||||
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
|
||||
if (!(spawnAd instanceof SpawnAd)) {
|
||||
console.error("spawn ad not found");
|
||||
@@ -261,6 +269,7 @@ export function createRenderer(
|
||||
spawnAd,
|
||||
gutterAdModal,
|
||||
alertFrame,
|
||||
fpsDisplay,
|
||||
];
|
||||
|
||||
return new GameRenderer(
|
||||
@@ -270,6 +279,7 @@ export function createRenderer(
|
||||
transformHandler,
|
||||
uiState,
|
||||
layers,
|
||||
fpsDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,6 +293,7 @@ export class GameRenderer {
|
||||
public transformHandler: TransformHandler,
|
||||
public uiState: UIState,
|
||||
private layers: Layer[],
|
||||
private fpsDisplay: FPSDisplay,
|
||||
) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
@@ -356,8 +367,10 @@ export class GameRenderer {
|
||||
this.transformHandler.resetChanged();
|
||||
|
||||
requestAnimationFrame(() => 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`,
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="fps-display ${this.isDragging ? "dragging" : ""}"
|
||||
style="${style}"
|
||||
@mousedown="${this.handleMouseDown}"
|
||||
>
|
||||
<button class="close-button" @click="${this.handleClose}">×</button>
|
||||
<div class="fps-line">
|
||||
FPS:
|
||||
<span class="${this.getFPSColor(this.currentFPS)}"
|
||||
>${this.currentFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="fps-line">
|
||||
Avg (60s):
|
||||
<span class="${this.getFPSColor(this.averageFPS)}"
|
||||
>${this.averageFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="fps-line">
|
||||
Frame:
|
||||
<span class="${this.getFPSColor(1000 / this.frameTime)}"
|
||||
>${this.frameTime}ms</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})}
|
||||
<!-- ${button({
|
||||
onClick: this.onToggleFocusLockedButtonClick,
|
||||
title: "Lock Focus",
|
||||
|
||||
@@ -114,6 +114,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onTogglePerformanceOverlayButtonClick() {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onExitButtonClick() {
|
||||
// redirect to the home page
|
||||
window.location.href = "/";
|
||||
@@ -298,6 +303,35 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
|
||||
@click="${this.onTogglePerformanceOverlayButtonClick}"
|
||||
>
|
||||
<img
|
||||
src=${settingsIcon}
|
||||
alt="performanceIcon"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText("user_setting.performance_overlay_label")}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.performanceOverlay()
|
||||
? translateText("user_setting.performance_overlay_enabled")
|
||||
: translateText(
|
||||
"user_setting.performance_overlay_disabled",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.performanceOverlay()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="border-t border-slate-600 pt-3 mt-4">
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded text-red-400 transition-colors"
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
<news-modal></news-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<spawn-ad></spawn-ad>
|
||||
<fps-display></fps-display>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
@@ -20,6 +20,10 @@ export class UserSettings {
|
||||
return this.get("settings.emojis", true);
|
||||
}
|
||||
|
||||
performanceOverlay() {
|
||||
return this.get("settings.performanceOverlay", false);
|
||||
}
|
||||
|
||||
alertFrame() {
|
||||
return this.get("settings.alertFrame", true);
|
||||
}
|
||||
@@ -66,6 +70,10 @@ export class UserSettings {
|
||||
this.set("settings.emojis", !this.emojis());
|
||||
}
|
||||
|
||||
togglePerformanceOverlay() {
|
||||
this.set("settings.performanceOverlay", !this.performanceOverlay());
|
||||
}
|
||||
|
||||
toggleAlertFrame() {
|
||||
this.set("settings.alertFrame", !this.alertFrame());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user