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