diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 1ce1dad..5841ea2 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -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 diff --git a/solitaire_engine/src/diagnostics_hud.rs b/solitaire_engine/src/diagnostics_hud.rs new file mode 100644 index 0000000..fca10ad --- /dev/null +++ b/solitaire_engine/src/diagnostics_hud.rs @@ -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` 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::() + .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>) { + 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>, + mut visible: ResMut, + mut roots: Query<&mut Visibility, With>, +) { + 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, + visible: Res, + mut texts: Query<&mut Text, With>, +) { + 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"); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index b2145b6..febd15c 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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,