playcanvas
Version:
PlayCanvas WebGL game engine
578 lines (575 loc) • 21.5 kB
JavaScript
import { Debug } from '../../../core/debug.js';
import { AnimClip } from '../../anim/evaluator/anim-clip.js';
import { AnimEvaluator } from '../../anim/evaluator/anim-evaluator.js';
import { AnimTrack } from '../../anim/evaluator/anim-track.js';
import { DefaultAnimBinder } from '../../anim/binder/default-anim-binder.js';
import { Skeleton } from '../../../scene/animation/skeleton.js';
import { Asset } from '../../asset/asset.js';
import { Component } from '../component.js';
/**
* @import { Animation } from '../../../scene/animation/animation.js'
* @import { Model } from '../../../scene/model.js'
*/ /**
* The Animation Component allows an Entity to playback animations on models.
*
* @hideconstructor
* @category Animation
*/ class AnimationComponent extends Component {
/**
* Sets the dictionary of animations by name.
*
* @type {Object<string, Animation>}
*/ set animations(value) {
this._animations = value;
this.onSetAnimations();
}
/**
* Gets the dictionary of animations by name.
*
* @type {Object<string, Animation>}
*/ get animations() {
return this._animations;
}
/**
* Sets the array of animation assets or asset ids.
*
* @type {Array.<number|Asset>}
*/ set assets(value) {
const assets = this._assets;
if (assets && assets.length) {
for(let i = 0; i < assets.length; i++){
// unsubscribe from change event for old assets
if (assets[i]) {
const asset = this.system.app.assets.get(assets[i]);
if (asset) {
asset.off('change', this.onAssetChanged, this);
asset.off('remove', this.onAssetRemoved, this);
const animName = this.animationsIndex[asset.id];
if (this.currAnim === animName) {
this._stopCurrentAnimation();
}
delete this.animations[animName];
delete this.animationsIndex[asset.id];
}
}
}
}
this._assets = value;
const assetIds = value.map((value)=>{
return value instanceof Asset ? value.id : value;
});
this.loadAnimationAssets(assetIds);
}
/**
* Gets the array of animation assets or asset ids.
*
* @type {Array.<number|Asset>}
*/ get assets() {
return this._assets;
}
/**
* Sets the current time position (in seconds) of the animation.
*
* @type {number}
*/ set currentTime(currentTime) {
if (this.skeleton) {
this.skeleton.currentTime = currentTime;
this.skeleton.addTime(0);
this.skeleton.updateGraph();
}
if (this.animEvaluator) {
const clips = this.animEvaluator.clips;
for(let i = 0; i < clips.length; ++i){
clips[i].time = currentTime;
}
}
}
/**
* Gets the current time position (in seconds) of the animation.
*
* @type {number}
*/ get currentTime() {
if (this.skeleton) {
return this.skeleton._time;
}
if (this.animEvaluator) {
// Get the last clip's current time which will be the one
// that is currently being blended
const clips = this.animEvaluator.clips;
if (clips.length > 0) {
return clips[clips.length - 1].time;
}
}
return 0;
}
/**
* Gets the duration in seconds of the current animation. Returns 0 if no animation is playing.
*
* @type {number}
*/ get duration() {
if (this.currAnim) {
return this.animations[this.currAnim].duration;
}
Debug.warn('No animation is playing to get a duration. Returning 0.');
return 0;
}
/**
* Sets whether the animation will restart from the beginning when it reaches the end.
*
* @type {boolean}
*/ set loop(value) {
this._loop = value;
if (this.skeleton) {
this.skeleton.looping = value;
}
if (this.animEvaluator) {
for(let i = 0; i < this.animEvaluator.clips.length; ++i){
this.animEvaluator.clips[i].loop = value;
}
}
}
/**
* Gets whether the animation will restart from the beginning when it reaches the end.
*
* @type {boolean}
*/ get loop() {
return this._loop;
}
/**
* Start playing an animation.
*
* @param {string} name - The name of the animation asset to begin playing.
* @param {number} [blendTime] - The time in seconds to blend from the current
* animation state to the start of the animation being set. Defaults to 0.
*/ play(name, blendTime = 0) {
if (!this.enabled || !this.entity.enabled) {
return;
}
if (!this.animations[name]) {
Debug.error(`Trying to play animation '${name}' which doesn't exist`);
return;
}
this.prevAnim = this.currAnim;
this.currAnim = name;
if (this.model) {
if (!this.skeleton && !this.animEvaluator) {
this._createAnimationController();
}
const prevAnim = this.animations[this.prevAnim];
const currAnim = this.animations[this.currAnim];
this.blending = blendTime > 0 && !!this.prevAnim;
if (this.blending) {
this.blend = 0;
this.blendSpeed = 1 / blendTime;
}
if (this.skeleton) {
if (this.blending) {
// Blend from the current time of the current animation to the start of
// the newly specified animation over the specified blend time period.
this.fromSkel.animation = prevAnim;
this.fromSkel.addTime(this.skeleton._time);
this.toSkel.animation = currAnim;
} else {
this.skeleton.animation = currAnim;
}
}
if (this.animEvaluator) {
const animEvaluator = this.animEvaluator;
if (this.blending) {
// remove all but the last clip
while(animEvaluator.clips.length > 1){
animEvaluator.removeClip(0);
}
} else {
this.animEvaluator.removeClips();
}
const clip = new AnimClip(this.animations[this.currAnim], 0, 1.0, true, this.loop);
clip.name = this.currAnim;
clip.blendWeight = this.blending ? 0 : 1;
clip.reset();
this.animEvaluator.addClip(clip);
}
}
this.playing = true;
}
/**
* Return an animation.
*
* @param {string} name - The name of the animation asset.
* @returns {Animation} An Animation.
*/ getAnimation(name) {
return this.animations[name];
}
/**
* Set the model driven by this animation component.
*
* @param {Model} model - The model to set.
* @ignore
*/ setModel(model) {
if (model !== this.model) {
// reset animation controller
this._resetAnimationController();
// set the model
this.model = model;
// Reset the current animation on the new model
if (this.animations && this.currAnim && this.animations[this.currAnim]) {
this.play(this.currAnim);
}
}
}
onSetAnimations() {
// If we have animations _and_ a model, we can create the skeletons
const modelComponent = this.entity.model;
if (modelComponent) {
const m = modelComponent.model;
if (m && m !== this.model) {
this.setModel(m);
}
}
if (!this.currAnim && this.activate && this.enabled && this.entity.enabled) {
// Set the first loaded animation as the current
const animationNames = Object.keys(this._animations);
if (animationNames.length > 0) {
this.play(animationNames[0]);
}
}
}
/** @private */ _resetAnimationController() {
this.skeleton = null;
this.fromSkel = null;
this.toSkel = null;
this.animEvaluator = null;
}
/** @private */ _createAnimationController() {
const model = this.model;
const animations = this.animations;
// check which type of animations are loaded
let hasJson = false;
let hasGlb = false;
for(const animation in animations){
if (animations.hasOwnProperty(animation)) {
const anim = animations[animation];
if (anim.constructor === AnimTrack) {
hasGlb = true;
} else {
hasJson = true;
}
}
}
const graph = model.getGraph();
if (hasJson) {
this.fromSkel = new Skeleton(graph);
this.toSkel = new Skeleton(graph);
this.skeleton = new Skeleton(graph);
this.skeleton.looping = this.loop;
this.skeleton.setGraph(graph);
} else if (hasGlb) {
this.animEvaluator = new AnimEvaluator(new DefaultAnimBinder(this.entity));
}
}
/**
* @param {number[]} ids - Array of animation asset ids.
* @private
*/ loadAnimationAssets(ids) {
if (!ids || !ids.length) {
return;
}
const assets = this.system.app.assets;
const onAssetReady = (asset)=>{
if (asset.resources.length > 1) {
for(let i = 0; i < asset.resources.length; i++){
this.animations[asset.resources[i].name] = asset.resources[i];
this.animationsIndex[asset.id] = asset.resources[i].name;
}
} else {
this.animations[asset.name] = asset.resource;
this.animationsIndex[asset.id] = asset.name;
}
/* eslint-disable no-self-assign */ this.animations = this.animations; // assigning ensures set_animations event is fired
/* eslint-enable no-self-assign */ };
const onAssetAdd = (asset)=>{
asset.off('change', this.onAssetChanged, this);
asset.on('change', this.onAssetChanged, this);
asset.off('remove', this.onAssetRemoved, this);
asset.on('remove', this.onAssetRemoved, this);
if (asset.resource) {
onAssetReady(asset);
} else {
asset.once('load', onAssetReady, this);
if (this.enabled && this.entity.enabled) {
assets.load(asset);
}
}
};
for(let i = 0, l = ids.length; i < l; i++){
const asset = assets.get(ids[i]);
if (asset) {
onAssetAdd(asset);
} else {
assets.on(`add:${ids[i]}`, onAssetAdd);
}
}
}
/**
* Handle asset change events.
*
* @param {Asset} asset - The asset that changed.
* @param {string} attribute - The name of the asset attribute that changed. Can be 'data',
* 'file', 'resource' or 'resources'.
* @param {*} newValue - The new value of the specified asset property.
* @param {*} oldValue - The old value of the specified asset property.
* @private
*/ onAssetChanged(asset, attribute, newValue, oldValue) {
if (attribute === 'resource' || attribute === 'resources') {
// If the attribute is 'resources', newValue can be an empty array when the
// asset is unloaded. Therefore, we should assign null in this case
if (attribute === 'resources' && newValue && newValue.length === 0) {
newValue = null;
}
// replace old animation with new one
if (newValue) {
let restarted = false;
if (newValue.length > 1) {
if (oldValue && oldValue.length > 1) {
for(let i = 0; i < oldValue.length; i++){
delete this.animations[oldValue[i].name];
}
} else {
delete this.animations[asset.name];
}
restarted = false;
for(let i = 0; i < newValue.length; i++){
this.animations[newValue[i].name] = newValue[i];
if (!restarted && this.currAnim === newValue[i].name) {
// restart animation
if (this.playing && this.enabled && this.entity.enabled) {
restarted = true;
this.play(newValue[i].name);
}
}
}
if (!restarted) {
this._stopCurrentAnimation();
this.onSetAnimations();
}
} else {
if (oldValue && oldValue.length > 1) {
for(let i = 0; i < oldValue.length; i++){
delete this.animations[oldValue[i].name];
}
}
this.animations[asset.name] = newValue[0] || newValue;
restarted = false;
if (this.currAnim === asset.name) {
// restart animation
if (this.playing && this.enabled && this.entity.enabled) {
restarted = true;
this.play(asset.name);
}
}
if (!restarted) {
this._stopCurrentAnimation();
this.onSetAnimations();
}
}
this.animationsIndex[asset.id] = asset.name;
} else {
if (oldValue.length > 1) {
for(let i = 0; i < oldValue.length; i++){
delete this.animations[oldValue[i].name];
if (this.currAnim === oldValue[i].name) {
this._stopCurrentAnimation();
}
}
} else {
delete this.animations[asset.name];
if (this.currAnim === asset.name) {
this._stopCurrentAnimation();
}
}
delete this.animationsIndex[asset.id];
}
}
}
/**
* @param {Asset} asset - The asset that was removed.
* @private
*/ onAssetRemoved(asset) {
asset.off('remove', this.onAssetRemoved, this);
if (this.animations) {
if (asset.resources.length > 1) {
for(let i = 0; i < asset.resources.length; i++){
delete this.animations[asset.resources[i].name];
if (this.currAnim === asset.resources[i].name) {
this._stopCurrentAnimation();
}
}
} else {
delete this.animations[asset.name];
if (this.currAnim === asset.name) {
this._stopCurrentAnimation();
}
}
delete this.animationsIndex[asset.id];
}
}
/** @private */ _stopCurrentAnimation() {
this.currAnim = null;
this.playing = false;
if (this.skeleton) {
this.skeleton.currentTime = 0;
this.skeleton.animation = null;
}
if (this.animEvaluator) {
for(let i = 0; i < this.animEvaluator.clips.length; ++i){
this.animEvaluator.clips[i].stop();
}
this.animEvaluator.update(0);
this.animEvaluator.removeClips();
}
}
onEnable() {
super.onEnable();
// load assets if they're not loaded
const assets = this.assets;
const registry = this.system.app.assets;
if (assets) {
for(let i = 0, len = assets.length; i < len; i++){
let asset = assets[i];
if (!(asset instanceof Asset)) {
asset = registry.get(asset);
}
if (asset && !asset.resource) {
registry.load(asset);
}
}
}
if (this.activate && !this.currAnim) {
const animationNames = Object.keys(this.animations);
if (animationNames.length > 0) {
this.play(animationNames[0]);
}
}
}
onBeforeRemove() {
for(let i = 0; i < this.assets.length; i++){
// this.assets can be an array of pc.Assets or an array of numbers (assetIds)
let asset = this.assets[i];
if (typeof asset === 'number') {
asset = this.system.app.assets.get(asset);
}
if (!asset) continue;
asset.off('change', this.onAssetChanged, this);
asset.off('remove', this.onAssetRemoved, this);
}
this.skeleton = null;
this.fromSkel = null;
this.toSkel = null;
this.animEvaluator = null;
}
/**
* Update the state of the component.
*
* @param {number} dt - The time delta.
* @ignore
*/ update(dt) {
// update blending
if (this.blending) {
this.blend += dt * this.blendSpeed;
if (this.blend >= 1) {
this.blend = 1;
}
}
// update skeleton
if (this.playing) {
const skeleton = this.skeleton;
if (skeleton !== null && this.model !== null) {
if (this.blending) {
skeleton.blend(this.fromSkel, this.toSkel, this.blend);
} else {
// Advance the animation, interpolating keyframes at each animated node in
// skeleton
const delta = dt * this.speed;
skeleton.addTime(delta);
if (this.speed > 0 && skeleton._time === skeleton.animation.duration && !this.loop) {
this.playing = false;
} else if (this.speed < 0 && skeleton._time === 0 && !this.loop) {
this.playing = false;
}
}
if (this.blending && this.blend === 1) {
skeleton.animation = this.toSkel.animation;
}
skeleton.updateGraph();
}
}
// update anim controller
const animEvaluator = this.animEvaluator;
if (animEvaluator) {
// force all clips' speed and playing state from the component
for(let i = 0; i < animEvaluator.clips.length; ++i){
const clip = animEvaluator.clips[i];
clip.speed = this.speed;
if (!this.playing) {
clip.pause();
} else {
clip.resume();
}
}
// update blend weight
if (this.blending && animEvaluator.clips.length > 1) {
animEvaluator.clips[1].blendWeight = this.blend;
}
animEvaluator.update(dt);
}
// clear blending flag
if (this.blending && this.blend === 1) {
this.blending = false;
}
}
constructor(...args){
super(...args), /**
* @type {Object<string, Animation>}
* @private
*/ this._animations = {}, /**
* @type {Array.<number|Asset>}
* @private
*/ this._assets = [], /** @private */ this._loop = true, /**
* @type {AnimEvaluator|null}
* @ignore
*/ this.animEvaluator = null, /**
* @type {Model|null}
* @ignore
*/ this.model = null, /**
* Get the skeleton for the current model. If the model is loaded from glTF/glb, then the
* skeleton is null.
*
* @type {Skeleton|null}
*/ this.skeleton = null, /**
* @type {Skeleton|null}
* @ignore
*/ this.fromSkel = null, /**
* @type {Skeleton|null}
* @ignore
*/ this.toSkel = null, /**
* @type {Object<string, string>}
* @ignore
*/ this.animationsIndex = {}, /**
* @type {string|null}
* @private
*/ this.prevAnim = null, /**
* @type {string|null}
* @private
*/ this.currAnim = null, /** @private */ this.blend = 0, /** @private */ this.blending = false, /** @private */ this.blendSpeed = 0, /**
* If true, the first animation asset will begin playing when the scene is loaded.
*
* @type {boolean}
*/ this.activate = true, /**
* Speed multiplier for animation play back. 1 is playback at normal speed and 0 pauses the
* animation.
*
* @type {number}
*/ this.speed = 1;
}
}
export { AnimationComponent };