Toolkitly

如何在 Phaser 3 中加载精灵图

Phaser 是三者中的异类。Godot 和 Unity 的切图器长在编辑器里; Phaser 只有两个加载函数和一个配置对象,图是在代码里描述的。 这让它成了三者中唯一原生就能读紧凑图集的—— 也是唯一一个「最常见的故障是什么都没加载出来,而控制台并不明说为什么」的。

0. 用 HTTP 提供它,否则下面全都不成立

把这条放最前面,是因为它耗掉的时间比这一整页其他内容加起来还多。 Phaser 通过网络请求资源,而浏览器在 file:// URL 下会拒绝跨源请求。双击打开 index.html, 每一张图都会失败,画布保持空白或者显示绿框, 而控制台的消息是关于 CORS 的,不是关于你那张图的。

在项目文件夹里随便跑一个本地服务器——npx servepython -m http.server、 Live Server 扩展,或者你的打包器本来就带的那个—— 然后访问 http://localhost:…

如果某个资源还是不出现,浏览器的 Network 面板比 Phaser 更快给你答案: 404 意味着路径是相对页面错的,而不是相对源文件错的。

1. 两个加载器,由图本身决定用哪个

后面所有事情都跟着这个选择走,包括用哪个函数来生成动画帧。

load.spritesheet

均匀网格——每一帧一样大

你给它一个格子的像素尺寸。帧按从左到右、从上到下编号,从 0 开始。 不涉及第二个文件。

load.atlas

紧凑图集——帧大小不一

你给它一个 PNG 和一个描述每帧位置的 JSON 文件。帧按名字引用,而不是按下标。

还有更多:load.atlasXML 对应 Starling 和 Adobe 的格式,load.unityAtlas 对应 Unity 的,load.multiatlas 对应拆在好几张图上的图集。 它们是同一个思路配不同的解析器。

2. load.spritesheet —— frameWidth 是尺寸,不是数量

这是最常见的一个错误,而它源自和其他引擎的命名冲突。 Unity 的 Sprite Editor 提供「Grid by Cell Count」,你填的是8 列。 Godot 的切图对话框要的是8 和 4。 Phaser 要的是一帧的像素尺寸。 一张 256×128、每帧 32px 的图,填的是 3232, 不是 84

function preload () {
  this.load.spritesheet('hero', 'assets/hero.png', {
    frameWidth: 32,
    frameHeight: 32,
    margin: 0,    // 网格外围的一圈边距
    spacing: 0    // 相邻帧之间的空隙
  })
}

marginspacing 存在, 是因为导出器常在格子之间留一两个像素,防止 GPU 跨帧采样。 图有留白而你没填这两个值,网格就会每往右一列偏得更多一点—— 检查最后一帧,不是第一帧,因为第 0 帧无论如何都看着是对的。

startFrameendFrame 能把加载限制在图的一部分上,但很少有理由这么做; 全部加载进来、每段动画各自挑下标,更简单。

如果这张图不是均匀网格,这个加载器描述不了它。那是第 4 节的事。

3. 把动画建出来

加载发生在 preload 里,其余一切发生在 create 里。 在 preload 里创建精灵必定得到一个「贴图不存在」的错误, 因为文件还没到。

function create () {
  this.anims.create({
    key: 'run',
    frames: this.anims.generateFrameNumbers('hero', { start: 0, end: 7 }),
    frameRate: 10,
    repeat: -1          // -1 是无限循环;默认的 0 只播一次
  })

  const hero = this.add.sprite(160, 120, 'hero')
  hero.play('run')
}

repeat: -1 是最容易漏掉的一个。默认值是 0, 意思是播一次然后停在最后一帧—— 而当你期待的是一个走路循环时,这看起来就是「动画坏了」。

帧下标按行优先、从 0 开始。在一张 8 列宽的图上,第 2 行是第 8 到第 15 帧。 这里搞错了,结果就是一段帧数对、行数错的动画。

动画是全局的,不是每个场景各一份。this.anims 是整个游戏唯一的 Animation Manager。 重启场景后,第二次用同一个 key 调 anims.create 会警告并且什么也不做。加个保护:

if (!this.anims.exists('run')) {
  this.anims.create({ /* … */ })
}

图集则把 generateFrameNumbers 换成 generateFrameNames,它拼名字而不是数数:

frames: this.anims.generateFrameNames('hero', {
  prefix: 'run_',
  start: 1,
  end: 8,
  zeroPad: 4,        // run_0001.png
  suffix: '.png'
})

这些名字必须和图集 JSON 里的完全一致,补零位数也算——zeroPad 差一位,就会得到一段空动画,而且不报任何错。

在写代码之前先把 frameRate 定下来。 10 是个合理的默认值,8–12 覆盖大多数像素画, 但那个读起来对的数字是看出来的,不是算出来的。精灵动画预览器能在浏览器里以任意 FPS 播放这张图, 这样你只用把答案填一次,而不是改一次刷新一次。

