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:
funman300
2026-04-25 22:56:35 -07:00
parent adacdf533c
commit b720588687
10 changed files with 214 additions and 25 deletions
+11 -6
View File
@@ -2,7 +2,7 @@
> Last updated: 2026-04-25
> 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 (3A3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin
### 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.
- `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.
- 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
### 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`.
- **`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.
- **Volume controls**: Settings overlay with `sfx_volume` slider; persist via `solitaire_data::Settings`. Apply to kira's main-track gain.
- **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`).
- **Optional**: block input while paused (drag, hotkeys) for stricter pause semantics.
### Phase 8 — Sync
+3 -2
View File
@@ -1,8 +1,8 @@
use bevy::prelude::*;
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin,
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin,
StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
};
fn main() {
@@ -30,6 +30,7 @@ fn main() {
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HelpPlugin)
.add_plugins(PausePlugin)
.add_plugins(AudioPlugin)
.run();
}
+19 -4
View File
@@ -7,12 +7,10 @@
//! |---|---|
//! | `DrawRequestEvent` | `card_flip.wav` |
//! | `MoveRequestEvent` | `card_place.wav` |
//! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.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
//! Linux box without a running PulseAudio/Pipewire session), the plugin
//! 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::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]>`),
/// so we hand a fresh handle to `manager.play()` on every event.
@@ -63,6 +63,7 @@ impl Plugin for AudioPlugin {
app.add_event::<DrawRequestEvent>()
.add_event::<MoveRequestEvent>()
.add_event::<MoveRejectedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_systems(
@@ -70,6 +71,7 @@ impl Plugin for AudioPlugin {
(
play_on_draw,
play_on_move,
play_on_rejected,
play_on_new_game,
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(
mut events: EventReader<NewGameRequestEvent>,
mut audio: NonSendMut<AudioState>,
+10
View File
@@ -34,6 +34,16 @@ pub struct NewGameRequestEvent {
#[derive(Event, Debug, Clone, Copy, Default)]
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.
#[derive(Event, Debug, Clone, Copy)]
pub struct GameWonEvent {
+8 -2
View File
@@ -35,6 +35,7 @@ impl Plugin for GamePlugin {
.add_event::<UndoRequestEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<StateChangedEvent>()
.add_event::<crate::events::MoveRejectedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
@@ -72,13 +73,18 @@ pub fn advance_elapsed(
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won). Stops counting on win so the final
/// time reflects how long the player took to solve the deal.
/// the game is in progress (not won) and not paused. Stops counting on
/// 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(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
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;
advance_elapsed(
&mut game.0.elapsed_seconds,
+1 -1
View File
@@ -53,7 +53,7 @@ fn spawn_help_screen(commands: &mut Commands) {
"-- Overlays --".to_string(),
" S Toggle stats / progression".to_string(),
" H or ? Toggle this help".to_string(),
" Esc Pause (placeholder)".to_string(),
" Esc Pause / resume".to_string(),
String::new(),
"Press H or ? to close".to_string(),
];
+15 -8
View File
@@ -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::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
@@ -93,10 +94,7 @@ fn handle_keyboard(
if keys.just_pressed(KeyCode::KeyD) {
draw.send(DrawRequestEvent);
}
if keys.just_pressed(KeyCode::Escape) {
// Pause placeholder — the pause screen hooks this up in a later phase.
info!("pause requested (not yet wired)");
}
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
fn handle_stock_click(
@@ -215,6 +213,7 @@ fn end_drag(
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
mut changed: EventWriter<StateChangedEvent>,
) {
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
// 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;
if let Some(target) = target {
if target != origin {
@@ -256,11 +257,17 @@ fn end_drag(
};
if ok {
moves.send(MoveRequestEvent {
from: origin,
to: target,
from: origin.clone(),
to: target.clone(),
count,
});
fired = true;
} else {
rejected.send(MoveRejectedEvent {
from: origin.clone(),
to: target.clone(),
count,
});
}
}
}
+4 -2
View File
@@ -11,6 +11,7 @@ pub mod game_plugin;
pub mod help_plugin;
pub mod input_plugin;
pub mod layout;
pub mod pause_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod stats_plugin;
@@ -31,12 +32,13 @@ pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
pub use game_plugin::{GameMutation, GamePlugin};
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use input_plugin::InputPlugin;
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
+139
View File
@@ -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>,
mut session: ResMut<TimeAttackResource>,
mut ended: EventWriter<TimeAttackEndedEvent>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if !session.active {
return;
}
if paused.is_some_and(|p| p.0) {
return;
}
session.remaining_secs -= time.delta_secs();
if session.remaining_secs <= 0.0 {
let wins = session.wins;