//! Card feedback animations: shake on invalid move, settle on valid placement, //! and animated deal on new game start. //! //! # Task #54 — Shake animation on invalid move target //! //! When `MoveRejectedEvent` fires, a `ShakeAnim` component is inserted on every //! card entity that belongs to the destination pile (`MoveRejectedEvent::to`). //! The component stores the card's original X position and an elapsed counter. //! Each frame, `tick_shake_anim` displaces `transform.translation.x` with a //! damped sine wave and removes the component after 0.3 s. //! //! # Task #55 — Settle/bounce on valid placement //! //! `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 //! //! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`), //! `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 plus a deterministic ±10 % //! jitter per card so the deal feels organic instead of mechanical: //! //! | `AnimSpeed` | Base stagger | //! |---------------|-------------------| //! | `Normal` | 0.04 s (default) | //! | `Fast` | 0.02 s (half) | //! | `Instant` | 0.00 s (no delay) | //! //! `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; use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; use crate::card_plugin::CardEntity; use crate::events::{ 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 // --------------------------------------------------------------------------- /// Duration of the shake animation in seconds. const SHAKE_SECS: f32 = 0.3; /// Angular frequency (radians/s) of the shake sine wave. const SHAKE_OMEGA: f32 = 40.0; /// Peak displacement of the shake in world units. const SHAKE_AMPLITUDE: f32 = 6.0; /// Duration of the settle animation in seconds. const SETTLE_SECS: f32 = 0.15; /// Maximum Y-scale compression at the midpoint of the settle animation. const SETTLE_MIN_SCALE: f32 = 0.92; /// Per-card stagger delay for the deal animation in seconds. pub const DEAL_STAGGER_SECS: f32 = 0.04; /// Duration of each card's slide during the deal animation in seconds. pub const DEAL_SLIDE_SECS: f32 = 0.25; // --------------------------------------------------------------------------- // Task #54 — Shake animation component // --------------------------------------------------------------------------- /// Drives a horizontal shake animation. /// /// Inserted on card entities belonging to the destination pile of a rejected /// move. Removed automatically when `elapsed >= SHAKE_SECS`. #[derive(Component, Debug, Clone)] pub struct ShakeAnim { /// Seconds elapsed since the shake began. pub elapsed: f32, /// The card's original X position (restored when the component is removed). pub origin_x: f32, } /// Computes the horizontal displacement of the shake animation at the given /// elapsed time. /// /// Returns `origin_x + sin(elapsed * SHAKE_OMEGA) * SHAKE_AMPLITUDE * /// (1.0 - elapsed / SHAKE_SECS)`. At `elapsed == 0.0` the sin term is 0, so /// the displacement is 0. At `elapsed == SHAKE_SECS` the envelope is 0, so the /// displacement is also 0. /// /// This is a pure function exposed for unit testing without Bevy. pub fn shake_offset(elapsed: f32, origin_x: f32) -> f32 { let envelope = 1.0 - (elapsed / SHAKE_SECS).min(1.0); origin_x + (elapsed * SHAKE_OMEGA).sin() * SHAKE_AMPLITUDE * envelope } // --------------------------------------------------------------------------- // Task #55 — Settle animation component // --------------------------------------------------------------------------- /// Drives a brief Y-scale compression (bounce) animation. /// /// Inserted on the top card entity of every non-empty pile after a successful /// move (`StateChangedEvent`). Removed automatically when `elapsed >= SETTLE_SECS`. #[derive(Component, Debug, Clone, Default)] pub struct SettleAnim { /// Seconds elapsed since the settle animation began. pub elapsed: f32, } /// Computes the Y scale of the settle animation at the given elapsed time. /// /// At `elapsed == 0.0` the scale is 1.0 (no compression). At the midpoint /// (`elapsed == SETTLE_SECS / 2`) the scale reaches its minimum (`SETTLE_MIN_SCALE ≈ 0.92`). /// At `elapsed == SETTLE_SECS` the scale returns to 1.0. /// /// This is a pure function exposed for unit testing without Bevy. pub fn settle_scale(elapsed: f32) -> f32 { let t = (elapsed / SETTLE_SECS).min(1.0); 1.0 - (1.0 - SETTLE_MIN_SCALE) * (t * PI).sin() } // --------------------------------------------------------------------------- // Task #69 — Stagger delay helpers // --------------------------------------------------------------------------- /// Returns the per-card stagger delay in seconds for the given `AnimSpeed`. /// /// | `AnimSpeed` | Returned value | /// |---------------|----------------| /// | `Normal` | `DEAL_STAGGER_SECS` (0.04 s) | /// | `Fast` | `DEAL_STAGGER_SECS / 2` (0.02 s) | /// | `Instant` | `0.0` — all cards appear simultaneously | /// /// This is a pure function exposed for unit testing without Bevy. pub fn deal_stagger_secs_for_speed(speed: &AnimSpeed) -> f32 { match speed { AnimSpeed::Normal => DEAL_STAGGER_SECS, AnimSpeed::Fast => DEAL_STAGGER_SECS / 2.0, AnimSpeed::Instant => 0.0, } } /// Returns the stagger delay in seconds for card at position `index` during the /// deal animation, given a per-card stagger interval. /// /// `delay = index * stagger_secs` /// /// This is a pure function exposed for unit testing without Bevy. 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 // --------------------------------------------------------------------------- /// Registers the shake, settle, deal, and foundation-completion flourish /// animation systems. pub struct FeedbackAnimPlugin; impl Plugin for FeedbackAnimPlugin { fn build(&self, app: &mut App) { // 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_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), start_foundation_flourish.after(GameMutation), tick_foundation_flourish, ), ); } } // --------------------------------------------------------------------------- // Task #54 — Shake systems // --------------------------------------------------------------------------- /// Inserts `ShakeAnim` on all card entities belonging to the destination pile /// when a `MoveRejectedEvent` fires. fn start_shake_anim( mut events: MessageReader, game: Res, card_entities: Query<(Entity, &CardEntity, &Transform)>, mut commands: Commands, ) { for ev in events.read() { let dest_pile = &ev.to; // Collect the card ids that belong to the destination pile. let Some(pile) = game.0.piles.get(dest_pile) else { continue }; let dest_card_ids: Vec = pile.cards.iter().map(|c| c.id).collect(); if dest_card_ids.is_empty() { continue; } for (entity, card_marker, transform) in card_entities.iter() { if dest_card_ids.contains(&card_marker.card_id) { commands.entity(entity).insert(ShakeAnim { elapsed: 0.0, origin_x: transform.translation.x, }); } } } } /// Advances `ShakeAnim` each frame and removes it once the animation completes. /// /// Applies `translation.x = shake_offset(elapsed, origin_x)`. When done, /// restores `translation.x = origin_x` so the card is left at its correct /// position. Skipped while the game is paused. fn tick_shake_anim( mut commands: Commands, time: Res