//! HUD construction: band, columns, avatar, and action-bar spawning. use super::*; #[cfg(not(target_arch = "wasm32"))] use crate::avatar_plugin::AvatarResource; /// 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). pub(super) 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. pub(super) 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. pub(super) 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. pub(super) 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. pub(super) 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()) { // Logged-in with a downloaded avatar: keep the accent disc behind it. commands .entity(parent) .insert(BackgroundColor(ACCENT_PRIMARY)); // 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('?'); // Real initial (logged in) keeps the red accent disc; the '?' // unauthenticated fallback uses a neutral grey so it reads as a // "tap to log in" affordance rather than an error. let disc_bg = if initial == '?' { BG_ELEVATED_HI } else { ACCENT_PRIMARY }; commands.entity(parent).insert(BackgroundColor(disc_bg)); 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. pub(super) 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. pub(super) fn spawn_action_buttons( font_res: Option>, windows: Query<&Window>, images: Option>>, mut commands: Commands, ) { let action_font_size = action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width())); let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: action_font_size, ..default() }; // Touch layout: the floating glass tab bar (pill + circular Menu). // `Assets` is absent under `MinimalPlugins`; `rasterize_into` // returning `None` switches the bar to its text-fallback form. if USE_TOUCH_UI_LAYOUT { let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs)); spawn_glass_tab_bar(&mut commands, &font, icons.as_ref()); return; } // Desktop bottom bar: full-width, centered, docked to the bottom edge. // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once // the platform reports it; 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: ACTION_BAR_COLUMN_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| { spawn_desktop_action_bar(row, &font); }); } /// Desktop action bar — unchanged by Phase F (decision 5: the touch bar /// is touch-only). All seven actions, uniform sizing. pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) { // 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, "Menu \u{2193}", None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", font, 0, TEXT_SECONDARY, ); spawn_action_button( row, UndoButton, "Undo", Some("U"), "Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.", font, 1, TEXT_PRIMARY, ); spawn_action_button( row, PauseButton, "Pause", Some("Esc"), "Pause the game and freeze the timer.", font, 2, TEXT_PRIMARY, ); spawn_action_button( row, HelpButton, "Help", Some("F1"), "Show controls, rules, and keyboard shortcuts.", font, 3, TEXT_SECONDARY, ); spawn_action_button( row, HintButton, "Hint", Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", font, 4, TEXT_SECONDARY, ); spawn_action_button( row, ModesButton, "Modes \u{2193}", None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", font, 5, TEXT_SECONDARY, ); spawn_action_button( row, NewGameButton, "New Game", 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)] pub(super) 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. The chevrons on // Menu/Modes remain because they indicate dropdown behaviour. let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None }; let (pad, min_w, min_h) = action_button_metrics(); spawn_action_button_sized( row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h, ); } /// Shared body of [`spawn_action_button`] — one place owns the component /// set so `paint_action_buttons`, tooltips, and the focus ring treat every /// bar button identically. #[allow(clippy::too_many_arguments)] fn spawn_action_button_sized( row: &mut ChildSpawnerCommands, marker: M, label: &str, hotkey: Option<&'static str>, tooltip: &'static str, font: &TextFont, order: i32, text_color: Color, pad: UiRect, min_w: Val, min_h: Val, ) { let hotkey_font = TextFont { font: font.font.clone(), font_size: TYPE_CAPTION, ..default() }; 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| { spawn_action_button_label(b, label, font, 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))); } }); }