强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 打包工具

使用流程

  1. 准备单帧 PNG 图片
  2. 导入 TexturePacker
  3. 选择输出格式(JSON Hash / JSON Array / PixiJS)
  4. 导出 .png + .json

推荐设置

设置项推荐值
AlgorithmMaxRects
Max size2048×2048 或 4096×4096
Allow rotation❌ 关闭(避免旋转导致混淆)
Trim modeTrim(去除透明边缘)
FormatJSON Hash
File formatRGBA8888

免费替代方案

工具名平台链接
TexturePacker全平台https://www.codeandweb.com/texturepacker
Free Texture PackerWebhttps://free-tex-packer.com/
ShoeBoxWindowshttps://renderhjs.net/shoebox/
spritesheet.jsNode.jshttps://github.com/krzysztof-o/spritesheet.js

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 属性

属性类型默认值说明
animationSpeednumber1播放速度(帧/帧)
loopbooleantrue是否循环播放
playingbooleanfalse是否正在播放
currentFramenumber0当前帧索引
totalFramesnumber-总帧数
autoUpdatebooleantrue是否自动更新

动画状态切换

状态机管理

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()

💡 进阶提示

  1. 帧动画倒放:

    // 反转纹理数组实现倒放
    const reversed = [...hero.textures].reverse();
    hero.textures = reversed;
    hero.gotoAndPlay(0);
    
  2. 动画过渡: 通过控制两个 AnimatedSprite 的 alpha 实现平滑过渡。淡出当前动画、淡入新动画,使用 ticker 控制过渡进度。


扩展阅读


上一章:08 - 输入交互(事件系统) 下一章:10 - 资源加载与管理(Assets)