feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
Pause overlay drops its bespoke full-screen layout and is rebuilt on the standard `ui_modal` scaffold: uniform scrim, centred card, real Resume (Primary, Esc) and Forfeit (Tertiary, G) action buttons. The Draw Mode row stays inline in the body so the existing toggle still fires `SettingsChangedEvent`. The G-key double-press toast countdown is replaced with a real modal: `G` (or clicking Forfeit on Pause) fires the new `ForfeitRequestEvent`, which `PausePlugin` answers by spawning `ForfeitConfirmScreen` at `Z_PAUSE_DIALOG` (above pause). The modal exposes Cancel + "Yes, forfeit" buttons plus Y/Enter/N/Esc accelerators; confirmation despawns both modals, clears `PausedResource`, and fires `ForfeitEvent` for `StatsPlugin`. `toggle_pause` now early-returns when a forfeit modal is visible (and runs `.before(handle_forfeit_keyboard)`) so an Esc that closes the forfeit modal doesn't also re-open pause in the same frame. The legacy `forfeit_countdown` field, `FORFEIT_CONFIRM_WINDOW` constant, and the six pure-function countdown tests are removed; new tests cover the modal-spawn / confirm / cancel paths and the active- game predicate that still gates the G hotkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,15 @@ pub struct XpAwardedEvent {
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitEvent;
|
||||
|
||||
/// Request to open the forfeit-confirm modal. Fired by the `G` accelerator
|
||||
/// and by the Pause modal's "Forfeit" button so the same modal opens
|
||||
/// either way. Consumed by `PausePlugin`, which spawns
|
||||
/// `ForfeitConfirmScreen` after checking that a game is in progress and
|
||||
/// no forfeit modal is already showing. Confirmation inside that modal
|
||||
/// then fires `ForfeitEvent` for `StatsPlugin` to consume.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitRequestEvent;
|
||||
|
||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::feedback_anim_plugin::ShakeAnim;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
@@ -50,18 +50,20 @@ use crate::time_attack_plugin::TimeAttackResource;
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
/// Shared countdown timers for the double-press confirmation flows.
|
||||
/// Shared countdown state for the new-game double-press confirmation
|
||||
/// flow.
|
||||
///
|
||||
/// Using a resource (instead of `Local`) lets the three keyboard sub-systems
|
||||
/// share the same countdown state without needing to pass values between them.
|
||||
/// 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,
|
||||
/// Seconds remaining in the forfeit confirmation window (> 0 while open).
|
||||
forfeit_countdown: f32,
|
||||
}
|
||||
|
||||
/// Registers keyboard, mouse, and touch input systems.
|
||||
@@ -87,7 +89,7 @@ impl Plugin for InputPlugin {
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<HintVisualEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -117,9 +119,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;
|
||||
|
||||
/// Seconds after the first G press during which a second G confirms forfeit.
|
||||
const FORFEIT_CONFIRM_WINDOW: f32 = 3.0;
|
||||
|
||||
/// Bundles the event writers needed by the core keyboard handler.
|
||||
///
|
||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||
@@ -135,9 +134,6 @@ struct CoreKeyboardMessages<'w> {
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
||||
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
|
||||
/// confirmation countdown each frame.
|
||||
///
|
||||
/// Also resets `forfeit_countdown` whenever U, D, Z, or N are pressed so that
|
||||
/// an in-flight forfeit confirmation is cancelled by any other action.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -168,15 +164,10 @@ fn handle_keyboard_core(
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyU) {
|
||||
// Cancel any pending forfeit when the player takes another action.
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
ev.undo.write(UndoRequestEvent);
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
// Cancel any pending forfeit when the player takes another action.
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
|
||||
// If a Time Attack session is running, cancel it and start a Classic game.
|
||||
if let Some(ref mut session) = time_attack
|
||||
&& session.active {
|
||||
@@ -214,8 +205,6 @@ fn handle_keyboard_core(
|
||||
|
||||
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. Either Z or the HUD
|
||||
// Modes-popover "Zen" row reaches this branch.
|
||||
@@ -238,16 +227,13 @@ fn handle_keyboard_core(
|
||||
let space_draws = keys.just_pressed(KeyCode::Space)
|
||||
&& selection.as_ref().is_none_or(|s| s.selected_pile.is_none());
|
||||
if keys.just_pressed(KeyCode::KeyD) || space_draws {
|
||||
// Cancel any pending forfeit when the player takes another action.
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
ev.draw.write(DrawRequestEvent);
|
||||
}
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: cycles through all available hints, highlighting the
|
||||
/// source card yellow for 2 s and showing a descriptive toast. Resets the
|
||||
/// forfeit countdown on each press.
|
||||
/// source card yellow for 2 s and showing a descriptive toast.
|
||||
///
|
||||
/// The hint index wraps around once all hints have been cycled through. When no
|
||||
/// moves are available a "No hints available" toast is shown instead.
|
||||
@@ -257,7 +243,6 @@ fn handle_keyboard_hint(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
@@ -271,9 +256,6 @@ fn handle_keyboard_hint(
|
||||
return;
|
||||
}
|
||||
|
||||
// H cancels any in-flight forfeit confirmation.
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
|
||||
let Some(ref g) = game else { return };
|
||||
|
||||
if g.0.is_won {
|
||||
@@ -353,52 +335,30 @@ fn handle_keyboard_hint(
|
||||
info_toast.write(InfoToastEvent(msg));
|
||||
}
|
||||
|
||||
/// Handles the G key: forfeit the current game with a 3-second double-confirm
|
||||
/// window to prevent accidental forfeits.
|
||||
/// Handles the G key: fires `ForfeitRequestEvent` so `PausePlugin`
|
||||
/// can spawn the `ForfeitConfirmScreen` modal.
|
||||
///
|
||||
/// First press shows a toast and starts the countdown.
|
||||
/// Second press **within the window** sends [`ForfeitEvent`].
|
||||
/// Pressing any other key between presses cancels the countdown
|
||||
/// (handled by [`handle_keyboard_core`]).
|
||||
/// Replaces a prior double-press toast countdown with a real
|
||||
/// Cancel / Yes-forfeit modal — the same code path the Pause modal's
|
||||
/// Forfeit button takes. Bails when no game is in progress so the
|
||||
/// hotkey is a no-op on the home screen / game-over screen.
|
||||
fn handle_keyboard_forfeit(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
time: Res<Time>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut forfeit: MessageWriter<ForfeitEvent>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tick down the forfeit confirmation window each frame.
|
||||
if confirm.forfeit_countdown > 0.0 {
|
||||
confirm.forfeit_countdown -= time.delta_secs();
|
||||
if confirm.forfeit_countdown <= 0.0 {
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if !keys.just_pressed(KeyCode::KeyG) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
if !active_game {
|
||||
return;
|
||||
}
|
||||
|
||||
if confirm.forfeit_countdown > 0.0 {
|
||||
// Second press within the confirmation window — confirmed.
|
||||
forfeit.write(ForfeitEvent);
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
} else {
|
||||
// First press — start the countdown and warn the player.
|
||||
confirm.forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
info_toast.write(InfoToastEvent("Press G again to forfeit".to_string()));
|
||||
}
|
||||
requests.write(ForfeitRequestEvent);
|
||||
}
|
||||
|
||||
/// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new
|
||||
@@ -1843,138 +1803,26 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #54 — forfeit double-confirm logic pure-function tests
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Verify the FORFEIT_CONFIRM_WINDOW constant is positive so the countdown
|
||||
/// window actually opens on the first G press.
|
||||
/// Pure-function check on the active-game predicate that gates the
|
||||
/// G hotkey: a game must have at least one move and not be won
|
||||
/// before forfeit is meaningful.
|
||||
#[test]
|
||||
fn forfeit_confirm_window_is_positive() {
|
||||
const { assert!(FORFEIT_CONFIRM_WINDOW > 0.0, "FORFEIT_CONFIRM_WINDOW must be > 0"); }
|
||||
}
|
||||
|
||||
/// Simulate the first G press: countdown was 0, so it should become
|
||||
/// FORFEIT_CONFIRM_WINDOW and no ForfeitEvent should be "sent" yet.
|
||||
#[test]
|
||||
fn forfeit_first_press_opens_countdown() {
|
||||
// Simulate: forfeit_countdown starts at 0 (no pending confirmation).
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = true;
|
||||
|
||||
// --- first G press logic (mirrors handle_keyboard) ---
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
// Second press — would send ForfeitEvent.
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
// First press — open window, send toast (not ForfeitEvent).
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must NOT fire on first G press");
|
||||
assert_eq!(
|
||||
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
|
||||
"countdown must be opened to FORFEIT_CONFIRM_WINDOW after first press"
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulate the second G press within the window: countdown > 0, so
|
||||
/// ForfeitEvent should fire and countdown resets to 0.
|
||||
#[test]
|
||||
fn forfeit_second_press_within_window_sends_event() {
|
||||
// Countdown is open from the first press.
|
||||
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 1.0; // still in window
|
||||
let active_game = true;
|
||||
|
||||
// --- second G press logic ---
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(forfeit_sent, "ForfeitEvent MUST fire on second G press within window");
|
||||
assert_eq!(forfeit_countdown, 0.0, "countdown must reset to 0 after confirmation");
|
||||
}
|
||||
|
||||
/// Simulate G press after the countdown has expired: countdown ticked to 0,
|
||||
/// so the next G press opens a fresh window (no ForfeitEvent).
|
||||
#[test]
|
||||
fn forfeit_press_after_countdown_expired_reopens_window() {
|
||||
// Countdown already expired.
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = true;
|
||||
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must NOT fire when countdown expired before second press");
|
||||
assert_eq!(
|
||||
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
|
||||
"a new confirmation window must open"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pressing any other key (e.g. U for undo) while the forfeit countdown is
|
||||
/// active must immediately cancel it (reset to 0.0).
|
||||
#[test]
|
||||
fn forfeit_cancelled_by_other_key_press() {
|
||||
// Countdown is open from the first G press.
|
||||
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 0.5; // still in window
|
||||
|
||||
// --- simulate U (undo) press: cancel countdown ---
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
fn g_key_active_game_predicate_requires_move_and_unwon() {
|
||||
fn is_active(game: &GameState) -> bool {
|
||||
game.move_count > 0 && !game.is_won
|
||||
}
|
||||
// Then perform undo logic (omitted here as it requires Bevy infrastructure).
|
||||
|
||||
assert_eq!(
|
||||
forfeit_countdown, 0.0,
|
||||
"forfeit countdown must be reset to 0.0 when another key is pressed"
|
||||
);
|
||||
}
|
||||
|
||||
/// G press when no game is active must never fire ForfeitEvent and must
|
||||
/// not open a countdown.
|
||||
#[test]
|
||||
fn forfeit_no_active_game_does_nothing() {
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = false;
|
||||
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must not fire when no game is active");
|
||||
assert_eq!(forfeit_countdown, 0.0, "countdown must remain 0 when no game is active");
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
// Fresh deal: move_count == 0 → not active.
|
||||
assert!(!is_active(&game));
|
||||
// Mid-game: move_count > 0, not won → active.
|
||||
game.move_count = 1;
|
||||
assert!(is_active(&game));
|
||||
// Won game: not active even with moves on the clock.
|
||||
game.is_won = true;
|
||||
assert!(!is_active(&game));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -69,9 +69,9 @@ pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
ForfeitEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent,
|
||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
@@ -87,7 +87,7 @@ pub use hud_plugin::{
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||
pub use settings_plugin::{
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
//! - Elapsed-time and Time Attack tickers stop counting (they read this
|
||||
//! resource and bail out early).
|
||||
//!
|
||||
//! Pressing Esc again dismisses the overlay and resumes ticking. Other
|
||||
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
||||
//! "stop the clock" screen for now. A future polish slice can layer
|
||||
//! input-blocking on top if desired.
|
||||
//! The pause modal is built on the standard `ui_modal` scaffold:
|
||||
//! uniform scrim, centred card, real Resume / Forfeit buttons. Clicking
|
||||
//! Forfeit (or pressing the `G` accelerator) fires
|
||||
//! `ForfeitRequestEvent`, which spawns a `ForfeitConfirmScreen` modal
|
||||
//! stacked above the pause card; confirming there fires `ForfeitEvent`
|
||||
//! and dismisses both modals so the new game can start cleanly.
|
||||
//!
|
||||
//! This replaces the prior double-G keyboard countdown — see commit
|
||||
//! history for the legacy `forfeit_countdown` toast design.
|
||||
//!
|
||||
//! **Drag cancellation:** when Esc is pressed while a mouse drag is in
|
||||
//! progress, the drag is cancelled (cards snap back to their origin) and
|
||||
@@ -19,19 +24,27 @@ use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::save_game_state_to;
|
||||
|
||||
use crate::events::{PauseRequestEvent, StateChangedEvent};
|
||||
use crate::events::{ForfeitEvent, ForfeitRequestEvent, PauseRequestEvent, StateChangedEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
||||
};
|
||||
|
||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct PausedResource(pub bool);
|
||||
|
||||
/// Marker on the pause overlay root node.
|
||||
/// Marker on the pause overlay scrim.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PauseScreen;
|
||||
|
||||
@@ -39,6 +52,28 @@ pub struct PauseScreen;
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseDrawToggle;
|
||||
|
||||
/// Marker on the Resume primary button on the pause modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseResumeButton;
|
||||
|
||||
/// Marker on the Forfeit tertiary button on the pause modal. A click
|
||||
/// fires `ForfeitRequestEvent`, the same event the `G` accelerator
|
||||
/// fires, so the same code path opens the confirm modal either way.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseForfeitButton;
|
||||
|
||||
/// Marker on the forfeit-confirm modal scrim.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ForfeitConfirmScreen;
|
||||
|
||||
/// Marker on the Cancel secondary button inside the forfeit-confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct ForfeitCancelButton;
|
||||
|
||||
/// Marker on the "Yes, forfeit" primary button inside the forfeit-confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct ForfeitConfirmButton;
|
||||
|
||||
/// Returns the human-readable label for a draw mode.
|
||||
///
|
||||
/// Used on the pause overlay draw-mode toggle button.
|
||||
@@ -49,24 +84,40 @@ pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles pause and resume: toggles the pause overlay on Esc, freezes game-input systems via `PausedResource`, and saves the in-progress game state to disk.
|
||||
/// Handles pause and resume: toggles the pause overlay on Esc, freezes
|
||||
/// game-input systems via `PausedResource`, saves the in-progress game
|
||||
/// to disk, and routes the Forfeit confirm-modal flow.
|
||||
pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Both add_event calls are idempotent — other plugins may register these
|
||||
// events first, but calling add_event again is always safe.
|
||||
// add_message is idempotent — other plugins may register these
|
||||
// first, but a duplicate call is always safe.
|
||||
app.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_message::<PauseRequestEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// toggle_pause must see SelectionState *before* handle_selection_keys
|
||||
// clears it, so it can skip Escape when a card is selected.
|
||||
toggle_pause.before(SelectionKeySet),
|
||||
// It must also run *before* handle_forfeit_keyboard so the
|
||||
// ForfeitConfirmScreen is still alive when toggle_pause's
|
||||
// early-return guard checks for it — otherwise an Esc that
|
||||
// closes the forfeit modal would also open pause in the
|
||||
// same frame.
|
||||
toggle_pause
|
||||
.before(SelectionKeySet)
|
||||
.before(handle_forfeit_keyboard),
|
||||
handle_pause_draw_toggle,
|
||||
handle_pause_resume_button,
|
||||
handle_pause_forfeit_button,
|
||||
handle_forfeit_request,
|
||||
handle_forfeit_confirm_buttons,
|
||||
handle_forfeit_keyboard,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -79,12 +130,14 @@ fn toggle_pause(
|
||||
mut requests: MessageReader<PauseRequestEvent>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
@@ -96,6 +149,11 @@ fn toggle_pause(
|
||||
if !keys.just_pressed(KeyCode::Escape) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
// Forfeit confirm modal eats Esc — let `handle_forfeit_keyboard`
|
||||
// close it instead of toggling pause.
|
||||
if !forfeit_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
@@ -123,7 +181,13 @@ fn toggle_pause(
|
||||
let level = progress.as_deref().map(|p| p.0.level);
|
||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
|
||||
spawn_pause_screen(&mut commands, level, streak, draw_mode);
|
||||
spawn_pause_screen(
|
||||
&mut commands,
|
||||
level,
|
||||
streak,
|
||||
draw_mode,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
paused.0 = true;
|
||||
// Persist the current game state whenever the player opens the pause
|
||||
// overlay so an OS-level kill still leaves a resumable save.
|
||||
@@ -167,108 +231,273 @@ fn handle_pause_draw_toggle(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-screen pause overlay.
|
||||
/// Closes the pause modal when the player clicks the Resume button.
|
||||
/// Routes through `PauseRequestEvent` so the same toggle path runs as
|
||||
/// when Esc is pressed or the HUD Pause button is clicked.
|
||||
fn handle_pause_resume_button(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseResumeButton>)>,
|
||||
mut requests: MessageWriter<PauseRequestEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction == Interaction::Pressed {
|
||||
requests.write(PauseRequestEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a click on the pause modal's Forfeit button into a
|
||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||
/// confirm modal — same code path as the `G` accelerator.
|
||||
fn handle_pause_forfeit_button(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseForfeitButton>)>,
|
||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction == Interaction::Pressed {
|
||||
requests.write(ForfeitRequestEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns `ForfeitConfirmScreen` in response to a `ForfeitRequestEvent`
|
||||
/// (from the `G` accelerator or the Pause modal's Forfeit button).
|
||||
///
|
||||
/// `level` and `streak` are optional snapshots taken at pause time. When
|
||||
/// `ProgressResource` or `StatsResource` is not installed (e.g. in headless
|
||||
/// tests), those lines are omitted from the overlay.
|
||||
/// Bails when no game is in progress so a stray request never opens
|
||||
/// the modal on the home screen / game-over screen.
|
||||
fn handle_forfeit_request(
|
||||
mut commands: Commands,
|
||||
mut requests: MessageReader<ForfeitRequestEvent>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
let requested = requests.read().count() > 0;
|
||||
if !requested {
|
||||
return;
|
||||
}
|
||||
if !forfeit_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let active_game = game
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
if !active_game {
|
||||
return;
|
||||
}
|
||||
spawn_forfeit_confirm_screen(&mut commands, font_res.as_deref());
|
||||
}
|
||||
|
||||
/// Mouse / touch handler for the forfeit-confirm modal buttons.
|
||||
///
|
||||
/// `draw_mode` is the current draw mode shown on the toggle button. When
|
||||
/// `SettingsResource` is absent the draw-mode row is omitted.
|
||||
/// Cancel despawns the confirm modal and leaves the pause modal (if
|
||||
/// any) untouched. Confirm despawns both modals, clears the paused
|
||||
/// flag, and fires `ForfeitEvent` for `StatsPlugin` to consume.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_forfeit_confirm_buttons(
|
||||
mut commands: Commands,
|
||||
yes_buttons: Query<&Interaction, (Changed<Interaction>, With<ForfeitConfirmButton>)>,
|
||||
no_buttons: Query<&Interaction, (Changed<Interaction>, With<ForfeitCancelButton>)>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||
paused: ResMut<PausedResource>,
|
||||
forfeit: MessageWriter<ForfeitEvent>,
|
||||
) {
|
||||
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !confirmed && !cancelled {
|
||||
return;
|
||||
}
|
||||
close_forfeit_modal(
|
||||
&mut commands,
|
||||
confirmed,
|
||||
&forfeit_screens,
|
||||
&pause_screens,
|
||||
paused,
|
||||
forfeit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Keyboard accelerator for the forfeit-confirm modal — Y / Enter
|
||||
/// confirms; N / Escape cancels.
|
||||
fn handle_forfeit_keyboard(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||
paused: ResMut<PausedResource>,
|
||||
forfeit: MessageWriter<ForfeitEvent>,
|
||||
) {
|
||||
if forfeit_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(keys) = keys else { return };
|
||||
let confirmed = keys.just_pressed(KeyCode::KeyY) || keys.just_pressed(KeyCode::Enter);
|
||||
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
|
||||
if !confirmed && !cancelled {
|
||||
return;
|
||||
}
|
||||
close_forfeit_modal(
|
||||
&mut commands,
|
||||
confirmed,
|
||||
&forfeit_screens,
|
||||
&pause_screens,
|
||||
paused,
|
||||
forfeit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Common cleanup shared by the click and keyboard close paths.
|
||||
///
|
||||
/// On `confirm`: despawn both modals, clear the paused flag, and fire
|
||||
/// `ForfeitEvent`. On cancel: despawn only the confirm modal so the
|
||||
/// pause modal (if any) stays open.
|
||||
fn close_forfeit_modal(
|
||||
commands: &mut Commands,
|
||||
confirm: bool,
|
||||
forfeit_screens: &Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
pause_screens: &Query<Entity, With<PauseScreen>>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
mut forfeit: MessageWriter<ForfeitEvent>,
|
||||
) {
|
||||
for entity in forfeit_screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
if confirm {
|
||||
for entity in pause_screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
paused.0 = false;
|
||||
forfeit.write(ForfeitEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
||||
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
|
||||
/// action buttons, plus a Draw Mode toggle row when settings are
|
||||
/// installed.
|
||||
///
|
||||
/// `level` and `streak` are optional snapshots taken at pause time —
|
||||
/// rendered as the modal's body line. `draw_mode` is the current draw
|
||||
/// mode shown on the toggle button. When the corresponding resources
|
||||
/// are absent (e.g. headless tests) the related sections are omitted.
|
||||
fn spawn_pause_screen(
|
||||
commands: &mut Commands,
|
||||
level: Option<u32>,
|
||||
streak: Option<u32>,
|
||||
draw_mode: Option<DrawMode>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
PauseScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(8.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
||||
ZIndex(220),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Paused"),
|
||||
TextFont {
|
||||
font_size: 48.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
// Level and streak line — only shown when the resources are present.
|
||||
if level.is_some() || streak.is_some() {
|
||||
let info = build_level_streak_line(level, streak);
|
||||
b.spawn((
|
||||
Text::new(info),
|
||||
TextFont {
|
||||
font_size: 22.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.75, 0.95, 0.75)),
|
||||
));
|
||||
}
|
||||
// Draw-mode toggle row — only shown when SettingsResource is present.
|
||||
if let Some(mode) = draw_mode {
|
||||
b.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(12.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Draw Mode:"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
PauseDrawToggle,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
||||
))
|
||||
.with_children(|btn| {
|
||||
btn.spawn((
|
||||
Text::new(draw_mode_label(mode)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
b.spawn((
|
||||
Text::new("Takes effect next game"),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||
));
|
||||
}
|
||||
b.spawn((
|
||||
Text::new("Press Esc to resume"),
|
||||
TextFont {
|
||||
font_size: 22.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
|
||||
spawn_modal_header(card, "Paused", font_res);
|
||||
if level.is_some() || streak.is_some() {
|
||||
let info = build_level_streak_line(level, streak);
|
||||
spawn_modal_body_text(card, info, TEXT_SECONDARY, font_res);
|
||||
}
|
||||
if let Some(mode) = draw_mode {
|
||||
spawn_draw_mode_row(card, mode, font_res);
|
||||
}
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseForfeitButton,
|
||||
"Forfeit",
|
||||
Some("G"),
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseResumeButton,
|
||||
"Resume",
|
||||
Some("Esc"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Inline "Draw Mode [Draw 1]" row + a caption explaining the change
|
||||
/// applies to the next game. Spawned inside the modal body.
|
||||
fn spawn_draw_mode_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
mode: DrawMode,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Draw Mode"),
|
||||
label_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PauseDrawToggle,
|
||||
draw_mode_label(mode),
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
parent.spawn((
|
||||
Text::new("Takes effect next game"),
|
||||
caption_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Yes, forfeit" modal
|
||||
/// stacked above the pause modal at `Z_PAUSE_DIALOG`.
|
||||
fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(
|
||||
commands,
|
||||
ForfeitConfirmScreen,
|
||||
ui_theme::Z_PAUSE_DIALOG,
|
||||
|card| {
|
||||
spawn_modal_header(card, "Forfeit this game?", font_res);
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"This will count as a loss and break your win streak.",
|
||||
TEXT_SECONDARY,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ForfeitCancelButton,
|
||||
"Cancel",
|
||||
Some("Esc"),
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ForfeitConfirmButton,
|
||||
"Yes, forfeit",
|
||||
Some("Y"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats the level / win-streak summary line for the pause overlay.
|
||||
@@ -303,6 +532,24 @@ mod tests {
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
|
||||
fn press_key(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
/// `MinimalPlugins` does not include the input-plugin tick that
|
||||
/// transitions `just_pressed` → `pressed` between frames, so a key
|
||||
/// press would otherwise stay "just-pressed" forever. Call this
|
||||
/// helper between `app.update()` calls in tests that span multiple
|
||||
/// frames without re-pressing — it mirrors the real input cycle.
|
||||
fn advance_input(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_esc_pauses() {
|
||||
let mut app = headless_app();
|
||||
@@ -542,4 +789,289 @@ mod tests {
|
||||
// Restore default settings state for hygiene.
|
||||
let _ = Settings::default();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pause modal exposes Resume + Forfeit buttons
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pause_modal_has_resume_and_forfeit_buttons() {
|
||||
let mut app = headless_app();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
|
||||
let resume_count = app
|
||||
.world_mut()
|
||||
.query::<&PauseResumeButton>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
let forfeit_count = app
|
||||
.world_mut()
|
||||
.query::<&PauseForfeitButton>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(resume_count, 1, "Resume button must be present on the pause modal");
|
||||
assert_eq!(forfeit_count, 1, "Forfeit button must be present on the pause modal");
|
||||
}
|
||||
|
||||
/// Clicking the Resume button (via Pressed interaction) closes the
|
||||
/// pause modal — same outcome as a second Esc.
|
||||
#[test]
|
||||
fn pause_resume_button_closes_modal() {
|
||||
let mut app = headless_app();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
|
||||
// Mark the Resume button as Pressed.
|
||||
let resume_entity = {
|
||||
let mut q = app.world_mut().query_filtered::<Entity, With<PauseResumeButton>>();
|
||||
q.iter(app.world()).next().expect("Resume button must exist")
|
||||
};
|
||||
app.world_mut()
|
||||
.entity_mut(resume_entity)
|
||||
.insert(Interaction::Pressed);
|
||||
|
||||
// Clear keys so the simulated "click" isn't competing with a real Esc press.
|
||||
app.world_mut().resource_mut::<ButtonInput<KeyCode>>().clear();
|
||||
app.update();
|
||||
// One more frame so the resulting PauseRequestEvent is consumed by toggle_pause.
|
||||
app.update();
|
||||
|
||||
assert!(!app.world().resource::<PausedResource>().0, "Resume must clear PausedResource");
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"Resume must despawn PauseScreen"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Forfeit confirm modal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Test app with the resources `handle_forfeit_request` reads.
|
||||
/// Provides a `GameStateResource` with one move so `active_game` is true.
|
||||
fn forfeit_app() -> App {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
|
||||
// Build an "active" game: move_count > 0 and not won.
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
game.move_count = 1;
|
||||
app.insert_resource(GameStateResource(game));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_request_event_spawns_forfeit_confirm_screen() {
|
||||
let mut app = forfeit_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"ForfeitRequestEvent must spawn the forfeit-confirm modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_request_does_not_double_spawn() {
|
||||
let mut app = forfeit_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"second ForfeitRequestEvent must not stack a second modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_request_does_nothing_when_no_active_game() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
// GameState with move_count == 0 — not an active game.
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.update();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"ForfeitRequestEvent must be ignored when no game is in progress"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_confirm_y_key_fires_forfeit_event_and_despawns() {
|
||||
let mut app = forfeit_app();
|
||||
// Open the forfeit confirm modal.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Press Y to confirm.
|
||||
press_key(&mut app, KeyCode::KeyY);
|
||||
app.update();
|
||||
|
||||
// Modal despawned.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"Y must despawn the forfeit-confirm modal"
|
||||
);
|
||||
// ForfeitEvent fired.
|
||||
let events = app.world().resource::<Messages<ForfeitEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(events).count(),
|
||||
1,
|
||||
"Y must fire exactly one ForfeitEvent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_confirm_n_key_cancels_without_firing_event() {
|
||||
let mut app = forfeit_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
|
||||
press_key(&mut app, KeyCode::KeyN);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"N must despawn the forfeit-confirm modal"
|
||||
);
|
||||
let events = app.world().resource::<Messages<ForfeitEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(events).count(),
|
||||
0,
|
||||
"Cancelling the modal must not fire ForfeitEvent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_confirm_esc_cancels_without_toggling_pause() {
|
||||
let mut app = forfeit_app();
|
||||
// Open the forfeit confirm modal directly via G-equivalent event.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
// Pause is NOT open at this point.
|
||||
assert!(!app.world().resource::<PausedResource>().0);
|
||||
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
|
||||
// Forfeit modal is gone.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
// Pause must NOT have toggled on — the Esc was consumed by the
|
||||
// forfeit-confirm handler, and toggle_pause must early-return
|
||||
// when a forfeit modal is visible (the despawn happens in the
|
||||
// same frame, but the early-return guard runs first).
|
||||
assert!(
|
||||
!app.world().resource::<PausedResource>().0,
|
||||
"Esc that closes the forfeit modal must not also open pause"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_confirm_y_also_closes_pause_modal() {
|
||||
let mut app = forfeit_app();
|
||||
// Open pause first.
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
// Cycle input so Esc is no longer treated as `just_pressed` —
|
||||
// otherwise the next update would toggle pause back off and the
|
||||
// freshly-spawned forfeit modal would also be cancelled by the
|
||||
// stale Esc in handle_forfeit_keyboard.
|
||||
advance_input(&mut app);
|
||||
// Open forfeit-confirm on top of pause.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ForfeitRequestEvent>>()
|
||||
.write(ForfeitRequestEvent);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ForfeitConfirmScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
press_key(&mut app, KeyCode::KeyY);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"confirming forfeit must also despawn the pause modal"
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<PausedResource>().0,
|
||||
"PausedResource must be cleared when a forfeit is confirmed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,10 @@ pub const Z_MODAL_SCRIM: i32 = 200;
|
||||
pub const Z_MODAL_PANEL: i32 = 210;
|
||||
/// Pause overlay outranks normal modals — pausing should always be on top.
|
||||
pub const Z_PAUSE: i32 = 220;
|
||||
/// Confirmation dialog stacked on top of the pause overlay (e.g. the
|
||||
/// forfeit-confirm modal launched from the pause modal). Sits above
|
||||
/// `Z_PAUSE` so the dialog is always visible over the paused state.
|
||||
pub const Z_PAUSE_DIALOG: i32 = 225;
|
||||
pub const Z_ONBOARDING: i32 = 230;
|
||||
/// Win cascade sits between modals and toasts so the celebration plays
|
||||
/// over a paused / mid-modal screen.
|
||||
@@ -325,6 +329,7 @@ mod tests {
|
||||
Z_MODAL_SCRIM,
|
||||
Z_MODAL_PANEL,
|
||||
Z_PAUSE,
|
||||
Z_PAUSE_DIALOG,
|
||||
Z_ONBOARDING,
|
||||
Z_WIN_CASCADE,
|
||||
Z_TOAST,
|
||||
|
||||
Reference in New Issue
Block a user