3c01cef5f3
Settings panel "coming soon" stubs replaced with live controls: - Draw Mode toggle (Draw 1 / Draw 3): new games read draw_mode from SettingsResource instead of the previous game's mode. Falls back to the current game's mode in headless/test contexts where SettingsPlugin is absent. - Theme selector (Green → Blue → Dark → Green): SettingsChangedEvent drives TablePlugin's background Sprite colour so the table re-colours immediately without a restart. - Music Volume [−]/[+]: dedicated kira sub-tracks created for SFX and music on startup. SFX sounds are routed to the SFX track; the music track exists for future ambient audio. Both volumes are set on SettingsChangedEvent and at startup. Also fixed: time_attack timer_expiry test double-fires when MinimalPlugins time delta is nonzero — removed the intermediate 0.001-remaining update step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
7.5 KiB
Rust
255 lines
7.5 KiB
Rust
//! 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` |
|
|
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
|
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
|
//! | `GameWonEvent` | `win_fanfare.wav` |
|
|
//!
|
|
//! 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 kira::track::{TrackBuilder, TrackHandle};
|
|
use kira::tween::Tween;
|
|
|
|
use crate::events::{
|
|
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
|
};
|
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
|
|
|
/// 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>>,
|
|
/// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`.
|
|
sfx_track: Option<TrackHandle>,
|
|
/// Dedicated sub-track for ambient music. Volume controlled by `music_volume`.
|
|
/// No sounds are currently routed here; the track exists so future ambient
|
|
/// music can be added without changing the volume architecture.
|
|
music_track: Option<TrackHandle>,
|
|
}
|
|
|
|
pub struct AudioPlugin;
|
|
|
|
impl Plugin for AudioPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
|
if manager.is_none() {
|
|
warn!("audio device unavailable; SFX disabled");
|
|
}
|
|
|
|
let (sfx_track, music_track) = match manager.as_mut() {
|
|
Some(mgr) => {
|
|
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
|
let music = mgr.add_sub_track(TrackBuilder::default()).ok();
|
|
(sfx, music)
|
|
}
|
|
None => (None, None),
|
|
};
|
|
|
|
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track });
|
|
|
|
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::<MoveRejectedEvent>()
|
|
.add_event::<NewGameRequestEvent>()
|
|
.add_event::<GameWonEvent>()
|
|
.add_event::<SettingsChangedEvent>()
|
|
.add_systems(
|
|
Startup,
|
|
apply_initial_volume,
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
play_on_draw,
|
|
play_on_move,
|
|
play_on_rejected,
|
|
play_on_new_game,
|
|
play_on_win,
|
|
apply_volume_on_change,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
// Route SFX through the dedicated sfx_track so its volume is independent
|
|
// of the music_track volume.
|
|
let mut data = sound.clone();
|
|
if let Some(track) = &audio.sfx_track {
|
|
data.settings.output_destination = track.id().into();
|
|
}
|
|
if let Err(e) = manager.play(data) {
|
|
warn!("failed to play SFX: {e}");
|
|
}
|
|
}
|
|
|
|
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
|
if let Some(track) = audio.sfx_track.as_mut() {
|
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
|
}
|
|
}
|
|
|
|
fn set_music_volume(audio: &mut AudioState, volume: f32) {
|
|
if let Some(track) = audio.music_track.as_mut() {
|
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
|
}
|
|
}
|
|
|
|
fn apply_initial_volume(
|
|
mut audio: NonSendMut<AudioState>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
) {
|
|
let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
|
|
set_sfx_volume(&mut audio, sfx);
|
|
set_music_volume(&mut audio, music);
|
|
}
|
|
|
|
fn apply_volume_on_change(
|
|
mut events: EventReader<SettingsChangedEvent>,
|
|
mut audio: NonSendMut<AudioState>,
|
|
) {
|
|
for ev in events.read() {
|
|
set_sfx_volume(&mut audio, ev.0.sfx_volume);
|
|
set_music_volume(&mut audio, ev.0.music_volume);
|
|
}
|
|
}
|
|
|
|
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_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>,
|
|
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");
|
|
}
|
|
}
|