feat(engine): add Undo, Pause, Help UI buttons in HUD action bar

Continues the UI-first pass started by the New Game button. Per the
design principle in CLAUDE.md / ARCHITECTURE.md §1, every player action
must be reachable from a visible UI control with the keyboard shortcut
as an optional accelerator. Refactor the single New Game button into a
flex-row "action bar" anchored top-right with four buttons: Undo,
Pause, Help, New Game (left → right; New Game rightmost as the most
consequential action).

Plumbing:
- New `PauseRequestEvent` and `HelpRequestEvent` in events.rs.
- pause_plugin::toggle_pause reads either Esc or PauseRequestEvent so
  the button and the keyboard accelerator drive the same code path
  (with the existing drag / game-over / selection guards).
- help_plugin::toggle_help_screen reads either F1 or HelpRequestEvent;
  also fix the stale module-doc claim that H toggles help (it's F1 —
  H is bound to hint cycle in input_plugin).
- hud_plugin now spawns four ActionButton-marked buttons via a
  ChildSpawnerCommands helper, with one click handler per button
  firing its respective request event. A single
  paint_action_buttons system covers hover/pressed colour for all of
  them via the shared ActionButton marker. The click handlers
  defensively re-register their request events so the plugin works in
  isolation under MinimalPlugins (tests). add_message is idempotent.
