Files
Ferrous-Solitaire/solitaire_engine/src/audio_plugin.rs
T
funman300 69ce9afab9 feat(engine): foundation completion flourish — King-on-foundation celebration
Now that foundations are unlocked and "completing" one is a real
moment (rather than a foregone conclusion based on suit assignment),
each Ace-through-King run gets its own small celebration when the
King lands.

Three layers fire on a single FoundationCompletedEvent emitted by
game_plugin's handle_move when a successful move leaves a
PileType::Foundation pile holding 13 cards:

1. King card scale-pulse via a new FoundationFlourish component.
   Triangular curve 1.0 → 1.15 → 1.0 over MOTION_FOUNDATION_FLOURISH
   _SECS (0.4s) — same shape as the existing ScorePulse so the feel
   matches.
2. Pile-marker tint flourish via FoundationMarkerFlourish — the
   foundation marker's sprite colour lerps to STATE_SUCCESS for the
   first half of the duration then fades back. Reuses the existing
   success-signal palette; no new colour token.
3. Audio cue: foundation_complete.wav, a synthesised C6→E6→G6 triad
   with 2nd-harmonic warmth and AR decay (~240 ms). Sits an octave
   above win_fanfare's root so the layered fourth-completion + win
   cascade reads cleanly. Generated via solitaire_assetgen's
   foundation_complete() function and embedded via include_bytes!().

The visual systems run .after(GameMutation) so the post-move pile
state is visible when the King is identified. Both flourish
components remove themselves once elapsed time exceeds duration —
no animation queue or scheduler integration needed.

Pure foundation_flourish_scale(elapsed, duration) helper is
unit-tested for the curve, edge clamps, and zero-duration safety.
Three integration tests on the firing logic verify the event fires
exactly once when a King completes a foundation, doesn't fire for
non-foundation moves, and doesn't fire when the foundation is at 12
cards.

The fourth completion still co-occurs with the win cascade — the
two layer cleanly because the flourish's scale is on the King card
sprite while the cascade is a screen-shake + per-card rotation, and
the foundation_complete ping is a higher octave than the win
fanfare's root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:19:50 +00:00

