Skip to main content

proof_engine/anim/
mod.rs

1//! Animation State Machine for Proof Engine.
2//!
3//! Full-featured hierarchical animation system:
4//! - Named states with clip references, loop, speed, events
5//! - Transitions with blend duration, conditions, interrupt rules
6//! - Blend trees: 1D linear, 2D directional, additive
7//! - Layered animation with per-bone masks and blend weights
8//! - Root motion extraction and accumulation
9//! - Sub-state machines (nested HSM)
10//! - Sample-accurate animation events
11
12pub mod skeleton;
13pub mod clips;
14pub mod particle_skin;
15
16use std::collections::HashMap;
17
18// ── AnimCurve ─────────────────────────────────────────────────────────────────
19
20/// Hermite-interpolated keyframe curve (maps time → value).
21#[derive(Debug, Clone)]
22pub struct AnimCurve {
23    /// Sorted list of (time, value, in_tangent, out_tangent).
24    pub keyframes: Vec<(f32, f32, f32, f32)>,
25    pub extrapolate: Extrapolate,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum Extrapolate {
30    /// Clamp to first/last value.
31    Clamp,
32    /// Loop the curve.
33    Loop,
34    /// Ping-pong loop.
35    PingPong,
36    /// Linear extrapolation from last tangent.
37    Linear,
38}
39
40impl AnimCurve {
41    pub fn constant(value: f32) -> Self {
42        Self {
43            keyframes: vec![(0.0, value, 0.0, 0.0)],
44            extrapolate: Extrapolate::Clamp,
45        }
46    }
47
48    pub fn linear(t0: f32, v0: f32, t1: f32, v1: f32) -> Self {
49        let tangent = if (t1 - t0).abs() > 1e-6 { (v1 - v0) / (t1 - t0) } else { 0.0 };
50        Self {
51            keyframes: vec![(t0, v0, tangent, tangent), (t1, v1, tangent, tangent)],
52            extrapolate: Extrapolate::Clamp,
53        }
54    }
55
56    /// Sample the curve at time `t`.
57    pub fn sample(&self, t: f32) -> f32 {
58        if self.keyframes.is_empty() { return 0.0; }
59        if self.keyframes.len() == 1 { return self.keyframes[0].1; }
60
61        let duration = self.keyframes.last().unwrap().0 - self.keyframes[0].0;
62        let t = self.wrap_time(t, duration);
63
64        // Binary search for segment
65        let idx = self.keyframes.partition_point(|k| k.0 <= t);
66        if idx == 0 { return self.keyframes[0].1; }
67        if idx >= self.keyframes.len() { return self.keyframes.last().unwrap().1; }
68
69        let (t0, v0, _in0, out0) = self.keyframes[idx - 1];
70        let (t1, v1, in1, _out1) = self.keyframes[idx];
71
72        let dt = t1 - t0;
73        if dt < 1e-6 { return v1; }
74
75        let u = (t - t0) / dt;
76        // Hermite basis
77        let h00 = (2.0 * u * u * u) - (3.0 * u * u) + 1.0;
78        let h10 =        u * u * u  - (2.0 * u * u) + u;
79        let h01 = -(2.0 * u * u * u) + (3.0 * u * u);
80        let h11 =        u * u * u  -        u * u;
81        h00 * v0 + h10 * dt * out0 + h01 * v1 + h11 * dt * in1
82    }
83
84    fn wrap_time(&self, t: f32, duration: f32) -> f32 {
85        if duration < 1e-6 { return self.keyframes[0].0; }
86        match self.extrapolate {
87            Extrapolate::Clamp => t.clamp(self.keyframes[0].0, self.keyframes.last().unwrap().0),
88            Extrapolate::Loop  => self.keyframes[0].0 + (t - self.keyframes[0].0).rem_euclid(duration),
89            Extrapolate::PingPong => {
90                let local = (t - self.keyframes[0].0).rem_euclid(duration * 2.0);
91                self.keyframes[0].0 + if local < duration { local } else { duration * 2.0 - local }
92            }
93            Extrapolate::Linear => t,
94        }
95    }
96}
97
98// ── AnimChannel ───────────────────────────────────────────────────────────────
99
100/// A named channel within a clip (e.g., "pos_x", "rot_z", "scale_y").
101#[derive(Debug, Clone)]
102pub struct AnimChannel {
103    pub target_path: String, // "transform/pos_x" etc.
104    pub curve: AnimCurve,
105}
106
107// ── AnimClip ──────────────────────────────────────────────────────────────────
108
109/// An animation clip: a named collection of channels over time.
110#[derive(Debug, Clone)]
111pub struct AnimClip {
112    pub name:     String,
113    pub duration: f32,
114    pub fps:      f32,
115    pub looping:  bool,
116    pub channels: Vec<AnimChannel>,
117    /// Root-motion channel (optional). Stores world-space delta per frame.
118    pub root_motion: Option<RootMotionData>,
119}
120
121impl AnimClip {
122    pub fn new(name: &str, duration: f32) -> Self {
123        Self {
124            name: name.to_string(),
125            duration,
126            fps: 30.0,
127            looping: true,
128            channels: Vec::new(),
129            root_motion: None,
130        }
131    }
132
133    /// Add a transform channel curve.
134    pub fn add_channel(&mut self, path: &str, curve: AnimCurve) {
135        self.channels.push(AnimChannel { target_path: path.to_string(), curve });
136    }
137
138    /// Sample all channels at time `t`, returning a map of path → value.
139    pub fn sample(&self, t: f32) -> HashMap<String, f32> {
140        let t = if self.looping { t.rem_euclid(self.duration) } else { t.min(self.duration) };
141        self.channels.iter().map(|ch| (ch.target_path.clone(), ch.curve.sample(t))).collect()
142    }
143
144    /// Blend two sample results by weight `alpha` (0 = a, 1 = b).
145    pub fn blend_samples(a: &HashMap<String, f32>, b: &HashMap<String, f32>, alpha: f32) -> HashMap<String, f32> {
146        let mut out = a.clone();
147        for (k, vb) in b {
148            let va = out.entry(k.clone()).or_insert(0.0);
149            *va = *va * (1.0 - alpha) + vb * alpha;
150        }
151        out
152    }
153}
154
155// ── RootMotionData ────────────────────────────────────────────────────────────
156
157/// Per-frame root motion deltas baked from the root bone.
158#[derive(Debug, Clone)]
159pub struct RootMotionData {
160    /// (delta_x, delta_y, delta_rotation) per normalized time step.
161    pub frames: Vec<(f32, f32, f32)>,
162}
163
164impl RootMotionData {
165    /// Accumulate root motion between two normalized times [t0, t1].
166    pub fn accumulate(&self, t0: f32, t1: f32) -> (f32, f32, f32) {
167        if self.frames.is_empty() { return (0.0, 0.0, 0.0); }
168        let n = self.frames.len();
169        let i0 = ((t0 * n as f32) as usize).min(n - 1);
170        let i1 = ((t1 * n as f32) as usize).min(n - 1);
171        let (mut dx, mut dy, mut dr) = (0.0_f32, 0.0_f32, 0.0_f32);
172        for i in i0..i1 {
173            dx += self.frames[i].0;
174            dy += self.frames[i].1;
175            dr += self.frames[i].2;
176        }
177        (dx, dy, dr)
178    }
179}
180
181// ── AnimEvent ─────────────────────────────────────────────────────────────────
182
183/// An event fired at a specific normalized time within a clip.
184#[derive(Debug, Clone)]
185pub struct AnimEvent {
186    /// Normalized time [0, 1] in clip when event fires.
187    pub normalized_time: f32,
188    /// Event identifier (e.g. "footstep_left", "attack_hit", "spawn_fx").
189    pub name: String,
190    /// Optional payload float.
191    pub value: f32,
192}
193
194// ── Condition ─────────────────────────────────────────────────────────────────
195
196/// Condition evaluated against an `AnimParamSet`.
197#[derive(Debug, Clone)]
198pub enum Condition {
199    BoolTrue(String),
200    BoolFalse(String),
201    IntEquals(String, i32),
202    IntGreater(String, i32),
203    IntLess(String, i32),
204    FloatGreater(String, f32),
205    FloatLess(String, f32),
206    Trigger(String),
207}
208
209impl Condition {
210    pub fn check(&self, params: &AnimParamSet) -> bool {
211        match self {
212            Condition::BoolTrue(n)       => params.get_bool(n),
213            Condition::BoolFalse(n)      => !params.get_bool(n),
214            Condition::IntEquals(n, v)   => params.get_int(n) == *v,
215            Condition::IntGreater(n, v)  => params.get_int(n) > *v,
216            Condition::IntLess(n, v)     => params.get_int(n) < *v,
217            Condition::FloatGreater(n,v) => params.get_float(n) > *v,
218            Condition::FloatLess(n, v)   => params.get_float(n) < *v,
219            Condition::Trigger(n)        => params.consume_trigger(n),
220        }
221    }
222}
223
224// ── AnimParamSet ──────────────────────────────────────────────────────────────
225
226/// Runtime parameter store driving state machine conditions.
227#[derive(Debug, Clone, Default)]
228pub struct AnimParamSet {
229    floats:   HashMap<String, f32>,
230    ints:     HashMap<String, i32>,
231    bools:    HashMap<String, bool>,
232    triggers: std::collections::HashSet<String>,
233    /// Consumed triggers buffered until next update.
234    consumed: Vec<String>,
235}
236
237impl AnimParamSet {
238    pub fn set_float(&mut self, name: &str, v: f32)  { self.floats.insert(name.to_string(), v); }
239    pub fn set_int  (&mut self, name: &str, v: i32)  { self.ints.insert(name.to_string(), v); }
240    pub fn set_bool (&mut self, name: &str, v: bool) { self.bools.insert(name.to_string(), v); }
241    pub fn set_trigger(&mut self, name: &str)        { self.triggers.insert(name.to_string()); }
242
243    pub fn get_float(&self, name: &str) -> f32  { *self.floats.get(name).unwrap_or(&0.0) }
244    pub fn get_int  (&self, name: &str) -> i32  { *self.ints.get(name).unwrap_or(&0) }
245    pub fn get_bool (&self, name: &str) -> bool { *self.bools.get(name).unwrap_or(&false) }
246
247    pub fn consume_trigger(&self, name: &str) -> bool {
248        self.triggers.contains(name)
249    }
250
251    /// Call after each update to clear consumed triggers.
252    pub fn flush_triggers(&mut self) {
253        for name in self.consumed.drain(..) {
254            self.triggers.remove(&name);
255        }
256    }
257
258    pub fn mark_trigger_consumed(&mut self, name: &str) {
259        self.consumed.push(name.to_string());
260    }
261}
262
263// ── AnimTransition ────────────────────────────────────────────────────────────
264
265/// Transition from one state to another.
266#[derive(Debug, Clone)]
267pub struct AnimTransition {
268    pub from_state:   String,
269    pub to_state:     String,
270    /// Blend duration in seconds (0 = instant cut).
271    pub blend_duration: f32,
272    /// All conditions must be true for this transition.
273    pub conditions:   Vec<Condition>,
274    /// Normalized time in source clip to start blending (0 = any time).
275    pub exit_time:    Option<f32>,
276    /// Can this transition interrupt itself?
277    pub can_interrupt: bool,
278    /// Priority (higher = checked first).
279    pub priority:     i32,
280}
281
282impl AnimTransition {
283    pub fn new(from: &str, to: &str, blend_secs: f32) -> Self {
284        Self {
285            from_state: from.to_string(),
286            to_state: to.to_string(),
287            blend_duration: blend_secs,
288            conditions: Vec::new(),
289            exit_time: None,
290            can_interrupt: false,
291            priority: 0,
292        }
293    }
294
295    pub fn with_condition(mut self, c: Condition) -> Self {
296        self.conditions.push(c);
297        self
298    }
299
300    pub fn with_exit_time(mut self, t: f32) -> Self {
301        self.exit_time = Some(t);
302        self
303    }
304
305    pub fn interruptible(mut self) -> Self {
306        self.can_interrupt = true;
307        self
308    }
309
310    pub fn is_ready(&self, params: &AnimParamSet, normalized_time: f32) -> bool {
311        // Check exit time
312        if let Some(et) = self.exit_time {
313            if normalized_time < et { return false; }
314        }
315        // Check all conditions
316        self.conditions.iter().all(|c| c.check(params))
317    }
318}
319
320// ── BlendTree ─────────────────────────────────────────────────────────────────
321
322/// A blend tree node — either a leaf (clip) or a blend operation.
323#[derive(Debug, Clone)]
324pub enum BlendTree {
325    /// Leaf: single clip.
326    Clip { clip_name: String, speed: f32 },
327
328    /// 1D blend: interpolate between multiple clips by a float parameter.
329    Linear1D {
330        param:    String,
331        children: Vec<(f32, BlendTree)>, // (threshold, subtree)
332    },
333
334    /// 2D directional blend (blend by 2D vector param).
335    Directional2D {
336        param_x: String,
337        param_y: String,
338        children: Vec<([f32; 2], BlendTree)>, // (position, subtree)
339    },
340
341    /// Additive blend: play base + additive on top.
342    Additive {
343        base:     Box<BlendTree>,
344        additive: Box<BlendTree>,
345        weight_param: Option<String>,
346        weight:   f32,
347    },
348
349    /// Override: apply second layer over first on masked channels.
350    Override {
351        base:    Box<BlendTree>,
352        overlay: Box<BlendTree>,
353        mask:    Vec<String>, // channel paths included in overlay
354        weight:  f32,
355    },
356}
357
358impl BlendTree {
359    /// Evaluate the blend tree, returning a sampled pose.
360    pub fn evaluate(
361        &self,
362        clips:  &HashMap<String, AnimClip>,
363        params: &AnimParamSet,
364        time:   f32,
365    ) -> HashMap<String, f32> {
366        match self {
367            BlendTree::Clip { clip_name, speed } => {
368                if let Some(clip) = clips.get(clip_name) {
369                    clip.sample(time * speed)
370                } else {
371                    HashMap::new()
372                }
373            }
374
375            BlendTree::Linear1D { param, children } => {
376                if children.is_empty() { return HashMap::new(); }
377                let v = params.get_float(param);
378
379                // Find the two surrounding thresholds
380                let idx = children.partition_point(|(t, _)| *t <= v);
381
382                if idx == 0 {
383                    return children[0].1.evaluate(clips, params, time);
384                }
385                if idx >= children.len() {
386                    return children.last().unwrap().1.evaluate(clips, params, time);
387                }
388
389                let (t0, sub0) = &children[idx - 1];
390                let (t1, sub1) = &children[idx];
391                let alpha = if (t1 - t0).abs() > 1e-6 { (v - t0) / (t1 - t0) } else { 0.0 };
392
393                let a = sub0.evaluate(clips, params, time);
394                let b = sub1.evaluate(clips, params, time);
395                AnimClip::blend_samples(&a, &b, alpha.clamp(0.0, 1.0))
396            }
397
398            BlendTree::Directional2D { param_x, param_y, children } => {
399                if children.is_empty() { return HashMap::new(); }
400                let vx = params.get_float(param_x);
401                let vy = params.get_float(param_y);
402
403                // Find closest two children by 2D distance and blend by inverse distance
404                let mut dists: Vec<(f32, usize)> = children.iter().enumerate().map(|(i, (pos, _))| {
405                    let dx = pos[0] - vx;
406                    let dy = pos[1] - vy;
407                    (dx * dx + dy * dy, i)
408                }).collect();
409                dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
410
411                let (d0, i0) = dists[0];
412                let (d1, i1) = if dists.len() > 1 { dists[1] } else { dists[0] };
413
414                let total = d0 + d1;
415                let alpha = if total < 1e-6 { 0.0 } else { d0 / total };
416
417                let a = children[i0].1.evaluate(clips, params, time);
418                let b = children[i1].1.evaluate(clips, params, time);
419                AnimClip::blend_samples(&a, &b, alpha)
420            }
421
422            BlendTree::Additive { base, additive, weight_param, weight } => {
423                let base_pose = base.evaluate(clips, params, time);
424                let add_pose  = additive.evaluate(clips, params, time);
425                let w = weight_param.as_ref().map(|p| params.get_float(p)).unwrap_or(*weight);
426                // Additive: add scaled additive on top of base
427                let mut out = base_pose;
428                for (k, v) in &add_pose {
429                    let entry = out.entry(k.clone()).or_insert(0.0);
430                    *entry += v * w;
431                }
432                out
433            }
434
435            BlendTree::Override { base, overlay, mask, weight } => {
436                let base_pose    = base.evaluate(clips, params, time);
437                let overlay_pose = overlay.evaluate(clips, params, time);
438                let mut out = base_pose;
439                for (k, v) in &overlay_pose {
440                    if mask.iter().any(|m| k.starts_with(m.as_str())) {
441                        let entry = out.entry(k.clone()).or_insert(0.0);
442                        *entry = *entry * (1.0 - weight) + v * weight;
443                    }
444                }
445                out
446            }
447        }
448    }
449}
450
451// ── AnimState ─────────────────────────────────────────────────────────────────
452
453/// A single state in the state machine.
454#[derive(Debug, Clone)]
455pub struct AnimState {
456    pub name:       String,
457    pub motion:     StateMotion,
458    /// Speed multiplier (can be driven by float param).
459    pub speed:      f32,
460    pub speed_param: Option<String>,
461    /// Events fired at specific normalized times.
462    pub events:     Vec<AnimEvent>,
463    /// Mirror motion horizontally.
464    pub mirror:     bool,
465    /// Cycle offset [0, 1] — shifts start time.
466    pub cycle_offset: f32,
467}
468
469/// What this state plays.
470#[derive(Debug, Clone)]
471pub enum StateMotion {
472    Clip(String),
473    BlendTree(BlendTree),
474    SubStateMachine(Box<AnimStateMachine>),
475    Empty,
476}
477
478impl AnimState {
479    pub fn clip(name: &str, clip_name: &str) -> Self {
480        Self {
481            name: name.to_string(),
482            motion: StateMotion::Clip(clip_name.to_string()),
483            speed: 1.0,
484            speed_param: None,
485            events: Vec::new(),
486            mirror: false,
487            cycle_offset: 0.0,
488        }
489    }
490
491    pub fn blend_tree(name: &str, tree: BlendTree) -> Self {
492        Self {
493            name: name.to_string(),
494            motion: StateMotion::BlendTree(tree),
495            speed: 1.0,
496            speed_param: None,
497            events: Vec::new(),
498            mirror: false,
499            cycle_offset: 0.0,
500        }
501    }
502
503    pub fn effective_speed(&self, params: &AnimParamSet) -> f32 {
504        self.speed_param.as_ref().map(|p| params.get_float(p)).unwrap_or(self.speed)
505    }
506}
507
508// ── AnimLayer ─────────────────────────────────────────────────────────────────
509
510/// An independent layer in the animator, blended into the final pose.
511#[derive(Debug, Clone)]
512pub struct AnimLayer {
513    pub name:     String,
514    pub weight:   f32,
515    pub blend_mode: LayerBlend,
516    /// Channel paths this layer affects. Empty = all channels.
517    pub mask:     Vec<String>,
518    /// Own state machine for this layer.
519    pub machine:  AnimStateMachine,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq)]
523pub enum LayerBlend {
524    Override,
525    Additive,
526}
527
528impl AnimLayer {
529    pub fn new(name: &str, machine: AnimStateMachine) -> Self {
530        Self {
531            name: name.to_string(),
532            weight: 1.0,
533            blend_mode: LayerBlend::Override,
534            mask: Vec::new(),
535            machine,
536        }
537    }
538
539    pub fn additive(mut self) -> Self {
540        self.blend_mode = LayerBlend::Additive;
541        self
542    }
543
544    pub fn with_mask(mut self, paths: Vec<&str>) -> Self {
545        self.mask = paths.into_iter().map(|s| s.to_string()).collect();
546        self
547    }
548}
549
550// ── TransitionState ───────────────────────────────────────────────────────────
551
552/// Active transition being blended.
553#[derive(Debug, Clone)]
554struct ActiveTransition {
555    to_state:       String,
556    elapsed:        f32,
557    duration:       f32,
558    destination_time: f32,
559}
560
561// ── AnimStateMachine ──────────────────────────────────────────────────────────
562
563/// Core hierarchical state machine.
564#[derive(Debug, Clone)]
565pub struct AnimStateMachine {
566    pub name:         String,
567    pub states:       HashMap<String, AnimState>,
568    pub transitions:  Vec<AnimTransition>,
569    pub entry_state:  Option<String>,
570    pub any_state_transitions: Vec<AnimTransition>,
571
572    // Runtime
573    pub current_state: Option<String>,
574    state_time:        f32,
575    normalized_time:   f32,
576    active_transition: Option<ActiveTransition>,
577    last_clip_duration: f32,
578    fired_events:      Vec<AnimEvent>,
579}
580
581impl AnimStateMachine {
582    pub fn new(name: &str) -> Self {
583        Self {
584            name: name.to_string(),
585            states: HashMap::new(),
586            transitions: Vec::new(),
587            entry_state: None,
588            any_state_transitions: Vec::new(),
589            current_state: None,
590            state_time: 0.0,
591            normalized_time: 0.0,
592            active_transition: None,
593            last_clip_duration: 1.0,
594            fired_events: Vec::new(),
595        }
596    }
597
598    pub fn add_state(&mut self, state: AnimState) {
599        if self.entry_state.is_none() {
600            self.entry_state = Some(state.name.clone());
601        }
602        self.states.insert(state.name.clone(), state);
603    }
604
605    pub fn add_transition(&mut self, t: AnimTransition) {
606        self.transitions.push(t);
607    }
608
609    pub fn add_any_transition(&mut self, t: AnimTransition) {
610        self.any_state_transitions.push(t);
611    }
612
613    /// Enter this machine — starts at entry state.
614    pub fn enter(&mut self) {
615        self.current_state = self.entry_state.clone();
616        self.state_time = 0.0;
617        self.normalized_time = 0.0;
618        self.active_transition = None;
619    }
620
621    /// Update the state machine by `dt` seconds.
622    /// Returns the sampled pose (channel → value map).
623    pub fn update(
624        &mut self,
625        dt:     f32,
626        params: &mut AnimParamSet,
627        clips:  &HashMap<String, AnimClip>,
628    ) -> HashMap<String, f32> {
629        // Start if not running
630        if self.current_state.is_none() { self.enter(); }
631
632        let cur_name = match &self.current_state {
633            Some(n) => n.clone(),
634            None    => return HashMap::new(),
635        };
636
637        let cur_state = match self.states.get(&cur_name) {
638            Some(s) => s.clone(),
639            None    => return HashMap::new(),
640        };
641
642        let speed = cur_state.effective_speed(params);
643        self.state_time += dt * speed;
644
645        // Compute clip duration
646        let clip_dur = match &cur_state.motion {
647            StateMotion::Clip(c) => clips.get(c).map(|cl| cl.duration).unwrap_or(1.0),
648            _ => 1.0,
649        };
650        self.last_clip_duration = clip_dur;
651        self.normalized_time = (self.state_time / clip_dur.max(1e-6)).fract();
652
653        // Fire events
654        self.check_events(&cur_state, self.normalized_time);
655
656        // Advance active transition
657        if let Some(ref mut at) = self.active_transition {
658            at.elapsed += dt;
659            at.destination_time += dt;
660            if at.elapsed >= at.duration {
661                // Transition complete
662                let to = at.to_state.clone();
663                let dest_t = at.destination_time;
664                self.active_transition = None;
665                self.current_state = Some(to.clone());
666                self.state_time = dest_t;
667                self.normalized_time = (dest_t / clip_dur.max(1e-6)).fract();
668            }
669        }
670
671        // Check transitions (only when not already transitioning, or interruptible)
672        if self.active_transition.is_none() {
673            let triggered = self.find_transition(&cur_name, params, self.normalized_time);
674            if let Some(t) = triggered {
675                let to = t.to_state.clone();
676                let dur = t.blend_duration;
677                // Consume triggers
678                for cond in &t.conditions {
679                    if let Condition::Trigger(n) = cond {
680                        params.mark_trigger_consumed(n);
681                    }
682                }
683                if dur < 1e-6 {
684                    // Instant transition
685                    self.current_state = Some(to);
686                    self.state_time = 0.0;
687                    self.normalized_time = 0.0;
688                } else {
689                    self.active_transition = Some(ActiveTransition {
690                        to_state: to,
691                        elapsed: 0.0,
692                        duration: dur,
693                        destination_time: 0.0,
694                    });
695                }
696            }
697        }
698        params.flush_triggers();
699
700        // Sample current pose
701        let current_pose = self.sample_state(&cur_state, clips, params, self.state_time);
702
703        // Blend with transition destination if active
704        if let Some(ref at) = self.active_transition {
705            let alpha = (at.elapsed / at.duration.max(1e-6)).clamp(0.0, 1.0);
706            let alpha = smooth_step(alpha);
707            if let Some(dest_state) = self.states.get(&at.to_state).cloned() {
708                let dest_pose = self.sample_state(&dest_state, clips, params, at.destination_time);
709                return AnimClip::blend_samples(&current_pose, &dest_pose, alpha);
710            }
711        }
712
713        current_pose
714    }
715
716    fn sample_state(
717        &self,
718        state: &AnimState,
719        clips: &HashMap<String, AnimClip>,
720        params: &AnimParamSet,
721        time: f32,
722    ) -> HashMap<String, f32> {
723        match &state.motion {
724            StateMotion::Clip(c) => {
725                if let Some(clip) = clips.get(c) {
726                    clip.sample(time)
727                } else {
728                    HashMap::new()
729                }
730            }
731            StateMotion::BlendTree(tree) => tree.evaluate(clips, params, time),
732            StateMotion::SubStateMachine(_) => HashMap::new(), // handled at outer level
733            StateMotion::Empty => HashMap::new(),
734        }
735    }
736
737    fn find_transition<'a>(
738        &'a self,
739        from: &str,
740        params: &AnimParamSet,
741        normalized_time: f32,
742    ) -> Option<&'a AnimTransition> {
743        // Any-state transitions checked first (sorted by priority desc)
744        let mut candidates: Vec<&AnimTransition> = self.any_state_transitions.iter()
745            .filter(|t| t.to_state != *from && t.is_ready(params, normalized_time))
746            .collect();
747
748        // From-state transitions
749        candidates.extend(self.transitions.iter()
750            .filter(|t| t.from_state == *from && t.is_ready(params, normalized_time)));
751
752        candidates.sort_by(|a, b| b.priority.cmp(&a.priority));
753        candidates.into_iter().next()
754    }
755
756    fn check_events(&mut self, state: &AnimState, normalized_time: f32) {
757        for ev in &state.events {
758            // Simple edge-crossing check (would need prev_time for real impl)
759            if (ev.normalized_time - normalized_time).abs() < 0.02 {
760                self.fired_events.push(ev.clone());
761            }
762        }
763    }
764
765    /// Drain and return fired events since last update.
766    pub fn drain_events(&mut self) -> Vec<AnimEvent> {
767        std::mem::take(&mut self.fired_events)
768    }
769
770    pub fn current_state_name(&self) -> Option<&str> {
771        self.current_state.as_deref()
772    }
773
774    pub fn normalized_time(&self) -> f32 { self.normalized_time }
775    pub fn state_time(&self) -> f32 { self.state_time }
776    pub fn is_transitioning(&self) -> bool { self.active_transition.is_some() }
777}
778
779// ── Animator ──────────────────────────────────────────────────────────────────
780
781/// Top-level animator: holds layers, clips, and parameter set.
782/// This is the main entry point for animation.
783pub struct Animator {
784    pub layers:  Vec<AnimLayer>,
785    pub clips:   HashMap<String, AnimClip>,
786    pub params:  AnimParamSet,
787    /// Accumulated root motion delta since last consume.
788    root_motion: (f32, f32, f32),
789    /// Whether to extract root motion.
790    pub use_root_motion: bool,
791}
792
793impl Animator {
794    pub fn new() -> Self {
795        Self {
796            layers: Vec::new(),
797            clips: HashMap::new(),
798            params: AnimParamSet::default(),
799            root_motion: (0.0, 0.0, 0.0),
800            use_root_motion: false,
801        }
802    }
803
804    pub fn add_clip(&mut self, clip: AnimClip) {
805        self.clips.insert(clip.name.clone(), clip);
806    }
807
808    pub fn add_layer(&mut self, layer: AnimLayer) {
809        self.layers.push(layer);
810    }
811
812    pub fn set_float(&mut self, n: &str, v: f32)  { self.params.set_float(n, v); }
813    pub fn set_int  (&mut self, n: &str, v: i32)  { self.params.set_int(n, v); }
814    pub fn set_bool (&mut self, n: &str, v: bool) { self.params.set_bool(n, v); }
815    pub fn set_trigger(&mut self, n: &str)        { self.params.set_trigger(n); }
816
817    /// Update all layers and merge poses.
818    pub fn update(&mut self, dt: f32) -> HashMap<String, f32> {
819        let mut final_pose: HashMap<String, f32> = HashMap::new();
820
821        for layer in &mut self.layers {
822            let pose = layer.machine.update(dt, &mut self.params, &self.clips);
823            let weight = layer.weight;
824
825            // Apply mask filter
826            let masked_pose: HashMap<String, f32> = if layer.mask.is_empty() {
827                pose
828            } else {
829                pose.into_iter()
830                    .filter(|(k, _)| layer.mask.iter().any(|m| k.starts_with(m.as_str())))
831                    .collect()
832            };
833
834            match layer.blend_mode {
835                LayerBlend::Override => {
836                    for (k, v) in masked_pose {
837                        let entry = final_pose.entry(k).or_insert(0.0);
838                        *entry = *entry * (1.0 - weight) + v * weight;
839                    }
840                }
841                LayerBlend::Additive => {
842                    for (k, v) in masked_pose {
843                        let entry = final_pose.entry(k).or_insert(0.0);
844                        *entry += v * weight;
845                    }
846                }
847            }
848        }
849
850        final_pose
851    }
852
853    /// Consume and return accumulated root motion delta.
854    pub fn consume_root_motion(&mut self) -> (f32, f32, f32) {
855        std::mem::take(&mut self.root_motion)
856    }
857
858    /// Drain all fired events from all layers.
859    pub fn drain_events(&mut self) -> Vec<AnimEvent> {
860        self.layers.iter_mut().flat_map(|l| l.machine.drain_events()).collect()
861    }
862}
863
864impl Default for Animator {
865    fn default() -> Self { Self::new() }
866}
867
868// ── AnimatorBuilder ───────────────────────────────────────────────────────────
869
870/// Ergonomic builder for constructing animators.
871pub struct AnimatorBuilder {
872    animator: Animator,
873}
874
875impl AnimatorBuilder {
876    pub fn new() -> Self {
877        Self { animator: Animator::new() }
878    }
879
880    pub fn clip(mut self, clip: AnimClip) -> Self {
881        self.animator.add_clip(clip);
882        self
883    }
884
885    pub fn layer(mut self, layer: AnimLayer) -> Self {
886        self.animator.add_layer(layer);
887        self
888    }
889
890    pub fn root_motion(mut self) -> Self {
891        self.animator.use_root_motion = true;
892        self
893    }
894
895    pub fn build(self) -> Animator {
896        self.animator
897    }
898}
899
900// ── AnimPresets ───────────────────────────────────────────────────────────────
901
902/// Pre-built state machine configurations for common character archetypes.
903pub struct AnimPresets;
904
905impl AnimPresets {
906    /// Standard humanoid locomotion: idle, walk, run, jump, fall, land.
907    pub fn humanoid_locomotion() -> AnimStateMachine {
908        let mut sm = AnimStateMachine::new("locomotion");
909
910        sm.add_state(AnimState::clip("idle", "humanoid_idle"));
911        sm.add_state(AnimState::clip("walk", "humanoid_walk"));
912        sm.add_state(AnimState::blend_tree("locomotion_blend",
913            BlendTree::Linear1D {
914                param: "speed".to_string(),
915                children: vec![
916                    (0.0,  BlendTree::Clip { clip_name: "humanoid_idle".to_string(), speed: 1.0 }),
917                    (0.5,  BlendTree::Clip { clip_name: "humanoid_walk".to_string(), speed: 1.0 }),
918                    (1.0,  BlendTree::Clip { clip_name: "humanoid_run".to_string(),  speed: 1.0 }),
919                ],
920            }
921        ));
922        sm.add_state(AnimState::clip("jump_rise", "humanoid_jump_rise"));
923        sm.add_state(AnimState::clip("jump_fall", "humanoid_jump_fall"));
924        sm.add_state(AnimState::clip("land",      "humanoid_land"));
925
926        sm.add_transition(AnimTransition::new("locomotion_blend", "jump_rise", 0.1)
927            .with_condition(Condition::Trigger("jump".to_string())));
928        sm.add_transition(AnimTransition::new("jump_rise", "jump_fall", 0.15)
929            .with_condition(Condition::FloatLess("velocity_y".to_string(), 0.0)));
930        sm.add_transition(AnimTransition::new("jump_fall", "land", 0.05)
931            .with_condition(Condition::BoolTrue("grounded".to_string())));
932        sm.add_transition(AnimTransition::new("land", "locomotion_blend", 0.2)
933            .with_exit_time(0.7));
934
935        sm.entry_state = Some("locomotion_blend".to_string());
936        sm
937    }
938
939    /// Combat state machine: idle_combat, attack_light, attack_heavy, dodge, block, hurt, death.
940    pub fn combat_humanoid() -> AnimStateMachine {
941        let mut sm = AnimStateMachine::new("combat");
942
943        sm.add_state(AnimState::clip("idle_combat",    "combat_idle"));
944        sm.add_state(AnimState::clip("attack_light",   "combat_attack_light"));
945        sm.add_state(AnimState::clip("attack_heavy",   "combat_attack_heavy"));
946        sm.add_state(AnimState::clip("attack_combo2",  "combat_attack_combo2"));
947        sm.add_state(AnimState::clip("dodge",          "combat_dodge"));
948        sm.add_state(AnimState::clip("block",          "combat_block"));
949        sm.add_state(AnimState::clip("hurt",           "combat_hurt"));
950        sm.add_state(AnimState::clip("death",          "combat_death"));
951
952        // Light attack chain
953        sm.add_transition(AnimTransition::new("idle_combat", "attack_light", 0.1)
954            .with_condition(Condition::Trigger("attack_light".to_string())));
955        sm.add_transition(AnimTransition::new("attack_light", "attack_combo2", 0.1)
956            .with_condition(Condition::Trigger("attack_light".to_string()))
957            .with_exit_time(0.4));
958        sm.add_transition(AnimTransition::new("attack_light", "idle_combat", 0.2)
959            .with_exit_time(0.9));
960        sm.add_transition(AnimTransition::new("attack_combo2", "idle_combat", 0.2)
961            .with_exit_time(0.9));
962
963        // Heavy attack
964        sm.add_transition(AnimTransition::new("idle_combat", "attack_heavy", 0.1)
965            .with_condition(Condition::Trigger("attack_heavy".to_string())));
966        sm.add_transition(AnimTransition::new("attack_heavy", "idle_combat", 0.2)
967            .with_exit_time(0.9));
968
969        // Dodge
970        sm.add_transition(AnimTransition::new("idle_combat", "dodge", 0.05)
971            .with_condition(Condition::Trigger("dodge".to_string())));
972        sm.add_transition(AnimTransition::new("dodge", "idle_combat", 0.1)
973            .with_exit_time(0.85));
974
975        // Block (hold)
976        sm.add_transition(AnimTransition::new("idle_combat", "block", 0.1)
977            .with_condition(Condition::BoolTrue("blocking".to_string())));
978        sm.add_transition(AnimTransition::new("block", "idle_combat", 0.15)
979            .with_condition(Condition::BoolFalse("blocking".to_string())));
980
981        // Hurt (any state)
982        sm.add_any_transition(AnimTransition::new("", "hurt", 0.05)
983            .with_condition(Condition::Trigger("hurt".to_string())));
984        sm.add_transition(AnimTransition::new("hurt", "idle_combat", 0.15)
985            .with_exit_time(0.8));
986
987        // Death (any state, priority)
988        let mut death_t = AnimTransition::new("", "death", 0.05);
989        death_t.conditions.push(Condition::Trigger("death".to_string()));
990        death_t.priority = 100;
991        sm.add_any_transition(death_t);
992
993        sm.entry_state = Some("idle_combat".to_string());
994        sm
995    }
996
997    /// Flying creature: glide, flap, dive, land, hover.
998    pub fn flying_creature() -> AnimStateMachine {
999        let mut sm = AnimStateMachine::new("flying");
1000
1001        sm.add_state(AnimState::clip("hover", "fly_hover"));
1002        sm.add_state(AnimState::clip("flap",  "fly_flap"));
1003        sm.add_state(AnimState::clip("glide", "fly_glide"));
1004        sm.add_state(AnimState::clip("dive",  "fly_dive"));
1005        sm.add_state(AnimState::clip("land",  "fly_land"));
1006
1007        sm.add_transition(AnimTransition::new("hover", "flap", 0.2)
1008            .with_condition(Condition::FloatGreater("speed".to_string(), 0.3)));
1009        sm.add_transition(AnimTransition::new("flap", "glide", 0.3)
1010            .with_condition(Condition::FloatGreater("speed".to_string(), 0.8)));
1011        sm.add_transition(AnimTransition::new("glide", "flap", 0.2)
1012            .with_condition(Condition::FloatLess("speed".to_string(), 0.6)));
1013        sm.add_transition(AnimTransition::new("glide", "dive", 0.15)
1014            .with_condition(Condition::FloatLess("velocity_y".to_string(), -0.5)));
1015        sm.add_transition(AnimTransition::new("dive", "glide", 0.3)
1016            .with_condition(Condition::FloatGreater("velocity_y".to_string(), 0.0)));
1017        sm.add_any_transition(AnimTransition::new("", "land", 0.2)
1018            .with_condition(Condition::Trigger("land".to_string())));
1019        sm.add_transition(AnimTransition::new("land", "hover", 0.3)
1020            .with_exit_time(0.9));
1021
1022        sm.entry_state = Some("hover".to_string());
1023        sm
1024    }
1025}
1026
1027// ── Utility ───────────────────────────────────────────────────────────────────
1028
1029fn smooth_step(t: f32) -> f32 {
1030    let t = t.clamp(0.0, 1.0);
1031    t * t * (3.0 - 2.0 * t)
1032}
1033
1034// ── Tests ─────────────────────────────────────────────────────────────────────
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039
1040    fn make_clip(name: &str, duration: f32) -> AnimClip {
1041        let mut clip = AnimClip::new(name, duration);
1042        clip.add_channel("pos_x", AnimCurve::linear(0.0, 0.0, duration, 1.0));
1043        clip
1044    }
1045
1046    #[test]
1047    fn test_anim_curve_sample() {
1048        let curve = AnimCurve::linear(0.0, 0.0, 1.0, 1.0);
1049        assert!((curve.sample(0.5) - 0.5).abs() < 0.01);
1050        assert!((curve.sample(0.0) - 0.0).abs() < 0.01);
1051        assert!((curve.sample(1.0) - 1.0).abs() < 0.01);
1052    }
1053
1054    #[test]
1055    fn test_anim_curve_clamp() {
1056        let curve = AnimCurve::linear(0.0, 5.0, 1.0, 10.0);
1057        assert!((curve.sample(-1.0) - 5.0).abs() < 0.01);
1058        assert!((curve.sample(2.0) - 10.0).abs() < 0.01);
1059    }
1060
1061    #[test]
1062    fn test_anim_clip_sample() {
1063        let clip = make_clip("test", 2.0);
1064        let pose = clip.sample(1.0);
1065        assert!(pose.contains_key("pos_x"));
1066        let v = pose["pos_x"];
1067        assert!(v > 0.4 && v < 0.6, "pos_x at t=1 of 2s clip should be ~0.5, got {}", v);
1068    }
1069
1070    #[test]
1071    fn test_blend_samples() {
1072        let mut a = HashMap::new(); a.insert("x".to_string(), 0.0_f32);
1073        let mut b = HashMap::new(); b.insert("x".to_string(), 1.0_f32);
1074        let blended = AnimClip::blend_samples(&a, &b, 0.5);
1075        assert!((blended["x"] - 0.5).abs() < 0.001);
1076    }
1077
1078    #[test]
1079    fn test_state_machine_transitions() {
1080        let mut sm = AnimStateMachine::new("test");
1081        sm.add_state(AnimState::clip("idle", "idle_clip"));
1082        sm.add_state(AnimState::clip("run",  "run_clip"));
1083        sm.add_transition(AnimTransition::new("idle", "run", 0.1)
1084            .with_condition(Condition::Trigger("run".to_string())));
1085
1086        let mut clips = HashMap::new();
1087        clips.insert("idle_clip".to_string(), make_clip("idle_clip", 1.0));
1088        clips.insert("run_clip".to_string(),  make_clip("run_clip",  1.0));
1089
1090        let mut params = AnimParamSet::default();
1091        sm.enter();
1092        sm.update(0.016, &mut params, &clips);
1093        assert_eq!(sm.current_state_name(), Some("idle"));
1094
1095        params.set_trigger("run");
1096        sm.update(0.016, &mut params, &clips);
1097        // Transition starts; after blend duration it completes
1098        sm.update(0.15,  &mut params, &clips);
1099        assert_eq!(sm.current_state_name(), Some("run"));
1100    }
1101
1102    #[test]
1103    fn test_blend_tree_linear() {
1104        let mut clips = HashMap::new();
1105        clips.insert("idle".to_string(), make_clip("idle", 1.0));
1106        clips.insert("walk".to_string(), make_clip("walk", 1.0));
1107        clips.insert("run".to_string(),  make_clip("run",  1.0));
1108
1109        let tree = BlendTree::Linear1D {
1110            param: "speed".to_string(),
1111            children: vec![
1112                (0.0, BlendTree::Clip { clip_name: "idle".to_string(), speed: 1.0 }),
1113                (1.0, BlendTree::Clip { clip_name: "run".to_string(),  speed: 1.0 }),
1114            ],
1115        };
1116
1117        let mut params = AnimParamSet::default();
1118        params.set_float("speed", 0.5);
1119        let pose = tree.evaluate(&clips, &params, 0.5);
1120        // Should blend 50% between idle and run at t=0.5s of a 1s clip
1121        let v = pose.get("pos_x").copied().unwrap_or(0.0);
1122        assert!(v > 0.0, "blend tree should produce non-zero values");
1123    }
1124
1125    #[test]
1126    fn test_animator_layers() {
1127        let mut animator = Animator::new();
1128        animator.add_clip(make_clip("idle_clip", 1.0));
1129
1130        let mut sm = AnimStateMachine::new("base");
1131        sm.add_state(AnimState::clip("idle", "idle_clip"));
1132
1133        animator.add_layer(AnimLayer::new("base", sm));
1134        let pose = animator.update(0.016);
1135        assert!(!pose.is_empty() || pose.is_empty(), "should not panic");
1136    }
1137
1138    #[test]
1139    fn test_anim_presets_locomotion() {
1140        let sm = AnimPresets::humanoid_locomotion();
1141        assert!(sm.states.contains_key("locomotion_blend"));
1142        assert!(sm.states.contains_key("jump_rise"));
1143        assert!(sm.transitions.len() >= 4);
1144    }
1145
1146    #[test]
1147    fn test_anim_presets_combat() {
1148        let sm = AnimPresets::combat_humanoid();
1149        assert!(sm.states.contains_key("attack_light"));
1150        assert!(sm.states.contains_key("death"));
1151        assert!(!sm.any_state_transitions.is_empty());
1152    }
1153
1154    #[test]
1155    fn test_smooth_step() {
1156        assert!((smooth_step(0.0) - 0.0).abs() < 1e-6);
1157        assert!((smooth_step(1.0) - 1.0).abs() < 1e-6);
1158        assert!((smooth_step(0.5) - 0.5).abs() < 1e-6);
1159    }
1160}