diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 9e2768c..5b1a3a4 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -16,9 +16,10 @@ 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, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM, + 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, }; @@ -251,7 +252,7 @@ impl Plugin for HudPlugin { .add_message::() .add_message::() .init_resource::() - .add_systems(Startup, (spawn_hud, spawn_action_buttons)) + .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) @@ -282,6 +283,34 @@ impl Plugin for HudPlugin { } } +/// 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. /// diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index e5311cc..3fc01cf 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -43,6 +43,15 @@ const TABLEAU_FAN_FRAC: f32 = 0.25; /// this column inside the visible window. const MAX_TABLEAU_CARDS: f32 = 13.0; +/// Vertical pixel band reserved at the top of the play area for the HUD +/// (action buttons, Score / Moves / Timer readouts). The card grid starts +/// below this band so the HUD doesn't bleed into the play surface. +/// +/// 64 px comfortably fits the action button bar (~32 px tall) plus the +/// Score/Moves text line plus padding, with a few pixels of breathing room. +/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`. +pub const HUD_BAND_HEIGHT: f32 = 64.0; + /// Table background colour (dark green felt). pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; @@ -88,8 +97,8 @@ pub fn compute_layout(window: Vec2) -> Layout { // // Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is: // top edge of window = +window.y / 2 - // top of top-row card = window.y/2 - h_gap (h_gap top margin) - // centre of top-row card = window.y/2 - h_gap - h/2 + // top of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap (HUD reserve + h_gap top margin) + // centre of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap - h/2 // centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h) // bottom of last fanned = tableau_centre + h/2 - fan_factor * h // where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC @@ -97,10 +106,10 @@ pub fn compute_layout(window: Vec2) -> Layout { // // Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the // largest w that fits gives: - // window.y = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) + // (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC; let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT; - let card_width_height_based = window.y / height_denom; + let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom; let card_width = card_width_width_based.min(card_width_height_based); let card_height = card_width * CARD_ASPECT; @@ -120,7 +129,7 @@ pub fn compute_layout(window: Vec2) -> Layout { }; let vertical_gap = card_height * VERTICAL_GAP_FRAC; - let top_y = window.y / 2.0 - h_gap - card_height / 2.0; + let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0; let tableau_y = top_y - card_height - vertical_gap; let mut pile_positions: HashMap = HashMap::with_capacity(13); @@ -217,6 +226,23 @@ mod tests { assert!(stock_y > tableau_y); } + /// HUD band reservation: the top edge of every top-row card must sit + /// at least `HUD_BAND_HEIGHT` pixels below the top of the window so + /// the action button bar / score readout has its own visual band + /// instead of bleeding into the play surface. + #[test] + fn top_row_clears_hud_band() { + let window = Vec2::new(1280.0, 800.0); + let layout = compute_layout(window); + let stock_y = layout.pile_positions[&PileType::Stock].y; + let card_top = stock_y + layout.card_size.y / 2.0; + let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; + assert!( + card_top <= band_bottom, + "top of stock card ({card_top}) must sit below the HUD band ({band_bottom})", + ); + } + #[test] fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { let layout = compute_layout(Vec2::new(1280.0, 800.0)); diff --git a/solitaire_engine/src/ui_theme.rs b/solitaire_engine/src/ui_theme.rs index 3826d44..08abf68 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -48,6 +48,13 @@ pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357); /// them. `rgba(13, 7, 28, 0.85)`. pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85); +/// Translucent fill for the top-of-window HUD band painted by +/// `hud_plugin::spawn_hud_band`. Same midnight-purple hue as `BG_BASE`, +/// but at 0.70 alpha so the green felt reads through subtly — enough +/// to mark the band as "UI" without feeling like a hard chrome strip. +/// `rgba(26, 15, 46, 0.70)`. +pub const BG_HUD_BAND: Color = Color::srgba(0.102, 0.059, 0.180, 0.70); + /// Primary text — warm off-white with a hint of purple to fit the /// midnight palette without feeling clinical. `#F5F0FF`. pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);