上一篇介绍了 在 Web 中使用 Spine 动画,但是这仅仅是把 Spine 动画简单的展示在页面中,实际项目中,必然有一些交互控制动画的展示。对于这些,又有很多的挑战!记录这篇文章,主要是想吐槽 Spine Web 的文档,真的是让人想吐。虽然最终是短短几行代码,但是你要去找论坛帖子 + 不断的尝试。
使用上篇文章中的代码 SpinePlayer 组件,就可以直接展示动画。但是实际的需求可能是在某种条件下才会出现动画,而且动画不要循环播放。而且设计做的动画可能是把多个动画效果做进一个素材里,那么如何展示特定的动画?看起来好像很好控制很容易,实际上完全不是。
很多时候需要去看源码,所以我的了解不一定对,如果不对,欢迎指正!以下是探索的过程,前面的很多做法可能并不合适。
适配问题
动画的位置和大小:根据设计稿在编辑器世界坐标中的位置,可以知道动画区域在设计稿中的位置。以及大小。
container 设置为动画的大小,它随着屏幕大小改变自动适应。
位置也是,所以跟一般元素适配一样。
setEmptyAnimation
当你
<Spineboy
ref="spineBoy"
jsonPath="./spine/spine-boy.json"
atlasPath="./spine/spine-boy.atlas"
:animation="['walk']"
/>
animation 表示进行播放的动画名称,可以通过组件传参,如果我的需求是加载页面先不要播放动画,在需要的时候才播放动画。该如何实现呢?库的配置并没有默认播放或暂停的设置。
如果设置一个 animation,会发现加载完之后直接自动播放。如果不设置 animation,会显示静止的动画元素。
通过 setEmptyAnimation 可以设置一个空动画。
player.animationState?.setEmptyAnimation(0, 0)
所以可以在动画加载完成之后,通过 setEmptyAnimation 清空动画。
Track 通道
Track 是 Spine 动画的通道,不同通道可以叠加运用,我这里没有运用多个通道,所以通道默认为 0。
setEmptyAnimation,addAnimation,setAnimation 第一个参数都是 trackIndex
播放动画
addAnimation:添加一个待播放动画, 在某轨道的当前或最后一个排队动画之后播放. 若该轨道为空, 则相当于调用setAnimation.
- addAnimation (int trackIndex, string animationName, bool loop, float delay): TrackEntry
- setAnimation (int trackIndex, string animationName, bool loop): TrackEntry
- setEmptyAnimation (int trackIndex, float mixDuration): TrackEntry
所以可以实现类似点击按钮开始动画的效果:
player.setAnimation(animationName, false)
player.play()
这是用库源码中的方法:
- setAnimation: github.com/EsotericSof…
- play: github.com/EsotericSof…
这里有点不明白,从源码上看,play 方法 只是根据 showControls 控制播放器的显示,我这里 showControls 为 false。如果不 player.play(),则动画不正常,只在结束位置显示动画元素的半透明效果或者不显示(不同动画)。
(随着后面新的解决方法的更新,我也了解了 play 的原理,play 虽然只是改变 paused 状态,但是会影响动画的执行 drawFrame)
知道这种用法是来自:en.esotericsoftware.com/forum/d/252…
success 回调
如果想要动画播放完之后做点什么,比如开始下一个动画,应该怎么做呢?
可以在 success 之后增加回调监听 addListener (AnimationStateListener listener): void
查看 AnimationStateListener 包含的方法。
success: (player) => {
console.log('The skeleton and its assets have been successfully loaded.')
// 监听动画完成事件
player.animationState?.addListener({
complete: function (entry) {
console.log('complete: ', entry.animation.name)
},
})
},
这样可以在动画 complete 之后继续想要的操作,可以获取完成的动画名称。
改变 container 大小
如何做连续的动画呢?
一开始我没有详细看 viewport 的使用,viewport 的设置为:
viewport: {
padTop: 0,
padLeft: 0,
padBottom: 0,
padRight: 0,
}
动画师设计导出的素材中,包含多个动画,但是每一个动画的大小不同,比如 SpineBoy walk 的动画和 jump 的动画。为了适配展示角色的大小,在切换动画的时候,我需要改变 container 的大小,否则下一个动画角色的大小就会改变。比如,idle 大小 127 * 185,move 大小 295 * 222,在这样的尺寸大小下,其中的角色大小一致,如果一开始 container 大小为 127 * 185,idle 动画切换到 move,此时 move 动画在 127 * 185 的大小中展示,其中的角色缩小了。
所以我改变 container 大小来展示不同的 animation。
但是不同动画之间 container 的改变,,比如 move->idle,move 结束在视口的右边,改变 container,container 变小:295 * 222 -> 127 * 185,视口适应,此时角色变小了,然后开始 idle动画,视口在改变后的 container 中适应,也就是 127 * 185,角色变大(本来大小)。因此有这样一个变小变大的过程。
当 Web Player 在动画之间切换时, 它会将新旧动画的视口间进行
0.2秒的插值. 这个视口转换时间可以通过transitionTime属性设置:
viewport: { transitionTime: 0 }
设置 transitionTime 可以减少过渡的时间,但还是有一闪的效果。
这个可能和后面遇到的一个问题相似
viewport 如何使用
视口可能是最难理解的了,视口跟世界坐标有关系,可能需要看一下 Spine Editor 中的操作才能明白。
首先有默认的 pad,为了减少干扰,设置 pad为 0。然后动画会在 container 中自适应,也就是要么高度撑满要么宽度撑满。
x、y 代表绿框左下角的位置,通过 x、y 来定位视口。意思是将视口的左下角放在世界坐标中心,此时世界坐标中展示区域就显示在视口中。
width、height 表示视口的宽高。
viewport: {
debugRender: true,
padTop: 0,
padLeft: 0,
padBottom: 0,
padRight: 0,
x: -225,
y: -200,
width: 450,
height: 400,
},
通过 debugRender 可以方便的查看视口大小和位置。
在这样设置之后,所有的 animation 都在同一视口中显示,所以就不用改变 container 大小了。
clearTracks
clearTracks (): void
移除所有轨道上的所有动画, 使skeleton保持当前姿势.若需要将Skelton混合回setup pose而非当前姿势时可以使用 setEmptyAnimation.
在进行下一次动画之前 clearTracks,可以保持动画当前 pose:
player.animationState?.clearTracks()
right_move -> front
遇到一个很奇怪的问题,我的动画有不同方向的待机和移动,当 right_idle 和 right_move 之间相互切换时,一切正常,但是 right_move 切换到 front_idle 却不正常了,表现为角色没有显示在容器中或者显示一个影子:
如果去掉上面的在切换动画之前的 clearTracks,会解决这个不显示的问题,但是动画会回到初始 pose。
多个方向上的实例
这个问题对于我来说有点难,不知道是动画本身的问题还是我的用法不对。但是还是要解决问题,最终,我想到一种权宜之计,既然不同方向上的切换有问题,那创建多个方向上的实例,当切换方向时,显示当前方向上的实例进行动画不就可以了?
for (const animateBoy of Object.values(animateBoyRefs)) {
animateBoy.value?.$el.classList.add('hidden')
// 清空动画
animateBoy.value?.setEmptyAnimation()
}
curAnimateBoy.value?.$el.classList.remove('hidden')
function switchDirection(position: number, direction?: string): void {
const finalDirection = direction || coordinates[position].direction
const refKey = `animateBoy${capitalizeFirstLetter(finalDirection)}`
curAnimateSky = animateBoyRefs[refKey as keyof typeof animateBoyRefs]
}
function setEmptyAnimation(): void {
if (player) {
player.animationState?.setEmptyAnimation(0, 0)
}
}
这样通过在切换方向时改变当前的动画实例,设置隐藏显示,清空动画,来实现不同方向上的动画衔接。
初始位置问题
然后又发现另外一个问题。
目前的实现是,right_idle -> right_move -> right_idle,move 的时候保持在结束时的 pose,所以 right_idle 时是在结束时的位置,而它的初始位置是在视口中间。当进行过 right_idle 之后,被隐藏以及 setEmptyAnimation 之后,最后再次显示、进行 right_idle 动画时,角色的位置还保留在上一次的位置(move 结束的位置),而不是 right_idle 初始位置。这样针对最后当前位置的显示就不对。
那么如何解决呢?详细分析问题之后,认为问题就出在 setEmptyAnimation 保留了最后 pose。问 ChatGPT 说 setEmptyAnimation 会让恢复初始位置。但是给出了重置骨骼到初始状态的方法:
player.animationState.clearTracks();
player.skeleton.setToSetupPose();
上面的 clearTracks 也提到,若需要将 Skelton 混合回 setup pose 而非当前姿势时可以使用 setEmptyAnimation.但是实际上却不是!
所以:
player.animationState?.setEmptyAnimation(0, 0)
player.skeleton?.setToSetupPose()
重新使用一个实例
既然 setToSetupPose 可以重置骨骼初始位置,那么之前使用一个实例时候的问题,是不是也可以解决?
确实可以解决。在转角的位置进行清除重置,同时需要设置位置。
但是在转角位置切换方向时,有一个比较明显的闪动,所以之前多个实例的方法还是有其优点。
新的解决方法
现在我们重新分析:
一个动画在播放结束时,会停留在结束位置;如果继续播放或者开始下一个动画,则会从上一个动画的开始位置开始。
defaultMix 可以设置两个动画之间的切换过渡时长,默认 0.25 秒。
transitionTime 可以设置视口之间的转换时间,默认 0.2 秒。
我的视口没有变化,跟 transitionTime 没有关系。
但是会在多个动画之间切换,所以能看到不同动画之间的切换过程,将 defaultMix 设为 0,会看到仍然有闪动的过程。
- 针对角色消失的问题,首先不要使用
clearTracks清空轨道。 - 其次,我们不需要在对运动的最后一次 idle 的情况,不进行位置设置,而是所有的动画都进行位置设置,让它们在当前的位置运动。
这样之后,动画都在当前的位置上,但是下一次的动画都会从上一次动画的开始位置开始,同时进行位置的移动,但是问题是它们似乎不能重合。
假设视口如蓝框。角色如黄框。动画 move 在视口中从实框移动到虚框。
每一次运动:设置位置,播放动画。
setAnimatePosition(position)
player.setAnimation(0, 'move', false)
第一次 move,设置位置 1,角色从位置 1 运动到位置 2;第二次 move,设置位置 2,角色从位置 2 运动到位置 3。当第二次位置开始时,角色还在位置 3,因为有下一次运动,角色会从位置 3 回到 2 开始 2 -> 3。这个从 3 回到 2 的过程就是动画与动画之间的切换过渡时间。
(删除说明,不准确,参考动画切换混入时间)
设置 defaultMix 为 0,还是会有一闪的过程。因为设置位置(1->2)之后,player 还没开始新的渲染,当开始渲染时,会有从结束位置到开始位置的变化。
问题是,如果先开始动画后移动位置,那动画会回到初始位置开始,不是从 1 开始吗?不也应该有从 1->2 的闪动吗?
(以上删除说明,当设置 defaultMix = 0,移动和开始应该是同时进行)
按说,同时移动位置、开始动画,第二次 move 动画,开始 pose 应该就在位置 2。为什么会从 3 闪到 2 呢?
解决是:异步执行设置位置。
但是为什么呢?如果先执行动画,根据我们了解的,move 动画中,角色会回到 1,无论如何都有一个闪动的效果。
答案是 Spine 动画的渲染是异步的。所以动画并不是真的先开始,而是动画本来就是异步的。
这个要具体的分析源码,核心在于:github.com/EsotericSof…
其中渲染循环 drawFrame:
-
AnimationState.update(playDelta): 根据时间差推进当前动画进度。 -
AnimationState.apply(skeleton): 将当前动画状态应用到骨骼。 -
skeleton.updateWorldTransform(): 更新骨骼世界变换,计算骨骼的全局位置、旋转等。 -
renderer.drawSkeleton: 渲染骨骼动画。
动画切换混入时间
defaultMix 的含义是,是两个动画之间切换的时间。混入指的是当一个动画结束或被另一个动画替换时,两个动画会在某个时间段内同时播放,并根据过渡时间的长短进行线性插值,使得角色从一个动画状态逐渐过渡到另一个动画状态。
以官网例子看,jump -> walk 时,如果在 jump 中间切换 walk,如果 defaultMix = 0.5,那么会有 0.5s 时间淡出 jump 淡入 walk 的过程,如果 walk 动画时间 1s,则接下来进行后面 0.5s 的 walk 动画。如果 defaultMix = 0,则从 jump 直接切换到 walk,从 walk 初始位置开始动画。
关于运行时的选择
在第一篇我就写过,关于用哪个库,有点让人犯难,不过肯定先用一下官方的
因为这句,就用了 spine-player。
但是这次在做的过程中,不止一次怀疑是不是不应该用这个,正如它的名字,它只是一个播放器,复杂一点的交互是不是应该有别的选择。但是因为是第一次,随着不断的使用,其他的也不熟,用哪个都是从头开始,就坚持下来了。
最后得到官方的建议,做 game 还是需要 spine-phaser 或者 spine-pixi.
第一次,也算是踩坑了。技术选型,也要有相应的经验才能做啊。
不过,spine-player 实现简单的动画应该是更容易一些的,而且实现简单的交互也并不是不能。下一次看看 spine-phaser。
吐槽收集
参考链接
- Spine Web Player
- Spine Runtimes Guide
- API Reference - Spine Runtimes Guide
- Versioning - Spine User Guide
- Applying Animations - Spine Runtimes Guide
- Spine player animation loop - Spine Forum
- How to get "Complete" event in Spine Web Player? - Spine Forum
- Viewport in Spine Player - Spine Forum
- pixijs/spine: Pixi.js plugin that enables Spine support.
- github.com/EsotericSof…
- github.com/EsotericSof…