4. 紧凑图集——Phaser 做得到而另外两个做不到的事

Godot 根本没有图集 JSON 的导入器。Unity 只有它自己格式的那一个。 Phaser 能读大多数打包器输出的 TexturePacker JSON, Array 和 Hash 两种排布都行,两个参数搞定:

this.load.atlas('hero', 'assets/hero.png', 'assets/hero.json')

那个格式带的不只是位置——trimmedspriteSourceSizesourceSize 让打包器可以把每一帧的透明边裁掉,再由 Phaser 在绘制时补回去, 于是一张大半是空白的图能小很多,而精灵不会移位。

我们的 精灵图生成器 就导出这种格式。把清单格式设成 TexturePacker(JSON Array)——默认就是它—— 下载 PNG 和 JSON,上面那个两参数的调用就是全部的集成工作。 这时打开裁边也是安全的: 清单记下了裁掉多少,Phaser 会补回去,图变小了而画面上什么都没动。

如果你的清单是别的形状——自己写的导出器,一种没人解析的格式—— 你也不需要转换器。Phaser 可以直接吃帧矩形:

function preload () {
  this.load.image('hero', 'assets/hero.png')
  this.load.json('heroFrames', 'assets/hero.json')
}

function create () {
  const texture = this.textures.get('hero')
  for (const f of this.cache.json.get('heroFrames').frames) {
    texture.add(f.filename, 0, f.x, f.y, f.w, f.h)
  }

  // 这些帧现在可以按名字寻址了,和真正的图集一样
  this.add.sprite(160, 120, 'hero', 'idle_01.png')
}

十二行,不需要构建步骤。它表达不了的是裁边—— 没有地方能说明某一帧被裁过——所以走这条路就别裁边打包。

有一句话值得直说:把图打包好,别发一堆散 PNG。六十次 load.image 就是六十个请求、六十张 GPU 贴图, 而每一次切换贴图的绘制都会打断合批。 一张图是一个请求、一张贴图,Phaser 能把整个场景合批。 如果你的帧现在是分开的文件,那正是精灵图生成器要干的事; 如果别人扔给你一张大图而你需要把帧取出来,精灵图切割工具走的是相反的方向。

5. 让像素画保持锐利

一个配置项干了大半的活。pixelArt: true 会在整个游戏范围内关掉贴图平滑, 这相当于 Godot 的 Nearest 过滤和 Unity 的 Point 过滤模式在 WebGL 里的对应物。

const config = {
  type: Phaser.AUTO,
  width: 320,
  height: 180,
  zoom: 4,             // 整数 —— 屏幕上是 1280x720
  pixelArt: true,
  roundPixels: true,
  scene: { preload, create }
}

按小分辨率设计,然后放大。一个 320×180 的游戏世界在 zoom: 4 下填满 1280×720, 每个像素正好是四个屏幕像素见方。 改成 width: 1280 再把精灵放大 4 倍, 得到的是同一幅画面、到处更难看的数字, 以及大得多的、一不小心就落到小数倍上的空间。

roundPixels 强制精灵绘制在整数像素位置上。 不开它,一个 x = 100.5 的精灵会跨两列像素渲染,移动时看上去在闪—— 动起来看得见,截图里看不见。

留意 Scale Manager。Phaser.Scale.FIT 会把画布拉到填满窗口, 而且很乐意停在 2.37 这样的比例上,把上面做的一切都撤销掉。 像素画要么关掉缩放、挑一个固定 zoom, 要么用 FIT 并接受锐利度只是近似的。没有一个设置能两者兼得。

另外检查 setScale 调用和摄像机 zoom 里有没有非整数—— 一个把精灵从 1 缓动到 1.2 的补间,会经过中间每一个小数值。

常见故障

画布空白,控制台里全是 CORS 错误

页面跑在 file:// 上。用 HTTP 提供这个文件夹。

本该是精灵的地方是绿框

那是 Phaser 的「贴图缺失」占位。key 写错了,或者文件 404 了—— 查代码之前先看 Network 面板。

整张图变成了一个巨大的精灵

frameWidth 和 frameHeight 没填,加载器把这张图当成了单独一帧。

帧有偏移,而且越往后越偏

这张图有没被声明的间距或边距。误差是累积的——去检查最后一列。

动画播一次就停了

repeat 默认是 0。设成 repeat: -1。

重启场景后提示动画 key 已存在

动画活在全局管理器上。把创建包在一个 anims.exists 检查里。

图集动画是空的,而且不报错

generateFrameNames 拼出来的名字在 JSON 里不存在—— 通常是 zeroPad 或者 suffix 的问题。 打印 this.textures.get(key).getFrameNames() 对一下。

像素画是糊的

游戏配置里少了 pixelArt: true。

精灵移动时在闪

位置或缩放是小数。打开 roundPixels, 并用整数 zoom 代替 Scale.FIT。

这里用到的工具