Skip to main content

gizmo_renderer/
animation_state_machine.rs

1use crate::animation::AnimationClip;
2use std::sync::Arc;
3
4/// A single state in the animation state machine — names one clip.
5#[derive(Clone, Debug)]
6pub struct AnimationState {
7    pub name: String,
8    pub clip_index: usize,
9    pub looped: bool,
10    pub speed: f32,
11}
12
13/// A directed transition between two named states.
14#[derive(Clone, Debug)]
15pub struct AnimationTransition {
16    /// Source state name (`"*"` matches any state).
17    pub from: String,
18    /// Destination state name.
19    pub to: String,
20    /// Cross-fade duration in seconds.
21    pub blend_duration: f32,
22    /// Optional trigger string that activates this transition.
23    /// If `None` the transition fires automatically when the source clip ends
24    /// (only meaningful when `has_exit_time` is `true`).
25    pub trigger: Option<String>,
26    /// When `true` the transition may only start once the source clip has
27    /// finished at least one full play-through.
28    pub has_exit_time: bool,
29}
30
31/// Per-entity state tracked while a cross-fade blend is in progress.
32#[derive(Clone, Debug)]
33pub struct ActiveBlend {
34    pub from_clip: usize,
35    pub to_clip: usize,
36    /// Time into the source clip at the moment the blend started.
37    pub from_time: f32,
38    /// Time into the destination clip (advances each frame).
39    pub to_time: f32,
40    /// Seconds elapsed since blend began.
41    pub elapsed: f32,
42    pub duration: f32,
43    pub to_state: String,
44    pub to_looped: bool,
45    pub to_speed: f32,
46}
47
48impl ActiveBlend {
49    /// Blend weight: 0.0 = fully source, 1.0 = fully destination.
50    #[inline]
51    pub fn alpha(&self) -> f32 {
52        if self.duration <= 0.0 {
53            1.0
54        } else {
55            (self.elapsed / self.duration).clamp(0.0, 1.0)
56        }
57    }
58}
59
60/// ECS component — full animation state machine with cross-fade blending.
61///
62/// # Usage
63/// ```ignore
64/// let mut fsm = AnimationStateMachine::new(
65///     "idle",
66///     clips,
67///     vec![
68///         AnimationState { name: "idle".into(), clip_index: 0, looped: true, speed: 1.0 },
69///         AnimationState { name: "run".into(),  clip_index: 1, looped: true, speed: 1.2 },
70///         AnimationState { name: "jump".into(), clip_index: 2, looped: false, speed: 1.0 },
71///     ],
72///     vec![
73///         AnimationTransition { from: "idle".into(), to: "run".into(),  blend_duration: 0.2, trigger: Some("run".into()),  has_exit_time: false },
74///         AnimationTransition { from: "run".into(),  to: "idle".into(), blend_duration: 0.3, trigger: Some("stop".into()), has_exit_time: false },
75///         AnimationTransition { from: "*".into(),    to: "jump".into(), blend_duration: 0.1, trigger: Some("jump".into()), has_exit_time: false },
76///     ],
77/// );
78/// fsm.trigger("run");
79/// ```
80#[derive(Clone)]
81pub struct AnimationStateMachine {
82    pub clips: Arc<[AnimationClip]>,
83    pub states: Vec<AnimationState>,
84    pub transitions: Vec<AnimationTransition>,
85    pub current_state: String,
86    pub current_time: f32,
87    pub active_blend: Option<ActiveBlend>,
88    pending_triggers: Vec<String>,
89}
90
91impl AnimationStateMachine {
92    pub fn new(
93        initial_state: &str,
94        clips: Arc<[AnimationClip]>,
95        states: Vec<AnimationState>,
96        transitions: Vec<AnimationTransition>,
97    ) -> Self {
98        Self {
99            clips,
100            states,
101            transitions,
102            current_state: initial_state.to_string(),
103            current_time: 0.0,
104            active_blend: None,
105            pending_triggers: Vec::new(),
106        }
107    }
108
109    /// Queue a trigger to be evaluated on the next `animation_state_machine_update`.
110    pub fn trigger(&mut self, name: &str) {
111        self.pending_triggers.push(name.to_string());
112    }
113
114    /// Drain and return all pending triggers (consumed by the update system).
115    pub fn drain_triggers(&mut self) -> Vec<String> {
116        self.pending_triggers.drain(..).collect()
117    }
118
119    // ── helpers ──────────────────────────────────────────────────────────────
120
121    pub fn find_state(&self, name: &str) -> Option<&AnimationState> {
122        self.states.iter().find(|s| s.name == name)
123    }
124
125    pub fn current_clip_index(&self) -> Option<usize> {
126        self.find_state(&self.current_state).map(|s| s.clip_index)
127    }
128
129    pub fn current_clip_duration(&self) -> f32 {
130        self.current_clip_index()
131            .and_then(|i| self.clips.get(i))
132            .map(|c| c.duration)
133            .unwrap_or(1.0)
134    }
135
136    pub fn current_speed(&self) -> f32 {
137        self.find_state(&self.current_state)
138            .map(|s| s.speed)
139            .unwrap_or(1.0)
140    }
141
142    pub fn is_current_looped(&self) -> bool {
143        self.find_state(&self.current_state)
144            .map(|s| s.looped)
145            .unwrap_or(true)
146    }
147
148    /// Find the first matching transition (trigger or exit-time based).
149    pub fn find_transition(
150        &self,
151        from: &str,
152        trigger: Option<&str>,
153        clip_finished: bool,
154    ) -> Option<&AnimationTransition> {
155        self.transitions.iter().find(|tr| {
156            // Source must match current state or wildcard
157            let from_matches = tr.from == from || tr.from == "*";
158            if !from_matches {
159                return false;
160            }
161
162            // Trigger-based transition
163            if let Some(ref req) = tr.trigger {
164                if let Some(t) = trigger {
165                    return t == req;
166                }
167                return false;
168            }
169            // Auto / exit-time transition
170            if tr.has_exit_time {
171                clip_finished
172            } else {
173                false
174            }
175        })
176    }
177}