diff --git a/Cargo.lock b/Cargo.lock index 97aaa97..b3468da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5672,6 +5672,10 @@ dependencies = [ "solitaire_engine", ] +[[package]] +name = "solitaire_assetgen" +version = "0.1.0" + [[package]] name = "solitaire_core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 768a383..f14bc35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "solitaire_server", "solitaire_gpgs", "solitaire_app", + "solitaire_assetgen", ] resolver = "2" diff --git a/assets/audio/card_deal.wav b/assets/audio/card_deal.wav new file mode 100644 index 0000000..a429eb0 Binary files /dev/null and b/assets/audio/card_deal.wav differ diff --git a/assets/audio/card_flip.wav b/assets/audio/card_flip.wav new file mode 100644 index 0000000..c17724d Binary files /dev/null and b/assets/audio/card_flip.wav differ diff --git a/assets/audio/card_invalid.wav b/assets/audio/card_invalid.wav new file mode 100644 index 0000000..af0f4d3 Binary files /dev/null and b/assets/audio/card_invalid.wav differ diff --git a/assets/audio/card_place.wav b/assets/audio/card_place.wav new file mode 100644 index 0000000..2074b3b Binary files /dev/null and b/assets/audio/card_place.wav differ diff --git a/assets/audio/win_fanfare.wav b/assets/audio/win_fanfare.wav new file mode 100644 index 0000000..1edaf61 Binary files /dev/null and b/assets/audio/win_fanfare.wav differ diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 77a60e6..7575c6f 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -2,7 +2,7 @@ > Last updated: 2026-04-25 > Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git -> Test count: **225 passing** (83 core + 54 data + 88 engine), `cargo clippy --workspace -- -D warnings` clean +> Test count: **226 passing** (83 core + 54 data + 89 engine), `cargo clippy --workspace -- -D warnings` clean --- @@ -153,13 +153,22 @@ All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin - `HelpPlugin`: **H** or `?` toggles a full-window cheat sheet listing all keybindings (gameplay, mode hotkeys, overlays). 3 unit tests. - `AnimationPlugin` now surfaces `ChallengeAdvancedEvent` as a 3-second toast ("Challenge N cleared!"). +### 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). +- 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. + ## What Is Next -### Phase 7 (part 2+) — Audio + Pause Menu +### Phase 7 (part 3+) — Pause Menu + Polish -- Audio (`kira`): card deal/flip/place/invalid SFX, win fanfare, ambient loop. Volume sliders in a Settings overlay. **Blocker:** asset files are not yet in the repo; sourcing/recording these is the first step. -- Pause menu: Esc currently logs a placeholder. Likely a small overlay similar to `HelpPlugin` with a `Paused` resource that gates `Time::delta_secs` propagation in `tick_elapsed_time` / `advance_time_attack`. -- Onboarding: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`). +- **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. +- **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`). ### Phase 8 — Sync diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 808fb5c..fe7885d 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,8 +1,8 @@ use bevy::prelude::*; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, CardPlugin, ChallengePlugin, DailyChallengePlugin, - GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, - WeeklyGoalsPlugin, + AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin, + DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin, + TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -30,5 +30,6 @@ fn main() { .add_plugins(ChallengePlugin) .add_plugins(TimeAttackPlugin) .add_plugins(HelpPlugin) + .add_plugins(AudioPlugin) .run(); } diff --git a/solitaire_assetgen/Cargo.toml b/solitaire_assetgen/Cargo.toml new file mode 100644 index 0000000..69a06f9 --- /dev/null +++ b/solitaire_assetgen/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "solitaire_assetgen" +version.workspace = true +edition.workspace = true +publish = false + +# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`. +# Not depended on by any other workspace crate. + +[[bin]] +name = "gen_sfx" +path = "src/bin/gen_sfx.rs" diff --git a/solitaire_assetgen/src/bin/gen_sfx.rs b/solitaire_assetgen/src/bin/gen_sfx.rs new file mode 100644 index 0000000..3b70d99 --- /dev/null +++ b/solitaire_assetgen/src/bin/gen_sfx.rs @@ -0,0 +1,207 @@ +//! Synthesize placeholder SFX into `assets/audio/`. +//! +//! Output: 44.1kHz mono 16-bit PCM WAV. Run with +//! `cargo run -p solitaire_assetgen --bin gen_sfx`. Files are committed to +//! the repo so end-users never need to run this generator. + +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const SAMPLE_RATE: u32 = 44_100; + +type Generator = fn() -> Vec; + +fn main() -> io::Result<()> { + let out_dir = workspace_root().join("assets").join("audio"); + fs::create_dir_all(&out_dir)?; + + let effects: [(&str, Generator); 5] = [ + ("card_flip.wav", card_flip), + ("card_place.wav", card_place), + ("card_deal.wav", card_deal), + ("card_invalid.wav", card_invalid), + ("win_fanfare.wav", win_fanfare), + ]; + + for (name, gen) in &effects { + let samples = gen(); + let path = out_dir.join(name); + write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?; + println!("wrote {} ({} samples)", path.display(), samples.len()); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Synth primitives +// --------------------------------------------------------------------------- + +/// Simple deterministic noise source — LCG, no `rand` dep needed. +struct Lcg(u64); + +impl Lcg { + fn new(seed: u64) -> Self { + Self(seed) + } + fn next_f32(&mut self) -> f32 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 32) as i32 as f32) / (i32::MAX as f32) + } +} + +fn duration_samples(seconds: f32) -> usize { + (seconds * SAMPLE_RATE as f32) as usize +} + +/// Linear attack / exponential decay envelope. `attack` and length in seconds. +fn ar_envelope(t_secs: f32, attack: f32, total: f32, decay_rate: f32) -> f32 { + if t_secs < attack { + (t_secs / attack).clamp(0.0, 1.0) + } else { + (-decay_rate * (t_secs - attack)).exp() * (1.0 - (t_secs - total).max(0.0)) + } +} + +fn quantize(sample: f32) -> i16 { + let clipped = sample.clamp(-1.0, 1.0); + (clipped * 32_767.0) as i16 +} + +// --------------------------------------------------------------------------- +// Effect generators +// --------------------------------------------------------------------------- + +fn card_flip() -> Vec { + let n = duration_samples(0.08); + let mut rng = Lcg::new(0x1234_5678_DEAD_BEEF); + let mut out = Vec::with_capacity(n); + let mut prev = 0.0f32; + let alpha = 0.35; + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + let raw = rng.next_f32(); + // High-pass-ish: subtract a low-pass-smoothed signal. + let lp = alpha * raw + (1.0 - alpha) * prev; + prev = lp; + let hp = raw - lp; + let env = ar_envelope(t, 0.005, 0.08, 60.0); + out.push(quantize(hp * env * 0.6)); + } + out +} + +fn card_place() -> Vec { + let n = duration_samples(0.14); + let mut rng = Lcg::new(0xCAFE_F00D_8BAD_F00D); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + // Low sine for body (~120 Hz) + filtered noise for click. + let body = (2.0 * std::f32::consts::PI * 120.0 * t).sin(); + let click = rng.next_f32() * 0.5; + let env = ar_envelope(t, 0.003, 0.14, 35.0); + let sample = (body * 0.7 + click) * env * 0.55; + out.push(quantize(sample)); + } + out +} + +fn card_deal() -> Vec { + let n = duration_samples(0.18); + let mut rng = Lcg::new(0xFEE1_DEAD_DEAD_BEEF); + let mut out = Vec::with_capacity(n); + let mut lp = 0.0f32; + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + let raw = rng.next_f32(); + // Sweeping low-pass: cutoff falls over time → "whoosh". + let alpha = 0.6 - (t / 0.18) * 0.5; + lp = alpha * raw + (1.0 - alpha) * lp; + let env = ar_envelope(t, 0.01, 0.18, 18.0); + out.push(quantize(lp * env * 0.7)); + } + out +} + +fn card_invalid() -> Vec { + let n = duration_samples(0.18); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + // Two dissonant squarish tones — strong beat creates a buzz. + let a = (2.0 * std::f32::consts::PI * 196.0 * t).sin().signum(); + let b = (2.0 * std::f32::consts::PI * 207.65 * t).sin().signum(); + let env = ar_envelope(t, 0.005, 0.18, 12.0); + out.push(quantize((a + b) * env * 0.18)); + } + out +} + +fn win_fanfare() -> Vec { + // C major arpeggio: C5, E5, G5, C6. + let notes = [523.25_f32, 659.25, 783.99, 1046.50]; + let note_dur = 0.18_f32; + let total = note_dur * notes.len() as f32 + 0.25; + let n = duration_samples(total); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + let mut sample = 0.0f32; + for (idx, freq) in notes.iter().enumerate() { + let start = idx as f32 * note_dur; + let local = t - start; + if !(0.0..=0.4).contains(&local) { + continue; + } + // Layered sine + soft 2nd harmonic for warmth. + let s = (2.0 * std::f32::consts::PI * freq * local).sin() + + 0.3 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin(); + let env = ar_envelope(local, 0.008, 0.4, 6.0); + sample += s * env; + } + out.push(quantize(sample * 0.22)); + } + out +} + +// --------------------------------------------------------------------------- +// Minimal WAV writer (mono 16-bit PCM) +// --------------------------------------------------------------------------- + +fn write_wav_mono_pcm16(path: &Path, sample_rate: u32, samples: &[i16]) -> io::Result<()> { + let mut f = File::create(path)?; + let byte_rate = sample_rate * 2; // mono 16-bit + let data_bytes = samples.len() as u32 * 2; + let chunk_size = 36 + data_bytes; + + f.write_all(b"RIFF")?; + f.write_all(&chunk_size.to_le_bytes())?; + f.write_all(b"WAVE")?; + + f.write_all(b"fmt ")?; + f.write_all(&16u32.to_le_bytes())?; // PCM fmt chunk size + f.write_all(&1u16.to_le_bytes())?; // PCM + f.write_all(&1u16.to_le_bytes())?; // mono + f.write_all(&sample_rate.to_le_bytes())?; + f.write_all(&byte_rate.to_le_bytes())?; + f.write_all(&2u16.to_le_bytes())?; // block align + f.write_all(&16u16.to_le_bytes())?; // bits per sample + + f.write_all(b"data")?; + f.write_all(&data_bytes.to_le_bytes())?; + for &s in samples { + f.write_all(&s.to_le_bytes())?; + } + Ok(()) +} + +fn workspace_root() -> PathBuf { + // CARGO_MANIFEST_DIR points at the assetgen crate; parent is workspace. + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + crate_dir.parent().expect("workspace root").to_path_buf() +} diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs new file mode 100644 index 0000000..98b384f --- /dev/null +++ b/solitaire_engine/src/audio_plugin.rs @@ -0,0 +1,177 @@ +//! Sound-effect playback via `kira`. +//! +//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in +//! response to gameplay events: +//! +//! | Event | Sound | +//! |---|---| +//! | `DrawRequestEvent` | `card_flip.wav` | +//! | `MoveRequestEvent` | `card_place.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 +//! silently. + +use std::io::Cursor; + +use bevy::prelude::*; +use kira::manager::backend::DefaultBackend; +use kira::manager::{AudioManager, AudioManagerSettings}; +use kira::sound::static_sound::StaticSoundData; + +use crate::events::{DrawRequestEvent, GameWonEvent, 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. +#[derive(Resource, Clone)] +pub struct SoundLibrary { + pub deal: StaticSoundData, + pub flip: StaticSoundData, + pub place: StaticSoundData, + pub invalid: StaticSoundData, + pub fanfare: StaticSoundData, +} + +/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on +/// some platforms. +pub struct AudioState { + manager: Option>, +} + +pub struct AudioPlugin; + +impl Plugin for AudioPlugin { + fn build(&self, app: &mut App) { + let manager = AudioManager::::new(AudioManagerSettings::default()).ok(); + if manager.is_none() { + warn!("audio device unavailable; SFX disabled"); + } + app.insert_non_send_resource(AudioState { manager }); + + let library = build_library(); + if let Some(lib) = library { + app.insert_resource(lib); + } else { + warn!("failed to decode embedded SFX assets; SFX disabled"); + } + + app.add_event::() + .add_event::() + .add_event::() + .add_event::() + .add_systems( + Update, + ( + play_on_draw, + play_on_move, + play_on_new_game, + play_on_win, + ), + ); + } +} + +fn build_library() -> Option { + let deal = decode(include_bytes!("../../assets/audio/card_deal.wav"))?; + let flip = decode(include_bytes!("../../assets/audio/card_flip.wav"))?; + let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?; + let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?; + let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?; + Some(SoundLibrary { + deal, + flip, + place, + invalid, + fanfare, + }) +} + +fn decode(bytes: &'static [u8]) -> Option { + match StaticSoundData::from_cursor(Cursor::new(bytes.to_vec())) { + Ok(data) => Some(data), + Err(e) => { + warn!("failed to decode SFX: {e}"); + None + } + } +} + +fn play(audio: &mut AudioState, sound: &StaticSoundData) { + let Some(manager) = audio.manager.as_mut() else { + return; + }; + if let Err(e) = manager.play(sound.clone()) { + warn!("failed to play SFX: {e}"); + } +} + +fn play_on_draw( + mut events: EventReader, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { + return; + }; + for _ in events.read() { + play(&mut audio, &lib.flip); + } +} + +fn play_on_move( + mut events: EventReader, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { + return; + }; + for _ in events.read() { + play(&mut audio, &lib.place); + } +} + +fn play_on_new_game( + mut events: EventReader, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { + return; + }; + for _ in events.read() { + play(&mut audio, &lib.deal); + } +} + +fn play_on_win( + mut events: EventReader, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { + return; + }; + for _ in events.read() { + play(&mut audio, &lib.fanfare); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_wavs_decode_successfully() { + // Verifies the include_bytes! paths resolve and the bytes are valid + // WAV (so the gen_sfx output stays in sync with the loader). + let lib = build_library(); + assert!(lib.is_some(), "embedded SFX failed to decode"); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 13383d7..273425b 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -2,6 +2,7 @@ pub mod achievement_plugin; pub mod animation_plugin; +pub mod audio_plugin; pub mod card_plugin; pub mod challenge_plugin; pub mod daily_challenge_plugin; @@ -27,6 +28,7 @@ pub use daily_challenge_plugin::{ pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; 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,