vsrg 0.3.0

Data structures for vertical scrolling rhythm games
Documentation
use std::f64::consts::{LN_2, PI};

use super::{AdvancedEasing, Easable, split_and_calc};

const C1: f64 = 1.70158;
const C2: f64 = C1 * 1.525;
const C3: f64 = C1 + 1.0;
const C4: f64 = 2.0 * PI / 3.0;
const C5: f64 = 2.0 * PI / 4.5;

pub fn evaluate_derivative<T, const N: usize>(ease: AdvancedEasing, from: T, to: T, t: T) -> T
where
    T: Easable<N>,
{
    split_and_calc::<T, N>(from, to, t, |from, to, t| {
        if !(0.0..=1.0).contains(&t) {
            0.0
        } else {
            let d = easing_derivative(ease, t);
            to * d - from * d
        }
    })
}

fn easing_derivative(ease: AdvancedEasing, t: f64) -> f64 {
    match ease {
        AdvancedEasing::Linear => 1.0,

        AdvancedEasing::InConst | AdvancedEasing::OutConst | AdvancedEasing::InOutConst => 0.0,

        AdvancedEasing::InSine => (PI / 2.0) * f64::sin(t * PI / 2.0),
        AdvancedEasing::OutSine => (PI / 2.0) * f64::cos(t * PI / 2.0),
        AdvancedEasing::InOutSine => (PI / 2.0) * f64::sin(t * PI),

        AdvancedEasing::InQuad => 2.0 * t,
        AdvancedEasing::OutQuad => 2.0 * (1.0 - t),
        AdvancedEasing::InOutQuad => {
            if t < 0.5 {
                4.0 * t
            } else {
                4.0 * (1.0 - t)
            }
        }

        AdvancedEasing::InCubic => 3.0 * t * t,
        AdvancedEasing::OutCubic => 3.0 * (1.0 - t) * (1.0 - t),
        AdvancedEasing::InOutCubic => {
            if t < 0.5 {
                12.0 * t * t
            } else {
                12.0 * (1.0 - t) * (1.0 - t)
            }
        }

        AdvancedEasing::InQuart => 4.0 * t * t * t,
        AdvancedEasing::OutQuart => 4.0 * (1.0 - t) * (1.0 - t) * (1.0 - t),
        AdvancedEasing::InOutQuart => {
            if t < 0.5 {
                32.0 * t * t * t
            } else {
                32.0 * (1.0 - t) * (1.0 - t) * (1.0 - t)
            }
        }

        AdvancedEasing::InQuint => 5.0 * t * t * t * t,
        AdvancedEasing::OutQuint => 5.0 * (1.0 - t) * (1.0 - t) * (1.0 - t) * (1.0 - t),
        AdvancedEasing::InOutQuint => {
            if t < 0.5 {
                80.0 * t * t * t * t
            } else {
                80.0 * (1.0 - t) * (1.0 - t) * (1.0 - t) * (1.0 - t)
            }
        }

        AdvancedEasing::InExpo => {
            if t == 0.0 {
                0.0
            } else {
                10.0 * LN_2 * f64::powf(2.0, 10.0 * t - 10.0)
            }
        }
        AdvancedEasing::OutExpo => {
            if t == 1.0 {
                0.0
            } else {
                10.0 * LN_2 * f64::powf(2.0, -10.0 * t)
            }
        }
        AdvancedEasing::InOutExpo => {
            if t == 0.0 || t == 1.0 {
                0.0
            } else if t < 0.5 {
                10.0 * LN_2 * f64::powf(2.0, 20.0 * t - 10.0)
            } else {
                10.0 * LN_2 * f64::powf(2.0, -20.0 * t + 10.0)
            }
        }

        AdvancedEasing::InCirc => t / f64::sqrt(1.0 - t * t),
        AdvancedEasing::OutCirc => (1.0 - t) / f64::sqrt(1.0 - (t - 1.0) * (t - 1.0)),
        AdvancedEasing::InOutCirc => {
            if t < 0.5 {
                2.0 * t / f64::sqrt(1.0 - 4.0 * t * t)
            } else {
                2.0 * (1.0 - t) / f64::sqrt(1.0 - f64::powi(-2.0 * t + 2.0, 2))
            }
        }

        AdvancedEasing::InBack => 3.0 * C3 * t * t - 2.0 * C1 * t,
        AdvancedEasing::OutBack => 3.0 * C3 * (t - 1.0) * (t - 1.0) + 2.0 * C1 * (t - 1.0),
        AdvancedEasing::InOutBack => {
            if t < 0.5 {
                12.0 * (C2 + 1.0) * t * t - 4.0 * C2 * t
            } else {
                3.0 * (C2 + 1.0) * f64::powi(2.0 * t - 2.0, 2) + 2.0 * C2 * (2.0 * t - 2.0)
            }
        }

        AdvancedEasing::InElastic => {
            if t == 0.0 || t == 1.0 {
                0.0
            } else {
                let arg = (t * 10.0 - 10.75) * C4;
                f64::powf(2.0, 10.0 * t - 10.0)
                    * (10.0 * LN_2 * f64::sin(arg) + 10.0 * C4 * f64::cos(arg))
            }
        }
        AdvancedEasing::OutElastic => {
            if t == 0.0 || t == 1.0 {
                0.0
            } else {
                let arg = (t * 10.0 - 0.75) * C4;
                f64::powf(2.0, -10.0 * t)
                    * (-10.0 * LN_2 * f64::sin(arg) + 10.0 * C4 * f64::cos(arg))
            }
        }
        AdvancedEasing::InOutElastic => {
            if t == 0.0 || t == 1.0 {
                0.0
            } else if t < 0.5 {
                let arg = (20.0 * t - 11.125) * C5;
                -f64::powf(2.0, 20.0 * t - 10.0)
                    * (10.0 * LN_2 * f64::sin(arg) + 10.0 * C5 * f64::cos(arg))
            } else {
                let arg = (20.0 * t - 11.125) * C5;
                f64::powf(2.0, -20.0 * t + 10.0)
                    * (-10.0 * LN_2 * f64::sin(arg) + 10.0 * C5 * f64::cos(arg))
            }
        }

        AdvancedEasing::InBounce => ease_out_bounce_derivative(1.0 - t),
        AdvancedEasing::OutBounce => ease_out_bounce_derivative(t),
        AdvancedEasing::InOutBounce => {
            if t < 0.5 {
                ease_out_bounce_derivative(1.0 - 2.0 * t)
            } else {
                ease_out_bounce_derivative(2.0 * t - 1.0)
            }
        }
    }
}

