1use sequoia_core::Point;
2
3use crate::Spring;
4
5#[derive(Clone, Debug, PartialEq)]
6#[non_exhaustive]
7pub enum AnimValue {
8 Static(f32),
9
10 Spring { target: f32, spring: Spring },
11}
12
13impl AnimValue {
14 pub fn static_px(v: f32) -> Self {
15 Self::Static(v)
16 }
17
18 pub fn spring(target: f32, damping_ratio: f32, frequency: f32) -> Self {
19 Self::Spring {
20 target,
21 spring: Spring::new(damping_ratio, frequency),
22 }
23 }
24
25 pub fn target(&self) -> f32 {
26 match self {
27 Self::Static(v) | Self::Spring { target: v, .. } => *v,
28 }
29 }
30
31 pub fn is_spring(&self) -> bool {
32 matches!(self, Self::Spring { .. })
33 }
34}
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct StateDef {
38 pub name: String,
39
40 pub x: AnimValue,
41
42 pub y: AnimValue,
43}
44
45impl StateDef {
46 pub fn new(name: &str, x: AnimValue, y: AnimValue) -> Self {
47 Self {
48 name: name.to_string(),
49 x,
50 y,
51 }
52 }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Trigger {
58 Load,
59}
60
61#[derive(Clone, Debug, PartialEq)]
62pub struct TransitionDef {
63 pub from: String,
64
65 pub to: String,
66
67 pub trigger: Trigger,
68}
69
70impl TransitionDef {
71 pub fn new(from: &str, to: &str, trigger: Trigger) -> Self {
72 Self {
73 from: from.to_string(),
74 to: to.to_string(),
75 trigger,
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug)]
81struct Flight {
82 from: usize,
83 to: usize,
84
85 elapsed: f32,
86}
87
88#[derive(Clone, Debug)]
89pub struct SpringMachine {
90 states: Vec<StateDef>,
91 transitions: Vec<TransitionDef>,
92
93 current: usize,
94
95 active: Option<Flight>,
96
97 fired: bool,
98
99 pos: Point,
100}
101
102impl SpringMachine {
103 pub fn new(states: Vec<StateDef>, transitions: Vec<TransitionDef>) -> Self {
104 let pos = states
105 .first()
106 .map(|s| Point::new(s.x.target(), s.y.target()))
107 .unwrap_or_default();
108 Self {
109 states,
110 transitions,
111 current: 0,
112 active: None,
113 fired: false,
114 pos,
115 }
116 }
117
118 pub fn states(&self) -> &[StateDef] {
119 &self.states
120 }
121
122 pub fn current_name(&self) -> &str {
123 self.states
124 .get(self.current)
125 .map(|s| s.name.as_str())
126 .unwrap_or("")
127 }
128
129 fn find_state(&self, name: &str) -> Option<usize> {
130 self.states.iter().position(|s| s.name == name)
131 }
132
133 pub fn advance(&mut self, dt: f64) -> bool {
134 let dt = dt.max(0.0) as f32;
135 if self.states.is_empty() {
136 self.pos = Point::default();
137 return false;
138 }
139 if !self.fired {
140 self.fired = true;
141 self.active = self.fire_load();
142 }
143 let running = match self.active.as_mut() {
144 None => false,
145 Some(f) => {
146 f.elapsed += dt;
147 if Self::settled(&self.states[f.to], f.elapsed) {
148 self.current = f.to;
149 self.active = None;
150 false
151 } else {
152 true
153 }
154 }
155 };
156 self.pos = self.offset_at();
157 running
158 }
159
160 fn fire_load(&mut self) -> Option<Flight> {
161 let tr = self
162 .transitions
163 .iter()
164 .find(|t| t.trigger == Trigger::Load)?;
165 let (from, to) = (self.find_state(&tr.from)?, self.find_state(&tr.to)?);
166 self.current = from;
167 let target = &self.states[to];
168 if matches!(target.x, AnimValue::Static(_)) && matches!(target.y, AnimValue::Static(_)) {
169 self.current = to;
170 self.pos = Point::new(target.x.target(), target.y.target());
171 return None;
172 }
173 Some(Flight {
174 from,
175 to,
176 elapsed: 0.0,
177 })
178 }
179
180 fn settled(state: &StateDef, t: f32) -> bool {
181 const EPS: f32 = 0.001;
182 let ok_x = match &state.x {
183 AnimValue::Static(_) => true,
184 AnimValue::Spring { spring, .. } => t >= spring.settle_time(EPS),
185 };
186 let ok_y = match &state.y {
187 AnimValue::Static(_) => true,
188 AnimValue::Spring { spring, .. } => t >= spring.settle_time(EPS),
189 };
190 ok_x && ok_y
191 }
192
193 fn offset_at(&self) -> Point {
194 let Some(f) = self.active else {
195 return self
196 .states
197 .get(self.current)
198 .map(|s| Point::new(s.x.target(), s.y.target()))
199 .unwrap_or_default();
200 };
201 Point::new(
202 interpolate_axis(&self.states[f.from].x, &self.states[f.to].x, f.elapsed),
203 interpolate_axis(&self.states[f.from].y, &self.states[f.to].y, f.elapsed),
204 )
205 }
206
207 pub fn offset(&self) -> Point {
208 self.pos
209 }
210
211 pub fn is_flying(&self) -> bool {
212 self.active.is_some()
213 }
214}
215
216fn interpolate_axis(from: &AnimValue, to: &AnimValue, t: f32) -> f32 {
217 let f = from.target();
218 let target = to.target();
219 match to {
220 AnimValue::Static(_) => target,
221 AnimValue::Spring { spring, .. } => f + (target - f) * spring.value(t),
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn demo() -> SpringMachine {
230 SpringMachine::new(
231 vec![
232 StateDef::new(
233 "idle",
234 AnimValue::static_px(408.0),
235 AnimValue::static_px(8.0),
236 ),
237 StateDef::new(
238 "bouncing",
239 AnimValue::spring(8.0, 0.5, 8.0),
240 AnimValue::spring(228.0, 0.5, 8.0),
241 ),
242 ],
243 vec![TransitionDef::new("idle", "bouncing", Trigger::Load)],
244 )
245 }
246
247 #[test]
248 fn load_fires_and_starts_at_from() {
249 let mut m = demo();
250 assert!(m.advance(0.0), "Load leci od pierwszej klatki");
251 assert_eq!(m.offset(), Point::new(408.0, 8.0));
252 assert_eq!(m.states()[1].x.target(), 8.0);
253 assert_eq!(m.states()[1].y.target(), 228.0);
254 }
255
256 #[test]
257 fn no_transition_stays_put() {
258 let mut m = SpringMachine::new(
259 vec![StateDef::new(
260 "idle",
261 AnimValue::static_px(10.0),
262 AnimValue::static_px(20.0),
263 )],
264 vec![],
265 );
266 assert!(!m.advance(0.016));
267 assert_eq!(m.offset(), Point::new(10.0, 20.0));
268 assert_eq!(m.current_name(), "idle");
269 }
270
271 #[test]
272 fn static_target_snaps_immediately() {
273 let mut m = SpringMachine::new(
274 vec![
275 StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
276 StateDef::new("b", AnimValue::static_px(100.0), AnimValue::static_px(50.0)),
277 ],
278 vec![TransitionDef::new("a", "b", Trigger::Load)],
279 );
280 assert!(!m.advance(0.0));
281 assert_eq!(m.offset(), Point::new(100.0, 50.0));
282 }
283
284 #[test]
285 fn empty_machine_is_inert() {
286 let mut m = SpringMachine::new(vec![], vec![]);
287 assert!(!m.advance(0.016));
288 assert_eq!(m.offset(), Point::ZERO);
289 assert_eq!(m.current_name(), "");
290 }
291
292 #[test]
293 fn unknown_states_never_fly() {
294 let mut m = SpringMachine::new(
295 vec![StateDef::new(
296 "a",
297 AnimValue::static_px(0.0),
298 AnimValue::static_px(0.0),
299 )],
300 vec![TransitionDef::new("a", "nope", Trigger::Load)],
301 );
302 assert!(!m.advance(0.016));
303 assert_eq!(m.offset(), Point::new(0.0, 0.0));
304 }
305
306 #[test]
307 fn negative_dt_is_clamped() {
308 let mut m = demo();
309 assert!(m.advance(-1.0));
310 assert_eq!(m.offset(), Point::new(408.0, 8.0));
311 }
312
313 #[test]
314 fn flight_is_visible_across_frames_then_settles() {
315 let mut m = SpringMachine::new(
316 vec![
317 StateDef::new(
318 "idle",
319 AnimValue::static_px(408.0),
320 AnimValue::static_px(8.0),
321 ),
322 StateDef::new(
323 "bouncing",
324 AnimValue::spring(8.0, 0.6, 2.5),
325 AnimValue::spring(228.0, 0.6, 2.5),
326 ),
327 ],
328 vec![TransitionDef::new("idle", "bouncing", Trigger::Load)],
329 );
330 assert!(m.advance(0.0));
331 let mut prev = m.offset().x;
332 assert!(prev > 380.0, "start at right edge");
333 let mut moved = 0;
334 for _ in 0..12 {
335 if !m.advance(0.025) {
336 break;
337 }
338 if m.offset().x < prev - 5.0 {
339 moved += 1;
340 }
341 prev = m.offset().x;
342 }
343 assert!(moved >= 2, "the flight is to be visible for several frames");
344
345 for _ in 0..200 {
346 if !m.advance(0.025) {
347 break;
348 }
349 }
350 assert!(!m.is_flying());
351 let p = m.offset();
352 assert!((p.x - 8.0).abs() < 0.01, "x settled: {p:?}");
353 assert!((p.y - 228.0).abs() < 0.01, "y settled: {p:?}");
354 }
355
356 #[test]
357 fn settles_exactly_on_target() {
358 let mut m = SpringMachine::new(
359 vec![
360 StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
361 StateDef::new(
362 "b",
363 AnimValue::spring(100.0, 1.0, 100.0),
364 AnimValue::spring(50.0, 1.0, 100.0),
365 ),
366 ],
367 vec![TransitionDef::new("a", "b", Trigger::Load)],
368 );
369 assert!(m.advance(0.0));
370 for _ in 0..100 {
371 if !m.advance(0.016) {
372 break;
373 }
374 }
375 assert!(!m.is_flying());
376 assert_eq!(m.offset(), Point::new(100.0, 50.0));
377 }
378
379 #[test]
380 fn mixed_static_and_spring_axes() {
381 let mut m = SpringMachine::new(
382 vec![
383 StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
384 StateDef::new(
385 "b",
386 AnimValue::static_px(100.0),
387 AnimValue::spring(50.0, 1.0, 4.0),
388 ),
389 ],
390 vec![TransitionDef::new("a", "b", Trigger::Load)],
391 );
392 assert!(m.advance(0.0));
393
394 assert_eq!(m.offset().x, 100.0);
395 for _ in 0..200 {
396 if !m.advance(0.016) {
397 break;
398 }
399 }
400 assert_eq!(m.offset(), Point::new(100.0, 50.0));
401 }
402
403 #[test]
404 fn underdamped_axis_overshoots_midflight() {
405 let mut m = SpringMachine::new(
406 vec![
407 StateDef::new("a", AnimValue::static_px(100.0), AnimValue::static_px(0.0)),
408 StateDef::new(
409 "b",
410 AnimValue::spring(0.0, 0.3, 3.0),
411 AnimValue::static_px(0.0),
412 ),
413 ],
414 vec![TransitionDef::new("a", "b", Trigger::Load)],
415 );
416 assert!(m.advance(0.0));
417 let mut min = f32::INFINITY;
418 for _ in 0..120 {
419 m.advance(1.0 / 60.0);
420 min = min.min(m.offset().x);
421 }
422 assert!(min < -1.0, "under-damped X should exceed 0, min={min}");
423 }
424}