Pixi.js 游戏开发教程 / 精灵表与帧动画(Spritesheet)
精灵表与帧动画(Spritesheet)
概述
精灵表(Spritesheet)将多张小图合并为一张大图,配合 JSON/XML 描述文件使用。这大幅减少纹理切换和 draw call,是 2D 游戏的标准做法。配合 AnimatedSprite,可以实现流畅的帧动画。
Spritesheet 概念
spritesheet.png(一张大图)
┌──────┬──────┬──────┬──────┐
│walk-0│walk-1│walk-2│walk-3│ ← 4 帧行走动画
├──────┼──────┼──────┼──────┤
│idle-0│idle-1│idle-2│idle-3│ ← 4 帧待机动画
├──────┼──────┼──────┼──────┤
│jump-0│jump-1│jump-2│jump-3│ ← 4 帧跳跃动画
└──────┴──────┴──────┴──────┘
spritesheet.json(帧位置描述)
优势
| 优化点 | 说明 |
|---|
| 减少 HTTP 请求 | 一次加载代替数十次 |
| 减少纹理切换 | GPU 批处理更高效 |
| 内存效率 | 合并边界空白,减少显存占用 |
| 动画管理方便 | JSON 中定义帧序列,代码直接引用名称 |
TexturePacker 打包工具
使用流程
- 准备单帧 PNG 图片
- 导入 TexturePacker
- 选择输出格式(JSON Hash / JSON Array / PixiJS)
- 导出
.png + .json
推荐设置
| 设置项 | 推荐值 |
|---|
| Algorithm | MaxRects |
| Max size | 2048×2048 或 4096×4096 |
| Allow rotation | ❌ 关闭(避免旋转导致混淆) |
| Trim mode | Trim(去除透明边缘) |
| Format | JSON Hash |
| File format | RGBA8888 |
免费替代方案
JSON Spritesheet 格式
JSON Hash 格式
{
"frames": {
"walk-0.png": {
"frame": { "x": 0, "y": 0, "w": 64, "h": 64 },
"rotated": false,
"trimmed": false,
"spriteSourceSize": { "x": 0, "y": 0, "w": 64, "h": 64 },
"sourceSize": { "w": 64, "h": 64 },
"anchor": { "x": 0.5, "y": 0.5 }
},
"walk-1.png": {
"frame": { "x": 64, "y": 0, "w": 64, "h": 64 },
"rotated": false,
"trimmed": false,
"spriteSourceSize": { "x": 0, "y": 0, "w": 64, "h": 64 },
"sourceSize": { "w": 64, "h": 64 }
}
},
"meta": {
"app": "TexturePacker",
"version": "7.0",
"image": "spritesheet.png",
"format": "RGBA8888",
"size": { "w": 256, "h": 256 },
"scale": "1"
}
}
JSON Array 格式
{
"frames": [
{
"filename": "walk-0.png",
"frame": { "x": 0, "y": 0, "w": 64, "h": 64 },
"rotated": false,
"trimmed": false,
"sourceSize": { "w": 64, "h": 64 }
},
{
"filename": "walk-1.png",
"frame": { "x": 64, "y": 0, "w": 64, "h": 64 }
}
],
"meta": {
"image": "spritesheet.png",
"size": { "w": 256, "h": 256 }
}
}
Spritesheet 加载与解析
import { Application, Assets, Spritesheet, Sprite } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600, background: '#1a1a2e' });
document.body.appendChild(app.canvas);
// 方式一:使用 Assets 加载(推荐,v8 自动解析)
const sheet = await Assets.load('/image/hero-spritesheet.json');
// 获取单帧纹理
const idleFrame = sheet.textures['idle-0.png'];
const sprite = new Sprite(idleFrame);
sprite.position.set(400, 300);
app.stage.addChild(sprite);
// 方式二:手动加载并解析
const atlasData = await fetch('/image/hero-spritesheet.json').then(r => r.json());
const baseTexture = await Assets.load('/image/hero-spritesheet.png');
const sheet2 = new Spritesheet(baseTexture, atlasData);
await sheet2.parse();
// 通过动画名获取帧序列
const walkFrames = sheet2.animations['walk'];
console.log(walkFrames); // [Texture, Texture, Texture, Texture]
动画帧序列
// 精灵表中的动画定义(需要在 JSON 中配置或代码中手动组织)
// 手动组织动画帧
const animations = {
idle: [
sheet.textures['idle-0.png'],
sheet.textures['idle-1.png'],
sheet.textures['idle-2.png'],
sheet.textures['idle-3.png'],
],
run: [
sheet.textures['run-0.png'],
sheet.textures['run-1.png'],
sheet.textures['run-2.png'],
sheet.textures['run-3.png'],
sheet.textures['run-4.png'],
sheet.textures['run-5.png'],
],
attack: [
sheet.textures['attack-0.png'],
sheet.textures['attack-1.png'],
sheet.textures['attack-2.png'],
],
};
JSON 中定义动画(PixiJS 格式)
{
"frames": { ... },
"animations": {
"idle": ["idle-0.png", "idle-1.png", "idle-2.png", "idle-3.png"],
"run": ["run-0.png", "run-1.png", "run-2.png", "run-3.png"]
},
"meta": { ... }
}
AnimatedSprite 播放
import { AnimatedSprite } from 'pixi.js';
// 创建 AnimatedSprite
const hero = new AnimatedSprite(sheet.animations['idle']);
hero.anchor.set(0.5);
hero.position.set(400, 300);
// 动画配置
hero.animationSpeed = 0.15; // 值越大播放越快(帧/帧)
hero.loop = true; // 是否循环
hero.autoUpdate = true; // 自动跟随 ticker 更新
// 播放控制
hero.play(); // 开始播放
hero.stop(); // 停止
hero.gotoAndPlay(2); // 从第 2 帧开始播放
hero.gotoAndStop(0); // 跳到第 0 帧并停止
app.stage.addChild(hero);
AnimatedSprite 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|
animationSpeed | number | 1 | 播放速度(帧/帧) |
loop | boolean | true | 是否循环播放 |
playing | boolean | false | 是否正在播放 |
currentFrame | number | 0 | 当前帧索引 |
totalFrames | number | - | 总帧数 |
autoUpdate | boolean | true | 是否自动更新 |
动画状态切换
状态机管理
type AnimState = 'idle' | 'run' | 'jump' | 'attack' | 'die';
class CharacterAnimator {
private sprite: AnimatedSprite;
private animations: Record<AnimState, Texture[]>;
private currentState: AnimState = 'idle';
private isLocked: boolean = false;
constructor(animations: Record<AnimState, Texture[]>) {
this.animations = animations;
this.sprite = new AnimatedSprite(animations.idle);
this.sprite.anchor.set(0.5);
this.sprite.animationSpeed = 0.15;
this.sprite.play();
// 动画播放完毕回调
this.sprite.onComplete = () => {
this.isLocked = false;
this.play('idle');
};
}
play(state: AnimState, force: boolean = false) {
if (this.isLocked && !force) return;
if (this.currentState === state && !force) return;
this.currentState = state;
this.sprite.textures = this.animations[state];
this.sprite.gotoAndPlay(0);
// 非循环动画锁定状态
this.isLocked = (state === 'attack' || state === 'die');
}
get view(): AnimatedSprite {
return this.sprite;
}
}
// 使用
const animator = new CharacterAnimator({
idle: sheet.animations['idle'],
run: sheet.animations['run'],
jump: sheet.animations['jump'],
attack: sheet.animations['attack'],
die: sheet.animations['die'],
});
animator.view.position.set(400, 300);
app.stage.addChild(animator.view);
// 状态切换
app.ticker.add(() => {
if (input.kb.isDown('ArrowRight')) {
animator.play('run');
animator.view.scale.x = 1;
} else if (input.kb.isDown('ArrowLeft')) {
animator.play('run');
animator.view.scale.x = -1;
} else if (input.kb.wasPressed('Space')) {
animator.play('attack');
} else {
animator.play('idle');
}
});
帧回调 onFrameChange
hero.onFrameChange = (frame: number) => {
console.log(`当前帧: ${frame}`);
// 根据帧触发音效
if (hero.textures === runTextures && (frame === 1 || frame === 3)) {
playSound('footstep');
}
// 攻击判定帧
if (hero.textures === attackTextures && frame === 2) {
checkAttackHitbox();
}
};
// 播放完成回调
hero.onComplete = () => {
console.log('动画播放完毕');
};
// 循环回调
hero.onLoop = () => {
console.log('动画循环');
};
帧动画性能优化
| 策略 | 说明 |
|---|
| 降低 animationSpeed | 减少每秒更新次数 |
| 减少帧数 | 关键帧动画代替全帧绘制 |
| 使用 autoUpdate: false | 手动控制更新时机 |
| 对象池管理 | 复用 AnimatedSprite 实例 |
| 限制同屏动画数 | 超出屏幕的动画暂停播放 |
// 手动更新:仅在可见时播放
hero.autoUpdate = false;
app.ticker.add((ticker) => {
// 仅当在屏幕内时更新动画
if (hero.x > -100 && hero.x < 900) {
hero.update(ticker.deltaTime);
}
});
// 对象池复用:使用 get()/release() 方法管理 AnimatedSprite 实例,
// 避免频繁 new 和 destroy,参考第04章 ContainerPool 模式。
龙骨 DragonBones 集成
DragonBones 是一款骨骼动画工具,可用于创建比帧动画更流畅、文件更小的动画。
安装与使用
npm install @pixi/dragonbones
import { dragonBones } from '@pixi/dragonbones';
const factory = dragonBones.PixiFactory.factory;
const data = await fetch('/dragonbones/hero_ske.json').then(r => r.json());
const atlasData = await fetch('/dragonbones/hero_tex.json').then(r => r.json());
const image = await Assets.load('/dragonbones/hero_tex.png');
factory.parseDragonBonesData(data);
factory.parseTextureAtlasData(atlasData, image);
const armature = factory.buildArmatureDisplay('Hero');
armature.position.set(400, 500);
armature.animation.play('walk');
app.stage.addChild(armature);
DragonBones vs Spritesheet
| 特性 | Spritesheet 帧动画 | DragonBones 骨骼动画 |
|---|
| 制作成本 | 低 | 中高 |
| 文件大小 | 较大(每帧像素) | 较小(骨骼数据) |
| 动画流畅度 | 有限(帧数决定) | 高(骨骼插值) |
| 运行时修改 | 不可 | 可调整骨骼参数 |
| 适用场景 | 像素游戏、简单动画 | 角色动作复杂的游戏 |
游戏开发场景
场景:完整角色动画系统
class Character {
animator: CharacterAnimator;
sprite: AnimatedSprite;
facing: 1 | -1 = 1;
constructor(animator: CharacterAnimator) {
this.animator = animator;
this.sprite = animator.view;
}
moveRight(speed: number, delta: number) {
this.sprite.x += speed * delta;
this.facing = 1;
this.sprite.scale.x = 1;
this.animator.play('run');
}
moveLeft(speed: number, delta: number) {
this.sprite.x -= speed * delta;
this.facing = -1;
this.sprite.scale.x = -1;
this.animator.play('run');
}
idle() { this.animator.play('idle'); }
attack() { this.animator.play('attack'); }
}
⚠️ 常见问题
| 问题 | 原因 | 解决方案 |
|---|
| 帧动画闪烁 | 纹理尺寸不一致 | 确保所有帧尺寸相同 |
| 动画播放过快/过慢 | animationSpeed 设置不当 | 调整到合适值 |
| Spritesheet 加载失败 | JSON 路径或格式错误 | 检查 JSON 格式和路径 |
| 动画切换时跳帧 | 未重置 currentFrame | 调用 gotoAndPlay(0) |
| 大尺寸精灵表内存溢出 | 超出 WebGL 纹理尺寸限制 | 使用 2048×2048 或分页加载 |
| 帧回调不触发 | autoUpdate 为 false 且未手动更新 | 确保调用 hero.update() |
💡 进阶提示
帧动画倒放:
// 反转纹理数组实现倒放
const reversed = [...hero.textures].reverse();
hero.textures = reversed;
hero.gotoAndPlay(0);
动画过渡: 通过控制两个 AnimatedSprite 的 alpha 实现平滑过渡。淡出当前动画、淡入新动画,使用 ticker 控制过渡进度。
扩展阅读
上一章:08 - 输入交互(事件系统)
下一章:10 - 资源加载与管理(Assets)