//! `CardAnimation` component and the system that drives it. //! //! # Design //! //! `CardAnimation` is a **drop-in upgrade** for the existing linear `CardAnim`. //! It targets `Transform` (the current sprite-based architecture). Swapping to //! Bevy UI requires only changing the four write lines in `advance_card_animations` //! to write `Style.left` / `Style.top` via a `Style` component query instead. //! //! # Z-lift //! //! During motion, `translation.z` follows a parabolic arc: //! //! ```text //! z(t) = lerp(start_z, end_z, t) + z_lift × sin(t × π) //! ``` //! //! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the //! card "floats up" in the middle of its travel and lands at its correct rest z. //! //! # Retargeting //! //! When a card is redirected mid-flight, call [`retarget_animation`]. It reads //! the current interpolated position so the card never snaps. //! //! # Coexistence with `CardAnim` //! //! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but //! **must never be on the same entity** — both write to `Transform`. When //! migrating, replace `CardAnim` insertions with `CardAnimation` insertions and //! register `CardAnimationPlugin` alongside `AnimationPlugin`. use std::f32::consts::PI; use bevy::prelude::*; use super::curves::{sample_curve, MotionCurve}; use super::timing::compute_duration; use crate::pause_plugin::PausedResource; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /// Curve-based card animation. /// /// Drives `Transform` XY translation via a [`MotionCurve`], with optional /// z-lift and scale interpolation. Removes itself when the animation completes. #[derive(Component, Debug, Clone)] pub struct CardAnimation { /// 2-D start position (world space). pub start: Vec2, /// 2-D destination (world space). pub end: Vec2, /// Seconds elapsed since the delay expired. pub elapsed: f32, /// Total animation duration in seconds (excluding delay). pub duration: f32, /// Easing curve applied to the interpolation factor. pub curve: MotionCurve, /// Seconds to wait before starting movement. pub delay: f32, /// Z coordinate at animation start (used for parabolic lift calculation). pub start_z: f32, /// Z coordinate at animation end — the card's resting z after completion. pub end_z: f32, /// Extra Z added at the midpoint of motion (`z(0.5) = base_z + z_lift`). /// Set to 0.0 to disable the depth arc. pub z_lift: f32, /// Transform scale at `t = 0`. pub scale_start: f32, /// Transform scale at `t = 1`. pub scale_end: f32, } impl CardAnimation { /// Convenience constructor: slide from `start` to `end` with auto-computed /// duration based on pixel distance. No z-lift or scale change. pub fn slide(start: Vec2, start_z: f32, end: Vec2, end_z: f32, curve: MotionCurve) -> Self { Self { start, end, elapsed: 0.0, duration: compute_duration(start.distance(end)), curve, delay: 0.0, start_z, end_z, z_lift: 0.0, scale_start: 1.0, scale_end: 1.0, } } /// Sets the pre-animation delay in seconds. #[must_use] pub fn with_delay(mut self, secs: f32) -> Self { self.delay = secs; self } /// Overrides the auto-computed duration. #[must_use] pub fn with_duration(mut self, secs: f32) -> Self { self.duration = secs; self } /// Enables the parabolic z-lift arc with the given peak offset. #[must_use] pub fn with_z_lift(mut self, lift: f32) -> Self { self.z_lift = lift; self } /// Interpolates `Transform.scale` from `start` to `end` over the animation. #[must_use] pub fn with_scale(mut self, start: f32, end: f32) -> Self { self.scale_start = start; self.scale_end = end; self } /// Returns the current interpolated XY position without advancing time. /// /// Used by [`retarget_animation`] to read mid-flight position cleanly. pub fn current_xy(&self) -> Vec2 { if self.duration <= 0.0 { return self.end; } let t = (self.elapsed / self.duration).clamp(0.0, 1.0); let s = sample_curve(self.curve, t); self.start.lerp(self.end, s) } } // --------------------------------------------------------------------------- // Retarget helper // --------------------------------------------------------------------------- /// Redirects a card to a new destination without snapping or interrupting motion. /// /// Reads the card's current interpolated position (from a live [`CardAnimation`] /// if present, or from `Transform` if stationary) and starts a fresh /// [`CardAnimation`] from that position. Duration is recalculated from the /// remaining distance so short paths stay quick. /// /// # Velocity continuity /// /// When a card is mid-flight, the new animation starts with a small positive /// `elapsed` offset (`carry`) derived from how far through the current animation /// the card is. This preserves a sense of forward momentum: the new curve does /// not restart from zero velocity, avoiding a visible "lurch" when the target /// changes rapidly. /// /// The carry is deliberately small (≤ 10 % of the new duration) so that it /// never causes a visible position jump — the card's start position is still /// read from the current transform. /// /// # Example /// /// ```ignore /// // Inside a system that decides to move a card to a new target: /// let (entity, transform, anim) = cards.get(card_entity)?; /// retarget_animation( /// &mut commands, /// entity, /// anim, // Option<&CardAnimation> /// transform, /// Vec2::new(400.0, 200.0), /// resting_z, /// MotionCurve::SmoothSnap, /// ); /// ``` pub fn retarget_animation( commands: &mut Commands, entity: Entity, current_anim: Option<&CardAnimation>, transform: &Transform, new_end: Vec2, new_end_z: f32, curve: MotionCurve, ) { let (current_xy, current_z, momentum_carry) = match current_anim { Some(anim) if anim.duration > 0.0 => { // Estimate how far into the current animation we are and carry // a small fraction of that progress into the new animation. // This avoids restarting from zero velocity and makes the motion // feel continuous when the target changes mid-flight. let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0); // Cap at 10 % of the new animation so there's no visible jump. let carry = (t * 0.12).min(0.10); (anim.current_xy(), transform.translation.z, carry) } _ => (transform.translation.truncate(), transform.translation.z, 0.0), }; let distance = current_xy.distance(new_end); let duration = compute_duration(distance); commands.entity(entity).insert(CardAnimation { start: current_xy, end: new_end, // Start slightly into the new animation to carry forward momentum. elapsed: momentum_carry * duration, duration, curve, delay: 0.0, start_z: current_z, end_z: new_end_z, z_lift: 8.0, scale_start: 1.0, scale_end: 1.0, }); } // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- /// Advances all [`CardAnimation`] components each frame. /// /// Skipped while the game is paused. On completion the component is removed /// and `Transform` is snapped to the exact destination to prevent floating-point /// drift. pub(crate) fn advance_card_animations( mut commands: Commands, time: Res