1#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq)]
14pub enum TransitionCondition {
15 Always,
17 AfterSeconds(f32),
20 ParameterAbove(String, f32),
22 ParameterBelow(String, f32),
24 ParameterEqual(String, f32),
26 AtEnd,
29}
30
31#[allow(dead_code)]
37pub struct MotionTransition {
38 pub from_state: String,
40 pub to_state: String,
42 pub condition: TransitionCondition,
44 pub blend_duration: f32,
46 pub priority: i32,
48}
49
50#[allow(dead_code)]
56pub struct MotionState {
57 pub name: String,
59 pub clip_name: String,
61 pub duration: f32,
63 pub loop_state: bool,
65 pub speed: f32,
67 pub morph_weights: HashMap<String, f32>,
69}
70
71impl MotionState {
72 pub fn new(name: impl Into<String>, clip_name: impl Into<String>, duration: f32) -> Self {
74 Self {
75 name: name.into(),
76 clip_name: clip_name.into(),
77 duration,
78 loop_state: true,
79 speed: 1.0,
80 morph_weights: HashMap::new(),
81 }
82 }
83
84 pub fn with_loop(mut self, loop_state: bool) -> Self {
86 self.loop_state = loop_state;
87 self
88 }
89
90 pub fn with_speed(mut self, speed: f32) -> Self {
92 self.speed = speed;
93 self
94 }
95
96 pub fn with_morph(mut self, key: impl Into<String>, value: f32) -> Self {
98 self.morph_weights.insert(key.into(), value);
99 self
100 }
101}
102
103pub struct MotionGraph {
109 states: HashMap<String, MotionState>,
110 transitions: Vec<MotionTransition>,
111 pub entry_state: Option<String>,
113}
114
115impl Default for MotionGraph {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl MotionGraph {
122 pub fn new() -> Self {
124 Self {
125 states: HashMap::new(),
126 transitions: Vec::new(),
127 entry_state: None,
128 }
129 }
130
131 pub fn add_state(&mut self, state: MotionState) {
134 if self.entry_state.is_none() {
135 self.entry_state = Some(state.name.clone());
136 }
137 self.states.insert(state.name.clone(), state);
138 }
139
140 pub fn add_transition(&mut self, transition: MotionTransition) {
142 self.transitions.push(transition);
143 }
144
145 pub fn state_count(&self) -> usize {
147 self.states.len()
148 }
149
150 pub fn transition_count(&self) -> usize {
152 self.transitions.len()
153 }
154
155 pub fn get_state(&self, name: &str) -> Option<&MotionState> {
157 self.states.get(name)
158 }
159
160 pub fn transitions_from(&self, state: &str) -> Vec<&MotionTransition> {
163 let mut ts: Vec<&MotionTransition> = self
164 .transitions
165 .iter()
166 .filter(|t| t.from_state == state)
167 .collect();
168 ts.sort_by(|a, b| b.priority.cmp(&a.priority));
169 ts
170 }
171
172 pub fn default_graph() -> Self {
174 build_locomotion_graph()
175 }
176}
177
178pub struct MotionController {
184 pub graph: MotionGraph,
186 pub current_state: String,
188 pub state_time: f32,
190 pub blend_state: Option<String>,
192 pub blend_time: f32,
194 pub blend_duration: f32,
196 pub parameters: HashMap<String, f32>,
198 pub total_time: f32,
200}
201
202impl MotionController {
203 pub fn new(graph: MotionGraph) -> Self {
206 let current_state = graph
207 .entry_state
208 .clone()
209 .unwrap_or_else(|| "idle".to_string());
210 Self {
211 graph,
212 current_state,
213 state_time: 0.0,
214 blend_state: None,
215 blend_time: 0.0,
216 blend_duration: 0.0,
217 parameters: HashMap::new(),
218 total_time: 0.0,
219 }
220 }
221
222 pub fn set_parameter(&mut self, name: &str, value: f32) {
224 self.parameters.insert(name.to_string(), value);
225 }
226
227 pub fn get_parameter(&self, name: &str) -> f32 {
229 self.parameters.get(name).copied().unwrap_or(0.0)
230 }
231
232 pub fn update(&mut self, dt: f32) {
239 self.state_time += dt;
240 self.total_time += dt;
241
242 if self.blend_state.is_some() {
244 self.blend_time += dt;
245 if self.blend_time >= self.blend_duration {
246 let Some(dest) = self.blend_state.take() else {
248 return;
249 };
250 self.current_state = dest;
251 self.state_time = 0.0;
252 self.blend_time = 0.0;
253 self.blend_duration = 0.0;
254 }
255 return;
257 }
258
259 let transitions: Vec<(String, f32)> = self
261 .graph
262 .transitions_from(&self.current_state.clone())
263 .iter()
264 .filter_map(|t| {
265 if self.check_condition(&t.condition) {
266 Some((t.to_state.clone(), t.blend_duration))
267 } else {
268 None
269 }
270 })
271 .collect();
272
273 if let Some((to_state, blend_dur)) = transitions.into_iter().next() {
274 self.transition_to(&to_state, blend_dur);
275 }
276 }
277
278 pub fn transition_to(&mut self, state: &str, blend_duration: f32) {
281 if blend_duration <= 0.0 {
282 self.current_state = state.to_string();
283 self.state_time = 0.0;
284 self.blend_state = None;
285 self.blend_time = 0.0;
286 self.blend_duration = 0.0;
287 } else {
288 self.blend_state = Some(state.to_string());
289 self.blend_time = 0.0;
290 self.blend_duration = blend_duration;
291 }
292 }
293
294 pub fn blend_weight(&self) -> f32 {
301 if self.blend_duration <= 0.0 {
302 return 0.0;
303 }
304 (self.blend_time / self.blend_duration).clamp(0.0, 1.0)
305 }
306
307 pub fn evaluate_morphs(&self) -> HashMap<String, f32> {
313 let current_morphs = self
314 .graph
315 .get_state(&self.current_state)
316 .map(|s| s.morph_weights.clone())
317 .unwrap_or_default();
318
319 match &self.blend_state {
320 None => current_morphs,
321 Some(dest_name) => {
322 let dest_morphs = self
323 .graph
324 .get_state(dest_name)
325 .map(|s| s.morph_weights.clone())
326 .unwrap_or_default();
327 blend_morph_maps(¤t_morphs, &dest_morphs, self.blend_weight())
328 }
329 }
330 }
331
332 pub fn check_condition(&self, cond: &TransitionCondition) -> bool {
334 match cond {
335 TransitionCondition::Always => true,
336 TransitionCondition::AfterSeconds(secs) => self.state_time >= *secs,
337 TransitionCondition::ParameterAbove(name, threshold) => {
338 self.get_parameter(name) > *threshold
339 }
340 TransitionCondition::ParameterBelow(name, threshold) => {
341 self.get_parameter(name) < *threshold
342 }
343 TransitionCondition::ParameterEqual(name, threshold) => {
344 (self.get_parameter(name) - threshold).abs() <= 0.05
345 }
346 TransitionCondition::AtEnd => {
347 if let Some(state) = self.graph.get_state(&self.current_state) {
348 let effective_dur = if state.speed > 0.0 {
349 state.duration / state.speed
350 } else {
351 f32::MAX
352 };
353 self.state_time >= effective_dur
354 } else {
355 false
356 }
357 }
358 }
359 }
360
361 pub fn is_blending(&self) -> bool {
363 self.blend_state.is_some()
364 }
365
366 pub fn current_state_name(&self) -> &str {
368 &self.current_state
369 }
370}
371
372pub fn build_locomotion_graph() -> MotionGraph {
381 let mut graph = MotionGraph::new();
382
383 let idle = MotionState::new("idle", "anim_idle", 2.0)
385 .with_loop(true)
386 .with_morph("body_relaxed", 1.0)
387 .with_morph("arms_down", 1.0);
388
389 let walk = MotionState::new("walk", "anim_walk", 1.2)
390 .with_loop(true)
391 .with_morph("body_relaxed", 0.5)
392 .with_morph("arms_swing", 0.8);
393
394 let run = MotionState::new("run", "anim_run", 0.8)
395 .with_loop(true)
396 .with_morph("body_tense", 0.7)
397 .with_morph("arms_swing", 1.0);
398
399 let land = MotionState::new("land", "anim_land", 0.5)
400 .with_loop(false)
401 .with_morph("legs_bent", 1.0);
402
403 graph.add_state(idle);
404 graph.add_state(walk);
405 graph.add_state(run);
406 graph.add_state(land);
407
408 graph.add_transition(MotionTransition {
411 from_state: "idle".into(),
412 to_state: "walk".into(),
413 condition: TransitionCondition::ParameterAbove("speed".into(), 0.5),
414 blend_duration: 0.3,
415 priority: 0,
416 });
417
418 graph.add_transition(MotionTransition {
420 from_state: "walk".into(),
421 to_state: "idle".into(),
422 condition: TransitionCondition::ParameterBelow("speed".into(), 0.3),
423 blend_duration: 0.4,
424 priority: 0,
425 });
426
427 graph.add_transition(MotionTransition {
429 from_state: "walk".into(),
430 to_state: "run".into(),
431 condition: TransitionCondition::ParameterAbove("speed".into(), 3.0),
432 blend_duration: 0.3,
433 priority: 1,
434 });
435
436 graph.add_transition(MotionTransition {
438 from_state: "run".into(),
439 to_state: "walk".into(),
440 condition: TransitionCondition::ParameterBelow("speed".into(), 2.5),
441 blend_duration: 0.4,
442 priority: 0,
443 });
444
445 graph.add_transition(MotionTransition {
447 from_state: "land".into(),
448 to_state: "idle".into(),
449 condition: TransitionCondition::AtEnd,
450 blend_duration: 0.2,
451 priority: 0,
452 });
453
454 graph
455}
456
457pub fn build_expression_graph() -> MotionGraph {
462 let mut graph = MotionGraph::new();
463
464 let neutral = MotionState::new("neutral", "expr_neutral", 1.0)
465 .with_loop(true)
466 .with_morph("mouth_closed", 1.0)
467 .with_morph("brow_neutral", 1.0);
468
469 let happy = MotionState::new("happy", "expr_happy", 1.0)
470 .with_loop(true)
471 .with_morph("mouth_smile", 1.0)
472 .with_morph("cheeks_raised", 0.7)
473 .with_morph("brow_raised", 0.2);
474
475 let sad = MotionState::new("sad", "expr_sad", 1.0)
476 .with_loop(true)
477 .with_morph("mouth_frown", 0.8)
478 .with_morph("brow_sad", 1.0)
479 .with_morph("eyes_half_closed", 0.5);
480
481 let angry = MotionState::new("angry", "expr_angry", 1.0)
482 .with_loop(true)
483 .with_morph("brow_furrow", 1.0)
484 .with_morph("mouth_tense", 0.6)
485 .with_morph("nostrils_flare", 0.4);
486
487 let surprised = MotionState::new("surprised", "expr_surprised", 1.0)
488 .with_loop(true)
489 .with_morph("mouth_open", 0.9)
490 .with_morph("brow_raised", 1.0)
491 .with_morph("eyes_wide", 1.0);
492
493 graph.add_state(neutral);
494 graph.add_state(happy);
495 graph.add_state(sad);
496 graph.add_state(angry);
497 graph.add_state(surprised);
498
499 for (target, lo, hi) in [
501 ("happy", 0.5_f32, 1.5_f32),
502 ("sad", 1.5, 2.5),
503 ("angry", 2.5, 3.5),
504 ("surprised", 3.5, 4.5),
505 ] {
506 let mid = (lo + hi) * 0.5;
507 graph.add_transition(MotionTransition {
508 from_state: "neutral".into(),
509 to_state: target.into(),
510 condition: TransitionCondition::ParameterEqual("emotion".into(), mid),
511 blend_duration: 0.25,
512 priority: 0,
513 });
514 }
515
516 for from in ["happy", "sad", "angry", "surprised"] {
518 graph.add_transition(MotionTransition {
519 from_state: from.into(),
520 to_state: "neutral".into(),
521 condition: TransitionCondition::ParameterEqual("emotion".into(), 0.0),
522 blend_duration: 0.35,
523 priority: 0,
524 });
525 }
526
527 graph
528}
529
530pub fn blend_morph_maps(
541 a: &HashMap<String, f32>,
542 b: &HashMap<String, f32>,
543 t: f32,
544) -> HashMap<String, f32> {
545 let t = t.clamp(0.0, 1.0);
546 let mut result: HashMap<String, f32> = HashMap::new();
547
548 for (k, &va) in a {
549 let vb = b.get(k).copied().unwrap_or(0.0);
550 result.insert(k.clone(), va * (1.0 - t) + vb * t);
551 }
552 for (k, &vb) in b {
553 if !result.contains_key(k) {
554 result.insert(k.clone(), vb * t);
555 }
556 }
557 result
558}
559
560#[cfg(test)]
565mod tests {
566 use super::*;
567 use std::fs;
568
569 fn write_tmp(name: &str, content: &str) {
570 fs::write(format!("/tmp/{}", name), content).expect("should succeed");
571 }
572
573 #[test]
575 fn test_locomotion_state_count() {
576 let g = build_locomotion_graph();
577 assert_eq!(g.state_count(), 4);
578 write_tmp(
579 "mg_locomotion_state_count.txt",
580 &g.state_count().to_string(),
581 );
582 }
583
584 #[test]
586 fn test_locomotion_transition_count() {
587 let g = build_locomotion_graph();
588 assert_eq!(g.transition_count(), 5);
589 write_tmp(
590 "mg_locomotion_transition_count.txt",
591 &g.transition_count().to_string(),
592 );
593 }
594
595 #[test]
597 fn test_entry_state() {
598 let g = build_locomotion_graph();
599 assert_eq!(g.entry_state.as_deref(), Some("idle"));
600 write_tmp("mg_entry_state.txt", "ok");
601 }
602
603 #[test]
605 fn test_get_state_clip_name() {
606 let g = build_locomotion_graph();
607 let state = g.get_state("walk").expect("should succeed");
608 assert_eq!(state.clip_name, "anim_walk");
609 write_tmp("mg_get_state_clip.txt", &state.clip_name);
610 }
611
612 #[test]
614 fn test_transitions_from_priority_order() {
615 let g = build_locomotion_graph();
616 let ts = g.transitions_from("walk");
617 assert!(ts.len() >= 2);
618 assert!(ts[0].priority >= ts[ts.len() - 1].priority);
620 write_tmp("mg_transition_priority.txt", "ok");
621 }
622
623 #[test]
625 fn test_controller_entry_state() {
626 let g = build_locomotion_graph();
627 let ctrl = MotionController::new(g);
628 assert_eq!(ctrl.current_state_name(), "idle");
629 write_tmp("mg_ctrl_entry.txt", "ok");
630 }
631
632 #[test]
634 fn test_parameter_round_trip() {
635 let g = build_locomotion_graph();
636 let mut ctrl = MotionController::new(g);
637 ctrl.set_parameter("speed", 1.5);
638 assert!((ctrl.get_parameter("speed") - 1.5).abs() < 1e-5);
639 write_tmp("mg_param_round_trip.txt", "ok");
640 }
641
642 #[test]
644 fn test_missing_parameter_default() {
645 let g = build_locomotion_graph();
646 let ctrl = MotionController::new(g);
647 assert_eq!(ctrl.get_parameter("nonexistent"), 0.0);
648 write_tmp("mg_missing_param.txt", "ok");
649 }
650
651 #[test]
653 fn test_instant_transition() {
654 let g = build_locomotion_graph();
655 let mut ctrl = MotionController::new(g);
656 ctrl.transition_to("run", 0.0);
657 assert_eq!(ctrl.current_state_name(), "run");
658 assert!(!ctrl.is_blending());
659 write_tmp("mg_instant_transition.txt", "ok");
660 }
661
662 #[test]
664 fn test_blend_transition() {
665 let g = build_locomotion_graph();
666 let mut ctrl = MotionController::new(g);
667 ctrl.transition_to("walk", 0.5);
668 assert!(ctrl.is_blending());
669 assert_eq!(ctrl.blend_state.as_deref(), Some("walk"));
670 assert!((ctrl.blend_duration - 0.5).abs() < 1e-6);
671 write_tmp("mg_blend_transition.txt", "ok");
672 }
673
674 #[test]
676 fn test_blend_finalises() {
677 let g = build_locomotion_graph();
678 let mut ctrl = MotionController::new(g);
679 ctrl.transition_to("walk", 0.3);
680 ctrl.update(0.1);
681 assert!(ctrl.is_blending());
682 ctrl.update(0.25); assert!(!ctrl.is_blending());
684 assert_eq!(ctrl.current_state_name(), "walk");
685 write_tmp("mg_blend_finalises.txt", "ok");
686 }
687
688 #[test]
690 fn test_auto_idle_to_walk() {
691 let g = build_locomotion_graph();
692 let mut ctrl = MotionController::new(g);
693 ctrl.set_parameter("speed", 1.0); ctrl.update(0.01);
695 assert_eq!(ctrl.blend_state.as_deref(), Some("walk"));
697 write_tmp("mg_auto_idle_walk.txt", "ok");
698 }
699
700 #[test]
702 fn test_blend_morphs_t0() {
703 let mut a = HashMap::new();
704 a.insert("smile".to_string(), 0.8_f32);
705 a.insert("brow".to_string(), 0.3_f32);
706 let b: HashMap<String, f32> = HashMap::new();
707 let result = blend_morph_maps(&a, &b, 0.0);
708 assert!((result["smile"] - 0.8).abs() < 1e-6);
709 assert!((result["brow"] - 0.3).abs() < 1e-6);
710 write_tmp("mg_blend_t0.txt", "ok");
711 }
712
713 #[test]
715 fn test_blend_morphs_t1() {
716 let a: HashMap<String, f32> = HashMap::new();
717 let mut b = HashMap::new();
718 b.insert("frown".to_string(), 0.9_f32);
719 let result = blend_morph_maps(&a, &b, 1.0);
720 assert!((result["frown"] - 0.9).abs() < 1e-6);
721 write_tmp("mg_blend_t1.txt", "ok");
722 }
723
724 #[test]
726 fn test_blend_morphs_midpoint() {
727 let mut a = HashMap::new();
728 a.insert("key".to_string(), 0.0_f32);
729 let mut b = HashMap::new();
730 b.insert("key".to_string(), 1.0_f32);
731 let result = blend_morph_maps(&a, &b, 0.5);
732 assert!((result["key"] - 0.5).abs() < 1e-6);
733 write_tmp("mg_blend_midpoint.txt", "ok");
734 }
735
736 #[test]
738 fn test_evaluate_morphs_blending() {
739 let g = build_locomotion_graph();
740 let mut ctrl = MotionController::new(g);
741 ctrl.transition_to("walk", 0.0);
743 ctrl.transition_to("run", 1.0);
744 ctrl.blend_time = 0.5; let morphs = ctrl.evaluate_morphs();
746 let w = morphs.get("arms_swing").copied().unwrap_or(0.0);
748 assert!((w - 0.9).abs() < 0.05, "arms_swing blend = {w}");
749 write_tmp("mg_evaluate_morphs_blend.txt", &w.to_string());
750 }
751
752 #[test]
754 fn test_condition_always() {
755 let g = build_locomotion_graph();
756 let ctrl = MotionController::new(g);
757 assert!(ctrl.check_condition(&TransitionCondition::Always));
758 write_tmp("mg_cond_always.txt", "ok");
759 }
760
761 #[test]
763 fn test_condition_after_seconds() {
764 let g = build_locomotion_graph();
765 let mut ctrl = MotionController::new(g);
766 ctrl.state_time = 0.5;
767 assert!(!ctrl.check_condition(&TransitionCondition::AfterSeconds(1.0)));
768 ctrl.state_time = 1.5;
769 assert!(ctrl.check_condition(&TransitionCondition::AfterSeconds(1.0)));
770 write_tmp("mg_cond_after_seconds.txt", "ok");
771 }
772
773 #[test]
775 fn test_condition_parameter_equal_tolerance() {
776 let g = build_locomotion_graph();
777 let mut ctrl = MotionController::new(g);
778 ctrl.set_parameter("x", 1.03);
779 assert!(ctrl.check_condition(&TransitionCondition::ParameterEqual("x".into(), 1.0)));
780 ctrl.set_parameter("x", 1.1);
781 assert!(!ctrl.check_condition(&TransitionCondition::ParameterEqual("x".into(), 1.0)));
782 write_tmp("mg_cond_param_equal.txt", "ok");
783 }
784
785 #[test]
787 fn test_condition_at_end() {
788 let g = build_locomotion_graph();
789 let mut ctrl = MotionController::new(g);
790 ctrl.transition_to("land", 0.0);
791 ctrl.state_time = 0.4;
793 assert!(!ctrl.check_condition(&TransitionCondition::AtEnd));
794 ctrl.state_time = 0.6;
795 assert!(ctrl.check_condition(&TransitionCondition::AtEnd));
796 write_tmp("mg_cond_at_end.txt", "ok");
797 }
798
799 #[test]
801 fn test_expression_graph_entry() {
802 let g = build_expression_graph();
803 assert_eq!(g.entry_state.as_deref(), Some("neutral"));
804 write_tmp("mg_expr_entry.txt", "ok");
805 }
806
807 #[test]
809 fn test_expression_graph_morph_weights() {
810 let g = build_expression_graph();
811 let happy = g.get_state("happy").expect("should succeed");
812 assert!(happy.morph_weights.contains_key("mouth_smile"));
813 write_tmp(
814 "mg_expr_morph_weights.txt",
815 &format!("{:?}", happy.morph_weights),
816 );
817 }
818
819 #[test]
821 fn test_blend_weight_no_blend() {
822 let g = build_locomotion_graph();
823 let ctrl = MotionController::new(g);
824 assert_eq!(ctrl.blend_weight(), 0.0);
825 write_tmp("mg_blend_weight_none.txt", "0.0");
826 }
827
828 #[test]
830 fn test_default_graph_alias() {
831 let g1 = MotionGraph::default_graph();
832 let g2 = build_locomotion_graph();
833 assert_eq!(g1.state_count(), g2.state_count());
834 assert_eq!(g1.transition_count(), g2.transition_count());
835 write_tmp("mg_default_graph_alias.txt", "ok");
836 }
837
838 #[test]
840 fn test_total_time_accumulates() {
841 let g = build_locomotion_graph();
842 let mut ctrl = MotionController::new(g);
843 ctrl.update(0.1);
844 ctrl.update(0.2);
845 assert!((ctrl.total_time - 0.3).abs() < 1e-6);
846 write_tmp("mg_total_time.txt", &ctrl.total_time.to_string());
847 }
848}