From 3a01318fbde354142fcf975bce53b3d6d301012c Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 04:38:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20upgrade=20animations=20?= =?UTF-8?q?=E2=80=94=20curves,=20scoped=20settle,=20deal=20jitter,=20casca?= =?UTF-8?q?de=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slide animations now interpolate through MotionCurve::SmoothSnap via sample_curve() at the call site (no struct field added). Slide and cascade durations route through ui_theme::scaled_duration with MOTION_SLIDE_SECS / MOTION_CASCADE_STAGGER_SECS / MOTION_CASCADE_SLIDE_SECS. Settle bounce in feedback_anim_plugin scoped to MoveRequestEvent and DrawRequestEvent receivers — only the top `count` cards of the destination pile (or top of waste) bounce; undo and other state changes no longer trigger a global all-tops settle. Deal stagger gains a deterministic ±10% jitter via DefaultHasher on card_id (no rand dep). Per-card stagger = base * (1.0 + jitter). Win cascade switched from CardAnim to CardAnimation with MotionCurve::Expressive and a deterministic ±15° per-card Z-rotation via Fibonacci hash. Win screen shake routes through MOTION_WIN_SHAKE_SECS / MOTION_WIN_SHAKE_AMPLITUDE; ScreenShakeResource gained a `total` field so decay computes correctly under Fast / Instant. cargo build / clippy --workspace -- -D warnings / test --workspace all green (819 passed, 0 failed, 8 ignored). --- solitaire_engine/src/animation_plugin.rs | 202 ++++++++++++++----- solitaire_engine/src/feedback_anim_plugin.rs | 169 ++++++++++++---- solitaire_engine/src/win_summary_plugin.rs | 37 +++- 3 files changed, 318 insertions(+), 90 deletions(-) diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 8815c15..0cbe373 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -17,6 +17,7 @@ use solitaire_data::AnimSpeed; use crate::achievement_plugin::display_name_for; use crate::auto_complete_plugin::AutoCompleteState; +use crate::card_animation::{sample_curve, CardAnimation, MotionCurve}; use crate::card_plugin::CardEntity; use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; @@ -28,10 +29,17 @@ use crate::pause_plugin::PausedResource; use crate::progress_plugin::LevelUpEvent; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::time_attack_plugin::TimeAttackEndedEvent; +use crate::ui_theme::{ + scaled_duration, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, +}; use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent; /// Duration of a card slide (move) animation in seconds at Normal speed. -pub const SLIDE_SECS: f32 = 0.15; +/// +/// Re-exported from `ui_theme::MOTION_SLIDE_SECS` so the entire engine pulls +/// gameplay slide timing from one design-token. Kept as a `pub const` for +/// backwards compatibility with existing callers that read this directly. +pub const SLIDE_SECS: f32 = MOTION_SLIDE_SECS; /// The effective slide duration, updated whenever `Settings::animation_speed` changes. #[derive(Resource, Debug, Clone, Copy)] @@ -46,11 +54,10 @@ impl Default for EffectiveSlideDuration { } fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 { - match speed { - AnimSpeed::Normal => SLIDE_SECS, - AnimSpeed::Fast => 0.07, - AnimSpeed::Instant => 0.0, - } + // Route through `ui_theme::scaled_duration` so the slide timing follows + // the same `MOTION_*_SECS` token / `AnimSpeed` mapping as every other + // motion in the engine (toasts, deal stagger, shake, settle, cascade). + scaled_duration(MOTION_SLIDE_SECS, *speed) } const WIN_TOAST_SECS: f32 = 4.0; @@ -63,38 +70,25 @@ const CHALLENGE_TOAST_SECS: f32 = 3.0; const VOLUME_TOAST_SECS: f32 = 1.4; /// Per-card stagger interval for the win cascade at Normal speed (seconds). -const CASCADE_STAGGER_NORMAL: f32 = 0.05; -/// Duration of each card's cascade slide at Normal speed (seconds). -const CASCADE_DURATION_NORMAL: f32 = 0.5; - -/// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`. /// -/// | `AnimSpeed` | Returned value | -/// |-------------|----------------| -/// | `Normal` | 0.05 s | -/// | `Fast` | 0.025 s | -/// | `Instant` | 0.0 s | +/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing +/// lives in one design-token module. +const CASCADE_STAGGER_NORMAL: f32 = MOTION_CASCADE_STAGGER_SECS; +/// Duration of each card's cascade slide at Normal speed (seconds). +/// +/// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`. +const CASCADE_DURATION_NORMAL: f32 = MOTION_CASCADE_SLIDE_SECS; + +/// Returns the per-card stagger delay for the win cascade at the given +/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`. pub fn cascade_step_secs(speed: AnimSpeed) -> f32 { - match speed { - AnimSpeed::Normal => CASCADE_STAGGER_NORMAL, - AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0, - AnimSpeed::Instant => 0.0, - } + scaled_duration(MOTION_CASCADE_STAGGER_SECS, speed) } -/// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`. -/// -/// | `AnimSpeed` | Returned value | -/// |-------------|----------------| -/// | `Normal` | 0.5 s | -/// | `Fast` | 0.25 s | -/// | `Instant` | 0.0 s | +/// Returns the slide duration for each card in the win cascade at the given +/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`. pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 { - match speed { - AnimSpeed::Normal => CASCADE_DURATION_NORMAL, - AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0, - AnimSpeed::Instant => 0.0, - } + scaled_duration(MOTION_CASCADE_SLIDE_SECS, speed) } /// Linear-lerp slide animation. @@ -237,13 +231,35 @@ fn advance_card_anims( } anim.elapsed += dt; let t = (anim.elapsed / anim.duration).min(1.0); - transform.translation = anim.start.lerp(anim.target, t); + // Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out + // with a small terminal overshoot). Hardcoded at the call site so the + // shared `CardAnim` struct stays a simple linear-tween container — the + // upgrade is one extra `sample_curve` call per advancing animation. + let s = sample_curve(MotionCurve::SmoothSnap, t); + transform.translation = anim.start.lerp(anim.target, s); if t >= 1.0 { + transform.translation = anim.target; commands.entity(entity).remove::(); } } } +/// Maximum per-card Z-rotation drift applied during the win cascade, in +/// radians. 15° gives a lively but legible scatter — anything larger starts +/// to look chaotic. +const WIN_CASCADE_MAX_ROTATION_RAD: f32 = std::f32::consts::PI / 12.0; + +/// Returns a deterministic per-card Z-rotation in `±WIN_CASCADE_MAX_ROTATION_RAD` +/// for the win cascade. Indexing by the card's position in the iterator keeps +/// the result reproducible for a given deal without needing a random crate. +fn cascade_rotation(index: usize) -> f32 { + // Pseudo-random hash from a Fibonacci multiplier; same approach used by + // `card_animation::timing::micro_vary`. Returns 0..=1. + let hash = (index as u32).wrapping_mul(2_654_435_761); + let noise = (hash >> 16) as f32 / 65_536.0; + (noise - 0.5) * 2.0 * WIN_CASCADE_MAX_ROTATION_RAD +} + fn handle_win_cascade( mut commands: Commands, mut events: MessageReader, @@ -274,17 +290,44 @@ fn handle_win_cascade( let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score); spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS); - let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone())); - let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone())); + let step = settings + .as_ref() + .map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed)); + let duration = settings + .as_ref() + .map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed)); for (i, (entity, transform)) in cards.iter().enumerate() { - commands.entity(entity).insert(CardAnim { - start: transform.translation, - target: targets[i % 8], + // Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can + // pick `MotionCurve::Expressive` for the cascade — the spring-style + // overshoot is what gives the win moment its theatrical feel. The + // `CardAnim`/`CardAnimation` coexistence rule (one per entity) is + // satisfied because cards have neither at the moment the cascade + // starts. + let start = transform.translation; + let target = targets[i % 8]; + commands.entity(entity).insert(CardAnimation { + start: start.truncate(), + end: target.truncate(), elapsed: 0.0, duration, + curve: crate::card_animation::MotionCurve::Expressive, delay: i as f32 * step, + start_z: start.z, + end_z: target.z, + z_lift: 0.0, + scale_start: 1.0, + scale_end: 1.0, }); + + // Per-card Z-rotation drift (±15°), deterministic per cascade + // ordering — gives the scatter a more lively feel without needing + // rotation interpolation in the tween system. Since cards fly off + // screen, the static rotation reads as motion. + let rot = cascade_rotation(i); + let mut new_transform = *transform; + new_transform.rotation = Quat::from_rotation_z(rot); + commands.entity(entity).insert(new_transform); } } @@ -584,13 +627,16 @@ mod tests { } #[test] - fn card_anim_at_half_elapsed_reaches_midpoint() { + fn card_anim_at_half_elapsed_passes_geometric_midpoint() { let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); let start = Vec3::ZERO; let target = Vec3::new(100.0, 0.0, 0.0); - // elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0 + // elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0. + // With `MotionCurve::SmoothSnap` (cubic ease-out) the position at + // t=0.5 is well past the geometric midpoint — assert we're past 50 + // but still short of the target so the animation is mid-flight. let entity = app .world_mut() .spawn(( @@ -602,7 +648,11 @@ mod tests { app.update(); let pos = app.world().entity(entity).get::().unwrap().translation; - assert!((pos.x - 50.0).abs() < 1e-3, "expected midpoint x=50, got {}", pos.x); + assert!( + pos.x > 50.0 && pos.x < 100.0, + "with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}", + pos.x + ); assert!( app.world().entity(entity).get::().is_some(), "animation not yet complete" @@ -788,7 +838,7 @@ mod tests { let before = app .world_mut() - .query::<&CardAnim>() + .query::<&CardAnimation>() .iter(app.world()) .count(); assert_eq!(before, 0, "no animations before win"); @@ -799,10 +849,60 @@ mod tests { let after = app .world_mut() - .query::<&CardAnim>() + .query::<&CardAnimation>() .iter(app.world()) .count(); - assert_eq!(after, 52, "all 52 cards should have cascade animations"); + assert_eq!( + after, 52, + "all 52 cards should have curve-based cascade animations" + ); + } + + #[test] + fn win_cascade_uses_expressive_curve() { + let mut app = app_with_anim(); + app.world_mut() + .write_message(GameWonEvent { score: 0, time_seconds: 0 }); + app.update(); + + let mut q = app.world_mut().query::<&CardAnimation>(); + for anim in q.iter(app.world()) { + assert_eq!( + anim.curve, + MotionCurve::Expressive, + "win cascade must use the Expressive curve" + ); + } + } + + #[test] + fn win_cascade_applies_per_card_rotation() { + let mut app = app_with_anim(); + app.world_mut() + .write_message(GameWonEvent { score: 0, time_seconds: 0 }); + app.update(); + + // At least one card's rotation must differ from identity — the + // deterministic hash will produce non-zero rotations for nearly all + // 52 indices. + let mut q = app.world_mut().query::<(&CardEntity, &Transform)>(); + let any_rotated = q + .iter(app.world()) + .any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999); + assert!(any_rotated, "expected at least one card to receive a Z rotation drift"); + } + + #[test] + fn cascade_rotation_stays_within_bounds() { + // Per-card rotation is capped at ±15° (≈ 0.2618 rad). Sampling a + // wider index range than a real deal exercises the hash distribution. + for i in 0..256 { + let r = cascade_rotation(i); + assert!( + r.abs() <= WIN_CASCADE_MAX_ROTATION_RAD + 1e-6, + "cascade_rotation({i}) = {r} exceeds the ±15° cap" + ); + } } // ----------------------------------------------------------------------- @@ -810,8 +910,9 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn cascade_step_normal_is_expected_value() { - assert!((cascade_step_secs(AnimSpeed::Normal) - 0.05).abs() < 1e-6); + fn cascade_step_normal_matches_design_token() { + // Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS`. + assert!((cascade_step_secs(AnimSpeed::Normal) - MOTION_CASCADE_STAGGER_SECS).abs() < 1e-6); } #[test] @@ -830,8 +931,11 @@ mod tests { } #[test] - fn cascade_duration_normal_is_expected_value() { - assert!((cascade_duration_secs(AnimSpeed::Normal) - 0.5).abs() < 1e-6); + fn cascade_duration_normal_matches_design_token() { + // Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`. + assert!( + (cascade_duration_secs(AnimSpeed::Normal) - MOTION_CASCADE_SLIDE_SECS).abs() < 1e-6 + ); } #[test] diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index c5c8523..72206a5 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -11,10 +11,13 @@ //! //! # Task #55 — Settle/bounce on valid placement //! -//! After `StateChangedEvent` fires, `start_settle_anim` inserts `SettleAnim` -//! on the top card of every non-empty pile. `tick_settle_anim` applies a brief -//! Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s) and removes -//! the component when elapsed ≥ 0.15 s. +//! `start_settle_anim` listens for `MoveRequestEvent` and `DrawRequestEvent` so +//! the bounce is **scoped to the cards that just moved**, not every top card on +//! the board. For a move it bounces the top `count` cards of the destination +//! pile; for a draw it bounces the top card of the waste. Undos are skipped so +//! reverting a move doesn't replay the placement feedback. `tick_settle_anim` +//! applies a brief Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s) +//! and removes the component when elapsed ≥ 0.15 s. //! //! # Task #69 — Animated card deal on new game start //! @@ -22,17 +25,21 @@ //! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and //! inserts a `CardAnim` on every card entity, sliding each card from the stock //! pile's position to its current (final) position with a per-card stagger -//! derived from the current `AnimSpeed` setting: +//! derived from the current `AnimSpeed` setting plus a deterministic ±10 % +//! jitter per card so the deal feels organic instead of mechanical: //! -//! | `AnimSpeed` | Stagger | +//! | `AnimSpeed` | Base stagger | //! |---------------|-------------------| //! | `Normal` | 0.04 s (default) | //! | `Fast` | 0.02 s (half) | //! | `Instant` | 0.00 s (no delay) | //! -//! `deal_stagger_delay` is a pure helper exposed for unit testing. +//! `deal_stagger_delay` and `deal_stagger_jitter` are pure helpers exposed for +//! unit testing. +use std::collections::hash_map::DefaultHasher; use std::f32::consts::PI; +use std::hash::{Hash, Hasher}; use bevy::prelude::*; use solitaire_core::pile::PileType; @@ -40,7 +47,9 @@ use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; use crate::card_plugin::CardEntity; -use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent}; +use crate::events::{ + DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, +}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; @@ -155,6 +164,23 @@ pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 { index as f32 * stagger_secs } +/// Returns a deterministic ±10 % jitter factor for `card_id`. +/// +/// Hashes `card_id` with `DefaultHasher` and maps the low bits into a value in +/// `0.0..=1.0`, then re-centres into `-0.1..=0.1`. The same card id always +/// produces the same factor so deals are reproducible (important for +/// seed-based testing and replay), while a 52-card deal still feels organic +/// because each card's offset varies. +/// +/// Multiply a base stagger interval by `1.0 + deal_stagger_jitter(card_id)` to +/// apply the jitter. +pub fn deal_stagger_jitter(card_id: u32) -> f32 { + let mut hasher = DefaultHasher::new(); + card_id.hash(&mut hasher); + let jitter_norm = (hasher.finish() % 1000) as f32 / 1000.0; // 0.0..=1.0 + (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % +} + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -164,16 +190,23 @@ pub struct FeedbackAnimPlugin; impl Plugin for FeedbackAnimPlugin { fn build(&self, app: &mut App) { - app.add_systems( - Update, - ( - start_shake_anim.after(GameMutation), - tick_shake_anim, - start_settle_anim.after(GameMutation), - tick_settle_anim, - start_deal_anim.after(GameMutation), - ), - ); + // Register the events this plugin consumes so it can run in isolation + // under `MinimalPlugins` (e.g. unit tests) without depending on other + // plugins to register them. Double-registration is idempotent in Bevy. + app.add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + start_shake_anim.after(GameMutation), + tick_shake_anim, + start_settle_anim.after(GameMutation), + tick_settle_anim, + start_deal_anim.after(GameMutation), + ), + ); } } @@ -240,28 +273,52 @@ fn tick_shake_anim( // Task #55 — Settle systems // --------------------------------------------------------------------------- -/// Inserts `SettleAnim` on the top card of every non-empty pile when -/// `StateChangedEvent` fires. +/// Inserts `SettleAnim` only on the cards that just moved — the top `count` +/// cards of the move destination, or the top of the waste pile for a draw. +/// +/// Triggered by `MoveRequestEvent` and `DrawRequestEvent`. Undo and other +/// state-mutations are deliberately skipped: replaying the placement bounce on +/// an undo would feel like the rejected-move shake fired by mistake. Note this +/// runs before the move resolves in `GameMutation`, so we read the destination +/// pile **after** the request has been accepted by reading the up-to-date game +/// state for both readers — the schedule labels the system `.after(GameMutation)` +/// to ensure that ordering. fn start_settle_anim( - mut events: MessageReader, + mut moves: MessageReader, + mut draws: MessageReader, game: Res, card_entities: Query<(Entity, &CardEntity)>, mut commands: Commands, ) { - if events.read().next().is_none() { + // Build the list of card ids that should bounce this frame from every + // queued request; multiple events can fire in the same frame (e.g. a move + // followed by a draw via keyboard accelerators). + let mut bounce_ids: Vec = Vec::new(); + + for ev in moves.read() { + if let Some(pile) = game.0.piles.get(&ev.to) { + // The moved cards land on top — take the last `count` ids. + let n = ev.count.min(pile.cards.len()); + if n > 0 { + let start = pile.cards.len() - n; + bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id)); + } + } + } + + if draws.read().next().is_some() + && let Some(pile) = game.0.piles.get(&PileType::Waste) + && let Some(top) = pile.cards.last() + { + bounce_ids.push(top.id); + } + + if bounce_ids.is_empty() { return; } - // Collect the id of the top card for each non-empty pile. - let top_ids: Vec = game - .0 - .piles - .values() - .filter_map(|p| p.cards.last().map(|c| c.id)) - .collect(); - for (entity, card_marker) in card_entities.iter() { - if top_ids.contains(&card_marker.card_id) { + if bounce_ids.contains(&card_marker.card_id) { commands.entity(entity).insert(SettleAnim::default()); } } @@ -308,7 +365,7 @@ fn start_deal_anim( layout: Option>, game: Res, settings: Option>, - card_entities: Query<(Entity, &Transform), With>, + card_entities: Query<(Entity, &CardEntity, &Transform)>, mut commands: Commands, ) { if events.read().next().is_none() { @@ -327,8 +384,12 @@ fn start_deal_anim( .map(deal_stagger_secs_for_speed) .unwrap_or(DEAL_STAGGER_SECS); - for (index, (entity, transform)) in card_entities.iter().enumerate() { + for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() { let final_pos = transform.translation; + // ±10 % jitter, deterministic per card id, so the deal feels organic + // without losing reproducibility (a given seed still produces the + // same per-card stagger pattern across runs). + let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id)); commands.entity(entity).insert(( Transform::from_translation(stock_start.with_z(final_pos.z)), CardAnim { @@ -336,7 +397,7 @@ fn start_deal_anim( target: final_pos, elapsed: 0.0, duration: DEAL_SLIDE_SECS, - delay: deal_stagger_delay(index, stagger_secs), + delay: deal_stagger_delay(index, per_card_stagger), }, )); } @@ -449,4 +510,44 @@ mod tests { ); } } + + // Step 9 — deal stagger jitter helper + + #[test] + fn deal_stagger_jitter_is_within_ten_percent() { + // Every card id in 0..256 must produce a jitter factor in ±10 %. + for card_id in 0u32..256 { + let j = deal_stagger_jitter(card_id); + assert!( + (-0.1..=0.1).contains(&j), + "deal_stagger_jitter({card_id}) = {j} is outside ±10 %" + ); + } + } + + #[test] + fn deal_stagger_jitter_is_deterministic() { + // Same card id must always produce the same jitter factor. + for card_id in [0u32, 7, 51, 999_999] { + assert!( + (deal_stagger_jitter(card_id) - deal_stagger_jitter(card_id)).abs() < 1e-9, + "deal_stagger_jitter({card_id}) is not deterministic" + ); + } + } + + #[test] + fn deal_stagger_jitter_varies_across_card_ids() { + // 52 cards should produce more than a couple distinct jitter factors; + // a constant function would return one value for all ids. + use std::collections::HashSet; + let unique: HashSet = (0u32..52) + .map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64) + .collect(); + assert!( + unique.len() > 10, + "expected > 10 distinct jitter factors for 52 cards, got {}", + unique.len() + ); + } } diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index 8b616bf..9d9f25f 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -20,7 +20,9 @@ use crate::events::{ use crate::game_plugin::GameMutation; use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; +use crate::settings_plugin::SettingsResource; use crate::stats_plugin::{StatsResource, StatsUpdate}; +use crate::ui_theme::{scaled_duration, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS}; // --------------------------------------------------------------------------- // Constants @@ -30,10 +32,12 @@ use crate::stats_plugin::{StatsResource, StatsUpdate}; /// Chosen so the cascade animation has a moment to start first. const WIN_SUMMARY_DELAY_SECS: f32 = 0.5; -/// Duration of the screen-shake in seconds. -const SHAKE_DURATION_SECS: f32 = 0.6; +/// Default duration of the screen-shake in seconds, before `AnimSpeed` scaling. +/// Sourced from `ui_theme::MOTION_WIN_SHAKE_SECS`. +const SHAKE_DURATION_SECS: f32 = MOTION_WIN_SHAKE_SECS; /// Maximum camera displacement in world-space pixels at the start of the shake. -const SHAKE_INTENSITY: f32 = 8.0; +/// Sourced from `ui_theme::MOTION_WIN_SHAKE_AMPLITUDE`. +const SHAKE_INTENSITY: f32 = MOTION_WIN_SHAKE_AMPLITUDE; // --------------------------------------------------------------------------- // Resources @@ -103,6 +107,11 @@ fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String { pub struct ScreenShakeResource { /// Seconds of shake remaining. pub remaining: f32, + /// Total duration the shake was armed for, used to compute the + /// `remaining / total` decay factor. Tracked separately from `remaining` + /// because the duration is now scaled by `AnimSpeed`, so a fixed + /// divisor would be wrong on Fast. + pub total: f32, /// Peak displacement in world-space pixels (decays to zero over `remaining`). pub intensity: f32, } @@ -308,14 +317,25 @@ fn spawn_win_summary_after_delay( mut shake: ResMut, mut pending: ResMut, session: Res, + settings: Option>, time: Res