feat(engine,assetgen): synthesized SFX + kira AudioPlugin
- New solitaire_assetgen crate with gen_sfx binary: synthesizes five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare) from an LCG noise source + sine/square synths. Output committed under assets/audio/. - AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes once with kira::StaticSoundData, plays on Draw / Move / NewGame / GameWon events. card_invalid is loaded but unused — wiring it needs a MoveRejectedEvent. - AudioManager kept on the main thread (NonSend) since cpal is !Send on some platforms; degrades gracefully if no audio device present. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+4
@@ -5672,6 +5672,10 @@ dependencies = [
|
|||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "solitaire_assetgen"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solitaire_core"
|
name = "solitaire_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ members = [
|
|||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_gpgs",
|
"solitaire_gpgs",
|
||||||
"solitaire_app",
|
"solitaire_app",
|
||||||
|
"solitaire_assetgen",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Last updated: 2026-04-25
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
> 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.
|
- `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!").
|
- `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
|
## 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` in `tick_elapsed_time` / `advance_time_attack`.
|
||||||
- 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`.
|
- **`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.
|
||||||
- Onboarding: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`).
|
- **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
|
### Phase 8 — Sync
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, CardPlugin, ChallengePlugin, DailyChallengePlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
||||||
GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin,
|
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, ProgressPlugin, StatsPlugin,
|
||||||
WeeklyGoalsPlugin,
|
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -30,5 +30,6 @@ fn main() {
|
|||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
|
.add_plugins(AudioPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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<i16>;
|
||||||
|
|
||||||
|
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<i16> {
|
||||||
|
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<i16> {
|
||||||
|
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<i16> {
|
||||||
|
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<i16> {
|
||||||
|
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<i16> {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -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<AudioManager<DefaultBackend>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
|
impl Plugin for AudioPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
let manager = AudioManager::<DefaultBackend>::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::<DrawRequestEvent>()
|
||||||
|
.add_event::<MoveRequestEvent>()
|
||||||
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
play_on_draw,
|
||||||
|
play_on_move,
|
||||||
|
play_on_new_game,
|
||||||
|
play_on_win,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_library() -> Option<SoundLibrary> {
|
||||||
|
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<StaticSoundData> {
|
||||||
|
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<DrawRequestEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.flip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_on_move(
|
||||||
|
mut events: EventReader<MoveRequestEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_on_new_game(
|
||||||
|
mut events: EventReader<NewGameRequestEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.deal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_on_win(
|
||||||
|
mut events: EventReader<GameWonEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
|
pub mod audio_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
pub mod daily_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 progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
|
||||||
|
|||||||
Reference in New Issue
Block a user