Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/curves.rs
T
funman300 eedddb979e 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>
2026-04-28 18:06:58 +00:00

197 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}