Skip to content

Spine 骨骼动画 ​

角色用 Spine 做(骨骼、网格变形、换装、事件)时,用独立的包 @sapling2d/spine,基于官方的 @esotericsoftware/spine-core 4.3。

sh
pnpm add @sapling2d/spine
ts
import { 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()] })
ts
// 无头测试从 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 编辑器许可,并在产品文档里附上许可证;玩家不需要。

MIT 许可证