//! 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; 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::avatar_plugin::AvatarResource; use solitaire_data::SyncBackend; 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::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop}; use crate::ui_theme::SPACE_2; use crate::ui_theme::{ scaled_duration, 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, }; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; #[cfg(target_os = "android")] use crate::input_plugin::TouchDragSet; use crate::layout::LayoutSystem; #[cfg(target_os = "android")] use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; #[cfg(target_os = "android")] use crate::resources::{DragState, GameInputConsumedResource}; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_modal::ModalScrim; 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 "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; /// 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; /// 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; /// 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 = "!"; /// 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; /// 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. #[derive(Component, Debug, Clone, Copy)] pub enum MenuOption { Help, Modes, 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; 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::() .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)) .add_systems(Update, update_hud.after(GameMutation)) .add_systems( Update, apply_hud_visibility.before(LayoutSystem::UpdateOnResize), ) .add_systems(Update, restore_hud_on_modal) .add_systems(Update, (update_hud_avatar, handle_avatar_button)) .add_systems(Update, update_won_previously.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems( Update, update_selection_hud.run_if( resource_exists_and_changed:: .or(resource_exists_and_changed::), ), ) .add_systems(Update, update_hud_typography) .add_systems( Update, ( detect_score_change, advance_score_pulse, advance_score_floater, ) .chain() .after(GameMutation), ) .add_systems( Update, (start_streak_flourish, advance_streak_flourish) .chain() .after(GameMutation), ) .add_systems( Update, ( handle_new_game_button, handle_undo_button, handle_pause_button, handle_help_button, handle_hint_button, handle_modes_button, handle_mode_option_click, 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, ), ) // 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), ); } } } /// Spawns the invisible HUD band that reserves vertical space at the top of /// the screen so the card layout (computed by `layout::compute_layout` using /// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts. /// /// The entity carries no `BackgroundColor` — the green felt shows through. /// A slim grey background is handled by each content section individually /// (the bottom action bar has its own `BG_HUD_BAND` background). fn spawn_hud_band(mut commands: Commands) { const BASE_TOP: f32 = 0.0; commands.spawn(( Node { position_type: PositionType::Absolute, top: Val::Px(BASE_TOP), left: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(HUD_BAND_HEIGHT), ..default() }, ZIndex(Z_HUD - 1), SafeAreaAnchoredTop { base_top: BASE_TOP }, HudBand, )); } /// 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, // On a narrow viewport the four tier rows (Score/Moves/Timer, // Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/ // Auto-complete, selection chip) can collectively be wider than // the available space and overflow into the action-button column // on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto // a second line; on a desktop window the rows stay single-line // because the parent column has no width cap and the row never // exceeds the natural line width. flex_wrap: FlexWrap::Wrap, row_gap: VAL_SPACE_1, align_items: AlignItems::Baseline, ..default() }; commands .spawn(( Node { position_type: PositionType::Absolute, left: VAL_SPACE_3, top: Val::Px(SPACE_2), flex_direction: FlexDirection::Column, // Cap the column at 50% of viewport so on narrow // (mobile) widths the inner tier rows have a bounded // width to wrap against, and the column can't bleed // into the right-anchored action button row (also // capped at 50%). On desktop 50% of 1920 = 960 px, // wider than any tier row's natural width, so the // visible layout is unaffected. max_width: Val::Percent(50.0), row_gap: VAL_SPACE_1, ..default() }, ZIndex(Z_HUD), SafeAreaAnchoredTop { base_top: SPACE_2 }, HudColumn, )) .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), )); t2.spawn(( HudWonPreviously, Tooltip::new( "You've won this deal before. Same seed in your replay history.", ), Text::new(""), font_body.clone(), TextColor(STATE_SUCCESS), )); }); // 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 circular avatar / initials button anchored to the top-right /// of the HUD band. Initial content is seeded from whatever resources are /// available at startup; `update_hud_avatar` replaces the children whenever /// `AvatarResource` or `SettingsResource` later changes. fn spawn_hud_avatar( font_res: Option>, avatar: Option>, settings: Option>, mut commands: Commands, ) { const SIZE: f32 = 32.0; let id = commands .spawn(( HudAvatar, Button, Tooltip::new("Your profile — tap to open."), Node { position_type: PositionType::Absolute, top: Val::Px(SPACE_2), right: VAL_SPACE_3, width: Val::Px(SIZE), height: Val::Px(SIZE), border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)), align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, BackgroundColor(ACCENT_PRIMARY), ZIndex(Z_HUD), SafeAreaAnchoredTop { base_top: SPACE_2 }, )) .id(); spawn_avatar_child( &mut commands, id, avatar.as_deref(), settings.as_deref(), font_res.as_deref(), ); } /// Re-spawns the avatar circle content (image or initials) whenever either /// [`AvatarResource`] or [`SettingsResource`] changes — covers both the /// image arriving after download and the username changing after login. fn update_hud_avatar( avatar: Option>, settings: Option>, font_res: Option>, q: Query>, mut commands: Commands, ) { let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed()); let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed()); if !avatar_changed && !settings_changed { return; } let Ok(entity) = q.single() else { return; }; commands.entity(entity).despawn_related::(); spawn_avatar_child( &mut commands, entity, avatar.as_deref(), settings.as_deref(), font_res.as_deref(), ); } /// Populates the avatar container with either the downloaded image or an /// initials fallback disc. Called from both the startup spawn and the /// reactive update system so the rendering logic lives in one place. fn spawn_avatar_child( commands: &mut Commands, parent: Entity, avatar: Option<&AvatarResource>, settings: Option<&SettingsResource>, font_res: Option<&FontResource>, ) { const SIZE: f32 = 32.0; if let Some(handle) = avatar.and_then(|a| a.0.clone()) { // Image fills the circle container; border_radius clips it to a disc. commands.entity(parent).with_children(|b| { b.spawn(( ImageNode::new(handle), Node { width: Val::Px(SIZE), height: Val::Px(SIZE), border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)), ..default() }, )); }); } else { let initial = settings .and_then(|s| match &s.0.sync_backend { SyncBackend::SolitaireServer { username, .. } => username.chars().next(), SyncBackend::Local => None, }) .and_then(|c| c.to_uppercase().next()) .unwrap_or('?'); commands.entity(parent).with_children(|b| { b.spawn(( Text::new(initial.to_string()), TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: 14.0, ..default() }, TextColor(TEXT_PRIMARY), )); }); } } /// Opens the Profile overlay when the avatar button is pressed. fn handle_avatar_button( interaction_query: Query<&Interaction, (With, Changed)>, mut toggle_profile: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { toggle_profile.write(ToggleProfileRequestEvent); } } } /// 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: TYPE_BODY, ..default() }; // On Android, compact Unicode symbols fit all 7 buttons in one row. // On desktop, keep the descriptive text labels. #[cfg(target_os = "android")] let col_gap = Val::Px(4.0); #[cfg(not(target_os = "android"))] let col_gap = VAL_SPACE_2; #[cfg(target_os = "android")] let labels = ( /* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono) /* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono) /* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono /* help */ "?", /* hint */ ANDROID_HINT_LABEL, /* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono /* new */ "+", ); #[cfg(not(target_os = "android"))] let labels = ( "Menu \u{25BE}", "Undo", "Pause", "Help", "Hint", "Modes \u{25BE}", "New Game", ); // Bottom bar: full-width, centered, sits above the gesture-navigation zone. // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once // Android reports it (frames 1-3); initial value is 0.0. commands .spawn(( Node { position_type: PositionType::Absolute, bottom: Val::Px(0.0), left: Val::Px(0.0), width: Val::Percent(100.0), flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, justify_content: JustifyContent::Center, column_gap: col_gap, row_gap: VAL_SPACE_2, align_items: AlignItems::Center, padding: UiRect { left: VAL_SPACE_3, right: VAL_SPACE_3, top: VAL_SPACE_2, bottom: VAL_SPACE_2, }, ..default() }, BackgroundColor(BG_HUD_BAND), ZIndex(Z_HUD), SafeAreaAnchoredBottom { base_bottom: 0.0 }, HudActionBar, )) .with_children(|row| { // The trailing `order` argument feeds `Focusable { group: Hud, order }` // so Tab cycles the action bar in visual reading order. // Undo and Pause are the primary gameplay actions — full brightness. // Menu, Help, Hint, Modes, New are navigation/utility — dimmed. spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY); spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY); spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY); spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY); spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY); spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY); spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY); }); } /// 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, text_color: Color, ) { // Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a // touch device — the button itself is the affordance — and they // visibly clutter the narrow-viewport action row. Force the hint // off on Android; the chevrons on Menu/Modes remain because they // indicate dropdown behaviour and still apply on touch. let hotkey = if cfg!(target_os = "android") { None } else { hotkey }; let hotkey_font = TextFont { font: font.font.clone(), font_size: TYPE_CAPTION, ..default() }; // On Android, use tighter padding and a slightly smaller min-size so all // 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥ // Apple's minimum touch target; padding of 4 dp each side keeps the icon // centred with room to breathe. On desktop, keep the comfortable 48 dp // floor and 8 dp side padding. #[cfg(target_os = "android")] let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0)); #[cfg(not(target_os = "android"))] let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0)); row.spawn(( marker, ActionButton, Button, Tooltip::new(tooltip), Focusable { group: FocusGroup::Hud, order, }, Node { padding: pad, min_width: min_w, min_height: min_h, 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), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label), font.clone(), TextColor(text_color))); 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); } } } fn handle_hint_button( interaction_query: Query<&Interaction, (With, Changed)>, paused: Option>, game: Option>, solver_config: Option>, mut pending_hint: Option>, mut info_toast: MessageWriter, ) { for interaction in &interaction_query { if *interaction != Interaction::Pressed { continue; } if paused.as_ref().is_some_and(|p| p.0) { return; } let Some(ref g) = game else { return }; if g.0.is_won { #[cfg(target_os = "android")] let won_msg = "Game won! Tap New Game to play again"; #[cfg(not(target_os = "android"))] let won_msg = "Game won! Press N for a new game"; info_toast.write(InfoToastEvent(won_msg.to_string())); return; } if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { hint.spawn(g.0.clone(), cfg.0); } } } /// 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>, backdrops: 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(); for e in &backdrops { commands.entity(e).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.", )); } // Popover opens upward from just above the bottom action bar. // Use a platform-aware offset that clears the bar height + safe-area // gesture zone on Android, and the flat bar height on desktop. #[cfg(target_os = "android")] let popover_bottom = Val::Px(200.0); #[cfg(not(target_os = "android"))] let popover_bottom = Val::Px(80.0); commands .spawn(( ModesPopover, HudPopoverOpen, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, bottom: popover_bottom, 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_POPOVER), )) .with_children(|panel| { for (option, label, tooltip) in rows { panel .spawn(( option, ActionButton, PopoverRow, 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))); }); } }); // Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the // popover at Z_HUD_POPOVER) so tapping outside light-dismisses it. commands.spawn(( ModesPopoverBackdrop, Button, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::NONE), ZIndex(Z_HUD_POPOVER_BACKDROP), )); } /// 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>, backdrops: 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(); for e in &backdrops { commands.entity(e).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>, backdrops: Query>, scrims: Query<(), With>, 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(); for e in &backdrops { commands.entity(e).despawn(); } } else if scrims.is_empty() { 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); 7] = [ ( MenuOption::Help, "Help", "Show controls, rules, and keyboard shortcuts.", ), ( MenuOption::Modes, "Game Modes", "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", ), ( 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.", ), ]; // Same upward-opening placement as ModesPopover. #[cfg(target_os = "android")] let popover_bottom = Val::Px(200.0); #[cfg(not(target_os = "android"))] let popover_bottom = Val::Px(80.0); commands .spawn(( MenuPopover, HudPopoverOpen, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, bottom: popover_bottom, 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_POPOVER), )) .with_children(|panel| { for (option, label, tooltip) in rows { panel .spawn(( option, ActionButton, PopoverRow, 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))); }); } }); // Transparent fullscreen backdrop behind the popover — tapping anywhere // outside the panel light-dismisses it via handle_menu_backdrop_click. commands.spawn(( MenuPopoverBackdrop, Button, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::NONE), ZIndex(Z_HUD_POPOVER_BACKDROP), )); } /// 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>, backdrops: Query>, mut stats: MessageWriter, mut achievements: MessageWriter, mut profile: MessageWriter, mut settings: MessageWriter, mut leaderboard: MessageWriter, mut help: MessageWriter, progress: Option>, daily: Option>, font_res: Option>, mut commands: Commands, ) { let mut clicked_any = false; let mut open_modes = false; for (interaction, option) in &interaction_query { if *interaction != Interaction::Pressed { continue; } clicked_any = true; match option { MenuOption::Help => { help.write(HelpRequestEvent); } MenuOption::Modes => { open_modes = true; } 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(); for e in &backdrops { commands.entity(e).despawn(); } } if open_modes { spawn_modes_popover( &mut commands, progress.as_deref(), daily.as_deref(), font_res.as_deref(), ); } } /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back /// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which /// checks [`HudPopoverOpen`]) sees an empty world and stays idle. fn close_modes_popover_on_escape( keys: Res>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back /// is pressed while the popover is open. fn close_menu_popover_on_escape( keys: Res>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`ModesPopover`] and its backdrop when the player taps /// anywhere outside the panel. fn handle_modes_backdrop_click( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`MenuPopover`] and its backdrop when the player taps /// anywhere outside the panel (i.e. the transparent backdrop is pressed). fn handle_menu_backdrop_click( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Auto-fade state for the action button bar. The bar fades out when /// the cursor is in the play area (below the HUD band) and back in when /// the cursor approaches the top of the window — same UX as a video /// player's auto-hide controls. Buttons remain fully interactive when /// visible; when faded out they're geometrically out of cursor reach /// (hover requires the cursor to be on a button), so no extra /// pointer-events guard is needed. #[derive(Resource, Debug, Clone, Copy)] pub struct HudActionFade { /// Currently displayed alpha. Lerped toward `target` each frame. pub alpha: f32, /// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible). pub target: f32, } impl Default for HudActionFade { fn default() -> Self { // Start visible so the player sees the controls on first launch // before they've moved the cursor anywhere. Self { alpha: 1.0, target: 1.0, } } } /// How many pixels from the bottom edge the cursor must be to reveal the bar. /// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the /// cursor approaches, not only when it crosses into the band itself. const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0; /// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full /// transition — fast enough to feel responsive without flashing on /// brief cursor wanders into the reveal zone. const ACTION_FADE_RATE_PER_SEC: f32 = 6.0; /// Updates the fade state from cursor position. Sets `target = 1.0` if /// the cursor is in the reveal zone (bottom of window) or off-screen /// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward /// `target` at a fixed rate so the visual transition is smooth across /// variable framerates. fn update_action_fade( windows: Query<&Window>, time: Res