18d7937b51
DrawMode is a fieldless two-variant enum — it is trivially bitwise- copyable. Adding Copy + updating choose_winnable_seed to take the value directly eliminates 13 superfluous .clone() calls across solitaire_core, solitaire_engine, solitaire_assetgen, and solitaire_wasm. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1201 lines
42 KiB
Rust
1201 lines
42 KiB
Rust
//! Pause overlay (Esc).
|
|
//!
|
|
//! While paused:
|
|
//! - The `PausedResource` flag is true.
|
|
//! - Elapsed-time and Time Attack tickers stop counting (they read this
|
|
//! resource and bail out early).
|
|
//!
|
|
//! 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
|
|
//! the pause overlay is **not** opened. Pressing Esc again with no drag
|
|
//! active opens the overlay as normal.
|
|
|
|
use bevy::prelude::*;
|
|
use solitaire_core::game_state::DrawMode;
|
|
use solitaire_data::save_game_state_to;
|
|
|
|
use crate::events::{
|
|
ForfeitEvent, ForfeitRequestEvent, InfoToastEvent, PauseRequestEvent, StateChangedEvent,
|
|
};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
|
use crate::progress_plugin::ProgressResource;
|
|
use crate::replay_playback::ReplayPlaybackState;
|
|
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::hud_plugin::HudPopoverOpen;
|
|
use crate::ui_modal::{
|
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
|
spawn_modal_header, ButtonVariant, ModalScrim,
|
|
};
|
|
use bevy::ecs::system::SystemParam;
|
|
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 scrim.
|
|
#[derive(Component, Debug)]
|
|
pub struct PauseScreen;
|
|
|
|
/// Marker on the "Draw 1" option button inside the pause overlay.
|
|
#[derive(Component, Debug)]
|
|
struct PauseDrawOneButton;
|
|
|
|
/// Marker on the "Draw 3" option button inside the pause overlay.
|
|
#[derive(Component, Debug)]
|
|
struct PauseDrawThreeButton;
|
|
|
|
/// 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 "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.
|
|
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
|
match mode {
|
|
DrawMode::DrawOne => "Draw 1",
|
|
DrawMode::DrawThree => "Draw 3",
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
// 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>()
|
|
.add_message::<InfoToastEvent>()
|
|
.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.
|
|
// 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_buttons,
|
|
handle_pause_resume_button,
|
|
handle_pause_forfeit_button,
|
|
handle_forfeit_request,
|
|
handle_forfeit_confirm_buttons,
|
|
handle_forfeit_keyboard,
|
|
auto_resume_on_overlay,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Bundles the modal-related queries `toggle_pause` reads each tick.
|
|
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
|
|
/// parameter cap after the cross-modal Esc guard query was added.
|
|
#[derive(SystemParam)]
|
|
struct PauseModalQueries<'w, 's> {
|
|
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
|
|
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
|
|
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
|
|
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
|
open_hud_popovers: Query<'w, 's, Entity, With<HudPopoverOpen>>,
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn toggle_pause(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
mut requests: MessageReader<PauseRequestEvent>,
|
|
mut paused: ResMut<PausedResource>,
|
|
modal_queries: PauseModalQueries<'_, '_>,
|
|
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>>,
|
|
replay_state: Option<Res<ReplayPlaybackState>>,
|
|
) {
|
|
let PauseModalQueries {
|
|
pause_screens: screens,
|
|
forfeit_screens,
|
|
game_over_screens,
|
|
other_modal_scrims,
|
|
open_hud_popovers,
|
|
} = modal_queries;
|
|
|
|
// Either Esc or a click on the HUD "Pause" button (which fires
|
|
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
|
|
// burst of clicks doesn't queue future toggles.
|
|
let button_clicked = requests.read().count() > 0;
|
|
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;
|
|
}
|
|
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
|
|
// Settings, etc.) owns its own dismissal — pause must not stack
|
|
// on top of it. Without this guard a single Esc both closes the
|
|
// open modal AND spawns the pause overlay underneath, leaving the
|
|
// player on a screen they didn't ask for. The HUD-button path
|
|
// (`button_clicked`) is gated too; clicking Pause while another
|
|
// modal is up is almost always an accident.
|
|
if !other_modal_scrims.is_empty() {
|
|
return;
|
|
}
|
|
// A HUD popover (Menu or Modes dropdown) is open — the popover's own
|
|
// Escape handler (in HudPlugin) will close it this frame. Don't also
|
|
// spawn the pause overlay on top of the closing popover.
|
|
if !open_hud_popovers.is_empty() {
|
|
return;
|
|
}
|
|
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
|
// own the Esc press — that handler stops the replay. Without this guard a
|
|
// single Esc both stops the replay AND opens the pause modal on top of the
|
|
// (now empty) board, leaving the player on a screen they didn't ask for.
|
|
// The HUD-button path is gated too; clicking Pause while watching a replay
|
|
// is almost always an accident.
|
|
if replay_state.is_some_and(|s| s.is_playing()) {
|
|
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()) {
|
|
return;
|
|
}
|
|
// If the game-over overlay is visible, let handle_game_over_input consume
|
|
// the Escape key (to start a new game). Do not open the pause overlay.
|
|
if !game_over_screens.is_empty() {
|
|
return;
|
|
}
|
|
// If a drag is in progress, cancel it instead of opening the pause overlay.
|
|
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
|
|
// back to their resting positions exactly as a rejected drop does.
|
|
if let Some(ref mut d) = drag
|
|
&& !d.is_idle() {
|
|
d.clear();
|
|
changed.write(StateChangedEvent);
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.single() {
|
|
commands.entity(entity).despawn();
|
|
paused.0 = false;
|
|
} else {
|
|
// Snapshot current level and streak at pause time.
|
|
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);
|
|
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.
|
|
if let (Some(g), Some(p)) = (game, path)
|
|
&& let Some(disk_path) = p.0.as_deref()
|
|
&& let Err(e) = save_game_state_to(disk_path, &g.0) {
|
|
warn!("game_state: failed to save on pause: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles the draw-mode segmented control on the pause overlay.
|
|
///
|
|
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
|
|
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
|
|
/// so the rest of the engine sees the update. Change takes effect next game.
|
|
fn handle_pause_draw_buttons(
|
|
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
|
|
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
|
|
paused: Res<PausedResource>,
|
|
settings: Option<ResMut<SettingsResource>>,
|
|
path: Option<Res<SettingsStoragePath>>,
|
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
|
) {
|
|
if !paused.0 {
|
|
return;
|
|
}
|
|
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
|
|
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
|
|
if !pressed_one && !pressed_three {
|
|
return;
|
|
}
|
|
let Some(mut settings) = settings else { return };
|
|
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
|
|
if settings.0.draw_mode == new_mode {
|
|
return;
|
|
}
|
|
settings.0.draw_mode = new_mode;
|
|
if let Some(p) = &path
|
|
&& let Some(target) = &p.0
|
|
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
|
warn!("failed to save settings after draw-mode change: {e}");
|
|
}
|
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// Surfaces a toast and bails when there is no game to forfeit (won
|
|
/// state, or no `GameStateResource` at all) so the request is never
|
|
/// silently dropped — the prior implementation's silent no-op made the
|
|
/// pause modal's Forfeit button feel broken.
|
|
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>>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
let requested = requests.read().count() > 0;
|
|
if !requested {
|
|
return;
|
|
}
|
|
if !forfeit_screens.is_empty() {
|
|
return;
|
|
}
|
|
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won);
|
|
if !game_in_progress {
|
|
toast.write(InfoToastEvent("No game to forfeit".to_string()));
|
|
return;
|
|
}
|
|
spawn_forfeit_confirm_screen(&mut commands, font_res.as_deref());
|
|
}
|
|
|
|
/// Mouse / touch handler for the forfeit-confirm modal buttons.
|
|
///
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// Automatically closes the pause modal when any non-pause overlay opens
|
|
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
|
|
///
|
|
/// The player reaches these overlays via the HUD menu while paused, which
|
|
/// causes both the pause modal and the overlay to be live simultaneously.
|
|
/// That is always unintentional — the overlay should own the screen.
|
|
/// Query filter for modals that are not part of the pause flow.
|
|
/// Excludes both `PauseScreen` (the pause modal itself) and
|
|
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
|
|
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
|
|
|
|
fn auto_resume_on_overlay(
|
|
mut commands: Commands,
|
|
pause_screens: Query<Entity, With<PauseScreen>>,
|
|
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
|
|
mut paused: ResMut<PausedResource>,
|
|
) {
|
|
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
|
return;
|
|
}
|
|
for entity in &pause_screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
if paused.0 {
|
|
paused.0 = false;
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
) {
|
|
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] [Draw 3]" segmented control + caption.
|
|
///
|
|
/// The active option renders as `Secondary` (elevated), the inactive one as
|
|
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
|
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()
|
|
};
|
|
let (one_variant, three_variant) = match mode {
|
|
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
|
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
|
};
|
|
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, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
|
|
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
|
|
});
|
|
parent.spawn((
|
|
Text::new("Takes effect next game"),
|
|
caption_font,
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
}
|
|
|
|
/// Spawns `ForfeitConfirmScreen` — a Cancel / "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,
|
|
"Forfeit",
|
|
Some("Y"),
|
|
ButtonVariant::Primary,
|
|
font_res,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Formats the level / win-streak summary line for the pause overlay.
|
|
///
|
|
/// Both values are optional because either resource may be absent in
|
|
/// headless or partially-configured app contexts.
|
|
fn build_level_streak_line(level: Option<u32>, streak: Option<u32>) -> String {
|
|
match (level, streak) {
|
|
(Some(l), Some(s)) => format!("Level {l} Win streak: {s}"),
|
|
(Some(l), None) => format!("Level {l}"),
|
|
(None, Some(s)) => format!("Win streak: {s}"),
|
|
(None, None) => String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn press_esc(app: &mut App) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(KeyCode::Escape);
|
|
input.clear();
|
|
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();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
assert!(app.world().resource::<PausedResource>().0);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&PauseScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_esc_twice_resumes() {
|
|
let mut app = headless_app();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
assert!(!app.world().resource::<PausedResource>().0);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&PauseScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_is_symmetric_for_multiple_cycles() {
|
|
let mut app = headless_app();
|
|
// Third press re-pauses after resume.
|
|
press_esc(&mut app);
|
|
app.update();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
assert!(
|
|
app.world().resource::<PausedResource>().0,
|
|
"third Esc must re-pause"
|
|
);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&PauseScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"third Esc must re-spawn PauseScreen"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// build_level_streak_line (pure function)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn level_streak_both_present() {
|
|
assert_eq!(
|
|
build_level_streak_line(Some(7), Some(3)),
|
|
"Level 7 Win streak: 3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn level_streak_only_level() {
|
|
assert_eq!(build_level_streak_line(Some(5), None), "Level 5");
|
|
}
|
|
|
|
#[test]
|
|
fn level_streak_only_streak() {
|
|
assert_eq!(build_level_streak_line(None, Some(4)), "Win streak: 4");
|
|
}
|
|
|
|
#[test]
|
|
fn level_streak_neither() {
|
|
assert_eq!(build_level_streak_line(None, None), "");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Pause screen with progress / stats resources present
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn pause_screen_spawns_with_level_and_streak_when_resources_present() {
|
|
use crate::progress_plugin::{ProgressPlugin, ProgressResource};
|
|
use crate::settings_plugin::SettingsPlugin;
|
|
use crate::stats_plugin::{StatsPlugin, StatsResource};
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(crate::game_plugin::GamePlugin)
|
|
.add_plugins(crate::table_plugin::TablePlugin)
|
|
.add_plugins(ProgressPlugin::headless())
|
|
.add_plugins(StatsPlugin::headless())
|
|
.add_plugins(SettingsPlugin::headless())
|
|
.add_plugins(PausePlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
|
|
// Set known values.
|
|
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
|
|
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
|
|
|
|
press_esc(&mut app);
|
|
app.update();
|
|
|
|
// Verify the screen was spawned.
|
|
assert!(app.world().resource::<PausedResource>().0);
|
|
|
|
// Find the text nodes on the PauseScreen children and check one contains
|
|
// the expected level/streak string.
|
|
let texts: Vec<String> = app
|
|
.world_mut()
|
|
.query::<&Text>()
|
|
.iter(app.world())
|
|
.map(|t| t.0.clone())
|
|
.collect();
|
|
assert!(
|
|
texts.iter().any(|t| t == "Level 7 Win streak: 3"),
|
|
"expected level/streak line in pause screen texts, got: {texts:?}"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// PausedResource default (pure)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn paused_resource_default_is_unpaused() {
|
|
let p = PausedResource::default();
|
|
assert!(!p.0, "game must start unpaused");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// draw_mode_label (pure function) — Task #64
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn draw_mode_label_draw_one() {
|
|
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
|
}
|
|
|
|
#[test]
|
|
fn draw_mode_label_draw_three() {
|
|
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
|
}
|
|
|
|
/// Both variants are covered so the match is exhaustive — this test would
|
|
/// fail to compile if a new DrawMode variant were added without updating
|
|
/// `draw_mode_label`.
|
|
#[test]
|
|
fn draw_mode_label_covers_all_variants() {
|
|
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
|
let label = draw_mode_label(mode);
|
|
assert!(!label.is_empty(), "draw_mode_label must never return an empty string");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// pause_draw_toggle_flips_draw_mode — Task #64
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn pause_draw_toggle_flips_draw_mode() {
|
|
use crate::settings_plugin::{SettingsPlugin, SettingsResource};
|
|
use solitaire_data::Settings;
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(SettingsPlugin::headless())
|
|
.add_plugins(PausePlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
|
|
// Ensure we start with DrawOne.
|
|
app.world_mut()
|
|
.resource_mut::<SettingsResource>()
|
|
.0
|
|
.draw_mode = DrawMode::DrawOne;
|
|
|
|
// Set paused so handle_pause_draw_toggle acts.
|
|
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
|
|
|
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
|
|
app.world_mut().spawn((
|
|
PauseDrawThreeButton,
|
|
Button,
|
|
Interaction::Pressed,
|
|
));
|
|
|
|
app.update();
|
|
|
|
let mode = &app
|
|
.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.draw_mode;
|
|
assert_eq!(
|
|
*mode,
|
|
DrawMode::DrawThree,
|
|
"pressing Draw 3 must set mode to DrawThree"
|
|
);
|
|
|
|
// Pressing "Draw 1" while DrawThree is active should switch back.
|
|
app.world_mut().spawn((
|
|
PauseDrawOneButton,
|
|
Button,
|
|
Interaction::Pressed,
|
|
));
|
|
|
|
app.update();
|
|
|
|
let mode2 = &app
|
|
.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.draw_mode;
|
|
assert_eq!(
|
|
*mode2,
|
|
DrawMode::DrawOne,
|
|
"pressing Draw 1 must set mode to DrawOne"
|
|
);
|
|
|
|
// Verify a SettingsChangedEvent was fired.
|
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let count = cursor.read(events).count();
|
|
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
|
|
|
|
// 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 fresh `GameStateResource` (not won) so the modal can
|
|
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
|
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>>();
|
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// When the game is already won, a `ForfeitRequestEvent` must not
|
|
/// open the modal (you can't forfeit a finished game) and instead
|
|
/// surface an `InfoToastEvent` so the user gets feedback that the
|
|
/// hotkey was received but is currently a no-op.
|
|
#[test]
|
|
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
|
game.is_won = true;
|
|
app.insert_resource(GameStateResource(game));
|
|
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,
|
|
"the forfeit modal must not open when the current game is already won"
|
|
);
|
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
assert!(
|
|
cursor.read(events).any(|t| t.0 == "No game to forfeit"),
|
|
"an InfoToastEvent must be fired so the player gets feedback"
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
|
|
/// from the menu while game is paused), `auto_resume_on_overlay` must
|
|
/// despawn the pause modal and clear `PausedResource`.
|
|
#[test]
|
|
fn auto_resume_closes_pause_when_overlay_opens() {
|
|
let mut app = headless_app();
|
|
press_esc(&mut app);
|
|
app.update();
|
|
assert!(app.world().resource::<PausedResource>().0);
|
|
assert_eq!(
|
|
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
|
1
|
|
);
|
|
|
|
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
|
|
app.world_mut().spawn(ModalScrim);
|
|
app.update();
|
|
|
|
assert!(
|
|
!app.world().resource::<PausedResource>().0,
|
|
"auto_resume_on_overlay must clear PausedResource when another modal opens"
|
|
);
|
|
assert_eq!(
|
|
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
|
0,
|
|
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|