diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 8047c9a..fdb1a4d 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -7,11 +7,23 @@ use bevy::prelude::*; use crate::events::HelpRequestEvent; +use crate::font_plugin::FontResource; +use crate::ui_modal::{ + spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, +}; +use crate::ui_theme::{ + Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, + TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, +}; /// Marker on the help overlay root node. #[derive(Component, Debug)] pub struct HelpScreen; +/// Marker on the "Close" button inside the Help modal. +#[derive(Component, Debug)] +pub struct HelpCloseButton; + /// Spawns and despawns the help / controls overlay shown when the player /// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture /// guides live here. @@ -20,7 +32,7 @@ pub struct HelpPlugin; impl Plugin for HelpPlugin { fn build(&self, app: &mut App) { app.add_message::() - .add_systems(Update, toggle_help_screen); + .add_systems(Update, (toggle_help_screen, handle_help_close_button)); } } @@ -29,6 +41,7 @@ fn toggle_help_screen( keys: Res>, mut requests: MessageReader, screens: Query>, + font_res: Option>, ) { // Either F1 or a click on the HUD "Help" button (which fires // HelpRequestEvent) toggles the overlay. @@ -39,70 +52,155 @@ fn toggle_help_screen( if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else { - spawn_help_screen(&mut commands); + spawn_help_screen(&mut commands, font_res.as_deref()); } } -fn spawn_help_screen(commands: &mut Commands) { - let lines: Vec = vec![ - "=== Controls ===".to_string(), - String::new(), - "-- Gameplay --".to_string(), - " D Draw from stock".to_string(), - " U Undo last move".to_string(), - " Drag Move cards between piles".to_string(), - " Click stock Draw".to_string(), - String::new(), - "-- New Game --".to_string(), - " N New Classic game (N twice if in progress)".to_string(), - " C Start today's daily challenge".to_string(), - " Z Start a Zen game (level 5+)".to_string(), - " X Start the next Challenge (level 5+)".to_string(), - " T Start a Time Attack session (level 5+)".to_string(), - String::new(), - "-- Overlays --".to_string(), - " S Stats & progression".to_string(), - " A Achievements".to_string(), - " L Leaderboard".to_string(), - " O Settings".to_string(), - " F1 This help screen".to_string(), - " F11 Toggle fullscreen".to_string(), - " Esc Pause / resume".to_string(), - " [ / ] SFX volume down / up".to_string(), - String::new(), - "Press F1 to close".to_string(), - ]; +/// Click handler for the modal's "Close" button. F1 toggles the overlay +/// the same way; this just exposes the close action to mouse / touch. +fn handle_help_close_button( + mut commands: Commands, + close_buttons: Query<&Interaction, (With, Changed)>, + screens: Query>, +) { + if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { + return; + } + for entity in &screens { + commands.entity(entity).despawn(); + } +} - commands - .spawn(( - HelpScreen, - Node { - 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::Center, - align_items: AlignItems::Center, - row_gap: Val::Px(4.0), - ..default() - }, - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)), - ZIndex(210), - )) - .with_children(|b| { - for line in lines { - b.spawn(( - Text::new(line), - TextFont { - font_size: 22.0, - ..default() - }, - TextColor(Color::srgb(0.95, 0.95, 0.90)), - )); +/// Each entry in the controls reference table. +struct ControlRow { + keys: &'static str, + description: &'static str, +} + +/// Each section of the controls reference. Sections render with a +/// section title and a vertically stacked list of `ControlRow`s. +struct ControlSection { + title: &'static str, + rows: &'static [ControlRow], +} + +const CONTROL_SECTIONS: &[ControlSection] = &[ + ControlSection { + title: "Gameplay", + rows: &[ + ControlRow { keys: "Drag", description: "Move cards between piles" }, + ControlRow { keys: "D / Space", description: "Draw from stock" }, + ControlRow { keys: "U", description: "Undo last move" }, + ControlRow { keys: "Click stock", description: "Draw" }, + ], + }, + ControlSection { + title: "New Game", + rows: &[ + ControlRow { keys: "N", description: "New Classic game (N twice if in progress)" }, + ControlRow { keys: "C", description: "Start today's daily challenge" }, + ControlRow { keys: "Z", description: "Start a Zen game (level 5+)" }, + ControlRow { keys: "X", description: "Start the next Challenge (level 5+)" }, + ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" }, + ], + }, + ControlSection { + title: "Overlays", + rows: &[ + ControlRow { keys: "S", description: "Stats & progression" }, + ControlRow { keys: "A", description: "Achievements" }, + ControlRow { keys: "L", description: "Leaderboard" }, + ControlRow { keys: "O", description: "Settings" }, + ControlRow { keys: "F1", description: "This help screen" }, + ControlRow { keys: "F11", description: "Toggle fullscreen" }, + ControlRow { keys: "Esc", description: "Pause / resume" }, + ControlRow { keys: "[ / ]", description: "SFX volume down / up" }, + ], + }, +]; + +fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_section = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY, + ..default() + }; + let font_row = font_section.clone(); + let font_kbd = TextFont { + font: font_handle, + font_size: TYPE_CAPTION, + ..default() + }; + + spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| { + spawn_modal_header(card, "Controls", font_res); + + for section in CONTROL_SECTIONS { + // Section title in muted text — distinguishes from row content. + card.spawn(( + Text::new(section.title), + font_section.clone(), + TextColor(TEXT_SECONDARY), + )); + + // Each row is a flex-row: kbd-style chip + description. + for row in section.rows { + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|line| { + // The hotkey rendered as a small chip with a border — + // visual cue that it's a key reference, not part of + // the description text. + line.spawn(( + Node { + padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), + min_width: Val::Px(64.0), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + )) + .with_children(|chip| { + chip.spawn(( + Text::new(row.keys), + font_kbd.clone(), + TextColor(TEXT_PRIMARY), + )); + }); + line.spawn(( + Text::new(row.description), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + }); } + + // Section spacer — small empty box. Keeps each section + // visually grouped. + card.spawn(Node { + height: Val::Px(SPACE_2), + ..default() + }); + } + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + HelpCloseButton, + "Close", + Some("F1"), + ButtonVariant::Primary, + font_res, + ); }); + }); } #[cfg(test)]