import { AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat } from "three"; import { Mathf } from "../engine/engine_math.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { IAnimationComponent } from "../engine/engine_types.js"; import { getParam } from "../engine/engine_utils.js"; import { MixerEvent } from "./Animator.js"; import { Behaviour } from "./Component.js"; const debug = getParam("debuganimation"); export declare type PlayOptions = { /** * The fade duration in seconds for the action to fade in and other actions to fade out (if exclusive is enabled) */ fadeDuration?: number; /** * If true, the animation will loop */ loop?: boolean; /** * If true, will stop all other animations before playing this one * @default true */ exclusive?: boolean; /** * The animation start time in seconds */ startTime?: number; /** * The animation end time in seconds */ endTime?: number; /** * If true, the animation will clamp when finished */ clampWhenFinished?: boolean; /** * Animation playback speed. This is a multiplier to the animation speed * @default 1 */ speed?: number; /** * Animation playback speed range. This will override speed * @default undefined */ minMaxSpeed?: Vec2; /** * The normalized offset to start the animation at. This will override startTime * @default undefined */ minMaxOffsetNormalized?: Vec2; } declare type AnimationIdentifier = AnimationClip | number | string | undefined; class Vec2 { x!: number; y!: number } /** * Animation component to play animations on a GameObject * @category Animation and Sequencing * @group Components */ export class Animation extends Behaviour implements IAnimationComponent { get isAnimationComponent(): boolean { return true; } addClip(clip: AnimationClip) { if (!this.animations) this.animations = []; this.animations.push(clip); } /** * If true, the animation will start playing when the component is enabled */ @serializable() playAutomatically: boolean = true; /** * If true, the animation will start at a random time. This is used when the animation component is enabled * @default true */ @serializable() randomStartTime: boolean = true; /** * The animation min-max speed range * @default undefined */ @serializable(Vec2) minMaxSpeed?: Vec2; /** * The normalized offset to start the animation at. This will override startTime * @default undefined */ @serializable(Vec2) minMaxOffsetNormalized?: Vec2; /** * Set to true to loop the animation * @default true */ @serializable() loop: boolean = true; /** * If true, the animation will clamp when finished */ @serializable() clampWhenFinished: boolean = 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: number) { if (this.actions) { for (const act of this.actions) { act.time = val; } } } private _tempAnimationClipBeforeGameObjectExisted: AnimationClip | null = null; /** * Get the first animation clip in the animations array */ get clip(): AnimationClip | null { return this.animations?.length ? this.animations[0] : null; } /** * Set the first animation clip in the animations array */ set clip(val: AnimationClip | null) { 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); } @serializable(AnimationClip) set clips(animations: AnimationClip[]) { this.animations = animations; } private _tempAnimationsArray: AnimationClip[] | undefined; set animations(animations: AnimationClip[]) { if (animations === null || animations === undefined || !Array.isArray(animations)) return; if (this.gameObject) this.gameObject.animations = animations; else { this._tempAnimationsArray = animations; } } get animations(): AnimationClip[] { return this.gameObject.animations || this._tempAnimationsArray || []; } private mixer: AnimationMixer | undefined = undefined; /** * The animation actions */ get actions(): Array { return this._actions; } set actions(val: Array) { this._actions = val; } private _actions!: Array; private _handles!: AnimationHandle[]; /** @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(): void { 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(): void { if (this.mixer) { this.mixer.stopAllAction(); } } /** @internal */ onDestroy(): void { this.context.animations.unregisterAnimationMixer(this.mixer); } /** Get an animation action by the animation clip name */ getAction(name: string): AnimationAction | null { 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?: Pick): void { 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?: AnimationIdentifier, opts?: Pick): void { 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?: AnimationIdentifier, unpause: boolean = false): void { 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: AnimationIdentifier = 0, options?: PlayOptions): Promise | void { 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: AnimationClip | undefined = clipOrNumber as AnimationClip; 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); } private internalOnPlay(action: AnimationAction, options: PlayOptions): Promise { 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(); } private tryFindHandle(action: AnimationAction): AnimationHandle | undefined { for (const handle of this._handles) { if (handle.action === action) return handle; } return undefined; } private 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); } } class AnimationHandle { readonly mixer: AnimationMixer; readonly action: AnimationAction; private promise: Promise | null = null; private _options?: PlayOptions | undefined; private _resolveCallback: Function | null = null; private _resolvedOrRejectedCallback?: (AnimationHandle) => void; constructor(action: AnimationAction, mixer: AnimationMixer, opts?: PlayOptions, onDone?: (handle: AnimationHandle) => void) { this.action = action; this.mixer = mixer; this._resolvedOrRejectedCallback = onDone; this._options = opts; } waitForFinish(): Promise { 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 as any); 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(); } } } private onResolve() { this.dispose(); this._resolvedOrRejectedCallback?.call(this, this); this._resolveCallback?.call(this, this.action); } // private onLoop = (_evt: MixerEvent) => { // } private onFinished = (evt: MixerEvent) => { if (evt.action === this.action) { this.onResolve(); } } private dispose() { // this.mixer.removeEventListener('loop', this.onLoop); this.mixer.removeEventListener('finished', this.onFinished as any); } }