feat(engine): N keypress now opens the real Confirm/Cancel modal
Previously a first N press during an active game showed a "Press N again" toast and started a 3-second countdown — a UI-first violation since the only continuation was another keystroke. The HUD New Game button already routed through `ConfirmNewGameScreen` with real Cancel / New game buttons; this change makes keyboard N do the same. - handle_keyboard_core fires NewGameRequestEvent::default() directly; handle_new_game's existing active-game check spawns the modal. - Shift+N keeps the keyboard power-user bypass (confirmed: true). - N is suppressed while the confirm modal or restore prompt is open so those modals' own input handlers can process N (cancel / start-new-game) without us re-firing the same frame they close. - KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent, and the "Press N again" toast handler are removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
|||||||
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::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
use crate::events::{InfoToastEvent, XpAwardedEvent};
|
||||||
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;
|
||||||
@@ -161,7 +161,6 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<TimeAttackEndedEvent>()
|
.add_message::<TimeAttackEndedEvent>()
|
||||||
.add_message::<ChallengeAdvancedEvent>()
|
.add_message::<ChallengeAdvancedEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<NewGameConfirmEvent>()
|
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
@@ -183,7 +182,6 @@ 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,
|
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
@@ -459,15 +457,6 @@ fn handle_auto_complete_toast(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_new_game_confirm_toast(
|
|
||||||
mut commands: Commands,
|
|
||||||
mut events: MessageReader<NewGameConfirmEvent>,
|
|
||||||
) {
|
|
||||||
for _ in events.read() {
|
|
||||||
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
||||||
///
|
///
|
||||||
/// This is the first half of the two-system toast queue (Task #67). The queue
|
/// This is the first half of the two-system toast queue (Task #67). The queue
|
||||||
|
|||||||
@@ -207,13 +207,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||||
|
|
||||||
/// 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(Message, Debug, Clone, Copy, Default)]
|
|
||||||
pub struct NewGameConfirmEvent;
|
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
//!
|
//!
|
||||||
//! # Task #69 — Animated card deal on new game start
|
//! # Task #69 — Animated card deal on new game start
|
||||||
//!
|
//!
|
||||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`),
|
||||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
//! `start_deal_anim` reads `LayoutResource` and
|
||||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||||
//! pile's position to its current (final) position with a per-card stagger
|
//! pile's position to its current (final) position with a per-card stagger
|
||||||
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ 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, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||||
StateChangedEvent, UndoRequestEvent,
|
UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -64,22 +64,6 @@ const DRAG_Z: f32 = 500.0;
|
|||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||||
|
|
||||||
/// Shared countdown state for the new-game double-press confirmation
|
|
||||||
/// flow.
|
|
||||||
///
|
|
||||||
/// Using a resource (instead of `Local`) lets the keyboard sub-systems
|
|
||||||
/// share the same countdown state without needing to pass values
|
|
||||||
/// between them. Forfeit no longer has a keyboard countdown — `G` now
|
|
||||||
/// fires `ForfeitRequestEvent` and `PausePlugin` shows a real
|
|
||||||
/// `ForfeitConfirmScreen` modal.
|
|
||||||
#[derive(Resource, Debug, Default)]
|
|
||||||
struct KeyboardConfirmState {
|
|
||||||
/// Seconds remaining in the new-game confirmation window (> 0 while open).
|
|
||||||
new_game_countdown: f32,
|
|
||||||
/// True while we are waiting for the second N press to confirm a new game.
|
|
||||||
new_game_pending: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers keyboard, mouse, and touch input systems.
|
/// Registers keyboard, mouse, and touch input systems.
|
||||||
///
|
///
|
||||||
/// Mouse drag pipeline (ordered, left-to-right):
|
/// Mouse drag pipeline (ordered, left-to-right):
|
||||||
@@ -100,8 +84,6 @@ impl Plugin for InputPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<HintCycleIndex>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
.init_resource::<HintSolverConfig>()
|
.init_resource::<HintSolverConfig>()
|
||||||
.init_resource::<KeyboardConfirmState>()
|
|
||||||
.add_message::<NewGameConfirmEvent>()
|
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
@@ -131,9 +113,6 @@ impl Plugin for InputPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seconds after the first N press during which a second N confirms new game.
|
|
||||||
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
|
||||||
|
|
||||||
/// Bundles the event writers needed by the core keyboard handler.
|
/// Bundles the event writers needed by the core keyboard handler.
|
||||||
///
|
///
|
||||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||||
@@ -141,43 +120,39 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
|||||||
struct CoreKeyboardMessages<'w> {
|
struct CoreKeyboardMessages<'w> {
|
||||||
undo: MessageWriter<'w, UndoRequestEvent>,
|
undo: MessageWriter<'w, UndoRequestEvent>,
|
||||||
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
||||||
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
|
|
||||||
info_toast: MessageWriter<'w, InfoToastEvent>,
|
info_toast: MessageWriter<'w, InfoToastEvent>,
|
||||||
draw: MessageWriter<'w, DrawRequestEvent>,
|
draw: MessageWriter<'w, DrawRequestEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
|
||||||
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
|
/// D / Space (draw).
|
||||||
/// confirmation countdown each frame.
|
///
|
||||||
|
/// `N` fires `NewGameRequestEvent` straight through; the existing
|
||||||
|
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
|
||||||
|
/// the current game is in progress, so a single press surfaces a real
|
||||||
|
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
|
||||||
|
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
|
||||||
|
///
|
||||||
|
/// While the confirm modal or the restore prompt is already open, the
|
||||||
|
/// system skips the N branch so those modals' own input handlers can
|
||||||
|
/// process N (cancel / start-new-game) without us re-firing a request
|
||||||
|
/// the same frame.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard_core(
|
fn handle_keyboard_core(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
|
||||||
time: Res<Time>,
|
|
||||||
mut confirm: ResMut<KeyboardConfirmState>,
|
|
||||||
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>,
|
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||||
|
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||||
|
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick down the new-game confirmation window each frame.
|
|
||||||
if confirm.new_game_countdown > 0.0 {
|
|
||||||
confirm.new_game_countdown -= time.delta_secs();
|
|
||||||
if confirm.new_game_countdown <= 0.0 {
|
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
if confirm.new_game_pending {
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyU) {
|
if keys.just_pressed(KeyCode::KeyU) {
|
||||||
ev.undo.write(UndoRequestEvent);
|
ev.undo.write(UndoRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -194,27 +169,24 @@ fn handle_keyboard_core(
|
|||||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
});
|
});
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
// The confirm modal and restore prompt own N while they're up —
|
||||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
// they cancel / accept respectively. Skipping here prevents us
|
||||||
if shift_held || !active_game {
|
// from firing a fresh request the same frame those modals close.
|
||||||
// Shift+N or no active game — start immediately, no confirmation.
|
if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
|
||||||
ev.new_game.write(NewGameRequestEvent::default());
|
// intentional: defer to those modals' input handlers.
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
} else if confirm.new_game_countdown > 0.0 {
|
|
||||||
// Second press within the window — confirmed.
|
|
||||||
ev.new_game.write(NewGameRequestEvent::default());
|
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
} else {
|
} else {
|
||||||
// First press on an active game — require confirmation.
|
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||||
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
|
ev.new_game.write(NewGameRequestEvent {
|
||||||
confirm.new_game_pending = true;
|
seed: None,
|
||||||
ev.confirm_event.write(NewGameConfirmEvent);
|
mode: None,
|
||||||
|
// Shift+N skips the confirm modal for keyboard power-users;
|
||||||
|
// bare N falls through `handle_new_game`'s active-game check
|
||||||
|
// and shows the modal when a game is in progress.
|
||||||
|
confirmed: shift_held,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2019,15 +1991,6 @@ mod tests {
|
|||||||
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
|
|
||||||
/// confirmation countdown actually opens on the first N press.
|
|
||||||
///
|
|
||||||
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
|
|
||||||
#[test]
|
|
||||||
fn new_game_confirm_window_is_positive() {
|
|
||||||
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ pub use events::{
|
|||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
|||||||
Reference in New Issue
Block a user