Compare commits

...

3 Commits

Author SHA1 Message Date
funman300 6240156fee feat(engine): add Menu dropdown for Stats/Achievements/Profile/Settings/Leaderboard
CI / Test & Lint (push) Failing after 20s
CI / Release Build (push) Has been skipped
Continues the UI-first pass. The five informational overlays were
each behind a single-key shortcut (S/A/P/O/L) with no visible UI
affordance. Add a "Menu ▾" button to the action bar that toggles a
popover with one row per overlay. Each row dispatches the same code
path the keyboard accelerator uses by writing a new
`Toggle*RequestEvent`:

- Stats        → ToggleStatsRequestEvent
- Achievements → ToggleAchievementsRequestEvent
- Profile      → ToggleProfileRequestEvent
- Settings     → ToggleSettingsRequestEvent
- Leaderboard  → ToggleLeaderboardRequestEvent

Each plugin's existing toggle handler now reads either its key or
the matching request event so the spawn / despawn / fetch logic stays
in the owning plugin (the popover never duplicates that behaviour).

Action bar order is now (left → right):
  Menu ▾   Undo   Pause   Help   Modes ▾   New Game

Menu sits on the far left because it's a navigation aggregator;
New Game stays on the far right as the most consequential action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:55:43 +00:00
funman300 1d9fb1884a feat(engine): add Modes dropdown with Classic/Daily/Zen/Challenge/Time Attack
Continues the UI-first pass. The five game modes were each behind a
keyboard shortcut (N/Z/X/T/C) with no visible UI affordance, three of
them additionally gated by an unlock level the player has to discover
themselves.

Add a "Modes ▾" button to the action bar that toggles a popover panel
beneath. Each row dispatches the same code path the keyboard
accelerator uses by writing a new `Start*RequestEvent` (or
`NewGameRequestEvent` for Classic):

- Classic        → NewGameRequestEvent::default()
- Daily Challenge → StartDailyChallengeRequestEvent
- Zen            → StartZenRequestEvent
- Challenge      → StartChallengeRequestEvent
- Time Attack    → StartTimeAttackRequestEvent

The existing keyboard handlers in input_plugin (Z), challenge_plugin
(X), time_attack_plugin (T), and daily_challenge_plugin (C) now read
either their key or the matching request event, so level gates,
TimeAttackResource setup, daily seed lookup, and toast feedback for
locked modes all stay in their owning plugins — the popover never
duplicates that logic.

The popover only lists modes available to the player: Classic always
shows, Daily Challenge shows when DailyChallengeResource is loaded,
and Zen/Challenge/Time Attack show once the player reaches level 5
(the existing CHALLENGE_UNLOCK_LEVEL).

