From eedddb979e972c1acbe2692b3dd1df564bc7e53f Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 28 Apr 2026 18:06:58 +0000 Subject: [PATCH] feat(engine): add curve-based card animation module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces solitaire_engine::card_animation — a drop-in upgrade over the existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift, scale interpolation, delay, retargeting mid-flight, and per-card timing variation. Coexists with the legacy AnimationPlugin during migration. Also adds .claude/ to .gitignore so Claude Code local tooling is never committed. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .../src/card_animation/animation.rs | 372 +++++++++++++++++ solitaire_engine/src/card_animation/curves.rs | 196 +++++++++ .../src/card_animation/interaction.rs | 321 ++++++++++++++ solitaire_engine/src/card_animation/mod.rs | 390 ++++++++++++++++++ solitaire_engine/src/card_animation/timing.rs | 152 +++++++ 6 files changed, 1432 insertions(+) create mode 100644 solitaire_engine/src/card_animation/animation.rs create mode 100644 solitaire_engine/src/card_animation/curves.rs create mode 100644 solitaire_engine/src/card_animation/interaction.rs create mode 100644 solitaire_engine/src/card_animation/mod.rs create mode 100644 solitaire_engine/src/card_animation/timing.rs diff --git a/.gitignore b/.gitignore index 62e6a4c..bba1d91 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .env *.tmp data/ +.claude/ diff --git a/solitaire_engine/src/card_animation/animation.rs b/solitaire_engine/src/card_animation/animation.rs new file mode 100644 index 0000000..8b597a5 --- /dev/null +++ b/solitaire_engine/src/card_animation/animation.rs @@ -0,0 +1,372 @@ +//! `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 the card is stationary) and starts a fresh +/// `CardAnimation` from that position. Duration is recalculated from the remaining +/// distance so short remaining paths feel appropriately quick. +/// +/// # 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) = match current_anim { + Some(anim) => (anim.current_xy(), transform.translation.z), + None => (transform.translation.truncate(), transform.translation.z), + }; + + let distance = current_xy.distance(new_end); + commands.entity(entity).insert(CardAnimation { + start: current_xy, + end: new_end, + elapsed: 0.0, + duration: compute_duration(distance), + 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