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:
Binary file not shown.
@@ -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<i16> {
|
||||
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<i16> {
|
||||
// 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).
|
||||
///
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.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<FoundationCompletedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
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<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
|
||||
mut marker_anims: Query<
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
for (entity, mut transform, mut anim) in &mut card_anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= anim.duration {
|
||||
// Restore identity scale so the card sits at its normal size
|
||||
// for the next frame's transform sync.
|
||||
transform.scale = Vec3::ONE;
|
||||
commands.entity(entity).remove::<FoundationFlourish>();
|
||||
} else {
|
||||
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
|
||||
transform.scale = Vec3::new(s, s, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the foundation marker's gold tint. Held flat for the
|
||||
// first half of the duration and faded back to the original colour
|
||||
// over the second half — feels celebratory without bleeding into
|
||||
// the next move's drop-target highlights.
|
||||
for (entity, mut sprite, mut anim) in &mut marker_anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= anim.duration {
|
||||
sprite.color = anim.original_color;
|
||||
commands.entity(entity).remove::<FoundationMarkerFlourish>();
|
||||
} else {
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Lerp factor: 1.0 (full tint) for the first half, then
|
||||
// ramps down linearly to 0.0 (original colour) by the end.
|
||||
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
|
||||
/// as a small helper so the `tick_foundation_flourish` body stays
|
||||
/// readable; sRGB-space lerping is fine for a brief decorative tint
|
||||
/// (a perceptually-uniform space would be overkill).
|
||||
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||
let from = from.to_srgba();
|
||||
let to = to.to_srgba();
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
Color::srgba(
|
||||
from.red + (to.red - from.red) * t,
|
||||
from.green + (to.green - from.green) * t,
|
||||
from.blue + (to.blue - from.blue) * t,
|
||||
from.alpha + (to.alpha - from.alpha) * t,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -534,6 +741,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Foundation-flourish curve tests
|
||||
|
||||
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
|
||||
#[test]
|
||||
fn foundation_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||
assert!(
|
||||
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"flourish scale at t=0 must be 1.0"
|
||||
);
|
||||
assert!(
|
||||
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
|
||||
);
|
||||
assert!(
|
||||
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"flourish scale at t=duration must return to 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Important so the
|
||||
/// King never ends up at a non-1.0 scale on the frame after the
|
||||
/// flourish ends (which would race against the despawn / restore step
|
||||
/// in `tick_foundation_flourish`).
|
||||
#[test]
|
||||
fn foundation_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||
// Negative elapsed clamps to 0 → scale 1.0.
|
||||
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
// Past-end clamps to t=1 → scale 1.0.
|
||||
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn foundation_flourish_scale_zero_duration_is_one() {
|
||||
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||
// 52 cards should produce more than a couple distinct jitter factors;
|
||||
|
||||
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
|
||||
save_game_state_to};
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<crate::events::CardFlippedEvent>()
|
||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -398,14 +399,18 @@ fn handle_draw(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_move(
|
||||
mut moves: MessageReader<MoveRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut won: MessageWriter<GameWonEvent>,
|
||||
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
||||
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
for ev in moves.read() {
|
||||
let was_won = game.0.is_won;
|
||||
// Identify the card that will be exposed (and may flip face-up) by the move.
|
||||
@@ -429,6 +434,19 @@ fn handle_move(
|
||||
{
|
||||
flipped.write(crate::events::CardFlippedEvent(fid));
|
||||
}
|
||||
// If this move landed on a foundation pile and that pile is
|
||||
// now complete (Ace → King, 13 cards), fire the per-suit
|
||||
// flourish event. Drives a brief decorative scale-pulse on
|
||||
// the King + a golden tint on the foundation marker plus a
|
||||
// short audio ping. Purely a UI / audio cue — does not
|
||||
// cross `solitaire_sync` and is not persisted.
|
||||
if let PileType::Foundation(slot) = ev.to
|
||||
&& let Some(pile) = game.0.piles.get(&ev.to)
|
||||
&& pile.cards.len() == 13
|
||||
&& let Some(suit) = pile.claimed_suit()
|
||||
{
|
||||
foundation_done.write(FoundationCompletedEvent { slot, suit });
|
||||
}
|
||||
changed.write(StateChangedEvent);
|
||||
if !was_won && game.0.is_won {
|
||||
won.write(GameWonEvent {
|
||||
@@ -1407,6 +1425,196 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Foundation-completion flourish — FoundationCompletedEvent firing logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
|
||||
/// (12 cards, all face-up) and place the King of `suit` on
|
||||
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
|
||||
/// foundation.
|
||||
fn seed_foundation_with_ace_through_queen(
|
||||
app: &mut App,
|
||||
slot: u8,
|
||||
suit: solitaire_core::card::Suit,
|
||||
) {
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
|
||||
];
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation = gs
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.expect("foundation slot must exist");
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 5_000 + i as u32 + (slot as u32) * 100,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Put the King on Tableau(0) so a single move can complete it.
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 6_000 + (slot as u32),
|
||||
suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reading helper: collect every `FoundationCompletedEvent` written
|
||||
/// during the most recent `update()` so the test body can assert
|
||||
/// against count, slot, and suit.
|
||||
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
|
||||
let events = app
|
||||
.world()
|
||||
.resource::<Messages<FoundationCompletedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor.read(events).copied().collect()
|
||||
}
|
||||
|
||||
/// When a King lands on a foundation that already holds Ace through
|
||||
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||
/// the matching slot + suit.
|
||||
#[test]
|
||||
fn foundation_completed_event_fires_when_king_lands() {
|
||||
use solitaire_core::card::Suit;
|
||||
|
||||
let mut app = test_app(1);
|
||||
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(2),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert_eq!(
|
||||
fired.len(),
|
||||
1,
|
||||
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
|
||||
);
|
||||
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
|
||||
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
|
||||
}
|
||||
|
||||
/// Moving a card to a tableau pile must never produce a
|
||||
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||
/// to have been a King.
|
||||
#[test]
|
||||
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app(1);
|
||||
// Reset the world: clear stock + waste so a draw isn't possible,
|
||||
// empty all tableaux + foundations, then place a face-up King of
|
||||
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
|
||||
// there legally.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 7_000,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
||||
/// flight), the event must NOT fire — the flourish is only for the
|
||||
/// final 13th completion.
|
||||
#[test]
|
||||
fn foundation_completed_event_does_not_fire_at_12_cards() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app(1);
|
||||
let suit = Suit::Diamonds;
|
||||
let slot: u8 = 1;
|
||||
// Pre-fill foundation with Ace through Jack (11 cards).
|
||||
let pre_ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack,
|
||||
];
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in pre_ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 8_000 + i as u32,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Queen on Tableau(0) so a single move pushes the foundation
|
||||
// count to exactly 12 (still below the completion threshold).
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 8_900,
|
||||
suit,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(slot),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Sanity: the move actually landed (foundation has 12 cards now).
|
||||
let foundation_len = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Foundation(slot)]
|
||||
.cards
|
||||
.len();
|
||||
assert_eq!(foundation_len, 12, "Queen must have landed on the foundation");
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
#[test]
|
||||
fn undo_after_draw_does_not_fire_info_toast() {
|
||||
|
||||
@@ -69,8 +69,9 @@ pub use card_animation::{
|
||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||
};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
|
||||
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
|
||||
ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
@@ -82,8 +83,8 @@ pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
|
||||
@@ -379,6 +379,17 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||
|
||||
/// Foundation-completion flourish — when a King lands on a foundation
|
||||
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
|
||||
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
|
||||
/// `PileMarker` gold. 400 ms.
|
||||
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
|
||||
|
||||
/// Peak scale magnification reached at the midpoint of the
|
||||
/// foundation-completion flourish. The triangular curve climbs from
|
||||
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
||||
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
||||
|
||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||
/// 400 ms.
|
||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||
|
||||
Reference in New Issue
Block a user