Compare commits
3
Commits
ea45f7ef1a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3b670ab9
|
||
|
|
42a1234e7f
|
||
|
|
5851077659
|
@@ -1,55 +0,0 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
packageName: "dev.hai-vr.animator-as-code.v1"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: get version
|
||||
id: version
|
||||
uses: notiz-dev/github-action-json-property@7c8cf5cc36eb85d8d287a8086a39dac59628eb31
|
||||
with:
|
||||
path: "Packages/${{env.packageName}}/package.json"
|
||||
prop_path: "version"
|
||||
|
||||
- name: Set Environment Variables
|
||||
run: |
|
||||
echo "zipFile=${{ env.packageName }}-${{ steps.version.outputs.prop }}".zip >> $GITHUB_ENV
|
||||
echo "unityPackage=${{ env.packageName }}-${{ steps.version.outputs.prop }}.unitypackage" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Zip
|
||||
uses: thedoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657
|
||||
with:
|
||||
type: "zip"
|
||||
directory: "Packages/${{env.packageName}}/"
|
||||
filename: "../../${{env.zipFile}}" # make the zip file two directories up, since we start two directories in above
|
||||
|
||||
- run: find "Packages/${{env.packageName}}/" -name \*.meta >> metaList
|
||||
|
||||
- name: Create UnityPackage
|
||||
uses: pCYSl5EDgo/create-unitypackage@cfcd3cf0391a5ef1306342794866a9897c32af0b
|
||||
with:
|
||||
package-path: ${{ env.unityPackage }}
|
||||
include-files: metaList
|
||||
|
||||
|
||||
- name: Make Release
|
||||
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.prop }}
|
||||
files: |
|
||||
${{ env.zipFile }}
|
||||
${{ env.unityPackage }}
|
||||
Packages/${{ env.packageName }}/package.json
|
||||
@@ -0,0 +1 @@
|
||||
/result
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "dev.hai-vr.animator-as-code.v1",
|
||||
"displayName": "Animator As Code V1",
|
||||
"version": "1.3.0-alpha.1",
|
||||
"unity": "2019.4",
|
||||
"description": "Base Animator As Code library. This library only requires Unity.",
|
||||
"vrchatVersion" : "2022.1.1",
|
||||
"author" : {
|
||||
"name" : "Haï~"
|
||||
},
|
||||
"url" : "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
|
||||
"documentationUrl": "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
|
||||
"changelogUrl": "https://docs.hai-vr.dev/docs/changelogs/animator-as-code",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,33 +1,103 @@
|
||||
Animator As Code V1
|
||||
====
|
||||
## Animator As Crab
|
||||
|
||||
**Animator As Code** is a small Unity Editor facility to generate Animator layers and animations from a [fluent builder](https://en.wikipedia.org/wiki/Fluent_interface) syntax written in C#.
|
||||
A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where animator
|
||||
controllers are written in [Rhai](https://rhai.rs/) instead of C#.
|
||||
|
||||
Describing your animators as code provides the following advantages:
|
||||
A Rhai script is evaluated by a Rust library (`libaac`), which validates it and emits the whole
|
||||
controller as JSON. A Unity Editor window deserializes that JSON and builds a real
|
||||
`AnimatorController` through the original Animator As Code V1 library, which is left untouched.
|
||||
|
||||
- you do not need to edit your animations by hand every time you add remove or change the location of a component in your hierarchy
|
||||
- you will not need to edit a hundred transitions by hand if you need to rectify your animator
|
||||
```
|
||||
script.rhai --> libaac (Rust) --JSON--> AacCrabWindow (Unity Editor) --> AnimatorController
|
||||
```
|
||||
|
||||
Initially designed for use in VRChat to create Avatars 3.0 animator controllers, this is now a generic package that does not require it.
|
||||
## Layout
|
||||
|
||||
https://user-images.githubusercontent.com/60819407/157751278-475538c7-3310-4fa5-9a87-3651c85eaa1c.mp4
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `rust/` | The Rhai DSL, graph model, validator, and the `libaac` cdylib. |
|
||||
| `rust/examples/avatar.rhai` | The reference script, exercising every feature of the DSL. |
|
||||
| `csharp/dev.doloro.animator-as-crab/` | The Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. |
|
||||
| `flake.nix` | Dev shell with the Rust toolchain, `dotnet`, `mono`, and `jq`. |
|
||||
|
||||
Initially created by **[@hai-vr](https://github.com/hai-vr)**,
|
||||
- with major contributions from **[@galister](https://github.com/galister)** for supporting sub-state machines and the generic use of nodes and parameters.
|
||||
## Building
|
||||
|
||||
## Installation
|
||||
```sh
|
||||
nix develop
|
||||
cargo test --manifest-path rust/Cargo.toml
|
||||
cargo run --bin aac-dump -- rust/examples/avatar.rhai # print the generated JSON
|
||||
nix build .#aac # libaac.so + aac-dump
|
||||
```
|
||||
|
||||
Instructions to install are available in [this page](https://docs.hai-vr.dev/docs/products/animator-as-code/install).
|
||||
Copy `libaac.so` (or `libaac.dll`, `libaac.dylib`) into the Unity project's `Assets/Plugins` folder,
|
||||
and add `csharp/dev.doloro.animator-as-crab` to the project's packages.
|
||||
|
||||
### Documentation
|
||||
## Writing a script
|
||||
|
||||
- [Documentation](https://docs.hai-vr.dev/docs/products/animator-as-code)
|
||||
- [V1 Reference manual](https://docs.hai-vr.dev/docs/products/animator-as-code/reference)
|
||||
- [V1 Functions reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base)
|
||||
- [VRChat reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat)
|
||||
- [VRChat (Destructive Workflow) reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat-destructive-workflow)
|
||||
- [Modular Avatar As Code reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/modular-avatar)
|
||||
```rhai
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("MyAvatar");
|
||||
aac.asset_key("AAC_");
|
||||
|
||||
## Animator As Code V0
|
||||
let speed = aac.float_param("Speed", 0.0);
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
|
||||
- You can browse the repository in its **[V0](https://github.com/hai-vr/av3-animator-as-code/tree/V1-2022-04) state**.
|
||||
let idle = base.state("Idle", 0, 0);
|
||||
let walk = base.state("Walk", 1, 0);
|
||||
|
||||
let walk_clip = aac.clip("walk_anim");
|
||||
walk_clip.toggle("Body/Props", true);
|
||||
|
||||
walk.set_clip(walk_clip);
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||
```
|
||||
|
||||
Conditions are written on the parameters themselves (`speed > 0.1`, `is_sitting == true`).
|
||||
There is no `&&` or `||` — Rhai's are short-circuit built-ins that cannot be overloaded — so
|
||||
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 `<folder>/<name>.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:
|
||||
|
||||
- the path to the `.rhai` script (it is watched, so saving the script regenerates the controller),
|
||||
- the `AnimatorController` to generate into,
|
||||
- the animator root, the asset container, and the container mode,
|
||||
- whether states should write defaults.
|
||||
|
||||
Nothing is defaulted silently, and the controller is rebuilt with Animator As Code's modification
|
||||
API: the controller is cleared, and the clips and blend trees of the same asset key are removed
|
||||
from the container before being recreated. Animator As Code's own C# API is untouched and still
|
||||
usable on the same controller.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The Unity side has not been run: there is no Unity project in this repository, so the bridge and
|
||||
the generator are verified by reading, not by executing.
|
||||
- Blend trees can only nest a blend tree that was declared earlier in the script.
|
||||
- The graph carries no avatar masks, layer weights, state behaviours, or parameter drivers yet.
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [],
|
||||
"precompiledReferences": [
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
@@ -0,0 +1,427 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AnimatorAsCode.V1;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AnimatorAsCrab.V1
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns an <see cref="AacCrabGraph"/> into an AnimatorController by driving Animator As Code V1.
|
||||
/// This uses the modification workflow: the controller is cleared and rebuilt, and no layer of the
|
||||
/// controller is touched beforehand.
|
||||
/// </summary>
|
||||
public static class AacCrabGenerator
|
||||
{
|
||||
/// <summary>Clears the AnimatorController and the assets of the same asset key, then rebuilds them.</summary>
|
||||
public static void Generate(AacCrabGraph graph, AacConfiguration configuration, AnimatorController controller)
|
||||
{
|
||||
if (graph == null) throw new ArgumentNullException(nameof(graph));
|
||||
if (controller == null) throw new ArgumentNullException(nameof(controller));
|
||||
if (graph.Controller == null) throw new InvalidOperationException("The graph has no controller.");
|
||||
|
||||
var aac = AacV1.Create(configuration);
|
||||
var modification = aac.Modification();
|
||||
|
||||
aac.ClearPreviousAssets();
|
||||
var aacController = modification.ResetAnimatorController(controller);
|
||||
|
||||
var layers = new Dictionary<string, AacFlLayer>();
|
||||
foreach (var layer in graph.Controller.Layers)
|
||||
{
|
||||
layers[layer.Name] = aacController.NewLayer(layer.Name);
|
||||
}
|
||||
|
||||
var floatingParameters = CreateParameters(graph, layers, controller);
|
||||
|
||||
var clips = new Dictionary<string, Motion>();
|
||||
foreach (var clip in graph.Clips)
|
||||
{
|
||||
clips[clip.Name] = CreateClip(aac, clip);
|
||||
}
|
||||
|
||||
// Blend trees are created in declaration order, so a child motion can only refer to a tree
|
||||
// that was declared before its parent. This also makes cycles impossible.
|
||||
var blendTrees = new Dictionary<string, AacFlBlendTree>();
|
||||
foreach (var blendTree in graph.BlendTrees)
|
||||
{
|
||||
blendTrees[blendTree.Name] = CreateBlendTree(aac, blendTree, clips, blendTrees, floatingParameters);
|
||||
}
|
||||
|
||||
foreach (var layer in graph.Controller.Layers)
|
||||
{
|
||||
BuildLayer(layers[layer.Name], layer, clips, blendTrees);
|
||||
}
|
||||
|
||||
modification.SetDirtyAll();
|
||||
EditorUtility.SetDirty(controller);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, AacFlFloatParameter> CreateParameters(AacCrabGraph graph, IReadOnlyDictionary<string, AacFlLayer> layers, AnimatorController controller)
|
||||
{
|
||||
var floatingParameters = new Dictionary<string, AacFlFloatParameter>();
|
||||
if (graph.Parameters.Count == 0)
|
||||
{
|
||||
return floatingParameters;
|
||||
}
|
||||
|
||||
if (layers.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The controller declares parameters but no layer to hold them.");
|
||||
}
|
||||
|
||||
// Parameters are controller-wide, so any layer can create them.
|
||||
var layer = layers.First().Value;
|
||||
foreach (var parameter in graph.Parameters)
|
||||
{
|
||||
switch (parameter.Type)
|
||||
{
|
||||
case AacCrabParameterType.Float:
|
||||
floatingParameters[parameter.Name] = layer.FloatParameter(parameter.Name);
|
||||
break;
|
||||
case AacCrabParameterType.Int:
|
||||
layer.IntParameter(parameter.Name);
|
||||
break;
|
||||
case AacCrabParameterType.Bool:
|
||||
layer.BoolParameter(parameter.Name);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown parameter type {parameter.Type}.");
|
||||
}
|
||||
}
|
||||
|
||||
ApplyParameterDefaults(controller, graph.Parameters);
|
||||
return floatingParameters;
|
||||
}
|
||||
|
||||
// Animator As Code creates parameters with Unity's defaults; the graph's defaults are applied
|
||||
// afterwards by mutating the controller's own parameter list.
|
||||
private static void ApplyParameterDefaults(AnimatorController controller, IEnumerable<AacCrabParameter> parameters)
|
||||
{
|
||||
var wanted = parameters.ToDictionary(parameter => parameter.Name);
|
||||
var current = controller.parameters;
|
||||
foreach (var parameter in current)
|
||||
{
|
||||
if (!wanted.TryGetValue(parameter.name, out var declared))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (declared.Type)
|
||||
{
|
||||
case AacCrabParameterType.Float:
|
||||
parameter.defaultFloat = declared.DefaultFloat;
|
||||
break;
|
||||
case AacCrabParameterType.Int:
|
||||
parameter.defaultInt = declared.DefaultInt;
|
||||
break;
|
||||
case AacCrabParameterType.Bool:
|
||||
parameter.defaultBool = declared.DefaultBool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
controller.parameters = current;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
AacFlClip clip;
|
||||
if (!string.IsNullOrEmpty(graph.Source))
|
||||
{
|
||||
var original = AssetDatabase.LoadAssetAtPath<AnimationClip>(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 if (graph.Looping == false)
|
||||
{
|
||||
clip.NonLooping();
|
||||
}
|
||||
|
||||
clip.Animating(edit =>
|
||||
{
|
||||
foreach (var curve in graph.Curves)
|
||||
{
|
||||
var keys = curve.Keys
|
||||
.Select(key => new Keyframe(key.Time, key.Value, key.InTangent, key.OutTangent))
|
||||
.ToArray();
|
||||
edit.Animates(curve.Path, UnityType(curve.Target), curve.Property)
|
||||
.WithAnimationCurve(new AnimationCurve(keys));
|
||||
}
|
||||
});
|
||||
return clip.Clip;
|
||||
}
|
||||
|
||||
private static Type UnityType(AacCrabTargetType target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case AacCrabTargetType.GameObject:
|
||||
return typeof(GameObject);
|
||||
case AacCrabTargetType.SkinnedMeshRenderer:
|
||||
return typeof(SkinnedMeshRenderer);
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown curve target {target}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AacFlBlendTree CreateBlendTree(
|
||||
AacFlBase aac,
|
||||
AacCrabBlendTree graph,
|
||||
IReadOnlyDictionary<string, Motion> clips,
|
||||
IReadOnlyDictionary<string, AacFlBlendTree> trees,
|
||||
IReadOnlyDictionary<string, AacFlFloatParameter> floatingParameters)
|
||||
{
|
||||
Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
|
||||
|
||||
AacFlFloatParameter FloatParameter(string name)
|
||||
{
|
||||
if (name == null || !floatingParameters.TryGetValue(name, out var parameter))
|
||||
{
|
||||
throw new InvalidOperationException($"Blend tree '{graph.Name}' uses '{name}', which is not a Float parameter.");
|
||||
}
|
||||
|
||||
return parameter;
|
||||
}
|
||||
|
||||
var uninitialized = aac.NewBlendTree(graph.Name);
|
||||
switch (graph.BlendType)
|
||||
{
|
||||
case AacCrabBlendType.Simple1D:
|
||||
{
|
||||
var tree = uninitialized.Simple1D(FloatParameter(graph.ParamX));
|
||||
tree.BlendTree.useAutomaticThresholds = graph.UseAutomaticThresholds;
|
||||
foreach (var child in graph.Children)
|
||||
{
|
||||
tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
case AacCrabBlendType.SimpleDirectional2D:
|
||||
case AacCrabBlendType.FreeformDirectional2D:
|
||||
case AacCrabBlendType.FreeformCartesian2D:
|
||||
{
|
||||
var x = FloatParameter(graph.ParamX);
|
||||
var y = FloatParameter(graph.ParamY);
|
||||
var tree = graph.BlendType == AacCrabBlendType.SimpleDirectional2D
|
||||
? uninitialized.SimpleDirectional2D(x, y)
|
||||
: graph.BlendType == AacCrabBlendType.FreeformDirectional2D
|
||||
? uninitialized.FreeformDirectional2D(x, y)
|
||||
: uninitialized.FreeformCartesian2D(x, y);
|
||||
foreach (var child in graph.Children)
|
||||
{
|
||||
tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f, child.ThresholdY ?? 0f);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
case AacCrabBlendType.Direct:
|
||||
{
|
||||
var tree = uninitialized.Direct();
|
||||
foreach (var child in graph.Children)
|
||||
{
|
||||
tree.WithAnimation(Resolve(child.Motion), FloatParameter(child.DirectParam));
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown blend type {graph.BlendType}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Motion MotionOf(
|
||||
AacCrabMotionRef reference,
|
||||
IReadOnlyDictionary<string, Motion> clips,
|
||||
IReadOnlyDictionary<string, AacFlBlendTree> trees)
|
||||
{
|
||||
switch (reference.Type)
|
||||
{
|
||||
case AacCrabMotionType.Clip:
|
||||
if (!clips.TryGetValue(reference.Name, out var clip))
|
||||
{
|
||||
throw new InvalidOperationException($"Clip '{reference.Name}' is not declared.");
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
throw new InvalidOperationException($"Blend tree '{reference.Name}' is not declared yet; declare it before the motion that uses it.");
|
||||
}
|
||||
|
||||
return tree.BlendTree;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown motion type {reference.Type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MachineScope
|
||||
{
|
||||
public AacFlStateMachine Machine;
|
||||
public AacCrabStateMachine Graph;
|
||||
}
|
||||
|
||||
private static void BuildLayer(
|
||||
AacFlLayer layer,
|
||||
AacCrabLayer graph,
|
||||
IReadOnlyDictionary<string, Motion> clips,
|
||||
IReadOnlyDictionary<string, AacFlBlendTree> trees)
|
||||
{
|
||||
if (graph.StateMachine == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Layer '{graph.Name}' has no state machine.");
|
||||
}
|
||||
|
||||
Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
|
||||
|
||||
// State names are unique within a layer, so a single dictionary resolves every transition,
|
||||
// including the ones that cross state machines.
|
||||
var states = new Dictionary<string, AacFlState>();
|
||||
var scopes = new List<MachineScope>();
|
||||
|
||||
AacFlStateMachine CreateMachine(AacFlStateMachine machine, AacCrabStateMachine machineGraph)
|
||||
{
|
||||
scopes.Add(new MachineScope { Machine = machine, Graph = machineGraph });
|
||||
foreach (var state in machineGraph.States)
|
||||
{
|
||||
var aacState = machine.NewState(state.Name, state.Position.X, state.Position.Y);
|
||||
if (state.Motion != null)
|
||||
{
|
||||
aacState.WithAnimation(Resolve(state.Motion));
|
||||
}
|
||||
|
||||
states[state.Name] = aacState;
|
||||
}
|
||||
|
||||
foreach (var subMachine in machineGraph.SubMachines)
|
||||
{
|
||||
CreateMachine(machine.NewSubStateMachine(subMachine.Name, subMachine.Position.X, subMachine.Position.Y), subMachine);
|
||||
}
|
||||
|
||||
return machine;
|
||||
}
|
||||
|
||||
// Create every state first: transitions may point forward.
|
||||
CreateMachine(layer.StateMachine, graph.StateMachine);
|
||||
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
foreach (var state in scope.Graph.States)
|
||||
{
|
||||
foreach (var transition in state.Transitions)
|
||||
{
|
||||
ApplyTransition(states[state.Name].TransitionsTo(Destination(states, transition)), transition);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var transition in scope.Graph.AnyStateTransitions)
|
||||
{
|
||||
ApplyTransition(scope.Machine.AnyTransitionsTo(Destination(states, transition)), transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AacFlState Destination(IReadOnlyDictionary<string, AacFlState> states, AacCrabTransition transition)
|
||||
{
|
||||
if (!states.TryGetValue(transition.To, out var destination))
|
||||
{
|
||||
throw new InvalidOperationException($"Transition target '{transition.To}' is not a state in this layer.");
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
|
||||
private static void ApplyTransition(AacFlTransition transition, AacCrabTransition graph)
|
||||
{
|
||||
transition.WithTransitionDurationSeconds(graph.Duration);
|
||||
if (graph.OrderedInterruption)
|
||||
{
|
||||
transition.WithOrderedInterruption();
|
||||
}
|
||||
else
|
||||
{
|
||||
transition.WithNoOrderedInterruption();
|
||||
}
|
||||
|
||||
if (graph.SourceInterruption)
|
||||
{
|
||||
transition.WithSourceInterruption();
|
||||
}
|
||||
|
||||
if (graph.CanTransitionToSelf)
|
||||
{
|
||||
transition.WithTransitionToSelf();
|
||||
}
|
||||
|
||||
if (graph.HasExitTime)
|
||||
{
|
||||
transition.AfterAnimationIsAtLeastAtNormalized(graph.ExitTime);
|
||||
}
|
||||
|
||||
// Conditions are applied last: Animator As Code forbids configuring a transition afterwards.
|
||||
if (graph.Conditions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var continuation = transition.When(Condition(graph.Conditions[0]));
|
||||
for (var index = 1; index < graph.Conditions.Count; index++)
|
||||
{
|
||||
continuation = continuation.And(Condition(graph.Conditions[index]));
|
||||
}
|
||||
}
|
||||
|
||||
// Animator As Code only exposes typed comparisons for some parameter types, so conditions are
|
||||
// built the same way the library builds them internally.
|
||||
private static IAacFlCondition Condition(AacCrabCondition condition)
|
||||
{
|
||||
var parameter = condition.Parameter;
|
||||
var mode = UnityConditionMode(condition.Mode);
|
||||
var threshold = condition.Threshold;
|
||||
return AacFlConditionSimple.Just(appender => appender.Add(parameter, mode, threshold));
|
||||
}
|
||||
|
||||
private static AnimatorConditionMode UnityConditionMode(AacCrabCondMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case AacCrabCondMode.Greater:
|
||||
return AnimatorConditionMode.Greater;
|
||||
case AacCrabCondMode.Less:
|
||||
return AnimatorConditionMode.Less;
|
||||
case AacCrabCondMode.Equals:
|
||||
return AnimatorConditionMode.Equals;
|
||||
case AacCrabCondMode.NotEqual:
|
||||
return AnimatorConditionMode.NotEqual;
|
||||
case AacCrabCondMode.If:
|
||||
return AnimatorConditionMode.If;
|
||||
case AacCrabCondMode.IfNot:
|
||||
return AnimatorConditionMode.IfNot;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown condition mode {mode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace AnimatorAsCrab.V1
|
||||
{
|
||||
/// <summary>
|
||||
/// The wire format produced by the Rust core. Keep in sync with rust/src/graph.rs.
|
||||
/// Property names are snake_cased by the serializer settings, so C# names must only differ
|
||||
/// from the wire format by casing; enum values are explicit.
|
||||
/// </summary>
|
||||
public static class AacCrabJson
|
||||
{
|
||||
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
|
||||
{
|
||||
// A graph the generator does not understand is a bug, not something to ignore.
|
||||
MissingMemberHandling = MissingMemberHandling.Error,
|
||||
ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() },
|
||||
Converters = { new StringEnumConverter() },
|
||||
};
|
||||
|
||||
public static AacCrabGraph Parse(string json)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<AacCrabGraph>(json, Settings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AacCrabGraph
|
||||
{
|
||||
public string SystemName { get; set; }
|
||||
public string AssetKey { get; set; }
|
||||
public List<AacCrabParameter> Parameters { get; set; }
|
||||
public List<AacCrabClip> Clips { get; set; }
|
||||
public List<AacCrabBlendTree> BlendTrees { get; set; }
|
||||
public AacCrabController Controller { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabParameter
|
||||
{
|
||||
public AacCrabParameterType Type { get; set; }
|
||||
public string Name { get; set; }
|
||||
public JToken Default { get; set; }
|
||||
|
||||
public float DefaultFloat => Default.Value<float>();
|
||||
public int DefaultInt => Default.Value<int>();
|
||||
public bool DefaultBool => Default.Value<bool>();
|
||||
}
|
||||
|
||||
public enum AacCrabParameterType
|
||||
{
|
||||
[EnumMember(Value = "float")] Float,
|
||||
[EnumMember(Value = "int")] Int,
|
||||
[EnumMember(Value = "bool")] Bool,
|
||||
}
|
||||
|
||||
public sealed class AacCrabClip
|
||||
{
|
||||
public string Name { get; set; }
|
||||
/// <summary>Looping override; null for a store clip that keeps the source asset's setting.</summary>
|
||||
public bool? Looping { get; set; }
|
||||
/// <summary>Asset path of a pre-made clip seeded into a store clip; null for generated clips.</summary>
|
||||
public string Source { get; set; }
|
||||
public List<AacCrabCurve> Curves { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabCurve
|
||||
{
|
||||
public string Path { get; set; }
|
||||
public AacCrabTargetType Target { get; set; }
|
||||
public string Property { get; set; }
|
||||
public List<AacCrabKeyframe> Keys { get; set; }
|
||||
}
|
||||
|
||||
public enum AacCrabTargetType
|
||||
{
|
||||
[EnumMember(Value = "game_object")] GameObject,
|
||||
[EnumMember(Value = "skinned_mesh_renderer")] SkinnedMeshRenderer,
|
||||
}
|
||||
|
||||
public sealed class AacCrabKeyframe
|
||||
{
|
||||
public float Time { get; set; }
|
||||
public float Value { get; set; }
|
||||
public float InTangent { get; set; }
|
||||
public float OutTangent { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabBlendTree
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public AacCrabBlendType BlendType { get; set; }
|
||||
public string ParamX { get; set; }
|
||||
public string ParamY { get; set; }
|
||||
public List<AacCrabBlendChild> Children { get; set; }
|
||||
public bool UseAutomaticThresholds { get; set; }
|
||||
}
|
||||
|
||||
public enum AacCrabBlendType
|
||||
{
|
||||
[EnumMember(Value = "simple_1d")] Simple1D,
|
||||
[EnumMember(Value = "simple_directional_2d")] SimpleDirectional2D,
|
||||
[EnumMember(Value = "freeform_directional_2d")] FreeformDirectional2D,
|
||||
[EnumMember(Value = "freeform_cartesian_2d")] FreeformCartesian2D,
|
||||
[EnumMember(Value = "direct")] Direct,
|
||||
}
|
||||
|
||||
public sealed class AacCrabBlendChild
|
||||
{
|
||||
public AacCrabMotionRef Motion { get; set; }
|
||||
public float? Threshold { get; set; }
|
||||
public float? ThresholdY { get; set; }
|
||||
public string DirectParam { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A motion is always a reference: clips and blend trees are declared at the top level.</summary>
|
||||
public sealed class AacCrabMotionRef
|
||||
{
|
||||
public AacCrabMotionType Type { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public enum AacCrabMotionType
|
||||
{
|
||||
[EnumMember(Value = "clip")] Clip,
|
||||
[EnumMember(Value = "blend_tree")] BlendTree,
|
||||
}
|
||||
|
||||
public sealed class AacCrabController
|
||||
{
|
||||
public List<AacCrabLayer> Layers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabLayer
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public AacCrabStateMachine StateMachine { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Name is null for the root state machine of a layer.</summary>
|
||||
public sealed class AacCrabStateMachine
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public AacCrabGridPos Position { get; set; }
|
||||
public List<AacCrabState> States { get; set; }
|
||||
public List<AacCrabStateMachine> SubMachines { get; set; }
|
||||
public List<AacCrabTransition> AnyStateTransitions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabState
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public AacCrabGridPos Position { get; set; }
|
||||
public AacCrabMotionRef Motion { get; set; }
|
||||
public List<AacCrabTransition> Transitions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabTransition
|
||||
{
|
||||
/// <summary>The name of the destination state, within the same layer.</summary>
|
||||
public string To { get; set; }
|
||||
public List<AacCrabCondition> Conditions { get; set; }
|
||||
public bool HasExitTime { get; set; }
|
||||
public float ExitTime { get; set; }
|
||||
public float Duration { get; set; }
|
||||
public bool OrderedInterruption { get; set; }
|
||||
public bool SourceInterruption { get; set; }
|
||||
public bool CanTransitionToSelf { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AacCrabCondition
|
||||
{
|
||||
public string Parameter { get; set; }
|
||||
public AacCrabCondMode Mode { get; set; }
|
||||
public float Threshold { get; set; }
|
||||
}
|
||||
|
||||
public enum AacCrabCondMode
|
||||
{
|
||||
[EnumMember(Value = "greater")] Greater,
|
||||
[EnumMember(Value = "less")] Less,
|
||||
[EnumMember(Value = "equals")] Equals,
|
||||
[EnumMember(Value = "not_equal")] NotEqual,
|
||||
[EnumMember(Value = "if")] If,
|
||||
[EnumMember(Value = "if_not")] IfNot,
|
||||
}
|
||||
|
||||
public struct AacCrabGridPos
|
||||
{
|
||||
public int X { get; set; }
|
||||
public int Y { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AnimatorAsCrab.V1
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke bindings for `libaac` (see rust/src/lib.rs). The native library is expected to live
|
||||
/// in the project's Assets/Plugins folder; place libaac.so, libaac.dll or libaac.dylib there.
|
||||
/// </summary>
|
||||
public static class AacCrabNative
|
||||
{
|
||||
private const string Library = "libaac";
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr aac_create();
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int aac_eval_rhai(IntPtr context, byte[] script);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr aac_to_json(IntPtr context);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr aac_last_error(IntPtr context);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern void aac_free_string(IntPtr value);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern void aac_destroy(IntPtr context);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate a Rhai script and return the graph as JSON, or null with a message in
|
||||
/// <paramref name="error"/>. The context lives only for the duration of this call.
|
||||
/// </summary>
|
||||
public static string Evaluate(string script, out string error)
|
||||
{
|
||||
IntPtr context;
|
||||
try
|
||||
{
|
||||
context = aac_create();
|
||||
}
|
||||
catch (DllNotFoundException exception)
|
||||
{
|
||||
error = $"Could not load {Library}: {exception.Message}. Place the native library in Assets/Plugins.";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context == IntPtr.Zero)
|
||||
{
|
||||
error = "aac_create returned null.";
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var status = aac_eval_rhai(context, Utf8Z(script));
|
||||
if (status != 0)
|
||||
{
|
||||
error = ReadUtf8(aac_last_error(context)) ?? $"The script failed with status {status}.";
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = aac_to_json(context);
|
||||
if (json == IntPtr.Zero)
|
||||
{
|
||||
error = ReadUtf8(aac_last_error(context)) ?? "The graph could not be serialized.";
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
error = null;
|
||||
return ReadUtf8(json);
|
||||
}
|
||||
finally
|
||||
{
|
||||
aac_free_string(json);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
aac_destroy(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Utf8Z(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var terminated = new byte[bytes.Length + 1];
|
||||
Array.Copy(bytes, terminated, bytes.Length);
|
||||
return terminated;
|
||||
}
|
||||
|
||||
private static string ReadUtf8(IntPtr pointer)
|
||||
{
|
||||
if (pointer == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Marshal.PtrToStringUTF8 is not available on all Unity runtimes, so the bytes are copied by hand.
|
||||
var length = 0;
|
||||
while (Marshal.ReadByte(pointer, length) != 0)
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
var bytes = new byte[length];
|
||||
Marshal.Copy(pointer, bytes, 0, length);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AnimatorAsCrab.V1
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a Rhai script with libaac and generates the Animator Controller through Animator As Code.
|
||||
/// The Unity-side configuration is never guessed: every field below is supplied by the user, and the
|
||||
/// system name and asset key are declared by the script itself.
|
||||
/// </summary>
|
||||
public class AacCrabWindow : EditorWindow
|
||||
{
|
||||
[SerializeField] private string _scriptPath = "";
|
||||
[SerializeField] private bool _generateOnScriptChange = true;
|
||||
[SerializeField] private AnimatorController _controller;
|
||||
[SerializeField] private Transform _animatorRoot;
|
||||
[SerializeField] private UnityEngine.Object _assetContainer;
|
||||
[SerializeField] private AacConfiguration.Container _containerMode = AacConfiguration.Container.Everything;
|
||||
[SerializeField] private bool _writeDefaults;
|
||||
[SerializeField] private long _lastWriteUtcTicks;
|
||||
|
||||
private string _error;
|
||||
private string _status;
|
||||
private bool _scriptChanged;
|
||||
|
||||
[MenuItem("Tools/Animator As Crab")]
|
||||
public static void Open()
|
||||
{
|
||||
var window = GetWindow<AacCrabWindow>();
|
||||
window.titleContent = new GUIContent("Animator As Crab");
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EditorApplication.update += OnUpdate;
|
||||
_scriptChanged = _lastWriteUtcTicks != 0 && _lastWriteUtcTicks != LastWriteUtcTicks();
|
||||
_lastWriteUtcTicks = LastWriteUtcTicks();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnUpdate;
|
||||
}
|
||||
|
||||
private void OnUpdate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ticks = LastWriteUtcTicks();
|
||||
if (ticks == _lastWriteUtcTicks)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastWriteUtcTicks = ticks;
|
||||
_scriptChanged = true;
|
||||
if (_generateOnScriptChange)
|
||||
{
|
||||
Generate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("Rhai script", EditorStyles.boldLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_scriptPath = EditorGUILayout.TextField(_scriptPath);
|
||||
if (GUILayout.Button("...", GUILayout.Width(28)))
|
||||
{
|
||||
var directory = string.IsNullOrEmpty(_scriptPath) ? null : Path.GetDirectoryName(_scriptPath);
|
||||
var picked = EditorUtility.OpenFilePanel("Rhai script", directory ?? string.Empty, "rhai");
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
{
|
||||
_scriptPath = picked;
|
||||
_lastWriteUtcTicks = LastWriteUtcTicks();
|
||||
_scriptChanged = false;
|
||||
Generate();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
_generateOnScriptChange = EditorGUILayout.Toggle("Generate when the script changes", _generateOnScriptChange);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Unity side", EditorStyles.boldLabel);
|
||||
_controller = (AnimatorController)EditorGUILayout.ObjectField("Animator Controller", _controller, typeof(AnimatorController), false);
|
||||
_animatorRoot = (Transform)EditorGUILayout.ObjectField("Animator Root", _animatorRoot, typeof(Transform), true);
|
||||
_assetContainer = EditorGUILayout.ObjectField("Asset Container", _assetContainer, typeof(UnityEngine.Object), false);
|
||||
_containerMode = (AacConfiguration.Container)EditorGUILayout.EnumPopup("Container Mode", _containerMode);
|
||||
_writeDefaults = EditorGUILayout.Toggle("Write Defaults", _writeDefaults);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
if (GUILayout.Button("Generate", GUILayout.Height(24)))
|
||||
{
|
||||
Generate();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space();
|
||||
if (_error != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(_error, MessageType.Error);
|
||||
}
|
||||
else if (_scriptChanged)
|
||||
{
|
||||
EditorGUILayout.HelpBox("The script changed since the last generation.", MessageType.Warning);
|
||||
}
|
||||
|
||||
if (_status != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(_status, MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void Generate()
|
||||
{
|
||||
_error = null;
|
||||
_status = null;
|
||||
_scriptChanged = false;
|
||||
try
|
||||
{
|
||||
_status = GenerateOrThrow();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_error = exception.Message;
|
||||
Debug.LogException(exception);
|
||||
}
|
||||
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private string GenerateOrThrow()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_scriptPath))
|
||||
{
|
||||
throw new InvalidOperationException("Select a Rhai script.");
|
||||
}
|
||||
|
||||
if (!File.Exists(_scriptPath))
|
||||
{
|
||||
throw new InvalidOperationException($"'{_scriptPath}' does not exist.");
|
||||
}
|
||||
|
||||
if (_controller == null)
|
||||
{
|
||||
throw new InvalidOperationException("Select the Animator Controller to generate into.");
|
||||
}
|
||||
|
||||
if (_animatorRoot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Select the animator root Transform.");
|
||||
}
|
||||
|
||||
if (_assetContainer == null)
|
||||
{
|
||||
throw new InvalidOperationException("Select the asset container that will hold the generated clips and blend trees.");
|
||||
}
|
||||
|
||||
var json = AacCrabNative.Evaluate(File.ReadAllText(_scriptPath), out var nativeError);
|
||||
if (json == null)
|
||||
{
|
||||
throw new InvalidOperationException(nativeError);
|
||||
}
|
||||
|
||||
var graph = AacCrabJson.Parse(json);
|
||||
var configuration = new AacConfiguration
|
||||
{
|
||||
SystemName = graph.SystemName,
|
||||
AssetKey = graph.AssetKey,
|
||||
AnimatorRoot = _animatorRoot,
|
||||
AssetContainer = _assetContainer,
|
||||
ContainerMode = _containerMode,
|
||||
DefaultsProvider = new AacDefaultsProvider(_writeDefaults),
|
||||
};
|
||||
|
||||
AacCrabGenerator.Generate(graph, configuration, _controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return $"Generated '{graph.SystemName}' with asset key '{graph.AssetKey}': " +
|
||||
$"{graph.Controller.Layers.Count} layer(s), {graph.Clips.Count} clip(s), {graph.BlendTrees.Count} blend tree(s).";
|
||||
}
|
||||
|
||||
private long LastWriteUtcTicks()
|
||||
{
|
||||
return string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath)
|
||||
? 0
|
||||
: File.GetLastWriteTimeUtc(_scriptPath).Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "dev.doloro.animator-as-crab",
|
||||
"displayName": "Animator As Crab",
|
||||
"version": "0.1.0",
|
||||
"unity": "2019.4",
|
||||
"description": "Writes Animator Controllers from a Rhai script, evaluated by the libaac native library.",
|
||||
"vrchatVersion" : "2022.1.1",
|
||||
"dependencies": {
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.1"
|
||||
},
|
||||
"author" : {
|
||||
"name" : "doloro"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
Generated
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1788465171,
|
||||
"narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789546076,
|
||||
"narHash": "sha256-zVxLZiSnmaaPLwnhj7pwmqe3axBg/C6nG5JZsJMh2g4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1789715798,
|
||||
"narHash": "sha256-hWw9vlrsFm9kOw8MT5k6IX/7xCzgROuJ6ZvQOrkq66Q=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "fbdb2de9e7619d660ae7e8f752f38b261020b701",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
description = "animator-as-crab: Animator Controllers described in Rhai, generated from Rust into Unity";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
crane,
|
||||
rust-overlay,
|
||||
}:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
|
||||
perSystem =
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
"clippy"
|
||||
"rustfmt"
|
||||
];
|
||||
};
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
# The Rust core: evaluates the Rhai DSL and emits the graph as JSON for Unity.
|
||||
aac = craneLib.buildPackage {
|
||||
src = ./rust;
|
||||
strictDeps = true;
|
||||
doCheck = true;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit aac;
|
||||
default = aac;
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
inputsFrom = [ aac ];
|
||||
packages = [
|
||||
rustToolchain
|
||||
pkgs.dotnet-sdk
|
||||
pkgs.mono
|
||||
pkgs.jq
|
||||
];
|
||||
};
|
||||
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system: (perSystem system).packages);
|
||||
devShells = forAllSystems (system: (perSystem system).devShells);
|
||||
formatter = forAllSystems (system: (perSystem system).formatter);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
Animator As Code V1
|
||||
====
|
||||
|
||||
**Animator As Code** is a small Unity Editor facility to generate Animator layers and animations from a [fluent builder](https://en.wikipedia.org/wiki/Fluent_interface) syntax written in C#.
|
||||
|
||||
Describing your animators as code provides the following advantages:
|
||||
|
||||
- you do not need to edit your animations by hand every time you add remove or change the location of a component in your hierarchy
|
||||
- you will not need to edit a hundred transitions by hand if you need to rectify your animator
|
||||
|
||||
Initially designed for use in VRChat to create Avatars 3.0 animator controllers, this is now a generic package that does not require it.
|
||||
|
||||
https://user-images.githubusercontent.com/60819407/157751278-475538c7-3310-4fa5-9a87-3651c85eaa1c.mp4
|
||||
|
||||
Initially created by **[@hai-vr](https://github.com/hai-vr)**,
|
||||
- with major contributions from **[@galister](https://github.com/galister)** for supporting sub-state machines and the generic use of nodes and parameters.
|
||||
|
||||
## Installation
|
||||
|
||||
Instructions to install are available in [this page](https://docs.hai-vr.dev/docs/products/animator-as-code/install).
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Documentation](https://docs.hai-vr.dev/docs/products/animator-as-code)
|
||||
- [V1 Reference manual](https://docs.hai-vr.dev/docs/products/animator-as-code/reference)
|
||||
- [V1 Functions reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base)
|
||||
- [VRChat reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat)
|
||||
- [VRChat (Destructive Workflow) reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat-destructive-workflow)
|
||||
- [Modular Avatar As Code reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/modular-avatar)
|
||||
|
||||
## Animator As Code V0
|
||||
|
||||
- You can browse the repository in its **[V0](https://github.com/hai-vr/av3-animator-as-code/tree/V1-2022-04) state**.
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+463
@@ -0,0 +1,463 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aac"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"const-random",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rhai"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rhai_codegen",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"thin-vec",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rhai_codegen"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
|
||||
[[package]]
|
||||
name = "smartstring"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"static_assertions",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "aac"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "aac"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
rhai = "1.26"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -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/.../<name>.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);
|
||||
@@ -0,0 +1,66 @@
|
||||
// An Animator Controller described as Rhai.
|
||||
//
|
||||
// cargo run --bin aac-dump -- rust/examples/avatar.rhai
|
||||
//
|
||||
// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded, so
|
||||
// `when(a > 0.1 && b)` is not valid.
|
||||
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("MyAvatar");
|
||||
aac.asset_key("AAC_");
|
||||
|
||||
let speed = aac.float_param("Speed", 0.0);
|
||||
let is_sitting = aac.bool_param("IsSitting", false);
|
||||
let gesture = aac.int_param("Gesture", 0);
|
||||
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
|
||||
let idle = base.state("Idle", 0, 0);
|
||||
let walk = base.state("Walk", 1, 0);
|
||||
let run = base.state("Run", 2, 0);
|
||||
let sit = base.state("Sit", 0, 1);
|
||||
|
||||
let idle_clip = aac.clip("idle_anim");
|
||||
idle_clip.looping(true);
|
||||
idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
|
||||
idle_clip.blend_shape("Body", "Smile", 0.0, 0.0);
|
||||
idle_clip.blend_shape("Body", "Smile", 1.0, 0.8);
|
||||
|
||||
let walk_clip = aac.clip("walk_anim");
|
||||
walk_clip.looping(true);
|
||||
walk_clip.toggle("Body/Props", true);
|
||||
|
||||
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);
|
||||
|
||||
let locomotion = aac.blend_tree("locomotion");
|
||||
locomotion.simple_1d(speed);
|
||||
locomotion.add_motion(walk_clip, 0.0);
|
||||
locomotion.add_motion(run_clip, 5.0);
|
||||
run.set_motion(locomotion);
|
||||
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||
walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.25);
|
||||
run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.25);
|
||||
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25);
|
||||
|
||||
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
|
||||
|
||||
let gestures = base.sub_machine("Gestures", 2, 0);
|
||||
let peace = gestures.state("Peace", 0, 0);
|
||||
let point = gestures.state("Point", 1, 0);
|
||||
peace.transition_to(point).when(gesture == 1).duration(0.1);
|
||||
point.transition_to(peace).when(gesture == 0).duration(0.1);
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Evaluate a `.rhai` script and print the generated JSON.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --bin aac-dump -- rust/examples/avatar.rhai
|
||||
//! ```
|
||||
|
||||
fn main() {
|
||||
let Some(path) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: aac-dump <script.rhai>");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let script = match std::fs::read_to_string(&path) {
|
||||
Ok(script) => script,
|
||||
Err(error) => {
|
||||
eprintln!("cannot read {path}: {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
match aac::evaluate_to_json(&script) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use crate::graph::*;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
/// Shared, thread-safe handle to the graph being built. Every builder carries a clone.
|
||||
#[derive(Clone)]
|
||||
pub struct Aac {
|
||||
pub(crate) graph: Arc<Mutex<ControllerGraph>>,
|
||||
}
|
||||
|
||||
impl Default for Aac {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Aac {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
graph: Arc::new(Mutex::new(ControllerGraph::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A poisoned lock still holds a usable graph; recovering avoids panicking across the FFI boundary.
|
||||
fn lock(&self) -> MutexGuard<'_, ControllerGraph> {
|
||||
self.graph.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
pub fn write<R>(&self, f: impl FnOnce(&mut ControllerGraph) -> R) -> R {
|
||||
f(&mut self.lock())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter handles only carry the name; conditions read nothing else from them.
|
||||
#[derive(Clone)]
|
||||
pub struct FloatParam {
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IntParam {
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BoolParam {
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControllerBuilder {
|
||||
pub(crate) aac: Aac,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LayerBuilder {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) layer: usize,
|
||||
}
|
||||
|
||||
/// A state machine. `machine` is the chain of sub-machine indices from the layer root, so the empty
|
||||
/// chain is the layer's root machine and indices stay stable because machines are only ever appended.
|
||||
#[derive(Clone)]
|
||||
pub struct MachineRef {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) layer: usize,
|
||||
pub(crate) machine: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StateRef {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) layer: usize,
|
||||
pub(crate) machine: Vec<usize>,
|
||||
pub(crate) state: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnyStateRef {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) layer: usize,
|
||||
pub(crate) machine: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClipBuilder {
|
||||
pub(crate) aac: Aac,
|
||||
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,
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
/// `source` is the originating state, or `None` for an any-state transition.
|
||||
#[derive(Clone)]
|
||||
pub struct TransitionRef {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) layer: usize,
|
||||
pub(crate) machine: Vec<usize>,
|
||||
pub(crate) source: Option<usize>,
|
||||
pub(crate) index: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn machine_mut<'a>(
|
||||
graph: &'a mut ControllerGraph,
|
||||
layer: usize,
|
||||
path: &[usize],
|
||||
) -> &'a mut StateMachine {
|
||||
let mut machine = &mut graph.controller.layers[layer].state_machine;
|
||||
for &index in path {
|
||||
machine = &mut machine.sub_machines[index];
|
||||
}
|
||||
machine
|
||||
}
|
||||
|
||||
pub(crate) fn clip_mut<'a>(graph: &'a mut ControllerGraph, name: &str) -> Option<&'a mut ClipData> {
|
||||
graph.clips.iter_mut().find(|clip| clip.name == name)
|
||||
}
|
||||
|
||||
pub(crate) fn blend_tree_mut<'a>(
|
||||
graph: &'a mut ControllerGraph,
|
||||
name: &str,
|
||||
) -> Option<&'a mut BlendTreeData> {
|
||||
graph.blend_trees.iter_mut().find(|tree| tree.name == name)
|
||||
}
|
||||
|
||||
impl StateRef {
|
||||
pub fn state_name(&self) -> String {
|
||||
self.aac
|
||||
.write(|graph| machine_mut(graph, self.layer, &self.machine).states[self.state].name.clone())
|
||||
}
|
||||
|
||||
pub fn set_motion(&self, motion: MotionRef) {
|
||||
self.aac.write(|graph| {
|
||||
machine_mut(graph, self.layer, &self.machine).states[self.state].motion = Some(motion);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
|
||||
let to = target.state_name();
|
||||
let index = self.aac.write(|graph| {
|
||||
let state = &mut machine_mut(graph, self.layer, &self.machine).states[self.state];
|
||||
state.transitions.push(Transition::new(to));
|
||||
state.transitions.len() - 1
|
||||
});
|
||||
TransitionRef {
|
||||
aac: self.aac.clone(),
|
||||
layer: self.layer,
|
||||
machine: self.machine.clone(),
|
||||
source: Some(self.state),
|
||||
index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnyStateRef {
|
||||
pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
|
||||
let to = target.state_name();
|
||||
let index = self.aac.write(|graph| {
|
||||
let machine = machine_mut(graph, self.layer, &self.machine);
|
||||
machine.any_state_transitions.push(Transition::new(to));
|
||||
machine.any_state_transitions.len() - 1
|
||||
});
|
||||
TransitionRef {
|
||||
aac: self.aac.clone(),
|
||||
layer: self.layer,
|
||||
machine: self.machine.clone(),
|
||||
source: None,
|
||||
index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransitionRef {
|
||||
/// Apply a mutation to the transition this handle points at.
|
||||
pub fn update(&self, f: impl FnOnce(&mut Transition)) {
|
||||
self.aac.write(|graph| {
|
||||
let machine = machine_mut(graph, self.layer, &self.machine);
|
||||
let transitions = match self.source {
|
||||
Some(state) => &mut machine.states[state].transitions,
|
||||
None => &mut machine.any_state_transitions,
|
||||
};
|
||||
f(&mut transitions[self.index]);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_conditions(&self, conditions: impl IntoIterator<Item = Condition>) {
|
||||
self.update(|transition| transition.conditions.extend(conditions));
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipBuilder {
|
||||
pub fn data_mut<R>(&self, f: impl FnOnce(&mut ClipData) -> R) -> Option<R> {
|
||||
let name = self.name.clone();
|
||||
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(|| {
|
||||
format!(
|
||||
"cannot tell which component `{property}` belongs to; \
|
||||
known properties are `m_IsActive` and `blendShape.*`"
|
||||
)
|
||||
})?;
|
||||
self.data_mut(|clip| {
|
||||
clip.curve_mut(path, target, property).keys.push(key);
|
||||
})
|
||||
.ok_or_else(|| format!("unknown clip `{}`", self.name))
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationStore {
|
||||
/// Resolve `folder/<name>.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<ClipBuilder, String> {
|
||||
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<R>(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option<R> {
|
||||
let name = self.name.clone();
|
||||
self.aac.write(|graph| blend_tree_mut(graph, &name).map(f))
|
||||
}
|
||||
|
||||
pub fn configure(&self, blend_type: BlendType, param_x: &str, param_y: Option<&str>) -> Result<(), String> {
|
||||
self.data_mut(|tree| {
|
||||
tree.blend_type = blend_type;
|
||||
tree.param_x = param_x.to_string();
|
||||
tree.param_y = param_y.map(str::to_string);
|
||||
})
|
||||
.ok_or_else(|| format!("unknown blend tree `{}`", self.name))
|
||||
}
|
||||
|
||||
pub fn push_child(&self, child: BlendChild) -> Result<(), String> {
|
||||
self.data_mut(|tree| tree.children.push(child))
|
||||
.ok_or_else(|| format!("unknown blend tree `{}`", self.name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use crate::graph::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Sort keyframes into time order, then validate, then serialize.
|
||||
pub fn to_json(graph: &ControllerGraph) -> Result<String, String> {
|
||||
let mut graph = graph.clone();
|
||||
normalize(&mut graph);
|
||||
validate(&graph)?;
|
||||
serde_json::to_string_pretty(&graph).map_err(|e| format!("serialization failed: {e}"))
|
||||
}
|
||||
|
||||
fn normalize(graph: &mut ControllerGraph) {
|
||||
for clip in &mut graph.clips {
|
||||
for curve in &mut clip.curves {
|
||||
curve
|
||||
.keys
|
||||
.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(graph: &ControllerGraph) -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if graph.system_name.trim().is_empty() {
|
||||
errors.push("system_name is not set (call `aac.system_name(\"...\")`)".to_string());
|
||||
}
|
||||
if graph.asset_key.trim().is_empty() {
|
||||
errors.push("asset_key is not set (call `aac.asset_key(\"...\")`)".to_string());
|
||||
}
|
||||
|
||||
let parameters = parameter_types(graph, &mut errors);
|
||||
validate_clips(graph, &mut errors);
|
||||
validate_blend_trees(graph, ¶meters, &mut errors);
|
||||
validate_layers(graph, ¶meters, &mut errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"graph is invalid ({} problem{}):\n- {}",
|
||||
errors.len(),
|
||||
if errors.len() == 1 { "" } else { "s" },
|
||||
errors.join("\n- ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ParamType {
|
||||
Float,
|
||||
Int,
|
||||
Bool,
|
||||
}
|
||||
|
||||
fn parameter_types(graph: &ControllerGraph, errors: &mut Vec<String>) -> HashMap<String, ParamType> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut map = HashMap::new();
|
||||
for parameter in &graph.parameters {
|
||||
let name = parameter.name();
|
||||
if name.trim().is_empty() {
|
||||
errors.push("a parameter has an empty name".to_string());
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(name.to_string()) {
|
||||
errors.push(format!("parameter `{name}` is declared more than once"));
|
||||
}
|
||||
let ty = match parameter {
|
||||
Parameter::Float { .. } => ParamType::Float,
|
||||
Parameter::Int { .. } => ParamType::Int,
|
||||
Parameter::Bool { .. } => ParamType::Bool,
|
||||
};
|
||||
map.insert(name.to_string(), ty);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn validate_clips(graph: &ControllerGraph, errors: &mut Vec<String>) {
|
||||
let mut seen = HashSet::new();
|
||||
for clip in &graph.clips {
|
||||
if clip.name.trim().is_empty() {
|
||||
errors.push("a clip has an empty name".to_string());
|
||||
} else if !seen.insert(clip.name.clone()) {
|
||||
errors.push(format!("clip `{}` is declared more than once", clip.name));
|
||||
}
|
||||
for curve in &clip.curves {
|
||||
if curve.path.trim().is_empty() {
|
||||
errors.push(format!("clip `{}` has a curve with an empty path", clip.name));
|
||||
}
|
||||
if curve.keys.is_empty() {
|
||||
errors.push(format!(
|
||||
"clip `{}` curve `{}` on `{}` has no keyframes",
|
||||
clip.name, curve.property, curve.path
|
||||
));
|
||||
}
|
||||
for key in &curve.keys {
|
||||
if !key.time.is_finite() || !key.value.is_finite() {
|
||||
errors.push(format!(
|
||||
"clip `{}` curve `{}` on `{}` has a non-finite keyframe",
|
||||
clip.name, curve.property, curve.path
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_blend_trees(
|
||||
graph: &ControllerGraph,
|
||||
parameters: &HashMap<String, ParamType>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
let mut seen = HashSet::new();
|
||||
for tree in &graph.blend_trees {
|
||||
if tree.name.trim().is_empty() {
|
||||
errors.push("a blend tree has an empty name".to_string());
|
||||
} else if !seen.insert(tree.name.clone()) {
|
||||
errors.push(format!("blend tree `{}` is declared more than once", tree.name));
|
||||
}
|
||||
let has_y = tree.param_y.as_ref().is_some_and(|y| !y.trim().is_empty());
|
||||
if tree.blend_type == BlendType::Direct {
|
||||
if !tree.param_x.trim().is_empty() || tree.param_y.is_some() {
|
||||
errors.push(format!(
|
||||
"blend tree `{}` is Direct and must not have blend parameters",
|
||||
tree.name
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if tree.param_x.trim().is_empty() {
|
||||
errors.push(format!("blend tree `{}` has no x parameter", tree.name));
|
||||
} else {
|
||||
expect_param(parameters, &tree.param_x, ParamType::Float, &tree.name, "x", errors);
|
||||
}
|
||||
if tree.blend_type.is_2d() && !has_y {
|
||||
errors.push(format!(
|
||||
"blend tree `{}` is 2D and needs a y parameter",
|
||||
tree.name
|
||||
));
|
||||
}
|
||||
if !tree.blend_type.is_2d() && has_y {
|
||||
errors.push(format!(
|
||||
"blend tree `{}` is {:?} and must not have a y parameter",
|
||||
tree.name, tree.blend_type
|
||||
));
|
||||
}
|
||||
if tree.blend_type.is_2d() {
|
||||
if let Some(y) = &tree.param_y {
|
||||
expect_param(parameters, y, ParamType::Float, &tree.name, "y", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tree.children.is_empty() {
|
||||
errors.push(format!("blend tree `{}` has no children", tree.name));
|
||||
}
|
||||
for child in &tree.children {
|
||||
expect_motion(&child.motion, graph, errors);
|
||||
if tree.blend_type == BlendType::Direct {
|
||||
match &child.direct_param {
|
||||
None => errors.push(format!(
|
||||
"blend tree `{}` is Direct and a child has no direct parameter",
|
||||
tree.name
|
||||
)),
|
||||
Some(p) => expect_param(parameters, p, ParamType::Float, &tree.name, "direct", errors),
|
||||
}
|
||||
} else if child.direct_param.is_some() {
|
||||
errors.push(format!(
|
||||
"blend tree `{}` is {:?} and thresholds are used, so a child must not have a direct parameter",
|
||||
tree.name, tree.blend_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_layers(
|
||||
graph: &ControllerGraph,
|
||||
parameters: &HashMap<String, ParamType>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
let mut layer_names = HashSet::new();
|
||||
for layer in &graph.controller.layers {
|
||||
let label = if layer.name.trim().is_empty() {
|
||||
errors.push("a layer has an empty name".to_string());
|
||||
"<unnamed>".to_string()
|
||||
} else {
|
||||
if !layer_names.insert(layer.name.clone()) {
|
||||
errors.push(format!("layer `{}` is declared more than once", layer.name));
|
||||
}
|
||||
layer.name.clone()
|
||||
};
|
||||
|
||||
// Transitions cannot cross layers, so resolution is per-layer.
|
||||
let mut machines = Vec::new();
|
||||
walk_machines(&layer.state_machine, &label, &mut machines);
|
||||
|
||||
let mut state_names = HashSet::new();
|
||||
for (machine_label, machine) in &machines {
|
||||
for state in &machine.states {
|
||||
if state.name.trim().is_empty() {
|
||||
errors.push(format!("machine `{machine_label}` has a state with an empty name"));
|
||||
} else if !state_names.insert(state.name.clone()) {
|
||||
errors.push(format!(
|
||||
"state `{}` is declared more than once in layer `{label}`",
|
||||
state.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (machine_label, machine) in &machines {
|
||||
for state in &machine.states {
|
||||
if let Some(motion) = &state.motion {
|
||||
expect_motion(motion, graph, errors);
|
||||
}
|
||||
for transition in &state.transitions {
|
||||
validate_transition(
|
||||
transition,
|
||||
&format!("state `{}`", state.name),
|
||||
&state_names,
|
||||
parameters,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
for transition in &machine.any_state_transitions {
|
||||
validate_transition(
|
||||
transition,
|
||||
&format!("machine `{machine_label}` any-state"),
|
||||
&state_names,
|
||||
parameters,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State names are collected in a first pass so that forward references resolve.
|
||||
fn walk_machines<'a>(machine: &'a StateMachine, label: &str, out: &mut Vec<(String, &'a StateMachine)>) {
|
||||
out.push((label.to_string(), machine));
|
||||
for sub in &machine.sub_machines {
|
||||
let name = sub.name.as_deref().unwrap_or("<unnamed>");
|
||||
walk_machines(sub, &format!("{label}/{name}"), out);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_transition(
|
||||
transition: &Transition,
|
||||
origin: &str,
|
||||
state_names: &HashSet<String>,
|
||||
parameters: &HashMap<String, ParamType>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if !state_names.contains(&transition.to) {
|
||||
errors.push(format!(
|
||||
"{origin} transitions to `{}`, which is not a state in that layer",
|
||||
transition.to
|
||||
));
|
||||
}
|
||||
if !transition.duration.is_finite() || !transition.exit_time.is_finite() {
|
||||
errors.push(format!("{origin} has a non-finite duration or exit time"));
|
||||
}
|
||||
if !transition.conditions.is_empty() && transition.has_exit_time {
|
||||
errors.push(format!(
|
||||
"{origin} has both conditions and an exit time; Unity will ignore the conditions"
|
||||
));
|
||||
}
|
||||
for condition in &transition.conditions {
|
||||
let Some(ty) = parameters.get(&condition.parameter) else {
|
||||
errors.push(format!(
|
||||
"{origin} uses parameter `{}`, which is not declared",
|
||||
condition.parameter
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if !condition.threshold.is_finite() {
|
||||
errors.push(format!("{origin} has a non-finite condition threshold"));
|
||||
}
|
||||
let ok = match condition.mode {
|
||||
CondMode::If | CondMode::IfNot => *ty == ParamType::Bool,
|
||||
CondMode::Greater | CondMode::Less => *ty != ParamType::Bool,
|
||||
CondMode::Equals | CondMode::NotEqual => true,
|
||||
};
|
||||
if !ok {
|
||||
errors.push(format!(
|
||||
"{origin} uses {:?} on `{}`, but that parameter cannot be compared that way",
|
||||
condition.mode, condition.parameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_motion(motion: &MotionRef, graph: &ControllerGraph, errors: &mut Vec<String>) {
|
||||
let (kind, name, exists) = match motion {
|
||||
MotionRef::Clip { name } => ("clip", name, graph.clips.iter().any(|c| &c.name == name)),
|
||||
MotionRef::BlendTree { name } => (
|
||||
"blend tree",
|
||||
name,
|
||||
graph.blend_trees.iter().any(|t| &t.name == name),
|
||||
),
|
||||
};
|
||||
if !exists {
|
||||
errors.push(format!("reference to {kind} `{name}`, which is not declared"));
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_param(
|
||||
parameters: &HashMap<String, ParamType>,
|
||||
name: &str,
|
||||
expected: ParamType,
|
||||
owner: &str,
|
||||
role: &str,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
match parameters.get(name) {
|
||||
None => errors.push(format!(
|
||||
"blend tree `{owner}` uses `{name}` as its {role} parameter, which is not declared"
|
||||
)),
|
||||
Some(actual) if *actual != expected => errors.push(format!(
|
||||
"blend tree `{owner}` uses `{name}` as its {role} parameter, which is not a float parameter"
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The complete description of one animator controller, produced entirely by a Rhai script.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ControllerGraph {
|
||||
pub system_name: String,
|
||||
pub asset_key: String,
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub clips: Vec<ClipData>,
|
||||
pub blend_trees: Vec<BlendTreeData>,
|
||||
pub controller: Controller,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Controller {
|
||||
pub layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Layer {
|
||||
/// The layer suffix as written in the script. Unity's layer name is derived from it by the C# side.
|
||||
pub name: String,
|
||||
pub state_machine: StateMachine,
|
||||
}
|
||||
|
||||
/// Grid position in Unity's animator graph. Serialized as an object so C# can bind it directly.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GridPos {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl GridPos {
|
||||
pub fn new(x: i32, y: i32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StateMachine {
|
||||
/// `None` for a layer's root machine, `Some` for a sub-state machine.
|
||||
pub name: Option<String>,
|
||||
pub position: GridPos,
|
||||
pub states: Vec<State>,
|
||||
pub sub_machines: Vec<StateMachine>,
|
||||
pub any_state_transitions: Vec<Transition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub name: String,
|
||||
pub position: GridPos,
|
||||
pub motion: Option<MotionRef>,
|
||||
pub transitions: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// References to entries in `ControllerGraph::clips` / `ControllerGraph::blend_trees`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MotionRef {
|
||||
Clip { name: String },
|
||||
BlendTree { name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Transition {
|
||||
pub to: String,
|
||||
pub conditions: Vec<Condition>,
|
||||
pub has_exit_time: bool,
|
||||
pub exit_time: f32,
|
||||
pub duration: f32,
|
||||
pub ordered_interruption: bool,
|
||||
pub source_interruption: bool,
|
||||
pub can_transition_to_self: bool,
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
/// Mirrors `AacDefaultsProvider.ConfigureTransition`, so unspecified fields match library defaults.
|
||||
pub fn new(to: impl Into<String>) -> Self {
|
||||
Self {
|
||||
to: to.into(),
|
||||
conditions: Vec::new(),
|
||||
has_exit_time: false,
|
||||
exit_time: 0.0,
|
||||
duration: 0.0,
|
||||
ordered_interruption: true,
|
||||
source_interruption: false,
|
||||
can_transition_to_self: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Condition {
|
||||
pub parameter: String,
|
||||
pub mode: CondMode,
|
||||
pub threshold: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CondMode {
|
||||
Greater,
|
||||
Less,
|
||||
Equals,
|
||||
NotEqual,
|
||||
If,
|
||||
IfNot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClipData {
|
||||
pub name: String,
|
||||
/// `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<bool>,
|
||||
pub curves: Vec<Curve>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Curve {
|
||||
pub path: String,
|
||||
pub target: TargetType,
|
||||
pub property: String,
|
||||
pub keys: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
/// The Unity component a curve binds to. Closed set: an unknown property is an error, not a guess.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TargetType {
|
||||
GameObject,
|
||||
SkinnedMeshRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Keyframe {
|
||||
pub time: f32,
|
||||
pub value: f32,
|
||||
pub in_tangent: f32,
|
||||
pub out_tangent: f32,
|
||||
}
|
||||
|
||||
impl Keyframe {
|
||||
pub fn linear(time: f32, value: f32) -> Self {
|
||||
Self {
|
||||
time,
|
||||
value,
|
||||
in_tangent: 0.0,
|
||||
out_tangent: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendTreeData {
|
||||
pub name: String,
|
||||
pub blend_type: BlendType,
|
||||
pub param_x: String,
|
||||
pub param_y: Option<String>,
|
||||
pub children: Vec<BlendChild>,
|
||||
pub use_automatic_thresholds: bool,
|
||||
}
|
||||
|
||||
/// `rename_all = "snake_case"` would turn `SimpleDirectional2D` into `simple_directional2_d`,
|
||||
/// so every variant is renamed explicitly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlendType {
|
||||
#[serde(rename = "simple_1d")]
|
||||
Simple1D,
|
||||
#[serde(rename = "simple_directional_2d")]
|
||||
SimpleDirectional2D,
|
||||
#[serde(rename = "freeform_directional_2d")]
|
||||
FreeformDirectional2D,
|
||||
#[serde(rename = "freeform_cartesian_2d")]
|
||||
FreeformCartesian2D,
|
||||
#[serde(rename = "direct")]
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl BlendType {
|
||||
pub fn is_2d(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
BlendType::SimpleDirectional2D
|
||||
| BlendType::FreeformDirectional2D
|
||||
| BlendType::FreeformCartesian2D
|
||||
)
|
||||
}
|
||||
|
||||
/// Blend trees that place children by threshold rather than by a direct blend parameter.
|
||||
pub fn uses_thresholds(self) -> bool {
|
||||
self != BlendType::Direct
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendChild {
|
||||
pub motion: MotionRef,
|
||||
pub threshold: f32,
|
||||
pub threshold_y: Option<f32>,
|
||||
/// Only meaningful on `Direct` blend trees.
|
||||
pub direct_param: Option<String>,
|
||||
}
|
||||
|
||||
impl BlendChild {
|
||||
pub fn motion(motion: MotionRef) -> Self {
|
||||
Self {
|
||||
motion,
|
||||
threshold: 0.0,
|
||||
threshold_y: None,
|
||||
direct_param: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Parameter {
|
||||
Float { name: String, default: f32 },
|
||||
Int { name: String, default: i32 },
|
||||
Bool { name: String, default: bool },
|
||||
}
|
||||
|
||||
impl Parameter {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Parameter::Float { name, .. } | Parameter::Int { name, .. } | Parameter::Bool { name, .. } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Property-name -> component-type inference. Deliberately strict; an unmapped property is an error.
|
||||
pub fn infer_target(property: &str) -> Option<TargetType> {
|
||||
if property.starts_with("blendShape.") {
|
||||
return Some(TargetType::SkinnedMeshRenderer);
|
||||
}
|
||||
match property {
|
||||
"m_IsActive" => Some(TargetType::GameObject),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipData {
|
||||
/// Find-or-create the curve for one (path, target, property) binding.
|
||||
pub fn curve_mut(&mut self, path: &str, target: TargetType, property: &str) -> &mut Curve {
|
||||
let index = match self
|
||||
.curves
|
||||
.iter()
|
||||
.position(|c| c.path == path && c.target == target && c.property == property)
|
||||
{
|
||||
Some(index) => index,
|
||||
None => {
|
||||
self.curves.push(Curve {
|
||||
path: path.to_string(),
|
||||
target,
|
||||
property: property.to_string(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
self.curves.len() - 1
|
||||
}
|
||||
};
|
||||
&mut self.curves[index]
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
//! Evaluates a Rhai script that describes an Animator Controller, and hands the result to C# as JSON.
|
||||
//!
|
||||
//! The C# side never sees Rhai: it receives a `ControllerGraph` and drives the Animator As Code
|
||||
//! modification API with it.
|
||||
|
||||
pub mod builder;
|
||||
pub mod export;
|
||||
pub mod graph;
|
||||
pub mod motion_api;
|
||||
pub mod rhai_api;
|
||||
|
||||
use builder::Aac;
|
||||
use rhai::{Dynamic, Engine, Scope};
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::ptr;
|
||||
|
||||
pub use graph::ControllerGraph;
|
||||
|
||||
/// Opaque to C#. Owns the graph, the Rhai engine, and the last error message.
|
||||
pub struct AacContext {
|
||||
aac: Aac,
|
||||
engine: Engine,
|
||||
last_error: Option<CString>,
|
||||
}
|
||||
|
||||
impl AacContext {
|
||||
fn set_error(&mut self, message: String) {
|
||||
// Interior NUL bytes would make CString::new fail; they can only come from a script string.
|
||||
self.last_error = Some(CString::new(message.replace('\0', "\\0")).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a script and serialize the resulting graph. The testable core of the FFI.
|
||||
pub fn evaluate_to_json(script: &str) -> Result<String, String> {
|
||||
let aac = Aac::new();
|
||||
let engine = rhai_api::engine(aac.clone());
|
||||
let mut scope = Scope::new();
|
||||
let _ = engine
|
||||
.eval_with_scope::<Dynamic>(&mut scope, script)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let graph = aac.write(|graph| graph.clone());
|
||||
export::to_json(&graph)
|
||||
}
|
||||
|
||||
/// Create a context. The returned pointer owns everything; release it with `aac_destroy`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_create() -> *mut AacContext {
|
||||
let aac = Aac::new();
|
||||
let engine = rhai_api::engine(aac.clone());
|
||||
Box::into_raw(Box::new(AacContext {
|
||||
aac,
|
||||
engine,
|
||||
last_error: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Evaluate a Rhai script. Returns 0 on success; on failure, call `aac_last_error`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_eval_rhai(handle: *mut AacContext, script: *const c_char) -> i32 {
|
||||
if handle.is_null() || script.is_null() {
|
||||
return 1;
|
||||
}
|
||||
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
|
||||
let context = unsafe { &mut *handle };
|
||||
let script = match unsafe { CStr::from_ptr(script) }.to_str() {
|
||||
Ok(script) => script,
|
||||
Err(error) => {
|
||||
context.set_error(format!("script is not valid UTF-8: {error}"));
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = {
|
||||
let engine = &context.engine;
|
||||
catch_unwind(AssertUnwindSafe(|| {
|
||||
let mut scope = Scope::new();
|
||||
engine.eval_with_scope::<Dynamic>(&mut scope, script)
|
||||
}))
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(_)) => {
|
||||
context.last_error = None;
|
||||
0
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
context.set_error(error.to_string());
|
||||
1
|
||||
}
|
||||
Err(_) => {
|
||||
context.set_error("internal error: the evaluator panicked".to_string());
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the graph to JSON. Returns null on error; release the result with `aac_free_string`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_to_json(handle: *mut AacContext) -> *mut c_char {
|
||||
if handle.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
|
||||
let context = unsafe { &mut *handle };
|
||||
let outcome = catch_unwind(AssertUnwindSafe(|| {
|
||||
let graph = context.aac.write(|graph| graph.clone());
|
||||
export::to_json(&graph)
|
||||
}));
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(json)) => match CString::new(json) {
|
||||
Ok(json) => {
|
||||
context.last_error = None;
|
||||
json.into_raw()
|
||||
}
|
||||
Err(_) => {
|
||||
context.set_error("internal error: serialized JSON contained a NUL byte".to_string());
|
||||
ptr::null_mut()
|
||||
}
|
||||
},
|
||||
Ok(Err(error)) => {
|
||||
context.set_error(error);
|
||||
ptr::null_mut()
|
||||
}
|
||||
Err(_) => {
|
||||
context.set_error("internal error: serialization panicked".to_string());
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last error message, or null if the last call succeeded. Borrowed: valid until the next call
|
||||
/// on this context, and must not be freed.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_last_error(handle: *mut AacContext) -> *const c_char {
|
||||
if handle.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
|
||||
let context = unsafe { &*handle };
|
||||
match &context.last_error {
|
||||
Some(message) => message.as_ptr(),
|
||||
None => ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string returned by `aac_to_json`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_free_string(string: *mut c_char) {
|
||||
if string.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `string` must come from `aac_to_json`, which uses `CString::into_raw`.
|
||||
unsafe { drop(CString::from_raw(string)) };
|
||||
}
|
||||
|
||||
/// Destroy a context created by `aac_create`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn aac_destroy(handle: *mut AacContext) {
|
||||
if handle.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `handle` must come from `aac_create`, which uses `Box::into_raw`.
|
||||
unsafe { drop(Box::from_raw(handle)) };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use crate::builder::*;
|
||||
use crate::graph::*;
|
||||
use crate::rhai_api::err;
|
||||
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);
|
||||
}
|
||||
|
||||
fn clip_ref(clip: &ClipBuilder) -> MotionRef {
|
||||
MotionRef::Clip {
|
||||
name: clip.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_ref(tree: &BlendTreeBuilder) -> MotionRef {
|
||||
MotionRef::BlendTree {
|
||||
name: tree.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_clips(engine: &mut Engine) {
|
||||
engine.register_fn("clip", |a: &mut Aac, name: &str| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(err("clip name is empty".to_string()));
|
||||
}
|
||||
if a.write(|graph| graph.clips.iter().any(|clip| clip.name == name)) {
|
||||
return Err(err(format!("clip `{name}` is already declared")));
|
||||
}
|
||||
a.write(|graph| {
|
||||
graph.clips.push(ClipData {
|
||||
name: name.to_string(),
|
||||
looping: Some(false),
|
||||
curves: Vec::new(),
|
||||
source: None,
|
||||
})
|
||||
});
|
||||
Ok(ClipBuilder {
|
||||
aac: a.clone(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
engine.register_fn("looping", |clip: ClipBuilder, value: bool| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
clip.set_looping(value).map_err(err)?;
|
||||
Ok(clip)
|
||||
});
|
||||
|
||||
engine.register_fn(
|
||||
"keyframe",
|
||||
|clip: ClipBuilder, path: &str, property: &str, time: f64, value: f64| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
clip.push_key(path, property, Keyframe::linear(time as f32, value as f32))
|
||||
.map_err(err)?;
|
||||
Ok(clip)
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_fn("toggle", |clip: ClipBuilder, path: &str, value: bool| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
let value = if value { 1.0 } else { 0.0 };
|
||||
// A one-frame constant, matching AAC's toggling semantics.
|
||||
clip.push_key(path, "m_IsActive", Keyframe::linear(0.0, value))
|
||||
.map_err(err)?;
|
||||
clip.push_key(path, "m_IsActive", Keyframe::linear(1.0 / 60.0, value))
|
||||
.map_err(err)?;
|
||||
Ok(clip)
|
||||
});
|
||||
|
||||
engine.register_fn(
|
||||
"blend_shape",
|
||||
|clip: ClipBuilder, path: &str, shape: &str, time: f64, value: f64| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
let property = format!("blendShape.{shape}");
|
||||
clip.push_key(path, &property, Keyframe::linear(time as f32, value as f32))
|
||||
.map_err(err)?;
|
||||
Ok(clip)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_animation_store(engine: &mut Engine) {
|
||||
engine.register_fn(
|
||||
"AnimationStore",
|
||||
|a: &mut Aac, folder: &str| -> Result<AnimationStore, Box<EvalAltResult>> {
|
||||
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<ClipBuilder, Box<EvalAltResult>> {
|
||||
store.clip(name).map_err(err)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_blend_trees(engine: &mut Engine) {
|
||||
engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result<BlendTreeBuilder, Box<EvalAltResult>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(err("blend tree name is empty".to_string()));
|
||||
}
|
||||
if a.write(|graph| graph.blend_trees.iter().any(|tree| tree.name == name)) {
|
||||
return Err(err(format!("blend tree `{name}` is already declared")));
|
||||
}
|
||||
a.write(|graph| {
|
||||
graph.blend_trees.push(BlendTreeData {
|
||||
name: name.to_string(),
|
||||
blend_type: BlendType::Simple1D,
|
||||
param_x: String::new(),
|
||||
param_y: None,
|
||||
children: Vec::new(),
|
||||
use_automatic_thresholds: false,
|
||||
})
|
||||
});
|
||||
Ok(BlendTreeBuilder {
|
||||
aac: a.clone(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
engine.register_fn("simple_1d", |tree: BlendTreeBuilder, x: FloatParam| {
|
||||
tree.configure(BlendType::Simple1D, &x.name, None).map_err(err)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("simple_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
|
||||
tree.configure(BlendType::SimpleDirectional2D, &x.name, Some(&y.name))
|
||||
.map_err(err)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("freeform_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
|
||||
tree.configure(BlendType::FreeformDirectional2D, &x.name, Some(&y.name))
|
||||
.map_err(err)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("freeform_cartesian_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
|
||||
tree.configure(BlendType::FreeformCartesian2D, &x.name, Some(&y.name))
|
||||
.map_err(err)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("direct", |tree: BlendTreeBuilder| {
|
||||
tree.configure(BlendType::Direct, "", None).map_err(err)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("automatic_thresholds", |tree: BlendTreeBuilder, value: bool| {
|
||||
tree.data_mut(|data| data.use_automatic_thresholds = value);
|
||||
tree
|
||||
});
|
||||
|
||||
engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, threshold: f64| {
|
||||
push_child(&tree, clip_ref(&clip), threshold, None, None)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, threshold: f64| {
|
||||
push_child(&tree, tree_ref(&child), threshold, None, None)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, x: f64, y: f64| {
|
||||
push_child(&tree, clip_ref(&clip), x, Some(y), None)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, x: f64, y: f64| {
|
||||
push_child(&tree, tree_ref(&child), x, Some(y), None)?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
|
||||
engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, clip: ClipBuilder, parameter: FloatParam| {
|
||||
push_child(&tree, clip_ref(&clip), 0.0, None, Some(¶meter.name))?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, child: BlendTreeBuilder, parameter: FloatParam| {
|
||||
push_child(&tree, tree_ref(&child), 0.0, None, Some(¶meter.name))?;
|
||||
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
|
||||
});
|
||||
}
|
||||
|
||||
fn register_state_motion(engine: &mut Engine) {
|
||||
engine.register_fn("set_clip", |state: StateRef, clip: ClipBuilder| {
|
||||
state.set_motion(clip_ref(&clip));
|
||||
state
|
||||
});
|
||||
engine.register_fn("set_motion", |state: StateRef, clip: ClipBuilder| {
|
||||
state.set_motion(clip_ref(&clip));
|
||||
state
|
||||
});
|
||||
engine.register_fn("set_motion", |state: StateRef, tree: BlendTreeBuilder| {
|
||||
state.set_motion(tree_ref(&tree));
|
||||
state
|
||||
});
|
||||
}
|
||||
|
||||
fn push_child(
|
||||
tree: &BlendTreeBuilder,
|
||||
motion: MotionRef,
|
||||
threshold: f64,
|
||||
threshold_y: Option<f64>,
|
||||
direct_param: Option<&str>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let child = BlendChild {
|
||||
motion,
|
||||
threshold: threshold as f32,
|
||||
threshold_y: threshold_y.map(|y| y as f32),
|
||||
direct_param: direct_param.map(str::to_string),
|
||||
};
|
||||
tree.push_child(child).map_err(err)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use crate::builder::*;
|
||||
use crate::graph::*;
|
||||
use rhai::{Array, Engine, EvalAltResult, Position};
|
||||
|
||||
pub(crate) fn err(message: String) -> Box<EvalAltResult> {
|
||||
Box::new(EvalAltResult::ErrorRuntime(message.into(), Position::NONE))
|
||||
}
|
||||
|
||||
fn condition(parameter: &str, mode: CondMode, threshold: f32) -> Condition {
|
||||
Condition {
|
||||
parameter: parameter.to_string(),
|
||||
mode,
|
||||
threshold,
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(value: i64) -> Result<i32, Box<EvalAltResult>> {
|
||||
i32::try_from(value).map_err(|_| err(format!("grid position {value} does not fit in a 32-bit integer")))
|
||||
}
|
||||
|
||||
fn add_state(aac: &Aac, layer: usize, machine: &[usize], name: &str, x: i64, y: i64) -> Result<StateRef, Box<EvalAltResult>> {
|
||||
let position = GridPos::new(grid(x)?, grid(y)?);
|
||||
let state = aac.write(|graph| {
|
||||
let states = &mut machine_mut(graph, layer, machine).states;
|
||||
states.push(State {
|
||||
name: name.to_string(),
|
||||
position,
|
||||
..Default::default()
|
||||
});
|
||||
states.len() - 1
|
||||
});
|
||||
Ok(StateRef {
|
||||
aac: aac.clone(),
|
||||
layer,
|
||||
machine: machine.to_vec(),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
fn add_sub_machine(
|
||||
aac: &Aac,
|
||||
layer: usize,
|
||||
machine: &[usize],
|
||||
name: &str,
|
||||
x: i64,
|
||||
y: i64,
|
||||
) -> Result<MachineRef, Box<EvalAltResult>> {
|
||||
let position = GridPos::new(grid(x)?, grid(y)?);
|
||||
let index = aac.write(|graph| {
|
||||
let sub_machines = &mut machine_mut(graph, layer, machine).sub_machines;
|
||||
sub_machines.push(StateMachine {
|
||||
name: Some(name.to_string()),
|
||||
position,
|
||||
..Default::default()
|
||||
});
|
||||
sub_machines.len() - 1
|
||||
});
|
||||
let mut path = machine.to_vec();
|
||||
path.push(index);
|
||||
Ok(MachineRef {
|
||||
aac: aac.clone(),
|
||||
layer,
|
||||
machine: path,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_new_parameter(aac: &Aac, name: &str) -> Result<(), Box<EvalAltResult>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(err("parameter name is empty".to_string()));
|
||||
}
|
||||
let duplicate = aac.write(|graph| graph.parameters.iter().any(|p| p.name() == name));
|
||||
if duplicate {
|
||||
return Err(err(format!("parameter `{name}` is already declared")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn engine(aac: Aac) -> Engine {
|
||||
let mut engine = Engine::new();
|
||||
|
||||
engine.register_type_with_name::<Aac>("Aac");
|
||||
engine.register_type_with_name::<FloatParam>("FloatParam");
|
||||
engine.register_type_with_name::<IntParam>("IntParam");
|
||||
engine.register_type_with_name::<BoolParam>("BoolParam");
|
||||
engine.register_type_with_name::<Condition>("Condition");
|
||||
engine.register_type_with_name::<ControllerBuilder>("Controller");
|
||||
engine.register_type_with_name::<LayerBuilder>("Layer");
|
||||
engine.register_type_with_name::<MachineRef>("Machine");
|
||||
engine.register_type_with_name::<StateRef>("State");
|
||||
engine.register_type_with_name::<TransitionRef>("Transition");
|
||||
engine.register_type_with_name::<ClipBuilder>("Clip");
|
||||
engine.register_type_with_name::<AnimationStore>("AnimationStore");
|
||||
engine.register_type_with_name::<BlendTreeBuilder>("BlendTree");
|
||||
|
||||
register_conditions(&mut engine);
|
||||
register_setup(&mut engine, aac);
|
||||
register_navigation(&mut engine);
|
||||
register_transitions(&mut engine);
|
||||
crate::motion_api::register(&mut engine);
|
||||
|
||||
engine
|
||||
}
|
||||
|
||||
/// Leaf conditions come from overloaded comparison operators, because Rhai's `&&`/`||` cannot be
|
||||
/// overloaded. Combine several conditions with `when_all([...])`.
|
||||
fn register_conditions(engine: &mut Engine) {
|
||||
engine.register_fn(">", |p: FloatParam, v: f64| condition(&p.name, CondMode::Greater, v as f32));
|
||||
engine.register_fn("<", |p: FloatParam, v: f64| condition(&p.name, CondMode::Less, v as f32));
|
||||
engine.register_fn("==", |p: FloatParam, v: f64| condition(&p.name, CondMode::Equals, v as f32));
|
||||
engine.register_fn("!=", |p: FloatParam, v: f64| condition(&p.name, CondMode::NotEqual, v as f32));
|
||||
engine.register_fn(">", |p: IntParam, v: i64| condition(&p.name, CondMode::Greater, v as f32));
|
||||
engine.register_fn("<", |p: IntParam, v: i64| condition(&p.name, CondMode::Less, v as f32));
|
||||
engine.register_fn("==", |p: IntParam, v: i64| condition(&p.name, CondMode::Equals, v as f32));
|
||||
engine.register_fn("!=", |p: IntParam, v: i64| condition(&p.name, CondMode::NotEqual, v as f32));
|
||||
engine.register_fn("==", |p: BoolParam, v: bool| {
|
||||
condition(&p.name, if v { CondMode::If } else { CondMode::IfNot }, 0.0)
|
||||
});
|
||||
engine.register_fn("!=", |p: BoolParam, v: bool| {
|
||||
condition(&p.name, if v { CondMode::IfNot } else { CondMode::If }, 0.0)
|
||||
});
|
||||
}
|
||||
|
||||
fn register_setup(engine: &mut Engine, aac: Aac) {
|
||||
engine.register_fn("AnimatorAsCode", move || aac.clone());
|
||||
|
||||
engine.register_fn("system_name", |a: &mut Aac, name: &str| {
|
||||
a.write(|graph| graph.system_name = name.to_string());
|
||||
});
|
||||
engine.register_fn("asset_key", |a: &mut Aac, key: &str| {
|
||||
a.write(|graph| graph.asset_key = key.to_string());
|
||||
});
|
||||
|
||||
engine.register_fn("float_param", |a: &mut Aac, name: &str, default: f64| -> Result<FloatParam, Box<EvalAltResult>> {
|
||||
assert_new_parameter(a, name)?;
|
||||
a.write(|graph| {
|
||||
graph.parameters.push(Parameter::Float {
|
||||
name: name.to_string(),
|
||||
default: default as f32,
|
||||
})
|
||||
});
|
||||
Ok(FloatParam {
|
||||
name: name.to_string(),
|
||||
})
|
||||
});
|
||||
engine.register_fn("int_param", |a: &mut Aac, name: &str, default: i64| -> Result<IntParam, Box<EvalAltResult>> {
|
||||
assert_new_parameter(a, name)?;
|
||||
a.write(|graph| {
|
||||
graph.parameters.push(Parameter::Int {
|
||||
name: name.to_string(),
|
||||
default: default as i32,
|
||||
})
|
||||
});
|
||||
Ok(IntParam {
|
||||
name: name.to_string(),
|
||||
})
|
||||
});
|
||||
engine.register_fn(
|
||||
"bool_param",
|
||||
|a: &mut Aac, name: &str, default: bool| -> Result<BoolParam, Box<EvalAltResult>> {
|
||||
assert_new_parameter(a, name)?;
|
||||
a.write(|graph| {
|
||||
graph.parameters.push(Parameter::Bool {
|
||||
name: name.to_string(),
|
||||
default,
|
||||
})
|
||||
});
|
||||
Ok(BoolParam {
|
||||
name: name.to_string(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_fn("new_controller", |a: &mut Aac| ControllerBuilder { aac: a.clone() });
|
||||
}
|
||||
|
||||
fn register_navigation(engine: &mut Engine) {
|
||||
// There is exactly one controller, so this is the only place layers are attached.
|
||||
engine.register_fn("layer", |c: ControllerBuilder, name: &str| -> LayerBuilder {
|
||||
let layer = c.aac.write(|graph| {
|
||||
graph.controller.layers.push(Layer {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
graph.controller.layers.len() - 1
|
||||
});
|
||||
LayerBuilder {
|
||||
aac: c.aac.clone(),
|
||||
layer,
|
||||
}
|
||||
});
|
||||
|
||||
engine.register_fn("state", |l: LayerBuilder, name: &str, x: i64, y: i64| {
|
||||
add_state(&l.aac, l.layer, &[], name, x, y)
|
||||
});
|
||||
engine.register_fn("state", |m: MachineRef, name: &str, x: i64, y: i64| {
|
||||
add_state(&m.aac, m.layer, &m.machine, name, x, y)
|
||||
});
|
||||
|
||||
engine.register_fn("sub_machine", |l: LayerBuilder, name: &str, x: i64, y: i64| {
|
||||
add_sub_machine(&l.aac, l.layer, &[], name, x, y)
|
||||
});
|
||||
engine.register_fn("sub_machine", |m: MachineRef, name: &str, x: i64, y: i64| {
|
||||
add_sub_machine(&m.aac, m.layer, &m.machine, name, x, y)
|
||||
});
|
||||
|
||||
engine.register_fn("any_state", |l: LayerBuilder| AnyStateRef {
|
||||
aac: l.aac.clone(),
|
||||
layer: l.layer,
|
||||
machine: Vec::new(),
|
||||
});
|
||||
engine.register_fn("any_state", |m: MachineRef| AnyStateRef {
|
||||
aac: m.aac.clone(),
|
||||
layer: m.layer,
|
||||
machine: m.machine.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn register_transitions(engine: &mut Engine) {
|
||||
engine.register_fn("transition_to", |from: StateRef, to: StateRef| from.transition_to(&to));
|
||||
engine.register_fn("transition_to", |from: AnyStateRef, to: StateRef| from.transition_to(&to));
|
||||
|
||||
engine.register_fn("when", |t: TransitionRef, c: Condition| {
|
||||
t.add_conditions([c]);
|
||||
t
|
||||
});
|
||||
engine.register_fn(
|
||||
"when_all",
|
||||
|t: TransitionRef, conditions: Array| -> Result<TransitionRef, Box<EvalAltResult>> {
|
||||
let mut collected = Vec::with_capacity(conditions.len());
|
||||
for value in conditions {
|
||||
collected.push(
|
||||
value
|
||||
.try_cast::<Condition>()
|
||||
.ok_or_else(|| err("when_all expects a list of conditions".to_string()))?,
|
||||
);
|
||||
}
|
||||
t.add_conditions(collected);
|
||||
Ok(t)
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_fn("duration", |t: TransitionRef, seconds: f64| {
|
||||
t.update(|transition| transition.duration = seconds as f32);
|
||||
t
|
||||
});
|
||||
engine.register_fn("no_exit_time", |t: TransitionRef| {
|
||||
t.update(|transition| {
|
||||
transition.has_exit_time = false;
|
||||
transition.exit_time = 0.0;
|
||||
});
|
||||
t
|
||||
});
|
||||
engine.register_fn("exit_time", |t: TransitionRef, normalized: f64| {
|
||||
t.update(|transition| {
|
||||
transition.has_exit_time = true;
|
||||
transition.exit_time = normalized as f32;
|
||||
});
|
||||
t
|
||||
});
|
||||
engine.register_fn("ordered_interruption", |t: TransitionRef, value: bool| {
|
||||
t.update(|transition| transition.ordered_interruption = value);
|
||||
t
|
||||
});
|
||||
engine.register_fn("source_interruption", |t: TransitionRef| {
|
||||
t.update(|transition| transition.source_interruption = true);
|
||||
t
|
||||
});
|
||||
engine.register_fn("to_self", |t: TransitionRef| {
|
||||
t.update(|transition| transition.can_transition_to_self = true);
|
||||
t
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
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")
|
||||
}
|
||||
|
||||
fn error(script: &str) -> String {
|
||||
aac::evaluate_to_json(script).expect_err("script should fail")
|
||||
}
|
||||
|
||||
/// A minimal but valid graph, for mutating into invalid shapes.
|
||||
fn valid_graph() -> ControllerGraph {
|
||||
let script = r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let speed = aac.float_param("Speed", 0.0);
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let a = base.state("A", 0, 0);
|
||||
let b = base.state("B", 1, 0);
|
||||
let clip = aac.clip("clip");
|
||||
clip.looping(true);
|
||||
a.set_clip(clip);
|
||||
a.transition_to(b).when(speed > 0.5).duration(0.1);
|
||||
"#;
|
||||
serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_script_describes_the_whole_controller() {
|
||||
let value = json(AVATAR);
|
||||
|
||||
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(), 4);
|
||||
assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
|
||||
|
||||
let layers = value["controller"]["layers"].as_array().unwrap();
|
||||
assert_eq!(layers.len(), 1);
|
||||
assert_eq!(layers[0]["name"], "Base");
|
||||
|
||||
let states = layers[0]["state_machine"]["states"].as_array().unwrap();
|
||||
assert_eq!(states.len(), 4);
|
||||
assert_eq!(states[0]["name"], "Idle");
|
||||
assert_eq!(states[0]["position"]["x"], 0);
|
||||
|
||||
// A clip used both as a state motion and as a blend tree child.
|
||||
assert_eq!(states[0]["motion"]["type"], "clip");
|
||||
assert_eq!(states[0]["motion"]["name"], "idle_anim");
|
||||
assert_eq!(states[2]["motion"]["type"], "blend_tree");
|
||||
assert_eq!(states[2]["motion"]["name"], "locomotion");
|
||||
|
||||
// `speed > 0.1` becomes a greater-than condition.
|
||||
let transition = &states[0]["transitions"][0];
|
||||
assert_eq!(transition["conditions"][0]["parameter"], "Speed");
|
||||
assert_eq!(transition["conditions"][0]["mode"], "greater");
|
||||
assert_eq!(transition["conditions"][0]["threshold"], 0.1);
|
||||
assert_eq!(transition["duration"], 0.25);
|
||||
assert_eq!(transition["has_exit_time"], false);
|
||||
|
||||
// `when_all([...])` produces two conditions on one transition.
|
||||
let both = states[1]["transitions"][1]["conditions"].as_array().unwrap();
|
||||
assert_eq!(both.len(), 2);
|
||||
assert_eq!(both[1]["parameter"], "IsSitting");
|
||||
assert_eq!(both[1]["mode"], "if_not");
|
||||
|
||||
let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap();
|
||||
assert_eq!(any_state.len(), 1);
|
||||
assert_eq!(any_state[0]["to"], "Sit");
|
||||
|
||||
let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap();
|
||||
assert_eq!(sub.len(), 1);
|
||||
assert_eq!(sub[0]["name"], "Gestures");
|
||||
assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Blend shape keys landed in time order on one curve.
|
||||
let clip = find_clip(&value, "idle_anim");
|
||||
let curve = curve_of(clip, "blendShape.Smile");
|
||||
assert_eq!(curve["target"], "skinned_mesh_renderer");
|
||||
assert_eq!(curve["keys"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(curve["keys"][0]["time"], 0.0);
|
||||
assert_eq!(curve["keys"][1]["time"], 1.0);
|
||||
|
||||
// A toggle is a one-frame constant: two keys with the same value.
|
||||
let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive");
|
||||
assert_eq!(toggle["target"], "game_object");
|
||||
let keys = toggle["keys"].as_array().unwrap();
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys[0]["value"], keys[1]["value"]);
|
||||
}
|
||||
|
||||
fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value {
|
||||
value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap()
|
||||
}
|
||||
|
||||
fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
|
||||
clip["curves"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["property"] == property)
|
||||
.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(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let c = aac.clip("c");
|
||||
c.keyframe("Body", "m_IsActive", 2.0, 1.0);
|
||||
c.keyframe("Body", "m_IsActive", 0.5, 0.0);
|
||||
c.keyframe("Body", "m_IsActive", 1.0, 1.0);
|
||||
"#,
|
||||
);
|
||||
let times: Vec<f64> = value["clips"][0]["curves"][0]["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|k| k["time"].as_f64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(times, vec![0.5, 1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_tree_shapes() {
|
||||
let value = json(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let x = aac.float_param("X", 0.0);
|
||||
let y = aac.float_param("Y", 0.0);
|
||||
let direct = aac.float_param("Direct", 0.0);
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let s = base.state("S", 0, 0);
|
||||
let one = aac.clip("one");
|
||||
let two = aac.clip("two");
|
||||
let inner = aac.blend_tree("inner");
|
||||
inner.simple_1d(x);
|
||||
inner.add_motion(one, 0.0);
|
||||
let outer = aac.blend_tree("outer");
|
||||
outer.simple_directional_2d(x, y);
|
||||
outer.add_motion(two, 0.0, 1.0);
|
||||
outer.add_motion(inner, 2.0, 3.0);
|
||||
outer.automatic_thresholds(true);
|
||||
s.set_motion(outer);
|
||||
let flat = aac.blend_tree("flat");
|
||||
flat.direct();
|
||||
flat.add_motion_direct(one, direct);
|
||||
"#,
|
||||
);
|
||||
let trees = value["blend_trees"].as_array().unwrap();
|
||||
let outer = trees.iter().find(|t| t["name"] == "outer").unwrap();
|
||||
assert_eq!(outer["blend_type"], "simple_directional_2d");
|
||||
assert_eq!(outer["param_x"], "X");
|
||||
assert_eq!(outer["param_y"], "Y");
|
||||
assert_eq!(outer["use_automatic_thresholds"], true);
|
||||
let children = outer["children"].as_array().unwrap();
|
||||
assert_eq!(children.len(), 2);
|
||||
assert_eq!(children[0]["threshold_y"], 1.0);
|
||||
assert_eq!(children[1]["motion"]["type"], "blend_tree");
|
||||
assert_eq!(children[1]["motion"]["name"], "inner");
|
||||
|
||||
let flat = trees.iter().find(|t| t["name"] == "flat").unwrap();
|
||||
assert_eq!(flat["blend_type"], "direct");
|
||||
assert_eq!(flat["param_x"], "");
|
||||
assert_eq!(flat["children"][0]["direct_param"], "Direct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trips_through_the_graph_type() {
|
||||
// Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits.
|
||||
let json = aac::evaluate_to_json(AVATAR).expect("evaluates");
|
||||
let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes");
|
||||
let reserialized = serde_json::to_string_pretty(&graph).expect("serializes");
|
||||
assert_eq!(reserialized, json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_configuration_is_reported() {
|
||||
assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set"));
|
||||
assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_are_rejected_at_the_call_site() {
|
||||
assert!(error(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let a = aac.float_param("Speed", 0.0);
|
||||
let b = aac.float_param("Speed", 0.0);
|
||||
"#
|
||||
)
|
||||
.contains("already declared"));
|
||||
|
||||
assert!(error(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let a = aac.clip("c");
|
||||
let b = aac.clip("c");
|
||||
"#
|
||||
)
|
||||
.contains("already declared"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_curve_property_is_rejected() {
|
||||
let message = error(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let c = aac.clip("c");
|
||||
c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0);
|
||||
"#,
|
||||
);
|
||||
assert!(message.contains("cannot tell which component"), "unexpected: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn and_operator_is_not_available() {
|
||||
// The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`.
|
||||
let message = error(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let a = aac.float_param("A", 0.0);
|
||||
let b = aac.bool_param("B", false);
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let s = base.state("S", 0, 0);
|
||||
let t = base.state("T", 1, 0);
|
||||
s.transition_to(t).when(a > 0.0 && b == true);
|
||||
"#,
|
||||
);
|
||||
assert!(message.contains("bool"), "unexpected: {message}");
|
||||
}
|
||||
|
||||
/// The validator is a safety net for graphs that do not come from the typed Rhai DSL.
|
||||
#[test]
|
||||
fn validator_rejects_broken_graphs() {
|
||||
let mut graph = valid_graph();
|
||||
graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string();
|
||||
let message = aac::export::to_json(&graph).expect_err("dangling target");
|
||||
assert!(message.contains("not a state in that layer"), "unexpected: {message}");
|
||||
|
||||
let mut graph = valid_graph();
|
||||
graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string();
|
||||
let message = aac::export::to_json(&graph).expect_err("undeclared parameter");
|
||||
assert!(message.contains("not declared"), "unexpected: {message}");
|
||||
|
||||
let mut graph = valid_graph();
|
||||
graph.clips.push(graph.clips[0].clone());
|
||||
let message = aac::export::to_json(&graph).expect_err("duplicate clip");
|
||||
assert!(message.contains("declared more than once"), "unexpected: {message}");
|
||||
|
||||
let mut graph = valid_graph();
|
||||
graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() });
|
||||
let message = aac::export::to_json(&graph).expect_err("dangling motion");
|
||||
assert!(message.contains("not declared"), "unexpected: {message}");
|
||||
|
||||
// Forward references across states must resolve.
|
||||
let graph = valid_graph();
|
||||
aac::export::to_json(&graph).expect("a valid graph stays valid");
|
||||
}
|
||||
|
||||
/// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or
|
||||
/// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set.
|
||||
#[test]
|
||||
fn json_keys_are_the_ones_the_unity_dtos_expect() {
|
||||
let value = json(AVATAR);
|
||||
|
||||
fn keys(value: &Value) -> Vec<&str> {
|
||||
value.as_object().expect("object").keys().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]);
|
||||
assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]);
|
||||
assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]);
|
||||
assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]);
|
||||
assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]);
|
||||
|
||||
let tree = &value["blend_trees"][0];
|
||||
assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]);
|
||||
assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]);
|
||||
assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]);
|
||||
|
||||
let layer = &value["controller"]["layers"][0];
|
||||
assert_eq!(keys(layer), ["name", "state_machine"]);
|
||||
assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]);
|
||||
assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]);
|
||||
assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]);
|
||||
assert_eq!(
|
||||
keys(&layer["state_machine"]["states"][0]["transitions"][0]),
|
||||
[
|
||||
"can_transition_to_self",
|
||||
"conditions",
|
||||
"duration",
|
||||
"exit_time",
|
||||
"has_exit_time",
|
||||
"ordered_interruption",
|
||||
"source_interruption",
|
||||
"to"
|
||||
]
|
||||
);
|
||||
assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]);
|
||||
}
|
||||
Reference in New Issue
Block a user