diff --git a/README.md b/README.md index 552cae5..4bc6e39 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,27 @@ use `when_all([speed < 0.05, is_sitting == false])`. See `rust/examples/avatar.rhai` for the full surface: parameters, clips, curves, blend trees, sub-state machines, any-state transitions, and transition settings. +### Pre-made clips (animation store) + +An animation store seeds clips from `AnimationClip` assets that already exist in the project. +`store.clip(name)` resolves to `/.anim` and returns a motion usable anywhere a +generated clip is: as a state motion, as a blend tree child, or through `add_motion_direct`. A store +clip can be edited like any other clip. + +```rhai +let store = aac.AnimationStore("Assets/Doloro/Clips"); +let walk = store.clip("Walk"); // Assets/Doloro/Clips/Walk.anim +walk.looping(true); +walk.keyframe("Body/Props", "m_IsActive", 0.0, 0.0); +walk_state.set_clip(walk); +locomotion.add_motion(walk, 0.0); +``` + +The generator loads the asset with `AssetDatabase.LoadAssetAtPath`, clones it into the asset +container, and applies the script's keyframes and looping on the clone: the pre-made asset is never +modified. A store clip with no `looping(...)` call keeps the source asset's own looping setting. +Requesting the same asset twice reuses the one reference, and a missing asset is a clear error. + ## Generating Open *Tools > Animator As Crab* and fill in the window: diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs index cd437a6..0957118 100644 --- a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs @@ -36,7 +36,7 @@ namespace AnimatorAsCrab.V1 var floatingParameters = CreateParameters(graph, layers, controller); - var clips = new Dictionary(); + var clips = new Dictionary(); foreach (var clip in graph.Clips) { clips[clip.Name] = CreateClip(aac, clip); @@ -126,19 +126,38 @@ namespace AnimatorAsCrab.V1 controller.parameters = current; } - private static AacFlClip CreateClip(AacFlBase aac, AacCrabClip graph) + // A generated clip is created empty; a store clip starts from a clone of a pre-made asset. + // Either way the script's curves and looping override are applied to the clip owned by this + // generation, so a pre-made asset is never modified. + private static Motion CreateClip(AacFlBase aac, AacCrabClip graph) { - var clip = aac.NewClip(graph.Name); - if (graph.Looping) + AacFlClip clip; + if (!string.IsNullOrEmpty(graph.Source)) + { + var original = AssetDatabase.LoadAssetAtPath(graph.Source); + if (original == null) + { + throw new InvalidOperationException( + $"Animation store clip '{graph.Source}' does not exist. Check the store folder and the clip name."); + } + + clip = new AacFlClip(AacAccessorForExtensions.AccessConfiguration(aac), aac.DuplicateAsset(original)); + } + else + { + clip = aac.NewClip(graph.Name); + } + + if (graph.Looping == true) { clip.Looping(); } - else + else if (graph.Looping == false) { clip.NonLooping(); } - return clip.Animating(edit => + clip.Animating(edit => { foreach (var curve in graph.Curves) { @@ -149,6 +168,7 @@ namespace AnimatorAsCrab.V1 .WithAnimationCurve(new AnimationCurve(keys)); } }); + return clip.Clip; } private static Type UnityType(AacCrabTargetType target) @@ -167,7 +187,7 @@ namespace AnimatorAsCrab.V1 private static AacFlBlendTree CreateBlendTree( AacFlBase aac, AacCrabBlendTree graph, - IReadOnlyDictionary clips, + IReadOnlyDictionary clips, IReadOnlyDictionary trees, IReadOnlyDictionary floatingParameters) { @@ -232,7 +252,7 @@ namespace AnimatorAsCrab.V1 private static Motion MotionOf( AacCrabMotionRef reference, - IReadOnlyDictionary clips, + IReadOnlyDictionary clips, IReadOnlyDictionary trees) { switch (reference.Type) @@ -243,7 +263,7 @@ namespace AnimatorAsCrab.V1 throw new InvalidOperationException($"Clip '{reference.Name}' is not declared."); } - return clip.Clip; + return clip; case AacCrabMotionType.BlendTree: // Blend trees are built in declaration order, so a tree can only refer to an earlier one. if (!trees.TryGetValue(reference.Name, out var tree)) @@ -266,7 +286,7 @@ namespace AnimatorAsCrab.V1 private static void BuildLayer( AacFlLayer layer, AacCrabLayer graph, - IReadOnlyDictionary clips, + IReadOnlyDictionary clips, IReadOnlyDictionary trees) { if (graph.StateMachine == null) diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs index a3ce546..ab8d1c9 100644 --- a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs @@ -59,7 +59,10 @@ namespace AnimatorAsCrab.V1 public sealed class AacCrabClip { public string Name { get; set; } - public bool Looping { get; set; } + /// Looping override; null for a store clip that keeps the source asset's setting. + public bool? Looping { get; set; } + /// Asset path of a pre-made clip seeded into a store clip; null for generated clips. + public string Source { get; set; } public List Curves { get; set; } } diff --git a/rust/examples/animation_store.rhai b/rust/examples/animation_store.rhai new file mode 100644 index 0000000..ef980c1 --- /dev/null +++ b/rust/examples/animation_store.rhai @@ -0,0 +1,115 @@ +// Pre-made animation clips: a store seeds a clip from an `.anim` asset in the project. +// +// cargo run --bin aac-dump -- rust/examples/animation_store.rhai +// +// A store clip is a starting point. Requesting `Assets/.../.anim` produces a clip that is +// usable anywhere a generated clip is; anything written in the script (looping, keyframes, toggles, +// blend shapes) is applied to a clone, so the pre-made asset itself is never modified. A clip the +// script does not touch keeps the asset's own settings. +// +// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded. + +let aac = AnimatorAsCode(); +aac.system_name("AnimationStore"); +aac.asset_key("AAC_STORE_"); + +let speed = aac.float_param("Speed", 0.0); +let vertical = aac.float_param("Vertical", 0.0); +let grip = aac.float_param("Grip", 0.0); +let crouching = aac.bool_param("Crouching", false); +let gesture = aac.int_param("Gesture", 0); + +let ctrl = aac.new_controller(); +let locomotion_layer = ctrl.layer("Locomotion"); +let props_layer = ctrl.layer("Props"); + +// A store per folder. The name is joined as written, so it may contain subfolders, and the `.anim` +// extension is optional. +let locomotion = aac.AnimationStore("Assets/Doloro/Clips/Locomotion"); +let props = aac.AnimationStore("Assets/Doloro/Clips/Props/"); +let directions = aac.AnimationStore("Assets/Doloro/Clips/Directions"); + +let idle_clip = locomotion.clip("Idle"); +let walk_clip = locomotion.clip("Walk"); +let run_clip = locomotion.clip("Run"); + +// Requesting the same asset again -- with the extension this time -- returns the same clip, so it is +// only declared once and only one clone is generated. +let walk_again = locomotion.clip("Walk.anim"); + +// Untouched: `Idle` is used as authored, with no looping override and no curves added here. +let idle = locomotion_layer.state("Idle", 0, 0); +idle.set_clip(idle_clip); + +// Edited: `Walk` loops and toggles a prop on top of whatever the asset already animates. +walk_clip.looping(true); +walk_clip.toggle("Body/Props/Umbrella", true); +let walk = locomotion_layer.state("Walk", 1, 0); +walk.set_clip(walk_clip); + +// Edited: `Run` forces non-looping and blends a smile in over one second. +run_clip.looping(false); +run_clip.blend_shape("Body", "Smile", 0.0, 0.0); +run_clip.blend_shape("Body", "Smile", 1.0, 1.0); +let run = locomotion_layer.state("Run", 2, 0); +run.set_clip(run_clip); + +// --- Blend trees over store clips + +// A 1D tree over three store clips. `idle_clip` is shared with the `Idle` state above. +let movement = aac.blend_tree("movement"); +movement.simple_1d(speed); +movement.add_motion(idle_clip, 0.0); +movement.add_motion(walk_clip, 2.0); +movement.add_motion(run_clip, 5.0); + +// A 2D tree over four directional assets. +let forward = directions.clip("Forward"); +let back = directions.clip("Back"); +let left = directions.clip("Left"); +let right = directions.clip("Right"); +let strafe = aac.blend_tree("strafe"); +strafe.freeform_directional_2d(speed, vertical); +strafe.add_motion(forward, 0.0, 1.0); +strafe.add_motion(back, 0.0, -1.0); +strafe.add_motion(left, -1.0, 0.0); +strafe.add_motion(right, 1.0, 0.0); + +// A tree may nest trees declared before it, so the two above blend into one full-body motion. +let full_body = aac.blend_tree("full_body"); +full_body.simple_1d(speed); +full_body.add_motion(movement, 0.0); +full_body.add_motion(strafe, 5.0); + +let move = locomotion_layer.state("Move", 3, 0); +move.set_motion(full_body); + +// A direct tree addresses a store clip by parameter, here a clip nested in a store subfolder. +let umbrella = props.clip("Weapons/Umbrella"); +umbrella.looping(true); +let hold = aac.blend_tree("hold"); +hold.direct(); +hold.add_motion_direct(umbrella, grip); + +let holding = props_layer.state("Hold", 0, 0); +holding.set_motion(hold); + +// --- Transitions, any-state, and a sub-state machine + +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.2); +walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.2); +run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.2); +walk.transition_to(idle).when_all([speed < 0.05, crouching == false]).duration(0.2); + +let sit_clip = locomotion.clip("Sit"); +let sit = locomotion_layer.state("Sit", 4, 0); +sit.set_clip(sit_clip); +locomotion_layer.any_state().transition_to(sit).when(crouching == true).no_exit_time().duration(0.15); + +let gestures = locomotion_layer.sub_machine("Gestures", 5, 0); +let wave = gestures.state("Wave", 0, 0); +let point = gestures.state("Point", 1, 0); +wave.set_clip(props.clip("Gestures/Wave")); +point.set_clip(props.clip("Gestures/Point")); +wave.transition_to(point).when(gesture == 1).duration(0.1); +point.transition_to(wave).when(gesture == 0).duration(0.1); diff --git a/rust/examples/avatar.rhai b/rust/examples/avatar.rhai index 8670f27..1819958 100644 --- a/rust/examples/avatar.rhai +++ b/rust/examples/avatar.rhai @@ -35,6 +35,14 @@ let run_clip = aac.clip("run_anim"); run_clip.looping(true); run_clip.toggle("Body/Props", false); +// A pre-made clip: it is cloned into the container at generation time, so it can be edited here +// without touching the asset stored in Assets/Doloro/Clips. +let store = aac.AnimationStore("Assets/Doloro/Clips"); +let crouch_clip = store.clip("Crouch"); +crouch_clip.looping(true); +crouch_clip.toggle("Body/Props", true); +sit.set_clip(crouch_clip); + idle.set_clip(idle_clip); walk.set_clip(walk_clip); diff --git a/rust/src/builder.rs b/rust/src/builder.rs index b5b40be..4763bd7 100644 --- a/rust/src/builder.rs +++ b/rust/src/builder.rs @@ -87,6 +87,13 @@ pub struct ClipBuilder { pub(crate) name: String, } +/// A folder of pre-made `AnimationClip` assets, resolved on the Unity side at generation time. +#[derive(Clone)] +pub struct AnimationStore { + pub(crate) aac: Aac, + pub(crate) folder: String, +} + #[derive(Clone)] pub struct BlendTreeBuilder { pub(crate) aac: Aac, @@ -197,6 +204,12 @@ impl ClipBuilder { self.aac.write(|graph| clip_mut(graph, &name).map(f)) } + /// Override the clip's looping setting. A store clip with no override keeps the source asset's own setting. + pub fn set_looping(&self, value: bool) -> Result<(), String> { + self.data_mut(|data| data.looping = Some(value)); + Ok(()) + } + /// Append one keyframe to the curve for (path, property), creating the curve if needed. pub fn push_key(&self, path: &str, property: &str, key: Keyframe) -> Result<(), String> { let target = infer_target(property).ok_or_else(|| { @@ -212,6 +225,38 @@ impl ClipBuilder { } } +impl AnimationStore { + /// Resolve `folder/.anim` and declare it as a clip the first time it is requested. The clip + /// name is the asset path, so the same asset seen through different names is still one clip. + pub fn clip(&self, name: &str) -> Result { + if name.trim().is_empty() { + return Err("animation store clip name is empty".to_string()); + } + let mut path = format!( + "{}/{}", + self.folder.trim_end_matches('/'), + name.trim_start_matches('/') + ); + if !path.ends_with(".anim") { + path.push_str(".anim"); + } + self.aac.write(|graph| { + if !graph.clips.iter().any(|clip| clip.source.as_deref() == Some(path.as_str())) { + graph.clips.push(ClipData { + name: path.clone(), + looping: None, + curves: Vec::new(), + source: Some(path.clone()), + }); + } + }); + Ok(ClipBuilder { + aac: self.aac.clone(), + name: path, + }) + } +} + impl BlendTreeBuilder { pub fn data_mut(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option { let name = self.name.clone(); diff --git a/rust/src/graph.rs b/rust/src/graph.rs index 4666cac..528a49e 100644 --- a/rust/src/graph.rs +++ b/rust/src/graph.rs @@ -111,8 +111,14 @@ pub enum CondMode { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ClipData { pub name: String, - pub looping: bool, + /// `None` for a store clip that leaves the source asset's looping setting alone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub looping: Option, pub curves: Vec, + /// Set when the clip is seeded from an asset (see `AnimationStore`): the project-relative asset + /// path. Omitted for generated clips so their JSON stays unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/rust/src/motion_api.rs b/rust/src/motion_api.rs index d41a31e..cf59daf 100644 --- a/rust/src/motion_api.rs +++ b/rust/src/motion_api.rs @@ -5,6 +5,7 @@ use rhai::{Engine, EvalAltResult}; pub fn register(engine: &mut Engine) { register_clips(engine); + register_animation_store(engine); register_blend_trees(engine); register_state_motion(engine); } @@ -32,8 +33,9 @@ fn register_clips(engine: &mut Engine) { a.write(|graph| { graph.clips.push(ClipData { name: name.to_string(), - looping: false, + looping: Some(false), curves: Vec::new(), + source: None, }) }); Ok(ClipBuilder { @@ -42,9 +44,9 @@ fn register_clips(engine: &mut Engine) { }) }); - engine.register_fn("looping", |clip: ClipBuilder, value: bool| { - clip.data_mut(|data| data.looping = value); - clip + engine.register_fn("looping", |clip: ClipBuilder, value: bool| -> Result> { + clip.set_looping(value).map_err(err)?; + Ok(clip) }); engine.register_fn( @@ -77,6 +79,28 @@ fn register_clips(engine: &mut Engine) { ); } +fn register_animation_store(engine: &mut Engine) { + engine.register_fn( + "AnimationStore", + |a: &mut Aac, folder: &str| -> Result> { + if folder.trim().is_empty() { + return Err(err("animation store folder is empty".to_string())); + } + Ok(AnimationStore { + aac: a.clone(), + folder: folder.to_string(), + }) + }, + ); + + engine.register_fn( + "clip", + |store: AnimationStore, name: &str| -> Result> { + store.clip(name).map_err(err) + }, + ); +} + fn register_blend_trees(engine: &mut Engine) { engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result> { if name.trim().is_empty() { diff --git a/rust/src/rhai_api.rs b/rust/src/rhai_api.rs index 7dca468..3740253 100644 --- a/rust/src/rhai_api.rs +++ b/rust/src/rhai_api.rs @@ -89,6 +89,7 @@ pub fn engine(aac: Aac) -> Engine { engine.register_type_with_name::("State"); engine.register_type_with_name::("Transition"); engine.register_type_with_name::("Clip"); + engine.register_type_with_name::("AnimationStore"); engine.register_type_with_name::("BlendTree"); register_conditions(&mut engine); diff --git a/rust/tests/dsl.rs b/rust/tests/dsl.rs index 16827ac..196c91d 100644 --- a/rust/tests/dsl.rs +++ b/rust/tests/dsl.rs @@ -2,6 +2,7 @@ use aac::graph::*; use serde_json::Value; const AVATAR: &str = include_str!("../examples/avatar.rhai"); +const ANIMATION_STORE: &str = include_str!("../examples/animation_store.rhai"); fn json(script: &str) -> Value { serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json") @@ -37,7 +38,7 @@ fn example_script_describes_the_whole_controller() { assert_eq!(value["system_name"], "MyAvatar"); assert_eq!(value["asset_key"], "AAC_"); assert_eq!(value["parameters"].as_array().unwrap().len(), 3); - assert_eq!(value["clips"].as_array().unwrap().len(), 3); + assert_eq!(value["clips"].as_array().unwrap().len(), 4); assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1); let layers = value["controller"]["layers"].as_array().unwrap(); @@ -107,6 +108,122 @@ fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value { .unwrap() } +#[test] +fn animation_store_clips_are_referenced_not_generated() { + let value = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let store = aac.AnimationStore("Assets/Doloro/Clips/"); + let walk = store.clip("Walk"); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let s = base.state("S", 0, 0); + s.set_clip(walk); + "#, + ); + let clips = value["clips"].as_array().unwrap(); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0]["name"], "Assets/Doloro/Clips/Walk.anim"); + assert_eq!(clips[0]["source"], "Assets/Doloro/Clips/Walk.anim"); + assert!(clips[0]["curves"].as_array().unwrap().is_empty()); + // Untouched, it keeps the source asset's looping setting. + assert!(clips[0].get("looping").is_none()); + + // A generated clip keeps its old shape: no `source` key at all. + let generated = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let c = aac.clip("c"); + "#, + ); + assert!(generated["clips"][0].get("source").is_none()); +} + +#[test] +fn animation_store_resolves_one_clip_per_asset() { + // The same asset asked for twice, with and without the extension, stays a single clip. + let value = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let store = aac.AnimationStore("Assets/Doloro/Clips"); + let x = store.clip("Walk"); + let y = store.clip("Walk.anim"); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let s = base.state("S", 0, 0); + s.set_clip(x); + "#, + ); + assert_eq!(value["clips"].as_array().unwrap().len(), 1); +} + +#[test] +fn animation_store_clips_can_be_edited_on_a_clone() { + let value = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let store = aac.AnimationStore("Assets/Doloro/Clips"); + let walk = store.clip("Walk"); + walk.looping(true); + walk.keyframe("Body", "m_IsActive", 0.0, 1.0); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let s = base.state("S", 0, 0); + s.set_clip(walk); + "#, + ); + let clip = &value["clips"][0]; + // The source is still carried, so the Unity side knows which asset to clone. + assert_eq!(clip["source"], "Assets/Doloro/Clips/Walk.anim"); + assert_eq!(clip["looping"], true); + assert_eq!(clip["curves"][0]["property"], "m_IsActive"); +} + +/// The store example is a whole controller, so evaluating it exercises every store path end to end. +#[test] +fn animation_store_example_sources_every_clip() { + let value = json(ANIMATION_STORE); + + assert_eq!(value["system_name"], "AnimationStore"); + assert_eq!(value["parameters"].as_array().unwrap().len(), 5); + assert_eq!(value["controller"]["layers"].as_array().unwrap().len(), 2); + + // Every clip comes from an asset, and each asset is declared exactly once. + let clips = value["clips"].as_array().unwrap(); + let sources: Vec<&str> = clips + .iter() + .map(|clip| clip["source"].as_str().expect("every clip has a source")) + .collect(); + let unique: std::collections::HashSet<&str> = sources.iter().copied().collect(); + assert_eq!(unique.len(), sources.len(), "the same asset must not be declared twice"); + assert_eq!(clips.len(), 11); + + // An edited clip carries its override and curves; an untouched one carries neither. + let walk = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Walk.anim"); + assert_eq!(walk["looping"], true); + assert_eq!(curve_of(walk, "m_IsActive")["target"], "game_object"); + let idle = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Idle.anim"); + assert!(idle.get("looping").is_none()); + assert!(idle["curves"].as_array().unwrap().is_empty()); + + // A store clip feeds a state and a blend tree, and a tree may nest a tree declared before it. + let trees = value["blend_trees"].as_array().unwrap(); + assert_eq!(trees.len(), 4); + let movement = trees.iter().find(|t| t["name"] == "movement").unwrap(); + assert_eq!(movement["children"].as_array().unwrap().len(), 3); + let full_body = trees.iter().find(|t| t["name"] == "full_body").unwrap(); + assert_eq!(full_body["children"][0]["motion"]["type"], "blend_tree"); + assert_eq!(full_body["children"][0]["motion"]["name"], "movement"); +} + #[test] fn keyframes_are_sorted_by_time() { let value = json(