//! Persistent in-game HUD: score, move count, elapsed time, mode badge, //! daily-challenge constraint, and undo count. //! //! The HUD spawns once at startup and lives for the app's lifetime. Text is //! refreshed whenever `GameStateResource` changes (which happens on every move //! and every elapsed-time tick), so score, moves, and timer all stay current //! without a separate tick system. use bevy::prelude::*; use bevy::window::WindowResized; mod fx; mod interaction; mod spawn; mod updates; pub use fx::*; use interaction::*; use spawn::*; use updates::*; // On wasm32 AvatarPlugin is gated out; define a placeholder type so the // Option> parameters below compile without changes. // The resource is never inserted on wasm, so every call resolves to None. #[cfg(target_arch = "wasm32")] #[derive(bevy::prelude::Resource)] struct AvatarResource(Option>); use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::{GameMutation, NewGameRequestWriters}; #[cfg(target_os = "android")] use crate::input_plugin::TouchDragSet; use crate::layout::HUD_BAND_HEIGHT; use crate::layout::LayoutSystem; use crate::pause_plugin::PausedResource; use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT}; use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; #[cfg(target_os = "android")] use crate::resources::{DragState, GameInputConsumedResource}; use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop}; use crate::selection_plugin::SelectionState; use crate::settings_plugin::SettingsResource; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_modal::ModalScrim; use crate::ui_theme::SPACE_2; use crate::ui_theme::UiTextFx; use crate::ui_theme::{ ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS, MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, scaled_duration, }; use crate::ui_tooltip::Tooltip; use solitaire_data::SyncBackend; /// Marker on the score text node. #[derive(Component, Debug)] pub struct HudScore; /// Marker on the move-count text node. #[derive(Component, Debug)] pub struct HudMoves; /// Marker on the elapsed-time text node. #[derive(Component, Debug)] pub struct HudTime; /// Marker on the mode badge text node. #[derive(Component, Debug)] pub struct HudMode; /// Marker on the daily-challenge constraint text node. /// /// Displays the active goal (time limit or score target) when a daily challenge /// is in progress. Empty string when no challenge is active or the game is won. #[derive(Component, Debug)] pub struct HudChallenge; /// Marker on the "won this deal before" indicator text node. /// /// Displays `"✓ Won before"` when the current deal's seed + draw_mode + /// mode triple matches one of the entries in `ReplayHistoryResource`. /// Empty string otherwise (including won games — the score readout /// already conveys the win on the active deal). Only meaningful for /// Classic / Zen / Challenge — daily-challenge and time-attack seeds /// are filtered out implicitly because their replay entries always /// carry a different mode tag. #[derive(Component, Debug)] pub struct HudWonPreviously; /// Marker on the undo-count text node. /// /// Shows how many undos have been used this game. Displayed in amber when /// `undo_count > 0` because using undo blocks the no-undo achievement. #[derive(Component, Debug)] pub struct HudUndos; /// Marker on the auto-complete badge text node. /// /// Displays `"AUTO"` in green while `AutoCompleteState.active` is true; /// empty string otherwise. #[derive(Component, Debug)] pub struct HudAutoComplete; /// Marker on the stock-recycle counter text node. /// /// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw /// mode, so the player can track stock recycling in both Draw-One and /// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string) /// until the first recycle occurs. #[derive(Component, Debug)] pub struct HudRecycles; /// Marker on the draw-cycle indicator text node. /// /// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the /// number of cards that will be drawn on the next stock click /// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty /// (recycle available). Hidden (empty string) in Draw-One mode or after the /// game is won. #[derive(Component, Debug)] pub struct HudDrawCycle; /// Marker on the keyboard-selection indicator text node. /// /// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty /// string when no pile is selected. Uses a light-yellow colour so it stands /// out from the other white HUD items. #[derive(Component, Debug)] pub struct HudSelection; /// Marker on the HUD band background node (the translucent band behind buttons). #[derive(Component, Debug)] pub struct HudBand; /// Marker on the HUD score/info column root node. #[derive(Component, Debug)] pub struct HudColumn; /// Marker on the action button bar root node. #[derive(Component, Debug)] pub struct HudActionBar; /// Set wrapping the chained HUD button/popover interaction systems. Other /// keyboard consumers order themselves around it (e.g. /// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is /// deterministic (#143). #[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct HudButtons; /// Marker on the text node inside each touch-layout action-bar button. /// Used by `resize_action_bar_labels` to update font size on window resize. #[derive(Component, Debug)] struct ActionButtonLabel; /// Marker on the circular profile-picture button anchored to the /// top-right of the HUD band. Pressing it opens the Profile overlay. /// Shows the server avatar image when loaded; falls back to the player's /// initial on a filled disc when no image is available. #[derive(Component, Debug)] pub struct HudAvatar; /// Controls whether the in-game HUD (band, score column, action buttons) is /// visible. Toggled on Android by tapping empty board space; always `Visible` /// on desktop. Resets to `Visible` whenever a modal opens. #[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum HudVisibility { #[default] Visible, Hidden, } #[cfg(target_os = "android")] #[derive(Resource, Debug, Default)] struct HudTapTracker { start_pos: Option, /// Set `true` when the finger-down hit an action button so the /// finger-up never toggles bar visibility. started_on_button: bool, } #[cfg(target_os = "android")] const HUD_TAP_SLOP_PX: f32 = 25.0; /// Drives the score-readout pulse: scales the [`HudScore`] text from /// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by /// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score /// entity whenever the score increases; removed once `elapsed >= /// duration`. #[derive(Component, Debug, Clone, Copy)] pub struct ScorePulse { /// Seconds elapsed since the pulse started. pub elapsed: f32, /// Total duration. Zero under `AnimSpeed::Instant` — the system /// snaps the scale back to 1.0 on first tick so no half-state /// is ever shown. pub duration: f32, } /// Marker on a transient floating "+N" text spawned next to the score /// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more. /// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`, /// then despawns. Kept rare/meaningful by the threshold gate. #[derive(Component, Debug, Clone, Copy)] pub struct ScoreFloater { /// Seconds elapsed since the floater spawned. pub elapsed: f32, /// Total lifetime. Zero under `AnimSpeed::Instant` — the system /// despawns it on first tick. pub duration: f32, } /// Drives the streak-milestone flourish: scales the [`HudScore`] text /// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over /// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by /// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it /// [`ACCENT_SECONDARY`] for the same window before restoring the /// original colour. /// /// The streak readout currently lives in the Stats overlay (press /// `S`) — there is no always-on HUD streak counter — so the flourish /// piggybacks on the score readout, which is the most prominent /// always-visible HUD number. Mirrors the `FoundationFlourish` /// pattern: triangular scale curve, fixed duration, restores state /// when the timer expires. /// /// Inserted on `HudScore` entities by `start_streak_flourish` when a /// `WinStreakMilestoneEvent` fires; removed once `elapsed >= /// duration` so the readout returns to its rest state for the next /// frame's transform sync. /// /// Coexists with [`ScorePulse`]: the streak flourish lives on a /// dedicated marker so a streak-crossing win that also ticks the /// score (every win does) doesn't have the two animations stomp on /// each other's `Transform.scale` writes — the streak flourish runs /// in a `Without` query so only the loudest of the two /// celebrations is active at a time. #[derive(Component, Debug, Clone, Copy)] pub struct StreakFlourish { /// The streak milestone that triggered this flourish (3, 5, 10). /// Carried for diagnostic logging only — the visual is identical /// for every threshold so play-testing can decide later whether /// to differentiate. pub streak: u32, /// Seconds elapsed since the flourish began. pub elapsed: f32, /// Total animation length in seconds. Zero under /// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system /// snaps the scale back to 1.0 on the first tick so no half-state /// is ever shown. pub duration: f32, /// The score readout's colour before the flourish began — /// restored when the timer expires so the readout returns to its /// resting `TEXT_PRIMARY` (or whatever it was) tint. pub original_color: Color, } /// Tracks the score from the previous frame so the HUD can detect /// changes without a `ScoreChangedEvent`. The plugin wires this to the /// pulse + floater systems on every `Update`. #[derive(Resource, Debug, Default, Clone, Copy)] pub struct PreviousScore(pub i32); /// Score increase (in points) below which no floating "+N" is spawned. /// 50 keeps the feedback for foundation drops and tableau-to-foundation /// promotions; single-card placements (which can earn as little as +5) /// stay quiet so the floater feels like a reward instead of noise. pub const SCORE_FLOATER_THRESHOLD: i32 = 50; /// Marker shared by every clickable HUD action button so a single /// `paint_action_buttons` system can recolour them on hover/press without /// each button needing its own paint handler. #[derive(Component, Debug)] pub struct ActionButton; /// Marker on rows inside a popover panel ([`ModesPopover`] or /// [`MenuPopover`]). Popover rows already carry `ActionButton` so the /// hover/press paint path applies to them, but the auto-fade applied /// to the top-level action bar must NOT also fade these rows — the /// popover only renders when the player has explicitly opened it, so /// its content should always be at full opacity. `apply_action_fade` /// excludes entities with this marker via `Without`. #[derive(Component, Debug)] pub struct PopoverRow; /// Marker on the "New Game" action button anchored top-right of the play /// area. Click fires [`NewGameRequestEvent`]; the existing /// `ConfirmNewGameScreen` modal handles confirmation when a game is in /// progress. #[derive(Component, Debug)] pub struct NewGameButton; /// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`], /// mirroring the `U` keyboard accelerator. #[derive(Component, Debug)] pub struct UndoButton; /// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`], /// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume /// affordance dismisses it from the paused state. #[derive(Component, Debug)] pub struct PauseButton; /// Accumulated hold time on the Undo button, driving hold-to-repeat undo /// (Phase F). `handle_undo_button` owns the instant first undo on press; /// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s /// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every /// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the /// normal request path, so the existing scoring penalty applies per step. #[derive(Resource, Debug, Default)] pub struct UndoHoldState { /// Seconds the Undo button has been continuously held. held_secs: f32, } /// Marker on the "Help" action button. Click fires [`HelpRequestEvent`], /// mirroring the `F1` keyboard accelerator. #[derive(Component, Debug)] pub struct HelpButton; /// Marker on the "Hint" action button. Click spawns an async solver task /// (same as the `H` keyboard accelerator) and highlights the suggested card. #[derive(Component, Debug)] pub struct HintButton; /// Marker on the touch action bar's "Draw" button (Phase F). Click fires /// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the /// draw action is within thumb reach without stretching to the top of a /// tall folded screen. Touch layout only; desktop draws via stock /// click / `D` / `Space`. #[derive(Component, Debug)] pub struct DrawButton; /// Android HUD label for the Hint button — shared with the help screen's /// controls reference so both always agree. #[cfg(target_os = "android")] pub(crate) const ANDROID_HINT_LABEL: &str = "Hint"; /// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`] /// on Android so the bar and the help screen's controls reference can /// never drift; the non-Android value only exists so the touch spawn /// path stays host-compilable for tests. #[cfg(target_os = "android")] const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL; #[cfg(not(target_os = "android"))] const TOUCH_HINT_LABEL: &str = "Hint"; #[cfg(target_os = "android")] const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0); #[cfg(not(target_os = "android"))] const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2; #[cfg(target_os = "android")] const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0; #[cfg(not(target_os = "android"))] const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0; #[cfg(target_os = "android")] const HINT_WON_MSG: &str = "Game won! Tap New Game to play again"; #[cfg(not(target_os = "android"))] const HINT_WON_MSG: &str = "Game won! Press N for a new game"; /// Marker on the "Modes" action button. Click toggles the [`ModesPopover`] /// (a small dropdown panel) below the action bar. Each popover row starts /// the corresponding game mode. #[derive(Component, Debug)] pub struct ModesButton; /// Marker on the dropdown panel that opens below the [`ModesButton`]. /// Spawned on first click, despawned on second click or on mode select. #[derive(Component, Debug)] pub struct ModesPopover; /// One row inside the [`ModesPopover`]. The variant carries which event /// the click handler should fire — Classic uses `NewGameRequestEvent` /// directly, the others go through their `Start*RequestEvent` so the /// existing keyboard handler's level gate / resource setup runs. #[derive(Component, Debug, Clone, Copy)] pub enum ModeOption { Classic, DailyChallenge, Zen, Challenge, TimeAttack, } /// Marker on the "Menu" action button. Click toggles the [`MenuPopover`] /// which exposes the Home / Profile / Stats / Achievements / /// Leaderboard / Settings / Help overlays without needing the /// M/P/S/A/L/O/F1 hotkeys. #[derive(Component, Debug)] pub struct MenuButton; /// Marker on the dropdown panel that opens below the [`MenuButton`]. #[derive(Component, Debug)] pub struct MenuPopover; /// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities /// while they are open. External systems (e.g. `PausePlugin`) query this to /// determine whether a HUD popover is currently visible without importing the /// individual popover types. #[derive(Component, Debug)] pub struct HudPopoverOpen; /// Fullscreen transparent backdrop spawned behind the [`MenuPopover`]. /// Pressing it (tap anywhere outside the popover) light-dismisses the menu. #[derive(Component, Debug)] struct MenuPopoverBackdrop; /// Fullscreen transparent backdrop spawned behind the [`ModesPopover`]. /// Pressing it (tap anywhere outside the popover) light-dismisses it. #[derive(Component, Debug)] struct ModesPopoverBackdrop; /// One row inside the [`MenuPopover`]. The variant selects which /// `Toggle*RequestEvent` the click handler fires. Rows render grouped /// under section headers (Play · You · Community · System); mode /// selection lives on Home, so there is no Modes row here. #[derive(Component, Debug, Clone, Copy)] pub enum MenuOption { Home, Profile, Stats, Achievements, Leaderboard, Settings, Help, } /// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module /// can use it as a `const` without a non-const expression in `ZIndex(...)`. const Z_HUD: i32 = crate::ui_theme::Z_HUD; const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP; const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER; const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP; /// Idle / hover / pressed colours shared by every action button. Aliased /// to the theme tokens so the HUD picks up palette changes for free. const ACTION_BTN_IDLE: Color = BG_ELEVATED; const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI; const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED; /// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input. pub struct HudPlugin; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { // The click handlers write to messages registered elsewhere by their // owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`, // `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`, // `input_plugin`). Re-register defensively so the HUD plugin works in // isolation under `MinimalPlugins` (tests). `add_message` is // idempotent. app.add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() // Escape-close handlers for popovers read this; init defensively // so HudPlugin works under MinimalPlugins in tests. .init_resource::>() // WindowResized is registered by table_plugin; re-register // defensively so the HUD plugin works standalone in tests. .add_message::() .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar)) // HUD text updaters run as one deterministic chain (they write // disjoint Text nodes, but Bevy can't prove it); update_hud also // reads AutoCompleteState, so the chain sits after the // auto-complete detect/drive chain (#143). .add_systems( Update, ( update_hud, update_selection_hud.run_if( resource_exists_and_changed:: .or(resource_exists_and_changed::), ), update_won_previously, ) .chain() .after(GameMutation) .after(crate::auto_complete_plugin::AutoComplete) .in_set(UiTextFx) .ambiguous_with(UiTextFx), ) // HUD chrome visibility: modal-restore writes HudVisibility, the // applier consumes it, and the layout recompute reads it — a // fixed chain instead of three racing systems (#143). .add_systems( Update, (restore_hud_on_modal, apply_hud_visibility) .chain() .before(LayoutSystem::UpdateOnResize), ) .add_systems( Update, ( update_hud_avatar.after(crate::settings_plugin::SettingsMutation), handle_avatar_button.ambiguous_with(HudButtons), ), ) .add_systems( Update, announce_auto_complete .after(GameMutation) .after(crate::auto_complete_plugin::AutoComplete) .in_set(crate::game_plugin::InfoToastWriters) .ambiguous_with(crate::game_plugin::InfoToastWriters), ) // Typography rescale touches HUD TextFont only, but orders after // the board painters that resize card/label text (#143). .add_systems( Update, update_hud_typography.after(crate::card_plugin::BoardVisuals), ) .add_systems( Update, ( detect_score_change, advance_score_pulse, advance_score_floater, ) .chain() .after(GameMutation) .in_set(UiTextFx) .ambiguous_with(UiTextFx) .ambiguous_with(crate::card_plugin::BoardVisuals), ) .add_systems( Update, (start_streak_flourish, advance_streak_flourish) .chain() .after(GameMutation) .in_set(UiTextFx) .ambiguous_with(UiTextFx) .ambiguous_with(crate::card_plugin::BoardVisuals), ) .add_systems( Update, ( handle_new_game_button .in_set(NewGameRequestWriters) .ambiguous_with(NewGameRequestWriters), handle_undo_button .in_set(crate::game_plugin::UndoRequestWriters) .ambiguous_with(crate::game_plugin::UndoRequestWriters) .before(GameMutation), repeat_undo_on_hold .in_set(crate::game_plugin::UndoRequestWriters) .ambiguous_with(crate::game_plugin::UndoRequestWriters) .before(GameMutation), handle_draw_button .in_set(crate::game_plugin::DrawRequestWriters) .ambiguous_with(crate::game_plugin::DrawRequestWriters) .before(GameMutation), handle_pause_button, handle_help_button, handle_hint_button .after(GameMutation) .in_set(crate::game_plugin::InfoToastWriters) .ambiguous_with(crate::game_plugin::InfoToastWriters), handle_modes_button, handle_mode_option_click .in_set(NewGameRequestWriters) .ambiguous_with(NewGameRequestWriters), handle_modes_backdrop_click, close_modes_popover_on_escape, handle_menu_button, handle_menu_option_click, handle_menu_backdrop_click, close_menu_popover_on_escape, paint_action_buttons, ) .chain() .in_set(HudButtons) .before(crate::ui_focus::FocusKeys), ) // Fade lives in `Last` so it always overrides whatever the // hover/paint pass set on `BackgroundColor` this frame. // Otherwise on a hover-state change (`Changed`), // `paint_action_buttons` would clobber the alpha back to 1.0 // mid-fade and produce a visible blip. ; // Desktop-only: cursor-proximity fade. On Android the bar // visibility is toggled explicitly; cursor_position() returning // Some(touch_pos) during a tap would otherwise fade the bar out. #[cfg(not(target_os = "android"))] app.add_systems(Last, (update_action_fade, apply_action_fade).chain()); #[cfg(target_os = "android")] { app.init_resource::() .add_message::() .add_systems( Update, toggle_hud_on_tap .after(TouchDragSet::AfterStartDrag) .in_set(TouchDragSet::BeforeEndDrag), ); app.add_systems( Update, resize_action_bar_labels .run_if(resource_exists_and_changed::), ); } } } #[cfg(test)] mod tests;