playcanvas
Version:
PlayCanvas WebGL game engine
579 lines (576 loc) • 27.4 kB
JavaScript
import { Debug } from '../../../core/debug.js';
import { sortPriority } from '../../../core/sort.js';
import { AnimClip } from '../evaluator/anim-clip.js';
import { ANIM_STATE_START, ANIM_INTERRUPTION_NONE, ANIM_STATE_END, ANIM_STATE_ANY, ANIM_NOT_EQUAL_TO, ANIM_EQUAL_TO, ANIM_LESS_THAN_EQUAL_TO, ANIM_GREATER_THAN_EQUAL_TO, ANIM_LESS_THAN, ANIM_GREATER_THAN, ANIM_INTERRUPTION_NEXT_PREV, ANIM_INTERRUPTION_PREV_NEXT, ANIM_INTERRUPTION_NEXT, ANIM_INTERRUPTION_PREV, ANIM_PARAMETER_TRIGGER, ANIM_CONTROL_STATES } from './constants.js';
import { AnimState } from './anim-state.js';
import { AnimNode } from './anim-node.js';
import { AnimTransition } from './anim-transition.js';
/**
* @import { AnimEvaluator } from '../evaluator/anim-evaluator.js'
* @import { EventHandler } from '../../../core/event-handler.js'
*/ /**
* The AnimController manages the animations for its entity, based on the provided state graph and
* parameters. Its update method determines which state the controller should be in based on the
* current time, parameters and available states / transitions. It also ensures the AnimEvaluator
* is supplied with the correct animations, based on the currently active state.
*
* @ignore
*/ class AnimController {
/**
* Create a new AnimController.
*
* @param {AnimEvaluator} animEvaluator - The animation evaluator used to blend all current
* playing animation keyframes and update the entities properties based on the current
* animation values.
* @param {object[]} states - The list of states used to form the controller state graph.
* @param {object[]} transitions - The list of transitions used to form the controller state
* graph.
* @param {boolean} activate - Determines whether the anim controller should automatically play
* once all {@link AnimNodes} are assigned animations.
* @param {EventHandler} eventHandler - The event handler which should be notified with anim
* events.
* @param {Function} findParameter - Retrieves a parameter which is used to control the
* transition between states.
* @param {Function} consumeTrigger - Used to set triggers back to their default state after
* they have been consumed by a transition.
*/ constructor(animEvaluator, states, transitions, activate, eventHandler, findParameter, consumeTrigger){
/**
* @type {Object<string, AnimState>}
* @private
*/ this._states = {};
/**
* @type {string[]}
* @private
*/ this._stateNames = [];
/**
* @type {Object<string, AnimTransition[]>}
* @private
*/ this._findTransitionsFromStateCache = {};
/**
* @type {Object<string, AnimTransition[]>}
* @private
*/ this._findTransitionsBetweenStatesCache = {};
/**
* @type {string|null}
* @private
*/ this._previousStateName = null;
/** @private */ this._activeStateName = ANIM_STATE_START;
/** @private */ this._activeStateDuration = 0;
/** @private */ this._activeStateDurationDirty = true;
/** @private */ this._playing = false;
/** @private */ this._currTransitionTime = 1;
/** @private */ this._totalTransitionTime = 1;
/** @private */ this._isTransitioning = false;
/** @private */ this._transitionInterruptionSource = ANIM_INTERRUPTION_NONE;
/** @private */ this._transitionPreviousStates = [];
/** @private */ this._timeInState = 0;
/** @private */ this._timeInStateBefore = 0;
this.findParameter = (name)=>{
return this._findParameter(name);
};
this._animEvaluator = animEvaluator;
this._eventHandler = eventHandler;
this._findParameter = findParameter;
this._consumeTrigger = consumeTrigger;
for(let i = 0; i < states.length; i++){
this._states[states[i].name] = new AnimState(this, states[i].name, states[i].speed, states[i].loop, states[i].blendTree);
this._stateNames.push(states[i].name);
}
this._transitions = transitions.map((transition)=>{
return new AnimTransition({
...transition
});
});
this._activate = activate;
}
get animEvaluator() {
return this._animEvaluator;
}
set activeState(stateName) {
this._activeStateName = stateName;
}
get activeState() {
return this._findState(this._activeStateName);
}
get activeStateName() {
return this._activeStateName;
}
get activeStateAnimations() {
return this.activeState.animations;
}
set previousState(stateName) {
this._previousStateName = stateName;
}
get previousState() {
return this._findState(this._previousStateName);
}
get previousStateName() {
return this._previousStateName;
}
get playable() {
let playable = true;
for(let i = 0; i < this._stateNames.length; i++){
if (!this._states[this._stateNames[i]].playable) {
playable = false;
}
}
return playable;
}
set playing(value) {
this._playing = value;
}
get playing() {
return this._playing;
}
get activeStateProgress() {
return this._getActiveStateProgressForTime(this._timeInState);
}
get activeStateDuration() {
if (this._activeStateDurationDirty) {
let maxDuration = 0.0;
for(let i = 0; i < this.activeStateAnimations.length; i++){
const activeClip = this._animEvaluator.findClip(this.activeStateAnimations[i].name);
if (activeClip) {
maxDuration = Math.max(maxDuration, activeClip.track.duration);
}
}
this._activeStateDuration = maxDuration;
this._activeStateDurationDirty = false;
}
return this._activeStateDuration;
}
set activeStateCurrentTime(time) {
this._timeInStateBefore = time;
this._timeInState = time;
for(let i = 0; i < this.activeStateAnimations.length; i++){
const clip = this.animEvaluator.findClip(this.activeStateAnimations[i].name);
if (clip) {
clip.time = time;
}
}
}
get activeStateCurrentTime() {
return this._timeInState;
}
get transitioning() {
return this._isTransitioning;
}
get transitionProgress() {
return this._currTransitionTime / this._totalTransitionTime;
}
get states() {
return this._stateNames;
}
assignMask(mask) {
return this._animEvaluator.assignMask(mask);
}
/**
* @param {string} stateName - The name of the state to find.
* @returns {AnimState} The state with the given name.
* @private
*/ _findState(stateName) {
return this._states[stateName];
}
_getActiveStateProgressForTime(time) {
if (this.activeStateName === ANIM_STATE_START || this.activeStateName === ANIM_STATE_END || this.activeStateName === ANIM_STATE_ANY) {
return 1.0;
}
const activeClip = this._animEvaluator.findClip(this.activeStateAnimations[0].name);
if (activeClip) {
return activeClip.progressForTime(time);
}
return null;
}
/**
* Return all the transitions that have the given stateName as their source state.
*
* @param {string} stateName - The name of the state to find transitions from.
* @returns {AnimTransition[]} The transitions that have the given stateName as their source
* state.
* @private
*/ _findTransitionsFromState(stateName) {
let transitions = this._findTransitionsFromStateCache[stateName];
if (!transitions) {
transitions = this._transitions.filter((transition)=>{
return transition.from === stateName;
});
// sort transitions in priority order
sortPriority(transitions);
this._findTransitionsFromStateCache[stateName] = transitions;
}
return transitions;
}
/**
* Return all the transitions that contain the given source and destination states.
*
* @param {string} sourceStateName - The name of the source state to find transitions from.
* @param {string} destinationStateName - The name of the destination state to find transitions
* to.
* @returns {AnimTransition[]} The transitions that have the given source and destination states.
* @private
*/ _findTransitionsBetweenStates(sourceStateName, destinationStateName) {
let transitions = this._findTransitionsBetweenStatesCache[`${sourceStateName}->${destinationStateName}`];
if (!transitions) {
transitions = this._transitions.filter((transition)=>{
return transition.from === sourceStateName && transition.to === destinationStateName;
});
// sort transitions in priority order
sortPriority(transitions);
this._findTransitionsBetweenStatesCache[`${sourceStateName}->${destinationStateName}`] = transitions;
}
return transitions;
}
_transitionHasConditionsMet(transition) {
const conditions = transition.conditions;
for(let i = 0; i < conditions.length; i++){
const condition = conditions[i];
const parameter = this._findParameter(condition.parameterName);
switch(condition.predicate){
case ANIM_GREATER_THAN:
if (!(parameter.value > condition.value)) return false;
break;
case ANIM_LESS_THAN:
if (!(parameter.value < condition.value)) return false;
break;
case ANIM_GREATER_THAN_EQUAL_TO:
if (!(parameter.value >= condition.value)) return false;
break;
case ANIM_LESS_THAN_EQUAL_TO:
if (!(parameter.value <= condition.value)) return false;
break;
case ANIM_EQUAL_TO:
if (!(parameter.value === condition.value)) return false;
break;
case ANIM_NOT_EQUAL_TO:
if (!(parameter.value !== condition.value)) return false;
break;
}
}
return true;
}
_findTransition(from, to) {
let transitions = [];
// If from and to are supplied, find transitions that include the required source and destination states
if (from && to) {
transitions = transitions.concat(this._findTransitionsBetweenStates(from, to));
} else {
// If no transition is active, look for transitions from the active & any states.
if (!this._isTransitioning) {
transitions = transitions.concat(this._findTransitionsFromState(this._activeStateName));
transitions = transitions.concat(this._findTransitionsFromState(ANIM_STATE_ANY));
} else {
// Otherwise look for transitions from the previous and active states based on the current interruption source.
// Accept transitions from the any state unless the interruption source is set to none
switch(this._transitionInterruptionSource){
case ANIM_INTERRUPTION_PREV:
transitions = transitions.concat(this._findTransitionsFromState(this._previousStateName));
transitions = transitions.concat(this._findTransitionsFromState(ANIM_STATE_ANY));
break;
case ANIM_INTERRUPTION_NEXT:
transitions = transitions.concat(this._findTransitionsFromState(this._activeStateName));
transitions = transitions.concat(this._findTransitionsFromState(ANIM_STATE_ANY));
break;
case ANIM_INTERRUPTION_PREV_NEXT:
transitions = transitions.concat(this._findTransitionsFromState(this._previousStateName));
transitions = transitions.concat(this._findTransitionsFromState(this._activeStateName));
transitions = transitions.concat(this._findTransitionsFromState(ANIM_STATE_ANY));
break;
case ANIM_INTERRUPTION_NEXT_PREV:
transitions = transitions.concat(this._findTransitionsFromState(this._activeStateName));
transitions = transitions.concat(this._findTransitionsFromState(this._previousStateName));
transitions = transitions.concat(this._findTransitionsFromState(ANIM_STATE_ANY));
break;
}
}
}
// filter out transitions that don't have their conditions met
transitions = transitions.filter((transition)=>{
// if the transition is moving to the already active state, ignore it
if (transition.to === this.activeStateName) {
return false;
}
// when an exit time is present, we should only exit if it falls within the current frame delta time
if (transition.hasExitTime) {
let progressBefore = this._getActiveStateProgressForTime(this._timeInStateBefore);
let progress = this._getActiveStateProgressForTime(this._timeInState);
// when the exit time is smaller than 1 and the state is looping, we should check for an exit each loop
if (transition.exitTime < 1.0 && this.activeState.loop) {
progressBefore -= Math.floor(progressBefore);
progress -= Math.floor(progress);
}
// if the delta time is 0 and the progress matches the exit time, the exitTime condition has been met
if (progress === progressBefore) {
if (progress !== transition.exitTime) {
return null;
}
// otherwise if the delta time is greater than 0, return false if exit time isn't within the frames delta time
} else if (!(transition.exitTime > progressBefore && transition.exitTime <= progress)) {
return null;
}
}
// if the exitTime condition has been met or is not present, check condition parameters
return this._transitionHasConditionsMet(transition);
});
// return the highest priority transition to use
if (transitions.length > 0) {
const transition = transitions[0];
if (transition.to === ANIM_STATE_END) {
const startTransition = this._findTransitionsFromState(ANIM_STATE_START)[0];
transition.to = startTransition.to;
}
return transition;
}
return null;
}
updateStateFromTransition(transition) {
let state;
let animation;
let clip;
// If transition.from is set, transition from the active state irregardless of the transition.from value (this could be the previous, active or ANY states).
// Otherwise the previousState is cleared.
this.previousState = transition.from ? this.activeStateName : null;
this.activeState = transition.to;
// when transitioning to a new state, we need to recalculate the duration of the active state based on its animations
this._activeStateDurationDirty = true;
// turn off any triggers which were required to activate this transition
for(let i = 0; i < transition.conditions.length; i++){
const condition = transition.conditions[i];
const parameter = this._findParameter(condition.parameterName);
if (parameter.type === ANIM_PARAMETER_TRIGGER) {
this._consumeTrigger(condition.parameterName);
}
}
if (this.previousState) {
if (!this._isTransitioning) {
this._transitionPreviousStates = [];
}
// record the transition source state in the previous states array
this._transitionPreviousStates.push({
name: this._previousStateName,
weight: 1
});
// if this new transition was activated during another transition, update the previous transition state weights based
// on the progress through the previous transition.
const interpolatedTime = Math.min(this._totalTransitionTime !== 0 ? this._currTransitionTime / this._totalTransitionTime : 1, 1.0);
for(let i = 0; i < this._transitionPreviousStates.length; i++){
// interpolate the weights of the most recent previous state and all other previous states based on the progress through the previous transition
if (!this._isTransitioning) {
this._transitionPreviousStates[i].weight = 1.0;
} else if (i !== this._transitionPreviousStates.length - 1) {
this._transitionPreviousStates[i].weight *= 1.0 - interpolatedTime;
} else {
this._transitionPreviousStates[i].weight = interpolatedTime;
}
state = this._findState(this._transitionPreviousStates[i].name);
// update the animations of previous states, set their name to include their position in the previous state array
// to uniquely identify animations from the same state that were added during different transitions
for(let j = 0; j < state.animations.length; j++){
animation = state.animations[j];
clip = this._animEvaluator.findClip(`${animation.name}.previous.${i}`);
if (!clip) {
clip = this._animEvaluator.findClip(animation.name);
clip.name = `${animation.name}.previous.${i}`;
}
// // pause previous animation clips to reduce their impact on performance
if (i !== this._transitionPreviousStates.length - 1) {
clip.pause();
}
}
}
}
this._isTransitioning = true;
this._totalTransitionTime = transition.time;
this._currTransitionTime = 0;
this._transitionInterruptionSource = transition.interruptionSource;
const activeState = this.activeState;
const hasTransitionOffset = transition.transitionOffset && transition.transitionOffset > 0.0 && transition.transitionOffset < 1.0;
// set the time in the new state to 0 or to a value based on transitionOffset if one was given
let timeInState = 0;
let timeInStateBefore = 0;
if (hasTransitionOffset) {
const offsetTime = activeState.timelineDuration * transition.transitionOffset;
timeInState = offsetTime;
timeInStateBefore = offsetTime;
}
this._timeInState = timeInState;
this._timeInStateBefore = timeInStateBefore;
// Add clips to the evaluator for each animation in the new state.
for(let i = 0; i < activeState.animations.length; i++){
clip = this._animEvaluator.findClip(activeState.animations[i].name);
if (!clip) {
const speed = Number.isFinite(activeState.animations[i].speed) ? activeState.animations[i].speed : activeState.speed;
clip = new AnimClip(activeState.animations[i].animTrack, this._timeInState, speed, true, activeState.loop, this._eventHandler);
clip.name = activeState.animations[i].name;
this._animEvaluator.addClip(clip);
} else {
clip.reset();
}
if (transition.time > 0) {
clip.blendWeight = 0.0;
} else {
clip.blendWeight = activeState.animations[i].normalizedWeight;
}
clip.play();
if (hasTransitionOffset) {
clip.time = activeState.timelineDuration * transition.transitionOffset;
} else {
const startTime = activeState.speed >= 0 ? 0 : this.activeStateDuration;
clip.time = startTime;
}
}
}
_transitionToState(newStateName) {
if (!this._findState(newStateName)) {
return;
}
// move to the given state, if a transition is present in the state graph use it. Otherwise move instantly to it.
let transition = this._findTransition(this._activeStateName, newStateName);
if (!transition) {
this._animEvaluator.removeClips();
transition = new AnimTransition({
from: null,
to: newStateName
});
}
this.updateStateFromTransition(transition);
}
assignAnimation(pathString, animTrack, speed, loop) {
const path = pathString.split('.');
let state = this._findState(path[0]);
if (!state) {
state = new AnimState(this, path[0], speed);
this._states[path[0]] = state;
this._stateNames.push(path[0]);
}
state.addAnimation(path, animTrack);
this._animEvaluator.updateClipTrack(state.name, animTrack);
if (speed !== undefined) {
state.speed = speed;
}
if (loop !== undefined) {
state.loop = loop;
}
if (!this._playing && this._activate && this.playable) {
this.play();
}
// when a new animation is added, the active state duration needs to be recalculated
this._activeStateDurationDirty = true;
}
removeNodeAnimations(nodeName) {
if (ANIM_CONTROL_STATES.indexOf(nodeName) !== -1) {
return false;
}
const state = this._findState(nodeName);
if (!state) {
Debug.error('Attempting to unassign animation tracks from a state that does not exist.', nodeName);
return false;
}
state.animations = [];
return true;
}
play(stateName) {
if (stateName) {
this._transitionToState(stateName);
}
this._playing = true;
}
pause() {
this._playing = false;
}
reset() {
this._previousStateName = null;
this._activeStateName = ANIM_STATE_START;
this._playing = false;
this._currTransitionTime = 1.0;
this._totalTransitionTime = 1.0;
this._isTransitioning = false;
this._timeInState = 0;
this._timeInStateBefore = 0;
this._animEvaluator.removeClips();
}
rebind() {
this._animEvaluator.rebind();
}
update(dt) {
if (!this._playing) {
return;
}
let state;
let animation;
let clip;
// update time when looping or when the active state is not at the end of its duration
if (this.activeState.loop || this._timeInState < this.activeStateDuration) {
this._timeInStateBefore = this._timeInState;
this._timeInState += dt * this.activeState.speed;
// if the active state is not looping and the time in state is greater than the duration, set the time in state to the state duration
// and update the delta time accordingly
if (!this.activeState.loop && this._timeInState > this.activeStateDuration) {
this._timeInState = this.activeStateDuration;
dt = this.activeStateDuration - this._timeInStateBefore;
}
}
// transition between states if a transition is available from the active state
const transition = this._findTransition(this._activeStateName);
if (transition) {
this.updateStateFromTransition(transition);
}
if (this._isTransitioning) {
this._currTransitionTime += dt;
if (this._currTransitionTime <= this._totalTransitionTime) {
const interpolatedTime = this._totalTransitionTime !== 0 ? this._currTransitionTime / this._totalTransitionTime : 1;
// while transitioning, set all previous state animations to be weighted by (1.0 - interpolationTime).
for(let i = 0; i < this._transitionPreviousStates.length; i++){
state = this._findState(this._transitionPreviousStates[i].name);
const stateWeight = this._transitionPreviousStates[i].weight;
for(let j = 0; j < state.animations.length; j++){
animation = state.animations[j];
clip = this._animEvaluator.findClip(`${animation.name}.previous.${i}`);
if (clip) {
clip.blendWeight = (1.0 - interpolatedTime) * animation.normalizedWeight * stateWeight;
}
}
}
// while transitioning, set active state animations to be weighted by (interpolationTime).
state = this.activeState;
for(let i = 0; i < state.animations.length; i++){
animation = state.animations[i];
this._animEvaluator.findClip(animation.name).blendWeight = interpolatedTime * animation.normalizedWeight;
}
} else {
this._isTransitioning = false;
// when a transition ends, remove all previous state clips from the evaluator
const activeClips = this.activeStateAnimations.length;
const totalClips = this._animEvaluator.clips.length;
for(let i = 0; i < totalClips - activeClips; i++){
this._animEvaluator.removeClip(0);
}
this._transitionPreviousStates = [];
// when a transition ends, set the active state clip weights so they sum to 1
state = this.activeState;
for(let i = 0; i < state.animations.length; i++){
animation = state.animations[i];
clip = this._animEvaluator.findClip(animation.name);
if (clip) {
clip.blendWeight = animation.normalizedWeight;
}
}
}
} else {
if (this.activeState._blendTree.constructor !== AnimNode) {
state = this.activeState;
for(let i = 0; i < state.animations.length; i++){
animation = state.animations[i];
clip = this._animEvaluator.findClip(animation.name);
if (clip) {
clip.blendWeight = animation.normalizedWeight;
if (animation.parent.syncAnimations) {
clip.speed = animation.speed;
}
}
}
}
}
this._animEvaluator.update(dt, this.activeState.hasAnimations);
}
}
export { AnimController };