diff --git a/solitaire_engine/src/challenge_plugin.rs b/solitaire_engine/src/challenge_plugin.rs index d2acd60..afce0a4 100644 --- a/solitaire_engine/src/challenge_plugin.rs +++ b/solitaire_engine/src/challenge_plugin.rs @@ -8,7 +8,9 @@ use bevy::prelude::*; use solitaire_core::game_state::GameMode; 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::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; @@ -33,6 +35,7 @@ impl Plugin for ChallengePlugin { app.add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() // Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp. .add_systems(Update, advance_on_challenge_win.after(ProgressUpdate)) @@ -70,11 +73,14 @@ fn advance_on_challenge_win( fn handle_start_challenge_request( keys: Res>, + mut requests: MessageReader, progress: Res, mut new_game: MessageWriter, mut info_toast: MessageWriter, ) { - 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; } if progress.0.level < CHALLENGE_UNLOCK_LEVEL { diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 1cde67f..db7db96 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -18,7 +18,10 @@ use chrono::{Local, NaiveDate}; use solitaire_data::{daily_seed_for, save_progress_to}; 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::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; @@ -83,6 +86,7 @@ impl Plugin for DailyChallengePlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_systems(Startup, fetch_server_challenge) .add_systems(Update, poll_server_challenge) @@ -189,22 +193,26 @@ fn handle_daily_completion( fn handle_start_daily_request( keys: Res>, + mut requests: MessageReader, daily: Res, mut new_game: MessageWriter, mut announce: MessageWriter, ) { - if keys.just_pressed(KeyCode::KeyC) { - new_game.write(NewGameRequestEvent { - seed: Some(daily.seed), - mode: None, - confirmed: false, - }); - let desc = daily - .goal_description - .clone() - .unwrap_or_else(|| "Daily Challenge".to_string()); - announce.write(DailyGoalAnnouncementEvent(desc)); + // 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 { + seed: Some(daily.seed), + mode: None, + confirmed: false, + }); + let desc = daily + .goal_description + .clone() + .unwrap_or_else(|| "Daily Challenge".to_string()); + announce.write(DailyGoalAnnouncementEvent(desc)); } #[cfg(test)] diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 6bc0e70..ccc39ce 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -99,6 +99,36 @@ pub struct PauseRequestEvent; #[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; + /// 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 785dc93..d46bc42 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -12,9 +12,13 @@ use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; +use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; +use crate::progress_plugin::ProgressResource; use crate::events::{ - HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, UndoRequestEvent, + HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, + StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, + StartZenRequestEvent, UndoRequestEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; @@ -115,6 +119,30 @@ pub struct PauseButton; #[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, +} + /// HUD Z-layer — above cards (which start at z=0) but below overlay screens. const Z_HUD: i32 = 50; @@ -128,16 +156,20 @@ pub struct HudPlugin; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { - // 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. + // 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::() .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)) @@ -149,6 +181,8 @@ impl Plugin for HudPlugin { handle_undo_button, handle_pause_button, handle_help_button, + handle_modes_button, + handle_mode_option_click, paint_action_buttons, ), ); @@ -261,6 +295,7 @@ fn spawn_action_buttons(font_res: Option>, mut commands: Comma 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); }); } @@ -341,6 +376,150 @@ fn handle_help_button( } } +/// 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, Changed)>, + popovers: Query>, + progress: Option>, + daily: Option>, + 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_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>, + popovers: Query>, + mut new_game: MessageWriter, + mut zen: MessageWriter, + mut challenge: MessageWriter, + mut time_attack: MessageWriter, + mut daily: 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 { + 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(); + } +} + /// 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/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index e7d7961..4ac5d07 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -36,7 +36,8 @@ use solitaire_core::game_state::DrawMode; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::events::{ DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent, - MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, + MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent, + StateChangedEvent, UndoRequestEvent, }; use crate::game_plugin::GameMutation; use crate::pause_plugin::PausedResource; @@ -84,6 +85,7 @@ impl Plugin for InputPlugin { app.init_resource::() .init_resource::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -147,6 +149,7 @@ fn handle_keyboard_core( mut ev: CoreKeyboardMessages<'_>, mut time_attack: Option>, selection: Option>, + mut zen_requests: MessageReader, ) { if paused.is_some_and(|p| p.0) { 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. confirm.forfeit_countdown = 0.0; // 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); if level >= CHALLENGE_UNLOCK_LEVEL { ev.new_game.write(NewGameRequestEvent { diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 52139ba..cdd99e7 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -69,14 +69,16 @@ pub use events::{ AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, - NewGameRequestEvent, PauseRequestEvent, StateChangedEvent, SyncCompleteEvent, - UndoRequestEvent, XpAwardedEvent, + NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, + StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, + StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent, }; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use help_plugin::{HelpPlugin, HelpScreen}; pub use home_plugin::{HomePlugin, HomeScreen}; pub use hud_plugin::{ - ActionButton, HelpButton, HudAutoComplete, HudPlugin, NewGameButton, PauseButton, UndoButton, + ActionButton, HelpButton, HudAutoComplete, HudPlugin, ModeOption, ModesButton, ModesPopover, + NewGameButton, PauseButton, UndoButton, }; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use input_plugin::InputPlugin; diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index d0ea6b8..709d892 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -8,7 +8,9 @@ use bevy::prelude::*; use solitaire_core::game_state::GameMode; 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::progress_plugin::ProgressResource; use crate::resources::GameStateResource; @@ -40,6 +42,7 @@ impl Plugin for TimeAttackPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_systems( Update, @@ -52,12 +55,15 @@ impl Plugin for TimeAttackPlugin { fn handle_start_time_attack_request( keys: Res>, + mut requests: MessageReader, progress: Res, mut session: ResMut, mut new_game: MessageWriter, mut info_toast: MessageWriter, ) { - 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; } if progress.0.level < CHALLENGE_UNLOCK_LEVEL {