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>
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -99,6 +99,36 @@ pub struct PauseRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct HelpRequestEvent;
|
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
|
/// 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
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ 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::progress_plugin::ProgressResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, UndoRequestEvent,
|
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
|
StartZenRequestEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -115,6 +119,30 @@ pub struct PauseButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HelpButton;
|
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.
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
const Z_HUD: i32 = 50;
|
const Z_HUD: i32 = 50;
|
||||||
|
|
||||||
@@ -128,16 +156,20 @@ pub struct HudPlugin;
|
|||||||
|
|
||||||
impl Plugin for HudPlugin {
|
impl Plugin for HudPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// The click handlers write to messages registered elsewhere
|
// The click handlers write to messages registered elsewhere by their
|
||||||
// (`NewGameRequestEvent` in `GamePlugin`, `UndoRequestEvent` in
|
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
|
||||||
// `GamePlugin`, `PauseRequestEvent` in `PausePlugin`,
|
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
|
||||||
// `HelpRequestEvent` in `HelpPlugin`). Re-register defensively so the
|
// `input_plugin`). Re-register defensively so the HUD plugin works in
|
||||||
// HUD plugin works in isolation under `MinimalPlugins` (tests).
|
// isolation under `MinimalPlugins` (tests). `add_message` is
|
||||||
// `add_message` is idempotent.
|
// idempotent.
|
||||||
app.add_message::<NewGameRequestEvent>()
|
app.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<HelpRequestEvent>()
|
.add_message::<HelpRequestEvent>()
|
||||||
|
.add_message::<StartZenRequestEvent>()
|
||||||
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
|
.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))
|
||||||
@@ -149,6 +181,8 @@ impl Plugin for HudPlugin {
|
|||||||
handle_undo_button,
|
handle_undo_button,
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
|
handle_modes_button,
|
||||||
|
handle_mode_option_click,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -261,6 +295,7 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
|||||||
spawn_action_button(row, UndoButton, "Undo", &font);
|
spawn_action_button(row, UndoButton, "Undo", &font);
|
||||||
spawn_action_button(row, PauseButton, "Pause", &font);
|
spawn_action_button(row, PauseButton, "Pause", &font);
|
||||||
spawn_action_button(row, HelpButton, "Help", &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);
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
/// changes. One query covers all action buttons via the shared
|
/// changes. One query covers all action buttons via the shared
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -69,14 +69,16 @@ pub use events::{
|
|||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent,
|
ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent,
|
||||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
UndoRequestEvent, XpAwardedEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
|
StateChangedEvent, SyncCompleteEvent, UndoRequestEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||||
pub use hud_plugin::{
|
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 leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user