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
+187 -91
View File
@@ -15,6 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
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::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
@@ -167,12 +173,15 @@ pub enum MenuOption {
}
/// 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.
const ACTION_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85);
const ACTION_BTN_HOVER: Color = Color::srgb(0.28, 0.65, 0.95);
const ACTION_BTN_PRESSED: Color = Color::srgb(0.15, 0.45, 0.75);
/// 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;
@@ -219,79 +228,145 @@ 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) {
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: 18.0,
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,
align_items: AlignItems::Baseline,
..default()
};
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(12.0),
top: Val::Px(8.0),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(20.0),
align_items: AlignItems::Center,
left: VAL_SPACE_3,
top: VAL_SPACE_2,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
))
.with_children(|b| {
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
b.spawn((
.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,
Text::new("Score: 0"),
font_score.clone(),
TextColor(TEXT_PRIMARY),
));
t1.spawn((
HudMoves,
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
t1.spawn((
HudTime,
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,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.25)),
font_body.clone(),
TextColor(ACCENT_PRIMARY),
));
// Daily-challenge constraint (hidden until a challenge is active).
b.spawn((
t2.spawn((
HudChallenge,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.4, 0.9, 1.0)),
font_body.clone(),
TextColor(STATE_INFO),
));
// Undo counter (white by default; turns amber when undos are used).
b.spawn((
HudUndos,
Text::new(""),
font.clone(),
white,
));
// Auto-complete badge (green "AUTO" when sequence is running).
b.spawn((
HudAutoComplete,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.2, 0.9, 0.3)),
));
// Recycle counter — hidden until the first recycle in either draw mode.
b.spawn((
HudRecycles,
Text::new(""),
font.clone(),
white,
));
// Draw-cycle indicator — only visible in Draw-Three mode.
b.spawn((
t2.spawn((
HudDrawCycle,
Text::new(""),
font,
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((
});
// 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,
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(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 1.0, 0.5)),
font_body,
TextColor(ACCENT_SECONDARY),
));
});
});
}
/// Spawns the action button bar anchored to the top-right of the window.
@@ -322,12 +397,15 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
ZIndex(Z_HUD),
))
.with_children(|row| {
spawn_action_button(row, MenuButton, "Menu \u{25BE}", &font);
spawn_action_button(row, UndoButton, "Undo", &font);
spawn_action_button(row, PauseButton, "Pause", &font);
spawn_action_button(row, HelpButton, "Help", &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", &font);
spawn_action_button(row, NewGameButton, "New Game", &font);
// Menu and Modes don't have a single hotkey accelerator
// (each row inside their popover has its own); their button
// labels carry the dropdown chevron in lieu of a key chip.
spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
spawn_action_button(row, UndoButton, "Undo", Some("U"), &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,
marker: M,
label: &str,
hotkey: Option<&'static str>,
font: &TextFont,
) {
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
row.spawn((
marker,
ActionButton,
Button,
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,
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()
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
))
.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)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
@@ -497,7 +589,7 @@ fn spawn_modes_popover(
BackgroundColor(ACTION_BTN_IDLE),
))
.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)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
@@ -626,7 +718,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
BackgroundColor(ACTION_BTN_IDLE),
))
.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
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
#[allow(clippy::type_complexity)]
fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
@@ -894,11 +987,12 @@ fn update_hud(
let count = g.undo_count;
if count == 0 {
**t = String::new();
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
*color = TextColor(TEXT_PRIMARY);
} else {
**t = format!("Undos: {count}");
// Amber warning: using undo blocks the no-undo achievement.
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
// STATE_WARNING signals "you took a penalty" — same hue
// 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 |
/// |-------------|--------|
/// | ≥ 60 s | Cyan (default) |
/// | 30 59 s | Orange (warning) |
/// | < 30 s | Red (urgent) |
/// | Remaining | Token |
/// |-------------|------------------|
/// | ≥ 60 s | `STATE_INFO` |
/// | 30 59 s | `STATE_WARNING` |
/// | < 30 s | `STATE_DANGER` |
pub fn challenge_time_color(remaining: u64) -> Color {
if remaining < 30 {
Color::srgb(1.0, 0.2, 0.2)
STATE_DANGER
} else if remaining < 60 {
Color::srgb(1.0, 0.6, 0.0)
STATE_WARNING
} else {
Color::srgb(0.4, 0.9, 1.0)
STATE_INFO
}
}
@@ -1189,39 +1285,39 @@ mod tests {
}
#[test]
fn challenge_time_color_above_60_is_cyan() {
fn challenge_time_color_above_60_is_info() {
let c = challenge_time_color(61);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_exactly_60_is_cyan() {
fn challenge_time_color_exactly_60_is_info() {
let c = challenge_time_color(60);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_59_is_orange() {
fn challenge_time_color_59_is_warning() {
let c = challenge_time_color(59);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_30_is_orange() {
fn challenge_time_color_30_is_warning() {
let c = challenge_time_color(30);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_29_is_red() {
fn challenge_time_color_29_is_danger() {
let c = challenge_time_color(29);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
assert_eq!(c, STATE_DANGER);
}
#[test]
fn challenge_time_color_zero_is_red() {
fn challenge_time_color_zero_is_danger() {
let c = challenge_time_color(0);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
assert_eq!(c, STATE_DANGER);
}
// -----------------------------------------------------------------------