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}