Spine 骨骼动画
角色用 Spine 做(骨骼、网格变形、换装、事件)时,用独立的包 @sapling2d/spine,基于官方的 @esotericsoftware/spine-core 4.3。
pnpm add @sapling2d/spineimport { ColorRect, Scene, v } from 'sapling2d'
import { spine, spineSkeleton, SpineBoneNode, SpineSprite } from '@sapling2d/spine'
// 骨架 JSON(Spine 4.3 导出)+ 图集 + 页图都放在资源目录里:public/assets/spineboy/。图集和骨架同名时不用写 atlas
const SPINEBOY = spineSkeleton('spineboy/spineboy-ess.json', { atlas: 'spineboy/spineboy.atlas' })
export class Arena extends Scene {
static override assets = { boy: SPINEBOY } // 进入场景前加载骨架、图集和页图
boy!: SpineSprite
footsteps = 0
override ready() {
// 原点在 Spine 的 root 骨骼(spineboy 是脚底);defaultMix:动作切换时过渡 0.2 秒
this.boy = this.add(new SpineSprite({ name: 'Boy', skeleton: SPINEBOY, animation: 'idle', defaultMix: 0.2, position: v(375, 1100) }))
// Spine 的用户事件(时间轴上的 footstep)
this.boy.animationEvent.connect((name) => {
if (name === 'footstep') this.footsteps++
}, this)
// 跟随骨骼的节点:枪口的闪光挂在 gun-tip 上,每帧跟着骨骼动
this.boy.add(new SpineBoneNode({ bone: 'gun-tip' })).add(new ColorRect({ name: 'Flash', size: v(60, 12), position: v(0, -6), color: 0xffdd55 }))
}
jump() {
this.boy.play('jump', { loop: false }) // 轨道 0:和 idle 混合 0.2 秒
this.boy.queue('idle') // 跳完接回 idle
}
shoot() {
this.boy.play('shoot', { track: 1, loop: false }) // 轨道 1 叠在走路 / 站立上面
}
faceLeft() {
this.boy.flipH = true
}
}
// 启动参数:startGame({ main: Arena, plugins: [spine()] })// 无头测试从 assetsDir 读真实的骨架和图集文件(默认 public/assets);页图不读,但图集里带尺寸,骨骼位置和事件都和真机一致
const g = await createTestGame({ main: Arena, plugins: [spine()], assetsDir: 'test/fixtures' })
const scene = g.scene as Arena
expect(scene.boy.animation).toBe('idle')
scene.jump()
expect(scene.boy.animation).toBe('jump')
g.stepSeconds(1.5) // jump 1.33 秒
expect(scene.boy.animation).toBe('idle')
scene.boy.play('walk')
g.stepSeconds(2)
expect(scene.footsteps).toBeGreaterThanOrEqual(3) // walk 每秒两步
const tip = scene.boy.bonePosition('gun-tip') // 节点局部坐标,y 向下
scene.faceLeft()
expect(scene.boy.bonePosition('gun-tip').x).toBeCloseTo(-tip.x)
expect(g.dump()).toContain('Boy (SpineSprite) position=(375, 1100) skeleton=spineboy/spineboy-ess.json animation=walk playing=true flipH=true')资源
- 安装插件:
startGame({ plugins: [spine()] })。 spineSkeleton(path, { atlas?, scale? }),放进static assets:path是 Spine 4.3 导出的 JSON,版本不对时加载报错;.skel暂不支持。- 图集默认是同目录同名的
.atlas,不同名时写atlas。页图和图集放在同一目录,和tex()一样走小游戏分包、切场景卸载。 scale整体缩放骨架。
- 资源上能读到
animationNames、skinNames、boneNames、slotNames、eventNames、rect(设置姿势的包围盒),data是 spine-core 的SkeletonData。
SpineSprite
构造参数:{ skeleton, animation?, loop?, skin?, defaultMix?, speedScale?, flipH?, flipV?, physics? }。
| 成员 | 作用 |
|---|---|
play(name, { loop, track }) | 播放(默认循环、轨道 0) |
queue(name, { loop, track, delay }) | 排在当前动画后面 |
stop(track, mixDuration) | 停止 |
animation / isPlaying | 轨道 0 的当前动画 |
speedScale | 播放速度,和 tree.timeScale 叠加 |
defaultMix / mix(from, to, s) | 动画之间的过渡时长 |
skin / setAttachment(slot, name | null) | 换装、换武器、换表情 |
flipH / flipV | 翻转 |
bonePosition(bone) / boneRotation(bone) | 骨骼在节点局部坐标里的位置和角度 |
rect | 没设 hitArea 时的点击区域 |
信号:animationStarted(name, track)、animationFinished(name, track)(不循环的播完、循环的每圈一次)、animationEvent(name, { int, float, string, time }, track)。
动画在 process 之前推进,暂停时停止。原点是 Spine 的 root 骨骼(角色一般在脚底),坐标 y 向下。
跟随骨骼:SpineBoneNode
SpineBoneNode({ bone }) 放在 SpineSprite 的直接子节点,每帧跟随那根骨骼的位置、旋转、缩放。枪口特效、手持武器、头顶血条挂在它下面。只能跟随,不能反过来驱动骨骼。
进阶
需要多轨道混合、IK 目标这类功能时,直接用 spine-core 的对象:sprite.state(AnimationState)和 sprite.spine(Skeleton)。改了姿势相关的东西之后调用 refreshPose()。
渲染与性能
- 每个 slot 一个网格,和精灵一起合批;slot 的混合模式(additive / multiply / screen)和裁剪都照 Spine。
- 不支持双色染色(tint black):会被忽略并警告一次。
- 静止的角色每帧没有开销;在播的角色每帧算一次顶点,一个 50 slot 的角色大约相当于 50 个精灵。
测试
无头测试读真实的骨架和图集(createTestGame({ assetsDir })),不读页图:动画时序、事件、骨骼位置和真机一致。dump() 显示 skeleton、animation、playing、skin、flipH。
许可证
Spine Runtimes License 要求每个使用本插件的开发者有自己的 Spine 编辑器许可,并在产品文档里附上许可证;玩家不需要。