Skip to content

帧动画与图集 ​

把多帧画在一张图里(图集),再用 AnimatedSprite2D 按顺序播放。图集的每一帧都是普通的 Texture,也可以直接给 Sprite2D 用。

ts
import { AnimatedSprite2D, atlas, Scene, sheet, Sprite2D, v } from 'sapling2d'
// 打包工具(TexturePacker 等)导出的 JSON 直接 import 进代码;图片放在 public/assets/
import spritesData from './sprites.json'

export class Battle extends Scene {
  static override assets = {
    // 网格图集:一张图切成 4 列 × 2 行,帧号从左到右、从上到下(0–7)
    boom: sheet('explosion.png', { columns: 4, rows: 2 }),
    // 打包图集:按名字取帧,尺寸来自 JSON
    sprites: atlas('sprites.png', spritesData),
  }
  enemy!: Sprite2D
  player!: AnimatedSprite2D<'fly' | 'hurt'>

  override ready() {
    // 图集里的一帧就是普通贴图
    this.enemy = this.add(new Sprite2D({ texture: Battle.assets.sprites.get('enemy_red'), position: v(375, 200) }))

    // 多套动画:名字有类型检查;autoplay 播放第一套(或 animation 指定的那套)
    this.player = this.add(
      new AnimatedSprite2D({
        animations: {
          fly: { frames: Battle.assets.boom.frames(0, 3), fps: 12 }, // loop 默认 true
          hurt: { frames: Battle.assets.boom.frames(4, 7), fps: 12, loop: false },
        },
        autoplay: true,
        position: v(375, 1100),
      }),
    )
  }

  explode(at: Sprite2D) {
    // 一次性特效:播完(不循环)就销毁
    const fx = this.add(new AnimatedSprite2D({ frames: Battle.assets.sprites.frames('explosion_'), fps: 16, loop: false, autoplay: true, position: at.position }))
    fx.animationFinished.connect(() => fx.queueFree(), fx)
    at.queueFree()
  }

