//! 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 solitaire_core::card::Suit; use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::progress_plugin::ProgressResource; use crate::settings_plugin::SettingsResource; use crate::layout::HUD_BAND_HEIGHT; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, }; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; use crate::resources::GameStateResource; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_tooltip::Tooltip; /// 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 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; /// 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, } /// 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 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; /// Marker on the "Help" action button. Click fires [`HelpRequestEvent`], /// mirroring the `F1` keyboard accelerator. #[derive(Component, Debug)] pub struct HelpButton; /// 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 Stats / Achievements / Profile / Settings / /// Leaderboard overlays without needing the S/A/P/O/L hotkeys. #[derive(Component, Debug)] pub struct MenuButton; /// Marker on the dropdown panel that opens below the [`MenuButton`]. #[derive(Component, Debug)] pub struct MenuPopover; /// One row inside the [`MenuPopover`]. The variant selects which /// `Toggle*RequestEvent` the click handler fires. #[derive(Component, Debug, Clone, Copy)] pub enum MenuOption { Stats, Achievements, Profile, Settings, Leaderboard, } /// 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; /// 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::() .init_resource::() .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons)) .add_systems(Update, update_hud.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, update_selection_hud) .add_systems( Update, ( detect_score_change, advance_score_pulse, advance_score_floater, ) .chain() .after(GameMutation), ) .add_systems( Update, ( handle_new_game_button, handle_undo_button, handle_pause_button, handle_help_button, handle_modes_button, handle_mode_option_click, handle_menu_button, handle_menu_option_click, paint_action_buttons, ), ); } } /// Spawns the translucent HUD band that anchors the action buttons /// and primary readouts visually. Sits behind every other HUD element /// (one z-rung below `Z_HUD`) so it reads as the band's "container" /// without intercepting clicks from the buttons it sits under. /// /// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the /// same constant the card layout reserves at the top), so the band's /// bottom edge lines up exactly with the top edge of the highest /// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70 /// alpha, so the green felt reads through subtly. fn spawn_hud_band(mut commands: Commands) { commands.spawn(( Node { position_type: PositionType::Absolute, top: Val::Px(0.0), left: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(HUD_BAND_HEIGHT), ..default() }, BackgroundColor(BG_HUD_BAND), // Sit one z-rung below the HUD content so the buttons and text // paint on top, but above the card sprites (which are 2D-world // entities and rendered behind UI regardless). ZIndex(Z_HUD - 1), )); } /// Spawns the in-game HUD as a 4-tier vertical column anchored to the /// top-left of the play area. /// /// Tiers (top to bottom): /// 1. **Primary** — Score (display weight) · Moves · Timer. /// Always visible during gameplay. /// 2. **Mode context** — Mode badge · Daily-challenge constraint · /// Draw-cycle indicator. Each cell is empty when not relevant; the /// row collapses visually when all cells are empty. /// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge. /// Both penalty counters share `STATE_WARNING` (the audit found /// they were inconsistent: Undos amber, Recycles white). /// 4. **Selection** — keyboard-driven pile selector chip. /// /// The audit identified the original single-row layout (10 readouts in /// one horizontal flex row, 5+ colour families competing) as the /// player's #1 complaint. This restructure groups by purpose, lets /// transient items disappear cleanly, and uses the typography scale to /// make Score the visual protagonist. fn spawn_hud(font_res: Option>, mut commands: Commands) { let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(); let font_score = TextFont { font: font_handle.clone(), font_size: TYPE_HEADLINE, ..default() }; let font_lg = TextFont { font: font_handle.clone(), font_size: TYPE_BODY_LG, ..default() }; let font_body = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; let row_node = || Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_3, align_items: AlignItems::Baseline, ..default() }; commands .spawn(( Node { position_type: PositionType::Absolute, left: VAL_SPACE_3, top: VAL_SPACE_2, flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, ..default() }, ZIndex(Z_HUD), )) .with_children(|hud| { // Tier 1 — primary readouts. Score is the protagonist (HEADLINE); // Moves and Timer are supporting context (BODY_LG, secondary tone). hud.spawn(row_node()).with_children(|t1| { t1.spawn(( HudScore, Tooltip::new("Points earned this game. Hidden in Zen mode."), Text::new("Score: 0"), font_score.clone(), TextColor(TEXT_PRIMARY), )); t1.spawn(( HudMoves, Tooltip::new( "Moves you've made this game. Counts placements and stock draws.", ), Text::new("Moves: 0"), font_lg.clone(), TextColor(TEXT_SECONDARY), )); t1.spawn(( HudTime, Tooltip::new("Time on this game. Counts down in Time Attack."), Text::new("0:00"), font_lg.clone(), TextColor(TEXT_SECONDARY), )); }); // Tier 2 — mode context. Each cell is empty until update_hud // populates it (and clears it when no longer relevant), so the // row collapses when nothing in this tier applies. hud.spawn(row_node()).with_children(|t2| { t2.spawn(( HudMode, Tooltip::new("Active game mode. Click Modes to switch."), Text::new(""), font_body.clone(), TextColor(ACCENT_PRIMARY), )); t2.spawn(( HudChallenge, Tooltip::new("Today's daily challenge target. Beat it for bonus XP."), Text::new(""), font_body.clone(), TextColor(STATE_INFO), )); t2.spawn(( HudDrawCycle, Tooltip::new("Cards drawn on the next stock click in Draw-Three."), Text::new(""), font_body.clone(), TextColor(STATE_INFO), )); }); // Tier 3 — penalty / bonus. Undos and Recycles share the // warning hue so they read as the same category ("you took a // penalty"); the auto-complete badge stays success-green. hud.spawn(row_node()).with_children(|t3| { t3.spawn(( HudUndos, Tooltip::new( "Undos used this game. Any undo blocks the No Undo achievement.", ), Text::new(""), font_body.clone(), TextColor(STATE_WARNING), )); t3.spawn(( HudRecycles, Tooltip::new( "Times you've recycled the stock. Three or more unlocks Comeback.", ), Text::new(""), font_body.clone(), TextColor(STATE_WARNING), )); t3.spawn(( HudAutoComplete, Tooltip::new("Board is solvable from here. Press Enter to auto-finish."), Text::new(""), font_body.clone(), TextColor(STATE_SUCCESS), )); }); // Tier 4 — selection chip. Stays in HUD for now; a future // pass can reposition it next to the selected pile. hud.spawn(row_node()).with_children(|t4| { t4.spawn(( HudSelection, Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."), Text::new(""), font_body, TextColor(ACCENT_SECONDARY), )); }); }); } /// Spawns the action button bar anchored to the top-right of the window. /// Each child is a clickable button mirroring a keyboard accelerator — /// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons /// are the primary entry point and the hotkeys are optional. /// /// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost /// because it's the most consequential action; the destructive button sits /// on its own visual edge. fn spawn_action_buttons(font_res: Option>, mut commands: Commands) { let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: 16.0, ..default() }; commands .spawn(( Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, top: VAL_SPACE_2, flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_2, align_items: AlignItems::Center, ..default() }, ZIndex(Z_HUD), )) .with_children(|row| { // Menu and Modes don't have a single hotkey accelerator // (each row inside their popover has its own); their button // labels carry the dropdown chevron in lieu of a key chip. // // The trailing `order` argument is the per-button index in // visual reading order (left → right). It feeds // `Focusable { group: Hud, order }` so Tab cycles the action // bar in the same order the eye scans it. spawn_action_button( row, MenuButton, "Menu \u{25BE}", None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, ); spawn_action_button( row, UndoButton, "Undo", Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, ); spawn_action_button( row, PauseButton, "Pause", Some("Esc"), "Pause the game and freeze the timer.", &font, 2, ); spawn_action_button( row, HelpButton, "Help", Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, ); spawn_action_button( row, ModesButton, "Modes \u{25BE}", None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 4, ); spawn_action_button( row, NewGameButton, "New Game", Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 5, ); }); } /// Spawns a single action button as a child of `row`. Each button shares /// the same node geometry, idle colour, and `ActionButton` marker so /// `paint_action_buttons` can recolour all of them with one query. /// /// `order` is the button's index inside the action bar (0 for the /// leftmost). It propagates into the [`Focusable`] this function inserts /// so Phase 2's keyboard focus ring cycles the HUD in visual order. /// /// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every /// action button ships with one — there is no opt-out — because each button /// represents a player-triggered action and benefits from a one-line /// reminder of what it does. #[allow(clippy::too_many_arguments)] fn spawn_action_button( row: &mut ChildSpawnerCommands, marker: M, label: &str, hotkey: Option<&'static str>, tooltip: &'static str, font: &TextFont, order: i32, ) { let hotkey_font = TextFont { font: font.font.clone(), font_size: TYPE_CAPTION, ..default() }; row.spawn(( marker, ActionButton, Button, Tooltip::new(tooltip), // Joins the `Hud` focus group at the supplied order so Tab // cycles HUD buttons left-to-right under Phase 2. The HUD focus // ring still only engages when a HUD button is hovered (or in // future phases, when the player explicitly switches groups); // the marker just declares membership. Focusable { group: FocusGroup::Hud, order, }, Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), column_gap: VAL_SPACE_2, ..default() }, BackgroundColor(ACTION_BTN_IDLE), BorderColor::all(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); if let Some(key) = hotkey { // Hotkey hint rendered as a dim caption next to the label — // keeps the keyboard accelerator discoverable without // hijacking the button's primary affordance. b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY))); } }); } /// `Changed` filter ensures we only react on the frame the /// interaction state transitions, avoiding repeat events while the button /// is held down. Each click handler fires the corresponding request event, /// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside /// their existing keyboard handlers. fn handle_new_game_button( interaction_query: Query<&Interaction, (With, Changed)>, mut new_game: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { new_game.write(NewGameRequestEvent::default()); } } } fn handle_undo_button( interaction_query: Query<&Interaction, (With, Changed)>, mut undo: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { undo.write(UndoRequestEvent); } } } fn handle_pause_button( interaction_query: Query<&Interaction, (With, Changed)>, mut pause: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { pause.write(PauseRequestEvent); } } } fn handle_help_button( interaction_query: Query<&Interaction, (With, Changed)>, mut help: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { help.write(HelpRequestEvent); } } } /// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on /// second click. Mode rows are populated per the player's current level so /// only unlocked options appear. fn handle_modes_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, progress: Option>, daily: Option>, font_res: Option>, mut commands: Commands, ) { let pressed = interaction_query .iter() .any(|i| *i == Interaction::Pressed); if !pressed { return; } if let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); } else { spawn_modes_popover( &mut commands, progress.as_deref(), daily.as_deref(), font_res.as_deref(), ); } } /// Spawns the modes popover anchored just below the action bar's right /// edge. Always includes Classic; includes Daily Challenge when a daily /// resource is loaded; includes Zen / Challenge / Time Attack once the /// player reaches the challenge unlock level. fn spawn_modes_popover( commands: &mut Commands, progress: Option<&ProgressResource>, daily: Option<&DailyChallengeResource>, font_res: Option<&FontResource>, ) { let level = progress.map_or(0, |p| p.0.level); let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: 15.0, ..default() }; // Each row carries a tooltip alongside its label so hover reveals // a one-line description of what the mode does — mirroring the // tooltips on the action-bar buttons that opened this popover. let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![( ModeOption::Classic, "Classic", "Standard Klondike. Score, timer, and full progression.", )]; if daily.is_some() { rows.push(( ModeOption::DailyChallenge, "Daily Challenge", "Today's seeded deal. Same for every player worldwide.", )); } if level >= CHALLENGE_UNLOCK_LEVEL { rows.push(( ModeOption::Zen, "Zen", "No timer, no score, no penalties. Just play.", )); rows.push(( ModeOption::Challenge, "Challenge", "Hand-picked hard seeds. No undo allowed.", )); rows.push(( ModeOption::TimeAttack, "Time Attack", "Win as many games as you can in ten minutes.", )); } commands .spawn(( ModesPopover, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, top: Val::Px(50.0), flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(BG_ELEVATED), ZIndex(Z_HUD + 5), )) .with_children(|panel| { for (option, label, tooltip) in rows { panel .spawn(( option, ActionButton, Button, Tooltip::new(tooltip), Node { padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)), justify_content: JustifyContent::FlexStart, align_items: AlignItems::Center, min_width: Val::Px(150.0), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(ACTION_BTN_IDLE), )) .with_children(|b| { b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); }); } }); } /// Dispatches the click on a popover row to the matching request event, /// then despawns the popover. /// /// Classic uses [`NewGameRequestEvent`] directly; the other modes use /// their `Start*RequestEvent` so the existing keyboard handler runs /// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) — /// the popover stays a thin entry point and never duplicates that logic. #[allow(clippy::too_many_arguments)] fn handle_mode_option_click( interaction_query: Query<(&Interaction, &ModeOption), Changed>, popovers: Query>, mut new_game: MessageWriter, mut zen: MessageWriter, mut challenge: MessageWriter, mut time_attack: MessageWriter, mut daily: MessageWriter, mut commands: Commands, ) { let mut clicked_any = false; for (interaction, option) in &interaction_query { if *interaction != Interaction::Pressed { continue; } clicked_any = true; match option { ModeOption::Classic => { new_game.write(NewGameRequestEvent::default()); } ModeOption::DailyChallenge => { daily.write(StartDailyChallengeRequestEvent); } ModeOption::Zen => { zen.write(StartZenRequestEvent); } ModeOption::Challenge => { challenge.write(StartChallengeRequestEvent); } ModeOption::TimeAttack => { time_attack.write(StartTimeAttackRequestEvent); } } } if clicked_any && let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); } } /// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on /// second click. The popover lists the five overlays previously only /// reachable via the S / A / P / O / L hotkeys. fn handle_menu_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, font_res: Option>, mut commands: Commands, ) { let pressed = interaction_query .iter() .any(|i| *i == Interaction::Pressed); if !pressed { return; } if let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); } else { spawn_menu_popover(&mut commands, font_res.as_deref()); } } /// Spawns the menu popover anchored just below the action bar, with one /// row per overlay. Each row dispatches its corresponding /// `Toggle*RequestEvent` so the existing toggle handler runs (and the /// HUD never duplicates spawn / despawn / fetch logic). fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: 15.0, ..default() }; // Each row carries a tooltip alongside its label so hover reveals // a one-line description of what each overlay shows — mirroring // the tooltips on the action-bar buttons that opened this popover. let rows: [(MenuOption, &'static str, &'static str); 5] = [ ( MenuOption::Stats, "Stats", "Lifetime totals: wins, streaks, fastest time, best score.", ), ( MenuOption::Achievements, "Achievements", "Browse unlocked achievements and the rewards still ahead.", ), ( MenuOption::Profile, "Profile", "Your level, XP progress, and sync status.", ), ( MenuOption::Settings, "Settings", "Audio, animations, theme, draw mode, and sync.", ), ( MenuOption::Leaderboard, "Leaderboard", "Top players from your sync server. Opt in from Profile.", ), ]; commands .spawn(( MenuPopover, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, top: Val::Px(50.0), flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(BG_ELEVATED), ZIndex(Z_HUD + 5), )) .with_children(|panel| { for (option, label, tooltip) in rows { panel .spawn(( option, ActionButton, Button, Tooltip::new(tooltip), Node { padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)), justify_content: JustifyContent::FlexStart, align_items: AlignItems::Center, min_width: Val::Px(150.0), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(ACTION_BTN_IDLE), )) .with_children(|b| { b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); }); } }); } /// Dispatches the click on a menu row to the matching toggle event, /// then despawns the popover. #[allow(clippy::too_many_arguments)] fn handle_menu_option_click( interaction_query: Query<(&Interaction, &MenuOption), Changed>, popovers: Query>, mut stats: MessageWriter, mut achievements: MessageWriter, mut profile: MessageWriter, mut settings: MessageWriter, mut leaderboard: MessageWriter, mut commands: Commands, ) { let mut clicked_any = false; for (interaction, option) in &interaction_query { if *interaction != Interaction::Pressed { continue; } clicked_any = true; match option { MenuOption::Stats => { stats.write(ToggleStatsRequestEvent); } MenuOption::Achievements => { achievements.write(ToggleAchievementsRequestEvent); } MenuOption::Profile => { profile.write(ToggleProfileRequestEvent); } MenuOption::Settings => { settings.write(ToggleSettingsRequestEvent); } MenuOption::Leaderboard => { leaderboard.write(ToggleLeaderboardRequestEvent); } } } if clicked_any && let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); } } /// Visual feedback for every action button — paints idle / hover / pressed /// states by mutating `BackgroundColor` whenever the interaction state /// changes. One query covers all action buttons via the shared /// `ActionButton` marker. #[allow(clippy::type_complexity)] fn paint_action_buttons( mut buttons: Query< (&Interaction, &mut BackgroundColor), (With, Changed), >, ) { for (interaction, mut bg) in &mut buttons { bg.0 = match interaction { Interaction::Pressed => ACTION_BTN_PRESSED, Interaction::Hovered => ACTION_BTN_HOVER, Interaction::None => ACTION_BTN_IDLE, }; } } /// Formats a time-limit value in seconds as `"mm:ss"` for HUD display. /// /// For example `format_time_limit(300)` returns `"5:00"`. pub fn format_time_limit(secs: u64) -> String { let m = secs / 60; let s = secs % 60; format!("{m}:{s:02}") } // --------------------------------------------------------------------------- // Score-change feedback (G2) // // The flow for each Update tick: // 1. `detect_score_change` diffs `GameStateResource.score` against // `PreviousScore`. On any positive delta it inserts/refreshes // `ScorePulse` on the score readout; on a delta ≥ // `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text // anchored just below the score. // 2. `advance_score_pulse` ticks the pulse component, applies the // triangular 1.0 → 1.1 → 1.0 scale curve, and removes the // component on completion. // 3. `advance_score_floater` drifts each floater upward, fades it to // transparent, and despawns it when its lifetime expires. // // The threshold of 50 (a foundation promotion's typical bonus) keeps // floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`. // --------------------------------------------------------------------------- /// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure /// function so the test suite can assert on the curve directly /// without spinning up a Bevy app. /// /// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`, /// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and /// ends at 1.1 instead of 1.0. The corrected form below preserves the /// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous /// triangle peaking at 1.1. fn score_pulse_scale(t: f32) -> f32 { let clamped = t.clamp(0.0, 1.0); if clamped < 0.5 { 1.0 + 0.2 * clamped } else { 1.1 - 0.2 * (clamped - 0.5) } } /// Vertical pixels the floating "+N" drifts up over its lifetime. const FLOATER_DRIFT_PX: f32 = 40.0; /// Diffs the current `GameStateResource.score` against /// [`PreviousScore`]. On a positive delta: /// /// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity /// so the readout pulses 1.0 → 1.1 → 1.0. /// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating /// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score /// readout (see the doc comment on [`ScoreFloater`] for why this is a /// UI Node rather than a `Text2d`). fn detect_score_change( game: Res, settings: Option>, mut prev: ResMut, font_res: Option>, score_q: Query>, mut commands: Commands, ) { let current = game.0.score; let delta = current - prev.0; prev.0 = current; if delta <= 0 { return; } let speed = settings .as_ref() .map(|s| s.0.animation_speed) .unwrap_or_default(); let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed); let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed); // Refresh ScorePulse on every score readout entity (in practice // there's exactly one, but iterating is cheaper than asserting). for entity in &score_q { commands.entity(entity).insert(ScorePulse { elapsed: 0.0, duration: pulse_secs, }); } if delta < SCORE_FLOATER_THRESHOLD { return; } let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY_LG, ..default() }; // Spawned as an absolutely-positioned UI Node so the floater rides // the same screen-coordinate system as the score readout. Using a // `Text2d` here would require translating UI layout coordinates to // world space every frame; a UI node piggybacks on the same // anchoring `update_hud` already uses for the score and stays // testable under `MinimalPlugins`. commands.spawn(( ScoreFloater { elapsed: 0.0, duration: floater_secs, }, Node { position_type: PositionType::Absolute, // Anchored next to the HUD column; matches the // `spawn_hud` left/top offsets so the floater appears // overlaid on the score line and drifts up from there. left: VAL_SPACE_3, top: Val::Px(0.0), ..default() }, ZIndex(Z_HUD + 10), Text::new(format!("+{delta}")), font, TextColor(ACCENT_PRIMARY), )); } /// Advances every [`ScorePulse`], scaling its entity's `Transform` /// using [`score_pulse_scale`]. Removes the component once /// `elapsed >= duration` (or immediately under /// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is /// 0) and pins the scale back to 1.0 so no float drift survives. fn advance_score_pulse( time: Res