From 6240156fee6768638d6c48de6e4cf96ee88d4116 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 29 Apr 2026 23:55:43 +0000 Subject: [PATCH] feat(engine): add Menu dropdown for Stats/Achievements/Profile/Settings/Leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- solitaire_engine/src/achievement_plugin.rs | 12 +- solitaire_engine/src/events.rs | 25 ++++ solitaire_engine/src/hud_plugin.rs | 157 ++++++++++++++++++++- solitaire_engine/src/leaderboard_plugin.rs | 13 +- solitaire_engine/src/lib.rs | 8 +- solitaire_engine/src/profile_plugin.rs | 8 +- solitaire_engine/src/settings_plugin.rs | 10 +- solitaire_engine/src/stats_plugin.rs | 9 +- 8 files changed, 224 insertions(+), 18 deletions(-) diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 59a5d4f..e685578 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -17,7 +17,9 @@ use solitaire_data::{ save_progress_to, }; -use crate::events::{AchievementUnlockedEvent, GameWonEvent, XpAwardedEvent}; +use crate::events::{ + AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent, +}; use crate::game_plugin::GameMutation; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; @@ -73,6 +75,7 @@ impl Plugin for AchievementPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() // Run after GameMutation (so GameWonEvent is available), after // StatsUpdate (so stats reflect this win), and after ProgressUpdate // (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()) } -/// 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( mut commands: Commands, keys: Res>, + mut requests: MessageReader, achievements: Res, screens: Query>, ) { - if !keys.just_pressed(KeyCode::KeyA) { + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::KeyA) && !button_clicked { return; } if let Ok(entity) = screens.single() { diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index ccc39ce..42e313c 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -129,6 +129,31 @@ pub struct StartTimeAttackRequestEvent; #[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 /// 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/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index d46bc42..82c7946 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -18,7 +18,9 @@ use crate::progress_plugin::ProgressResource; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, - StartZenRequestEvent, UndoRequestEvent, + StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, + ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, + UndoRequestEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; @@ -143,6 +145,27 @@ pub enum ModeOption { 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. const Z_HUD: i32 = 50; @@ -170,6 +193,11 @@ impl Plugin for HudPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() + .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)) @@ -183,6 +211,8 @@ impl Plugin for HudPlugin { handle_help_button, handle_modes_button, handle_mode_option_click, + handle_menu_button, + handle_menu_option_click, paint_action_buttons, ), ); @@ -292,6 +322,7 @@ fn spawn_action_buttons(font_res: Option>, mut commands: Comma 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); @@ -520,6 +551,130 @@ fn handle_mode_option_click( } } +/// 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, Changed)>, + popovers: Query>, + font_res: Option>, + 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>, + popovers: Query>, + mut stats: MessageWriter, + mut achievements: MessageWriter, + mut profile: MessageWriter, + mut settings: MessageWriter, + mut leaderboard: MessageWriter, + 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 diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 665bc36..82aadb6 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -14,7 +14,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use solitaire_data::settings::SyncBackend; use solitaire_sync::LeaderboardEntry; -use crate::events::InfoToastEvent; +use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent}; use crate::settings_plugin::SettingsResource; use crate::sync_plugin::SyncProviderResource; @@ -73,6 +73,7 @@ impl Plugin for LeaderboardPlugin { .init_resource::() .init_resource::() .init_resource::() + .add_message::() .add_systems( Update, ( @@ -99,18 +100,22 @@ fn reset_closed_flag(mut flag: ResMut) { flag.0 = false; } -/// `L` key — open or close the leaderboard panel. -/// On open, starts a new fetch if no data is cached or a fetch is not in flight. +/// `L` keyboard accelerator or `ToggleLeaderboardRequestEvent` from the +/// 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( mut commands: Commands, keys: Res>, + mut requests: MessageReader, screens: Query>, data: Res, provider: Option>, mut task_res: ResMut, mut closed_flag: ResMut, ) { - if !keys.just_pressed(KeyCode::KeyL) { + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::KeyL) && !button_clicked { return; } if let Ok(entity) = screens.single() { diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index cdd99e7..b05a240 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -71,14 +71,16 @@ pub use events::{ ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, - StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent, + StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent, + ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, + ToggleStatsRequestEvent, 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::{ - ActionButton, HelpButton, HudAutoComplete, HudPlugin, ModeOption, ModesButton, ModesPopover, - NewGameButton, PauseButton, UndoButton, + ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, MenuOption, MenuPopover, + ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, UndoButton, }; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use input_plugin::InputPlugin; diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index dcbb44f..8422870 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -10,6 +10,7 @@ use solitaire_core::achievement::achievement_by_id; use solitaire_data::SyncBackend; use crate::achievement_plugin::AchievementsResource; +use crate::events::ToggleProfileRequestEvent; use crate::progress_plugin::ProgressResource; use crate::resources::{SyncStatus, SyncStatusResource}; use crate::settings_plugin::SettingsResource; @@ -24,7 +25,8 @@ pub struct ProfilePlugin; impl Plugin for ProfilePlugin { fn build(&self, app: &mut App) { - app.add_systems(Update, toggle_profile_screen); + app.add_message::() + .add_systems(Update, toggle_profile_screen); } } @@ -32,6 +34,7 @@ impl Plugin for ProfilePlugin { fn toggle_profile_screen( mut commands: Commands, keys: Res>, + mut requests: MessageReader, settings: Option>, sync_status: Option>, progress: Option>, @@ -39,7 +42,8 @@ fn toggle_profile_screen( stats: Option>, screens: Query>, ) { - if !keys.just_pressed(KeyCode::KeyP) { + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::KeyP) && !button_clicked { return; } if let Ok(entity) = screens.single() { diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index f9b5dbb..8cf2fbc 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -16,7 +16,7 @@ use bevy::prelude::*; use solitaire_core::game_state::DrawMode; 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::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; @@ -146,6 +146,7 @@ impl Plugin for SettingsPlugin { .init_resource::() .add_message::() .add_message::() + .add_message::() .add_message::() .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())); } -/// 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( keys: Res>, + mut requests: MessageReader, mut screen: ResMut, ) { - 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; } } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 5c198e3..bd95df7 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -17,7 +17,9 @@ use solitaire_data::{ use crate::auto_complete_plugin::AutoCompleteState; 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::progress_plugin::ProgressResource; use crate::resources::GameStateResource; @@ -81,6 +83,7 @@ impl Plugin for StatsPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() // record_abandoned must read `move_count` BEFORE handle_new_game // clobbers it with a fresh game. These are NOT in StatsUpdate because // StatsUpdate (as a set) is ordered after GameMutation by external @@ -181,12 +184,14 @@ fn handle_forfeit( fn toggle_stats_screen( mut commands: Commands, keys: Res>, + mut requests: MessageReader, stats: Res, progress: Option>, time_attack: Option>, screens: Query>, ) { - if !keys.just_pressed(KeyCode::KeyS) { + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::KeyS) && !button_clicked { return; } if let Ok(entity) = screens.single() {