Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/curves.rs
T
funman300 6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00

223 lines
7.1 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",
);
}
}