@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
263 lines • 11.6 kB
JavaScript
import { AnimatorConditionMode, AnimatorControllerParameterType } from "../engine/extensions/NEEDLE_animator_controller_model.js";
import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
import { AnimatorController } from "./AnimatorController.js";
import { resolveClipSource, track as trackFn } from "./AnimationBuilder.js";
function conditionModeToEnum(mode) {
switch (mode) {
case "if": return AnimatorConditionMode.If;
case "ifNot": return AnimatorConditionMode.IfNot;
case "greater": return AnimatorConditionMode.Greater;
case "less": return AnimatorConditionMode.Less;
case "equals": return AnimatorConditionMode.Equals;
case "notEqual": return AnimatorConditionMode.NotEqual;
}
}
/**
* A fluent builder for creating {@link AnimatorController} instances from code.
*
* Use {@link AnimatorControllerBuilder.create} or {@link AnimatorController.build} to create a new builder.
*
* The builder tracks state names and parameter types through the fluent chain,
* providing autocomplete for state names in `.transition()` and type-aware
* `.condition()` calls (e.g., trigger parameters don't require a mode argument).
*
* @example With pre-built AnimationClips
* ```ts
* const controller = AnimatorControllerBuilder.create("CharacterController")
* .floatParameter("Speed", 0)
* .triggerParameter("Jump")
* .state("Idle", { clip: idleClip, loop: true })
* .state("Walk", { clip: walkClip, loop: true })
* .state("Jump", { clip: jumpClip })
* .transition("Idle", "Walk", { duration: 0.25 })
* .condition("Speed", "greater", 0.1)
* .transition("Walk", "Idle", { duration: 0.25 })
* .condition("Speed", "less", 0.1)
* .transition("*", "Jump", { duration: 0.1 })
* .condition("Jump")
* .transition("Jump", "Idle", { hasExitTime: true, exitTime: 0.9, duration: 0.25 })
* .build();
* ```
*
* @example With inline tracks (no pre-built clips needed)
* ```ts
* const controller = AnimatorControllerBuilder.create("Door")
* .boolParameter("Open", false)
* .state("Closed", { loop: true })
* .track(door, "position", { from: [0, 0, 0], to: [0, 0, 0], duration: 1 })
* .state("Open", { loop: true })
* .track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* .transition("Closed", "Open", { duration: 0.25 })
* .condition("Open", "if")
* .transition("Open", "Closed", { duration: 0.25 })
* .condition("Open", "ifNot")
* .build(room);
* ```
*
* @typeParam TStates - Union of state names added via `.state()`. Used for autocomplete and validation in `.transition()` and `.defaultState()`.
* @typeParam TParams - Record mapping parameter names to their types (`"trigger"`, `"bool"`, `"float"`, `"int"`). Used for type-aware `.condition()` overloads.
*
* @category Animation and Sequencing
* @group Utilities
*/
export class AnimatorControllerBuilder {
_name;
_parameters = [];
_states = [];
_anyStateTransitions = [];
_defaultStateName = null;
_lastTransition = null;
_lastState = null;
/**
* Creates a new AnimatorControllerBuilder instance.
* @param name - Optional name for the controller
*/
static create(name) {
return new AnimatorControllerBuilder(name);
}
constructor(name) {
this._name = name ?? "AnimatorController";
}
/** Adds a float parameter */
floatParameter(name, defaultValue = 0) {
this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Float, value: defaultValue });
return this;
}
/** Adds an integer parameter */
intParameter(name, defaultValue = 0) {
this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Int, value: defaultValue });
return this;
}
/** Adds a boolean parameter */
boolParameter(name, defaultValue = false) {
this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Bool, value: defaultValue });
return this;
}
/** Adds a trigger parameter */
triggerParameter(name) {
this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Trigger, value: false });
return this;
}
/**
* Adds a state to the controller. The first state added becomes the default state.
*
* When `options.clip` is provided, the state uses that clip directly.
* When omitted, chain `.track()` calls to define animation tracks inline:
* ```ts
* .state("Open", { loop: true })
* .track(door, "position", { from: [0,0,0], to: [2,0,0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* ```
*
* @param name - Unique name for the state
* @param options - State configuration including clip, loop, speed. When omitted, use `.track()` to add animation data.
*/
state(name, options) {
const state = { name, options: options ?? {}, inlineTracks: [], transitions: [] };
this._states.push(state);
this._lastState = state;
return this;
}
/**
* Adds a transition between two states.
* Use `"*"` as the source to create a transition from any state.
* Chain `.condition()` calls after this to add conditions.
* @param from - Source state name, or `"*"` for any-state transition
* @param to - Destination state name
* @param options - Transition configuration
*/
transition(from, to, options) {
this._lastState = null;
const t = { to: to, options: options ?? {}, conditions: [] };
if (from === "*") {
this._anyStateTransitions.push(t);
}
else {
const state = this._states.find(s => s.name === from);
if (!state)
throw new Error(`AnimatorControllerBuilder: source state "${from}" not found. Add it with .state() first.`);
state.transitions.push(t);
}
this._lastTransition = t;
return this;
}
condition(parameter, mode, threshold) {
if (!this._lastTransition)
throw new Error("AnimatorControllerBuilder: .condition() must be called after .transition()");
this._lastTransition.conditions.push({ parameter, mode: mode ?? "if", threshold: threshold ?? 0 });
return this;
}
/**
* Adds an animation track to the most recently added state.
* Must be called after `.state()`. The track has the same type-safe overloads
* as the standalone {@link track} function.
*
* @param target - The object whose type determines valid properties and value types
* @param property - The property to animate
* @param keyframes - Keyframe array or {@link Tween} shorthand
* @param options - Optional {@link TrackOptions} with a `root` for named targeting
*/
track(target, property, keyframes, options) {
if (!this._lastState)
throw new Error("AnimatorControllerBuilder: .track() must be called after .state()");
if (this._lastState.options.clip)
throw new Error(`AnimatorControllerBuilder: state "${this._lastState.name}" already has a clip. Use either .track() or { clip: ... }, not both.`);
this._lastState.inlineTracks.push(trackFn(target, property, keyframes, options));
return this;
}
/**
* Sets which state is the default/entry state.
* If not called, the first added state is used.
* @param name - Name of the state
*/
defaultState(name) {
this._defaultStateName = name;
return this;
}
/**
* Builds and returns the {@link AnimatorController}.
* Resolves all state name references to indices.
* @param root - Optional root Object3D for resolving {@link TrackDescriptor} track paths.
* When provided, tracks targeting a different object use `target.name` for named resolution.
*/
build(root) {
const stateIndexMap = new Map();
this._states.forEach((s, i) => stateIndexMap.set(s.name, i));
let defaultStateIndex = 0;
if (this._defaultStateName !== null) {
const idx = stateIndexMap.get(this._defaultStateName);
if (idx === undefined)
throw new Error(`AnimatorControllerBuilder: default state "${this._defaultStateName}" not found`);
defaultStateIndex = idx;
}
const resolveTransition = (t) => {
const destIndex = stateIndexMap.get(t.to);
if (destIndex === undefined)
throw new Error(`AnimatorControllerBuilder: transition target "${t.to}" not found`);
return {
exitTime: t.options.exitTime ?? 1,
hasExitTime: t.options.exitTime !== undefined,
duration: t.options.duration ?? 0,
offset: t.options.offset ?? 0,
hasFixedDuration: t.options.hasFixedDuration ?? false,
destinationState: destIndex,
conditions: t.conditions.map(c => ({
parameter: c.parameter,
mode: conditionModeToEnum(c.mode),
threshold: c.threshold,
})),
};
};
const states = this._states.map((s, index) => {
const transitions = s.transitions.map(resolveTransition);
// Replicate any-state transitions onto every state (except self-targeting)
for (const anyT of this._anyStateTransitions) {
const destIndex = stateIndexMap.get(anyT.to);
if (destIndex === index)
continue;
transitions.push(resolveTransition(anyT));
}
// Resolve clip: from options.clip, inline .track() calls, or error
let clip;
if (s.options.clip) {
clip = resolveClipSource(s.options.clip, root);
}
else if (s.inlineTracks.length > 0) {
clip = resolveClipSource(s.inlineTracks, root);
}
else {
throw new Error(`AnimatorControllerBuilder: state "${s.name}" has no clip and no inline tracks. Provide { clip } or chain .track() calls.`);
}
return {
name: s.name,
hash: index,
motion: {
name: clip.name,
clip: clip,
isLooping: s.options.loop ?? false,
},
transitions,
behaviours: [],
speed: s.options.speed,
speedParameter: s.options.speedParameter,
cycleOffset: s.options.cycleOffset,
cycleOffsetParameter: s.options.cycleOffsetParameter,
};
});
const model = {
name: this._name,
guid: new InstantiateIdProvider(Date.now()).generateUUID(),
parameters: this._parameters,
layers: [{
name: "Base Layer",
stateMachine: {
defaultState: defaultStateIndex,
states,
}
}],
};
return new AnimatorController(model);
}
}
//# sourceMappingURL=AnimatorController.builder.js.map