- ARCHITECTURE.md HudPlugin row updated to call out the action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-29 23:38:54 +00:00
parent 62cd1cf924
commit 97f38085e3
6 changed files with 184 additions and 53 deletions
+1 -1
View File
@@ -251,7 +251,7 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
| `CursorPlugin` | — | Custom cursor sprite during drag | | `CursorPlugin` | — | Custom cursor sprite during drag |
| `SelectionPlugin` | — | Keyboard-driven card selection | | `SelectionPlugin` | — | Keyboard-driven card selection |
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays | | `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 | | `StatsPlugin` | S | Stats overlay and persistence |
| `ProgressPlugin` | — | XP/level system, persistence | | `ProgressPlugin` | — | XP/level system, persistence |
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence | | `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
+13
View File
@@ -86,6 +86,19 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent; 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 /// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a /// any `ConflictReport`s the merge produced. `Err(String)` carries a
+14 -5
View File
@@ -1,30 +1,39 @@
//! Toggleable on-screen help / cheat sheet showing keyboard bindings. //! 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. //! gameplay, modes, and overlays.
use bevy::prelude::*; use bevy::prelude::*;
use crate::events::HelpRequestEvent;
/// Marker on the help overlay root node. /// Marker on the help overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HelpScreen; pub struct HelpScreen;
/// Spawns and despawns the help/controls overlay shown when the player presses H (or the help button). /// Spawns and despawns the help / controls overlay shown when the player
/// All hotkeys and gesture guides live here. /// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here.
pub struct HelpPlugin; pub struct HelpPlugin;
impl Plugin for HelpPlugin { impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, toggle_help_screen); app.add_message::<HelpRequestEvent>()
.add_systems(Update, toggle_help_screen);
} }
} }
fn toggle_help_screen( fn toggle_help_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<HelpRequestEvent>,
screens: Query<Entity, With<HelpScreen>>, screens: Query<Entity, With<HelpScreen>>,
) { ) {
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; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
+141 -41
View File
@@ -13,7 +13,9 @@ use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::daily_challenge_plugin::DailyChallengeResource; 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::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -84,30 +86,72 @@ pub struct HudDrawCycle;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HudSelection; pub struct HudSelection;
/// Marker on the New Game action button anchored top-right of the play area. /// Marker shared by every clickable HUD action button so a single
/// Click fires [`NewGameRequestEvent`]; the existing `ConfirmNewGameScreen` /// `paint_action_buttons` system can recolour them on hover/press without
/// modal then handles confirmation when a game is in progress. /// 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)] #[derive(Component, Debug)]
pub struct NewGameButton; 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. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50; const Z_HUD: i32 = 50;
/// Idle / hover / pressed colours for the New Game action button. /// Idle / hover / pressed colours shared by every action button.
const NEW_GAME_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85); const ACTION_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 ACTION_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); 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. /// 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;
impl Plugin for HudPlugin { impl Plugin for HudPlugin {
fn build(&self, app: &mut App) { 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::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>()
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation)) .add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud) .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<Res<FontResource>>, mut commands: Commands) {
}); });
} }
/// Spawns the New Game action button anchored to the top-right of the /// Spawns the action button bar anchored to the top-right of the window.
/// window. Click fires [`NewGameRequestEvent`]; the existing /// Each child is a clickable button mirroring a keyboard accelerator —
/// `ConfirmNewGameScreen` modal in `GamePlugin` handles confirmation when /// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
/// a game is in progress, and starts a fresh deal otherwise. /// are the primary entry point and the hotkeys are optional.
/// ///
/// Per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1), this /// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// button is the primary entry point for starting a new game. The `N` /// because it's the most consequential action; the destructive button sits
/// keyboard shortcut is an optional accelerator. /// on its own visual edge.
fn spawn_new_game_button(font_res: Option<Res<FontResource>>, mut commands: Commands) { fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let font = TextFont { let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: 16.0, font_size: 16.0,
@@ -202,35 +246,57 @@ fn spawn_new_game_button(font_res: Option<Res<FontResource>>, mut commands: Comm
}; };
commands commands
.spawn(( .spawn((
NewGameButton,
Button,
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
right: Val::Px(12.0), right: Val::Px(12.0),
top: Val::Px(8.0), top: Val::Px(8.0),
padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)), flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center, column_gap: Val::Px(8.0),
align_items: AlignItems::Center, align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(6.0)),
..default() ..default()
}, },
BackgroundColor(NEW_GAME_BTN_IDLE),
ZIndex(Z_HUD), ZIndex(Z_HUD),
)) ))
.with_children(|b| { .with_children(|row| {
b.spawn(( spawn_action_button(row, UndoButton, "Undo", &font);
Text::new("New Game"), spawn_action_button(row, PauseButton, "Pause", &font);
font, spawn_action_button(row, HelpButton, "Help", &font);
TextColor(Color::WHITE), 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<M: Component>(
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<Interaction>` filter ensures we only react on the frame the /// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button /// 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( fn handle_new_game_button(
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
@@ -242,20 +308,54 @@ fn handle_new_game_button(
} }
} }
/// Visual feedback for the New Game button — paints idle / hover / pressed fn handle_undo_button(
/// states by mutating the `BackgroundColor` whenever the interaction state interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
/// changes. mut undo: MessageWriter<UndoRequestEvent>,
fn paint_new_game_button( ) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
undo.write(UndoRequestEvent);
}
}
}
fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
}
}
}
fn handle_help_button(
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
mut help: MessageWriter<HelpRequestEvent>,
) {
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< mut buttons: Query<
(&Interaction, &mut BackgroundColor), (&Interaction, &mut BackgroundColor),
(With<NewGameButton>, Changed<Interaction>), (With<ActionButton>, Changed<Interaction>),
>, >,
) { ) {
for (interaction, mut bg) in &mut buttons { for (interaction, mut bg) in &mut buttons {
bg.0 = match interaction { bg.0 = match interaction {
Interaction::Pressed => NEW_GAME_BTN_PRESSED, Interaction::Pressed => ACTION_BTN_PRESSED,
Interaction::Hovered => NEW_GAME_BTN_HOVER, Interaction::Hovered => ACTION_BTN_HOVER,
Interaction::None => NEW_GAME_BTN_IDLE, Interaction::None => ACTION_BTN_IDLE,
}; };
} }
} }
+7 -4
View File
@@ -67,14 +67,17 @@ pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent,
MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent, NewGameRequestEvent, PauseRequestEvent, StateChangedEvent, SyncCompleteEvent,
UndoRequestEvent, XpAwardedEvent,
}; };
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen}; 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 leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin; pub use input_plugin::InputPlugin;
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
+8 -2
View File
@@ -19,7 +19,7 @@ use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::save_game_state_to; use solitaire_data::save_game_state_to;
use crate::events::StateChangedEvent; use crate::events::{PauseRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameOverScreen, GameStatePath}; use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
@@ -58,6 +58,7 @@ impl Plugin for PausePlugin {
// events first, but calling add_event again is always safe. // events first, but calling add_event again is always safe.
app.add_message::<SettingsChangedEvent>() app.add_message::<SettingsChangedEvent>()
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.add_message::<PauseRequestEvent>()
.init_resource::<PausedResource>() .init_resource::<PausedResource>()
.add_systems( .add_systems(
Update, Update,
@@ -75,6 +76,7 @@ impl Plugin for PausePlugin {
fn toggle_pause( fn toggle_pause(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<PauseRequestEvent>,
mut paused: ResMut<PausedResource>, mut paused: ResMut<PausedResource>,
screens: Query<Entity, With<PauseScreen>>, screens: Query<Entity, With<PauseScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
@@ -87,7 +89,11 @@ fn toggle_pause(
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
) { ) {
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; return;
} }
// If a card is currently selected, let SelectionPlugin handle this Escape // If a card is currently selected, let SelectionPlugin handle this Escape