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
+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;