docs: add comprehensive documentation and Rhai scripting guide
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
# Writing Animator Scripts in Rhai
|
||||
|
||||
A practical guide to writing `.rhai` files that generate Unity Animator Controllers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a file called `avatar.rhai`:
|
||||
|
||||
```rhai
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("MyAvatar");
|
||||
aac.asset_key("AAC_");
|
||||
|
||||
let speed = aac.float_param("Speed", 0.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 idle_clip = aac.clip("idle_anim");
|
||||
idle_clip.looping(true);
|
||||
idle_clip.toggle("Body/Props", true);
|
||||
|
||||
idle.set_clip(idle_clip);
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||
```
|
||||
|
||||
Open **Tools > Animator As Crab** in Unity, point it at this file, pick your controller and
|
||||
animator root, and hit Generate.
|
||||
|
||||
## How Scripts Are Structured
|
||||
|
||||
Every script follows the same pattern:
|
||||
|
||||
1. **Create the context and set metadata** — `AnimatorAsCode()`, `system_name`, `asset_key`.
|
||||
2. **Declare parameters** — floats, ints, bools. These become the controller's parameters.
|
||||
3. **Create the controller and layers** — one controller, one or more layers.
|
||||
4. **Declare clips and blend trees** — the motions your states will use.
|
||||
5. **Create states and assign motions** — place them on the grid.
|
||||
6. **Wire up transitions** — conditions, timing, interruption settings.
|
||||
|
||||
The order within each group matters (parameters before layers, clips before states that use
|
||||
them), but the groups themselves can be interleaved freely — you can declare a clip right before
|
||||
the state that uses it, or batch all clips at the top.
|
||||
|
||||
## Parameters
|
||||
|
||||
Parameters are the knobs your animator exposes at runtime. Declare them early — layers and
|
||||
transitions reference them by name.
|
||||
|
||||
```rhai
|
||||
let speed = aac.float_param("Speed", 0.0); // FloatParam, default 0.0
|
||||
let gesture = aac.int_param("Gesture", 0); // IntParam, default 0
|
||||
let is_sitting = aac.bool_param("IsSitting", false); // BoolParam, default false
|
||||
```
|
||||
|
||||
You get typed handles back. The same handle is used both in transition conditions and as blend
|
||||
tree parameters — the type is enforced at validation time, not at declaration time.
|
||||
|
||||
Duplicate names are rejected immediately with a clear error.
|
||||
|
||||
## States and Grid Position
|
||||
|
||||
States live on a layer. The last two arguments are the `(x, y)` position in Unity's animator
|
||||
graph editor — purely cosmetic, but useful for keeping things organized:
|
||||
|
||||
```rhai
|
||||
let idle = base.state("Idle", 0, 0); // top-left
|
||||
let walk = base.state("Walk", 1, 0); // one column right
|
||||
let run = base.state("Run", 2, 0); // further right
|
||||
```
|
||||
|
||||
State names must be unique within a layer (including across sub-state machines).
|
||||
|
||||
## Clips
|
||||
|
||||
### Generated Clips
|
||||
|
||||
Create a clip, configure it, then assign it to a state:
|
||||
|
||||
```rhai
|
||||
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("Face", "Smile", 0.0, 0.0);
|
||||
idle_clip.blend_shape("Face", "Smile", 1.0, 0.8);
|
||||
|
||||
idle.set_clip(idle_clip);
|
||||
```
|
||||
|
||||
Clip names must be unique across the entire script.
|
||||
|
||||
### Keyframes
|
||||
|
||||
Keyframes are always linear (tangents are zero). You add them by specifying a path, a property,
|
||||
a time, and a value:
|
||||
|
||||
```rhai
|
||||
clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
|
||||
clip.keyframe("Body/Hand", "m_IsActive", 1.0, 0.0);
|
||||
```
|
||||
|
||||
Multiple keyframes on the same curve are sorted by time automatically — the order you write
|
||||
them doesn't matter.
|
||||
|
||||
### Toggles
|
||||
|
||||
The most common pattern: turning a GameObject on or off. `toggle` is a shortcut that writes a
|
||||
two-keyframe constant on `m_IsActive`:
|
||||
|
||||
```rhai
|
||||
clip.toggle("Body/Props", true); // enable at frame 0, hold
|
||||
clip.toggle("Body/Props", false); // disable
|
||||
```
|
||||
|
||||
### Blend Shapes
|
||||
|
||||
Animate a blend shape weight:
|
||||
|
||||
```rhai
|
||||
clip.blend_shape("Face", "Smile", 0.0, 0.0); // start at 0
|
||||
clip.blend_shape("Face", "Smile", 1.0, 0.8); // end at 0.8
|
||||
```
|
||||
|
||||
### Curve Targets
|
||||
|
||||
The generator infers the Unity component type from the property name:
|
||||
|
||||
| Property | Component |
|
||||
| --- | --- |
|
||||
| `m_IsActive` | `GameObject` |
|
||||
| `blendShape.*` | `SkinnedMeshRenderer` |
|
||||
|
||||
Any other property name is rejected. If you need a different component type, extend
|
||||
`infer_target` in `rust/src/graph.rs`.
|
||||
|
||||
## Animation Store
|
||||
|
||||
If you already have `.anim` files in your project, you can use them instead of generating clips
|
||||
from scratch:
|
||||
|
||||
```rhai
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||
let walk = store.clip("Walk"); // loads Assets/Doloro/Clips/Walk.anim
|
||||
```
|
||||
|
||||
The `.anim` extension is optional — `store.clip("Walk")` and `store.clip("Walk.anim")` resolve
|
||||
to the same asset and produce the same clip.
|
||||
|
||||
### Editing Store Clips
|
||||
|
||||
Store clips are starting points. Anything you write on them — looping, keyframes, toggles — is
|
||||
applied to a **clone**. The original asset is never modified:
|
||||
|
||||
```rhai
|
||||
let walk = store.clip("Walk");
|
||||
walk.looping(true); // override the clone's looping
|
||||
walk.toggle("Body/Props/Umbrella", true); // add a keyframe on the clone
|
||||
```
|
||||
|
||||
A store clip with no `looping(...)` call keeps the source asset's own looping setting.
|
||||
|
||||
### Using Store Clips
|
||||
|
||||
Store clips work exactly like generated clips everywhere:
|
||||
|
||||
```rhai
|
||||
walk_state.set_clip(walk); // assign to a state
|
||||
locomotion.add_motion(walk, 0.0); // add to a blend tree
|
||||
```
|
||||
|
||||
### Subfolders
|
||||
|
||||
Store paths can contain subfolders. The folder argument to `AnimationStore` and the clip name
|
||||
are joined directly:
|
||||
|
||||
```rhai
|
||||
let props = aac.AnimationStore("Assets/Doloro/Clips/Props");
|
||||
let umbrella = props.clip("Weapons/Umbrella");
|
||||
// → Assets/Doloro/Clips/Props/Weapons/Umbrella.anim
|
||||
```
|
||||
|
||||
### Missing Assets
|
||||
|
||||
If a store clip references an asset that doesn't exist, you'll get a clear error at generation
|
||||
time — not a silent failure.
|
||||
|
||||
## Blend Trees
|
||||
|
||||
Blend trees mix multiple clips (or other blend trees) based on parameter values.
|
||||
|
||||
### 1D Blend Tree
|
||||
|
||||
The simplest kind — one parameter controls which clip plays:
|
||||
|
||||
```rhai
|
||||
let locomotion = aac.blend_tree("locomotion");
|
||||
locomotion.simple_1d(speed);
|
||||
locomotion.add_motion(idle_clip, 0.0); // speed 0 → idle
|
||||
locomotion.add_motion(walk_clip, 2.0); // speed 2 → walk
|
||||
locomotion.add_motion(run_clip, 5.0); // speed 5 → run
|
||||
```
|
||||
|
||||
The thresholds define the blend points. Unity interpolates between them.
|
||||
|
||||
### 2D Blend Trees
|
||||
|
||||
Two parameters control a 2D blend space:
|
||||
|
||||
```rhai
|
||||
let strafe = aac.blend_tree("strafe");
|
||||
strafe.freeform_directional_2d(speed, vertical);
|
||||
strafe.add_motion(forward_clip, 0.0, 1.0); // (x, y) threshold
|
||||
strafe.add_motion(back_clip, 0.0, -1.0);
|
||||
strafe.add_motion(left_clip, -1.0, 0.0);
|
||||
strafe.add_motion(right_clip, 1.0, 0.0);
|
||||
```
|
||||
|
||||
Three 2D types are available:
|
||||
|
||||
| Type | Behavior |
|
||||
| --- | --- |
|
||||
| `simple_directional_2d` | Clips are evenly distributed around a circle |
|
||||
| `freeform_directional_2d` | Clips can be at any angle, but magnitudes are normalized |
|
||||
| `freeform_cartesian_2d` | Clips can be at any position in the (x, y) plane |
|
||||
|
||||
### Direct Blend Tree
|
||||
|
||||
Each child is addressed by its own float parameter — useful for layered motion:
|
||||
|
||||
```rhai
|
||||
let hold = aac.blend_tree("hold");
|
||||
hold.direct();
|
||||
hold.add_motion_direct(umbrella_clip, grip); // grip parameter drives this child
|
||||
hold.add_motion_direct(sword_clip, sword_param);
|
||||
```
|
||||
|
||||
### Nesting Blend Trees
|
||||
|
||||
A blend tree can reference another blend tree that was declared **earlier** in the script:
|
||||
|
||||
```rhai
|
||||
let movement = aac.blend_tree("movement");
|
||||
movement.simple_1d(speed);
|
||||
movement.add_motion(walk_clip, 0.0);
|
||||
movement.add_motion(run_clip, 5.0);
|
||||
|
||||
let strafe = aac.blend_tree("strafe");
|
||||
strafe.freeform_directional_2d(speed, vertical);
|
||||
strafe.add_motion(forward_clip, 0.0, 1.0);
|
||||
|
||||
let full_body = aac.blend_tree("full_body");
|
||||
full_body.simple_1d(speed);
|
||||
full_body.add_motion(movement, 0.0); // nests the movement tree
|
||||
full_body.add_motion(strafe, 5.0); // nests the strafe tree
|
||||
```
|
||||
|
||||
Cycles are impossible — you can only reference trees that exist at the point you reference them.
|
||||
|
||||
### Automatic Thresholds
|
||||
|
||||
Let Unity figure out the blend thresholds:
|
||||
|
||||
```rhai
|
||||
tree.automatic_thresholds(true);
|
||||
```
|
||||
|
||||
## Transitions
|
||||
|
||||
Transitions connect states. You create one by calling `transition_to` on the source state:
|
||||
|
||||
```rhai
|
||||
idle.transition_to(walk); // creates a transition from idle to walk
|
||||
```
|
||||
|
||||
### Adding Conditions
|
||||
|
||||
Chain `.when(...)` to add conditions. Conditions are written as comparisons on parameter
|
||||
handles:
|
||||
|
||||
```rhai
|
||||
idle.transition_to(walk).when(speed > 0.1);
|
||||
walk.transition_to(run).when(speed > 4.0);
|
||||
```
|
||||
|
||||
The comparison operators (`>`, `<`, `==`, `!=`) are overloaded on parameter handles to produce
|
||||
conditions, not booleans. This is the core of the DSL — you write conditions exactly the way
|
||||
you'd think about them.
|
||||
|
||||
**Bool parameters** use `==` and `!=`:
|
||||
|
||||
```rhai
|
||||
any_state.transition_to(sit).when(is_sitting == true);
|
||||
any_state.transition_to(stand).when(is_sitting != true);
|
||||
```
|
||||
|
||||
### Combining Conditions with when_all
|
||||
|
||||
Rhai's `&&` and `||` cannot be overloaded — they're language built-ins. To combine multiple
|
||||
conditions on one transition, use `when_all`:
|
||||
|
||||
```rhai
|
||||
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]);
|
||||
```
|
||||
|
||||
This is the only DSL limitation with no workaround. Every multi-condition transition uses
|
||||
`when_all`.
|
||||
|
||||
### Transition Timing
|
||||
|
||||
```rhai
|
||||
// Instant transition, no blending:
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.0);
|
||||
|
||||
// 250ms cross-fade, fires immediately:
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||
|
||||
// Transition fires after 80% of the source animation plays:
|
||||
idle.transition_to(walk).when(speed > 0.1).exit_time(0.8).duration(0.25);
|
||||
```
|
||||
|
||||
`no_exit_time()` and `exit_time(normalized)` are mutually exclusive — the last one called wins.
|
||||
|
||||
### Self-Transitions
|
||||
|
||||
By default, a transition to the same state is ignored. Enable it explicitly:
|
||||
|
||||
```rhai
|
||||
walk.transition_to(walk).when(speed > 4.0).to_self().no_exit_time().duration(0.0);
|
||||
```
|
||||
|
||||
### Interruption Settings
|
||||
|
||||
```rhai
|
||||
transition.ordered_interruption(false); // disable ordered interruption
|
||||
transition.source_interruption(); // enable source interruption
|
||||
```
|
||||
|
||||
## Any-State Transitions
|
||||
|
||||
Any-state transitions fire from every state in a machine, regardless of which state is active:
|
||||
|
||||
```rhai
|
||||
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
|
||||
```
|
||||
|
||||
This is the standard way to implement global interrupts — sit down from any animation, reset
|
||||
from any gesture, etc.
|
||||
|
||||
Any-state can also be scoped to a sub-state machine:
|
||||
|
||||
```rhai
|
||||
let gestures = base.sub_machine("Gestures", 2, 0);
|
||||
gestures.any_state().transition_to(reset).when(gesture == 99);
|
||||
```
|
||||
|
||||
## Sub-State Machines
|
||||
|
||||
Group related states into a sub-state machine:
|
||||
|
||||
```rhai
|
||||
let gestures = base.sub_machine("Gestures", 2, 0);
|
||||
|
||||
let wave = gestures.state("Wave", 0, 0);
|
||||
let point = gestures.state("Point", 1, 0);
|
||||
|
||||
wave.transition_to(point).when(gesture == 1).duration(0.1);
|
||||
point.transition_to(wave).when(gesture == 0).duration(0.1);
|
||||
```
|
||||
|
||||
Sub-state machines work exactly like the root machine — you can add states, transitions,
|
||||
any-state, and even nested sub-state machines to them.
|
||||
|
||||
## Assigning Motions to States
|
||||
|
||||
States can hold a clip or a blend tree as their motion:
|
||||
|
||||
```rhai
|
||||
// From a generated clip:
|
||||
idle.set_clip(idle_clip);
|
||||
walk.set_motion(walk_clip);
|
||||
|
||||
// From a blend tree:
|
||||
run.set_motion(locomotion);
|
||||
|
||||
// From a store clip:
|
||||
sit.set_clip(store_clip);
|
||||
```
|
||||
|
||||
`set_clip` and `set_motion` are interchangeable — use whichever reads better.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a full script that demonstrates every feature:
|
||||
|
||||
```rhai
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("MyAvatar");
|
||||
aac.asset_key("AAC_");
|
||||
|
||||
// Parameters
|
||||
let speed = aac.float_param("Speed", 0.0);
|
||||
let is_sitting = aac.bool_param("IsSitting", false);
|
||||
let gesture = aac.int_param("Gesture", 0);
|
||||
|
||||
// Controller and layer
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
|
||||
// States
|
||||
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);
|
||||
|
||||
// Generated clips
|
||||
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);
|
||||
|
||||
// Store clip
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||
let crouch_clip = store.clip("Crouch");
|
||||
crouch_clip.looping(true);
|
||||
|
||||
// Assign clips
|
||||
idle.set_clip(idle_clip);
|
||||
walk.set_clip(walk_clip);
|
||||
sit.set_clip(crouch_clip);
|
||||
|
||||
// Blend tree
|
||||
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);
|
||||
|
||||
// Transitions
|
||||
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);
|
||||
|
||||
// Any-state interrupt
|
||||
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
|
||||
|
||||
// Sub-state machine
|
||||
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);
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Start minimal.** One layer, two states, one transition. Get it generating. Then add
|
||||
complexity incrementally.
|
||||
- **Use the grid positions.** `(0, 0)` is top-left. Spread states horizontally for logical
|
||||
flow, vertically for alternative branches.
|
||||
- **Name clips descriptively.** They become the asset names in Unity. `"walk_anim"` is clearer
|
||||
than `"clip1"`.
|
||||
- **Store clips for reused animations.** If multiple avatars share the same `.anim` assets, a
|
||||
store avoids duplicating keyframes in every script.
|
||||
- **`when_all` is your `&&`.** Every multi-condition transition uses it. There's no way around
|
||||
this — it's a Rhai language limitation.
|
||||
- **Test with `aac-dump`.** Run `cargo run --bin aac-dump -- your_script.rhai` to see the JSON
|
||||
output without opening Unity. Catches script errors instantly.
|
||||
Reference in New Issue
Block a user