feat(engine): convert HomeScreen to modal scaffold + Done button

Phase 3 step 5f of the UX overhaul. Closes the per-overlay
conversion phase: every read-only overlay (Help, Stats, Achievements,
Profile, Leaderboard, and now Home) sits inside the same ui_modal
scaffold, picks colours from ui_theme, and dismisses via a real
"Done" primary button alongside its keyboard accelerator.

Home modal:
- Header: "Solitaire Quest"
- Mode badge: "Current mode: <mode>" in ACCENT_PRIMARY (yellow)
- Two sections (Game Controls / Screens), each rendering keyboard
  shortcuts as kbd-chip rows — the same pattern Help uses, so the
  two reference screens read consistently. Section titles use
  STATE_INFO.
- "L" leaderboard row added so the screens list is now complete.
- Actions: primary Done button with the M hotkey chip.
- handle_home_close_button is the click counterpart to M.

Home overlap with Help is intentional during the overhaul — both
exist as hotkey references for now. A future commit can repurpose
Home as a true mode launcher (the proposal called for this) or
remove it entirely if Help is sufficient. Either path is easier with
both screens already in the consistent shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:44:33 +00:00
parent 37681cf33e
commit 3b619b8950
+124 -90
View File
@@ -7,18 +7,30 @@ use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Marker component on the home-menu overlay root node. /// Marker component on the home-menu overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HomeScreen; pub struct HomeScreen;
/// Marker on the "Done" button inside the Home modal.
#[derive(Component, Debug)]
pub struct HomeCloseButton;
/// Registers the M-key toggle and the overlay spawn/despawn logic. /// Registers the M-key toggle and the overlay spawn/despawn logic.
pub struct HomePlugin; pub struct HomePlugin;
impl Plugin for HomePlugin { impl Plugin for HomePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, toggle_home_screen); app.add_systems(Update, (toggle_home_screen, handle_home_close_button));
} }
} }
@@ -26,6 +38,7 @@ fn toggle_home_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
) { ) {
if !keys.just_pressed(KeyCode::KeyM) { if !keys.just_pressed(KeyCode::KeyM) {
@@ -34,12 +47,32 @@ fn toggle_home_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
spawn_home_screen(&mut commands, &game); spawn_home_screen(&mut commands, &game, font_res.as_deref());
} }
} }
/// Spawns the full-window home-menu overlay derived from the current `game` state. fn handle_home_close_button(
fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) { mut commands: Commands,
close_buttons: Query<&Interaction, (With<HomeCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Spawns the home-menu modal — a hotkey reference grouped into "Game
/// Controls" and "Screens" sections plus the current game mode badge.
/// A future pass can pivot Home into a true mode launcher (the
/// Modes-popover already covers that path from the action bar).
fn spawn_home_screen(
commands: &mut Commands,
game: &GameStateResource,
font_res: Option<&FontResource>,
) {
let mode_label = match game.0.mode { let mode_label = match game.0.mode {
GameMode::Classic => "Classic", GameMode::Classic => "Classic",
GameMode::Zen => "Zen", GameMode::Zen => "Zen",
@@ -47,121 +80,122 @@ fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
GameMode::TimeAttack => "Time Attack", GameMode::TimeAttack => "Time Attack",
}; };
commands let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
.spawn(( let font_section = TextFont {
HomeScreen, font: font_handle.clone(),
Node { font_size: TYPE_BODY_LG,
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
..default() ..default()
}, };
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)), let font_row = TextFont {
ZIndex(200), font: font_handle.clone(),
)) font_size: TYPE_BODY,
.with_children(|root| { ..default()
// Title };
root.spawn(( let font_kbd = TextFont {
Text::new("Solitaire Quest"), font: font_handle,
TextFont { font_size: 48.0, ..default() }, font_size: TYPE_CAPTION,
TextColor(Color::srgb(1.0, 0.85, 0.3)), ..default()
)); };
// Mode subtitle spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
root.spawn(( spawn_modal_header(card, "Solitaire Quest", font_res);
// Mode badge — current game's mode, ACCENT_PRIMARY so it pops.
card.spawn((
Text::new(format!("Current mode: {mode_label}")), Text::new(format!("Current mode: {mode_label}")),
TextFont { font_size: 28.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.8, 0.8, 0.8)), TextColor(ACCENT_PRIMARY),
)); ));
// Spacer // Game controls section.
root.spawn(Node { card.spawn((
height: Val::Px(8.0),
..default()
});
// "Game Controls" section header
root.spawn((
Text::new("Game Controls"), Text::new("Game Controls"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.9, 0.9, 0.9)), TextColor(STATE_INFO),
)); ));
for (key, action) in [
("N", "New game (N again confirms)"),
("U", "Undo last move"),
("Space / D", "Draw from stock"),
("G", "Forfeit current game"),
("Tab", "Cycle hint highlight"),
("Enter", "Auto-complete if available"),
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
}
spawn_shortcut_row(root, "N", "New game (N again confirms)"); // Screens section.
spawn_shortcut_row(root, "U", "Undo last move"); card.spawn((
spawn_shortcut_row(root, "Space / D", "Draw from stock");
spawn_shortcut_row(root, "G", "Forfeit current game");
spawn_shortcut_row(root, "Tab", "Cycle hint highlight");
spawn_shortcut_row(root, "Enter", "Auto-complete if available");
// Spacer
root.spawn(Node {
height: Val::Px(8.0),
..default()
});
// "Screens" section header
root.spawn((
Text::new("Screens"), Text::new("Screens"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.9, 0.9, 0.9)), TextColor(STATE_INFO),
)); ));
for (key, action) in [
("M", "Main menu (this screen)"),
("S", "Statistics"),
("A", "Achievements"),
("O", "Settings"),
("P", "Profile"),
("L", "Leaderboard"),
("F1", "Help"),
("F11", "Toggle fullscreen"),
("Esc", "Pause / Resume"),
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
}
spawn_shortcut_row(root, "M", "Main menu (this screen)"); spawn_modal_actions(card, |actions| {
spawn_shortcut_row(root, "S", "Statistics"); spawn_modal_button(
spawn_shortcut_row(root, "A", "Achievements"); actions,
spawn_shortcut_row(root, "O", "Settings"); HomeCloseButton,
spawn_shortcut_row(root, "P", "Profile"); "Done",
spawn_shortcut_row(root, "F1", "Help"); Some("M"),
spawn_shortcut_row(root, "F11", "Toggle fullscreen"); ButtonVariant::Primary,
spawn_shortcut_row(root, "Esc", "Pause / Resume"); font_res,
);
// Spacer
root.spawn(Node {
height: Val::Px(16.0),
..default()
}); });
// Dismiss hint
root.spawn((
Text::new("Press M to close"),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.55)),
));
}); });
} }
fn spawn_shortcut_row(parent: &mut ChildSpawnerCommands, key: &str, action: &str) { /// One row inside Home's controls reference: a kbd-chip + description.
/// Same look as Help's rows so the two screens read consistently.
fn spawn_shortcut_row(
parent: &mut ChildSpawnerCommands,
key: &str,
action: &str,
font_row: &TextFont,
font_kbd: &TextFont,
) {
parent parent
.spawn(Node { .spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
min_width: Val::Px(380.0), column_gap: VAL_SPACE_3,
column_gap: Val::Px(16.0),
..default() ..default()
}) })
.with_children(|row| { .with_children(|row| {
row.spawn(( row.spawn((
Text::new(key.to_string()),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.4)),
Node { Node {
min_width: Val::Px(120.0), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(80.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(key.to_string()),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
)); ));
});
row.spawn(( row.spawn((
Text::new(action.to_string()), Text::new(action.to_string()),
TextFont { font_size: 16.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.85)), TextColor(TEXT_SECONDARY),
)); ));
}); });
} }