fn ease_out_bounce_derivative(x: f64) -> f64 {
    let n1 = 7.5625;
    let d1 = 2.75;

    if x < 1.0 / d1 {
        2.0 * n1 * x
    } else if x < 2.0 / d1 {
        2.0 * n1 * (x - 1.5 / d1)
    } else if x < 2.5 / d1 {
        2.0 * n1 * (x - 2.25 / d1)
    } else {
        2.0 * n1 * (x - 2.625 / d1)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::math::easing::eval::evaluate_easing;

    const FROM: f64 = 0.0;
    const TO: f64 = 100.0;
    const H: f64 = 1e-4;

    // Interior sample points, chosen to avoid the singular/kink points of
    // circ (t=0,0.5,1) and bounce piecewise boundaries.
    const SAMPLE_TS: &[f64] = &[0.123, 0.37, 0.62, 0.88];

    fn numerical_derivative(ease: AdvancedEasing, t: f64) -> f64 {
        let vp = evaluate_easing::<f64, 1>(ease, FROM, TO, t + H);
        let vm = evaluate_easing::<f64, 1>(ease, FROM, TO, t - H);
        (vp - vm) / (2.0 * H)
    }

    fn matches(analytical: f64, numerical: f64) -> bool {
        let abs = (analytical - numerical).abs();
        abs <= 0.05 || abs <= 0.01 * analytical.abs()
    }

    #[test]
    fn derivative_matches_finite_difference() {
        let mut failures: Vec<(AdvancedEasing, f64, f64, f64)> = Vec::new();
        for &ease in AdvancedEasing::ALL {
            if matches!(
                ease,
                AdvancedEasing::InConst | AdvancedEasing::OutConst | AdvancedEasing::InOutConst
            ) {
                continue;
            }

            for &t in SAMPLE_TS {
                let analytical = evaluate_derivative::<f64, 1>(ease, FROM, TO, t);
                let numerical = numerical_derivative(ease, t);
                if !matches(analytical, numerical) {
                    failures.push((ease, t, analytical, numerical));
                }
            }
        }
        assert!(
            failures.is_empty(),
            "analytical derivative did not match finite-difference: {}",
            failures
                .iter()
                .map(|(e, t, a, n)| format!("{e:?}@t={t}: analytical={a}, numerical={n}"))
                .collect::<Vec<_>>()
                .join("; ")
        );
    }

    #[test]
    fn linear_velocity_is_constant_delta() {
        // v(t) = (to - from) * d/dt[t] = (to - from)
        for t in [0.0_f64, 0.25, 0.5, 0.75, 1.0] {
            let v = evaluate_derivative::<f64, 1>(AdvancedEasing::Linear, FROM, TO, t);
            assert!(
                (v - (TO - FROM)).abs() <= 1e-4,
                "Linear velocity at t={t} should be {}, got {v}",
                TO - FROM
            );
        }
    }
}