From 69ce9afab9e71c927ba4f46b567b29465cc460ec Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 01:19:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20foundation=20completion=20flour?= =?UTF-8?q?ish=20=E2=80=94=20King-on-foundation=20celebration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/audio/foundation_complete.wav | Bin 0 -> 22976 bytes solitaire_assetgen/src/bin/gen_sfx.rs | 41 ++- solitaire_engine/src/audio_plugin.rs | 34 ++- solitaire_engine/src/events.rs | 23 ++ solitaire_engine/src/feedback_anim_plugin.rs | 252 ++++++++++++++++++- solitaire_engine/src/game_plugin.rs | 212 +++++++++++++++- solitaire_engine/src/lib.rs | 9 +- solitaire_engine/src/ui_theme.rs | 11 + 8 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 assets/audio/foundation_complete.wav diff --git a/assets/audio/foundation_complete.wav b/assets/audio/foundation_complete.wav new file mode 100644 index 0000000000000000000000000000000000000000..0570e907a77975fc2cf71ad78359d06836938b8e GIT binary patch literal 22976 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 N&AT8CN7wOv@_&DwYHt7l literal 0 HcmV?d00001 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