feat(engine): restructure HUD into 4-tier layout, adopt design tokens

Phase 3 step 2 of the UX overhaul. Closes the player's #1 complaint
("HUD too cluttered") by regrouping the 10 readouts that previously
sat in a single dense horizontal row.

HUD structure (top → bottom):
- Tier 1 (always on)        Score · Moves · Timer
                            Score uses TYPE_HEADLINE so it's the
                            visual protagonist; Moves/Timer use
                            TYPE_BODY_LG with TEXT_SECONDARY tone.
- Tier 2 (mode context)     Mode · Daily-challenge constraint ·
                            Draw-cycle indicator. Each cell is
                            empty when not relevant — the row
                            collapses visually if all are empty.
- Tier 3 (penalty / bonus)  Undos · Recycles · Auto-complete badge.
                            Both penalty counters now share
                            STATE_WARNING — the audit found Undos
                            were amber but Recycles were white,
                            making the "you took a penalty" signal
                            inconsistent.
- Tier 4 (selection chip)   keyboard-driven pile selector.

Action bar polish:
- Each button gains a TYPE_CAPTION hotkey-hint chip (Undo · U,
  Pause · Esc, Help · F1, New Game · N). Menu and Modes get no
  chip because each row in their popovers carries its own hotkey.
- Buttons recoloured to BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED — the bright-blue palette stood out from
  the rest of the (still-to-come) midnight purple chrome.
- Buttons gain a BORDER_SUBTLE outline so the boundary reads even
  when hovered over the felt.

Other migrations in this commit:
- Popover panels (Menu, Modes) now use BG_ELEVATED instead of an
  ad-hoc dark grey.
- challenge_time_color now returns STATE_DANGER / STATE_WARNING /
  STATE_INFO tokens instead of literal hexes; tests updated.
- The Undos in-place colour toggle uses TEXT_PRIMARY / STATE_WARNING.

