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>
This commit is contained in:
@@ -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::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.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<SoundLibrary> {
|
||||
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<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::*;
|
||||
|
||||
Reference in New Issue
Block a user