帧动画与图集
把多帧画在一张图里(图集),再用 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 packedts
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.jsonts
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,不读写文件。