feat(engine): shake/settle/deal animations (#54, #55, #69)

Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
  destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
  1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
  position on NewGameRequestEvent (move_count == 0), using existing
  CardAnim with 0.04 s per-card stagger

Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-27 19:55:24 +00:00
parent ddd7502a06
commit f32e53dd0b
11 changed files with 1766 additions and 194 deletions
+136 -14
View File
@@ -5,12 +5,16 @@
//!
//! | Event | Sound |
//! |---|---|
//! | `DrawRequestEvent` | `card_flip.wav` |
//! | `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 is started at plugin startup using `card_flip.wav` at very
//! low volume (0.05 amplitude) routed through `music_track` as a placeholder
//! until a dedicated ambient track is available.
//!
//! 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
@@ -21,16 +25,35 @@ 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::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::track::{TrackBuilder, TrackHandle};
use kira::tween::Tween;
use kira::Volume;
use crate::events::{
CardFlippedEvent, DrawRequestEvent, 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;
/// 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 `manager.play()` on every event.
@@ -50,9 +73,10 @@ pub struct AudioState {
/// 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>,
/// 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.
@@ -75,6 +99,11 @@ impl Plugin for AudioPlugin {
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, music_track) = match manager.as_mut() {
Some(mgr) => {
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
@@ -84,14 +113,21 @@ impl Plugin for AudioPlugin {
None => (None, None),
};
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track })
.init_resource::<MuteState>();
// Start the ambient loop placeholder (card_flip.wav looped at very low
// volume through music_track).
let ambient_handle =
start_ambient_loop(manager.as_mut(), library.as_ref(), &music_track);
app.insert_non_send_resource(AudioState {
manager,
sfx_track,
music_track,
ambient_handle,
})
.init_resource::<MuteState>();
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>()
@@ -102,10 +138,7 @@ impl Plugin for AudioPlugin {
.add_event::<CardFlippedEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(
Startup,
apply_initial_volume,
)
.add_systems(Startup, apply_initial_volume)
.add_systems(
Update,
(
@@ -148,6 +181,36 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
}
}
/// Starts the ambient music loop placeholder (`card_flip.wav` looped at very
/// low volume) 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 library failed to load.
fn start_ambient_loop(
manager: Option<&mut AudioManager<DefaultBackend>>,
library: Option<&SoundLibrary>,
music_track: &Option<TrackHandle>,
) -> Option<StaticSoundHandle> {
let manager = manager?;
let lib = library?;
let mut data = lib.flip.clone();
// Loop the entire file from start to end.
data.settings.loop_region = Some(Region::default());
data.settings.volume = Volume::Amplitude(AMBIENT_VOLUME).into();
if let Some(track) = music_track {
data.settings.output_destination = track.id().into();
}
match manager.play(data) {
Ok(handle) => Some(handle),
Err(e) => {
warn!("failed to start ambient loop: {e}");
None
}
}
}
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
let Some(manager) = audio.manager.as_mut() else {
return;
@@ -244,12 +307,34 @@ fn play_on_draw(
mut events: EventReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
game: Option<Res<GameStateResource>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.flip);
// 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 = Volume::Amplitude(RECYCLE_VOLUME).into();
if let Some(track) = &audio.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Some(manager) = audio.manager.as_mut() {
if let Err(e) = manager.play(data) {
warn!("failed to play recycle SFX: {e}");
}
}
} else {
play(&mut audio, &lib.flip);
}
}
}
@@ -383,4 +468,41 @@ mod tests {
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());
}
}