Add animations, animations-bound and default-mix attributes.

This commit is contained in:
Davide Tantillo 2025-01-16 11:41:40 +01:00
parent a636ef0964
commit a46e76b1d7
2 changed files with 487 additions and 15 deletions

View File

@ -633,6 +633,307 @@
/////////////////////
-->
<!--
/////////////////////
// start section //
/////////////////////
-->
<div class="section vertical-split">
<div class="split-top split">
<div class="split-left">
<p>To change animation, we could also just change the animation attribute. The widget will reinitiate itself and change animation.</p>
<p>In this case you would use <code>auto-recalculate-bounds</code> to ask the widget to always recalculate the bounds, as in the top example.</p>
<p>If want to keep the scale consistent, but fit multiple animations in the container, you can use the <code>animation-bounds</code> attribute to define a bounds containing a list of animations, as in the bottom example.</p>
</div>
<div class="split-right" style="display: flex; flex-direction: column;">
<spine-widget
style="width: 100%; flex: 1; border: 1px solid black; box-sizing: border-box;"
identifier="spineboy-change-animation"
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="jump"
auto-recalculate-bounds
></spine-widget>
<spine-widget
style="width: 100%; flex: 1; border: 1px solid black; box-sizing: border-box;"
identifier="spineboy-change-animation2"
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="jump"
animation-bounds="jump,death"
></spine-widget>
</div>
</div>
<script>
(async () => {
{
const widget = await spine.getSpineWidget("spineboy-change-animation").loadingPromise;
let toogleAnimation = false;
setInterval(() => {
const newAnimation = toogleAnimation ? "jump" : "death";
widget.setAttribute("animation", newAnimation)
toogleAnimation = !toogleAnimation;
}, 4000);
}
{
const widget = await spine.getSpineWidget("spineboy-change-animation2").loadingPromise;
let toogleAnimation = false;
setInterval(() => {
const newAnimation = toogleAnimation ? "jump" : "death";
widget.setAttribute("animation", newAnimation)
toogleAnimation = !toogleAnimation;
}, 4000);
}
})();
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
// access the spine widget
<spine-widget
style="width: 100%; height: 150px; border: 1px solid black;"
identifier="spineboy-change-animation"
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="jump"
auto-recalculate-bounds
></spine-widget>
<spine-widget
style="width: 100%; height: 150px; border: 1px solid black;"
identifier="spineboy-change-animation2"
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="jump"
animation-bounds="jump,death"
></spine-widget>
...
// using js, access the skeleton and the state asynchronously
{
const widget = document.querySelector('[identifier="spineboy-change-animation"]');
let toogleAnimation = false;
setInterval(() => {
const newAnimation = toogleAnimation ? "jump" : "death";
widget.setAttribute("animation", newAnimation)
toogleAnimation = !toogleAnimation;
}, 4000);
}
{
const widget = document.querySelector('[identifier="spineboy-change-animation2"]');
let toogleAnimation = false;
setInterval(() => {
const newAnimation = toogleAnimation ? "jump" : "death";
widget.setAttribute("animation", newAnimation)
toogleAnimation = !toogleAnimation;
}, 4000);
}
`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section //
/////////////////////
-->
<!--
/////////////////////
// start section //
/////////////////////
-->
<div class="section vertical-split">
<div class="split" style="flex-direction: column;">
<div class="split-nosize full-width" style="width: 80%; padding: 1em;">
<p>If you want to display a sequence of animations without using js or on multiple tracks, you can use the <code>animations</code> attribute.</p>
<p>It accepts a string composed of groups surrounded by square brackets, like this: <code>[...][...][...]</code></p>
<p>Each square bracket represents an animation to play, with some parameters. It contains a comma separated list with the following:
<ol>
<li><code>track</code>: the number of the track on which to play the animation</li>
<li><code>animation name</code>: the name of the animation to play</li>
<li><code>loop</code>: true, if this animation has to loop. False, otherwise</li>
<li><code>delay</code>: the seconds to wait after the start of the previous animation, to play the animatino of this group (not available for the first animation on this track)</li>
<li><code>mixDuration</code>: the mix duration between this animation and the previous (not available for the first animation on this track)</li>
</ol>
</p>
<p>Once you composed your animation, if you that is loops once it reaches the end, you can add the special group <code>[loop, trackNumber]</code>, where:
<ul>
<li><code>loop</code>: is the "loop" string to identify this special group</li>
<li><code>trackNumber</code>: is the number of the track you want to be looped</li>
</ul>
</p>
<p>
The parameters of the first group of each track are passed to the `setAnimation` method, while the remaining groups on the track use `addAnimation`.
</p>
<p>
You can use respectively use `setEmptyAnimation` or `addEmptyAnimation`, by using the string <code>#EMPTY#</code> as animation name. In this case the <code>loop</code> parameter is ignored.
</p>
<p>
The <code>default-mix</code> attribute allow to the the default mix of the <code>AnimationState</code>.
</p>
<p>Have a look at the two examples below.</p>
</div>
<div class="split-nosize full-width" style="width: 80%; min-height: 300px; margin: 1em; padding: 1em;">
<spine-widget
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation-bounds="jump,death"
default-mix="0.05"
animations="
[loop, 0]
[0, idle, true]
[0, run, false, 2, 0.25]
[0, run, false]
[0, run, false]
[0, run-to-idle, false, 0, 0.15]
[0, idle, true]
[0, jump, false, 0, 0.15]
[0, walk, false, 0, 0.05]
[0, death, false, 0, 0.05]
"
></spine-widget>
</div>
<div class="split-nosize full-width" style="width: 80%; padding: 1em;">
<p>Spineboy here uses the following value for <code>animations</code> attribute.</p>
<p>
<textarea style="font-size: 0.6rem; width: 100%;" rows="10" readonly>
[loop, 0]
[0, idle, true]
[0, run, false, 2, 0.25]
[0, run, false]
[0, run, false]
[0, run-to-idle, false, 0, 0.15]
[0, idle, true]
[0, jump, false, 0, 0.15]
[0, walk, false, 0, 0.05]
[0, death, false, 0, 0.05]</textarea>
</p>
We use a single track for this animation. Let's analyze it:
<ol>
<li><code>[loop, 0]</code>: when the track 0 reaches the end, start from the beginning</li>
<li><code>[0, idle, true]</code>: set the idle animation in loop</li>
<li><code>[0, run, true, 2, 0.25]</code>: queue a cycle of the run animation, start it after 2 seconds from the beginning of the previous one, set a mix of 0.25 seconds from the previous one.</li>
<li><code>[0, run, false]</code>: queue a cycle of run animation</li>
<li><code>[0, run, false]</code>: queue a cycle of run animation</li>
<li><code>[0, run-to-idle, false, 0, 0.15]</code>: queue a cycle of run-to-idle animation, with no delay, and a mix of 0.15 seconds</li>
<li><code>[0, idle, true]</code>: queue the idle animation in loop</li>
<li><code>[0, jump, false, 0, 0.15]</code>: queue a cycle of jump animation in loop, with no delay, and a mix of 0.15 seconds</li>
<li><code>[0, walk, false, 0, 0.05]</code>: queue a cycle of walk animation in loop, with no delay, and a mix of 0.05 seconds</li>
<li><code>[0, death, false, 0, 0.05]</code>: queue a cycle of death animation in loop, with no delay, and a mix of 0.05 seconds</li>
</ol>
</div>
<div class="split-nosize full-width" style="width: 80%; min-height: 300px; margin: 1em; padding: 1em;">
<spine-widget
identifier="celeste-animations"
atlas="assets/celestial-circus-pma.atlas"
skeleton="assets/celestial-circus-pro.skel"
animations="
[0, wings-and-feet, true]
[loop, 1]
[1, #EMPTY#, false]
[1, eyeblink, false, 2]
"
></spine-widget>
</div>
<div class="split-nosize full-width" style="width: 80%; padding: 1em;">
<p>Celeste here uses the following value for <code>animations</code> attribute.</p>
<p>
<textarea id="celeste-animations-text-area" style="font-size: 0.6rem; width: 100%;" rows="5">
[0, wings-and-feet, true]
[loop, 1]
[1, #EMPTY#, false]
[1, eyeblink, false, 2]</textarea>
</p>
<p>
It uses two tracks. In track 0 we simply set the wings-and-feet animation. <br>
In track 1 we loop over the entire animation, set an empty animation and queue an eyeblink animation with a 2 seconds delay.
</p>
<p>You can modify the textarea above and experiment with the values. For example, change the delay from 2 to 0.5, or add the swing animation to track 0 like this <code>[0, swing, true, 5, .5]</code> with a delay of 5 seconds and a mix of 0.5 seconds. Click the button below and Celeste will start to blink the eyes more frequently.</p>
<input type="button" value="Update animation" onclick="updateCelesteAnimations(this)">
</div>
</div>
<script>
async function updateCelesteAnimations() {
const celesteAnimations = await spine.getSpineWidget("celeste-animations").loadingPromise;
var celesteAnimationsTextArea = document.getElementById("celeste-animations-text-area");
celesteAnimations.setAttribute("animations", celesteAnimationsTextArea.value)
}
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
// access the spine widget
<spine-widget
identifier="raptor"
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
></spine-widget>
...
// using js, access the skeleton and the state asynchronously
(async () => {
const widget = spine.getSpineWidget("raptor");
const { state } = await widget.loadingPromise;
let isRoaring = false;
setInterval(() => {
const newAnimation = isRoaring ? "walk" : "roar";
state.setAnimation(0, newAnimation, true);
widget.recalculateBounds(); // scale the skeleton based on the new animation
isRoaring = !isRoaring;
}, 4000);
})();
`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section //
/////////////////////
-->
<!--
/////////////////////
// start section //

