eedddb979e
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>
197 lines
6.8 KiB
Rust
197 lines
6.8 KiB
Rust
//! 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");
|
||
}
|
||
}
|