diff --git a/assets/audio/foundation_complete.wav b/assets/audio/foundation_complete.wav new file mode 100644 index 0000000..0570e90 Binary files /dev/null and b/assets/audio/foundation_complete.wav differ diff --git a/solitaire_assetgen/src/bin/gen_sfx.rs b/solitaire_assetgen/src/bin/gen_sfx.rs index 6548315..e230151 100644 --- a/solitaire_assetgen/src/bin/gen_sfx.rs +++ b/solitaire_assetgen/src/bin/gen_sfx.rs @@ -16,13 +16,14 @@ fn main() -> io::Result<()> { let out_dir = workspace_root().join("assets").join("audio"); fs::create_dir_all(&out_dir)?; - let effects: [(&str, Generator); 6] = [ + let effects: [(&str, Generator); 7] = [ ("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), ("ambient_loop.wav", ambient_loop), + ("foundation_complete.wav", foundation_complete), ]; for (name, make) in &effects { @@ -170,6 +171,44 @@ fn win_fanfare() -> Vec { out } +/// Per-suit foundation-completion ping (~240 ms): a rising three-note +/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each +/// note. Shorter and brighter than `win_fanfare` so it can fire up to +/// four times per game (once per suit) without drowning out subsequent +/// move sounds. The fourth firing co-occurs with the win cascade and +/// `win_fanfare`; the C-major triad sits an octave above the +/// fanfare's root so the two layer cleanly instead of fighting for the +/// same frequency band. +fn foundation_complete() -> Vec { + // C major triad, one octave up from win_fanfare's root. + let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6 + let note_dur = 0.07_f32; // brisk, ascending + let total = note_dur * notes.len() as f32 + 0.05; + 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; + // Each note rings out for 0.18 s — overlapping notes form a + // brief chord at the tail. + if !(0.0..=0.18).contains(&local) { + continue; + } + // Sine + soft 2nd harmonic for warmth, ar_envelope decays + // sharply so each note is bell-like rather than sustained. + let s = (2.0 * std::f32::consts::PI * freq * local).sin() + + 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin(); + let env = ar_envelope(local, 0.005, 0.18, 14.0); + sample += s * env; + } + out.push(quantize(sample * 0.20)); + } + out +} + /// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz /// mono 16-bit PCM). /// diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 2d33813..7567e3c 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle}; use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value}; use crate::events::{ - CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, - MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent, + CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, + GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent, }; use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; @@ -70,6 +70,12 @@ pub struct SoundLibrary { 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 @@ -145,6 +151,7 @@ impl Plugin for AudioPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_systems(Startup, apply_initial_volume) .add_systems( @@ -157,6 +164,7 @@ impl Plugin for AudioPlugin { play_on_win, play_on_face_revealed, play_on_undo, + play_on_foundation_complete, apply_volume_on_change, handle_mute_keys, ), @@ -170,12 +178,15 @@ fn build_library() -> Option { 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, }) } @@ -451,6 +462,25 @@ fn play_on_face_revealed( } } +/// 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, + mut audio: NonSendMut, + lib: Option>, +) { + let Some(lib) = lib else { + return; + }; + for _ in events.read() { + play(&mut audio, &lib.foundation_complete); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 65b5f20..538bc3e 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -1,6 +1,7 @@ //! Cross-system events used by the engine's plugins. use bevy::prelude::Message; +use solitaire_core::card::Suit; use solitaire_core::game_state::GameMode; use solitaire_core::pile::PileType; use solitaire_data::AchievementRecord; @@ -60,6 +61,28 @@ pub struct GameWonEvent { pub time_seconds: u64, } +/// Fired by `GamePlugin` whenever a successful move lands a card on a +/// foundation pile that, after the move, contains all 13 cards of its +/// suit (Ace → King). Drives the per-suit completion flourish — a brief +/// scale pulse on the King card and a golden tint on the foundation +/// pile marker — plus a short audio ping. +/// +/// Fired once per per-suit completion. The fourth completion will +/// co-occur with `GameWonEvent` and the win cascade — they layer +/// cleanly because the flourish is purely decorative and lives on a +/// dedicated marker component. +/// +/// This event is a UI/audio cue only. It does **not** cross +/// `solitaire_sync` and is not persisted. +#[derive(Message, Debug, Clone, Copy)] +pub struct FoundationCompletedEvent { + /// Foundation pile slot (0..=3) that just reached 13 cards. + pub slot: u8, + /// The suit of the completed foundation, taken from the bottom card + /// (always an Ace by construction). + pub suit: Suit, +} + /// Fired when a card's face-up state changes during gameplay. #[derive(Message, Debug, Clone, Copy)] pub struct CardFlippedEvent(pub u32); diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 9cf4576..b549230 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; use crate::card_plugin::CardEntity; use crate::events::{ - DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, + DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent, + NewGameRequestEvent, }; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; use crate::settings_plugin::SettingsResource; +use crate::table_plugin::PileMarker; +use crate::ui_theme::{ + FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS, +}; // --------------------------------------------------------------------------- // Shared constants @@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 { // Plugin // --------------------------------------------------------------------------- -/// Registers the shake, settle, and deal animation systems. +/// Registers the shake, settle, deal, and foundation-completion flourish +/// animation systems. pub struct FeedbackAnimPlugin; impl Plugin for FeedbackAnimPlugin { @@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_systems( Update, ( @@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin { start_settle_anim.after(GameMutation), tick_settle_anim, start_deal_anim.after(GameMutation), + start_foundation_flourish.after(GameMutation), + tick_foundation_flourish, ), ); } @@ -401,6 +410,204 @@ fn start_deal_anim( } } +// --------------------------------------------------------------------------- +// Foundation-completion flourish +// --------------------------------------------------------------------------- + +/// Drives the per-foundation completion flourish on the King card that +/// just landed on a foundation pile (Ace → King, 13 cards). +/// +/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent` +/// fires; removed once `elapsed >= duration`. Decorative only — does +/// not block input or interfere with the win cascade, settle, or hint +/// systems (those operate on different markers and read the same +/// `Transform.scale` coordinate non-conflictingly because the flourish +/// finishes in well under a second). +#[derive(Component, Debug, Clone, Copy)] +pub struct FoundationFlourish { + /// Foundation slot (0..=3) this flourish is celebrating. + pub foundation_slot: u8, + /// Seconds elapsed since the flourish began. + pub elapsed: f32, + /// Total animation length in seconds. + pub duration: f32, +} + +/// Drives a brief golden tint on the foundation `PileMarker` whose +/// foundation just completed. Stores the marker's original colour so +/// it can be restored when the timer expires. +/// +/// Inserted alongside (and concurrent with) `FoundationFlourish` on the +/// matching `PileMarker` entity. The system runs independently of the +/// existing `HintPileHighlight` so the two never share state — a hint +/// landing during a completion flourish (highly unlikely in practice +/// since the foundation just completed) won't corrupt either party's +/// `original_color` snapshot. +#[derive(Component, Debug, Clone, Copy)] +pub struct FoundationMarkerFlourish { + /// Seconds elapsed since the tint was applied. + pub elapsed: f32, + /// Total animation length in seconds. + pub duration: f32, + /// The pile marker's sprite colour before the tint was applied — + /// restored when the timer expires. + pub original_color: Color, +} + +/// Pure helper for unit tests — returns the per-frame scale factor for +/// the foundation flourish at `elapsed_secs` over `duration_secs`. +/// +/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`: +/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns +/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns +/// `1.0`. Out-of-range values are clamped so the King never freezes +/// at a non-1.0 scale on the frame after the flourish ends. +/// +/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running +/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish +/// without dividing by zero. +pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 { + if duration_secs <= 0.0 { + return 1.0; + } + let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0); + let peak = FOUNDATION_FLOURISH_PEAK_SCALE; + if t < 0.5 { + // Climb from 1.0 at t=0 to peak at t=0.5. + 1.0 + (peak - 1.0) * (t / 0.5) + } else { + // Descend from peak at t=0.5 back to 1.0 at t=1.0. + peak - (peak - 1.0) * ((t - 0.5) / 0.5) + } +} + +/// Inserts `FoundationFlourish` on the King card entity at the +/// completed foundation and `FoundationMarkerFlourish` on its +/// `PileMarker`. The King is identified as the *top* card of the +/// foundation pile after the move — by definition the 13th card, +/// always rank King by foundation rules. +fn start_foundation_flourish( + mut events: MessageReader, + game: Res, + card_entities: Query<(Entity, &CardEntity)>, + mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>, + mut commands: Commands, +) { + for ev in events.read() { + let pile_type = PileType::Foundation(ev.slot); + // Top card of the completed foundation is the King. + let Some(king_id) = game + .0 + .piles + .get(&pile_type) + .and_then(|p| p.cards.last()) + .map(|c| c.id) + else { + continue; + }; + + // Tag the King's card entity. + for (entity, card_marker) in card_entities.iter() { + if card_marker.card_id == king_id { + commands.entity(entity).insert(FoundationFlourish { + foundation_slot: ev.slot, + elapsed: 0.0, + duration: MOTION_FOUNDATION_FLOURISH_SECS, + }); + } + } + + // Tint the matching PileMarker. Snapshot the current colour so + // tick_foundation_flourish can restore it; if a stale flourish + // is somehow still active, reuse its `original_color` so we + // don't capture the gold tint as the new "original". + for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() { + if pile_marker.0 != pile_type { + continue; + } + let original_color = existing.map_or(sprite.color, |f| f.original_color); + commands.entity(entity).insert(FoundationMarkerFlourish { + elapsed: 0.0, + duration: MOTION_FOUNDATION_FLOURISH_SECS, + original_color, + }); + } + } +} + +/// Advances both the King's scale pulse and the foundation marker's +/// gold tint each frame. Removes both components once their timers +/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and +/// the marker's sprite colour to its captured original. +/// +/// Skipped while paused so a player who hits Esc mid-flourish doesn't +/// see frozen scaled state (the next unpause tick resumes from the +/// stored `elapsed`). +#[allow(clippy::type_complexity)] +fn tick_foundation_flourish( + mut commands: Commands, + time: Res