Skip to main content

kvas_engine/
tween.rs

1//! Easing curves and script-free local-position animation.
2//!
3//! [`MoveTween`] runs after ordinary modifiers and before physics, making it
4//! useful for kinematic lifts and animated scene objects. Avoid assigning multiple
5//! independent position writers to one entity: the tween writes last.
6//!
7//! ```
8//! use kvas_engine::{scene::Scene, runtime::Runtime, tween::{Ease, MoveTween}};
9//! use kvas_engine::components::Transform;
10//! use glam::Vec3;
11//! let mut scene = Scene::default();
12//! let entity = scene.spawn("lift");
13//! let mut tween = MoveTween::to(Vec3::Y, 1.0).unwrap();
14//! tween.ease = Ease::Linear;
15//! scene.world.insert(entity, tween);
16//! let mut runtime = Runtime::new(scene, None);
17//! runtime.tick(0.1);
18//! assert!((runtime.scene.world.get::<Transform>(entity).unwrap().position.y - 0.1).abs() < 1e-5);
19//! ```
20
21use crate::{
22    choice_enum,
23    components::{Component, Transform},
24    reflect::Field,
25};
26use glam::Vec3;
27use kvas_ecs::World;
28use serde::{Deserialize, Serialize};
29
30choice_enum!(
31    /// Normalized interpolation curves. BackOut overshoots; BounceOut rebounds.
32    Ease { Linear, SmoothStep, SmootherStep, QuadIn, QuadOut, QuadInOut, CubicIn, CubicOut, CubicInOut, SineIn, SineOut, SineInOut, BackOut, BounceOut } default SmoothStep
33);
34
35impl Ease {
36    /// Samples a curve at time clamped to 0..1. Non-finite time selects zero.
37    /// Endpoints return exactly zero/one; BackOut may exceed one in between.
38    pub fn sample(self, time: f32) -> f32 {
39        let t = if time.is_finite() { time.clamp(0.0, 1.0) } else { 0.0 };
40        if t == 0.0 || t == 1.0 {
41            return t;
42        }
43        match self {
44            Self::Linear => t,
45            Self::SmoothStep => t * t * (3.0 - 2.0 * t),
46            Self::SmootherStep => t * t * t * (t * (t * 6.0 - 15.0) + 10.0),
47            Self::QuadIn => t * t,
48            Self::QuadOut => 1.0 - (1.0 - t).powi(2),
49            Self::QuadInOut => {
50                if t < 0.5 {
51                    2.0 * t * t
52                } else {
53                    1.0 - (-2.0 * t + 2.0).powi(2) * 0.5
54                }
55            }
56            Self::CubicIn => t * t * t,
57            Self::CubicOut => 1.0 - (1.0 - t).powi(3),
58            Self::CubicInOut => {
59                if t < 0.5 {
60                    4.0 * t * t * t
61                } else {
62                    1.0 - (-2.0 * t + 2.0).powi(3) * 0.5
63                }
64            }
65            Self::SineIn => 1.0 - (t * std::f32::consts::FRAC_PI_2).cos(),
66            Self::SineOut => (t * std::f32::consts::FRAC_PI_2).sin(),
67            Self::SineInOut => -((std::f32::consts::PI * t).cos() - 1.0) * 0.5,
68            Self::BackOut => {
69                let t = t - 1.0;
70                1.0 + 2.70158 * t.powi(3) + 1.70158 * t.powi(2)
71            }
72            Self::BounceOut => {
73                let (t, offset) = if t < 1.0 / 2.75 {
74                    (t, 0.0)
75                } else if t < 2.0 / 2.75 {
76                    (t - 1.5 / 2.75, 0.75)
77                } else if t < 2.5 / 2.75 {
78                    (t - 2.25 / 2.75, 0.9375)
79                } else {
80                    (t - 2.625 / 2.75, 0.984375)
81                };
82                7.5625 * t * t + offset
83            }
84        }
85    }
86}
87
88choice_enum!(
89    /// Once stops at the target; Repeat jumps to the origin; PingPong reverses each leg.
90    TweenMode { Once, Repeat, PingPong } default Once
91);
92
93/// Local-position tween from the first observed pose to a parent-local target.
94/// Runtime progress is omitted from serde; loaded/duplicated components start afresh.
95#[derive(Clone, Debug, Serialize, Deserialize)]
96#[serde(default)]
97pub struct MoveTween {
98    /// Absolute target position in parent-local metres.
99    pub target: Vec3,
100    /// Duration of one leg in simulation seconds. Zero snaps once, in any mode.
101    pub duration: f32,
102    /// Initial delay in simulation seconds; negative values behave as zero.
103    pub delay: f32,
104    /// Interpolation curve for each leg.
105    pub ease: Ease,
106    /// Completion/repetition policy.
107    pub mode: TweenMode,
108    /// Whether the update system advances this component.
109    pub playing: bool,
110    #[serde(skip)]
111    origin: Option<Vec3>,
112    #[serde(skip)]
113    elapsed: f64,
114    #[serde(skip)]
115    finished: bool,
116}
117
118impl Default for MoveTween {
119    fn default() -> Self {
120        Self {
121            target: Vec3::Y,
122            duration: 1.0,
123            delay: 0.0,
124            ease: Ease::SmoothStep,
125            mode: TweenMode::Once,
126            playing: true,
127            origin: None,
128            elapsed: 0.0,
129            finished: false,
130        }
131    }
132}
133
134impl MoveTween {
135    /// Creates a one-shot tween; rejects invalid targets and negative/non-finite durations.
136    pub fn to(target: Vec3, duration: f32) -> Option<Self> {
137        (target.is_finite() && duration.is_finite() && duration >= 0.0).then_some(Self { target, duration, ..Self::default() })
138    }
139    /// Whether a one-shot (including zero duration) has reached its target.
140    pub fn is_finished(&self) -> bool {
141        self.finished
142    }
143    /// Total simulated seconds since the current start, including initial delay.
144    pub fn elapsed(&self) -> f64 {
145        self.elapsed
146    }
147    /// Restarts the clock and resumes, keeping the captured original position.
148    /// The next update may jump back toward the original position.
149    pub fn restart(&mut self) {
150        self.elapsed = 0.0;
151        self.finished = false;
152        self.playing = true;
153    }
154    /// Restarts from the position observed on the next update.
155    pub fn restart_from_current(&mut self) {
156        self.origin = None;
157        self.restart();
158    }
159    /// Suspends progress without resetting it.
160    pub fn pause(&mut self) {
161        self.playing = false;
162    }
163    /// Resumes an unfinished tween. Completed one-shots require restart.
164    pub fn resume(&mut self) {
165        if !self.finished {
166            self.playing = true;
167        }
168    }
169
170    fn advance(&mut self, position: Vec3, dt: f32) -> Option<Vec3> {
171        if !self.playing || self.finished {
172            return None;
173        }
174        if !position.is_finite() || !self.target.is_finite() || !self.duration.is_finite() || self.duration < 0.0 || !self.delay.is_finite()
175        {
176            self.playing = false;
177            return None;
178        }
179        let origin = *self.origin.get_or_insert(position);
180        self.elapsed += dt as f64;
181        let active = self.elapsed - self.delay.max(0.0) as f64;
182        if active < 0.0 {
183            return None;
184        }
185        if self.duration == 0.0 {
186            self.finished = true;
187            self.playing = false;
188            return Some(self.target);
189        }
190        let phase = active / self.duration as f64;
191        let progress = match self.mode {
192            TweenMode::Once => {
193                if phase >= 1.0 {
194                    self.finished = true;
195                    self.playing = false;
196                }
197                phase.min(1.0)
198            }
199            TweenMode::Repeat => phase.fract(),
200            TweenMode::PingPong => {
201                let cycle = phase.rem_euclid(2.0);
202                if cycle <= 1.0 {
203                    cycle
204                } else {
205                    2.0 - cycle
206                }
207            }
208        } as f32;
209        Some(origin.lerp(self.target, self.ease.sample(progress)))
210    }
211}
212
213impl Component for MoveTween {
214    fn fields(&mut self) -> Vec<Field<'_>> {
215        vec![
216            Field::vec3("target", &mut self.target),
217            Field::f32("duration", &mut self.duration),
218            Field::f32("delay", &mut self.delay),
219            Field::choice("ease", &mut self.ease),
220            Field::choice("mode", &mut self.mode),
221            Field::bool("playing", &mut self.playing),
222        ]
223    }
224}
225
226/// Advances every position tween by a finite positive simulation delta.
227/// Entities without a transform are ignored. Invalid tween data pauses the tween.
228pub fn update(world: &mut World, dt: f32) {
229    if !dt.is_finite() || dt <= 0.0 {
230        return;
231    }
232    let positions: Vec<_> = world.iter2::<MoveTween, Transform>().map(|(e, _, t)| (e, t.position)).collect();
233    for (entity, position) in positions {
234        let target = world.get_mut::<MoveTween>(entity).and_then(|tween| tween.advance(position, dt));
235        if let Some(target) = target {
236            if let Some(transform) = world.get_mut::<Transform>(entity) {
237                transform.position = target;
238            }
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::reflect::Choice;
247    #[test]
248    fn curves_have_exact_endpoints_and_finite_samples() {
249        for index in 0..Ease::OPTIONS.len() {
250            let ease = Ease::from_index(index);
251            assert_eq!(ease.sample(-1.0), 0.0);
252            assert_eq!(ease.sample(2.0), 1.0);
253            assert_eq!(ease.sample(f32::NAN), 0.0);
254            for i in 0..=100 {
255                assert!(ease.sample(i as f32 / 100.0).is_finite());
256            }
257        }
258    }
259    #[test]
260    fn delay_pause_restart_and_pingpong() {
261        let mut tween = MoveTween::to(Vec3::X, 1.0).unwrap();
262        tween.ease = Ease::Linear;
263        tween.delay = 0.5;
264        assert!(tween.advance(Vec3::ZERO, 0.25).is_none());
265        assert_eq!(tween.advance(Vec3::ZERO, 0.75), Some(Vec3::X * 0.5));
266        tween.pause();
267        assert!(tween.advance(Vec3::ZERO, 0.25).is_none());
268        tween.resume();
269        assert_eq!(tween.advance(Vec3::ZERO, 0.5), Some(Vec3::X));
270        assert!(tween.is_finished());
271        tween.restart();
272        tween.mode = TweenMode::PingPong;
273        tween.delay = 0.0;
274        assert_eq!(tween.advance(Vec3::ZERO, 1.5), Some(Vec3::X * 0.5));
275        assert!(!tween.is_finished());
276        assert_eq!(tween.advance(Vec3::ZERO, 0.5), Some(Vec3::ZERO));
277        tween.restart_from_current();
278        assert_eq!(tween.advance(Vec3::Y, 0.5), Some((Vec3::X + Vec3::Y) * 0.5));
279    }
280}