Skip to main content

sequoia_animation/
machine.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crate::animation::{Animation, AnimationFrame};
5use crate::mixer::Mixer;
6use crate::value::Value;
7
8#[derive(Clone, Debug, PartialEq)]
9#[non_exhaustive]
10pub enum MachineState {
11    Entry,
12
13    Exit,
14
15    Animation {
16        name: String,
17        animation: String,
18    },
19
20    Single {
21        name: String,
22        properties: HashMap<String, Value>,
23    },
24}
25
26impl MachineState {
27    pub fn entry() -> Self {
28        Self::Entry
29    }
30    pub fn exit() -> Self {
31        Self::Exit
32    }
33    pub fn animation(name: impl Into<String>, animation: impl Into<String>) -> Self {
34        Self::Animation {
35            name: name.into(),
36            animation: animation.into(),
37        }
38    }
39    pub fn single(name: impl Into<String>, properties: HashMap<String, Value>) -> Self {
40        Self::Single {
41            name: name.into(),
42            properties,
43        }
44    }
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum FromState {
50    Entry,
51
52    Any,
53
54    State(String),
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum ToState {
60    Exit,
61
62    State(String),
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum CmpOp {
68    Eq,
69    Ne,
70    Lt,
71    Le,
72    Gt,
73    Ge,
74}
75
76impl CmpOp {
77    fn compare(self, a: f32, b: f32) -> bool {
78        match self {
79            Self::Eq => a == b,
80            Self::Ne => a != b,
81            Self::Lt => a < b,
82            Self::Le => a <= b,
83            Self::Gt => a > b,
84            Self::Ge => a >= b,
85        }
86    }
87}
88
89#[derive(Clone, Debug, PartialEq)]
90#[non_exhaustive]
91pub enum Condition {
92    Trigger(String),
93
94    BooleanEquals(String, bool),
95
96    NumberCompare(String, CmpOp, f32),
97
98    AfterSeconds(f64),
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct Transition {
103    pub from: FromState,
104    pub to: ToState,
105    pub conditions: Vec<Condition>,
106
107    pub mix_seconds: f64,
108}
109
110impl Transition {
111    pub fn new(from: FromState, to: ToState, conditions: Vec<Condition>) -> Self {
112        Self {
113            from,
114            to,
115            conditions,
116            mix_seconds: 0.0,
117        }
118    }
119    pub fn with_mix(mut self, secs: f64) -> Self {
120        self.mix_seconds = secs;
121        self
122    }
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
126#[non_exhaustive]
127pub enum MachineError {
128    NoStates,
129
130    MissingEntry,
131
132    DuplicateEntryOrExit,
133
134    DuplicateState(String),
135
136    UnknownState(String),
137
138    UnknownAnimation(String),
139}
140
141impl fmt::Display for MachineError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::NoStates => write!(f, "state machine has no states"),
145            Self::MissingEntry => write!(f, "state machine has no entry state"),
146            Self::DuplicateEntryOrExit => {
147                write!(f, "state machine has more than one entry or exit state")
148            }
149            Self::DuplicateState(name) => write!(f, "duplicate state `{name}`"),
150            Self::UnknownState(name) => write!(f, "transition references unknown state `{name}`"),
151            Self::UnknownAnimation(name) => {
152                write!(f, "animation state references unknown animation `{name}`")
153            }
154        }
155    }
156}
157
158impl std::error::Error for MachineError {}
159
160#[derive(Clone, Debug, Default)]
161pub struct MachineInputs {
162    triggers: HashMap<String, bool>,
163    pub booleans: HashMap<String, bool>,
164    pub numbers: HashMap<String, f32>,
165}
166
167impl MachineInputs {
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    pub fn fire(&mut self, name: impl Into<String>) {
173        self.triggers.insert(name.into(), true);
174    }
175
176    pub fn set_boolean(&mut self, name: impl Into<String>, value: bool) {
177        self.booleans.insert(name.into(), value);
178    }
179
180    pub fn set_number(&mut self, name: impl Into<String>, value: f32) {
181        self.numbers.insert(name.into(), value);
182    }
183
184    fn trigger_fired(&self, name: &str) -> bool {
185        self.triggers.get(name).copied().unwrap_or(false)
186    }
187
188    fn clear_triggers(&mut self) {
189        self.triggers.clear();
190    }
191}
192
193#[derive(Clone, Copy, Debug, PartialEq)]
194struct Layer {
195    state: usize,
196
197    time: f64,
198
199    state_time: f64,
200}
201
202impl Layer {
203    fn new(state: usize) -> Self {
204        Self {
205            state,
206            time: 0.0,
207            state_time: 0.0,
208        }
209    }
210}
211
212#[derive(Clone, Copy, Debug)]
213struct Mix {
214    from: Layer,
215    to: Layer,
216    elapsed: f64,
217    duration: f64,
218}
219
220#[derive(Clone, Debug)]
221pub struct StateMachine {
222    states: Vec<MachineState>,
223    transitions: Vec<Transition>,
224    animations: HashMap<String, Animation>,
225    current: Option<Layer>,
226    mix: Option<Mix>,
227    frame: AnimationFrame,
228    inputs: MachineInputs,
229    state_name: String,
230}
231
232impl StateMachine {
233    pub fn try_new(
234        states: Vec<MachineState>,
235        transitions: Vec<Transition>,
236        animations: HashMap<String, Animation>,
237    ) -> Result<Self, MachineError> {
238        if states.is_empty() {
239            return Err(MachineError::NoStates);
240        }
241        let mut named: HashMap<&str, ()> = HashMap::new();
242        let mut entries = 0usize;
243        let mut exits = 0usize;
244        for state in &states {
245            match state {
246                MachineState::Entry => entries += 1,
247                MachineState::Exit => exits += 1,
248                MachineState::Animation { name, animation } => {
249                    if named.insert(name.as_str(), ()).is_some() {
250                        return Err(MachineError::DuplicateState(name.clone()));
251                    }
252                    if !animations.contains_key(animation) {
253                        return Err(MachineError::UnknownAnimation(animation.clone()));
254                    }
255                }
256                MachineState::Single { name, .. } => {
257                    if named.insert(name.as_str(), ()).is_some() {
258                        return Err(MachineError::DuplicateState(name.clone()));
259                    }
260                }
261            }
262        }
263        if entries == 0 {
264            return Err(MachineError::MissingEntry);
265        }
266        if entries > 1 || exits > 1 {
267            return Err(MachineError::DuplicateEntryOrExit);
268        }
269        for transition in &transitions {
270            match (&transition.from, &transition.to) {
271                (FromState::State(name), _) if !named.contains_key(name.as_str()) => {
272                    return Err(MachineError::UnknownState(name.clone()));
273                }
274                (_, ToState::State(name)) if !named.contains_key(name.as_str()) => {
275                    return Err(MachineError::UnknownState(name.clone()));
276                }
277                _ => {}
278            }
279        }
280        Ok(Self {
281            states,
282            transitions,
283            animations,
284            current: None,
285            mix: None,
286            frame: AnimationFrame::new(),
287            inputs: MachineInputs::new(),
288            state_name: "entry".to_string(),
289        })
290    }
291
292    pub fn fire(&mut self, trigger: impl Into<String>) {
293        self.inputs.fire(trigger);
294    }
295    pub fn set_boolean(&mut self, name: impl Into<String>, value: bool) {
296        self.inputs.set_boolean(name, value);
297    }
298    pub fn set_number(&mut self, name: impl Into<String>, value: f32) {
299        self.inputs.set_number(name, value);
300    }
301    pub fn inputs(&mut self) -> &mut MachineInputs {
302        &mut self.inputs
303    }
304
305    pub fn advance(&mut self, dt: f64) -> bool {
306        let dt = dt.max(0.0);
307
308        if self.current.is_none()
309            && self.mix.is_none()
310            && let Some(entry) = self
311                .states
312                .iter()
313                .position(|s| matches!(s, MachineState::Entry))
314        {
315            self.current = Some(Layer::new(entry));
316        }
317
318        if let Some(mix) = self.mix.as_mut() {
319            mix.elapsed += dt;
320            advance_layer(&self.states, &self.animations, &mut mix.from, dt);
321            advance_layer(&self.states, &self.animations, &mut mix.to, dt);
322        }
323        if let Some(current) = self.current.as_mut() {
324            advance_layer(&self.states, &self.animations, current, dt);
325        }
326
327        if let Some(mix) = self.mix
328            && mix.elapsed >= mix.duration.max(1e-9)
329        {
330            self.current = Some(mix.to);
331            self.mix = None;
332        }
333
334        if self.mix.is_none()
335            && let Some(idx) = self.evaluate_transition()
336        {
337            self.apply_transition(idx);
338        }
339        self.frame = self.blend_output();
340        self.state_name = self
341            .source_layer()
342            .map(|layer| self.state_name_of(Some(layer)))
343            .unwrap_or_else(|| "entry".to_string());
344        self.is_active()
345    }
346
347    pub fn frame(&self) -> &AnimationFrame {
348        &self.frame
349    }
350
351    pub fn current_state(&self) -> &str {
352        &self.state_name
353    }
354
355    pub fn is_active(&self) -> bool {
356        if self.mix.is_some() {
357            return true;
358        }
359        match self.current {
360            None => false,
361            Some(layer) => match &self.states[layer.state] {
362                MachineState::Entry | MachineState::Exit | MachineState::Single { .. } => false,
363                MachineState::Animation { animation, .. } => match self.animations.get(animation) {
364                    None => false,
365                    Some(a) => match a.loop_mode() {
366                        crate::animation::LoopMode::Loop | crate::animation::LoopMode::PingPong => {
367                            true
368                        }
369                        crate::animation::LoopMode::Once => !a.is_finished_at(layer.time),
370                    },
371                },
372            },
373        }
374    }
375
376    fn source_layer(&self) -> Option<Layer> {
377        self.mix.map(|m| m.from).or(self.current)
378    }
379
380    fn evaluate_transition(&self) -> Option<usize> {
381        let source = self.source_layer()?;
382        let in_entry = matches!(&self.states[source.state], MachineState::Entry);
383        let in_exit = matches!(&self.states[source.state], MachineState::Exit);
384        if in_exit {
385            return None;
386        }
387        if in_entry {
388            for (i, t) in self.transitions.iter().enumerate() {
389                if matches!(t.from, FromState::Entry) && self.conditions_hold(t, source) {
390                    return Some(i);
391                }
392            }
393        }
394        for (i, t) in self.transitions.iter().enumerate() {
395            if matches!(t.from, FromState::Any) && self.conditions_hold(t, source) {
396                return Some(i);
397            }
398        }
399        if let Some(name) = state_name_of(&self.states[source.state]) {
400            for (i, t) in self.transitions.iter().enumerate() {
401                if matches!(&t.from, FromState::State(n) if n == name)
402                    && self.conditions_hold(t, source)
403                {
404                    return Some(i);
405                }
406            }
407        }
408        None
409    }
410
411    fn conditions_hold(&self, transition: &Transition, source: Layer) -> bool {
412        transition.conditions.iter().all(|c| match c {
413            Condition::Trigger(name) => self.inputs.trigger_fired(name),
414            Condition::BooleanEquals(name, want) => {
415                self.inputs.booleans.get(name).copied() == Some(*want)
416            }
417            Condition::NumberCompare(name, op, want) => {
418                let value = self.inputs.numbers.get(name).copied().unwrap_or(0.0);
419                op.compare(value, *want)
420            }
421            Condition::AfterSeconds(secs) => source.state_time >= *secs,
422        })
423    }
424
425    fn apply_transition(&mut self, idx: usize) {
426        let transition = self.transitions[idx].clone();
427        let Some(target) = self.resolve_state(&transition.to) else {
428            return;
429        };
430
431        self.inputs.clear_triggers();
432        let to = Layer::new(target);
433        if transition.mix_seconds > 0.0 {
434            let from = self.source_layer().unwrap_or(to);
435            self.mix = Some(Mix {
436                from,
437                to,
438                elapsed: 0.0,
439                duration: transition.mix_seconds,
440            });
441            self.current = None;
442        } else {
443            self.current = Some(to);
444            self.mix = None;
445        }
446    }
447
448    fn resolve_state(&self, to: &ToState) -> Option<usize> {
449        match to {
450            ToState::Exit => self
451                .states
452                .iter()
453                .position(|s| matches!(s, MachineState::Exit)),
454            ToState::State(name) => self
455                .states
456                .iter()
457                .position(|s| state_name_of(s) == Some(name.as_str())),
458        }
459    }
460
461    fn sample_layer(&self, layer: Layer) -> AnimationFrame {
462        match &self.states[layer.state] {
463            MachineState::Entry | MachineState::Exit => AnimationFrame::new(),
464            MachineState::Single { properties, .. } => AnimationFrame {
465                properties: properties.clone(),
466            },
467            MachineState::Animation { animation, .. } => self
468                .animations
469                .get(animation)
470                .map(|a| a.sample(layer.time))
471                .unwrap_or_default(),
472        }
473    }
474
475    fn blend_output(&self) -> AnimationFrame {
476        match (self.current, self.mix) {
477            (None, None) => AnimationFrame::new(),
478            (Some(current), None) => self.sample_layer(current),
479            (None, Some(mix)) => {
480                let w = (mix.elapsed / mix.duration.max(1e-9)).min(1.0) as f32;
481                let from = self.sample_layer(mix.from);
482                let to = self.sample_layer(mix.to);
483                Mixer::mix_frames([(&from, 1.0 - w), (&to, w)])
484            }
485            (Some(_), Some(_)) => AnimationFrame::new(),
486        }
487    }
488
489    fn state_name_of(&self, current: Option<Layer>) -> String {
490        match current {
491            None => "entry".to_string(),
492            Some(layer) => match &self.states[layer.state] {
493                MachineState::Entry => "entry".to_string(),
494                MachineState::Exit => "exit".to_string(),
495                MachineState::Animation { name, .. } | MachineState::Single { name, .. } => {
496                    name.clone()
497                }
498            },
499        }
500    }
501}
502
503fn advance_layer(
504    states: &[MachineState],
505    animations: &HashMap<String, Animation>,
506    layer: &mut Layer,
507    dt: f64,
508) {
509    let speed = match &states[layer.state] {
510        MachineState::Animation { animation, .. } => animations
511            .get(animation)
512            .map(|a| f64::from(a.speed()))
513            .unwrap_or(1.0),
514        _ => 1.0,
515    };
516    layer.time += dt * speed;
517    layer.state_time += dt;
518}
519
520fn state_name_of(state: &MachineState) -> Option<&str> {
521    match state {
522        MachineState::Animation { name, .. } | MachineState::Single { name, .. } => {
523            Some(name.as_str())
524        }
525        MachineState::Entry | MachineState::Exit => None,
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::animation::{Keyframe, LoopMode, Track};
533
534    fn anim(name: &str, from: f32, to: f32) -> Animation {
535        Animation::new(name, 1.0).with_track(
536            "x",
537            Track::try_new(vec![
538                Keyframe::new(0.0, Value::px(from)),
539                Keyframe::new(1.0, Value::px(to)),
540            ])
541            .unwrap(),
542        )
543    }
544
545    fn registry() -> HashMap<String, Animation> {
546        HashMap::from([
547            ("idle".to_string(), anim("idle", 0.0, 0.0)),
548            ("wave".to_string(), anim("wave", 0.0, 100.0)),
549            ("spin".to_string(), anim("spin", 0.0, 360.0)),
550        ])
551    }
552
553    fn base_machine() -> StateMachine {
554        StateMachine::try_new(
555            vec![
556                MachineState::entry(),
557                MachineState::animation("idle", "idle"),
558                MachineState::animation("wave", "wave"),
559            ],
560            vec![
561                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
562                Transition::new(
563                    FromState::State("idle".into()),
564                    ToState::State("wave".into()),
565                    vec![Condition::Trigger("go".into())],
566                )
567                .with_mix(0.5),
568            ],
569            registry(),
570        )
571        .expect("valid machine")
572    }
573
574    #[test]
575    fn invalid_machines_are_rejected() {
576        assert_eq!(
577            StateMachine::try_new(vec![], vec![], HashMap::new()).err(),
578            Some(MachineError::NoStates)
579        );
580        assert_eq!(
581            StateMachine::try_new(
582                vec![MachineState::animation("a", "idle")],
583                vec![],
584                registry()
585            )
586            .err(),
587            Some(MachineError::MissingEntry)
588        );
589        assert_eq!(
590            StateMachine::try_new(
591                vec![
592                    MachineState::entry(),
593                    MachineState::animation("idle", "missing"),
594                ],
595                vec![],
596                registry()
597            )
598            .err(),
599            Some(MachineError::UnknownAnimation("missing".into()))
600        );
601        assert_eq!(
602            StateMachine::try_new(
603                vec![
604                    MachineState::entry(),
605                    MachineState::animation("idle", "idle")
606                ],
607                vec![Transition::new(
608                    FromState::Entry,
609                    ToState::State("nope".into()),
610                    vec![]
611                )],
612                registry()
613            )
614            .err(),
615            Some(MachineError::UnknownState("nope".into()))
616        );
617        assert_eq!(
618            StateMachine::try_new(
619                vec![
620                    MachineState::entry(),
621                    MachineState::entry(),
622                    MachineState::animation("idle", "idle"),
623                ],
624                vec![],
625                registry()
626            )
627            .err(),
628            Some(MachineError::DuplicateEntryOrExit)
629        );
630    }
631
632    #[test]
633    fn entry_transition_starts_playback() {
634        let mut sm = base_machine();
635        sm.advance(0.0);
636        assert_eq!(sm.current_state(), "idle");
637        assert!(sm.is_active(), "once animation is not finished");
638    }
639
640    #[test]
641    fn trigger_fires_transition() {
642        let mut sm = base_machine();
643        sm.advance(0.0);
644        assert_eq!(sm.current_state(), "idle");
645
646        sm.fire("go");
647        sm.advance(0.0);
648        assert_eq!(sm.current_state(), "idle", "still mixing");
649        assert!(sm.is_active(), "crossfade is in flight");
650
651        sm.advance(0.6);
652        sm.advance(0.01);
653        assert_eq!(sm.current_state(), "wave");
654    }
655
656    #[test]
657    fn trigger_is_consumed_after_firing() {
658        let mut sm = base_machine();
659        sm.advance(0.0);
660        sm.fire("go");
661        sm.advance(0.6);
662        sm.advance(0.6);
663        assert_eq!(sm.current_state(), "wave");
664
665        sm.advance(0.1);
666        assert_eq!(sm.current_state(), "wave");
667    }
668
669    #[test]
670    fn boolean_and_number_conditions_gate_transitions() {
671        let mut sm = StateMachine::try_new(
672            vec![
673                MachineState::entry(),
674                MachineState::animation("idle", "idle"),
675                MachineState::animation("wave", "wave"),
676                MachineState::animation("spin", "spin"),
677            ],
678            vec![
679                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
680                Transition::new(
681                    FromState::State("idle".into()),
682                    ToState::State("wave".into()),
683                    vec![Condition::BooleanEquals("enabled".into(), true)],
684                ),
685                Transition::new(
686                    FromState::State("idle".into()),
687                    ToState::State("spin".into()),
688                    vec![Condition::NumberCompare("level".into(), CmpOp::Gt, 3.0)],
689                ),
690            ],
691            registry(),
692        )
693        .expect("valid");
694
695        sm.advance(0.0);
696        sm.set_boolean("enabled", false);
697        sm.advance(0.1);
698        assert_eq!(sm.current_state(), "idle");
699
700        sm.set_boolean("enabled", true);
701        sm.advance(0.1);
702        assert_eq!(sm.current_state(), "wave");
703
704        let mut sm2 = StateMachine::try_new(sm.states.clone(), sm.transitions.clone(), registry())
705            .expect("valid");
706        sm2.advance(0.0);
707        sm2.set_number("level", 5.0);
708        sm2.advance(0.1);
709        assert_eq!(sm2.current_state(), "spin");
710    }
711
712    #[test]
713    fn any_transition_overrides_state_transitions() {
714        let mut sm = StateMachine::try_new(
715            vec![
716                MachineState::entry(),
717                MachineState::animation("idle", "idle"),
718                MachineState::animation("wave", "wave"),
719            ],
720            vec![
721                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
722                Transition::new(
723                    FromState::Any,
724                    ToState::State("wave".into()),
725                    vec![Condition::Trigger("panic".into())],
726                ),
727            ],
728            registry(),
729        )
730        .expect("valid");
731        sm.advance(0.0);
732        assert_eq!(sm.current_state(), "idle");
733        sm.fire("panic");
734        sm.advance(0.1);
735        assert_eq!(sm.current_state(), "wave");
736    }
737
738    #[test]
739    fn after_seconds_auto_fires() {
740        let mut sm = StateMachine::try_new(
741            vec![
742                MachineState::entry(),
743                MachineState::animation("idle", "idle"),
744                MachineState::animation("wave", "wave"),
745            ],
746            vec![
747                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
748                Transition::new(
749                    FromState::State("idle".into()),
750                    ToState::State("wave".into()),
751                    vec![Condition::AfterSeconds(2.0)],
752                )
753                .with_mix(0.1),
754            ],
755            registry(),
756        )
757        .expect("valid");
758        sm.advance(0.5);
759        sm.advance(0.5);
760        assert_eq!(sm.current_state(), "idle", "1s total, gate is 2s");
761        sm.advance(1.0);
762        assert_eq!(sm.current_state(), "idle", "1.5s in idle, gate is 2s");
763        sm.advance(0.5);
764        assert_eq!(
765            sm.current_state(),
766            "idle",
767            "2s reached: transition just fired, still mixing from idle"
768        );
769        sm.advance(0.2);
770        assert_eq!(sm.current_state(), "wave");
771    }
772
773    #[test]
774    fn crossfade_completes_with_small_steps() {
775        let mut sm = StateMachine::try_new(
776            vec![
777                MachineState::entry(),
778                MachineState::animation("idle", "idle"),
779                MachineState::animation("wave", "wave"),
780            ],
781            vec![
782                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
783                Transition::new(
784                    FromState::State("idle".into()),
785                    ToState::State("wave".into()),
786                    vec![Condition::AfterSeconds(1.0)],
787                )
788                .with_mix(0.5),
789            ],
790            registry(),
791        )
792        .expect("valid");
793        for _ in 0..75 {
794            sm.advance(0.016);
795        }
796        for _ in 0..64 {
797            sm.advance(0.016);
798        }
799        assert_eq!(sm.current_state(), "wave");
800        assert!(
801            sm.mix.is_none(),
802            "crossfade must complete, not restart every frame"
803        );
804    }
805
806    #[test]
807    fn crossfade_blends_property_values() {
808        let mut sm = base_machine();
809        sm.advance(0.0);
810        assert_eq!(sm.frame().get("x"), Some(&Value::px(0.0)));
811
812        sm.fire("go");
813        sm.advance(0.0);
814        sm.advance(0.125);
815        let x = sm.frame().get("x").copied().unwrap();
816        assert!(
817            matches!(x, Value::Px(v) if v > 0.0 && v < 100.0),
818            "blended value should sit between idle and wave: {x:?}"
819        );
820
821        sm.advance(0.5);
822        assert_eq!(sm.current_state(), "wave");
823        sm.advance(1.0);
824        assert_eq!(sm.frame().get("x"), Some(&Value::px(100.0)));
825    }
826
827    #[test]
828    fn exit_state_stops_playback() {
829        let mut sm = StateMachine::try_new(
830            vec![
831                MachineState::entry(),
832                MachineState::animation("idle", "idle"),
833                MachineState::exit(),
834            ],
835            vec![
836                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
837                Transition::new(
838                    FromState::State("idle".into()),
839                    ToState::Exit,
840                    vec![Condition::Trigger("stop".into())],
841                ),
842            ],
843            registry(),
844        )
845        .expect("valid");
846        sm.advance(0.0);
847        assert!(sm.is_active());
848        sm.fire("stop");
849        sm.advance(0.1);
850        assert_eq!(sm.current_state(), "exit");
851        assert!(!sm.is_active());
852        assert!(sm.frame().is_empty());
853    }
854
855    #[test]
856    fn single_state_outputs_static_properties() {
857        let mut sm = StateMachine::try_new(
858            vec![
859                MachineState::entry(),
860                MachineState::single(
861                    "frozen",
862                    HashMap::from([("x".to_string(), Value::px(42.0))]),
863                ),
864            ],
865            vec![Transition::new(
866                FromState::Entry,
867                ToState::State("frozen".into()),
868                vec![],
869            )],
870            registry(),
871        )
872        .expect("valid");
873        sm.advance(0.0);
874        assert_eq!(sm.current_state(), "frozen");
875        assert_eq!(sm.frame().get("x"), Some(&Value::px(42.0)));
876        assert!(!sm.is_active());
877    }
878
879    #[test]
880    fn looping_animation_stays_active() {
881        let looped = anim("idle", 0.0, 0.0).with_loop(LoopMode::Loop);
882        let mut sm = StateMachine::try_new(
883            vec![
884                MachineState::entry(),
885                MachineState::animation("idle", "idle"),
886            ],
887            vec![Transition::new(
888                FromState::Entry,
889                ToState::State("idle".into()),
890                vec![],
891            )],
892            HashMap::from([("idle".to_string(), looped)]),
893        )
894        .expect("valid");
895        sm.advance(0.0);
896        sm.advance(100.0);
897        assert_eq!(sm.current_state(), "idle");
898        assert!(sm.is_active(), "looping animation never finishes");
899    }
900
901    #[test]
902    fn once_animation_goes_inactive_when_done() {
903        let mut sm = base_machine();
904        sm.advance(0.0);
905        sm.advance(2.0);
906        assert_eq!(sm.current_state(), "idle");
907        assert!(!sm.is_active());
908    }
909
910    #[test]
911    fn speed_scales_playback_time() {
912        let fast = anim("idle", 0.0, 0.0).with_speed(2.0);
913        let mut sm = StateMachine::try_new(
914            vec![
915                MachineState::entry(),
916                MachineState::animation("idle", "idle"),
917            ],
918            vec![Transition::new(
919                FromState::Entry,
920                ToState::State("idle".into()),
921                vec![],
922            )],
923            HashMap::from([("idle".to_string(), fast)]),
924        )
925        .expect("valid");
926        sm.advance(0.0);
927        sm.advance(0.5);
928        assert!(!sm.is_active());
929    }
930}