@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.
509 lines • 17.8 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { AnimationClip, AnimationMixer, LoopOnce, LoopRepeat } from "three";
import { Mathf } from "../engine/engine_math.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { getParam } from "../engine/engine_utils.js";
import { Behaviour } from "./Component.js";
const debug = getParam("debuganimation");
class Vec2 {
x;
y;
}
/**
* Animation component to play animations on a GameObject
* @category Animation and Sequencing
* @group Components
*/
export class Animation extends Behaviour {
get isAnimationComponent() { return true; }
addClip(clip) {
if (!this.animations)
this.animations = [];
this.animations.push(clip);
}
/**
* If true, the animation will start playing when the component is enabled
*/
playAutomatically = true;
/**
* If true, the animation will start at a random time. This is used when the animation component is enabled
* @default true
*/
randomStartTime = true;
/**
* The animation min-max speed range
* @default undefined
*/
minMaxSpeed;
/**
* The normalized offset to start the animation at. This will override startTime
* @default undefined
*/
minMaxOffsetNormalized;
/**
* Set to true to loop the animation
* @default true
*/
loop = true;
/**
* If true, the animation will clamp when finished
*/
clampWhenFinished = false;
/**
* The time in seconds of the first running animation action
* @default 0
*/
get time() {
if (this.actions) {
for (const action of this.actions) {
if (action.isRunning())
return action.time;
}
}
return 0;
}
set time(val) {
if (this.actions) {
for (const act of this.actions) {
act.time = val;
}
}
}
_tempAnimationClipBeforeGameObjectExisted = null;
/**
* Get the first animation clip in the animations array
*/
get clip() {
return this.animations?.length ? this.animations[0] : null;
}
/**
* Set the first animation clip in the animations array
*/
set clip(val) {
if (!this.__didAwake) {
if (debug)
console.warn("Assign clip during serialization", val);
this._tempAnimationClipBeforeGameObjectExisted = val;
return;
}
if (!val)
return;
// if (debug) console.log("Assign clip", val, Boolean(this.gameObject));
if (!this.gameObject.animations)
this.gameObject.animations = [];
if (this.animations.includes(val))
return;
if (this.animations.length > 0) {
this.animations.splice(0, 0, val);
}
else
this.animations.push(val);
}
set clips(animations) {
this.animations = animations;
}
_tempAnimationsArray;
set animations(animations) {
if (animations === null || animations === undefined || !Array.isArray(animations))
return;
if (this.gameObject)
this.gameObject.animations = animations;
else {
this._tempAnimationsArray = animations;
}
}
get animations() {
return this.gameObject.animations || this._tempAnimationsArray || [];
}
mixer = undefined;
/**
* The animation actions
*/
get actions() {
return this._actions;
}
set actions(val) {
this._actions = val;
}
_actions;
_handles;
/** @internal */
awake() {
this.mixer = undefined;
if (debug)
console.log("Animation Awake", this.name, this);
if (this._tempAnimationsArray) {
this.animations = this._tempAnimationsArray;
this._tempAnimationsArray = undefined;
}
if (this._tempAnimationClipBeforeGameObjectExisted) {
this.clip = this._tempAnimationClipBeforeGameObjectExisted;
this._tempAnimationClipBeforeGameObjectExisted = null;
}
// actions need to reset (e.g. if the animation component was duplicated this array must not contain previous content)
this.actions = [];
this._handles = [];
}
/** @internal */
onEnable() {
if (this.playAutomatically && this.animations?.length > 0) {
const index = Math.floor(Math.random() * this.animations.length);
const animation = this.animations[index];
this.play(index, {
exclusive: true,
fadeDuration: 0,
startTime: this.randomStartTime ? Math.random() * animation.duration : 0,
loop: this.loop,
clampWhenFinished: this.clampWhenFinished
});
}
}
/** @internal */
update() {
if (!this.mixer)
return;
this.mixer.update(this.context.time.deltaTime);
this._handles.forEach(h => h.update());
}
/** @internal */
onDisable() {
if (this.mixer) {
this.mixer.stopAllAction();
}
}
/** @internal */
onDestroy() {
this.context.animations.unregisterAnimationMixer(this.mixer);
}
/** Get an animation action by the animation clip name */
getAction(name) {
return this.actions?.find(a => a.getClip().name === name) || null;
}
/** Is any animation playing? */
get isPlaying() {
if (this.actions) {
for (let i = 0; i < this.actions.length; i++) {
if (this.actions[i].isRunning())
return true;
}
}
return false;
}
/** Stops all currently playing animations */
stopAll(opts) {
if (this.actions) {
for (const act of this.actions) {
if (opts?.fadeDuration) {
act.fadeOut(opts.fadeDuration);
}
else {
act.stop();
}
}
}
}
/**
* Stops a specific animation clip or index. If clip is undefined then all animations will be stopped
*/
stop(clip, opts) {
if (clip === undefined) {
this.stopAll();
return;
}
else if (typeof clip === "number") {
if (clip >= this.animations.length) {
if (debug)
console.log("No animation at index", clip);
return;
}
clip = this.animations[clip];
}
else if (typeof clip === "string") {
clip = this.animations.find(a => a.name === clip);
}
if (!clip) {
console.error("Could not find clip", clip);
return;
}
const act = this.actions.find(a => a.getClip() === clip);
if (!act) {
console.error("Could not find action", clip);
return;
}
if (opts?.fadeDuration) {
act.fadeOut(opts.fadeDuration);
}
else {
act.stop();
}
}
/**
* Pause all animations or a specific animation clip or index
* @param clip optional animation clip, index or name, if undefined all animations will be paused
* @param unpause if true, the animation will be resumed
*/
pause(clip, unpause = false) {
if (clip === undefined) {
for (const act of this.actions) {
act.paused = !unpause;
}
return;
}
else if (typeof clip === "number") {
if (clip >= this.animations.length) {
if (debug)
console.log("No animation at index", clip);
return;
}
clip = this.animations[clip];
}
else if (typeof clip === "string") {
clip = this.animations.find(a => a.name === clip);
}
if (!clip) {
console.error("Could not find clip", clip);
return;
}
const act = this.actions.find(a => a.getClip() === clip);
if (!act) {
console.error("Could not find action", clip);
return;
}
act.paused = !unpause;
}
/**
* Resume all paused animations.
* Note that this will not fade animations in or out and just unpause previous animations. If an animation was faded out which means it's not running anymore, it will not be resumed.
*/
resume() {
for (const act of this.actions) {
act.paused = false;
}
}
/**
* Play an animation clip or an clip at the specified index.
* @param clipOrNumber the animation clip, index or name to play. If undefined, the first animation in the animations array will be played
* @param options the play options. Use to set the fade duration, loop, speed, start time, end time, clampWhenFinished
* @returns a promise that resolves when the animation is finished (note that it will not resolve if the animation is looping)
*/
play(clipOrNumber = 0, options) {
if (debug)
console.log("PLAY", clipOrNumber);
this.ensureMixer();
if (!this.mixer) {
if (debug)
console.warn("Missing mixer", this);
return;
}
if (clipOrNumber === undefined)
clipOrNumber = 0;
let clip = clipOrNumber;
if (typeof clipOrNumber === 'number') {
if (clipOrNumber >= this.animations.length) {
if (debug)
console.log("No animation at index", clipOrNumber);
return;
}
clip = this.animations[clipOrNumber];
}
else if (typeof clipOrNumber === "string") {
clip = this.animations.find(a => a.name === clipOrNumber);
}
if (!clip) {
console.error("Could not find clip", clipOrNumber);
return;
}
if (!options)
options = {};
for (const act of this.actions) {
if (act.getClip() === clip) {
return this.internalOnPlay(act, options);
}
}
if (!clip.tracks) {
console.warn("Clip is no AnimationClip", clip);
return;
}
const act = this.mixer.clipAction(clip);
this.actions.push(act);
return this.internalOnPlay(act, options);
}
internalOnPlay(action, options) {
var existing = this.actions.find(a => a === action);
if (existing === action && existing.isRunning() && existing.time < existing.getClip().duration) {
const handle = this.tryFindHandle(action);
if (existing.paused) {
existing.paused = false;
}
if (handle)
return handle.waitForFinish();
}
// Assign defaults
if (options.loop === undefined)
options.loop = this.loop;
if (options.clampWhenFinished === undefined)
options.clampWhenFinished = this.clampWhenFinished;
if (options.minMaxOffsetNormalized === undefined && this.randomStartTime)
options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
if (options.minMaxSpeed === undefined)
options.minMaxSpeed = this.minMaxSpeed;
// Reset currently running animations
const stopOther = options?.exclusive ?? true;
if (stopOther) {
for (const act of this.actions) {
if (act != existing) {
if (options.fadeDuration) {
act.fadeOut(options.fadeDuration);
}
else {
act.stop();
}
}
}
}
if (options?.fadeDuration) {
action.fadeIn(options.fadeDuration);
}
action.enabled = true;
// Apply start time
if (options?.startTime != undefined) {
action.time = options.startTime;
}
// Only apply random start offset if it's not 0:0 (default). Otherwise `play` will not resume paused animations but instead restart them
else if (options?.minMaxOffsetNormalized && options.minMaxOffsetNormalized.x != 0 && options.minMaxOffsetNormalized.y != 0) {
const clip = action.getClip();
action.time = Mathf.lerp(options.minMaxOffsetNormalized.x, options.minMaxOffsetNormalized.y, Math.random()) * clip.duration;
}
// If the animation is at the end, reset the time
else if (action.time >= action.getClip().duration) {
action.time = 0;
}
// Apply speed
if (options?.minMaxSpeed) {
action.timeScale = Mathf.lerp(options.minMaxSpeed.x, options.minMaxSpeed.y, Math.random());
}
else {
action.timeScale = options?.speed ?? 1;
}
// Apply looping
if (options?.loop != undefined) {
action.loop = options.loop ? LoopRepeat : LoopOnce;
}
else
action.loop = LoopOnce;
if (options?.clampWhenFinished) {
action.clampWhenFinished = true;
}
action.paused = false;
action.play();
if (debug)
console.log("PLAY", action.getClip().name, action);
const handle = new AnimationHandle(action, this.mixer, options, _ => {
this._handles.splice(this._handles.indexOf(handle), 1);
});
this._handles.push(handle);
return handle.waitForFinish();
}
tryFindHandle(action) {
for (const handle of this._handles) {
if (handle.action === action)
return handle;
}
return undefined;
}
ensureMixer() {
if (!this.mixer) {
// try getting the animation mixer from the assigned gameobject
const key = "animationMixer";
if (this.gameObject[key]) {
this.mixer = this.gameObject[key];
}
if (!this.mixer || !this.mixer.clipAction) {
this.mixer = new AnimationMixer(this.gameObject);
this.gameObject[key] = this.mixer;
}
}
this.context.animations.registerAnimationMixer(this.mixer);
}
}
__decorate([
serializable()
], Animation.prototype, "playAutomatically", void 0);
__decorate([
serializable()
], Animation.prototype, "randomStartTime", void 0);
__decorate([
serializable(Vec2)
], Animation.prototype, "minMaxSpeed", void 0);
__decorate([
serializable(Vec2)
], Animation.prototype, "minMaxOffsetNormalized", void 0);
__decorate([
serializable()
], Animation.prototype, "loop", void 0);
__decorate([
serializable()
], Animation.prototype, "clampWhenFinished", void 0);
__decorate([
serializable(AnimationClip)
], Animation.prototype, "clips", null);
class AnimationHandle {
mixer;
action;
promise = null;
_options;
_resolveCallback = null;
_resolvedOrRejectedCallback;
constructor(action, mixer, opts, onDone) {
this.action = action;
this.mixer = mixer;
this._resolvedOrRejectedCallback = onDone;
this._options = opts;
}
waitForFinish() {
if (this.promise)
return this.promise;
this.promise = new Promise((res) => {
this._resolveCallback = res;
});
// this.mixer.addEventListener('loop', this.onLoop);
this.mixer.addEventListener('finished', this.onFinished);
return this.promise;
}
update() {
if (!this._options)
return;
if (this._options.endTime !== undefined && this.action.time > this._options.endTime) {
if (this._options.loop === true) {
this.action.time = this._options.startTime ?? 0;
}
else {
this.action.time = this._options.endTime;
this.action.timeScale = 0;
this.onResolve();
}
}
}
onResolve() {
this.dispose();
this._resolvedOrRejectedCallback?.call(this, this);
this._resolveCallback?.call(this, this.action);
}
// private onLoop = (_evt: MixerEvent) => {
// }
onFinished = (evt) => {
if (evt.action === this.action) {
this.onResolve();
}
};
dispose() {
// this.mixer.removeEventListener('loop', this.onLoop);
this.mixer.removeEventListener('finished', this.onFinished);
}
}
//# sourceMappingURL=Animation.js.map