UNPKG

@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.

388 lines (356 loc) 18.7 kB
import { AnimationClip, Object3D } from "three"; import type { Light, Material, PerspectiveCamera } from "three"; import { AnimatorConditionMode, AnimatorControllerParameterType } from "../engine/extensions/NEEDLE_animator_controller_model.js"; import type { AnimatorControllerModel, Condition, Parameter, State, Transition } 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, type TrackDescriptor, type TrackOptions, type AnimationKeyframe, type Tween, type Vec3Value, type QuatValue, type EulerValue, type ColorValue } from "./AnimationBuilder.js"; /** Keyframe array or tween shorthand */ type KF<V> = AnimationKeyframe<V>[] | Tween<V>; /** Extracts parameter names of a given type from the builder's tracked parameter map */ type ParamNamesOfType<TParams, PType extends string> = { [K in keyof TParams & string]: TParams[K] extends PType ? K : never }[keyof TParams & string]; /** * Configuration for an animation state in the builder */ export declare type StateOptions = { /** * The animation clip for this state. Accepts: * - A pre-built `AnimationClip` * - A single {@link TrackDescriptor} from {@link track} * - An array of {@link TrackDescriptor}s (multiple tracks combined into one clip) * * When omitted, use {@link AnimatorControllerBuilder.track .track()} to define animation tracks inline. */ clip?: AnimationClip | TrackDescriptor | TrackDescriptor[]; /** Whether the animation should loop (default: false) */ loop?: boolean; /** Base speed multiplier (default: 1) */ speed?: number; /** Name of a float parameter to multiply with speed */ speedParameter?: string; /** Normalized cycle offset 0-1 (default: 0) */ cycleOffset?: number; /** Name of a float parameter to use as cycle offset */ cycleOffsetParameter?: string; } /** * Configuration for a transition in the builder */ export declare type TransitionOptions = { /** Duration of the crossfade in seconds (default: 0) */ duration?: number; /** Normalized exit time 0-1. When set, the transition waits until the source animation reaches this point before transitioning. */ exitTime?: number; /** Normalized offset into the destination state's animation (default: 0) */ offset?: number; /** Whether duration is in seconds (true) or normalized (false) (default: false) */ hasFixedDuration?: boolean; } /** String condition modes for the builder, mapped to {@link AnimatorConditionMode} */ export type ConditionMode = "if" | "ifNot" | "greater" | "less" | "equals" | "notEqual"; function conditionModeToEnum(mode: ConditionMode): AnimatorConditionMode { 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; } } type BuilderTransition = { to: string; options: TransitionOptions; conditions: Array<{ parameter: string; mode: ConditionMode; threshold: number }>; }; type BuilderState = { name: string; options: StateOptions; inlineTracks: TrackDescriptor[]; transitions: BuilderTransition[]; }; /** * 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< TStates extends string = never, TParams extends Record<string, "trigger" | "bool" | "float" | "int"> = {}, > { private _name: string; private _parameters: Parameter[] = []; private _states: BuilderState[] = []; private _anyStateTransitions: BuilderTransition[] = []; private _defaultStateName: string | null = null; private _lastTransition: BuilderTransition | null = null; private _lastState: BuilderState | null = null; /** * Creates a new AnimatorControllerBuilder instance. * @param name - Optional name for the controller */ static create(name?: string): AnimatorControllerBuilder { return new AnimatorControllerBuilder(name); } constructor(name?: string) { this._name = name ?? "AnimatorController"; } /** Adds a float parameter */ floatParameter<N extends string>(name: N, defaultValue: number = 0): AnimatorControllerBuilder<TStates, TParams & Record<N, "float">> { this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Float, value: defaultValue }); return this as any; } /** Adds an integer parameter */ intParameter<N extends string>(name: N, defaultValue: number = 0): AnimatorControllerBuilder<TStates, TParams & Record<N, "int">> { this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Int, value: defaultValue }); return this as any; } /** Adds a boolean parameter */ boolParameter<N extends string>(name: N, defaultValue: boolean = false): AnimatorControllerBuilder<TStates, TParams & Record<N, "bool">> { this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Bool, value: defaultValue }); return this as any; } /** Adds a trigger parameter */ triggerParameter<N extends string>(name: N): AnimatorControllerBuilder<TStates, TParams & Record<N, "trigger">> { this._parameters.push({ name, hash: this._parameters.length, type: AnimatorControllerParameterType.Trigger, value: false }); return this as any; } /** * 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<N extends string>(name: N, options?: StateOptions): AnimatorControllerBuilder<TStates | N, TParams> { const state: BuilderState = { name, options: options ?? {}, inlineTracks: [], transitions: [] }; this._states.push(state); this._lastState = state; return this as any; } /** * 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: TStates | "*", to: TStates, options?: TransitionOptions): AnimatorControllerBuilder<TStates, TParams> { this._lastState = null; const t: BuilderTransition = { to: to as string, 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 as any; } /** * Adds a condition to the most recently added transition. * Multiple conditions on the same transition are AND-ed together. * * The required arguments depend on the parameter type: * - **Trigger**: `.condition("Jump")` — mode defaults to `"if"`, no threshold needed * - **Bool**: `.condition("Open", "if")` or `.condition("Open", "ifNot")` * - **Float/Int**: `.condition("Speed", "greater", 0.1)` * * @param parameter - Name of the parameter to evaluate */ // Trigger parameters: mode is optional (defaults to "if") condition(parameter: ParamNamesOfType<TParams, "trigger">, mode?: "if" | "ifNot"): AnimatorControllerBuilder<TStates, TParams>; // Bool parameters: mode is required condition(parameter: ParamNamesOfType<TParams, "bool">, mode: "if" | "ifNot"): AnimatorControllerBuilder<TStates, TParams>; // Float/Int parameters: mode and optional threshold condition(parameter: ParamNamesOfType<TParams, "float" | "int">, mode: "greater" | "less" | "equals" | "notEqual", threshold?: number): AnimatorControllerBuilder<TStates, TParams>; condition(parameter: string, mode?: ConditionMode, threshold?: number): AnimatorControllerBuilder<TStates, TParams> { 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 as any; } // --- Object3D --- /** Adds an animation track for an Object3D's position or scale to the current state */ track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): this; /** Adds an animation track for an Object3D's quaternion to the current state */ track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): this; /** Adds an animation track for an Object3D's rotation (Euler, converted to quaternion) to the current state */ track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): this; /** Adds an animation track for an Object3D's visibility to the current state */ track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): this; // --- Material --- /** Adds an animation track for a material's numeric property to the current state */ track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): this; /** Adds an animation track for a material's color property to the current state */ track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): this; // --- Light --- /** Adds an animation track for a light's numeric property to the current state */ track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): this; /** Adds an animation track for a light's color to the current state */ track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): this; // --- Camera --- /** Adds an animation track for a camera's numeric property to the current state */ track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): 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: object, property: string, keyframes: KF<any>, options?: TrackOptions): this { 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 as Object3D, property as "position", 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: TStates): AnimatorControllerBuilder<TStates, TParams> { this._defaultStateName = name as string; return this as any; } /** * 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?: Object3D): AnimatorController { const stateIndexMap = new Map<string, number>(); 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: BuilderTransition): Transition => { 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: State[] = this._states.map((s, index) => { const transitions: Transition[] = 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: AnimationClip; 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: AnimatorControllerModel = { name: this._name, guid: new InstantiateIdProvider(Date.now()).generateUUID(), parameters: this._parameters, layers: [{ name: "Base Layer", stateMachine: { defaultState: defaultStateIndex, states, } }], }; return new AnimatorController(model); } }