View File

@ -101,7 +101,64 @@ function isFitType (value: string | null): value is FitType {
);
}
export type AttributeTypes = "string" | "number" | "boolean" | "string-number" | "fitType" | "modeType" | "offScreenUpdateBehaviourType";
const animatonTypeRegExp = /\[([^\]]+)\]/g;
export type AnimationsInfo = Record<string, { cycle?: boolean, animations: Array<AnimationsType> }>;
export type AnimationsType = { animationName: string | "#EMPTY#", loop?: boolean, delay?: number, mixDuration?: number };
function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined {
if (value === null) {
return undefined;
}
const matches = value.match(animatonTypeRegExp);
if (!matches) return undefined;
return matches.reduce((obj, group) => {
const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loop, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim());
if (trackIndexStringOrLoopDefinition === "loop") {
if (!Number.isInteger(Number(animationNameOrTrackIndexStringCycle))) {
throw new Error(`Track index of cycle in ${group} must be a positive integer number, instead it is ${animationNameOrTrackIndexStringCycle}. Original value: ${value}`);
}
const animationInfoObject = obj[animationNameOrTrackIndexStringCycle] ||= { animations: [] };
animationInfoObject.cycle = true;
return obj;
}
const trackIndex = Number(trackIndexStringOrLoopDefinition);
if (!Number.isInteger(trackIndex)) {
throw new Error(`Track index in ${group} must be a positive integer number, instead it is ${trackIndexStringOrLoopDefinition}. Original value: ${value}`);
}
let delay;
if (delayString !== undefined) {
delay = parseFloat(delayString);
if (isNaN(delay)) {
throw new Error(`Delay in ${group} must be a positive number, instead it is ${delayString}. Original value: ${value}`);
}
}
let mixDuration;
if (mixDurationString !== undefined) {
mixDuration = parseFloat(mixDurationString);
if (isNaN(mixDuration)) {
throw new Error(`mixDuration in ${group} must be a positive number, instead it is ${mixDurationString}. Original value: ${value}`);
}
}
const animationInfoObject = obj[trackIndexStringOrLoopDefinition] ||= { animations: [] };
animationInfoObject.animations.push({
animationName: animationNameOrTrackIndexStringCycle,
loop: loop.trim().toLowerCase() === "true",
delay,
mixDuration,
});
return obj;
}, {} as AnimationsInfo);
}
export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "fitType" | "modeType" | "offScreenUpdateBehaviourType" | "animationsInfo";
export type CursorEventTypes = "down" | "up" | "enter" | "leave" | "move" | "drag";
export type CursorEventTypesInput = Exclude<CursorEventTypes, "enter" | "leave">;
@ -113,6 +170,8 @@ interface WidgetAttributes {
jsonSkeletonKey?: string
scale: number
animation?: string
animations?: AnimationsInfo
defaultMix?: number
skin?: string
fit: FitType
mode: ModeType
@ -124,6 +183,7 @@ interface WidgetAttributes {
padRight: number
padTop: number
padBottom: number
animationsBound?: string[]
boundsX: number
boundsY: number
boundsWidth: number
@ -221,7 +281,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
public scale = 1;
/**
* Optional: The name of the animation to be played
* Optional: The name of the animation to be played. When set, the widget is reinitialized.
* Connected to `animation` attribute.
*/
public get animation (): string | undefined {
@ -234,6 +294,37 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
private _animation?: string
/**
* An {@link AnimationsInfo} that describes a sequence of animations on different tracks.
* Connected to `animations` attribute, but since attributes are string, there's a different form to pass it.
* It is a string composed of groups surrounded by square brackets. Each group has 5 parameters, the firsts 2 mandatory. They corresponds to: track, animation name, loop, delay, mix time.
* For the first group on a track {@link AnimationState.setAnimation} is used, while {@link AnimationState.addAnimation} is used for the others.
* If you use the special token #EMPTY# as animation name {@link AnimationState.setEmptyAnimation} and {@link AnimationState.addEmptyAnimation} iare used respectively.
* Use the special group [loop, trackNumber], to allow the animation of the track on the given trackNumber to restart from the beginning once finished.
*/
public get animations (): AnimationsInfo | undefined {
return this._animations;
}
public set animations (value: AnimationsInfo | undefined) {
if (value === undefined) value = undefined;
this._animations = value;
this.initWidget();
}
public _animations?: AnimationsInfo
/**
* Optional: The default mix set to the {@link AnimationStateData.defaultMix}.
* Connected to `default-mix` attribute.
*/
public get defaultMix (): number {
return this._defaultMix;
}
public set defaultMix (value: number | undefined) {
if (value === undefined) value = 0;
this._defaultMix = value;
}
public _defaultMix = 0;
/**
* Optional: The name of the skin to be set
* Connected to `skin` attribute.
@ -377,6 +468,12 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
if (value <= 0) this.initWidget(true);
}
/**
* Optional: an array of animation names that are used to calculate the bounds of the skeleton.
* Connected to `animations-bound` attribute.
*/
public animationsBound?: string[];
/**
* Whether or not the bounds are recalculated when an animation or a skin is changed. `false` by default.
* Connected to `auto-recalculate-bounds` attribute.
@ -457,7 +554,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
public cursorBoundsEventCallback = (event: CursorEventTypes) => {}
/**
* This methods allows to associate to a Slot a callback. For these slots, ff the widget is interactive,
* This methods allows to associate to a Slot a callback. For these slots, if the widget is interactive,
* when the cursor performs actions within the slot's attachment the associated callback is invoked with
* a {@link CursorEventTypes} (for example, it enter or leaves the slot's attachment bounds).
* This is an experimental property and might be removed in the future.
@ -662,6 +759,9 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
"json-skeleton-key": { propertyName: "jsonSkeletonKey", type: "string" },
scale: { propertyName: "scale", type: "number" },
animation: { propertyName: "animation", type: "string", defaultValue: undefined },
animations: { propertyName: "animations", type: "animationsInfo", defaultValue: undefined },
"animation-bounds": { propertyName: "animationsBound", type: "array-string", defaultValue: undefined },
"default-mix": { propertyName: "defaultMix", type: "number", defaultValue: 0 },
skin: { propertyName: "skin", type: "string" },
width: { propertyName: "width", type: "number", defaultValue: -1 },
height: { propertyName: "height", type: "number", defaultValue: -1 },
@ -686,7 +786,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
"on-screen-manual-start": { propertyName: "onScreenManualStart", type: "boolean" },
spinner: { propertyName: "loadingSpinner", type: "boolean" },
clip: { propertyName: "clip", type: "boolean" },
pages: { propertyName: "pages", type: "string-number" },
pages: { propertyName: "pages", type: "array-number" },
fit: { propertyName: "fit", type: "fitType", defaultValue: "contain" },
mode: { propertyName: "mode", type: "modeType", defaultValue: "inside" },
offscreen: { propertyName: "offScreenUpdateBehaviour", type: "offScreenUpdateBehaviourType", defaultValue: "pause" },
@ -825,12 +925,35 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
* Useful when animations or skins are set programmatically.
* @returns void
*/
public recalculateBounds (): void {
public recalculateBounds (forcedRecalculate = false): void {
const { skeleton, state } = this;
if (!skeleton || !state) return;
const track = state.getCurrent(0);
const animation = track?.animation as (Animation | undefined);
const bounds = this.calculateAnimationViewport(animation);
let bounds: Rectangle;
if (this.animationsBound && forcedRecalculate) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
this.animationsBound.forEach((animationName) => {
const animation = this.skeleton?.data.animations.find(({ name }) => animationName === name)
const { x, y, width, height } = this.calculateAnimationViewport(animation);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
bounds = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
} else {
bounds = this.calculateAnimationViewport(state.getCurrent(0)?.animation as (Animation | undefined));
}
bounds.x /= skeleton.scaleX;
bounds.y /= skeleton.scaleY;
bounds.width /= skeleton.scaleX;
@ -892,19 +1015,56 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
private initWidget (forceRecalculate = false) {
const { skeleton, state, animation, skin } = this;
const { skeleton, state, animation, animations: animationsInfo, skin, defaultMix } = this;
if (skin) {
skeleton?.setSkinByName(skin);
skeleton?.setSlotsToSetupPose();
}
if (animation) {
state?.setAnimation(0, animation, true);
} else {
state?.setEmptyAnimation(0);
if (state) {
state.data.defaultMix = defaultMix;
if (animationsInfo) {
Object.entries(animationsInfo).forEach(([trackIndexString, { cycle, animations }]) => {
const cycleFn = () => {
const trackIndex = Number(trackIndexString);
animations.forEach(({ animationName, delay, loop, mixDuration }, index) => {
let track;
if (index === 0) {
if (animationName === "#EMPTY#") {
track = state.setEmptyAnimation(trackIndex, mixDuration);
} else {
track = state.setAnimation(trackIndex, animationName, loop);
}
} else {
if (animationName === "#EMPTY#") {
track = state.addEmptyAnimation(trackIndex, mixDuration, delay);
} else {
track = state.addAnimation(trackIndex, animationName, loop, delay);
}
}
if (mixDuration) track.mixDuration = mixDuration;
if (cycle && index === animations.length - 1) {
track.listener = { complete: () => cycleFn() };
};
});
}
cycleFn();
});
} else if (animation) {
state.setAnimation(0, animation, true);
} else {
state.setEmptyAnimation(0);
}
}
if (forceRecalculate || this.autoRecalculateBounds) this.recalculateBounds();
if (forceRecalculate || this.autoRecalculateBounds) this.recalculateBounds(forceRecalculate);
}
private render (): void {
@ -1145,6 +1305,8 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
}
skeleton.setToSetupPose();
return {
x: minX,
y: minY,
@ -2081,6 +2243,11 @@ function castArrayNumber (value: string | null, defaultValue = undefined) {
}, [] as Array<number>);
}
function castArrayString (value: string | null, defaultValue = undefined) {
if (value === null) return defaultValue;
return value.split(",");
}
function castValue (type: AttributeTypes, value: string | null, defaultValue?: any) {
switch (type) {
case "string":
@ -2089,14 +2256,18 @@ function castValue (type: AttributeTypes, value: string | null, defaultValue?: a
return castNumber(value, defaultValue);
case "boolean":
return castBoolean(value, defaultValue);
case "string-number":
case "array-number":
return castArrayNumber(value, defaultValue);
case "array-string":
return castArrayString(value, defaultValue);
case "fitType":
return isFitType(value) ? value : defaultValue;
case "modeType":
return isModeType(value) ? value : defaultValue;
case "offScreenUpdateBehaviourType":
return isOffScreenUpdateBehaviourType(value) ? value : defaultValue;
case "animationsInfo":
return castToAnimationsInfo(value) || defaultValue;
default:
break;
}