diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a6460e5..a3cb0a2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -251,7 +251,7 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this | `CursorPlugin` | — | Custom cursor sprite during drag | | `SelectionPlugin` | — | Keyboard-driven card selection | | `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays | -| `HudPlugin` | — | Score, move counter, timer, auto-complete badge | +| `HudPlugin` | — | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. | | `StatsPlugin` | S | Stats overlay and persistence | | `ProgressPlugin` | — | XP/level system, persistence | | `AchievementPlugin` | A | Unlock evaluation, toast events, persistence | diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index bb2f290..6bc0e70 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -86,6 +86,19 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord); #[derive(Message, Debug, Clone, Copy, Default)] pub struct ManualSyncRequestEvent; +/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so +/// the same toggle path runs whether the player presses `Esc` or clicks. +/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag / +/// game-over / selection guards either way. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct PauseRequestEvent; + +/// Request to toggle the help / controls overlay. Fired by the HUD "Help" +/// button alongside the existing `F1` accelerator so the overlay is +/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct HelpRequestEvent; + /// Fired by `SyncPlugin` after a pull task resolves and the merged result has /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus /// any `ConflictReport`s the merge produced. `Err(String)` carries a diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 1b4d56e..8047c9a 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -1,30 +1,39 @@ //! Toggleable on-screen help / cheat sheet showing keyboard bindings. //! -//! Press **F1** to toggle. Listed shortcuts are grouped by intent — +//! Reachable from the HUD "Help" button (per the UI-first principle); `F1` +//! is an optional accelerator. Listed shortcuts are grouped by intent — //! gameplay, modes, and overlays. use bevy::prelude::*; +use crate::events::HelpRequestEvent; + /// Marker on the help overlay root node. #[derive(Component, Debug)] pub struct HelpScreen; -/// Spawns and despawns the help/controls overlay shown when the player presses H (or the help button). -/// All hotkeys and gesture guides live here. +/// 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. pub struct HelpPlugin; impl Plugin for HelpPlugin { fn build(&self, app: &mut App) { - app.add_systems(Update, toggle_help_screen); + app.add_message::() + .add_systems(Update, toggle_help_screen); } } fn toggle_help_screen( mut commands: Commands, keys: Res>, + mut requests: MessageReader, screens: Query>, ) { - if !keys.just_pressed(KeyCode::F1) { + // Either F1 or a click on the HUD "Help" button (which fires + // HelpRequestEvent) toggles the overlay. + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::F1) && !button_clicked { return; } if let Ok(entity) = screens.single() { diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index e706c7b..785dc93 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -13,7 +13,9 @@ use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; use crate::daily_challenge_plugin::DailyChallengeResource; -use crate::events::{InfoToastEvent, NewGameRequestEvent}; +use crate::events::{ + HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, UndoRequestEvent, +}; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; use crate::resources::GameStateResource; @@ -84,30 +86,72 @@ pub struct HudDrawCycle; #[derive(Component, Debug)] pub struct HudSelection; -/// Marker on the New Game action button anchored top-right of the play area. -/// Click fires [`NewGameRequestEvent`]; the existing `ConfirmNewGameScreen` -/// modal then handles confirmation when a game is in progress. +/// Marker shared by every clickable HUD action button so a single +/// `paint_action_buttons` system can recolour them on hover/press without +/// each button needing its own paint handler. +#[derive(Component, Debug)] +pub struct ActionButton; + +/// Marker on the "New Game" action button anchored top-right of the play +/// area. Click fires [`NewGameRequestEvent`]; the existing +/// `ConfirmNewGameScreen` modal handles confirmation when a game is in +/// progress. #[derive(Component, Debug)] pub struct NewGameButton; +/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`], +/// mirroring the `U` keyboard accelerator. +#[derive(Component, Debug)] +pub struct UndoButton; + +/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`], +/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume +/// affordance dismisses it from the paused state. +#[derive(Component, Debug)] +pub struct PauseButton; + +/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`], +/// mirroring the `F1` keyboard accelerator. +#[derive(Component, Debug)] +pub struct HelpButton; + /// HUD Z-layer — above cards (which start at z=0) but below overlay screens. const Z_HUD: i32 = 50; -/// Idle / hover / pressed colours for the New Game action button. -const NEW_GAME_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85); -const NEW_GAME_BTN_HOVER: Color = Color::srgb(0.28, 0.65, 0.95); -const NEW_GAME_BTN_PRESSED: Color = Color::srgb(0.15, 0.45, 0.75); +/// 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); /// 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; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { - app.add_systems(Startup, (spawn_hud, spawn_new_game_button)) + // The click handlers write to messages registered elsewhere + // (`NewGameRequestEvent` in `GamePlugin`, `UndoRequestEvent` in + // `GamePlugin`, `PauseRequestEvent` in `PausePlugin`, + // `HelpRequestEvent` in `HelpPlugin`). Re-register defensively so the + // HUD plugin works in isolation under `MinimalPlugins` (tests). + // `add_message` is idempotent. + app.add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_systems(Startup, (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) - .add_systems(Update, (handle_new_game_button, paint_new_game_button)); + .add_systems( + Update, + ( + handle_new_game_button, + handle_undo_button, + handle_pause_button, + handle_help_button, + paint_action_buttons, + ), + ); } } @@ -186,15 +230,15 @@ fn spawn_hud(font_res: Option>, mut commands: Commands) { }); } -/// Spawns the New Game action button anchored to the top-right of the -/// window. Click fires [`NewGameRequestEvent`]; the existing -/// `ConfirmNewGameScreen` modal in `GamePlugin` handles confirmation when -/// a game is in progress, and starts a fresh deal otherwise. +/// Spawns the action button bar anchored to the top-right of the window. +/// Each child is a clickable button mirroring a keyboard accelerator — +/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons +/// are the primary entry point and the hotkeys are optional. /// -/// Per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1), this -/// button is the primary entry point for starting a new game. The `N` -/// keyboard shortcut is an optional accelerator. -fn spawn_new_game_button(font_res: Option>, mut commands: Commands) { +/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost +/// because it's the most consequential action; the destructive button sits +/// on its own visual edge. +fn spawn_action_buttons(font_res: Option>, mut commands: Commands) { let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: 16.0, @@ -202,35 +246,57 @@ fn spawn_new_game_button(font_res: Option>, mut commands: Comm }; commands .spawn(( - NewGameButton, - Button, Node { position_type: PositionType::Absolute, right: Val::Px(12.0), top: Val::Px(8.0), - padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)), - justify_content: JustifyContent::Center, + flex_direction: FlexDirection::Row, + column_gap: Val::Px(8.0), align_items: AlignItems::Center, - border_radius: BorderRadius::all(Val::Px(6.0)), ..default() }, - BackgroundColor(NEW_GAME_BTN_IDLE), ZIndex(Z_HUD), )) - .with_children(|b| { - b.spawn(( - Text::new("New Game"), - font, - TextColor(Color::WHITE), - )); + .with_children(|row| { + 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, NewGameButton, "New Game", &font); }); } -/// Click handler for the New Game button — fires `NewGameRequestEvent`. -/// +/// Spawns a single action button as a child of `row`. Each button shares +/// the same node geometry, idle colour, and `ActionButton` marker so +/// `paint_action_buttons` can recolour all of them with one query. +fn spawn_action_button( + row: &mut ChildSpawnerCommands, + marker: M, + label: &str, + font: &TextFont, +) { + row.spawn(( + marker, + ActionButton, + Button, + Node { + padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border_radius: BorderRadius::all(Val::Px(6.0)), + ..default() + }, + BackgroundColor(ACTION_BTN_IDLE), + )) + .with_children(|b| { + b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE))); + }); +} + /// `Changed` filter ensures we only react on the frame the /// interaction state transitions, avoiding repeat events while the button -/// is held down. +/// is held down. Each click handler fires the corresponding request event, +/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside +/// their existing keyboard handlers. fn handle_new_game_button( interaction_query: Query<&Interaction, (With, Changed)>, mut new_game: MessageWriter, @@ -242,20 +308,54 @@ fn handle_new_game_button( } } -/// Visual feedback for the New Game button — paints idle / hover / pressed -/// states by mutating the `BackgroundColor` whenever the interaction state -/// changes. -fn paint_new_game_button( +fn handle_undo_button( + interaction_query: Query<&Interaction, (With, Changed)>, + mut undo: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction == Interaction::Pressed { + undo.write(UndoRequestEvent); + } + } +} + +fn handle_pause_button( + interaction_query: Query<&Interaction, (With, Changed)>, + mut pause: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction == Interaction::Pressed { + pause.write(PauseRequestEvent); + } + } +} + +fn handle_help_button( + interaction_query: Query<&Interaction, (With, Changed)>, + mut help: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction == Interaction::Pressed { + help.write(HelpRequestEvent); + } + } +} + +/// Visual feedback for every action button — paints idle / hover / pressed +/// states by mutating `BackgroundColor` whenever the interaction state +/// changes. One query covers all action buttons via the shared +/// `ActionButton` marker. +fn paint_action_buttons( mut buttons: Query< (&Interaction, &mut BackgroundColor), - (With, Changed), + (With, Changed), >, ) { for (interaction, mut bg) in &mut buttons { bg.0 = match interaction { - Interaction::Pressed => NEW_GAME_BTN_PRESSED, - Interaction::Hovered => NEW_GAME_BTN_HOVER, - Interaction::None => NEW_GAME_BTN_IDLE, + Interaction::Pressed => ACTION_BTN_PRESSED, + Interaction::Hovered => ACTION_BTN_HOVER, + Interaction::None => ACTION_BTN_IDLE, }; } } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 5fe9570..52139ba 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -67,14 +67,17 @@ pub use font_plugin::{FontPlugin, FontResource}; pub use cursor_plugin::CursorPlugin; pub use events::{ AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, - ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, - MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, - StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent, + ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent, + ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, + NewGameRequestEvent, PauseRequestEvent, StateChangedEvent, SyncCompleteEvent, + UndoRequestEvent, XpAwardedEvent, }; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use help_plugin::{HelpPlugin, HelpScreen}; pub use home_plugin::{HomePlugin, HomeScreen}; -pub use hud_plugin::{HudAutoComplete, HudPlugin}; +pub use hud_plugin::{ + ActionButton, HelpButton, HudAutoComplete, HudPlugin, NewGameButton, PauseButton, UndoButton, +}; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use input_plugin::InputPlugin; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index b20b4bc..f4d5c7a 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -19,7 +19,7 @@ use bevy::prelude::*; use solitaire_core::game_state::DrawMode; use solitaire_data::save_game_state_to; -use crate::events::StateChangedEvent; +use crate::events::{PauseRequestEvent, StateChangedEvent}; use crate::game_plugin::{GameOverScreen, GameStatePath}; use crate::progress_plugin::ProgressResource; use crate::resources::{DragState, GameStateResource}; @@ -58,6 +58,7 @@ impl Plugin for PausePlugin { // events first, but calling add_event again is always safe. app.add_message::() .add_message::() + .add_message::() .init_resource::() .add_systems( Update, @@ -75,6 +76,7 @@ impl Plugin for PausePlugin { fn toggle_pause( mut commands: Commands, keys: Res>, + mut requests: MessageReader, mut paused: ResMut, screens: Query>, game_over_screens: Query>, @@ -87,7 +89,11 @@ fn toggle_pause( mut changed: MessageWriter, selection: Option>, ) { - if !keys.just_pressed(KeyCode::Escape) { + // Either Esc or a click on the HUD "Pause" button (which fires + // PauseRequestEvent) opens or closes the overlay. Drain the queue so a + // burst of clicks doesn't queue future toggles. + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::Escape) && !button_clicked { return; } // If a card is currently selected, let SelectionPlugin handle this Escape