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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user