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.

825 lines (752 loc) 36 kB
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 }