@esotericsoftware/spine-core
Version:
The official Spine Runtimes for the web.
1,005 lines • 182 kB
JavaScript
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
/** biome-ignore-all lint/style/noNonNullAssertion: reference runtime expects some nullable to not be null */
import { Animation, AttachmentTimeline, DrawOrderFolderTimeline, DrawOrderTimeline, EventTimeline, RotateTimeline, Timeline } from "./Animation.js";
import { Interpolation, MathUtils, Pool, StringSet, Utils } from "./Utils.js";
/** Applies animations over time, queues animations for later playback, mixes (crossfading) between animations, and applies
* multiple animations on top of each other (layering).
*
* See [Applying Animations](http://esotericsoftware.com/spine-applying-animations#AnimationState-API) in the Spine Runtimes Guide. */
export class AnimationState {
static emptyAnimation = new Animation("<empty>", [], 0);
/** The AnimationStateData to look up mix durations. */
data;
/** The list of tracks that have had animations. May contain null entries for tracks that currently have no animation. */
tracks = [];
/** Multiplier for the delta time when the animation state is updated, causing time for all animations and mixes to play slower
* or faster. Defaults to 1.
*
* See {@link TrackEntry.timeScale} to affect a single animation. */
timeScale = 1;
unkeyedState = 0;
events = [];
listeners = [];
queue = new EventQueue(this);
propertyIds = new StringSet();
animationsChanged = false;
trackEntryPool = new Pool(() => new TrackEntry());
constructor(data) {
this.data = data;
}
/** Increments each track entry {@link TrackEntry.trackTime}, setting queued animations as current if needed. */
update(delta) {
delta *= this.timeScale;
const tracks = this.tracks;
for (let i = 0, n = tracks.length; i < n; i++) {
const current = tracks[i];
if (!current)
continue;
current.animationLast = current.nextAnimationLast;
current.trackLast = current.nextTrackLast;
let currentDelta = delta * current.timeScale;
if (current.delay > 0) {
current.delay -= currentDelta;
if (current.delay > 0)
continue;
currentDelta = -current.delay;
current.delay = 0;
}
let next = current.next;
if (next) {
// When the next entry's delay is passed, change to the next entry, preserving leftover time.
const nextTime = current.trackLast - next.delay;
if (nextTime >= 0) {
next.delay = 0;
next.trackTime += current.timeScale === 0 ? 0 : (nextTime / current.timeScale + delta) * next.timeScale;
current.trackTime += currentDelta;
this.setTrack(i, next, true);
while (next.mixingFrom) {
next.mixTime += delta;
next = next.mixingFrom;
}
continue;
}
}
else if (current.trackLast >= current.trackEnd && !current.mixingFrom) {
tracks[i] = null;
this.queue.end(current);
this.clearNext(current);
continue;
}
if (current.mixingFrom && this.updateMixingFrom(current, delta)) {
// End mixing from entries once all have completed.
let from = current.mixingFrom;
current.mixingFrom = null;
if (from)
from.mixingTo = null;
while (from) {
this.queue.end(from);
from = from.mixingFrom;
}
}
current.trackTime += currentDelta;
}
this.queue.drain();
}
/** Returns true when all mixing from entries are complete. */
updateMixingFrom(to, delta) {
const from = to.mixingFrom;
if (!from)
return true;
const finished = this.updateMixingFrom(from, delta);
from.animationLast = from.nextAnimationLast;
from.trackLast = from.nextTrackLast;
// The from entry was applied at least once and the mix is complete.
if (to.nextTrackLast !== -1 && to.mixTime >= to.mixDuration) {
// Mixing is complete for all entries before the from entry or the mix is instantaneous.
if (from.totalAlpha === 0 || to.mixDuration === 0) {
to.mixingFrom = from.mixingFrom;
if (from.mixingFrom != null)
from.mixingFrom.mixingTo = to;
if (from.totalAlpha === 0) {
for (let next = to; next.mixingTo != null; next = next.mixingTo)
next.keepHold = true;
}
this.queue.end(from);
}
return finished;
}
from.trackTime += delta * from.timeScale;
to.mixTime += delta;
return false;
}
/** Poses the skeleton using the track entry animations. The animation state is not changed, so can be applied to multiple
* skeletons to pose them identically.
* @returns True if any animations were applied. */
apply(skeleton) {
if (!skeleton)
throw new Error("skeleton cannot be null.");
if (this.animationsChanged)
this._animationsChanged();
const events = this.events;
const tracks = this.tracks;
let applied = false;
for (let i = 0, n = tracks.length; i < n; i++) {
const current = tracks[i];
if (!current || current.delay > 0)
continue;
applied = true;
// Apply mixing from entries first.
let alpha = current.alpha;
if (current.mixingFrom)
alpha *= this.applyMixingFrom(current, skeleton);
else if (current.trackTime >= current.trackEnd && !current.next)
alpha = 0;
// Apply current entry.
let animationLast = current.animationLast, animationTime = current.getAnimationTime(), applyTime = animationTime;
let applyEvents = events;
if (current.reverse) {
applyTime = current.animation.duration - applyTime;
applyEvents = null;
}
const timelines = current.animation.timelines;
const timelineCount = timelines.length;
if ((i === 0 && alpha === 1)) {
for (let ii = 0; ii < timelineCount; ii++) {
// Fixes issue #302 on IOS9 where mix, blend sometimes became undefined and caused assets
// to sometimes stop rendering when using color correction, as their RGBA values become NaN.
// (https://github.com/pixijs/pixi-spine/issues/302)
Utils.webkit602BugfixHelper(alpha);
const timeline = timelines[ii];
if (timeline instanceof AttachmentTimeline)
this.applyAttachmentTimeline(timeline, skeleton, applyTime, true, true);
else
timeline.apply(skeleton, animationLast, applyTime, applyEvents, alpha, true, false, false, false);
}
}
else {
const timelineMode = current.timelineMode;
const retainAttachments = alpha >= current.alphaAttachmentThreshold;
const add = current.additive, shortestRotation = add || current.shortestRotation;
const firstFrame = !shortestRotation && current.timelinesRotation.length !== timelineCount << 1;
if (firstFrame)
current.timelinesRotation.length = timelineCount << 1;
for (let ii = 0; ii < timelineCount; ii++) {
const timeline = timelines[ii];
const fromSetup = (timelineMode[ii] & FIRST) !== 0;
if (!shortestRotation && timeline instanceof RotateTimeline) {
this.applyRotateTimeline(timeline, skeleton, applyTime, alpha, fromSetup, current.timelinesRotation, ii << 1, firstFrame);
}
else if (timeline instanceof AttachmentTimeline) {
this.applyAttachmentTimeline(timeline, skeleton, applyTime, fromSetup, retainAttachments);
}
else {
// This fixes the WebKit 602 specific issue described at https://esotericsoftware.com/forum/d/10109-ios-10-disappearing-graphics
Utils.webkit602BugfixHelper(alpha);
timeline.apply(skeleton, animationLast, applyTime, applyEvents, alpha, fromSetup, add, false, false);
}
}
}
if (current.reverse)
this.eventsReverse(current, animationLast, animationTime);
this.queueEvents(current, animationTime);
events.length = 0;
current.nextAnimationLast = animationTime;
current.nextTrackLast = current.trackTime;
}
// Set slot attachments to the setup pose if they were set temporarily to apply deform timelines.
const setupState = this.unkeyedState + SETUP;
const slots = skeleton.slots;
for (let i = 0, n = skeleton.slots.length; i < n; i++) {
const slot = slots[i];
if (slot.attachmentState === setupState) {
const attachmentName = slot.data.attachmentName;
slot.pose.setAttachment(!attachmentName ? null : skeleton.getAttachment(slot.data.index, attachmentName));
}
}
this.unkeyedState += 2; // Reset.
this.queue.drain();
return applied;
}
applyMixingFrom(to, skeleton) {
const from = to.mixingFrom;
const fromMix = from.mixingFrom !== null ? this.applyMixingFrom(from, skeleton) : 1;
const mix = to.mix();
const a = from.alpha * fromMix, keep = 1 - mix * to.alpha;
const alphaMix = a * (1 - mix), alphaHold = keep > 0 ? alphaMix / keep : a;
const timelines = from.animation.timelines;
const timelineCount = timelines.length;
const timelineMode = from.timelineMode;
const timelineHoldMix = from.timelineHoldMix;
const retainAttachments = mix < from.mixAttachmentThreshold, drawOrder = mix < from.mixDrawOrderThreshold;
const add = from.additive, shortestRotation = add || from.shortestRotation;
const firstFrame = !shortestRotation && from.timelinesRotation.length !== timelineCount << 1;
if (firstFrame)
from.timelinesRotation.length = timelineCount << 1;
const timelinesRotation = from.timelinesRotation;
let animationLast = from.animationLast, animationTime = from.getAnimationTime(), applyTime = animationTime;
let events = null;
if (from.reverse)
applyTime = from.animation.duration - applyTime;
else if (mix < from.eventThreshold) //
events = this.events;
from.totalAlpha = 0;
for (let i = 0; i < timelineCount; i++) {
const timeline = timelines[i];
const mode = timelineMode[i];
const fromSetup = (mode & FIRST) !== 0;
let alpha = 0;
if ((mode & HOLD) !== 0) {
const holdMix = timelineHoldMix[i];
alpha = holdMix == null ? alphaHold : alphaHold * (1 - holdMix.mix());
}
else {
if (!drawOrder && timeline instanceof DrawOrderTimeline && !fromSetup)
continue;
alpha = alphaMix;
}
from.totalAlpha += alpha;
if (!shortestRotation && timeline instanceof RotateTimeline) {
this.applyRotateTimeline(timeline, skeleton, applyTime, alpha, fromSetup, timelinesRotation, i << 1, firstFrame);
}
else if (timeline instanceof AttachmentTimeline)
this.applyAttachmentTimeline(timeline, skeleton, applyTime, fromSetup, retainAttachments && alpha >= from.alphaAttachmentThreshold);
else {
const out = !drawOrder || !(timeline instanceof DrawOrderTimeline) || !fromSetup;
timeline.apply(skeleton, animationLast, applyTime, events, alpha, fromSetup, add, out, false);
}
}
if (from.reverse && mix < from.eventThreshold)
this.eventsReverse(from, animationLast, animationTime);
if (to.mixDuration > 0)
this.queueEvents(from, animationTime);
this.events.length = 0;
from.nextAnimationLast = animationTime;
from.nextTrackLast = from.trackTime;
return mix;
}
/** Applies the attachment timeline and sets {@link Slot.attachmentState}.
* @param retain True if the attachment remains after apply, false if temporary for deform timelines. */
applyAttachmentTimeline(timeline, skeleton, time, fromSetup, retain) {
const slot = skeleton.slots[timeline.slotIndex];
if (!slot.bone.active)
return;
if (!retain && slot.attachmentState === this.unkeyedState + RETAIN)
return;
let setup = time < timeline.frames[0];
let name = null;
if (!setup) {
name = timeline.attachmentNames[Timeline.search(timeline.frames, time)];
setup = !retain && name == null;
}
if (setup) {
if (!fromSetup)
return;
name = slot.data.attachmentName;
}
slot.pose.setAttachment(name == null ? null : skeleton.getAttachment(slot.data.index, name));
if (retain)
slot.attachmentState = this.unkeyedState + RETAIN;
else if (!setup) //
slot.attachmentState = this.unkeyedState + SETUP;
}
/** Applies the rotate timeline, mixing with the current pose while keeping the same rotation direction chosen as the shortest
* the first time the mixing was applied. */
applyRotateTimeline(timeline, skeleton, time, alpha, fromSetup, timelinesRotation, i, firstFrame) {
if (firstFrame)
timelinesRotation[i] = 0;
if (alpha === 1) {
timeline.apply(skeleton, 0, time, null, 1, fromSetup, false, false, false);
return;
}
const bone = skeleton.bones[timeline.boneIndex];
if (!bone.active)
return;
const pose = bone.pose, setup = bone.data.setupPose;
const frames = timeline.frames;
if (time < frames[0]) { // Time is before first frame.
if (fromSetup)
pose.rotation = setup.rotation;
return;
}
const r1 = fromSetup ? setup.rotation : pose.rotation;
const r2 = setup.rotation + timeline.getCurveValue(time);
// Mix between rotations using the direction of the shortest route on the first frame while detecting crosses.
let total = 0, diff = r2 - r1;
diff -= Math.ceil(diff / 360 - 0.5) * 360;
if (diff === 0) {
total = timelinesRotation[i];
}
else {
let lastTotal = 0, lastDiff = 0;
if (firstFrame) {
lastTotal = 0;
lastDiff = diff;
}
else {
lastTotal = timelinesRotation[i];
lastDiff = timelinesRotation[i + 1];
}
const loops = lastTotal - lastTotal % 360;
total = diff + loops;
let current = diff >= 0, dir = lastTotal >= 0;
if (Math.abs(lastDiff) <= 90 && MathUtils.signum(lastDiff) !== MathUtils.signum(diff)) {
if (Math.abs(lastTotal - loops) > 180) {
total += 360 * MathUtils.signum(lastTotal);
dir = current;
}
else if (loops !== 0)
total -= 360 * MathUtils.signum(lastTotal);
else
dir = current;
}
if (dir !== current)
total += 360 * MathUtils.signum(lastTotal);
timelinesRotation[i] = total;
}
timelinesRotation[i + 1] = diff;
pose.rotation = r1 + total * alpha;
}
queueEvents(entry, animationTime) {
const animationStart = entry.animationStart, animationEnd = entry.animationEnd, duration = animationEnd - animationStart;
const reverse = entry.reverse;
let split = entry.trackLast % duration;
if (reverse)
split = duration - split;
// Queue events before complete.
const events = this.events;
let i = 0, n = events.length;
for (; i < n; i++) {
const event = events[i];
if ((event.time < split) !== reverse)
break; // java: if (event.time < split ^ reverse) break;
if (event.time >= animationStart && event.time <= animationEnd)
this.queue.event(entry, event);
}
// Queue complete if completed a loop iteration or the animation.
let complete = false;
if (entry.loop) {
if (duration === 0)
complete = true;
else {
const cycles = Math.floor(entry.trackTime / duration);
complete = cycles > 0 && cycles > Math.floor(entry.trackLast / duration);
}
}
else
complete = animationTime >= animationEnd && entry.animationLast < animationEnd;
if (complete)
this.queue.complete(entry);
// Queue events after complete.
for (; i < n; i++) {
const event = events[i];
if (event.time >= animationStart && event.time <= animationEnd)
this.queue.event(entry, event);
}
}
eventsReverse(entry, animationLast, animationTime) {
const duration = entry.animation.duration, from = duration - animationLast, to = duration - animationTime;
const timelines = entry.animation.timelines;
for (let i = 0, n = entry.animation.timelines.length; i < n; i++) {
const eventTimeline = timelines[i];
if (!(eventTimeline instanceof EventTimeline))
continue;
const timelineEvents = eventTimeline.events;
const frames = eventTimeline.frames;
const frameCount = frames.length;
if (from >= to) { // from -> to
for (let ii = 0; ii < frameCount; ii++) {
if (frames[ii] < to)
continue;
if (frames[ii] >= from)
break;
this.events.push(timelineEvents[ii]);
}
}
else {
for (let ii = 0; ii < frameCount; ii++) { // from -> 0
if (frames[ii] >= from)
break;
this.events.push(timelineEvents[ii]);
}
let ii = 0; // end -> to
for (; ii < frameCount; ii++)
if (frames[ii] >= to)
break;
for (; ii < frameCount; ii++)
this.events.push(timelineEvents[ii]);
}
}
}
/** Removes all animations from all tracks, leaving skeletons in their current pose.
*
* Usually you want to use {@link setEmptyAnimations} to mix the skeletons back to the setup pose, rather than leaving
* them in their current pose. */
clearTracks() {
const oldDrainDisabled = this.queue.drainDisabled;
this.queue.drainDisabled = true;
for (let i = 0, n = this.tracks.length; i < n; i++)
this.clearTrack(i);
this.tracks.length = 0;
this.queue.drainDisabled = oldDrainDisabled;
this.queue.drain();
}
/** Removes all animations from the track, leaving skeletons in their current pose.
*
* Usually you want to use {@link setEmptyAnimation} to mix the skeletons back to the setup pose, rather than
* leaving them in their current pose. */
clearTrack(trackIndex) {
if (trackIndex < 0)
throw new Error("trackIndex must be >= 0.");
if (trackIndex >= this.tracks.length)
return;
const current = this.tracks[trackIndex];
if (!current)
return;
this.queue.end(current);
this.clearNext(current);
let entry = current;
while (true) {
const from = entry.mixingFrom;
if (!from)
break;
this.queue.end(from);
entry.mixingFrom = null;
entry.mixingTo = null;
entry = from;
}
this.tracks[current.trackIndex] = null;
this.queue.drain();
}
setTrack(index, current, interrupt) {
const from = this.expandToIndex(index);
this.tracks[index] = current;
current.previous = null;
if (from) {
from.next = null;
if (interrupt)
this.queue.interrupt(from);
current.mixingFrom = from;
from.mixingTo = current;
current.mixTime = 0;
from.timelinesRotation.length = 0; // Reset rotation for mixing out, in case entry was mixed in.
}
this.queue.start(current);
}
setAnimation(trackIndex, animationNameOrAnimation, loop = false) {
if (typeof animationNameOrAnimation === "string")
return this.setAnimation1(trackIndex, animationNameOrAnimation, loop);
return this.setAnimation2(trackIndex, animationNameOrAnimation, loop);
}
setAnimation1(trackIndex, animationName, loop = false) {
const animation = this.data.skeletonData.findAnimation(animationName);
if (!animation)
throw new Error(`Animation not found: ${animationName}`);
return this.setAnimation2(trackIndex, animation, loop);
}
/** Sets the current animation for a track, discarding any queued animations.
*
* If the formerly current track entry is for the same animation and was never applied to a skeleton, it is replaced (not mixed
* from).
* @param loop If true, the animation will repeat. If false it will not, instead its last frame is applied if played beyond its
* duration. In either case {@link TrackEntry.getTrackEnd} determines when the track is cleared.
* @return A track entry to allow further customization of animation playback. References to the track entry must not be kept
* after the {@link AnimationStateListener.dispose} event occurs. */
setAnimation2(trackIndex, animation, loop = false) {
if (trackIndex < 0)
throw new Error("trackIndex must be >= 0.");
if (!animation)
throw new Error("animation cannot be null.");
let interrupt = true;
let current = this.expandToIndex(trackIndex);
if (current) {
if (current.nextTrackLast === -1 && current.animation === animation) {
// Don't mix from an entry that was never applied.
this.tracks[trackIndex] = current.mixingFrom;
this.queue.interrupt(current);
this.queue.end(current);
this.clearNext(current);
current = current.mixingFrom;
interrupt = false;
}
else
this.clearNext(current);
}
const entry = this.trackEntry(trackIndex, animation, loop, current);
this.setTrack(trackIndex, entry, interrupt);
this.queue.drain();
return entry;
}
addAnimation(trackIndex, animationNameOrAnimation, loop = false, delay = 0) {
if (typeof animationNameOrAnimation === "string")
return this.addAnimation1(trackIndex, animationNameOrAnimation, loop, delay);
return this.addAnimation2(trackIndex, animationNameOrAnimation, loop, delay);
}
addAnimation1(trackIndex, animationName, loop = false, delay = 0) {
const animation = this.data.skeletonData.findAnimation(animationName);
if (!animation)
throw new Error(`Animation not found: ${animationName}`);
return this.addAnimation2(trackIndex, animation, loop, delay);
}
addAnimation2(trackIndex, animation, loop = false, delay = 0) {
if (trackIndex < 0)
throw new Error("trackIndex must be >= 0.");
if (!animation)
throw new Error("animation cannot be null.");
let last = this.expandToIndex(trackIndex);
if (last) {
while (last.next)
last = last.next;
}
const entry = this.trackEntry(trackIndex, animation, loop, last);
if (!last) {
this.setTrack(trackIndex, entry, true);
this.queue.drain();
if (delay < 0)
delay = 0;
}
else {
last.next = entry;
entry.previous = last;
if (delay <= 0)
delay = Math.max(delay + last.getTrackComplete() - entry.mixDuration, 0);
}
entry.delay = delay;
return entry;
}
/** Sets an empty animation for a track, discarding any queued animations, and sets the track entry's
* {@link TrackEntry.mixduration}. An empty animation has no timelines and serves as a placeholder for mixing in or out.
*
* Mixing out is done by setting an empty animation with a mix duration using either {@link setEmptyAnimation},
* {@link setEmptyAnimations}, or {@link addEmptyAnimation}. Mixing to an empty animation causes
* the previous animation to be applied less and less over the mix duration. Properties keyed in the previous animation
* transition to the value from lower tracks or to the setup pose value if no lower tracks key the property. A mix duration of
* 0 still needs to be applied one more time to mix out, so the properties it was animating are reverted.
*
* Mixing in is done by first setting an empty animation, then adding an animation using
* {@link addAnimation} with the desired delay (an empty animation has a duration of 0) and on
* the returned track entry, set the {@link TrackEntry.setMixDuration}. Mixing from an empty animation causes the new
* animation to be applied more and more over the mix duration. Properties keyed in the new animation transition from the value
* from lower tracks or from the setup pose value if no lower tracks key the property to the value keyed in the new animation.
*
* See <a href='https://esotericsoftware.com/spine-applying-animations#Empty-animations'>Empty animations</a> in the Spine
* Runtimes Guide. */
setEmptyAnimation(trackIndex, mixDuration = 0) {
const entry = this.setAnimation(trackIndex, AnimationState.emptyAnimation, false);
entry.mixDuration = mixDuration;
entry.trackEnd = mixDuration;
return entry;
}
/** Adds an empty animation to be played after the current or last queued animation for a track, and sets the track entry's
* {@link TrackEntry.mixDuration}. If the track has no entries, it is equivalent to calling
* {@link setEmptyAnimation}.
*
* See {@link setEmptyAnimation} and
* <a href='https://esotericsoftware.com/spine-applying-animations#Empty-animations'>Empty animations</a> in the Spine Runtimes
* Guide.
* @param delay If > 0, sets {@link TrackEntry.delay}. If <= 0, the delay set is the duration of the previous track entry minus
* any mix duration plus the specified `delay` (ie the mix ends at (when `delay` = 0) or before
* (when `delay` < 0) the previous track entry duration). If the previous entry is looping, its next loop
* completion is used instead of its duration.
* @return A track entry to allow further customization of animation playback. References to the track entry must not be kept
* after the {@link AnimationStateListener.dispose} event occurs. */
addEmptyAnimation(trackIndex, mixDuration = 0, delay = 0) {
const entry = this.addAnimation(trackIndex, AnimationState.emptyAnimation, false, delay);
if (delay <= 0)
entry.delay = Math.max(entry.delay + entry.mixDuration - mixDuration, 0);
entry.mixDuration = mixDuration;
entry.trackEnd = mixDuration;
return entry;
}
/** Sets an empty animation for every track, discarding any queued animations, and mixes to it over the specified mix duration.
*
* See <a href='https://esotericsoftware.com/spine-applying-animations#Empty-animations'>Empty animations</a> in the Spine
* Runtimes Guide. */
setEmptyAnimations(mixDuration = 0) {
const oldDrainDisabled = this.queue.drainDisabled;
this.queue.drainDisabled = true;
for (let i = 0, n = this.tracks.length; i < n; i++) {
const current = this.tracks[i];
if (current)
this.setEmptyAnimation(current.trackIndex, mixDuration);
}
this.queue.drainDisabled = oldDrainDisabled;
this.queue.drain();
}
expandToIndex(index) {
if (index < this.tracks.length)
return this.tracks[index];
Utils.ensureArrayCapacity(this.tracks, index + 1, null);
this.tracks.length = index + 1;
return null;
}
/** @param last May be null. */
trackEntry(trackIndex, animation, loop, last) {
const entry = this.trackEntryPool.obtain();
entry.reset();
entry.trackIndex = trackIndex;
entry.animation = animation;
entry.loop = loop;
entry.additive = false;
entry.reverse = false;
entry.shortestRotation = false;
entry.eventThreshold = 0;
entry.alphaAttachmentThreshold = 0;
entry.mixAttachmentThreshold = 0;
entry.mixDrawOrderThreshold = 0;
entry.animationStart = 0;
entry.animationEnd = animation.duration;
entry.animationLast = -1;
entry.nextAnimationLast = -1;
entry.delay = 0;
entry.trackTime = 0;
entry.trackLast = -1;
entry.nextTrackLast = -1;
entry.trackEnd = Number.MAX_VALUE;
entry.timeScale = 1;
entry.alpha = 1;
entry.mixTime = 0;
entry.mixDuration = !last ? 0 : this.data.getMix(last.animation, animation);
entry.totalAlpha = 0;
entry.keepHold = false;
return entry;
}
/** Removes {@link TrackEntry.next} and all entries after it for the specified entry. */
clearNext(entry) {
let next = entry.next;
while (next) {
this.queue.dispose(next);
next = next.next;
}
entry.next = null;
}
_animationsChanged() {
this.animationsChanged = false;
const tracks = this.tracks;
for (let i = 0, n = tracks.length; i < n; i++) {
let entry = tracks[i];
if (!entry)
continue;
while (entry.mixingFrom)
entry = entry.mixingFrom;
do {
this.computeHold(entry);
entry = entry.mixingTo;
} while (entry);
}
this.propertyIds.clear();
}
computeHold(entry) {
const timelines = entry.animation.timelines;
const timelinesCount = entry.animation.timelines.length;
const timelineMode = entry.timelineMode;
timelineMode.length = timelinesCount;
const timelineHoldMix = entry.timelineHoldMix;
timelineHoldMix.length = 0;
const propertyIds = this.propertyIds;
const add = entry.additive, keepHold = entry.keepHold;
const to = entry.mixingTo;
outer: for (let i = 0; i < timelinesCount; i++) {
const timeline = timelines[i];
const ids = timeline.propertyIds;
const first = propertyIds.addAll(ids)
&& !(timeline instanceof DrawOrderFolderTimeline && propertyIds.contains(DrawOrderTimeline.propertyID));
if (add && timeline.additive) {
timelineMode[i] = first ? FIRST : SUBSEQUENT;
continue;
}
for (let from = entry.mixingFrom; from != null; from = from.mixingFrom) {
if (from.animation.hasTimeline(ids)) {
// An earlier entry on this track keys this property, isolating it from lower tracks.
timelineMode[i] = SUBSEQUENT;
continue outer;
}
}
// Hold if the next entry will overwrite this property.
let mode;
if (to === null || timeline.instant || (to.additive && timeline.additive) || !to.animation?.hasTimeline(ids))
mode = first ? FIRST : SUBSEQUENT;
else {
mode = first ? HOLD_FIRST : HOLD;
// Find next entry that doesn't overwrite this property. Its mix fades out the hold, instead of it ending abruptly.
for (let next = to.mixingTo; next != null; next = next.mixingTo) {
if ((next.additive && timeline.additive) || !next.animation?.hasTimeline(ids)) {
if (next.mixDuration > 0)
timelineHoldMix[i] = next;
break;
}
}
}
if (keepHold)
mode = (mode & ~HOLD) | (timelineMode[i] & HOLD);
timelineMode[i] = mode;
}
}
/** Returns the track entry for the animation currently playing on the track, or null if no animation is currently playing. */
getTrack(trackIndex) {
if (trackIndex < 0)
throw new Error("trackIndex must be >= 0.");
if (trackIndex >= this.tracks.length)
return null;
return this.tracks[trackIndex];
}
/** Adds a listener to receive events for all track entries. */
addListener(listener) {
if (!listener)
throw new Error("listener cannot be null.");
this.listeners.push(listener);
}
/** Removes the listener added with {@link addListener}. */
removeListener(listener) {
const index = this.listeners.indexOf(listener);
if (index >= 0)
this.listeners.splice(index, 1);
}
/** Removes all listeners added with {@link addListener}. */
clearListeners() {
this.listeners.length = 0;
}
/** Discards all listener notifications that have not yet been delivered. This can be useful to call from an
* {@link AnimationStateListener} when it is known that further notifications that may have been already queued for delivery
* are not wanted because new animations are being set. */
clearListenerNotifications() {
this.queue.clear();
}
}
/** Stores settings and other state for the playback of an animation on an {@link AnimationState} track.
*
* References to a track entry must not be kept after the {@link AnimationStateListener.dispose} event occurs. */
export class TrackEntry {
/** The animation to apply for this track entry. */
animation = null;
previous = null;
/** The animation queued to start after this animation, or null. `next` makes up a linked list. */
next = null;
/** The track entry for the previous animation when mixing to this animation, or null if no mixing is currently occurring.
* When mixing from multiple animations, `mixingFrom` makes up a doubly linked list. */
mixingFrom = null;
/** The track entry for the next animation when mixing from this animation, or null if no mixing is currently occurring.
* When mixing to multiple animations, `mixingTo` makes up a doubly linked list. */
mixingTo = null;
/** The listener for events generated by this track entry, or null.
*
* A track entry returned from {@link AnimationState.setAnimation} is already the current animation
* for the track, so the callback for listener {@link AnimationStateListener.start} will not be called. */
listener = null;
/** The index of the track where this track entry is either current or queued.
*
* See {@link AnimationState.getTrack}. */
trackIndex = 0;
/** If true, the animation will repeat. If false it will not, instead its last frame is applied if played beyond its
* duration. */
loop = false;
/** When true, timelines in this animation that support additive have their values added to the setup or current pose values
* instead of replacing them. Additive can be set for a new track entry only before {@link AnimationState.apply}
* is next called. */
additive = false;
/** If true, the animation will be applied in reverse. */
reverse = false;
/** If true, mixing rotation between tracks always uses the shortest rotation direction. If the rotation is animated, the
* shortest rotation direction may change during the mix.
*
* If false, the shortest rotation direction is remembered when the mix starts and the same direction is used for the rest
* of the mix. Defaults to false.
*
* See {@link resetRotationDirections}. */
shortestRotation = false;
keepHold = false;
/** When the interpolated mix percentage is less than the `eventThreshold` , event timelines are applied while
* this animation is being mixed out. Defaults to 0, so event timelines are not applied while this animation is being mixed
* out. */
eventThreshold = 0;
/** When the interpolated mix percentage is less than the `mixAttachmentThreshold`, attachment timelines are
* applied while this animation is being mixed out. Defaults to 0, so attachment timelines are not applied while this
* animation is being mixed out. */
mixAttachmentThreshold = 0;
/** When the computed alpha is greater than `alphaAttachmentThreshold`, attachment timelines are applied. The
* computed alpha includes {@link alpha} and the interpolated mix percentage. Defaults to 0, so attachment timelines are
* always applied. */
alphaAttachmentThreshold = 0;
/** When the interpolated mix percentage is less than the `mixAttachmentThreshold`, attachment timelines are
* applied while this animation is being mixed out. Defaults to 0, so attachment timelines are not applied while this
* animation is being mixed out. */
mixDrawOrderThreshold = 0;
/** The time in seconds for the first frame of this animation, both initially and after looping. Defaults to 0.
*
* When setting `animationStart` time, {@link animationLast} can be set to the same value to avoid firing events
* from the start of the animation. */
animationStart = 0;
/** The time in seconds for the last frame of this animation. Past this time, non-looping animations hold the pose at this
* time while looping animations will loop back to {@link animationStart}. Defaults to the {@link Animation.duration}. */
animationEnd = 0;
/** The time in seconds this animation was last applied. Some timelines use this for one-time triggers. For example, when
* this animation is applied, event timelines will fire all events between the `animationLast` time (exclusive)
* and `animationTime` (inclusive). Defaults to -1 to ensure triggers on frame 0 happen the first time this
* animation is applied. */
animationLast = 0;
nextAnimationLast = 0;
/** Seconds to postpone playing the animation. Must be >= 0. When this track entry is the current track entry,
* `delay` postpones incrementing the {@link trackTime}. When this track entry is queued, `delay` is
* the time from the start of the previous animation to when this track entry will become the current track entry (ie when
* the previous track entry {@link trackTime} >= this track entry's `delay`).
*
* {@link timeScale} affects the delay.
*
* When passing `delay` <= 0 to {@link AnimationState.addAnimation} this
* `delay` is set using a mix duration from {@link AnimationStateData}. To change the {@link mixDuration}
* afterward, use {@link setMixDuration} so this `delay` is adjusted. */
delay = 0;
/** The time in seconds this track entry has been the current track entry, starting at 0 and increasing forever. Compare to
* {@link getAnimationTime}, which is always between {@link animationStart} and {@link animationEnd}.
*
* The track time can be set to start the animation at a time other than 0, without affecting looping. When doing so,
* {@link animationLast} can be set to the same value to avoid firing events from the start of the animation.
*
* To set the time an animation starts and loops, use {@link animationStart} and {@link animationEnd}. */
trackTime = 0;
trackLast = 0;
nextTrackLast = 0;
/** The track time in seconds when this animation will be removed from the track. Defaults to the highest possible float
* value, meaning the animation will be applied until a new animation is set or the track is cleared. If the track end time
* is reached, no other animations are queued for playback, and mixing from any previous animations is complete, then the
* properties keyed by the animation are set to the setup pose and the track is cleared.
*
* Usually you want to use {@link AnimationState.addEmptyAnimation} rather than have the animation
* abruptly cease being applied, leaving the current pose. */
trackEnd = 0;
/** Multiplier for the delta time when this track entry is updated, causing time for this animation to pass slower or
* faster. Defaults to 1.
*
* Values < 0 are not supported. To play an animation in reverse, use {@link reverse}.
*
* {@link mixTime} is not affected by track entry time scale, so {@link mixDuration} may need to be adjusted to match the
* animation speed.
*
* When using {@link AnimationState.addAnimation} with a `delay` <= 0, the
* {@link delay} is set using the mix duration from {@link AnimationState.data}, assuming time scale to be 1. If the time
* scale is not 1, the delay may need to be adjusted.
*
* See {@link AnimationState.timeScale} to affect all animations. */
timeScale = 0;
/** Values < 1 mix this animation with the skeleton's current pose (either the setup pose or the pose from lower tracks).
* Defaults to 1, which overwrites the skeleton's current pose with this animation.
*
* Alpha should be 1 on track 0.
*
* See {@link getAlphaAttachmentThreshold}. */
alpha = 0;
/** Seconds elapsed from 0 to the {@link mixDuration} when mixing from the previous animation to this animation. May
* be slightly more than `mixDuration` when the mix is complete. */
mixTime = 0;
/** Seconds for mixing from the previous animation to this animation. Defaults to the value provided by
* {@link AnimationStateData.getMix} based on the animation before this animation (if any).
*
* A mix duration of 0 still needs to be applied one more time to mix out, so the the properties it was animating are
* reverted. A mix duration of 0 can be set at any time to end the mix on the next
* {@link AnimationState.update | update}.
*
* The `mixDuration` can be set manually rather than use the value from
* {@link AnimationStateData.getMix}. In that case, the `mixDuration` can be set for a new
* track entry only before {@link AnimationState.update} is next called.
*
* When using {@link AnimationState.addAnimation} with a `delay` <= 0, the
* {@link getDelay} is set using the mix duration from {@link AnimationState.data}. If `mixDuration` is set
* afterward, the delay needs to be adjusted:
*
* <pre>
* entry.mixDuration = 0.25;<br>
* entry.delay = entry.previous.getTrackComplete() - entry.mixDuration + 0;
* </pre>
*
* Alternatively, use {@link setMixDuration} to set both the mix duration and recompute the delay:<br>
*
* <pre>
entry.setMixDuration(0.25f, 0); // mixDuration, delay
* </pre>
*/
mixDuration = 0;
totalAlpha = 0;
mixInterpolation = Interpolation.linear;
/** Sets both {@link getMixDuration} and {@link getDelay}.
* @param delay If > 0, sets {@link getDelay}. If <= 0, the delay set is the duration of the previous track entry minus
* the specified mix duration plus the specified `delay` (ie the mix ends at (when `delay` =
* 0) or before (when `delay` < 0) the previous track entry duration). If the previous entry is
* looping, its next loop completion is used instead of its duration. */
setMixDuration(mixDuration, delay) {
this.mixDuration = mixDuration;
if (delay !== undefined) {
if (delay <= 0)
delay = this.previous == null ? 0 : Math.max(delay + this.previous.getTrackComplete() - mixDuration, 0);
this.delay = delay;
}
}
/** The interpolation to apply to the mix percentage ({@link mixTime} / {@link mixDuration}) when mixing from the previous
* animation to this animation. Defaults to linear. */
setMixInterpolation(mixInterpolation) {
if (!mixInterpolation)
throw new Error("mixInterpolation cannot be null.");
this.mixInterpolation = mixInterpolation;
}
mix() {
if (this.mixDuration === 0)
return 1;
let mix = this.mixTime / this.mixDuration;
if (mix >= 1)
return 1;
if (this.mixInterpolation === Interpolation.linear)
return mix;
mix = this.mixInterpolation.apply(mix);
if (mix < 0)
return 0;
if (mix > 1)
return 1;
return mix;
}
/** For each timeline:
* - Bit 0, FIRST: 0 = mix from current pose, 1 = mix from setup pose. Timeline is first to set the property.
* - Bit 1, HOLD: 0 = mix out using alphaMix, 1 = apply full alpha to prevent dipping. Timeline is first on its track to
* set the property and the next entry (mixingTo) also sets it. When held, timelineHoldMix's mix controls how the hold fades
* out (for 3+ entry chains where the chain eventually stops setting the property). */
timelineMode = [];
timelineHoldMix = [];
timelinesRotation = [];
reset() {
this.next = null;
this.previous = null;
this.mixingFrom = null;
this.mixingTo = null;
this.mixInterpolation = Interpolation.linear;
this.animation = null;
this.listener = null;
this.timelineMode.length = 0;
this.timelineHoldMix.length = 0;
this.timelinesRotation.length = 0;
}
/** Uses {@link trackTime} to compute the `animationTime`, which is always between {@link animationStart} and
* {@link animationEnd}. When `trackTime` is 0, `animationTime` is equal to the
* `animationStart` time. */
getAnimationTime() {
if (!this.loop)
return Math.min(this.trackTime + this.animationStart, this.animationEnd);
const duration = this.animationEnd - this.animationStart;
if (duration === 0)
return this.animationStart;
return (this.trackTime % duration) + this.animationStart;
}
setAnimationLast(animationLast) {
this.animationLast = animationLast;
this.nextAnimationLast = animationLast;
}
/** Returns true if at least one loop has been completed.
*
* See {@link AnimationStateListener.complete}. */
isComplete() {
return this.trackTime >= this.animationEnd - this.animationStart;
}
/** When {@link shortestRotation} is false, this clears the directions for mixing this entry's rotation. This can be useful
* to avoid bones rotating the long way around when using {@link getAlpha} and starting animations on other tracks.
*
* Mixing involves finding a rotation between