feat(engine): MoveRejectedEvent + PausePlugin (Esc)
- New MoveRejectedEvent fires from end_drag when the cursor is over a real pile but the placement is illegal. AudioPlugin plays card_invalid.wav on it. - New PausePlugin + PausedResource: Esc toggles a full-window overlay and the flag. tick_elapsed_time and advance_time_attack skip work while paused. Help cheat sheet updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+11
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Last updated: 2026-04-25
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||||
> Test count: **226 passing** (83 core + 54 data + 89 engine), `cargo clippy --workspace -- -D warnings` clean
|
> Test count: **228 passing** (83 core + 54 data + 91 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,19 +156,24 @@ All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin
|
|||||||
### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE
|
### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE
|
||||||
|
|
||||||
- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator.
|
- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator.
|
||||||
- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare). `card_invalid.wav` is loaded but unused — wiring it needs a `MoveRejectedEvent` (no such event today).
|
- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare).
|
||||||
- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently.
|
- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently.
|
||||||
- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync.
|
- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync.
|
||||||
|
|
||||||
|
### Phase 7 (part 3) — MoveRejectedEvent + Pause Menu ✅ COMPLETE
|
||||||
|
|
||||||
|
- New `MoveRejectedEvent { from, to, count }`. `end_drag` fires it when the cursor is over a real pile but `can_place_*` rejects the placement. `AudioPlugin` plays `card_invalid.wav` on it.
|
||||||
|
- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more.
|
||||||
|
- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour.
|
||||||
|
|
||||||
## What Is Next
|
## What Is Next
|
||||||
|
|
||||||
### Phase 7 (part 3+) — Pause Menu + Polish
|
### Phase 7 (part 4+) — Polish
|
||||||
|
|
||||||
- **Pause menu**: Esc currently logs a placeholder. Likely a small overlay similar to `HelpPlugin` with a `Paused` resource that gates `Time::delta_secs` in `tick_elapsed_time` / `advance_time_attack`.
|
- **Volume controls**: Settings overlay with `sfx_volume` slider; persist via `solitaire_data::Settings`. Apply to kira's main-track gain.
|
||||||
- **`MoveRejectedEvent`** (small): emit from `end_drag` when a drop is on a real pile but validation fails, so `card_invalid.wav` finally has something to fire on.
|
|
||||||
- **Volume controls**: Settings overlay with `sfx_volume` slider; persist via `solitaire_data::Settings` (already defined). Apply to kira's main-track gain.
|
|
||||||
- **Ambient loop**: optional sixth WAV — needs taste, deferred.
|
- **Ambient loop**: optional sixth WAV — needs taste, deferred.
|
||||||
- **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`).
|
- **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`).
|
||||||
|
- **Optional**: block input while paused (drag, hotkeys) for stricter pause semantics.
|
||||||
|
|
||||||
### Phase 8 — Sync
|
### Phase 8 — Sync
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
||||||
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin,
|
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin,
|
||||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -30,6 +30,7 @@ fn main() {
|
|||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,10 @@
|
|||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | `DrawRequestEvent` | `card_flip.wav` |
|
//! | `DrawRequestEvent` | `card_flip.wav` |
|
||||||
//! | `MoveRequestEvent` | `card_place.wav` |
|
//! | `MoveRequestEvent` | `card_place.wav` |
|
||||||
|
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||||
//!
|
//!
|
||||||
//! `card_invalid.wav` is loaded but not yet wired — there is no
|
|
||||||
//! "rejected move" event today; adding one is a follow-up.
|
|
||||||
//!
|
|
||||||
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
||||||
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
||||||
//! logs a warning and degrades gracefully — gameplay continues, just
|
//! logs a warning and degrades gracefully — gameplay continues, just
|
||||||
@@ -25,7 +23,9 @@ use kira::manager::backend::DefaultBackend;
|
|||||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||||
use kira::sound::static_sound::StaticSoundData;
|
use kira::sound::static_sound::StaticSoundData;
|
||||||
|
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent};
|
use crate::events::{
|
||||||
|
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||||
|
};
|
||||||
|
|
||||||
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
|
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
|
||||||
/// so we hand a fresh handle to `manager.play()` on every event.
|
/// so we hand a fresh handle to `manager.play()` on every event.
|
||||||
@@ -63,6 +63,7 @@ impl Plugin for AudioPlugin {
|
|||||||
|
|
||||||
app.add_event::<DrawRequestEvent>()
|
app.add_event::<DrawRequestEvent>()
|
||||||
.add_event::<MoveRequestEvent>()
|
.add_event::<MoveRequestEvent>()
|
||||||
|
.add_event::<MoveRejectedEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -70,6 +71,7 @@ impl Plugin for AudioPlugin {
|
|||||||
(
|
(
|
||||||
play_on_draw,
|
play_on_draw,
|
||||||
play_on_move,
|
play_on_move,
|
||||||
|
play_on_rejected,
|
||||||
play_on_new_game,
|
play_on_new_game,
|
||||||
play_on_win,
|
play_on_win,
|
||||||
),
|
),
|
||||||
@@ -137,6 +139,19 @@ fn play_on_move(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn play_on_rejected(
|
||||||
|
mut events: EventReader<MoveRejectedEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn play_on_new_game(
|
fn play_on_new_game(
|
||||||
mut events: EventReader<NewGameRequestEvent>,
|
mut events: EventReader<NewGameRequestEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ pub struct NewGameRequestEvent {
|
|||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||||
pub struct StateChangedEvent;
|
pub struct StateChangedEvent;
|
||||||
|
|
||||||
|
/// Fired by input/UI systems when a player attempts to drop dragged cards
|
||||||
|
/// on a real pile but the move violates the rules. Drives the
|
||||||
|
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||||
|
#[derive(Event, Debug, Clone)]
|
||||||
|
pub struct MoveRejectedEvent {
|
||||||
|
pub from: PileType,
|
||||||
|
pub to: PileType,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fired once when the active game transitions to won.
|
/// Fired once when the active game transitions to won.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
pub struct GameWonEvent {
|
pub struct GameWonEvent {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ impl Plugin for GamePlugin {
|
|||||||
.add_event::<UndoRequestEvent>()
|
.add_event::<UndoRequestEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
.add_event::<StateChangedEvent>()
|
.add_event::<StateChangedEvent>()
|
||||||
|
.add_event::<crate::events::MoveRejectedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<crate::events::CardFlippedEvent>()
|
.add_event::<crate::events::CardFlippedEvent>()
|
||||||
.add_event::<crate::events::AchievementUnlockedEvent>()
|
.add_event::<crate::events::AchievementUnlockedEvent>()
|
||||||
@@ -72,13 +73,18 @@ pub fn advance_elapsed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||||
/// the game is in progress (not won). Stops counting on win so the final
|
/// the game is in progress (not won) and not paused. Stops counting on
|
||||||
/// time reflects how long the player took to solve the deal.
|
/// win so the final time reflects how long the player took to solve the
|
||||||
|
/// deal; stops while the pause overlay is open.
|
||||||
fn tick_elapsed_time(
|
fn tick_elapsed_time(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut accumulator: Local<f32>,
|
mut accumulator: Local<f32>,
|
||||||
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let is_won = game.0.is_won;
|
let is_won = game.0.is_won;
|
||||||
advance_elapsed(
|
advance_elapsed(
|
||||||
&mut game.0.elapsed_seconds,
|
&mut game.0.elapsed_seconds,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ fn spawn_help_screen(commands: &mut Commands) {
|
|||||||
"-- Overlays --".to_string(),
|
"-- Overlays --".to_string(),
|
||||||
" S Toggle stats / progression".to_string(),
|
" S Toggle stats / progression".to_string(),
|
||||||
" H or ? Toggle this help".to_string(),
|
" H or ? Toggle this help".to_string(),
|
||||||
" Esc Pause (placeholder)".to_string(),
|
" Esc Pause / resume".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
"Press H or ? to close".to_string(),
|
"Press H or ? to close".to_string(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
|||||||
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
|
UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -93,10 +94,7 @@ fn handle_keyboard(
|
|||||||
if keys.just_pressed(KeyCode::KeyD) {
|
if keys.just_pressed(KeyCode::KeyD) {
|
||||||
draw.send(DrawRequestEvent);
|
draw.send(DrawRequestEvent);
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::Escape) {
|
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||||
// Pause placeholder — the pause screen hooks this up in a later phase.
|
|
||||||
info!("pause requested (not yet wired)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_stock_click(
|
fn handle_stock_click(
|
||||||
@@ -215,6 +213,7 @@ fn end_drag(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut drag: ResMut<DragState>,
|
mut drag: ResMut<DragState>,
|
||||||
mut moves: EventWriter<MoveRequestEvent>,
|
mut moves: EventWriter<MoveRequestEvent>,
|
||||||
|
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
) {
|
) {
|
||||||
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
|
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
|
||||||
@@ -234,7 +233,9 @@ fn end_drag(
|
|||||||
|
|
||||||
// Whether we fire a MoveRequestEvent or not, always trigger a resync so
|
// Whether we fire a MoveRequestEvent or not, always trigger a resync so
|
||||||
// the dragged cards snap back to their resting positions if the move is
|
// the dragged cards snap back to their resting positions if the move is
|
||||||
// rejected (or never fired).
|
// rejected (or never fired). When the cursor was over a real pile but
|
||||||
|
// the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
|
||||||
|
// play card_invalid.wav.
|
||||||
let mut fired = false;
|
let mut fired = false;
|
||||||
if let Some(target) = target {
|
if let Some(target) = target {
|
||||||
if target != origin {
|
if target != origin {
|
||||||
@@ -256,11 +257,17 @@ fn end_drag(
|
|||||||
};
|
};
|
||||||
if ok {
|
if ok {
|
||||||
moves.send(MoveRequestEvent {
|
moves.send(MoveRequestEvent {
|
||||||
from: origin,
|
from: origin.clone(),
|
||||||
to: target,
|
to: target.clone(),
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
fired = true;
|
fired = true;
|
||||||
|
} else {
|
||||||
|
rejected.send(MoveRejectedEvent {
|
||||||
|
from: origin.clone(),
|
||||||
|
to: target.clone(),
|
||||||
|
count,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod game_plugin;
|
|||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod pause_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
@@ -31,12 +32,13 @@ pub use animation_plugin::{AnimationPlugin, CardAnim};
|
|||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{GameMutation, GamePlugin};
|
pub use game_plugin::{GameMutation, GamePlugin};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
|
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! 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).
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PauseScreen;
|
||||||
|
|
||||||
|
pub struct PausePlugin;
|
||||||
|
|
||||||
|
impl Plugin for PausePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<PausedResource>()
|
||||||
|
.add_systems(Update, toggle_pause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_pause(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut paused: ResMut<PausedResource>,
|
||||||
|
screens: Query<Entity, With<PauseScreen>>,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = screens.get_single() {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
paused.0 = false;
|
||||||
|
} else {
|
||||||
|
spawn_pause_screen(&mut commands);
|
||||||
|
paused.0 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_pause_screen(commands: &mut Commands) {
|
||||||
|
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)),
|
||||||
|
));
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Press Esc to resume"),
|
||||||
|
TextFont {
|
||||||
|
font_size: 22.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,10 +79,14 @@ fn advance_time_attack(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut session: ResMut<TimeAttackResource>,
|
mut session: ResMut<TimeAttackResource>,
|
||||||
mut ended: EventWriter<TimeAttackEndedEvent>,
|
mut ended: EventWriter<TimeAttackEndedEvent>,
|
||||||
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
) {
|
) {
|
||||||
if !session.active {
|
if !session.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
session.remaining_secs -= time.delta_secs();
|
session.remaining_secs -= time.delta_secs();
|
||||||
if session.remaining_secs <= 0.0 {
|
if session.remaining_secs <= 0.0 {
|
||||||
let wins = session.wins;
|
let wins = session.wins;
|
||||||
|
|||||||
Reference in New Issue
Block a user