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:
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user