The four `ui_theme` self-tests plus all existing 791 tests stay
green (795 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 00:30:42 +00:00
parent e14852c093
commit 73cad7e205
+199 -103
View File
@@ -15,6 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BORDER_SUBTLE, RADIUS_MD, 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::{ use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
@@ -167,12 +173,15 @@ pub enum MenuOption {
} }
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50; /// 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. /// Idle / hover / pressed colours shared by every action button. Aliased
const ACTION_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85); /// to the theme tokens so the HUD picks up palette changes for free.
const ACTION_BTN_HOVER: Color = Color::srgb(0.28, 0.65, 0.95); const ACTION_BTN_IDLE: Color = BG_ELEVATED;
const ACTION_BTN_PRESSED: Color = Color::srgb(0.15, 0.45, 0.75); 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. /// 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; pub struct HudPlugin;
@@ -219,78 +228,144 @@ impl Plugin for HudPlugin {
} }
} }
/// 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<Res<FontResource>>, mut commands: Commands) { fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80)); let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font = TextFont { let font_score = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_handle.clone(),
font_size: 18.0, font_size: TYPE_HEADLINE,
..default() ..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 commands
.spawn(( .spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
left: Val::Px(12.0), left: VAL_SPACE_3,
top: Val::Px(8.0), top: VAL_SPACE_2,
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Column,
column_gap: Val::Px(20.0), row_gap: VAL_SPACE_1,
align_items: AlignItems::Center,
..default() ..default()
}, },
ZIndex(Z_HUD), ZIndex(Z_HUD),
)) ))
.with_children(|b| { .with_children(|hud| {
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white)); // Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white)); // Moves and Timer are supporting context (BODY_LG, secondary tone).
b.spawn((HudTime, Text::new("0:00"), font.clone(), white)); hud.spawn(row_node()).with_children(|t1| {
b.spawn(( t1.spawn((
HudMode, HudScore,
Text::new(""), Text::new("Score: 0"),
TextFont { font_size: 17.0, ..default() }, font_score.clone(),
TextColor(Color::srgb(1.0, 0.85, 0.25)), TextColor(TEXT_PRIMARY),
)); ));
// Daily-challenge constraint (hidden until a challenge is active). t1.spawn((
b.spawn(( HudMoves,
HudChallenge, Text::new("Moves: 0"),
Text::new(""), font_lg.clone(),
TextFont { font_size: 17.0, ..default() }, TextColor(TEXT_SECONDARY),
TextColor(Color::srgb(0.4, 0.9, 1.0)), ));
)); t1.spawn((
// Undo counter (white by default; turns amber when undos are used). HudTime,
b.spawn(( Text::new("0:00"),
HudUndos, font_lg.clone(),
Text::new(""), TextColor(TEXT_SECONDARY),
font.clone(), ));
white, });
));
// Auto-complete badge (green "AUTO" when sequence is running). // Tier 2 — mode context. Each cell is empty until update_hud
b.spawn(( // populates it (and clears it when no longer relevant), so the
HudAutoComplete, // row collapses when nothing in this tier applies.
Text::new(""), hud.spawn(row_node()).with_children(|t2| {
TextFont { font_size: 17.0, ..default() }, t2.spawn((
TextColor(Color::srgb(0.2, 0.9, 0.3)), HudMode,
)); Text::new(""),
// Recycle counter — hidden until the first recycle in either draw mode. font_body.clone(),
b.spawn(( TextColor(ACCENT_PRIMARY),
HudRecycles, ));
Text::new(""), t2.spawn((
font.clone(), HudChallenge,
white, Text::new(""),
)); font_body.clone(),
// Draw-cycle indicator — only visible in Draw-Three mode. TextColor(STATE_INFO),
b.spawn(( ));
HudDrawCycle, t2.spawn((
Text::new(""), HudDrawCycle,
font, Text::new(""),
TextColor(Color::srgb(0.7, 0.85, 1.0)), font_body.clone(),
)); TextColor(STATE_INFO),
// Keyboard-selection indicator — shows which pile is Tab-selected. ));
b.spawn(( });
HudSelection,
Text::new(""), // Tier 3 — penalty / bonus. Undos and Recycles share the
TextFont { font_size: 17.0, ..default() }, // warning hue so they read as the same category ("you took a
TextColor(Color::srgb(1.0, 1.0, 0.5)), // penalty"); the auto-complete badge stays success-green.
)); hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudRecycles,
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudAutoComplete,
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,
Text::new(""),
font_body,
TextColor(ACCENT_SECONDARY),
));
});
}); });
} }
@@ -322,12 +397,15 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
ZIndex(Z_HUD), ZIndex(Z_HUD),
)) ))
.with_children(|row| { .with_children(|row| {
spawn_action_button(row, MenuButton, "Menu \u{25BE}", &font); // Menu and Modes don't have a single hotkey accelerator
spawn_action_button(row, UndoButton, "Undo", &font); // (each row inside their popover has its own); their button
spawn_action_button(row, PauseButton, "Pause", &font); // labels carry the dropdown chevron in lieu of a key chip.
spawn_action_button(row, HelpButton, "Help", &font); spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", &font); spawn_action_button(row, UndoButton, "Undo", Some("U"), &font);
spawn_action_button(row, NewGameButton, "New Game", &font); spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
}); });
} }
@@ -338,23 +416,37 @@ fn spawn_action_button<M: Component>(
row: &mut ChildSpawnerCommands, row: &mut ChildSpawnerCommands,
marker: M, marker: M,
label: &str, label: &str,
hotkey: Option<&'static str>,
font: &TextFont, font: &TextFont,
) { ) {
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
row.spawn(( row.spawn((
marker, marker,
ActionButton, ActionButton,
Button, Button,
Node { Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)), padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(6.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
column_gap: VAL_SPACE_2,
..default() ..default()
}, },
BackgroundColor(ACTION_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE))); 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)));
}
}); });
} }
@@ -476,7 +568,7 @@ fn spawn_modes_popover(
border_radius: BorderRadius::all(Val::Px(6.0)), border_radius: BorderRadius::all(Val::Px(6.0)),
..default() ..default()
}, },
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)), BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5), ZIndex(Z_HUD + 5),
)) ))
.with_children(|panel| { .with_children(|panel| {
@@ -497,7 +589,7 @@ fn spawn_modes_popover(
BackgroundColor(ACTION_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE))); b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
}); });
} }
}); });
@@ -605,7 +697,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
border_radius: BorderRadius::all(Val::Px(6.0)), border_radius: BorderRadius::all(Val::Px(6.0)),
..default() ..default()
}, },
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)), BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5), ZIndex(Z_HUD + 5),
)) ))
.with_children(|panel| { .with_children(|panel| {
@@ -626,7 +718,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
BackgroundColor(ACTION_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE))); b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
}); });
} }
}); });
@@ -679,6 +771,7 @@ fn handle_menu_option_click(
/// states by mutating `BackgroundColor` whenever the interaction state /// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared /// changes. One query covers all action buttons via the shared
/// `ActionButton` marker. /// `ActionButton` marker.
#[allow(clippy::type_complexity)]
fn paint_action_buttons( fn paint_action_buttons(
mut buttons: Query< mut buttons: Query<
(&Interaction, &mut BackgroundColor), (&Interaction, &mut BackgroundColor),
@@ -894,11 +987,12 @@ fn update_hud(
let count = g.undo_count; let count = g.undo_count;
if count == 0 { if count == 0 {
**t = String::new(); **t = String::new();
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80)); *color = TextColor(TEXT_PRIMARY);
} else { } else {
**t = format!("Undos: {count}"); **t = format!("Undos: {count}");
// Amber warning: using undo blocks the no-undo achievement. // STATE_WARNING signals "you took a penalty" — same hue
*color = TextColor(Color::srgb(1.0, 0.7, 0.2)); // as the Recycles counter so they read as one category.
*color = TextColor(STATE_WARNING);
} }
} }
@@ -1025,20 +1119,22 @@ fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
} }
} }
/// Returns the colour for the challenge time-limit HUD label based on seconds remaining. /// Returns the colour for the challenge time-limit HUD label based on
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
/// palette changes for free.
/// ///
/// | Remaining | Colour | /// | Remaining | Token |
/// |-------------|--------| /// |-------------|------------------|
/// | ≥ 60 s | Cyan (default) | /// | ≥ 60 s | `STATE_INFO` |
/// | 30 59 s | Orange (warning) | /// | 30 59 s | `STATE_WARNING` |
/// | < 30 s | Red (urgent) | /// | < 30 s | `STATE_DANGER` |
pub fn challenge_time_color(remaining: u64) -> Color { pub fn challenge_time_color(remaining: u64) -> Color {
if remaining < 30 { if remaining < 30 {
Color::srgb(1.0, 0.2, 0.2) STATE_DANGER
} else if remaining < 60 { } else if remaining < 60 {
Color::srgb(1.0, 0.6, 0.0) STATE_WARNING
} else { } else {
Color::srgb(0.4, 0.9, 1.0) STATE_INFO
} }
} }
@@ -1189,39 +1285,39 @@ mod tests {
} }
#[test] #[test]
fn challenge_time_color_above_60_is_cyan() { fn challenge_time_color_above_60_is_info() {
let c = challenge_time_color(61); let c = challenge_time_color(61);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0)); assert_eq!(c, STATE_INFO);
} }
#[test] #[test]
fn challenge_time_color_exactly_60_is_cyan() { fn challenge_time_color_exactly_60_is_info() {
let c = challenge_time_color(60); let c = challenge_time_color(60);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0)); assert_eq!(c, STATE_INFO);
} }
#[test] #[test]
fn challenge_time_color_59_is_orange() { fn challenge_time_color_59_is_warning() {
let c = challenge_time_color(59); let c = challenge_time_color(59);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0)); assert_eq!(c, STATE_WARNING);
} }
#[test] #[test]
fn challenge_time_color_30_is_orange() { fn challenge_time_color_30_is_warning() {
let c = challenge_time_color(30); let c = challenge_time_color(30);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0)); assert_eq!(c, STATE_WARNING);
} }
#[test] #[test]
fn challenge_time_color_29_is_red() { fn challenge_time_color_29_is_danger() {
let c = challenge_time_color(29); let c = challenge_time_color(29);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2)); assert_eq!(c, STATE_DANGER);
} }
#[test] #[test]
fn challenge_time_color_zero_is_red() { fn challenge_time_color_zero_is_danger() {
let c = challenge_time_color(0); let c = challenge_time_color(0);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2)); assert_eq!(c, STATE_DANGER);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------