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
|
||||
> 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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