Skip to main content

slt/
anim.rs

1use std::f64::consts::PI;
2
3/// Linear interpolation between `a` and `b` at position `t` (0.0..=1.0).
4///
5/// Values of `t` outside `[0, 1]` are not clamped; use an easing function
6/// first if you need clamping.
7pub fn lerp(a: f64, b: f64, t: f64) -> f64 {
8    a + (b - a) * t
9}
10
11/// Linear easing: constant rate from 0.0 to 1.0.
12pub fn ease_linear(t: f64) -> f64 {
13    clamp01(t)
14}
15
16/// Quadratic ease-in: slow start, fast end.
17pub fn ease_in_quad(t: f64) -> f64 {
18    let t = clamp01(t);
19    t * t
20}
21
22/// Quadratic ease-out: fast start, slow end.
23pub fn ease_out_quad(t: f64) -> f64 {
24    let t = clamp01(t);
25    1.0 - (1.0 - t) * (1.0 - t)
26}
27
28/// Quadratic ease-in-out: slow start, fast middle, slow end.
29pub fn ease_in_out_quad(t: f64) -> f64 {
30    let t = clamp01(t);
31    if t < 0.5 {
32        2.0 * t * t
33    } else {
34        1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
35    }
36}
37
38/// Cubic ease-in: slow start, fast end (stronger than quadratic).
39pub fn ease_in_cubic(t: f64) -> f64 {
40    let t = clamp01(t);
41    t * t * t
42}
43
44/// Cubic ease-out: fast start, slow end (stronger than quadratic).
45pub fn ease_out_cubic(t: f64) -> f64 {
46    let t = clamp01(t);
47    1.0 - (1.0 - t).powi(3)
48}
49
50/// Cubic ease-in-out: slow start, fast middle, slow end (stronger than quadratic).
51pub fn ease_in_out_cubic(t: f64) -> f64 {
52    let t = clamp01(t);
53    if t < 0.5 {
54        4.0 * t * t * t
55    } else {
56        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
57    }
58}
59
60/// Elastic ease-out: overshoots the target and oscillates before settling.
61pub fn ease_out_elastic(t: f64) -> f64 {
62    let t = clamp01(t);
63    if t == 0.0 {
64        0.0
65    } else if t == 1.0 {
66        1.0
67    } else {
68        let c4 = (2.0 * PI) / 3.0;
69        2f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
70    }
71}
72
73/// Bounce ease-out: simulates a ball bouncing before coming to rest.
74pub fn ease_out_bounce(t: f64) -> f64 {
75    let t = clamp01(t);
76    let n1 = 7.5625;
77    let d1 = 2.75;
78
79    if t < 1.0 / d1 {
80        n1 * t * t
81    } else if t < 2.0 / d1 {
82        let t = t - 1.5 / d1;
83        n1 * t * t + 0.75
84    } else if t < 2.5 / d1 {
85        let t = t - 2.25 / d1;
86        n1 * t * t + 0.9375
87    } else {
88        let t = t - 2.625 / d1;
89        n1 * t * t + 0.984_375
90    }
91}
92
93/// Linear interpolation between two values over a duration, with optional easing.
94///
95/// A `Tween` advances from `from` to `to` over `duration_ticks` render ticks.
96/// Call [`Tween::value`] each frame with the current tick to get the
97/// interpolated value. The tween is inactive until [`Tween::reset`] is called
98/// with a start tick.
99///
100/// # Example
101///
102/// ```
103/// use slt::Tween;
104/// use slt::anim::ease_out_quad;
105///
106/// let mut tween = Tween::new(0.0, 100.0, 20).easing(ease_out_quad);
107/// tween.reset(0);
108///
109/// let v = tween.value(10); // roughly halfway, eased
110/// assert!(v > 50.0);       // ease-out is faster at the start
111/// ```
112pub struct Tween {
113    from: f64,
114    to: f64,
115    duration_ticks: u64,
116    start_tick: u64,
117    easing: fn(f64) -> f64,
118    done: bool,
119}
120
121impl Tween {
122    /// Create a new tween from `from` to `to` over `duration_ticks` ticks.
123    ///
124    /// Uses linear easing by default. Call [`Tween::easing`] to change it.
125    /// The tween starts paused; call [`Tween::reset`] with the current tick
126    /// before reading values.
127    pub fn new(from: f64, to: f64, duration_ticks: u64) -> Self {
128        Self {
129            from,
130            to,
131            duration_ticks,
132            start_tick: 0,
133            easing: ease_linear,
134            done: false,
135        }
136    }
137
138    /// Set the easing function used to interpolate the value.
139    ///
140    /// Any function with signature `fn(f64) -> f64` that maps `[0, 1]` to
141    /// `[0, 1]` works. The nine built-in options are in this module.
142    pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
143        self.easing = f;
144        self
145    }
146
147    /// Return the interpolated value at the given `tick`.
148    ///
149    /// Returns `to` immediately if the tween has finished or `duration_ticks`
150    /// is zero. Marks the tween as done once `tick >= start_tick + duration_ticks`.
151    pub fn value(&mut self, tick: u64) -> f64 {
152        if self.done {
153            return self.to;
154        }
155
156        if self.duration_ticks == 0 {
157            self.done = true;
158            return self.to;
159        }
160
161        let elapsed = tick.wrapping_sub(self.start_tick);
162        if elapsed >= self.duration_ticks {
163            self.done = true;
164            return self.to;
165        }
166
167        let progress = elapsed as f64 / self.duration_ticks as f64;
168        let eased = (self.easing)(clamp01(progress));
169        lerp(self.from, self.to, eased)
170    }
171
172    /// Returns `true` if the tween has reached its end value.
173    pub fn is_done(&self) -> bool {
174        self.done
175    }
176
177    /// Restart the tween, treating `tick` as the new start time.
178    pub fn reset(&mut self, tick: u64) {
179        self.start_tick = tick;
180        self.done = false;
181    }
182}
183
184/// Spring physics animation that settles toward a target value.
185///
186/// Models a damped harmonic oscillator. Call [`Spring::set_target`] to change
187/// the goal, then call [`Spring::tick`] once per frame to advance the
188/// simulation. Read the current position with [`Spring::value`].
189///
190/// Tune behavior with `stiffness` (how fast it accelerates toward the target)
191/// and `damping` (how quickly oscillations decay). A damping value close to
192/// 1.0 is overdamped (no oscillation); lower values produce more bounce.
193///
194/// # Example
195///
196/// ```
197/// use slt::Spring;
198///
199/// let mut spring = Spring::new(0.0, 0.2, 0.85);
200/// spring.set_target(100.0);
201///
202/// for _ in 0..200 {
203///     spring.tick();
204///     if spring.is_settled() { break; }
205/// }
206///
207/// assert!((spring.value() - 100.0).abs() < 0.01);
208/// ```
209pub struct Spring {
210    value: f64,
211    target: f64,
212    velocity: f64,
213    stiffness: f64,
214    damping: f64,
215}
216
217impl Spring {
218    /// Create a new spring at `initial` position with the given physics parameters.
219    ///
220    /// - `stiffness`: acceleration per unit of displacement (try `0.1`..`0.5`)
221    /// - `damping`: velocity multiplier per tick, `< 1.0` (try `0.8`..`0.95`)
222    pub fn new(initial: f64, stiffness: f64, damping: f64) -> Self {
223        Self {
224            value: initial,
225            target: initial,
226            velocity: 0.0,
227            stiffness,
228            damping,
229        }
230    }
231
232    /// Set the target value the spring will move toward.
233    pub fn set_target(&mut self, target: f64) {
234        self.target = target;
235    }
236
237    /// Advance the spring simulation by one tick.
238    ///
239    /// Call this once per frame before reading [`Spring::value`].
240    pub fn tick(&mut self) {
241        let displacement = self.target - self.value;
242        let spring_force = displacement * self.stiffness;
243        self.velocity = (self.velocity + spring_force) * self.damping;
244        self.value += self.velocity;
245    }
246
247    /// Return the current spring position.
248    pub fn value(&self) -> f64 {
249        self.value
250    }
251
252    /// Returns `true` if the spring has effectively settled at its target.
253    ///
254    /// Settled means both the distance to target and the velocity are below
255    /// `0.01`.
256    pub fn is_settled(&self) -> bool {
257        (self.target - self.value).abs() < 0.01 && self.velocity.abs() < 0.01
258    }
259}
260
261fn clamp01(t: f64) -> f64 {
262    t.clamp(0.0, 1.0)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn assert_endpoints(f: fn(f64) -> f64) {
270        assert_eq!(f(0.0), 0.0);
271        assert_eq!(f(1.0), 1.0);
272    }
273
274    #[test]
275    fn easing_functions_have_expected_endpoints() {
276        let easing_functions: [fn(f64) -> f64; 9] = [
277            ease_linear,
278            ease_in_quad,
279            ease_out_quad,
280            ease_in_out_quad,
281            ease_in_cubic,
282            ease_out_cubic,
283            ease_in_out_cubic,
284            ease_out_elastic,
285            ease_out_bounce,
286        ];
287
288        for easing in easing_functions {
289            assert_endpoints(easing);
290        }
291    }
292
293    #[test]
294    fn tween_returns_start_middle_end_values() {
295        let mut tween = Tween::new(0.0, 10.0, 10);
296        tween.reset(100);
297
298        assert_eq!(tween.value(100), 0.0);
299        assert_eq!(tween.value(105), 5.0);
300        assert_eq!(tween.value(110), 10.0);
301        assert!(tween.is_done());
302    }
303
304    #[test]
305    fn tween_reset_restarts_animation() {
306        let mut tween = Tween::new(0.0, 1.0, 10);
307        tween.reset(0);
308        let _ = tween.value(10);
309        assert!(tween.is_done());
310
311        tween.reset(20);
312        assert!(!tween.is_done());
313        assert_eq!(tween.value(20), 0.0);
314        assert_eq!(tween.value(30), 1.0);
315        assert!(tween.is_done());
316    }
317
318    #[test]
319    fn spring_settles_to_target() {
320        let mut spring = Spring::new(0.0, 0.2, 0.85);
321        spring.set_target(10.0);
322
323        for _ in 0..300 {
324            spring.tick();
325            if spring.is_settled() {
326                break;
327            }
328        }
329
330        assert!(spring.is_settled());
331        assert!((spring.value() - 10.0).abs() < 0.01);
332    }
333
334    #[test]
335    fn lerp_interpolates_values() {
336        assert_eq!(lerp(0.0, 10.0, 0.0), 0.0);
337        assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
338        assert_eq!(lerp(0.0, 10.0, 1.0), 10.0);
339    }
340}