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.

506 lines 20.2 kB
import { AnimationClip, Object3D } from "three"; import { GameObject } from "../Component.js"; import { InstantiateIdProvider } from "../../engine/engine_networking_instantiate.js"; import { EventList } from "../EventList.js"; import { resolveToClip, track as trackFn } from "../AnimationBuilder.js"; import { SignalAsset, SignalReceiver, SignalReceiverEvent } from "./SignalAsset.js"; import { ClipExtrapolation, TrackType } from "./TimelineModels.js"; import { MarkerType } from "./TimelineModels.js"; /** * 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 { _name; _tracks = []; _currentTrack = null; _pendingSignals = []; _idProvider; constructor(name, seed) { 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, seed) { 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, binding) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Animation, binding ?? null); return this; } /** * 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, binding, volume) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Audio, binding ?? null); this._currentTrack.volume = volume; return this; } /** * 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, binding) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Activation, binding ?? null); return this; } /** * Adds a control track. Subsequent `.clip()` calls control nested timelines or objects. * @param name - Display name for the track */ controlTrack(name) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Control, null); return this; } /** * 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, binding) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Signal, binding ?? null); return this; } /** * Adds a marker track. Use `.marker()` to add markers. * @param name - Display name for the track */ markerTrack(name) { this.commitInlineTracks(); this._currentTrack = this.pushTrack(name, TrackType.Marker, null); return this; } clip(assetOrOptions, options) { 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; if (assetOrOptions instanceof AnimationClip) { animClip = assetOrOptions; } else { const descriptors = Array.isArray(assetOrOptions) ? assetOrOptions : [assetOrOptions]; // 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.gameObject : undefined; animClip = resolveToClip(descriptors, root); } const opts = (options ?? {}); const duration = opts.duration ?? animClip.duration; const start = opts.start ?? track.cursor; const end = start + duration; const 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, }, }; track.clips.push(clipModel); track.cursor = end; break; } case TrackType.Audio: { const url = assetOrOptions; const opts = (options ?? {}); const duration = opts.duration; const start = opts.start ?? track.cursor; const end = start + duration; const 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, }, }; track.clips.push(clipModel); track.cursor = end; break; } case TrackType.Activation: { const opts = assetOrOptions; const start = opts.start ?? track.cursor; const end = start + opts.duration; const 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; const opts = (options ?? {}); const start = opts.start ?? track.cursor; const duration = opts.duration; const end = start + duration; const 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, }, }; 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, asset, options) { 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 = { 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, callback, options) { 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 = { 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 = true) { if (!this._currentTrack) throw new Error("TimelineBuilder: .muted() must be called after a track method"); this._currentTrack.muted = muted; return 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, property, keyframes, options) { 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, property, 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() { this.commitInlineTracks(); const tracks = this._tracks.map(t => { const track = { 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) { 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 pushTrack(name, type, binding) { const track = { 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 */ commitInlineTracks() { 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.gameObject : undefined; const animClip = resolveToClip(t.inlineTracks, root); const start = t.cursor; const duration = animClip.duration; const 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, }, }; t.clips.push(clipModel); t.cursor = start + duration; t.inlineTracks = []; } } //# sourceMappingURL=TimelineBuilder.js.map