24 Commits
Author SHA1 Message Date
doloro 9041776554 docs: add comprehensive documentation and Rhai scripting guide 2026-09-19 18:51:45 +01:00
doloro 6f6af41245 stuff 2026-09-19 18:24:12 +01:00
doloro e45928b82d remove continuous release workflow and version entry 2026-09-19 18:23:21 +01:00
gitea-actions 4c7a4574ab vpm: continuous index update 2026-09-19 17:18:48 +00:00
doloro 1ca4e0bba5 fix: use 0.0.1-continuous as continuous version
continuous / Publish continuous release (push) Successful in 1m17s
2026-09-19 18:17:30 +01:00
gitea-actions ceae378040 vpm: continuous index update 2026-09-19 17:15:21 +00:00
doloro 701d9dc731 fix: use 0.0.1 as continuous version
continuous / Publish continuous release (push) Successful in 14s
2026-09-19 18:15:05 +01:00
gitea-actions d9ce28b5fa vpm: continuous index update 2026-09-19 17:10:48 +00:00
doloro 105f9359e3 fix: drop stale continuous entry, ci only on tags
continuous / Publish continuous release (push) Successful in 14s
2026-09-19 18:10:31 +01:00
gitea-actions bc2f1354c0 vpm: continuous index update 2026-09-19 17:09:44 +00:00
doloro 13b8d62370 fix: use valid semver 0.0.0-continuous in CI workflow
ci / Nix flake checks (push) Successful in 8s
continuous / Publish continuous release (push) Successful in 13s
2026-09-19 18:09:30 +01:00
gitea-actions 8159442e9d vpm: continuous index update 2026-09-19 17:08:40 +00:00
doloro 3d640ebd4d fix: use valid semver for continuous version (0.0.0-continuous)
ci / Nix flake checks (push) Successful in 7s
continuous / Publish continuous release (push) Successful in 13s
2026-09-19 18:08:24 +01:00
gitea-actions 86f42b6552 vpm: continuous index update 2026-09-19 17:05:58 +00:00
doloro 31affff9dd ci: add continuous release workflow for rolling VPM pre-releases
ci / Nix flake checks (push) Successful in 8s
continuous / Publish continuous release (push) Successful in 13s
2026-09-19 18:05:39 +01:00
doloro d665a01a1f bleh
ci / Nix flake checks (push) Successful in 10s
2026-09-19 17:59:53 +01:00
gitea-actions 41a3656d78 vpm: index for v0.1.0 2026-09-19 16:53:27 +00:00
doloro c4ed44e00b ci: run the vpm scripts through bash
ci / Nix flake checks (push) Successful in 9s
release / Publish VPM release (push) Successful in 12s
The nix runner has no /usr/bin/env, so the '#!/usr/bin/env bash' shebang
cannot resolve; invoke the scripts as arguments to bash instead.
2026-09-19 17:52:06 +01:00
doloro e82f2b7de7 ci: target this instance's nix runner
ci / Nix flake checks (push) Failing after 1h6m15s
- run on the 'nix' label with Nix preinstalled; drop the installer action
- replace the inline release API calls with vpm/publish.sh
- add curl to the devShell for the publish step
2026-09-19 16:09:21 +01:00
doloro e5f8a078e1 vpm: use this Gitea's raw and release paths
ci / build (push) Canceled after 0s
The -/raw/main and -/releases/download forms are GitLab routing and 404 on
git.scug.io; the working forms are /raw/branch/main and /releases/download.
2026-09-19 16:08:31 +01:00
doloro a1b81b8eb7 flake: emit Unity bundle zip; add Gitea Actions CI/release; publish VPM index
ci / build (push) Canceled after 0s
- flake.nix: install the libaac cdylib alongside aac-dump and zip it with
  the C# Unity packages as the default package
- .gitea/workflows: build+package on push/PR, publish release on v* tags
- vpm: commit the repository index and packaging script, with VPM_INDEX and
  VPM_INDEX_URL overrides for generating a local listing
2026-09-19 16:07:43 +01:00
doloro 2c3b670ab9 Add animation store for pre-made clips
aac.AnimationStore(folder) seeds a clip from an existing .anim asset and
returns a normal ClipBuilder, so pre-made clips work anywhere generated
clips do and can still be edited (looping, keyframes, toggles, blend
shapes). The Unity side clones the asset into the container and applies
the script's changes to the clone, leaving the original untouched.

