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}