@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.
825 lines (752 loc) • 36 kB
text/typescript
import { AnimationClip, Object3D } from "three";
import type { Light, Material, PerspectiveCamera } from "three";
import type { Animator } from "../Animator.js";
import type { AudioSource } from "../AudioSource.js";
import { GameObject } from "../Component.js";
import { InstantiateIdProvider } from "../../engine/engine_networking_instantiate.js";
import { EventList } from "../EventList.js";
import { isTrackDescriptor, resolveToClip, 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>;
import { SignalAsset, SignalReceiver, SignalReceiverEvent } from "./SignalAsset.js";
import type { PlayableDirector } from "./PlayableDirector.js";
import { ClipExtrapolation, TrackType } from "./TimelineModels.js";
import type { TimelineAssetModel, TrackModel, ClipModel, AnimationClipModel, AudioClipModel, ControlClipModel, MarkerModel, SignalMarkerModel, TrackOffset } from "./TimelineModels.js";
import { MarkerType } from "./TimelineModels.js";
/**
* Options for an animation clip in the timeline builder
*/
export declare type AnimationClipOptions = {
/** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
start?: number;
/** Duration of the clip in seconds. Defaults to the animation clip duration. */
duration?: number;
/** Playback speed multiplier (default: 1) */
speed?: number;
/** Whether the animation should loop within the clip (default: false) */
loop?: boolean;
/** Ease-in duration in seconds (default: 0) */
easeIn?: number;
/** Ease-out duration in seconds (default: 0) */
easeOut?: number;
/** Offset into the source animation clip in seconds (default: 0) */
clipIn?: number;
/** Whether to remove the start offset of the animation (default: false) */
removeStartOffset?: boolean;
/** Pre-extrapolation mode (default: None) */
preExtrapolation?: ClipExtrapolation;
/** Post-extrapolation mode (default: None) */
postExtrapolation?: ClipExtrapolation;
/** Play the clip in reverse */
reversed?: boolean;
}
/**
* Options for an audio clip in the timeline builder
*/
export declare type AudioClipOptions = {
/** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
start?: number;
/** Duration of the clip in seconds (required for audio since we can't infer it) */
duration: number;
/** Playback speed multiplier (default: 1) */
speed?: number;
/** Volume multiplier for this clip (default: 1) */
volume?: number;
/** Whether the audio should loop within the clip (default: false) */
loop?: boolean;
/** Ease-in duration in seconds (default: 0) */
easeIn?: number;
/** Ease-out duration in seconds (default: 0) */
easeOut?: number;
}
/**
* Options for an activation clip in the timeline builder
*/
export declare type ActivationClipOptions = {
/** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
start?: number;
/** Duration of the clip in seconds (required) */
duration: number;
/** Ease-in duration in seconds (default: 0) */
easeIn?: number;
/** Ease-out duration in seconds (default: 0) */
easeOut?: number;
}
/**
* Options for a control clip in the timeline builder
*/
export declare type ControlClipOptions = {
/** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
start?: number;
/** Duration of the clip in seconds (required) */
duration: number;
/** Whether to control the activation of the source object (default: true) */
controlActivation?: boolean;
/** Whether to update a nested PlayableDirector on the source object (default: true) */
updateDirector?: boolean;
}
/**
* Options for a signal marker in the timeline builder
*/
export declare type SignalMarkerOptions = {
/** Whether the signal should fire if the playback starts past its time (default: false) */
retroActive?: boolean;
/** Whether the signal should only fire once (default: false) */
emitOnce?: boolean;
}
// Internal types for track building
type BuilderTrack = {
name: string;
type: TrackType;
muted: boolean;
outputs: Array<null | object>;
clips: ClipModel[];
markers: MarkerModel[];
volume?: number;
trackOffset?: TrackOffset;
cursor: number; // current time position for auto-advancing
inlineTracks: TrackDescriptor[]; // accumulated by .track() calls, committed at boundaries
};
type PendingSignal = {
trackIndex: number;
guid: string;
callback: Function;
};
// ============================================================
// Track builder interfaces — typed views per track type
// ============================================================
/**
* Shared methods available on all track builders and the TimelineBuilder entry point.
* Provides track creation, build, and install methods.
*
* @category Animation and Sequencing
*/
export interface TimelineBuilderBase {
/** Adds an animation track. Chain `.clip()` or `.track()` to add content. */
animationTrack(name: string, binding?: Animator | Object3D | null): AnimationTrackBuilder;
/** Adds an audio track. Chain `.clip()` to add audio clips. */
audioTrack(name: string, binding?: AudioSource | Object3D | null, volume?: number): AudioTrackBuilder;
/** Adds an activation track. Chain `.clip()` to define activation windows. */
activationTrack(name: string, binding?: Object3D | null): ActivationTrackBuilder;
/** Adds a control track. Chain `.clip()` to control nested objects/timelines. */
controlTrack(name: string): ControlTrackBuilder;
/** Adds a signal track. Chain `.signal()` or `.marker()` to add events. */
signalTrack(name: string, binding?: SignalReceiver | Object3D | null): SignalTrackBuilder;
/** Adds a marker track. Chain `.marker()` to add markers. */
markerTrack(name: string): MarkerTrackBuilder;
/** Builds and returns the {@link TimelineAssetModel}. */
build(): TimelineAssetModel;
/** Builds the timeline, assigns it to the director, and wires up signal callbacks. */
install(director: PlayableDirector): TimelineAssetModel;
}
/**
* Builder for animation tracks.
* Provides `.clip()` for pre-built AnimationClips and `.track()` for inline animation definition.
*
* @category Animation and Sequencing
*/
export interface AnimationTrackBuilder extends TimelineBuilderBase {
/** Adds a pre-built AnimationClip */
clip(asset: AnimationClip, options?: AnimationClipOptions): AnimationTrackBuilder;
/** Adds a clip from a single {@link TrackDescriptor} */
clip(descriptor: TrackDescriptor, options?: AnimationClipOptions): AnimationTrackBuilder;
/** Adds a clip from multiple {@link TrackDescriptor}s */
clip(descriptors: TrackDescriptor[], options?: AnimationClipOptions): AnimationTrackBuilder;
/** Adds an animation track for an Object3D's position or scale */
track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for an Object3D's quaternion */
track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for an Object3D's rotation (Euler, converted to quaternion) */
track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for an Object3D's visibility */
track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for a material's numeric property */
track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for a material's color property */
track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for a light's numeric property */
track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for a light's color */
track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): AnimationTrackBuilder;
/** Adds an animation track for a camera's numeric property */
track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): AnimationTrackBuilder;
}
/**
* Builder for audio tracks. Provides `.clip()` for adding audio clips by URL.
* @category Animation and Sequencing
*/
export interface AudioTrackBuilder extends TimelineBuilderBase {
/** Adds an audio clip by URL */
clip(url: string, options: AudioClipOptions): AudioTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): AudioTrackBuilder;
}
/**
* Builder for activation tracks. Provides `.clip()` for defining activation windows.
* @category Animation and Sequencing
*/
export interface ActivationTrackBuilder extends TimelineBuilderBase {
/** Adds an activation clip that shows/hides the bound object */
clip(options: ActivationClipOptions): ActivationTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): ActivationTrackBuilder;
}
/**
* Builder for control tracks. Provides `.clip()` for controlling nested objects/timelines.
* @category Animation and Sequencing
*/
export interface ControlTrackBuilder extends TimelineBuilderBase {
/** Adds a control clip for a source object */
clip(sourceObject: Object3D, options: ControlClipOptions): ControlTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): ControlTrackBuilder;
}
/**
* Builder for signal tracks. Provides `.signal()` for callback-based signals and `.marker()` for asset-based markers.
* @category Animation and Sequencing
*/
export interface SignalTrackBuilder extends TimelineBuilderBase {
/** Adds a signal with a callback that fires at the given time */
signal(time: number, callback: Function, options?: SignalMarkerOptions): SignalTrackBuilder;
/** Adds a signal marker referencing a signal asset by guid */
marker(time: number, asset: string, options?: SignalMarkerOptions): SignalTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): SignalTrackBuilder;
}
/**
* Builder for marker tracks. Provides `.marker()` for adding markers.
* @category Animation and Sequencing
*/
export interface MarkerTrackBuilder extends TimelineBuilderBase {
/** Adds a marker referencing a signal asset by guid */
marker(time: number, asset: string, options?: SignalMarkerOptions): MarkerTrackBuilder;
/** Mutes this track so it is skipped during playback */
muted(muted?: boolean): MarkerTrackBuilder;
}
/**
* A fluent builder for creating timeline assets ({@link TimelineAssetModel}) from code.
*
* Use {@link TimelineBuilder.create} to start building a timeline.
*
* @example Using build() for timelines without signal callbacks
* ```ts
* const timeline = TimelineBuilder.create("MySequence")
* .animationTrack("Character", animator)
* .clip(walkClip, { duration: 2, easeIn: 0.3 })
* .clip(runClip, { duration: 3, easeIn: 0.5, easeOut: 0.5 })
* .activationTrack("FX", particleObject)
* .clip({ start: 1, duration: 2 })
* .audioTrack("Music", audioSource)
* .clip("music.mp3", { start: 0, duration: 5, volume: 0.8 })
* .build();
*
* director.playableAsset = timeline;
* director.play();
* ```
*
* @example With inline tracks (no pre-built clips needed)
* ```ts
* TimelineBuilder.create("DoorSequence")
* .animationTrack("Door", door)
* .track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* .signalTrack("Events")
* .signal(0.5, () => playSound("creak"))
* .install(director);
*
* director.play();
* ```
*
* @example Using install() with signal callbacks
* ```ts
* TimelineBuilder.create("WithSignals")
* .animationTrack("Character", animator)
* .clip(walkClip, { duration: 2 })
* .signalTrack("Events")
* .signal(1.0, () => console.log("1 second!"))
* .signal(2.0, () => spawnParticles())
* .install(director);
*
* director.play();
* ```
*
* @category Animation and Sequencing
* @group Utilities
*/
export class TimelineBuilder {
private _name: string;
private _tracks: BuilderTrack[] = [];
private _currentTrack: BuilderTrack | null = null;
private _pendingSignals: PendingSignal[] = [];
private _idProvider: InstantiateIdProvider;
private constructor(name: string, seed?: number) {
this._name = name;
this._idProvider = new InstantiateIdProvider(seed ?? Date.now());
}
/**
* Creates a new TimelineBuilder instance.
* @param name - Name for the timeline asset
* @param seed - Optional numeric seed for deterministic guid generation. Defaults to `Date.now()`.
*/
static create(name?: string, seed?: number): TimelineBuilderBase {
return new TimelineBuilder(name ?? "Timeline", seed);
}
// #region Track creation
/**
* Adds an animation track. Chain `.clip()` calls to add pre-built clips,
* or chain `.track()` calls to define animation data inline:
*
* @example With pre-built AnimationClip
* ```ts
* .animationTrack("Character", animator)
* .clip(walkClip, { duration: 2, easeIn: 0.3 })
* .clip(runClip, { duration: 3 })
* ```
*
* @example With inline tracks
* ```ts
* .animationTrack("Door", door)
* .track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* ```
*
* @param name - Display name for the track
* @param binding - The Animator or Object3D to animate
*/
animationTrack(name: string, binding?: Animator | Object3D | null): AnimationTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Animation, binding ?? null);
return this as unknown as AnimationTrackBuilder;
}
/**
* Adds an audio track. Subsequent `.clip()` calls add audio clips to this track.
* @param name - Display name for the track
* @param binding - The AudioSource to play audio on (optional)
* @param volume - Track volume multiplier (default: 1)
*/
audioTrack(name: string, binding?: AudioSource | Object3D | null, volume?: number): AudioTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Audio, binding ?? null);
this._currentTrack.volume = volume;
return this as unknown as AudioTrackBuilder;
}
/**
* Adds an activation track. Subsequent `.clip()` calls define when the bound object is active.
* @param name - Display name for the track
* @param binding - The Object3D to show/hide
*/
activationTrack(name: string, binding?: Object3D | null): ActivationTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Activation, binding ?? null);
return this as unknown as ActivationTrackBuilder;
}
/**
* Adds a control track. Subsequent `.clip()` calls control nested timelines or objects.
* @param name - Display name for the track
*/
controlTrack(name: string): ControlTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Control, null);
return this as unknown as ControlTrackBuilder;
}
/**
* Adds a signal track. Use `.signal()` or `.marker()` to add signal markers.
* @param name - Display name for the track
* @param binding - The SignalReceiver component (optional — if using `.signal()` with callbacks, one is created automatically by {@link install})
*/
signalTrack(name: string, binding?: SignalReceiver | Object3D | null): SignalTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Signal, binding ?? null);
return this as unknown as SignalTrackBuilder;
}
/**
* Adds a marker track. Use `.marker()` to add markers.
* @param name - Display name for the track
*/
markerTrack(name: string): MarkerTrackBuilder {
this.commitInlineTracks();
this._currentTrack = this.pushTrack(name, TrackType.Marker, null);
return this as unknown as MarkerTrackBuilder;
}
// #endregion
// #region Clip and marker methods
/**
* Adds a clip to the current track. The clip type must match the track type.
*
* - On an **animation track**: pass an `AnimationClip`, a {@link TrackDescriptor}, or a `TrackDescriptor[]` — and optional {@link AnimationClipOptions}
* - On an **audio track**: pass a clip URL (string) and {@link AudioClipOptions}
* - On an **activation track**: pass {@link ActivationClipOptions}
* - On a **control track**: pass an Object3D and {@link ControlClipOptions}
*/
clip(asset: AnimationClip, options?: AnimationClipOptions): this;
clip(descriptor: TrackDescriptor, options?: AnimationClipOptions): this;
clip(descriptors: TrackDescriptor[], options?: AnimationClipOptions): this;
clip(url: string, options: AudioClipOptions): this;
clip(options: ActivationClipOptions): this;
clip(sourceObject: Object3D, options: ControlClipOptions): this;
clip(assetOrOptions: AnimationClip | TrackDescriptor | TrackDescriptor[] | string | Object3D | ActivationClipOptions, options?: AnimationClipOptions | AudioClipOptions | ControlClipOptions): this {
if (!this._currentTrack) throw new Error("TimelineBuilder: .clip() must be called after a track method (e.g. .animationTrack())");
this.commitInlineTracks();
const track = this._currentTrack;
switch (track.type) {
case TrackType.Animation: {
// Resolve TrackDescriptor(s) to AnimationClip if needed
let animClip: AnimationClip;
if (assetOrOptions instanceof AnimationClip) {
animClip = assetOrOptions;
}
else {
const descriptors = Array.isArray(assetOrOptions) ? assetOrOptions : [assetOrOptions as TrackDescriptor];
// Use the track's binding as root for resolution
const binding = track.outputs[0];
const root = binding instanceof Object3D ? binding
: (binding != null && "gameObject" in binding) ? (binding as any).gameObject as Object3D
: undefined;
animClip = resolveToClip(descriptors, root);
}
const opts = (options ?? {}) as AnimationClipOptions;
const duration = opts.duration ?? animClip.duration;
const start = opts.start ?? track.cursor;
const end = start + duration;
const clipModel: ClipModel = {
start,
end,
duration,
timeScale: opts.speed ?? 1,
clipIn: opts.clipIn ?? 0,
easeInDuration: opts.easeIn ?? 0,
easeOutDuration: opts.easeOut ?? 0,
preExtrapolationMode: opts.preExtrapolation ?? ClipExtrapolation.None,
postExtrapolationMode: opts.postExtrapolation ?? ClipExtrapolation.None,
reversed: opts.reversed,
asset: {
clip: animClip,
loop: opts.loop ?? false,
duration: animClip.duration,
removeStartOffset: opts.removeStartOffset ?? false,
} satisfies AnimationClipModel,
};
track.clips.push(clipModel);
track.cursor = end;
break;
}
case TrackType.Audio: {
const url = assetOrOptions as string;
const opts = (options ?? {}) as AudioClipOptions;
const duration = opts.duration;
const start = opts.start ?? track.cursor;
const end = start + duration;
const clipModel: ClipModel = {
start,
end,
duration,
timeScale: opts.speed ?? 1,
clipIn: 0,
easeInDuration: opts.easeIn ?? 0,
easeOutDuration: opts.easeOut ?? 0,
preExtrapolationMode: ClipExtrapolation.None,
postExtrapolationMode: ClipExtrapolation.None,
asset: {
clip: url,
loop: opts.loop ?? false,
volume: opts.volume ?? 1,
} satisfies AudioClipModel,
};
track.clips.push(clipModel);
track.cursor = end;
break;
}
case TrackType.Activation: {
const opts = assetOrOptions as ActivationClipOptions;
const start = opts.start ?? track.cursor;
const end = start + opts.duration;
const clipModel: ClipModel = {
start,
end,
duration: opts.duration,
timeScale: 1,
clipIn: 0,
easeInDuration: opts.easeIn ?? 0,
easeOutDuration: opts.easeOut ?? 0,
preExtrapolationMode: ClipExtrapolation.None,
postExtrapolationMode: ClipExtrapolation.None,
asset: {},
};
track.clips.push(clipModel);
track.cursor = end;
break;
}
case TrackType.Control: {
const sourceObject = assetOrOptions as Object3D;
const opts = (options ?? {}) as ControlClipOptions;
const start = opts.start ?? track.cursor;
const duration = opts.duration;
const end = start + duration;
const clipModel: ClipModel = {
start,
end,
duration,
timeScale: 1,
clipIn: 0,
easeInDuration: 0,
easeOutDuration: 0,
preExtrapolationMode: ClipExtrapolation.None,
postExtrapolationMode: ClipExtrapolation.None,
asset: {
sourceObject,
controlActivation: opts.controlActivation ?? true,
updateDirector: opts.updateDirector ?? true,
} satisfies ControlClipModel,
};
track.clips.push(clipModel);
track.cursor = end;
break;
}
default:
throw new Error(`TimelineBuilder: .clip() is not supported on track type "${track.type}"`);
}
return this;
}
/**
* Adds a signal marker to the current signal or marker track.
* @param time - Time in seconds when the signal fires
* @param asset - The signal asset identifier (guid string)
* @param options - Optional marker configuration
*/
marker(time: number, asset: string, options?: SignalMarkerOptions): this {
if (!this._currentTrack) throw new Error("TimelineBuilder: .marker() must be called after a track method");
if (this._currentTrack.type !== TrackType.Signal && this._currentTrack.type !== TrackType.Marker) {
throw new Error(`TimelineBuilder: .marker() is only supported on signal and marker tracks, not "${this._currentTrack.type}"`);
}
const marker: SignalMarkerModel = {
type: MarkerType.Signal,
time,
retroActive: options?.retroActive ?? false,
emitOnce: options?.emitOnce ?? false,
asset,
};
this._currentTrack.markers.push(marker);
// Update cursor past the marker
if (time > this._currentTrack.cursor) {
this._currentTrack.cursor = time;
}
return this;
}
/**
* Adds a signal with a callback to the current signal track.
* This is a convenience method that automatically generates a signal asset guid,
* adds the marker, and registers the callback so that {@link install} can wire up
* the `SignalReceiver` on the director's GameObject.
*
* @param time - Time in seconds when the signal fires
* @param callback - The function to invoke when the signal fires
* @param options - Optional marker configuration
*
* @example
* ```ts
* const timeline = TimelineBuilder.create("Sequence")
* .signalTrack("Events")
* .signal(1.0, () => console.log("1 second reached!"))
* .signal(3.5, () => console.log("halfway!"), { emitOnce: true })
* .install(director);
* ```
*/
signal(time: number, callback: Function, options?: SignalMarkerOptions): this {
if (!this._currentTrack) throw new Error("TimelineBuilder: .signal() must be called after a track method");
if (this._currentTrack.type !== TrackType.Signal && this._currentTrack.type !== TrackType.Marker) {
throw new Error(`TimelineBuilder: .signal() is only supported on signal and marker tracks, not "${this._currentTrack.type}"`);
}
const guid = this._idProvider.generateUUID();
const trackIndex = this._tracks.indexOf(this._currentTrack);
// Add the marker with the generated guid
const marker: SignalMarkerModel = {
type: MarkerType.Signal,
time,
retroActive: options?.retroActive ?? false,
emitOnce: options?.emitOnce ?? false,
asset: guid,
};
this._currentTrack.markers.push(marker);
// Store the pending signal for wiring during install()
this._pendingSignals.push({ trackIndex, guid, callback });
if (time > this._currentTrack.cursor) {
this._currentTrack.cursor = time;
}
return this;
}
/**
* Mutes the current track so it is skipped during playback.
*/
muted(muted: boolean = true): this {
if (!this._currentTrack) throw new Error("TimelineBuilder: .muted() must be called after a track method");
this._currentTrack.muted = muted;
return this;
}
// --- Object3D ---
/** Adds an animation track descriptor for an Object3D's position or scale to the current animation track */
track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): this;
/** Adds an animation track descriptor for an Object3D's quaternion to the current animation track */
track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): this;
/** Adds an animation track descriptor for an Object3D's rotation (Euler, converted to quaternion) to the current animation track */
track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): this;
/** Adds an animation track descriptor for an Object3D's visibility to the current animation track */
track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): this;
// --- Material ---
/** Adds an animation track descriptor for a material's numeric property to the current animation track */
track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): this;
/** Adds an animation track descriptor for a material's color property to the current animation track */
track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): this;
// --- Light ---
/** Adds an animation track descriptor for a light's numeric property to the current animation track */
track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): this;
/** Adds an animation track descriptor for a light's color to the current animation track */
track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): this;
// --- Camera ---
/** Adds an animation track descriptor for a camera's numeric property to the current animation track */
track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): this;
/**
* Adds an animation track descriptor to the current animation track.
* Multiple `.track()` calls accumulate into a single animation clip that is
* committed when the next `.clip()`, track method, or `.build()`/`.install()` is called.
*
* Must be called after `.animationTrack()`.
*
* @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._currentTrack) throw new Error("TimelineBuilder: .track() must be called after .animationTrack()");
if (this._currentTrack.type !== TrackType.Animation) throw new Error("TimelineBuilder: .track() is only supported on animation tracks");
this._currentTrack.inlineTracks.push(trackFn(target as Object3D, property as "position", keyframes, options));
return this;
}
// #endregion
/**
* Builds and returns the {@link TimelineAssetModel}.
* Assign the result to `PlayableDirector.playableAsset` to play it.
*
* If you used `.signal()` with callbacks, use {@link install} instead — it calls `build()`
* internally and also wires up the SignalReceiver on the director's GameObject.
*/
build(): TimelineAssetModel {
this.commitInlineTracks();
const tracks: TrackModel[] = this._tracks.map(t => {
const track: TrackModel = {
name: t.name,
type: t.type,
muted: t.muted,
outputs: t.outputs,
};
if (t.clips.length > 0) track.clips = t.clips;
if (t.markers.length > 0) track.markers = t.markers;
if (t.volume !== undefined) track.volume = t.volume;
if (t.trackOffset !== undefined) track.trackOffset = t.trackOffset;
return track;
});
return {
name: this._name,
tracks,
};
}
/**
* Builds the timeline asset, assigns it to the director, and wires up any
* `.signal()` callbacks by creating/configuring a {@link SignalReceiver} on the
* director's GameObject.
*
* @param director - The PlayableDirector to install the timeline on
* @returns The built TimelineAssetModel (also assigned to `director.playableAsset`)
*
* @example
* ```ts
* TimelineBuilder.create("MyTimeline")
* .animationTrack("Anim", animator)
* .clip(walkClip, { duration: 2 })
* .signalTrack("Events")
* .signal(1.0, () => console.log("signal fired!"))
* .install(director);
*
* director.play();
* ```
*/
install(director: PlayableDirector): TimelineAssetModel {
const asset = this.build();
// Wire up signal callbacks
if (this._pendingSignals.length > 0) {
const obj = director.gameObject;
let receiver = GameObject.getComponent(obj, SignalReceiver);
if (!receiver) {
receiver = GameObject.addComponent(obj, SignalReceiver);
}
if (!receiver.events) {
receiver.events = [];
}
for (const pending of this._pendingSignals) {
const signalAsset = new SignalAsset();
signalAsset.guid = pending.guid;
const evt = new SignalReceiverEvent();
evt.signal = signalAsset;
evt.reaction = new EventList([pending.callback]);
receiver.events.push(evt);
// Wire the receiver as the output binding for the signal track
const track = asset.tracks[pending.trackIndex];
if (track && !track.outputs.includes(receiver)) {
track.outputs.push(receiver);
}
}
}
director.playableAsset = asset;
return asset;
}
// #region Private helpers
private pushTrack(name: string, type: TrackType, binding: object | null): BuilderTrack {
const track: BuilderTrack = {
name,
type,
muted: false,
outputs: binding ? [binding] : [],
clips: [],
markers: [],
cursor: 0,
inlineTracks: [],
};
this._tracks.push(track);
return track;
}
/** Commits any pending `.track()` descriptors on the current animation track into a clip */
private commitInlineTracks(): void {
if (!this._currentTrack || this._currentTrack.inlineTracks.length === 0) return;
const t = this._currentTrack;
const binding = t.outputs[0];
const root = binding instanceof Object3D ? binding
: (binding != null && "gameObject" in binding) ? (binding as any).gameObject as Object3D
: undefined;
const animClip = resolveToClip(t.inlineTracks, root);
const start = t.cursor;
const duration = animClip.duration;
const clipModel: ClipModel = {
start,
end: start + duration,
duration,
timeScale: 1,
clipIn: 0,
easeInDuration: 0,
easeOutDuration: 0,
preExtrapolationMode: ClipExtrapolation.None,
postExtrapolationMode: ClipExtrapolation.None,
asset: {
clip: animClip,
loop: false,
duration: animClip.duration,
removeStartOffset: false,
} satisfies AnimationClipModel,
};
t.clips.push(clipModel);
t.cursor = start + duration;
t.inlineTracks = [];
}
// #endregion
}