  async hit() {
    this.player.play('hurt')
    await this.player.animationFinished // 不循环的动画播完时触发
    this.player.play('fly')
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('sprite animation', async () => {
  const g = await createTestGame({ main: Battle })
  g.scene.explode(g.scene.enemy)
  g.step()
  expect(g.dump()).toContain('texture=sprites.png#explosion_1 frame=0 playing=true') // 贴图路径带帧名 / 帧号
  g.stepSeconds(0.25) // 3 帧 × 1/16 秒后播完、销毁
  expect(g.dump()).not.toContain('explosion_')

  void g.scene.hit()
  await Promise.resolve() // 让 hit() 里的 await 先注册监听(游戏里 emit 发生在之后的帧,不需要这一步)
  g.stepSeconds(0.4)
  await new Promise((r) => setTimeout(r, 0)) // await 之后的代码在微任务里执行
  expect([g.scene.player.animation, g.scene.player.isPlaying]).toEqual(['fly', true])
})

两种图集 ​

函数适用读取帧
sheet(path, { columns, rows })等大格子的网格图frame(i)、frames(start, end)(含两端)、count
atlas(path, data)打包工具产出的图集,data 是图集 JSON(直接 import)get(name)、frames(prefix)、names、has
  • sheet 的帧尺寸 = 整张图尺寸 ÷ 列数(行数),图片加载后才知道,无头模式下为 0。
  • atlas 读通用的图集 JSON(JSON Hash / JSON Array;Pixi、Phaser、Aseprite、TexturePacker、free-tex-packer 都产出这种格式,Phaser 3 的多页 JSON 也认)。尺寸来自 JSON,无头模式下也正确;支持裁剪透明边、旋转的帧和锚点。
  • 同一张图的帧共用一份显存,绘制时能合批。图集放进 static assets,切换场景时按整张图判断是否卸载。

AnimatedSprite2D ​

  • 一套动画:frames + fps(默认 10)+ loop(默认 true)。
  • 多套动画:animations: { 名字: { frames, fps, loop } },用 play(name) 切换。
  • 控制:play(name?)、pause()、stop()(回到第 0 帧)、frame(可赋值)、frameCount、speedScale、isPlaying、animation。
  • 信号:frameChanged;animationFinished(不循环的动画播完时触发,参数是动画名)。
  • texture 由动画控制,不要直接赋值。
  • 动画决定玩法时(攻击动作的出手帧)加 processInPhysics: true:按物理步推进,倍速下出手的那个物理步和 1 倍速一样。

每帧时长不同 ​

攻击动作通常是"慢慢蓄力、一下挥出、慢慢收招",每帧时长不一样。用 durations(秒)代替 fps:

ts
import { AnimatedSprite2D, Node2D, Scene, sheet, v } from 'sapling2d'

class Hero extends Node2D {
  static readonly HIT_FRAME = 2 // 出手的那一帧:在这一帧结算伤害、发射弹道
  attackInterval = 1 // 两次攻击之间的秒数,升级攻速时变小
  hits = 0
  sprite!: AnimatedSprite2D<'idle' | 'attack'>

  override ready() {
    const frames = Arena.assets.hero.frames()
    this.sprite = this.add(
      new AnimatedSprite2D({
        animations: {
          idle: { frames: frames.slice(0, 2), fps: 4 },
          // 每帧各自的时长(秒):前摇停住 0.2 秒,出手帧只有 0.04 秒,收招 0.16 秒
          attack: { frames: frames.slice(2, 6), durations: [0.1, 0.2, 0.04, 0.16], loop: false },
        },
        autoplay: true,
      }),
    )
    // 换帧时检查帧号:speedScale 再大也会逐帧触发,出手帧不会被跳过
    this.sprite.frameChanged.connect(() => {
      if (this.sprite.animation === 'attack' && this.sprite.frame === Hero.HIT_FRAME) this.hits++
    }, this)
    this.sprite.animationFinished.connect(() => this.sprite.play('idle'), this)
  }

  attack() {
    // 攻击间隔比动画(0.5 秒)还短时加速播放,保证下一次攻击前播完
    this.sprite.speedScale = Math.max(1, this.sprite.getAnimationDuration('attack') / this.attackInterval)
    this.sprite.play('attack')
  }

  /** 从开始攻击到出手的秒数(算上加速):例如让伤害数字、音效对上画面。 */
  get windup(): number {
    return this.sprite.getFrameTime('attack', Hero.HIT_FRAME) / this.sprite.speedScale
  }
}

export class Arena extends Scene {
  // 一张图里 6 帧:0–1 待机,2–5 攻击
  static override assets = { hero: sheet('hero.png', { columns: 6, rows: 1 }) }
  hero!: Hero

  override ready() {
    this.hero = this.add(new Hero({ position: v(375, 1000) }))
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('frame durations', async () => {
  const g = await createTestGame({ main: Arena })
  const hero = g.scene.hero
  hero.attack()
  expect(hero.windup).toBeCloseTo(0.3) // 0.1 + 0.2
  g.stepSeconds(0.29)
  expect(hero.hits).toBe(0) // 还在前摇
  g.stepSeconds(0.02)
  expect(hero.hits).toBe(1) // 出手
  g.stepSeconds(0.25)
  expect(hero.sprite.animation).toBe('idle') // 播完回到待机

  hero.attackInterval = 0.25 // 攻速翻倍:动画以 2 倍速播放
  hero.attack()
  expect([hero.sprite.speedScale, hero.windup]).toEqual([2, expect.closeTo(0.15)])
  g.stepSeconds(0.16)
  expect(hero.hits).toBe(2)
})
  • durations 的长度和 frames 相同,每个值大于 0;和 fps 只能二选一。
  • getAnimationDuration(name?) 是一轮的总秒数,getFrameTime(name, frame) 是这一帧开始的时间点。两者都不算 speedScale,实际时间要除以它。
  • 命中帧在 frameChanged 里按 frame 判断。一次推进跨过好几帧时,每一帧都会触发,不会被跳过。

Aseprite ​

美术在 Aseprite 里用 tag 分段、调每帧时长、用 slice 标挂点(枪口、剑尖),导出 png + json 之后,程序不用改任何数字。导出时 --list-tags 和 --list-slices 不能漏:

sh
aseprite -b hero.aseprite --sheet public/assets/hero.png --data src/hero.json --format json-array --list-tags --list-slices --sheet-type packed
ts
import { AnimatedSprite2D, aseprite, Node2D, Scene, v } from 'sapling2d'
// Aseprite 导出的 JSON 直接 import 进代码;图片放在 public/assets/
import heroData from './hero.json'

class Hero extends Node2D {
  static readonly HIT_FRAME = 2 // attack 动画里出手的那一帧(动画里的序号,从 0 开始)
  shots: { x: number; y: number }[] = [] // 子弹的发射点(演示用)
  sprite!: AnimatedSprite2D<'idle' | 'attack'>

  override ready() {
    const sheet = Arena.assets.hero
    this.sprite = this.add(
      new AnimatedSprite2D({
        // tag → 动画,每帧时长来自 Aseprite;attack 只播一次
        animations: sheet.animations({ attack: { loop: false } }),
        autoplay: true,
      }),
    )
    this.sprite.frameChanged.connect(() => {
      if (this.sprite.animation !== 'attack' || this.sprite.frame !== Hero.HIT_FRAME) return
      // 挂点:当前帧里 muzzle slice 的 pivot(以精灵中心为原点);传 sprite.texture,不是 sprite.frame
      const muzzle = sheet.slice('muzzle', this.sprite.texture)!.pivot!
      const dx = this.sprite.flipH ? -muzzle.x : muzzle.x // 精灵翻转时 x 取反
      this.shots.push({ x: this.x + dx, y: this.y + muzzle.y })
    }, this)
    this.sprite.animationFinished.connect(() => this.sprite.play('idle'), this)
  }

  attack() {
    this.sprite.play('attack')
  }
}

export class Arena extends Scene {
  // 类型参数是 tag 的名字(从 JSON 推断不出来);用到不存在的 tag 时运行时报错
  static override assets = { hero: aseprite<'idle' | 'attack'>('hero.png', heroData) }
  hero!: Hero

  override ready() {
    this.hero = this.add(new Hero({ position: v(375, 1000) }))
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('aseprite', async () => {
  const g = await createTestGame({ main: Arena })
  const hero = g.scene.hero
  expect(hero.sprite.getAnimationDuration('attack')).toBeCloseTo(0.5) // 100 + 200 + 40 + 160 毫秒
  hero.attack()
  g.stepSeconds(0.31) // 前摇 0.3 秒之后出手
  expect(hero.shots).toEqual([{ x: 375 + 16, y: 1000 - 16 }]) // 画布 48×48:pivot (40, 8) 相对中心 (24, 24)
  g.stepSeconds(0.2)
  expect(hero.sprite.animation).toBe('idle')
})
  • aseprite<Tag>(path, data):类型参数是 tag 的名字,只用于类型检查。放进 static assets。
  • 帧按导出顺序编号:frame(i)、frames(start, end)、count、duration(i)(秒)。
  • animation(tag?, { loop }) 得到一个 tag 的动画(frames + durations);animations({ tag: { loop } }) 得到所有 tag。
    • 播放方向 forward / reverse / pingpong / pingpong_reverse 会展开成帧序列,和 Aseprite 里播放的一样。
    • tag 没设播放次数时循环;设了 N 次就展开 N 遍、不循环。攻击这类只播一次的动作写 { attack: { loop: false } }。
  • slice(name, frame) 返回 slice 在这一帧的 { bounds, pivot },以精灵中心为原点,直接加到精灵位置上即可(flipH 时 x 取反)。frame 传播放中精灵的 sprite.texture,或者图里的帧号;返回的对象是共用的,不要修改。
  • 仓库里提交导出好的 png + json,其他人不需要安装 Aseprite。

PNG 序列(TexturePacker) ​

高清手绘的角色一般在 Spine、AE、Animate、PS 里做好,导出成 PNG 序列(hero_attack_01.png …),再打包成图集。这种 JSON 里没有帧时长和命中帧,写在代码里:

ts
import { AnimatedSprite2D, atlas, Scene, v } from 'sapling2d'
// TexturePacker 打包 PNG 序列(hero_attack_01.png …)导出的 JSON;图片放在 public/assets/
import knightData from './knight.json'

// 帧时长和命中帧写在数据里:美术加减帧时只改这里(长度对不上会报错,指出是哪套动画)
const KNIGHT = {
  idle: { fps: 4 },
  attack: { durations: [0.1, 0.2, 0.04, 0.04, 0.15], hitFrame: 2 },
}

export class Arena extends Scene {
  static override assets = { knight: atlas('knight.png', knightData) }
  knight!: AnimatedSprite2D<'idle' | 'attack'>

  override ready() {
    const sheet = Arena.assets.knight
    this.knight = this.add(
      new AnimatedSprite2D({
        animations: {
          // frames(prefix) 按名字里的数字排序:hero_attack_01 … hero_attack_05
          idle: { frames: sheet.frames('hero_idle_'), fps: KNIGHT.idle.fps },
          attack: { frames: sheet.frames('hero_attack_'), durations: KNIGHT.attack.durations, loop: false },
        },
        autoplay: true,
        position: v(375, 1000),
      }),
    )
    this.knight.animationFinished.connect(() => this.knight.play('idle'), this)
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('texturepacker sequence', async () => {
  const g = await createTestGame({ main: Arena })
  const knight = g.scene.knight
  // 裁掉的透明边各不相同,但尺寸都是导出时的画布 128×128:以画布中心对齐,切换动作时角色不会跳
  const sizes = new Set(Arena.assets.knight.names.map((n) => `${Arena.assets.knight.get(n).width}×${Arena.assets.knight.get(n).height}`))
  expect([...sizes]).toEqual(['128×128'])

  knight.play('attack')
  const hitAt = knight.getFrameTime('attack', KNIGHT.attack.hitFrame)
  expect(hitAt).toBeCloseTo(0.3)
  g.stepSeconds(hitAt + 0.01)
  expect(knight.texture?.path).toBe('knight.png#hero_attack_03.png')
  g.stepSeconds(0.3)
  expect(knight.animation).toBe('idle')
})
  • 同一个角色的所有序列用相同的画布尺寸导出(武器挥出去的部分也要在画布里),打包时开启 trim 裁掉透明边。帧的尺寸仍是画布尺寸、以画布中心对齐,切换动作时角色不会跳。
  • 画布没法统一时用锚点:在 TexturePacker 里把每帧的 pivot 放在角色脚底,导出的 JSON 里每帧带 pivot(PixiJS 格式叫 anchor,两个都认)。Sprite2D 默认以锚点为原点,切换动作时脚底不动。
  • frames(prefix) 只取前缀后面紧跟编号的帧,按编号排序:frames('hero_attack_') 不会混进 hero_attack_heavy_01.png。一帧都没取到时,报错会提示更合适的前缀。
  • durations 的长度和帧数对不上时报错:美术加减了帧,记得改时长。

多页图集与旋转的帧 ​

一张图放不下(小游戏上单页别超过 2048)时分成几页,每页一张图、一个 JSON。atlas() 传两个顺序一致的数组:

ts
import { atlas, Scene, Sprite2D, v } from 'sapling2d'
// 一页放不下时分成 items-0 / items-1 两页,每页一张图、一个 JSON(这两个 JSON 是 TexturePacker 的 Multipack 导出的);图片放在 public/assets/
import page0 from './items-0.json'
import page1 from './items-1.json'

export class Inventory extends Scene {
  // 图片按页的顺序列出(和 JSON 里的 meta.image 对不上时报错);帧按名字取,不用管在哪一页
  static override assets = { items: atlas(['items-0.png', 'items-1.png'], [page0, page1]) }

  override ready() {
    const items = Inventory.assets.items
    items.names.forEach((name, i) => {
      const position = v(150 + (i % 4) * 150, 400 + Math.floor(i / 4) * 150)
      this.add(new Sprite2D({ name: name.replace('.png', ''), texture: items.get(name), position, scale: v(3, 3) }))
    })
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('multipack atlas with rotated frames', async () => {
  const g = await createTestGame({ main: Inventory })
  const items = Inventory.assets.items
  expect(items.pages.map((p) => p.path)).toEqual(['items-0.png', 'items-1.png'])
  expect(items.isLoaded).toBe(true) // 两页都在 ready 之前加载完
  // 帧带着自己那一页:路径是 页#名字
  expect([items.get('coin.png').path, items.get('key.png').path]).toEqual(['items-0.png#coin.png', 'items-1.png#key.png'])
  // 旋转的帧(JSON 里 rotated: true,剑在图里顺时针转了 90°):游戏里的尺寸和方向仍是原图的,渲染时转回来
  const sword = items.get('sword.png')
  expect([sword.width, sword.height]).toEqual([14, 48])
  expect(g.scene.children).toHaveLength(items.names.length)
})
  • 页的顺序错了会报错。帧名在所有页里唯一,get / frames(prefix) 不用管帧在哪一页。
  • Phaser 3 格式把所有页放在一个 JSON 里,只传这一个 JSON。
  • 加载和卸载以页为单位;pages 是每页的 Texture。
  • JSON 里 rotated: true 的帧(TexturePacker 的 Allow rotation)会在渲染时转回来,尺寸、锚点、点击范围都按原图算。
  • 不支持 polygon 打包、meta.scale 缩放变体、法线贴图、plist / XML 等格式。

自己打包图集:@sapling2d/pack ​

散图打成图集不需要 TexturePacker:

sh
pnpm add -D @sapling2d/pack
pnpm sapling-pack art/items public/assets --json src   # art/items/**/*.png → public/assets/items.png + src/items.json
ts
import itemsData from './items.json'
static assets = { items: atlas('items.png', itemsData) }
  • 帧名是相对输入目录的路径(coin.png、hero/attack_01.png),frames('hero/attack_') 照常用。
  • 默认裁掉透明边(--no-trim 关掉)、不旋转、单页最大 2048(--max-size)、帧间距 2(--padding)。放不下时自动分页(items-0.png、items-1.png……)。
  • 输出是确定的:同样的输入得到逐字节相同的文件。产物提交进仓库,改了散图重跑一次。
  • 在自己的脚本里可以调用 packAtlas(name, sprites, options):输入 RGBA 像素,输出每页的像素和 JSON,不读写文件。

MIT 许可证