- ClipData gains optional source; looping becomes Option<bool> so an
  untouched store clip keeps the asset's own looping setting.
- Add rust/examples/animation_store.rhai, a worked store example.
2026-09-18 14:52:02 +01:00
doloro 42a1234e7f shity basic one shot 2026-09-18 14:01:10 +01:00
doloro 5851077659 new read me 2026-09-18 13:06:03 +01:00
92 changed files with 5184 additions and 92 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+27
View File
@@ -0,0 +1,27 @@
name: ci
on:
push:
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
nix-flake:
name: Nix flake checks
runs-on: nix
steps:
- uses: actions/checkout@v4
- name: Check flake
run: nix flake check --print-build-logs
# package.sh bundles the cdylib from ./result/lib, so link the aac package first.
- name: Build Rust core
run: nix build .#aac --print-build-logs
- name: Package VPM zip
run: nix develop -c bash ./vpm/package.sh
- name: Build Unity bundle
run: nix build .#default --print-build-logs
+45
View File
@@ -0,0 +1,45 @@
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
release:
name: Publish VPM release
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Rust core
run: nix build .#aac --print-build-logs
- name: Package VPM zip
run: nix develop -c bash ./vpm/package.sh
- name: Publish release and upload zip
env:
TOKEN: ${{ secrets.VPM_TOKEN || secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: nix develop -c bash ./vpm/publish.sh
# The index carries the zip's sha256, which only exists after packaging.
- name: Commit updated VPM index
env:
TOKEN: ${{ secrets.VPM_TOKEN || secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git config user.name gitea-actions
git config user.email actions@git.scug.io
git add vpm/index.json
git diff --cached --quiet && exit 0
git commit -m "vpm: index for $TAG"
git push "https://x-access-token:$TOKEN@${SERVER#https://}/${REPO}.git" HEAD:main
-55
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/result
vpm/dist/
vpm/index.local.json
+557
View File
@@ -0,0 +1,557 @@
# Animator As Crab
A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where Animator
Controllers are described in [Rhai](https://rhai.rs/) instead of C#. A Rust library (`libaac`)
evaluates the script, validates it, and emits the 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.
```
script.rhai → libaac (Rust) ──JSON──→ AacCrabWindow (Unity Editor) → AnimatorController
```
## Table of Contents
- [Why](#why)
- [Architecture](#architecture)
- [Layout](#layout)
- [Building](#building)
- [Installation](#installation)
- [Usage](#usage)
- [DSL Reference](#dsl-reference)
- [Setup](#setup)
- [Parameters](#parameters)
- [Controller and Layers](#controller-and-layers)
- [States](#states)
- [Clips](#clips)
- [Animation Store](#animation-store)
- [Blend Trees](#blend-trees)
- [Transitions](#transitions)
- [Any-State Transitions](#any-state-transitions)
- [Sub-State Machines](#sub-state-machines)
- [Transition Settings](#transition-settings)
- [JSON Wire Format](#json-wire-format)
- [Native FFI API](#native-ffi-api)
- [C# Bridge](#c-bridge)
- [VPM Distribution](#vpm-distribution)
- [Nix Development](#nix-development)
- [Limitations](#limitations)
- [License](#license)
## Why
Animator As Code V1 lets you build Animator Controllers from a fluent C# API. That works, but
every change requires recompilation. Animator As Crab replaces the C# builder with a Rhai script
that is evaluated at edit time:
- **No recompilation.** Save the `.rhai` file and the controller rebuilds automatically.
- **Version control friendly.** A `.rhai` script is a single readable file; no binary assets.
- **Validates before generation.** The Rust core checks the graph for dangling references,
duplicate names, mismatched parameter types, and missing configuration before Unity ever sees it.
- **Untouched upstream.** The original Animator As Code V1 C# library ships as-is; the generator
drives its public API.
## Architecture
The system is a pipeline with three stages:
1. **Rhai evaluation** (`libaac`). The engine registers builder types (`Aac`, `ClipBuilder`,
`StateRef`, etc.) and Rhai-callable functions. The script mutates a shared `ControllerGraph`
through these builders.
2. **Validation and serialization** (`libaac`). After the script runs, keyframes are sorted by
time, then the graph is validated: every name is unique, every reference resolves, every
parameter type matches its usage, every transition targets a real state, and required fields
(`system_name`, `asset_key`) are non-empty. The validated graph is serialized to JSON.
3. **C# generation** (Unity Editor). `AacCrabWindow` reads the JSON, deserializes it into
`AacCrabGraph` DTOs, and drives Animator As Code V1's modification API: clears the controller,
creates parameters, builds clips (cloning store assets via `AssetDatabase`), builds blend
trees in declaration order (so forward references are impossible), and wires up states and
transitions.
The Rust and C# sides share no runtime dependency. The contract is a JSON document whose shape is
pinned by a test (`json_keys_are_the_ones_the_unity_dtos_expect` in `rust/tests/dsl.rs`).
## Layout
| Path | Contents |
| --- | --- |
| `rust/` | The Rhai DSL, graph model, validator, FFI exports, and the `libaac` cdylib. |
| `rust/examples/avatar.rhai` | Reference script exercising every feature of the DSL. |
| `rust/examples/animation_store.rhai` | Reference script for the animation store feature. |
| `rust/tests/dsl.rs` | Integration tests: round-trip JSON, validation errors, API surface. |
| `csharp/dev.doloro.animator-as-crab/` | Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. |
| `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs` | P/Invoke bindings for `libaac`. |
| `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs` | JSON DTOs matching `rust/src/graph.rs`. |
| `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs` | Turns the graph into an `AnimatorController` via AAC V1. |
| `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs` | Unity Editor window with file watching. |
| `vpm/` | VPM packaging scripts and the package index. |
| `flake.nix` | Dev shell with Rust, `dotnet`, `jq`, `zip`, and `just`. |
| `justfile` | Shortcuts for importing the VPM package into a Unity project. |
## Building
### With Nix
```sh
nix develop # enter the dev shell
cargo test --manifest-path rust/Cargo.toml # run tests
nix build .#aac # produces libaac.so + aac-dump
nix build .#bundle # produces a Unity-importable zip
```
### Without Nix
You need Rust (stable) and a C-compatible linker.
```sh
cd rust
cargo build --release
# Output: target/release/libaac.so (Linux), libaac.dylib (macOS), or aac.dll (Windows)
# Binary: target/release/aac-dump
```
### Standalone dump tool
```sh
cargo run --bin aac-dump -- rust/examples/avatar.rhai # prints the generated JSON
```
## Installation
### Unity package (VPM)
Add the VPM repository URL to the VRChat Creator Companion or ALCOM:
```
https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json
```
Or import manually:
```sh
# Build the package zip first
./vpm/package.sh
# Then import into a Unity project
just import /path/to/unity-project
```
### Manual
1. Copy `libaac.so` / `libaac.dll` / `libaac.dylib` into your Unity project's `Assets/Plugins/`.
2. Add the `csharp/dev.doloro.animator-as-crab` folder to your project's `Packages/` directory.
3. The package depends on `com.unity.nuget.newtonsoft-json` (3.2.1); Unity's Package Manager
resolves this automatically.
## Usage
1. Write a `.rhai` script (see [DSL Reference](#dsl-reference)).
2. Open **Tools > Animator As Crab** in the Unity Editor.
3. Fill in the window:
- **Rhai script** — path to your `.rhai` file.
- **Animator Controller** — the controller asset to generate into.
- **Animator Root** — the `Transform` that owns the `Animator` component.
- **Asset Container** — the folder/object that will hold generated clips and blend trees.
- **Container Mode** — how assets are organized (`Everything`, `ChildrenOfRoot`, etc.).
- **Write Defaults** — whether states write defaults.
4. Click **Generate**, or enable **Generate when the script changes** for automatic regeneration
on save.
## DSL Reference
### Setup
Every script starts by creating an `Aac` context and configuring the system name and asset key:
```rhai
let aac = AnimatorAsCode();
aac.system_name("MyAvatar"); // required — identifies the system
aac.asset_key("AAC_"); // required — prefix for generated assets
```
The system name and asset key are written by the script, not guessed by the Unity side. The
validator rejects empty values.
### Parameters
Parameters are controller-wide. Declare them before building layers.
| Function | Return type | Default |
| --- | --- | --- |
| `aac.float_param("Name", 0.0)` | `FloatParam` | `0.0` |
| `aac.int_param("Name", 0)` | `IntParam` | `0` |
| `aac.bool_param("Name", false)` | `BoolParam` | `false` |
Duplicate parameter names are rejected at the call site.
### Controller and Layers
```rhai
let ctrl = aac.new_controller(); // exactly one controller
let base = ctrl.layer("Base"); // adds a layer; returns a LayerBuilder
let props = ctrl.layer("Props");
```
### States
States live on a layer or inside a sub-state machine. The last two arguments are the grid
position in the Unity animator graph:
```rhai
let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
```
### Clips
Generated clips are declared on the `Aac` context:
```rhai
let clip = aac.clip("idle_anim"); // name must be unique
clip.looping(true); // optional override
clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
clip.blend_shape("Body", "Smile", 0.0, 0.0);
clip.blend_shape("Body", "Smile", 1.0, 0.8);
clip.toggle("Body/Props", true); // shortcut for a one-frame m_IsActive constant
```
Keyframes are sorted by time automatically. All keyframes are linear (tangents are 0).
**Supported curve targets** (inferred from the property name):
| Property | Component type |
| --- | --- |
| `m_IsActive` | `GameObject` |
| `blendShape.*` | `SkinnedMeshRenderer` |
Any other property is rejected with a clear error.
**Assigning clips to states:**
```rhai
idle.set_clip(clip); // same as set_motion
walk.set_motion(clip);
```
### Animation Store
An animation store seeds clips from `.anim` assets that already exist in the project. 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.
```rhai
let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk"); // resolves to Assets/Doloro/Clips/Walk.anim
walk.looping(true); // override on the clone
walk.keyframe("Body/Props", "m_IsActive", 0.0, 0.0);
```
A store clip with no `looping(...)` call keeps the source asset's own looping setting. Requesting
the same asset twice (with or without `.anim`) reuses the same reference. A missing asset is a
clear error at generation time.
Store clips are usable anywhere a generated clip is:
```rhai
walk_state.set_clip(walk);
locomotion.add_motion(walk, 0.0);
```
### Blend Trees
Blend trees are declared on the `Aac` context and configured with a blend type and parameters:
```rhai
let tree = aac.blend_tree("locomotion");
tree.simple_1d(speed); // 1D blend
tree.add_motion(walk_clip, 0.0); // (clip, threshold)
tree.add_motion(run_clip, 5.0);
```
**Blend types:**
| Function | Parameters | Use case |
| --- | --- | --- |
| `tree.simple_1d(x)` | 1 float param | Walk speed, locomotion blends |
| `tree.simple_directional_2d(x, y)` | 2 float params | Strafe direction |
| `tree.freeform_directional_2d(x, y)` | 2 float params | Complex directional |
| `tree.freeform_cartesian_2d(x, y)` | 2 float params | Full 2D control |
| `tree.direct()` | none | Per-parameter clip addressing |
**Adding children:**
```rhai
// 1D / 2D threshold-based:
tree.add_motion(clip, threshold);
tree.add_motion(clip, x, y);
// 2D also works with blend trees as children:
tree.add_motion(inner_tree, 0.0, 1.0);
// Direct blend:
tree.add_motion_direct(clip, parameter);
```
**Automatic thresholds:**
```rhai
tree.automatic_thresholds(true);
```
Blend trees can nest other blend trees that were declared earlier in the script. Cycles are
impossible because trees are created in declaration order.
**Assigning blend trees to states:**
```rhai
run.set_motion(tree);
```
### Transitions
Transitions are created by calling `transition_to` on a state (or any-state ref) and chaining
condition and setting methods:
```rhai
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
```
**Conditions** are written on the parameter objects using comparison operators:
| Operator | Float/Int | Bool |
| --- | --- | --- |
| `>` | Greater | — |
| `<` | Less | — |
| `==` | Equals | If / IfNot |
| `!=` | NotEqual | IfNot / If |
Combine multiple conditions with `when_all([...])`:
```rhai
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25);
```
> **Why not `&&`?** Rhai's `&&` and `||` are language built-ins that cannot be overloaded.
> `speed > 0.1 && is_sitting == true` will not compile. Use `when_all`.
### Any-State Transitions
Any-state transitions fire from every state in a machine:
```rhai
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
```
Any-state can also be used inside a sub-state machine:
```rhai
let gestures = base.sub_machine("Gestures", 2, 0);
gestures.any_state().transition_to(reset).when(reset_all == true);
```
### Sub-State Machines
```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);
```
States and transitions inside a sub-state machine work exactly like the root machine.
### Transition Settings
All settings are chainable on a `TransitionRef`:
| Method | Effect | Default |
| --- | --- | --- |
| `.duration(seconds)` | Cross-fade duration | `0.0` |
| `.no_exit_time()` | Transition fires immediately | — |
| `.exit_time(normalized)` | Transition fires after this normalized time | — |
| `.to_self()` | Allow self-transitions | `false` |
| `.ordered_interruption(bool)` | Ordered interruption | `true` |
| `.source_interruption()` | Source interruption | `false` |
Default transition values match Animator As Code V1's `AacDefaultsProvider.ConfigureTransition`.
## JSON Wire Format
The Rust core serializes the graph as JSON. The C# side deserializes it with
`MissingMemberHandling.Error`, so the shape is a strict contract. Every object's key set is
pinned by the test `json_keys_are_the_ones_the_unity_dtos_expect` in `rust/tests/dsl.rs`.
Top-level keys: `system_name`, `asset_key`, `parameters`, `clips`, `blend_trees`, `controller`.
Example (trimmed):
```json
{
"system_name": "MyAvatar",
"asset_key": "AAC_",
"parameters": [
{ "type": "float", "name": "Speed", "default": 0.0 }
],
"clips": [
{
"name": "idle_anim",
"looping": true,
"curves": [
{
"path": "Body/Hand",
"target": "game_object",
"property": "m_IsActive",
"keys": [{ "time": 0.0, "value": 1.0, "in_tangent": 0.0, "out_tangent": 0.0 }]
}
]
}
],
"blend_trees": [
{
"name": "locomotion",
"blend_type": "simple_1d",
"param_x": "Speed",
"param_y": null,
"children": [
{ "motion": { "type": "clip", "name": "walk_anim" }, "threshold": 0.0, "threshold_y": null, "direct_param": null }
],
"use_automatic_thresholds": false
}
],
"controller": {
"layers": [
{
"name": "Base",
"state_machine": {
"name": null,
"position": { "x": 0, "y": 0 },
"states": [
{
"name": "Idle",
"position": { "x": 0, "y": 0 },
"motion": { "type": "clip", "name": "idle_anim" },
"transitions": [
{
"to": "Walk",
"conditions": [{ "parameter": "Speed", "mode": "greater", "threshold": 0.1 }],
"has_exit_time": false,
"exit_time": 0.0,
"duration": 0.25,
"ordered_interruption": true,
"source_interruption": false,
"can_transition_to_self": false
}
]
}
],
"sub_machines": [],
"any_state_transitions": []
}
}
]
}
}
```
Store clips include a `"source"` field with the project-relative asset path; generated clips
omit it.
## Native FFI API
`libaac` exposes a C ABI for Unity's P/Invoke:
| Function | Signature | Description |
| --- | --- | --- |
| `aac_create` | `() → *mut AacContext` | Create a context. Owns the graph and engine. |
| `aac_eval_rhai` | `(handle, script) → i32` | Evaluate a script. Returns 0 on success. |
| `aac_to_json` | `(handle) → *mut c_char` | Serialize the graph to JSON. Free with `aac_free_string`. |
| `aac_last_error` | `(handle) → *const c_char` | Last error message, or null. Borrowed until next call. |
| `aac_free_string` | `(string)` | Free a string returned by `aac_to_json`. |
| `aac_destroy` | `(handle)` | Destroy a context. |
The context is single-threaded. Panics across the FFI boundary are caught and reported as errors.
**Standalone dump tool** (`aac-dump`):
```sh
cargo run --bin aac-dump -- path/to/script.rhai
```
Prints the generated JSON to stdout. Exits with a non-zero status and prints the error to stderr
on failure.
## C# Bridge
The Unity side lives in `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/`:
- **`AacCrabNative`** — P/Invoke wrapper around `libaac`. Creates a context, evaluates the
script, reads the JSON, and destroys the context in a single `Evaluate` call. Handles
`DllNotFoundException` with a clear message.
- **`AacCrabGraph`** — DTO classes mirroring `rust/src/graph.rs`. Deserialized with
Newtonsoft.Json using `snake_case` naming and `StringEnumConverter`. Missing fields are an
error, not silently ignored.
- **`AacCrabGenerator`** — The core generator. Clears the controller via AAC V1's modification
API, creates parameters (applying defaults by mutating the controller's parameter list), builds
clips (cloning store assets via `AssetDatabase`), builds blend trees in declaration order, and
wires states and transitions. Forward references within a layer resolve because all states are
created before any transitions.
- **`AacCrabWindow`** — Editor window (Tools > Animator As Crab). Watches the script file for
changes and regenerates automatically. All Unity-side configuration (controller, root,
container, container mode, write defaults) is supplied by the user; nothing is defaulted
silently.
## VPM Distribution
The `vpm/` directory contains the packaging and publishing pipeline:
- **`package.sh`** — Builds a VPM release zip containing the C# package and the native library,
then updates `vpm/index.json` with the new version's URL and SHA-256. Environment variables
override the host, project, and version.
- **`publish.sh`** — Creates a Gitea release for a given tag and attaches the VPM zip. Requires
`TOKEN`, `SERVER`, `REPO`, and `TAG` environment variables.
- **`index.json`** — The VPM repository index read by VCC and ALCOM.
To add the repository to VCC/ALCOM, paste the index URL:
```
https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json
```
## Nix Development
The `flake.nix` provides:
- **Dev shell** (`nix develop`): Rust stable with clippy, rustfmt, rust-analyzer; `dotnet`,
`mono`, `jq`, `zip`, `unzip`, `just`, `curl`.
- **Packages**: `nix build .#aac` (native library + dump binary), `nix build .#bundle`
(Unity-importable zip).
- **Formatter**: `nix fmt` (nixfmt-rfc-style).
The dev shell also provides `.direnv` integration via `.envrc`.
## Limitations
- **No Unity project in this repo.** The bridge and generator are verified by reading and
testing the Rust side, not by running in Unity.
- **Blend tree nesting.** A blend tree can only reference a tree declared earlier in the script.
- **No avatar masks, layer weights, state behaviours, or parameter drivers.** The graph model
does not carry them yet.
- **Linear keyframes only.** All tangent values are 0; no easing curves.
- **Two curve target types.** Only `GameObject` (`m_IsActive`) and `SkinnedMeshRenderer`
(`blendShape.*`). Unknown properties are rejected.
- **Single-threaded FFI.** The `AacContext` is not safe to share across threads.
## License
MIT. See [LICENSE](LICENSE).
Originally created by [@hai-vr](https://github.com/hai-vr) with major contributions from
[@galister](https://github.com/galister). Forked and extended by doloro.
@@ -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"
}
+91 -21
View File
@@ -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.
+480
View File
@@ -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.
@@ -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,202 @@
using System;
using System.IO;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using AnimatorAsCode.V1;
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
View File
@@ -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
}
+111
View File
@@ -0,0 +1,111 @@
{
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;
# Name Unity expects on the current platform; AacCrabNative.cs imports "libaac".
nativeLib =
if pkgs.stdenv.hostPlatform.isDarwin then
"libaac.dylib"
else if pkgs.stdenv.hostPlatform.isWindows then
"aac.dll"
else
"libaac.so";
# The Rust core: evaluates the Rhai DSL and emits the graph as JSON for Unity.
aac = craneLib.buildPackage {
src = ./rust;
strictDeps = true;
doCheck = true;
# crane's default install only copies binaries; install the cdylib explicitly.
installPhase = ''
runHook preInstall
mkdir -p $out/lib $out/bin
cp target/release/${nativeLib} $out/lib/
cp target/release/aac-dump $out/bin/
runHook postInstall
'';
};
# Unity-importable bundle: native library plus the C# packages, as a single zip.
bundle =
pkgs.runCommand "animator-as-crab-${system}.zip"
{
nativeBuildInputs = [ pkgs.zip ];
meta.description = "Unity plugin bundle for animator-as-crab";
}
''
mkdir -p root/Plugins root/bin
cp ${aac}/lib/${nativeLib} root/Plugins/
cp ${aac}/bin/aac-dump root/bin/
cp -r ${./csharp}/dev.doloro.animator-as-crab root/
cp -r ${./csharp}/dev.hai-vr.animator-as-code.v1.base.test root/
(cd root && zip -q -r $out .)
'';
in
{
packages = {
inherit aac bundle;
default = bundle;
};
devShells.default = craneLib.devShell {
inputsFrom = [ aac ];
packages = [
rustToolchain
pkgs.curl
pkgs.jq
pkgs.zip
pkgs.just
];
};
formatter = pkgs.nixfmt-rfc-style;
};
in
{
packages = forAllSystems (system: (perSystem system).packages);
devShells = forAllSystems (system: (perSystem system).devShells);
formatter = forAllSystems (system: (perSystem system).formatter);
};
}
+13
View File
@@ -0,0 +1,13 @@
# Import the locally built VPM package into a Unity project.
# Usage: just import /path/to/unity-project
import unity-proj:
#!/usr/bin/env bash
set -euo pipefail
pkg="dev.doloro.animator-as-crab"
zip=$(ls vpm/dist/$pkg-*.zip 2>/dev/null | head -1)
[ -n "$zip" ] || { echo "no zip in vpm/dist/ — run vpm/package.sh first"; exit 1; }
target="$unity-proj/Packages/$pkg"
rm -rf "$target"
mkdir -p "$target"
unzip -q -o "$zip" -d "$target"
echo "imported $pkg -> $target"
+33
View File
@@ -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**.
+1
View File
@@ -0,0 +1 @@
target/
+463
View File
@@ -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"
+13
View File
@@ -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"
+115
View File
@@ -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);
+66
View File
@@ -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);
+26
View File
@@ -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);
}
}
}
+279
View File
@@ -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))
}
}
+325
View File
@@ -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, &parameters, &mut errors);
validate_layers(graph, &parameters, &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(_) => {}
}
}
+269
View File
@@ -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
View File
@@ -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)) };
}
+212
View File
@@ -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(&parameter.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(&parameter.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)
}
+272
View File
@@ -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
});
}
+438
View File
@@ -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"]);
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "Doloro VPM Packages",
"id": "dev.doloro.vpm",
"url": "https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json",
"author": {
"name": "doloro"
},
"packages": {
"dev.doloro.animator-as-crab": {
"versions": {
"0.1.0": {
"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.",
"url": "https://git.scug.io/doloro/av3-animation-as-crab/releases/download/v0.1.0/dev.doloro.animator-as-crab-0.1.0.zip",
"zipSHA256": "a2113de469c07b1dd1373fb76bbc90fa9f7a2d4855eca57fa3351c9754821af8",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"vpmDependencies": {},
"author": {
"name": "doloro"
}
}
}
}
}
}
Executable
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Builds the VPM release zip for dev.doloro.animator-as-crab and (re)generates vpm/index.json,
# the repository listing that VCC and ALCOM read.
#
# Needs `zip` and `jq`: run it inside `nix develop`.
#
# Environment overrides (all optional):
# VPM_HOST default https://git.scug.io
# VPM_PROJECT default doloro/av3-animation-as-crab
# VPM_RELEASE_BASE default <host>/<project>/releases/download/v<version>
# VPM_INDEX default <root>/vpm/index.json
# VPM_INDEX_URL default <host>/<project>/raw/branch/main/vpm/index.json
# VPM_REPO_NAME default "Doloro VPM Packages"
# VPM_REPO_ID default dev.doloro.vpm
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
pkg="$root/csharp/dev.doloro.animator-as-crab"
vpm="$root/vpm"
host=${VPM_HOST:-https://git.scug.io}
project=${VPM_PROJECT:-doloro/av3-animation-as-crab}
repo_name=${VPM_REPO_NAME:-Doloro VPM Packages}
repo_id=${VPM_REPO_ID:-dev.doloro.vpm}
index_url=${VPM_INDEX_URL:-$host/$project/raw/branch/main/vpm/index.json}
release_base=${VPM_RELEASE_BASE:-}
id=$(jq -r .name "$pkg/package.json")
version=${VPM_VERSION:-$(jq -r .version "$pkg/package.json")}
zip_name="$id-$version.zip"
: "${release_base:=$host/$project/releases/download/v$version}"
zip_url="$release_base/$zip_name"
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
cp -r "$pkg/." "$stage/"
rm -f "$stage/.gitignore"
# The native library is not optional: DllImport("libaac") fails without it, and a VPM package is
# unpacked into Packages/, so it cannot rely on the user's Assets/Plugins. Ship it in the package.
# Only Linux is built here; drop a libaac.dll / libaac.dylib into vpm/native/ for other hosts.
shopt -s nullglob
native=("$vpm"/native/* "$root"/result/lib/*)
shopt -u nullglob
if [ ${#native[@]} -eq 0 ]; then
echo "warning: no native library found; the package will not load libaac" >&2
fi
for lib in "${native[@]}"; do
cp "$lib" "$stage/V1/Editor/"
echo "bundled $(basename "$lib")"
done
mkdir -p "$vpm/dist"
rm -f "$vpm/dist/$zip_name"
(cd "$stage" && zip -r -X -q "$vpm/dist/$zip_name" .)
sha=$(sha256sum "$vpm/dist/$zip_name" | cut -d' ' -f1)
entry=$(jq -n \
--arg id "$id" --arg version "$version" --arg zip_url "$zip_url" --arg sha "$sha" \
--slurpfile manifest "$pkg/package.json" \
'{name: $id, displayName: $manifest[0].displayName, version: $version, unity: $manifest[0].unity,
description: $manifest[0].description, url: $zip_url, zipSHA256: $sha,
dependencies: ($manifest[0].dependencies // {}), vpmDependencies: {}, author: $manifest[0].author}')
index=${VPM_INDEX:-$vpm/index.json}
old='{}'
[ -f "$index" ] && old=$(cat "$index")
echo "$old" | jq \
--arg repo_name "$repo_name" --arg repo_id "$repo_id" --arg index_url "$index_url" \
--arg id "$id" --arg version "$version" --argjson entry "$entry" \
'.name = $repo_name | .id = $repo_id | .url = $index_url | .author = $entry.author
| .packages = (.packages // {})
| .packages[$id] = (.packages[$id] // {versions: {}})
| .packages[$id].versions[$version] = $entry' > "$index"
jq -e . "$index" > /dev/null
echo "wrote $vpm/dist/$zip_name"
echo "sha256 $sha"
echo "updated $index -> $zip_url"
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Creates the Gitea release for the current tag and attaches the VPM zip built by package.sh.
#
# Needs `curl` and `jq`: run it inside `nix develop`.
#
# Environment:
# TOKEN required, Gitea token with repository write access
# SERVER required, e.g. https://git.scug.io
# REPO required, e.g. doloro/av3-animation-as-crab
# TAG required, release tag
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
manifest="$root/csharp/dev.doloro.animator-as-crab/package.json"
id=$(jq -r .name "$manifest")
version=${VPM_VERSION:-$(jq -r .version "$manifest")}
zip="$root/vpm/dist/$id-$version.zip"
zip_name=$(basename "$zip")
[ -f "$zip" ] || { echo "missing $zip; run package.sh first" >&2; exit 1; }
# Delete existing release with same tag if present (needed for rolling tags like continuous)
existing=$(curl -fsS -H "Authorization: token $TOKEN" \
"$SERVER/api/v1/repos/$REPO/releases/tags/$TAG" 2>/dev/null || true)
if [ -n "$existing" ] && [ "$existing" != 'null' ]; then
existing_id=$(jq -r .id <<<"$existing")
echo "deleting existing release $existing_id for tag $TAG"
curl -fsS -X DELETE -H "Authorization: token $TOKEN" \
"$SERVER/api/v1/repos/$REPO/releases/$existing_id"
fi
release=$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"$SERVER/api/v1/repos/$REPO/releases" \
-d "$(jq -n --arg tag "$TAG" --arg name "$id $version" '{tag_name: $tag, name: $name}')")
asset_url="$SERVER/api/v1/repos/$REPO/releases/$(jq -r .id <<<"$release")/assets?name=$zip_name"
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
"$asset_url" \
-F "attachment=@$zip"
echo "released $TAG with $zip_name"