feat(engine): F3-toggleable FPS / frame-time overlay

Performance work for the upcoming Android port needs a numeric
baseline we can quote across desktop and mobile, instead of "feels
slow". DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin
and renders a tiny corner readout the developer can toggle with F3.

- Hidden by default — production builds ship the plugin but the
  overlay starts invisible.
- F3 reads ButtonInput<KeyCode> directly (not gated by pause /
  modal state); diagnostics should always be reachable.
- Reads `smoothed()` FPS + frame_time so the cell isn't a jittery
  per-frame scoreboard. Format: "FPS NN \u{2022} M.MM ms".
- Anchored top-right at z = Z_SPLASH + 100 so the readout sits
  above every modal / toast / splash layer.
- Update system bails when hidden so we don't pay the
  diagnostic-store lookup or text mutation when nobody's looking.

Next up on the perf track: get the Android build target wired so we
can put real numbers in this readout from a phone or emulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-07 18:03:18 +00:00
parent 35516d31f6
commit 690e1d2ad6
3 changed files with 172 additions and 5 deletions
+6 -5
View File
@@ -10,10 +10,10 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SelectionPlugin, SettingsPlugin, SplashPlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
@@ -145,7 +145,8 @@ fn main() {
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin);
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
+164
View File
@@ -0,0 +1,164 @@
//! Optional on-screen FPS / frame-time overlay.
//!
//! Wraps Bevy's [`FrameTimeDiagnosticsPlugin`] and renders a tiny
//! corner readout that the developer (or a curious player) can toggle
//! with `F3`. Hidden by default — production builds ship the plugin
//! but the overlay starts invisible, so the production HUD is never
//! cluttered unless explicitly summoned.
//!
//! Why this exists: with an Android port on the roadmap, "feels
//! slow" became a real risk to plan around. A togglable FPS / frame-
//! time pair gives us a numeric baseline we can quote across desktop
//! and mobile, instead of optimising on vibes.
//!
//! ## Display contract
//!
//! When visible, the overlay reads `"FPS NN \u{2022} M.MM ms"` in a
//! small monospaced cell, anchored top-right. Both numbers are the
//! `smoothed()` value (Bevy's exponential moving average) — peak
//! and worst-case readings would jitter the text every frame, which
//! is harder to glance at than a smoothed reading.
//!
//! ## Hotkey scope
//!
//! `F3` is a global, gameplay-blockable toggle: the system reads
//! `ButtonInput<KeyCode>` directly and ignores the rest of the modal
//! / pause stack. The overlay is informational and shouldn't depend
//! on game state.
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::ui_theme::Z_SPLASH;
/// Z-index for the diagnostics HUD — above every modal / toast /
/// splash layer so a developer can always see the readout, no matter
/// what overlay is up.
const Z_DIAGNOSTICS_HUD: i32 = Z_SPLASH + 100;
/// Width-stable font size for the readout. Hand-tuned literal — the
/// HUD is a developer affordance and uses its own sizing rather than
/// borrowing a typography token whose meaning may drift.
const DIAGNOSTICS_FONT_SIZE: f32 = 12.0;
/// Background alpha for the readout cell. Translucent so the HUD
/// doesn't fully obscure whatever's behind it but stays legible.
const DIAGNOSTICS_BG_ALPHA: f32 = 0.7;
/// Wires the FPS / frame-time HUD overlay.
///
/// Adds [`FrameTimeDiagnosticsPlugin`] (no-op if already added — the
/// plugin's `Plugin::build` is idempotent on duplicate registration
/// in our codebase since no other site adds it). Spawns the HUD
/// hidden, registers the toggle handler, and wires the per-frame
/// text refresh.
pub struct DiagnosticsHudPlugin;
impl Plugin for DiagnosticsHudPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(FrameTimeDiagnosticsPlugin::default())
.init_resource::<DiagnosticsHudVisible>()
.add_systems(Startup, spawn_diagnostics_hud)
.add_systems(
Update,
(toggle_diagnostics_hud, update_diagnostics_hud).chain(),
);
}
}
/// Tracks whether the overlay is currently visible. Flipped by the
/// `F3` toggle; defaults to hidden so production launches start clean.
#[derive(Resource, Debug, Default)]
struct DiagnosticsHudVisible(bool);
/// Marker on the overlay's root Node — used to flip `Visibility`.
#[derive(Component, Debug)]
struct DiagnosticsHudRoot;
/// Marker on the readout `Text` node — used by the per-frame refresh
/// system to find the right text to overwrite.
#[derive(Component, Debug)]
struct DiagnosticsHudText;
/// Spawns the (initially-hidden) overlay at startup. Anchored
/// top-right with absolute positioning so it never participates in
/// the rest of the UI flex tree.
fn spawn_diagnostics_hud(mut commands: Commands, font_res: Option<Res<FontResource>>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let bg = Color::srgba(0.0, 0.0, 0.0, DIAGNOSTICS_BG_ALPHA);
commands
.spawn((
DiagnosticsHudRoot,
Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
right: Val::Px(8.0),
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
..default()
},
BackgroundColor(bg),
Visibility::Hidden,
GlobalZIndex(Z_DIAGNOSTICS_HUD),
))
.with_children(|parent| {
parent.spawn((
DiagnosticsHudText,
Text::new("FPS \u{2014}"),
TextFont {
font: font_handle,
font_size: DIAGNOSTICS_FONT_SIZE,
..default()
},
TextColor(Color::WHITE),
));
});
}
/// `F3` flips the visible flag and the overlay's `Visibility`. Reads
/// the keyboard input directly so it isn't gated by pause / modal
/// state — diagnostics should always be reachable.
fn toggle_diagnostics_hud(
keys: Res<ButtonInput<KeyCode>>,
mut visible: ResMut<DiagnosticsHudVisible>,
mut roots: Query<&mut Visibility, With<DiagnosticsHudRoot>>,
) {
if !keys.just_pressed(KeyCode::F3) {
return;
}
visible.0 = !visible.0;
let target = if visible.0 {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut v in &mut roots {
*v = target;
}
}
/// Reads the smoothed FPS + frame-time diagnostics each frame and
/// rewrites the readout text. Skipped while the overlay is hidden so
/// we don't pay the diagnostic-store lookup or the text mutation
/// when nobody's looking.
fn update_diagnostics_hud(
diagnostics: Res<DiagnosticsStore>,
visible: Res<DiagnosticsHudVisible>,
mut texts: Query<&mut Text, With<DiagnosticsHudText>>,
) {
if !visible.0 {
return;
}
let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let frame_time_ms = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FRAME_TIME)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
for mut text in &mut texts {
**text = format!("FPS {fps:.0} \u{2022} {frame_time_ms:.2} ms");
}
}
+2
View File
@@ -12,6 +12,7 @@ pub mod feedback_anim_plugin;
pub mod challenge_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
pub mod diagnostics_hud;
pub mod events;
pub mod game_plugin;
pub mod help_plugin;
@@ -85,6 +86,7 @@ pub use card_plugin::{
};
pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin;
pub use diagnostics_hud::DiagnosticsHudPlugin;
pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,