feat(engine): require N-key confirmation when abandoning an active game

Pressing N during an active game (move_count > 0, not won) now shows a
"Press N again to start a new game" toast and only starts a new game if
N is pressed a second time within 3 seconds. Starting a fresh game or
pressing N after a win still acts immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 02:53:28 +00:00
parent dfeaed6de2
commit 4a33cbdc22
5 changed files with 66 additions and 16 deletions
+12
View File
@@ -11,6 +11,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::NewGameConfirmEvent;
use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -91,6 +92,7 @@ impl Plugin for AnimationPlugin {
.add_event::<TimeAttackEndedEvent>() .add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>() .add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>() .add_event::<SettingsChangedEvent>()
.add_event::<NewGameConfirmEvent>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.add_systems(Startup, init_slide_duration) .add_systems(Startup, init_slide_duration)
.add_systems( .add_systems(
@@ -108,6 +110,7 @@ impl Plugin for AnimationPlugin {
handle_challenge_toast, handle_challenge_toast,
handle_settings_toast, handle_settings_toast,
handle_auto_complete_toast, handle_auto_complete_toast,
handle_new_game_confirm_toast,
tick_toasts, tick_toasts,
) )
.after(GameMutation), .after(GameMutation),
@@ -310,6 +313,15 @@ fn handle_auto_complete_toast(
} }
} }
fn handle_new_game_confirm_toast(
mut commands: Commands,
mut events: EventReader<NewGameConfirmEvent>,
) {
for _ in events.read() {
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
}
}
fn tick_toasts( fn tick_toasts(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
+7
View File
@@ -68,3 +68,10 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
/// starting a new pull task if one is not already in flight. /// starting a new pull task if one is not already in flight.
#[derive(Event, Debug, Clone, Copy, Default)] #[derive(Event, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent; pub struct ManualSyncRequestEvent;
/// Fired by `InputPlugin` when N is pressed while a game is in progress
/// but confirmation has not yet been received. The animation plugin shows
/// a "Press N again to confirm" toast. A second N press within the
/// confirmation window sends `NewGameRequestEvent`.
#[derive(Event, Debug, Clone, Copy, Default)]
pub struct NewGameConfirmEvent;
+1 -1
View File
@@ -44,7 +44,7 @@ fn spawn_help_screen(commands: &mut Commands) {
" Click stock Draw".to_string(), " Click stock Draw".to_string(),
String::new(), String::new(),
"-- New Game --".to_string(), "-- New Game --".to_string(),
" N New Classic game".to_string(), " N New Classic game (N twice if in progress)".to_string(),
" C Start today's daily challenge".to_string(), " C Start today's daily challenge".to_string(),
" Z Start a Zen game (level 5+)".to_string(), " Z Start a Zen game (level 5+)".to_string(),
" X Start the next Challenge (level 5+)".to_string(), " X Start the next Challenge (level 5+)".to_string(),
+44 -14
View File
@@ -25,8 +25,8 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC}; use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent,
UndoRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -47,32 +47,62 @@ pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_event::<NewGameConfirmEvent>()
Update, .add_systems(
( Update,
handle_keyboard, (
handle_stock_click, handle_keyboard,
start_drag, handle_stock_click,
follow_drag, start_drag,
end_drag.before(GameMutation), follow_drag,
) end_drag.before(GameMutation),
.chain(), )
); .chain(),
);
} }
} }
/// Seconds after the first N press during which a second N confirms new game.
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
#[allow(clippy::too_many_arguments)]
fn handle_keyboard( fn handle_keyboard(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
game: Option<Res<crate::resources::GameStateResource>>,
time: Res<Time>,
mut confirm_countdown: Local<f32>,
mut undo: EventWriter<UndoRequestEvent>, mut undo: EventWriter<UndoRequestEvent>,
mut new_game: EventWriter<NewGameRequestEvent>, mut new_game: EventWriter<NewGameRequestEvent>,
mut confirm_event: EventWriter<NewGameConfirmEvent>,
mut draw: EventWriter<DrawRequestEvent>, mut draw: EventWriter<DrawRequestEvent>,
) { ) {
// Tick down any active confirmation window.
if *confirm_countdown > 0.0 {
*confirm_countdown -= time.delta_secs();
if *confirm_countdown <= 0.0 {
*confirm_countdown = 0.0;
}
}
if keys.just_pressed(KeyCode::KeyU) { if keys.just_pressed(KeyCode::KeyU) {
undo.send(UndoRequestEvent); undo.send(UndoRequestEvent);
} }
if keys.just_pressed(KeyCode::KeyN) { if keys.just_pressed(KeyCode::KeyN) {
new_game.send(NewGameRequestEvent::default()); let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
if !active_game {
// No active game — start immediately.
new_game.send(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
} else if *confirm_countdown > 0.0 {
// Second press within the window — confirmed.
new_game.send(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
} else {
// First press on an active game — require confirmation.
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
confirm_event.send(NewGameConfirmEvent);
}
} }
if keys.just_pressed(KeyCode::KeyZ) { if keys.just_pressed(KeyCode::KeyZ) {
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL. // Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
+2 -1
View File
@@ -40,7 +40,8 @@ pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, ManualSyncRequestEvent, AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, ManualSyncRequestEvent,
MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent,
UndoRequestEvent,
}; };
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath}; pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};