feat(engine): reserve top band for HUD so it stops crowding the cards
Player report: the action button bar (Menu / Undo / Pause / Help / Modes / New Game) and Score / Moves / Timer text were sharing the same vertical band as the stock + foundation row, with no visual separation. The HUD read as part of the play surface. Two-part fix: 1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the window. Card-grid math takes that off the available vertical budget so cards still fit; top_y shifts down by the same amount. New layout test pins the reservation. Existing worst_case_tableau_fits_vertically tests verify the height-budget arithmetic still holds. 2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling that reserved zone. Z-index sits one rung below Z_HUD so action buttons paint on top while the band reads as their container. The band's bottom edge lines up with the top edge of the highest playable card, so the buttons feel anchored to a "tools strip" rather than floating in the play area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.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.
|
||||
///
|
||||
|
||||
@@ -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<PileType, Vec2> = 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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user