feat(engine): add curve-based card animation module

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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 18:06:58 +00:00
parent 59a023ed5e
commit eedddb979e
6 changed files with 1432 additions and 0 deletions
@@ -0,0 +1,196 @@
//! Motion curve definitions for card animations.
//!
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
//! outside `[0, 1]` near the destination — callers should not clamp the output
//! before applying it to a lerp, as the overshoot is intentional.
//!
//! # Curve selection guide
//!
//! | Interaction | Recommended curve |
//! |----------------------|-------------------|
//! | Standard card move | `SmoothSnap` |
//! | Foundation placement | `SoftBounce` |
//! | Invalid snap-back | `Responsive` |
//! | Win cascade | `Expressive` |
use std::f32::consts::PI;
/// Motion curve variant controlling animation easing behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotionCurve {
/// Cubic ease-out with a 1.5 % terminal overshoot.
///
/// Overshoot is a sine arch in the final 25 % of the animation that peaks
/// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
/// lively, slightly "alive" feel without feeling heavy.
#[default]
SmoothSnap,
/// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
///
/// One visible overshoot of ~8 % followed by fast decay. Good for
/// satisfying "thud" feedback when placing cards on foundations or tableau.
SoftBounce,
/// Quintic ease-out — aggressive deceleration, zero overshoot.
///
/// Starts extremely fast and decelerates hard. Best for snap-back on
/// invalid drops: the card returns instantly without any bounce.
Responsive,
/// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
///
/// Two visible bounces before settling. High visual energy — reserved for
/// win cascade animations where expressivity matters more than subtlety.
Expressive,
}
/// Samples `curve` at normalised time `t ∈ [0, 1]`.
///
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
#[inline]
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match curve {
MotionCurve::SmoothSnap => smooth_snap(t),
MotionCurve::SoftBounce => soft_bounce(t),
MotionCurve::Responsive => responsive(t),
MotionCurve::Expressive => expressive(t),
}
}
/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
///
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
/// past its target; at `t = 1` the card is exactly on target.
#[inline]
fn smooth_snap(t: f32) -> f32 {
let base = 1.0 - (1.0 - t).powi(3);
let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
let overshoot = (tail * PI).sin() * 0.015;
base + overshoot
}
/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
///
/// Derived from the exact closed-form solution:
/// `x(t) = 1 e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
/// where `ωd = ω₀·√(1 ζ²)`.
#[inline]
fn soft_bounce(t: f32) -> f32 {
const OMEGA: f32 = 20.0;
const ZETA: f32 = 0.65;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
/// Quintic ease-out: `f(t) = 1 (1 t)^5`.
///
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
#[inline]
fn responsive(t: f32) -> f32 {
1.0 - (1.0 - t).powi(5)
}
/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
///
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
/// before settling.
#[inline]
fn expressive(t: f32) -> f32 {
const OMEGA: f32 = 18.0;
const ZETA: f32 = 0.45;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
}
#[test]
fn all_curves_start_at_zero() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
}
}
#[test]
fn all_curves_end_at_one() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
sample_curve(MotionCurve::Expressive, 1.0),
1.0,
2e-3,
"Expressive at t=1",
);
}
#[test]
fn responsive_reaches_half_before_midpoint() {
// Quintic ease-out accelerates fast — >50 % by t=0.5.
let v = sample_curve(MotionCurve::Responsive, 0.5);
assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
}
#[test]
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
}
#[test]
fn soft_bounce_overshoots_and_returns() {
let v = sample_curve(MotionCurve::SoftBounce, 1.0);
assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
}
#[test]
fn expressive_has_more_overshoot_than_soft_bounce() {
// Compare max value in [0,1] range.
let max_soft: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
let max_expr: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
assert!(
max_expr > max_soft,
"Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
);
}
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
}
}