Skip to main content

concinnity_core/components/
animation_graph.rs

1// src/components/animation_graph.rs
2
3use alloc::format;
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use crate::ecs::asset_id::{AssetId, de_opt_asset_ref};
8use crate::ecs::{SkinnedMeshHandle, de_opt_skinned_mesh_handle};
9use crate::gfx::anim_graph::{
10    Blend1D, Blend2D, ClipPlay, CmpOp, CompiledCondition, CompiledGraph, CompiledState,
11    CompiledTransition, ParamSpec, StatePlay,
12};
13
14/// A named float parameter driving a graph's transitions. Gameplay systems
15/// (or the `anim-param` debug command) write parameter values at runtime;
16/// transitions compare against them. Flag-like parameters use 0 and 1.
17#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
18#[serde(default)]
19pub struct AnimationParam {
20    /// Parameter name, referenced by transition conditions.
21    pub name: String,
22    /// Initial value at world start.
23    pub default: f32,
24}
25
26/// One member of a 1D blendspace: a clip pinned at a parameter `value`.
27#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct AnimationBlendPoint {
30    /// Parameter value at which this clip plays alone.
31    pub value: f32,
32    /// The [Animation](#animation) clip at this point. Must target the same
33    /// [SkinnedMesh](#skinnedmesh) as the graph.
34    #[serde(deserialize_with = "de_opt_asset_ref")]
35    pub clip: Option<AssetId>,
36}
37
38/// A blendspace: several clips mixed continuously by parameter value instead
39/// of one clip per state. `kind` selects the shape.
40///
41/// With `sync` true the members share one normalized phase clock -- a walk
42/// and a run cycle stay foot-aligned while the blend moves between them, so
43/// speed changes do not slide the feet. Leave it false for members that are
44/// not cyclic gaits.
45#[derive(Debug, Clone)]
46pub enum AnimationBlend {
47    /// Clips along one parameter. The parameter picks the two neighbouring
48    /// `points` (by ascending `value`) and blends them; outside the range
49    /// the nearest end clip plays alone.
50    Blend1d {
51        /// Name of the declared graph parameter driving the blend.
52        parameter: String,
53        /// Members in ascending `value` order.
54        points: Vec<AnimationBlendPoint>,
55        /// Phase-sync the members (see above).
56        sync: bool,
57    },
58    /// Clips on a regular grid over two parameters, blended bilinearly
59    /// between the four grid neighbours of the parameter point (clamped at
60    /// the grid edges).
61    Blend2d {
62        /// Name of the parameter along the grid's x axis.
63        parameter_x: String,
64        /// Name of the parameter along the grid's y axis.
65        parameter_y: String,
66        /// Ascending x-axis sample positions, one per grid column.
67        x_values: Vec<f32>,
68        /// Ascending y-axis sample positions, one per grid row.
69        y_values: Vec<f32>,
70        /// One row of [Animation](#animation) clip names per `y_values`
71        /// entry, each row holding one clip per `x_values` entry.
72        rows: Vec<Vec<AssetId>>,
73        /// Phase-sync the members (see above).
74        sync: bool,
75    },
76}
77
78// The authored JSON shape is internally tagged (`{"kind":"blend1d",...}`),
79// which serde can only deserialize from a self-describing format; the baked
80// postcard form is not one. Serde impls branch on the format through two
81// derived mirrors: human-readable keeps the `kind`-tagged schema, binary uses
82// the plain externally-indexed enum encoding.
83#[derive(serde::Serialize, serde::Deserialize)]
84#[serde(tag = "kind", rename_all = "lowercase")]
85enum GraphBlendTagged {
86    Blend1d {
87        parameter: String,
88        points: Vec<AnimationBlendPoint>,
89        #[serde(default)]
90        sync: bool,
91    },
92    Blend2d {
93        parameter_x: String,
94        parameter_y: String,
95        x_values: Vec<f32>,
96        y_values: Vec<f32>,
97        rows: Vec<Vec<AssetId>>,
98        #[serde(default)]
99        sync: bool,
100    },
101}
102
103#[derive(serde::Serialize, serde::Deserialize)]
104enum GraphBlendPlain {
105    Blend1d {
106        parameter: String,
107        points: Vec<AnimationBlendPoint>,
108        sync: bool,
109    },
110    Blend2d {
111        parameter_x: String,
112        parameter_y: String,
113        x_values: Vec<f32>,
114        y_values: Vec<f32>,
115        rows: Vec<Vec<AssetId>>,
116        sync: bool,
117    },
118}
119
120macro_rules! graph_blend_from {
121    ($src:ident, $dst:ident, $value:expr) => {
122        match $value {
123            $src::Blend1d {
124                parameter,
125                points,
126                sync,
127            } => $dst::Blend1d {
128                parameter,
129                points,
130                sync,
131            },
132            $src::Blend2d {
133                parameter_x,
134                parameter_y,
135                x_values,
136                y_values,
137                rows,
138                sync,
139            } => $dst::Blend2d {
140                parameter_x,
141                parameter_y,
142                x_values,
143                y_values,
144                rows,
145                sync,
146            },
147        }
148    };
149}
150
151impl serde::Serialize for AnimationBlend {
152    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
153        let cloned = self.clone();
154        if s.is_human_readable() {
155            graph_blend_from!(AnimationBlend, GraphBlendTagged, cloned).serialize(s)
156        } else {
157            graph_blend_from!(AnimationBlend, GraphBlendPlain, cloned).serialize(s)
158        }
159    }
160}
161
162impl<'de> serde::Deserialize<'de> for AnimationBlend {
163    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
164        if d.is_human_readable() {
165            let b = GraphBlendTagged::deserialize(d)?;
166            Ok(graph_blend_from!(GraphBlendTagged, AnimationBlend, b))
167        } else {
168            let b = GraphBlendPlain::deserialize(d)?;
169            Ok(graph_blend_from!(GraphBlendPlain, AnimationBlend, b))
170        }
171    }
172}
173
174/// One state of the graph: while active it plays either a single
175/// [Animation](#animation) `clip` or a `blend` (a blendspace mixing several
176/// clips by parameter value). Exactly one of the two must be set.
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
178#[serde(default)]
179pub struct AnimationState {
180    /// State name, referenced by `initial` and by transitions.
181    pub name: String,
182    /// The [Animation](#animation) clip this state plays. Must target the
183    /// same [SkinnedMesh](#skinnedmesh) as the graph. Leave unset when the
184    /// state plays a `blend` instead.
185    #[serde(deserialize_with = "de_opt_asset_ref")]
186    pub clip: Option<AssetId>,
187    /// A blendspace to play instead of a single `clip`.
188    pub blend: Option<AnimationBlend>,
189    /// Playback speed scale; 1.0 plays at authored speed.
190    pub rate: f32,
191    /// Overrides the loop mode while this state plays: a single `clip`
192    /// defaults to its own `looping` flag, a `blend` defaults to looping.
193    pub loop_override: Option<bool>,
194}
195
196impl Default for AnimationState {
197    fn default() -> Self {
198        Self {
199            name: String::new(),
200            clip: None,
201            blend: None,
202            rate: 1.0,
203            loop_override: None,
204        }
205    }
206}
207
208/// One two-bone IK chain, pinning the chain's end joint (typically a foot)
209/// to the ground the physics scene finds beneath it.
210///
211/// `joints` names the chain root, middle, and end in the target skeleton --
212/// e.g. a hip, knee, and foot. The middle joint must be the direct child of
213/// the root and the end the direct child of the middle. Every frame the
214/// runtime probes straight down from the animated end joint; when a surface
215/// is within range, the chain bends so the end lands `foot_height` above it.
216/// Pinning pauses automatically while the character is airborne.
217#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
218#[serde(default)]
219pub struct AnimationIkChain {
220    /// Names of the chain's root, middle, and end joints, in order. Exactly
221    /// three are required, matching the target skeleton's joint names.
222    pub joints: Vec<String>,
223    /// Bend direction in mesh space: the middle joint bows toward this
224    /// vector (a knee points forward, an elbow backward).
225    pub pole: [f32; 3],
226    /// Name of a declared graph parameter scaling the solve in `[0, 1]`;
227    /// empty pins at full strength. Lets gameplay fade IK in and out.
228    pub weight_parameter: String,
229    /// Height the end joint rests above the probed surface, in mesh units
230    /// (the sole-to-ankle offset for a foot).
231    pub foot_height: f32,
232}
233
234impl Default for AnimationIkChain {
235    fn default() -> Self {
236        Self {
237            joints: Vec::new(),
238            pole: [0.0, 0.0, 1.0],
239            weight_parameter: String::new(),
240            foot_height: 0.0,
241        }
242    }
243}
244
245/// One transition condition, `parameter <op> value`. All of a transition's
246/// conditions must pass for it to fire.
247#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
248#[serde(default)]
249pub struct AnimationCondition {
250    /// Name of a declared graph parameter.
251    pub parameter: String,
252    /// Comparison operator: `lt`, `le`, `gt`, `ge`, `eq`, or `ne`.
253    pub op: CmpOp,
254    /// Right-hand side of the comparison.
255    pub value: f32,
256}
257
258/// One directed transition between two states.
259#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
260#[serde(default)]
261pub struct AnimationTransition {
262    /// Source state name.
263    pub from: String,
264    /// Destination state name.
265    pub to: String,
266    /// Crossfade length in seconds between the outgoing and incoming poses.
267    /// Zero snaps to the new state's pose immediately.
268    pub duration_secs: f32,
269    /// When set (0 to 1), the transition waits until the source state has
270    /// played this fraction of its clip. On a looping state the gate re-opens
271    /// every loop; on a non-looping state it stays open once reached. Useful
272    /// for letting a clip finish before leaving, e.g. `0.9` on a jump.
273    pub exit_time: Option<f32>,
274    /// Conditions that must all pass (in addition to any `exit_time` gate).
275    /// An empty list always passes.
276    pub conditions: Vec<AnimationCondition>,
277}
278
279/// An animation state machine for one [SkinnedMesh](#skinnedmesh).
280///
281/// While a plain set of [Animation](#animation) clips blends every clip all
282/// the time, a graph plays exactly one *state* at a time and moves between
283/// states along declared transitions, crossfading poses over each
284/// transition's `duration_secs`. Transitions fire when their conditions --
285/// comparisons against the graph's named float `parameters` -- pass. Gameplay
286/// systems write parameter values each frame (the `anim-param` debug command
287/// does the same from a `cn debug` session).
288///
289/// A graph owns its target: every [Animation](#animation) targeting the
290/// graph's mesh must be referenced by exactly one state, and at most one
291/// graph may target a given mesh (both are build errors otherwise). Clip
292/// `weight` and `fade_in_secs` have no effect under a graph.
293///
294/// Transitions are checked in declaration order and the first match wins.
295/// A state with no outgoing transitions (or none passing) keeps playing;
296/// looping states wrap, non-looping states hold their final pose.
297///
298/// ```rust
299/// # use concinnity_core::components::AnimationGraph;
300/// AnimationGraph {
301///     initial: "idle".into(),
302///     ..Default::default()
303/// };
304/// ```
305#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
306#[serde(default)]
307pub struct AnimationGraph {
308    /// Asset identity; injected via `inject_name`. Not part of `args`.
309    #[serde(skip)]
310    pub asset_id: AssetId,
311    /// The [SkinnedMesh](#skinnedmesh) asset this graph animates.
312    #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
313    pub target: Option<SkinnedMeshHandle>,
314    /// Named float parameters transitions compare against.
315    pub parameters: Vec<AnimationParam>,
316    /// Name of the state the graph starts in. Defaults to the first state.
317    pub initial: String,
318    /// The graph's states. At least one is required.
319    pub states: Vec<AnimationState>,
320    /// Directed transitions between states.
321    pub transitions: Vec<AnimationTransition>,
322    /// Two-bone IK chains applied on top of every state's pose; see
323    /// [AnimationIkChain](#animationikchain).
324    pub ik_chains: Vec<AnimationIkChain>,
325}
326
327impl AnimationGraph {
328    /// Compile the authored graph into the runtime representation, resolving
329    /// state and parameter names to indices and clip references through
330    /// `resolve_clip`, which maps an [Animation](#animation) asset id to its
331    /// index, duration, and looping flag in the target's clip list. Structural
332    /// problems (unknown names, missing clips, non-positive rates) are
333    /// reported as errors; the build validates the same rules earlier, so a
334    /// runtime failure here means the world blob and the clip list disagree.
335    pub fn compile(
336        &self,
337        resolve_clip: impl Fn(AssetId) -> Option<(usize, f32, bool)>,
338    ) -> Result<CompiledGraph, String> {
339        let ctx = |detail: String| format!("AnimationGraph {}: {detail}", self.asset_id);
340        if self.states.is_empty() {
341            return Err(ctx("graph has no states".into()));
342        }
343
344        let params: Vec<ParamSpec> = self
345            .parameters
346            .iter()
347            .map(|p| ParamSpec {
348                name: p.name.clone(),
349                default: p.default,
350            })
351            .collect();
352        let param_index = |name: &str| params.iter().position(|p| p.name == name);
353        let state_index = |name: &str| self.states.iter().position(|s| s.name == name);
354
355        let mut states: Vec<CompiledState> = Vec::with_capacity(self.states.len());
356        for s in &self.states {
357            if s.rate <= 0.0 {
358                return Err(ctx(format!("state '{}': rate must be positive", s.name)));
359            }
360            // The member resolver: an Animation reference -> a ClipPlay,
361            // shared by the single-clip and blendspace arms.
362            let play_for = |clip_id: AssetId| -> Result<(ClipPlay, bool), String> {
363                let Some((clip, duration_secs, clip_looping)) = resolve_clip(clip_id) else {
364                    return Err(ctx(format!(
365                        "state '{}': clip {clip_id} is not a clip on the graph's target",
366                        s.name
367                    )));
368                };
369                Ok((
370                    ClipPlay {
371                        clip,
372                        duration_secs,
373                    },
374                    clip_looping,
375                ))
376            };
377            let (play, default_looping) = match (&s.clip, &s.blend) {
378                (Some(_), Some(_)) => {
379                    return Err(ctx(format!(
380                        "state '{}' sets both `clip` and `blend`; pick one",
381                        s.name
382                    )));
383                }
384                (None, None) => {
385                    return Err(ctx(format!("state '{}' has no `clip` or `blend`", s.name)));
386                }
387                (Some(clip_id), None) => {
388                    let (clip_play, clip_looping) = play_for(*clip_id)?;
389                    (StatePlay::Clip(clip_play), clip_looping)
390                }
391                // Blendspaces default to looping (their members are cyclic
392                // gaits far more often than one-shots).
393                (None, Some(blend)) => (compile_blend(s, blend, &param_index, &play_for)?, true),
394            };
395            states.push(CompiledState {
396                name: s.name.clone(),
397                rate: s.rate,
398                looping: s.loop_override.unwrap_or(default_looping),
399                play,
400                transitions: Vec::new(),
401            });
402        }
403
404        for t in &self.transitions {
405            let Some(from) = state_index(&t.from) else {
406                return Err(ctx(format!("transition from unknown state '{}'", t.from)));
407            };
408            let Some(to) = state_index(&t.to) else {
409                return Err(ctx(format!("transition to unknown state '{}'", t.to)));
410            };
411            let mut conditions = Vec::with_capacity(t.conditions.len());
412            for c in &t.conditions {
413                let Some(param) = param_index(&c.parameter) else {
414                    return Err(ctx(format!(
415                        "transition '{}' -> '{}' references undeclared parameter '{}'",
416                        t.from, t.to, c.parameter
417                    )));
418                };
419                conditions.push(CompiledCondition {
420                    param,
421                    op: c.op,
422                    value: c.value,
423                });
424            }
425            states[from].transitions.push(CompiledTransition {
426                to,
427                duration_secs: t.duration_secs.max(0.0),
428                exit_time: t.exit_time,
429                conditions,
430            });
431        }
432
433        let initial = if self.initial.is_empty() {
434            0
435        } else {
436            state_index(&self.initial)
437                .ok_or_else(|| ctx(format!("initial state '{}' not found", self.initial)))?
438        };
439
440        Ok(CompiledGraph {
441            params,
442            states,
443            initial,
444        })
445    }
446}
447
448// Compile one blendspace node: parameter and clip names resolve to indices,
449// axis positions must ascend strictly, and 2D grids must be complete.
450fn compile_blend(
451    state: &AnimationState,
452    blend: &AnimationBlend,
453    param_index: &impl Fn(&str) -> Option<usize>,
454    play_for: &impl Fn(AssetId) -> Result<(ClipPlay, bool), String>,
455) -> Result<StatePlay, String> {
456    let err = |detail: String| format!("state '{}': {detail}", state.name);
457    let param = |name: &str, axis: &str| {
458        param_index(name)
459            .ok_or_else(|| err(format!("blend {axis} '{name}' is not a declared parameter")))
460    };
461    let strictly_ascending = |v: &[f32]| v.windows(2).all(|w| w[0] < w[1]);
462    let member = |clip: Option<AssetId>| -> Result<ClipPlay, String> {
463        let id = clip.ok_or_else(|| err("blend member has no `clip`".into()))?;
464        Ok(play_for(id)?.0)
465    };
466
467    match blend {
468        AnimationBlend::Blend1d {
469            parameter,
470            points,
471            sync,
472        } => {
473            if points.is_empty() {
474                return Err(err("blend has no `points`".into()));
475            }
476            let thresholds: Vec<f32> = points.iter().map(|p| p.value).collect();
477            if !strictly_ascending(&thresholds) {
478                return Err(err("blend point `value`s must be strictly ascending".into()));
479            }
480            let plays = points
481                .iter()
482                .map(|p| member(p.clip))
483                .collect::<Result<Vec<_>, _>>()?;
484            Ok(StatePlay::Blend1D(Blend1D {
485                param: param(parameter, "parameter")?,
486                thresholds,
487                plays,
488                sync: *sync,
489            }))
490        }
491        AnimationBlend::Blend2d {
492            parameter_x,
493            parameter_y,
494            x_values,
495            y_values,
496            rows,
497            sync,
498        } => {
499            if x_values.is_empty() || y_values.is_empty() {
500                return Err(err("blend `x_values` / `y_values` must not be empty".into()));
501            }
502            if !strictly_ascending(x_values) || !strictly_ascending(y_values) {
503                return Err(err(
504                    "blend `x_values` and `y_values` must be strictly ascending".into(),
505                ));
506            }
507            if rows.len() != y_values.len() || rows.iter().any(|r| r.len() != x_values.len()) {
508                return Err(err(format!(
509                    "blend `rows` must be {} row(s) of {} clip(s) to match the grid",
510                    y_values.len(),
511                    x_values.len()
512                )));
513            }
514            let plays = rows
515                .iter()
516                .flatten()
517                .map(|&clip| member(Some(clip)))
518                .collect::<Result<Vec<_>, _>>()?;
519            Ok(StatePlay::Blend2D(Blend2D {
520                param_x: param(parameter_x, "parameter_x")?,
521                param_y: param(parameter_y, "parameter_y")?,
522                x_values: x_values.clone(),
523                y_values: y_values.clone(),
524                plays,
525                sync: *sync,
526            }))
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use alloc::vec;
535
536    fn graph_json() -> serde_json::Value {
537        serde_json::json!({
538            "target": "hero",
539            "parameters": [{"name": "speed", "default": 0.5}],
540            "initial": "idle",
541            "states": [
542                {"name": "idle", "clip": "hero_idle"},
543                {"name": "run", "clip": "hero_run", "rate": 1.5, "loop_override": false}
544            ],
545            "transitions": [
546                {"from": "idle", "to": "run", "duration_secs": 0.2, "exit_time": 0.5,
547                 "conditions": [{"parameter": "speed", "op": "gt", "value": 1.0}]}
548            ]
549        })
550    }
551
552    // Maps every clip id to slot 0 of a 1-second looping clip.
553    fn any_clip(_: AssetId) -> Option<(usize, f32, bool)> {
554        Some((0, 1.0, true))
555    }
556
557    #[test]
558    fn deserialises_full_graph() {
559        crate::test_support::reset_interner();
560        let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
561        assert!(g.target.is_some());
562        assert_eq!(g.parameters.len(), 1);
563        assert_eq!(g.states.len(), 2);
564        assert_eq!(g.states[1].rate, 1.5);
565        assert_eq!(g.states[1].loop_override, Some(false));
566        assert_eq!(g.transitions.len(), 1);
567        assert_eq!(g.transitions[0].exit_time, Some(0.5));
568        assert_eq!(g.transitions[0].conditions[0].op, CmpOp::Gt);
569    }
570
571    #[test]
572    fn deserialises_with_defaults() {
573        let g: AnimationGraph = serde_json::from_str("{}").unwrap();
574        assert!(g.target.is_none());
575        assert!(g.states.is_empty());
576        assert!(g.initial.is_empty());
577    }
578
579    #[test]
580    fn compiles_names_to_indices() {
581        crate::test_support::reset_interner();
582        let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
583        let compiled = g.compile(any_clip).unwrap();
584        assert_eq!(compiled.initial, 0);
585        assert_eq!(compiled.states[0].transitions.len(), 1);
586        let tr = &compiled.states[0].transitions[0];
587        assert_eq!(tr.to, 1);
588        assert_eq!(tr.conditions[0].param, 0);
589        // loop_override false beats the clip's own looping flag.
590        assert!(!compiled.states[1].looping);
591        assert!(compiled.states[0].looping);
592    }
593
594    #[test]
595    fn compile_empty_initial_defaults_to_first_state() {
596        crate::test_support::reset_interner();
597        let mut v = graph_json();
598        v["initial"] = serde_json::json!("");
599        let g: AnimationGraph = serde_json::from_value(v).unwrap();
600        assert_eq!(g.compile(any_clip).unwrap().initial, 0);
601    }
602
603    #[test]
604    fn compile_rejects_unknown_names() {
605        crate::test_support::reset_interner();
606        let mut v = graph_json();
607        v["transitions"][0]["to"] = serde_json::json!("ghost");
608        let g: AnimationGraph = serde_json::from_value(v).unwrap();
609        assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
610
611        let mut v = graph_json();
612        v["transitions"][0]["conditions"][0]["parameter"] = serde_json::json!("nope");
613        let g: AnimationGraph = serde_json::from_value(v).unwrap();
614        assert!(g.compile(any_clip).unwrap_err().contains("nope"));
615
616        let mut v = graph_json();
617        v["initial"] = serde_json::json!("ghost");
618        let g: AnimationGraph = serde_json::from_value(v).unwrap();
619        assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
620    }
621
622    #[test]
623    fn compile_rejects_unresolvable_clip_and_bad_rate() {
624        crate::test_support::reset_interner();
625        let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
626        assert!(g.compile(|_| None).unwrap_err().contains("clip"));
627
628        let mut v = graph_json();
629        v["states"][0]["rate"] = serde_json::json!(0.0);
630        let g: AnimationGraph = serde_json::from_value(v).unwrap();
631        assert!(g.compile(any_clip).unwrap_err().contains("rate"));
632    }
633
634    #[test]
635    fn compile_rejects_empty_graph() {
636        let g = AnimationGraph::default();
637        assert!(g.compile(any_clip).unwrap_err().contains("no states"));
638    }
639
640    fn blend1d_graph_json() -> serde_json::Value {
641        serde_json::json!({
642            "target": "hero",
643            "parameters": [{"name": "speed", "default": 0.0}],
644            "states": [
645                {"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
646                 "sync": true,
647                 "points": [
648                     {"value": 0.0, "clip": "idle"},
649                     {"value": 1.6, "clip": "walk"},
650                     {"value": 5.0, "clip": "run"}
651                 ]}}
652            ]
653        })
654    }
655
656    fn blend2d_graph_json() -> serde_json::Value {
657        serde_json::json!({
658            "target": "hero",
659            "parameters": [{"name": "speed"}, {"name": "strafe"}],
660            "states": [
661                {"name": "locomotion", "blend": {"kind": "blend2d",
662                 "parameter_x": "speed", "parameter_y": "strafe",
663                 "x_values": [0.0, 5.0], "y_values": [-1.0, 1.0],
664                 "rows": [["run_l", "run_l"], ["run_r", "run_r"]]}}
665            ]
666        })
667    }
668
669    #[test]
670    fn compiles_blend1d_state() {
671        crate::test_support::reset_interner();
672        let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
673        let compiled = g.compile(any_clip).unwrap();
674        let StatePlay::Blend1D(b) = &compiled.states[0].play else {
675            panic!("expected a 1D blendspace");
676        };
677        assert_eq!(b.param, 0);
678        assert_eq!(b.thresholds, vec![0.0, 1.6, 5.0]);
679        assert_eq!(b.plays.len(), 3);
680        assert!(b.sync);
681        assert!(compiled.states[0].looping, "blendspaces default to looping");
682    }
683
684    #[test]
685    fn compiles_blend2d_state() {
686        crate::test_support::reset_interner();
687        let g: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
688        let compiled = g.compile(any_clip).unwrap();
689        let StatePlay::Blend2D(b) = &compiled.states[0].play else {
690            panic!("expected a 2D blendspace");
691        };
692        assert_eq!((b.param_x, b.param_y), (0, 1));
693        assert_eq!(b.plays.len(), 4);
694        assert!(!b.sync);
695    }
696
697    // The authored JSON schema tags a blend with `kind`; the baked binary form
698    // uses the plain enum encoding. Both shapes must keep working.
699    #[test]
700    fn graph_blend_keeps_the_tagged_json_shape_and_round_trips_through_postcard() {
701        crate::test_support::reset_interner();
702        let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
703        let json = serde_json::to_value(&g).unwrap();
704        assert_eq!(
705            json["states"][0]["blend"]["kind"],
706            serde_json::json!("blend1d"),
707            "authored JSON stays kind-tagged"
708        );
709
710        let bytes = postcard::to_allocvec(&g).unwrap();
711        let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
712        let Some(AnimationBlend::Blend1d {
713            parameter,
714            points,
715            sync,
716        }) = &back.states[0].blend
717        else {
718            panic!("expected a 1D blendspace after the round trip");
719        };
720        assert_eq!(parameter, "speed");
721        assert_eq!(points.len(), 3);
722        assert!(sync);
723
724        let g2: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
725        let bytes = postcard::to_allocvec(&g2).unwrap();
726        let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
727        let Some(AnimationBlend::Blend2d { rows, .. }) = &back.states[0].blend else {
728            panic!("expected a 2D blendspace after the round trip");
729        };
730        assert_eq!(rows.len(), 2);
731    }
732
733    #[test]
734    fn compile_rejects_clip_and_blend_together_or_neither() {
735        crate::test_support::reset_interner();
736        let mut v = blend1d_graph_json();
737        v["states"][0]["clip"] = serde_json::json!("idle");
738        let g: AnimationGraph = serde_json::from_value(v).unwrap();
739        assert!(g.compile(any_clip).unwrap_err().contains("pick one"));
740
741        let v = serde_json::json!({"target":"hero","states":[{"name":"empty"}]});
742        let g: AnimationGraph = serde_json::from_value(v).unwrap();
743        assert!(
744            g.compile(any_clip)
745                .unwrap_err()
746                .contains("no `clip` or `blend`")
747        );
748    }
749
750    #[test]
751    fn compile_rejects_unsorted_blend_points() {
752        crate::test_support::reset_interner();
753        let mut v = blend1d_graph_json();
754        v["states"][0]["blend"]["points"][2]["value"] = serde_json::json!(1.0);
755        let g: AnimationGraph = serde_json::from_value(v).unwrap();
756        assert!(g.compile(any_clip).unwrap_err().contains("ascending"));
757    }
758
759    #[test]
760    fn compile_rejects_undeclared_blend_parameter() {
761        crate::test_support::reset_interner();
762        let mut v = blend1d_graph_json();
763        v["states"][0]["blend"]["parameter"] = serde_json::json!("nope");
764        let g: AnimationGraph = serde_json::from_value(v).unwrap();
765        assert!(g.compile(any_clip).unwrap_err().contains("nope"));
766    }
767
768    #[test]
769    fn compile_rejects_mismatched_grid_rows() {
770        crate::test_support::reset_interner();
771        let mut v = blend2d_graph_json();
772        v["states"][0]["blend"]["rows"] = serde_json::json!([["a", "b"]]);
773        let g: AnimationGraph = serde_json::from_value(v).unwrap();
774        assert!(g.compile(any_clip).unwrap_err().contains("rows"));
775    }
776}