Click handler despawns the popover after dispatch; clicking the
Modes button again toggles it shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:49:40 +00:00
funman300 97f38085e3 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>
2026-04-29 23:38:54 +00:00
15 changed files with 659 additions and 86 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 |
+9 -3
View File
@@ -17,7 +17,9 @@ use solitaire_data::{
save_progress_to, save_progress_to,
}; };
use crate::events::{AchievementUnlockedEvent, GameWonEvent, XpAwardedEvent}; use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -73,6 +75,7 @@ impl Plugin for AchievementPlugin {
.add_message::<AchievementUnlockedEvent>() .add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (so daily_challenge_streak is up to date for daily_devotee).
@@ -197,14 +200,17 @@ pub fn display_name_for(id: &str) -> String {
.unwrap_or_else(|| id.to_string()) .unwrap_or_else(|| id.to_string())
} }
/// Toggle the achievements overlay with the `A` key. /// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen( fn toggle_achievements_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>, achievements: Res<AchievementsResource>,
screens: Query<Entity, With<AchievementsScreen>>, screens: Query<Entity, With<AchievementsScreen>>,
) { ) {
if !keys.just_pressed(KeyCode::KeyA) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
+8 -2
View File
@@ -8,7 +8,9 @@ use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to}; use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent}; use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -33,6 +35,7 @@ impl Plugin for ChallengePlugin {
app.add_message::<ChallengeAdvancedEvent>() app.add_message::<ChallengeAdvancedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp. // Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate)) .add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
@@ -70,11 +73,14 @@ fn advance_on_challenge_win(
fn handle_start_challenge_request( fn handle_start_challenge_request(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<StartChallengeRequestEvent>,
progress: Res<ProgressResource>, progress: Res<ProgressResource>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
) { ) {
if !keys.just_pressed(KeyCode::KeyX) { // Either X or the HUD Modes-popover "Challenge" row triggers this.
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyX) && !button_clicked {
return; return;
} }
if progress.0.level < CHALLENGE_UNLOCK_LEVEL { if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
+11 -3
View File
@@ -18,7 +18,10 @@ use chrono::{Local, NaiveDate};
use solitaire_data::{daily_seed_for, save_progress_to}; use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent}; use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -83,6 +86,7 @@ impl Plugin for DailyChallengePlugin {
.add_message::<DailyGoalAnnouncementEvent>() .add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge) .add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge) .add_systems(Update, poll_server_challenge)
@@ -189,11 +193,16 @@ fn handle_daily_completion(
fn handle_start_daily_request( fn handle_start_daily_request(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<StartDailyChallengeRequestEvent>,
daily: Res<DailyChallengeResource>, daily: Res<DailyChallengeResource>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut announce: MessageWriter<DailyGoalAnnouncementEvent>, mut announce: MessageWriter<DailyGoalAnnouncementEvent>,
) { ) {
if keys.just_pressed(KeyCode::KeyC) { // Either C or the HUD Modes-popover "Daily Challenge" row triggers this.
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyC) && !button_clicked {
return;
}
new_game.write(NewGameRequestEvent { new_game.write(NewGameRequestEvent {
seed: Some(daily.seed), seed: Some(daily.seed),
mode: None, mode: None,
@@ -205,7 +214,6 @@ fn handle_start_daily_request(
.unwrap_or_else(|| "Daily Challenge".to_string()); .unwrap_or_else(|| "Daily Challenge".to_string());
announce.write(DailyGoalAnnouncementEvent(desc)); announce.write(DailyGoalAnnouncementEvent(desc));
} }
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+68
View File
@@ -86,6 +86,74 @@ 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;
/// Request to start a Zen-mode game. Fired by the HUD Modes-popover "Zen"
/// row alongside the existing `Z` accelerator. The handler in
/// `input_plugin` enforces the level gate (Zen unlocks at level 5) and
/// shows an informational toast when locked.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartZenRequestEvent;
/// Request to start the next Challenge-mode game. Fired by the HUD
/// Modes-popover "Challenge" row alongside the existing `X` accelerator.
/// The handler in `challenge_plugin` enforces the level gate, picks the
/// next seed from `progress.challenge_index`, and writes the
/// corresponding `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartChallengeRequestEvent;
/// Request to start a Time Attack session. Fired by the HUD
/// Modes-popover "Time Attack" row alongside the existing `T`
/// accelerator. The handler in `time_attack_plugin` enforces the level
/// gate, initialises `TimeAttackResource`, and writes the corresponding
/// `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartTimeAttackRequestEvent;
/// Request to start today's Daily Challenge. Fired by the HUD
/// Modes-popover "Daily Challenge" row alongside the existing `C`
/// accelerator. The handler in `daily_challenge_plugin` reads
/// `DailyChallengeResource::seed` and writes a `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StartDailyChallengeRequestEvent;
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
/// "Stats" row alongside the existing `S` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleStatsRequestEvent;
/// Request to toggle the Achievements overlay. Fired by the HUD
/// Menu-popover "Achievements" row alongside the existing `A` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleAchievementsRequestEvent;
/// Request to toggle the Profile overlay. Fired by the HUD Menu-popover
/// "Profile" row alongside the existing `P` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleProfileRequestEvent;
/// Request to toggle the Settings overlay. Fired by the HUD Menu-popover
/// "Settings" row alongside the existing `O` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleSettingsRequestEvent;
/// Request to toggle the Leaderboard overlay. Fired by the HUD
/// Menu-popover "Leaderboard" row alongside the existing `L` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent;
/// 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() {
+472 -38
View File
@@ -12,8 +12,16 @@ use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{InfoToastEvent, NewGameRequestEvent}; use crate::progress_plugin::ProgressResource;
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
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 +92,130 @@ 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;
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode.
#[derive(Component, Debug)]
pub struct ModesButton;
/// Marker on the dropdown panel that opens below the [`ModesButton`].
/// Spawned on first click, despawned on second click or on mode select.
#[derive(Component, Debug)]
pub struct ModesPopover;
/// One row inside the [`ModesPopover`]. The variant carries which event
/// the click handler should fire — Classic uses `NewGameRequestEvent`
/// directly, the others go through their `Start*RequestEvent` so the
/// existing keyboard handler's level gate / resource setup runs.
#[derive(Component, Debug, Clone, Copy)]
pub enum ModeOption {
Classic,
DailyChallenge,
Zen,
Challenge,
TimeAttack,
}
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
#[derive(Component, Debug)]
pub struct MenuButton;
/// Marker on the dropdown panel that opens below the [`MenuButton`].
#[derive(Component, Debug)]
pub struct MenuPopover;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Stats,
Achievements,
Profile,
Settings,
Leaderboard,
}
/// 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 by their
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
// `input_plugin`). 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_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>()
.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,
handle_modes_button,
handle_mode_option_click,
handle_menu_button,
handle_menu_option_click,
paint_action_buttons,
),
);
} }
} }
@@ -186,15 +294,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 +310,59 @@ 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),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
..default()
},
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);
});
}
/// 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)), padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(6.0)), border_radius: BorderRadius::all(Val::Px(6.0)),
..default() ..default()
}, },
BackgroundColor(NEW_GAME_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
ZIndex(Z_HUD),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
Text::new("New Game"),
font,
TextColor(Color::WHITE),
));
}); });
} }
/// Click handler for the New Game button — fires `NewGameRequestEvent`.
///
/// `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 +374,322 @@ 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);
}
}
}
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear.
fn handle_modes_button(
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
} else {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Spawns the modes popover anchored just below the action bar's right
/// edge. Always includes Classic; includes Daily Challenge when a daily
/// resource is loaded; includes Zen / Challenge / Time Attack once the
/// player reaches the challenge unlock level.
fn spawn_modes_popover(
commands: &mut Commands,
progress: Option<&ProgressResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&FontResource>,
) {
let level = progress.map_or(0, |p| p.0.level);
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
let mut rows: Vec<(ModeOption, &'static str)> = vec![(ModeOption::Classic, "Classic")];
if daily.is_some() {
rows.push((ModeOption::DailyChallenge, "Daily Challenge"));
}
if level >= CHALLENGE_UNLOCK_LEVEL {
rows.push((ModeOption::Zen, "Zen"));
rows.push((ModeOption::Challenge, "Challenge"));
rows.push((ModeOption::TimeAttack, "Time Attack"));
}
commands
.spawn((
ModesPopover,
Node {
position_type: PositionType::Absolute,
right: Val::Px(12.0),
top: Val::Px(50.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
for (option, label) in rows {
panel
.spawn((
option,
ActionButton,
Button,
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
});
}
});
}
/// Dispatches the click on a popover row to the matching request event,
/// then despawns the popover.
///
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
/// their `Start*RequestEvent` so the existing keyboard handler runs
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
/// the popover stays a thin entry point and never duplicates that logic.
#[allow(clippy::too_many_arguments)]
fn handle_mode_option_click(
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
popovers: Query<Entity, With<ModesPopover>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut commands: Commands,
) {
let mut clicked_any = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
ModeOption::Classic => {
new_game.write(NewGameRequestEvent::default());
}
ModeOption::DailyChallenge => {
daily.write(StartDailyChallengeRequestEvent);
}
ModeOption::Zen => {
zen.write(StartZenRequestEvent);
}
ModeOption::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
ModeOption::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
}
}
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
/// second click. The popover lists the five overlays previously only
/// reachable via the S / A / P / O / L hotkeys.
fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
} else {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
}
/// Spawns the menu popover anchored just below the action bar, with one
/// row per overlay. Each row dispatches its corresponding
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
/// HUD never duplicates spawn / despawn / fetch logic).
fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
let rows: [(MenuOption, &'static str); 5] = [
(MenuOption::Stats, "Stats"),
(MenuOption::Achievements, "Achievements"),
(MenuOption::Profile, "Profile"),
(MenuOption::Settings, "Settings"),
(MenuOption::Leaderboard, "Leaderboard"),
];
commands
.spawn((
MenuPopover,
Node {
position_type: PositionType::Absolute,
right: Val::Px(12.0),
top: Val::Px(50.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
for (option, label) in rows {
panel
.spawn((
option,
ActionButton,
Button,
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
});
}
});
}
/// Dispatches the click on a menu row to the matching toggle event,
/// then despawns the popover.
#[allow(clippy::too_many_arguments)]
fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut commands: Commands,
) {
let mut clicked_any = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
MenuOption::Achievements => {
achievements.write(ToggleAchievementsRequestEvent);
}
MenuOption::Profile => {
profile.write(ToggleProfileRequestEvent);
}
MenuOption::Settings => {
settings.write(ToggleSettingsRequestEvent);
}
MenuOption::Leaderboard => {
leaderboard.write(ToggleLeaderboardRequestEvent);
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
}
}
/// 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,
}; };
} }
} }
+8 -3
View File
@@ -36,7 +36,8 @@ use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent, DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
StateChangedEvent, UndoRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
@@ -84,6 +85,7 @@ impl Plugin for InputPlugin {
app.init_resource::<HintCycleIndex>() app.init_resource::<HintCycleIndex>()
.init_resource::<KeyboardConfirmState>() .init_resource::<KeyboardConfirmState>()
.add_message::<NewGameConfirmEvent>() .add_message::<NewGameConfirmEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
.add_message::<HintVisualEvent>() .add_message::<HintVisualEvent>()
@@ -147,6 +149,7 @@ fn handle_keyboard_core(
mut ev: CoreKeyboardMessages<'_>, mut ev: CoreKeyboardMessages<'_>,
mut time_attack: Option<ResMut<TimeAttackResource>>, mut time_attack: Option<ResMut<TimeAttackResource>>,
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
mut zen_requests: MessageReader<StartZenRequestEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
@@ -209,11 +212,13 @@ fn handle_keyboard_core(
} }
} }
if keys.just_pressed(KeyCode::KeyZ) { let zen_clicked = zen_requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyZ) || zen_clicked {
// Cancel any pending forfeit when the player takes another action. // Cancel any pending forfeit when the player takes another action.
confirm.forfeit_countdown = 0.0; confirm.forfeit_countdown = 0.0;
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL. // Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
// X is gated separately by ChallengePlugin. // X is gated separately by ChallengePlugin. Either Z or the HUD
// Modes-popover "Zen" row reaches this branch.
let level = progress.as_ref().map_or(0, |p| p.0.level); let level = progress.as_ref().map_or(0, |p| p.0.level);
if level >= CHALLENGE_UNLOCK_LEVEL { if level >= CHALLENGE_UNLOCK_LEVEL {
ev.new_game.write(NewGameRequestEvent { ev.new_game.write(NewGameRequestEvent {
+9 -4
View File
@@ -14,7 +14,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
use solitaire_sync::LeaderboardEntry; use solitaire_sync::LeaderboardEntry;
use crate::events::InfoToastEvent; use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
@@ -73,6 +73,7 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<ClosedThisFrame>() .init_resource::<ClosedThisFrame>()
.init_resource::<OptInTask>() .init_resource::<OptInTask>()
.init_resource::<OptOutTask>() .init_resource::<OptOutTask>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -99,18 +100,22 @@ fn reset_closed_flag(mut flag: ResMut<ClosedThisFrame>) {
flag.0 = false; flag.0 = false;
} }
/// `L` key — open or close the leaderboard panel. /// `L` keyboard accelerator or `ToggleLeaderboardRequestEvent` from the
/// On open, starts a new fetch if no data is cached or a fetch is not in flight. /// HUD Menu popover — open or close the leaderboard panel. On open,
/// starts a new fetch if no data is cached or a fetch is not in flight.
#[allow(clippy::too_many_arguments)]
fn toggle_leaderboard_screen( fn toggle_leaderboard_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
data: Res<LeaderboardResource>, data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>, provider: Option<Res<SyncProviderResource>>,
mut task_res: ResMut<LeaderboardFetchTask>, mut task_res: ResMut<LeaderboardFetchTask>,
mut closed_flag: ResMut<ClosedThisFrame>, mut closed_flag: ResMut<ClosedThisFrame>,
) { ) {
if !keys.just_pressed(KeyCode::KeyL) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyL) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
+11 -4
View File
@@ -67,14 +67,21 @@ 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, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, 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, MenuButton, MenuOption, MenuPopover,
ModeOption, ModesButton, ModesPopover, 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
+6 -2
View File
@@ -10,6 +10,7 @@ use solitaire_core::achievement::achievement_by_id;
use solitaire_data::SyncBackend; use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
use crate::events::ToggleProfileRequestEvent;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
@@ -24,7 +25,8 @@ pub struct ProfilePlugin;
impl Plugin for ProfilePlugin { impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, toggle_profile_screen); app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, toggle_profile_screen);
} }
} }
@@ -32,6 +34,7 @@ impl Plugin for ProfilePlugin {
fn toggle_profile_screen( fn toggle_profile_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleProfileRequestEvent>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
sync_status: Option<Res<SyncStatusResource>>, sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
@@ -39,7 +42,8 @@ fn toggle_profile_screen(
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
screens: Query<Entity, With<ProfileScreen>>, screens: Query<Entity, With<ProfileScreen>>,
) { ) {
if !keys.just_pressed(KeyCode::KeyP) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyP) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
+7 -3
View File
@@ -16,7 +16,7 @@ use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings}; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
use crate::events::ManualSyncRequestEvent; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
@@ -146,6 +146,7 @@ impl Plugin for SettingsPlugin {
.init_resource::<SettingsScrollPos>() .init_resource::<SettingsScrollPos>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<bevy::input::mouse::MouseWheel>() .add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel)); .add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
@@ -206,12 +207,15 @@ fn handle_volume_keys(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
/// Opens or closes the Settings panel when `O` is pressed. /// Opens or closes the Settings panel `O` keyboard accelerator or
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
fn toggle_settings_screen( fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
) { ) {
if keys.just_pressed(KeyCode::KeyO) { let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0; screen.0 = !screen.0;
} }
} }
+7 -2
View File
@@ -17,7 +17,9 @@ use solitaire_data::{
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::challenge_progress_label; use crate::challenge_plugin::challenge_progress_label;
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent}; use crate::events::{
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -81,6 +83,7 @@ impl Plugin for StatsPlugin {
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleStatsRequestEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because // clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external // StatsUpdate (as a set) is ordered after GameMutation by external
@@ -181,12 +184,14 @@ fn handle_forfeit(
fn toggle_stats_screen( fn toggle_stats_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleStatsRequestEvent>,
stats: Res<StatsResource>, stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
if !keys.just_pressed(KeyCode::KeyS) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
+8 -2
View File
@@ -8,7 +8,9 @@ use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent}; use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartTimeAttackRequestEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -40,6 +42,7 @@ impl Plugin for TimeAttackPlugin {
.add_message::<TimeAttackEndedEvent>() .add_message::<TimeAttackEndedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_systems( .add_systems(
Update, Update,
@@ -52,12 +55,15 @@ impl Plugin for TimeAttackPlugin {
fn handle_start_time_attack_request( fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<StartTimeAttackRequestEvent>,
progress: Res<ProgressResource>, progress: Res<ProgressResource>,
mut session: ResMut<TimeAttackResource>, mut session: ResMut<TimeAttackResource>,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
) { ) {
if !keys.just_pressed(KeyCode::KeyT) { // Either T or the HUD Modes-popover "Time Attack" row triggers this.
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyT) && !button_clicked {
return; return;
} }
if progress.0.level < CHALLENGE_UNLOCK_LEVEL { if progress.0.level < CHALLENGE_UNLOCK_LEVEL {