587 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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` (recycle: 0.5× volume) |
//! | `MoveRequestEvent` | `card_place.wav` |
//! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` |
//!
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
//! low volume (0.05 amplitude) routed through `music_track`.
//!
//! 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::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
};
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use solitaire_core::pile::PileType;
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop placeholder.
const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
/// kira 0.12. Clamps to `Decibels::SILENCE` for non-positive amplitudes.
fn amplitude_to_decibels(amplitude: f32) -> Decibels {
if amplitude <= 0.0 {
Decibels::SILENCE
} else {
Decibels(20.0 * amplitude.log10())
}
}
/// Returns `true` when a `DrawRequestEvent` will recycle the waste pile back
/// to stock rather than drawing a new card.
///
/// This is a pure function with no side effects — it can be called from tests
/// without an audio device or Bevy world.
fn is_recycle(stock_len: usize) -> bool {
stock_len == 0
}
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
/// so we hand a fresh handle to `track.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,
/// Per-suit foundation-completion ping. Played whenever a King
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
/// rising C-major triad an octave above `fanfare`'s root so the
/// two layer cleanly when the fourth completion co-occurs with
/// the win cascade.
pub foundation_complete: 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`.
music_track: Option<TrackHandle>,
/// Handle to the looping ambient track so it can be paused or stopped later.
#[allow(dead_code)]
ambient_handle: Option<StaticSoundHandle>,
}
/// Tracks which audio channels the player has silenced via the M / Shift+M shortcuts.
///
/// These booleans override the `sfx_volume` / `music_volume` settings. When
/// `true`, the corresponding track is forced to 0. When toggled back to `false`
/// the volume is restored from `SettingsResource`.
#[derive(Resource, Default)]
pub struct MuteState {
pub sfx_muted: bool,
pub music_muted: bool,
}
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
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 library = build_library();
if library.is_none() {
warn!("failed to decode embedded SFX assets; SFX disabled");
}
let (sfx_track, mut 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),
};
// Start the ambient loop (ambient_loop.wav looped at very low volume
// through music_track).
let ambient_handle =
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track);
app.insert_non_send_resource(AudioState {
manager,
sfx_track,
music_track,
ambient_handle,
})
.init_resource::<MuteState>();
if let Some(lib) = library {
app.insert_resource(lib);
}
app.add_message::<DrawRequestEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<GameWonEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<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,
play_on_face_revealed,
play_on_undo,
play_on_foundation_complete,
apply_volume_on_change,
handle_mute_keys,
),
);
}
}
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"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary {
deal,
flip,
place,
invalid,
fanfare,
foundation_complete,
})
}
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
}
}
}
/// Decodes the embedded `ambient_loop.wav` and starts it as a seamlessly
/// looping ambient track routed through `music_track`. Returns the handle so
/// it can be stored in `AudioState` for future pause/stop control.
///
/// Returns `None` when audio is unavailable or the WAV fails to decode.
fn start_ambient_loop(
manager: Option<&mut AudioManager<DefaultBackend>>,
_library: Option<&SoundLibrary>,
music_track: &mut Option<TrackHandle>,
) -> Option<StaticSoundHandle> {
let manager = manager?;
let ambient_bytes: &'static [u8] =
include_bytes!("../../assets/audio/ambient_loop.wav");
let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) {
Ok(d) => d,
Err(e) => {
warn!("failed to decode ambient_loop.wav: {e}");
return None;
}
};
data.settings.loop_region = Some(Region::default());
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
let result = if let Some(track) = music_track.as_mut() {
track.play(data)
} else {
manager.play(data)
};
match result {
Ok(handle) => Some(handle),
Err(e) => {
warn!("failed to start ambient loop: {e}");
None
}
}
}
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
let data = sound.clone();
// Route SFX through the dedicated sfx_track so its volume is independent
// of the music_track volume.
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
return;
};
if let Err(e) = result {
warn!("failed to play SFX: {e}");
}
}
impl AudioState {
/// Plays `sound` through the SFX sub-track at `volume` amplitude (0.01.0+).
///
/// Behaves identically to the crate-private `play()` function but accepts an
/// explicit volume override so callers can play sounds at a fraction of their
/// normal level. Silently does nothing when audio is unavailable.
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
let mut data = sound.clone();
data.settings.volume = Value::Fixed(amplitude_to_decibels(volume as f32));
let result = if let Some(track) = self.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = self.manager.as_mut() {
manager.play(data)
} else {
return;
};
if let Err(e) = result {
warn!("failed to play SFX at volume {volume}: {e}");
}
}
}
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
}
}
fn set_music_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.music_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), 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 play_on_undo(
mut events: MessageReader<UndoRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else { return };
for _ in events.read() {
play(&mut audio, &lib.flip);
}
}
fn apply_volume_on_change(
mut events: MessageReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
mute: Option<Res<MuteState>>,
) {
for ev in events.read() {
let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted);
let music_muted = mute.as_ref().is_some_and(|m| m.music_muted);
set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume });
set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume });
}
}
/// `M` toggles mute for all audio; `Shift+M` toggles music only.
/// Volumes are restored from `SettingsResource` on unmute.
fn handle_mute_keys(
keys: Res<ButtonInput<KeyCode>>,
mut audio: NonSendMut<AudioState>,
mut mute: ResMut<MuteState>,
settings: Option<Res<SettingsResource>>,
paused: Option<Res<PausedResource>>,
) {
if paused.is_some_and(|p| p.0) || !keys.just_pressed(KeyCode::KeyM) {
return;
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let (sfx_vol, music_vol) = settings
.as_ref()
.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
if shift {
// Shift+M: toggle music mute only, SFX unaffected.
mute.music_muted = !mute.music_muted;
} else {
// M: mute all if either channel is audible; unmute all otherwise.
let new_state = !(mute.sfx_muted && mute.music_muted);
mute.sfx_muted = new_state;
mute.music_muted = new_state;
}
set_sfx_volume(&mut audio, if mute.sfx_muted { 0.0 } else { sfx_vol });
set_music_volume(&mut audio, if mute.music_muted { 0.0 } else { music_vol });
}
fn play_on_draw(
mut events: MessageReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
game: Option<Res<GameStateResource>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
// When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw.
let stock_len = game
.as_ref()
.and_then(|g| g.0.piles.get(&PileType::Stock))
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
data.settings.volume =
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
continue;
};
if let Err(e) = result {
warn!("failed to play recycle SFX: {e}");
}
} else {
play(&mut audio, &lib.flip);
}
}
}
fn play_on_move(
mut events: MessageReader<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: MessageReader<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: MessageReader<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: MessageReader<GameWonEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.fanfare);
}
}
/// Plays the card-flip sound at the animation midpoint — the instant the face
/// is visually revealed — keeping audio and visuals in sync.
///
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
/// the phase transition (scale.x crosses 0), not by the move event itself.
fn play_on_face_revealed(
mut events: MessageReader<CardFaceRevealedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.flip);
}
}
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
/// fires (a King lands on a foundation pile that now holds Ace → King).
///
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
/// the two layer cleanly because the ping sits an octave above the
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
fn play_on_foundation_complete(
mut events: MessageReader<FoundationCompletedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.foundation_complete);
}
}
#[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");
}
// -----------------------------------------------------------------------
// MuteState toggle logic (pure, no AudioManager needed)
// -----------------------------------------------------------------------
/// Helper that mirrors the toggle logic inside `handle_mute_keys`
/// for M (mute-all).
fn toggle_all(mute: &mut MuteState) {
let new_state = !(mute.sfx_muted && mute.music_muted);
mute.sfx_muted = new_state;
mute.music_muted = new_state;
}
/// Helper that mirrors the toggle logic for Shift+M (music-only).
fn toggle_music(mute: &mut MuteState) {
mute.music_muted = !mute.music_muted;
}
#[test]
fn mute_all_toggles_both_channels() {
let mut m = MuteState::default();
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M should mute both channels");
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels");
}
#[test]
fn shift_m_toggles_music_only() {
let mut m = MuteState::default();
toggle_music(&mut m);
assert!(m.music_muted, "Shift+M should mute music");
assert!(!m.sfx_muted, "Shift+M must not mute SFX");
toggle_music(&mut m);
assert!(!m.music_muted, "second Shift+M should unmute music");
}
#[test]
fn mute_all_while_music_already_muted_mutes_sfx_too() {
let mut m = MuteState::default();
// Music already muted via Shift+M.
toggle_music(&mut m);
assert!(m.music_muted && !m.sfx_muted);
// M should mute sfx (not-all-muted → mute-all).
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible");
}
#[test]
fn mute_all_when_both_already_muted_unmutes_both() {
let mut m = MuteState { sfx_muted: true, music_muted: true };
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted");
}
// -----------------------------------------------------------------------
// Task #60 — stock-recycle detection (pure, no audio hardware needed)
// -----------------------------------------------------------------------
/// The recycle volume constant must be exactly half of normal (1.0).
#[test]
fn recycle_volume_is_half_normal() {
assert!((RECYCLE_VOLUME - 0.5).abs() < f64::EPSILON);
}
/// `is_recycle` returns `true` only when the stock pile is empty.
#[test]
fn stock_empty_means_recycle() {
assert!(is_recycle(0), "empty stock should trigger recycle");
assert!(!is_recycle(1), "non-empty stock must not trigger recycle");
}
// -----------------------------------------------------------------------
// Task #61 — AudioState has ambient_handle slot (compile-time check)
// -----------------------------------------------------------------------
/// Verifies that `AudioState` exposes an `ambient_handle` field of the
/// correct type. No real `AudioManager` is created; the field is set to
/// `None` to avoid requiring audio hardware in CI.
#[test]
fn audio_state_has_music_track_slot() {
let state = AudioState {
manager: None,
sfx_track: None,
music_track: None,
ambient_handle: None,
};
// The assertion is intentionally trivial — the real check is that this
// code compiles, confirming the field exists with the expected type.
assert!(state.ambient_handle.is_none());
}
}