每个游戏开发教程的开头都如出一辙:彩色矩形在灰色画布上滑动。这对于学习语法来说没问题,但它无法教会你游戏引擎是如何“呼吸”的。我不想再玩矩形那一套了。我想构建一些感觉像真实游戏的东西——一个具有移动、战斗、HUD 和声音的俯视角地牢探索游戏。

我决定使用 Phaser v4,并给自己定下了一条铁律:零外部资源。没有图像文件,没有音频剪辑,也没有 Webpack 或 Vite 之类的构建工具。整个项目必须存在于一个用纯 JavaScript 编写的单个 HTML 文件中。这种约束并非为了极简主义而极简,而是为了消除所有的借口和每一个“黑盒”。当你无法通过下载一个 sprite pack 来掩盖知识盲区时,你就会被迫去学习引擎在底层是如何管理纹理、动画、音频和状态的。

用代码绘制世界

在一个普通的 Phaser 项目中,你会通过在 preload 函数中调用 this.load.image() 并将引擎指向一个 PNG 文件。在没有这种选项的情况下,你会转向 Graphics 对象。你实例化它,绘制原始形状——用于地板瓦片的矩形、用于墙壁的粗线条,或者可能是一个用于玩家标记的圆形——然后调用 generateTexture。该方法会捕获图形缓冲区,并根据你选择的键将其注册到 Phaser 的纹理管理器中。

从那一刻起,引擎对待那个生成的位图就像对待加载的图像文件一样。你可以将其分配给 tilemaps,将其切割成 sprites,或者对其进行着色。对于这款地牢探索游戏,这意味着我可以程序化地生成地板网格,盖上墙壁片段,并在不离开代码编辑器的情况下迭代调色板。实际的教训是:纹理只是内存中的一块位图数据。Phaser 并不会在意它是通过 HTTP 请求到达的,还是通过手写的 Graphics 调用生成的。

这种方法还会让你深思绘图顺序和批处理(batching)。当每一面墙和每一块地板瓦片都来自同一系列生成的纹理时,你就会开始关注 Phaser 如何对渲染调用进行分组。你会注意到静态 tilemap 层与单个对象的 spritemap 之间的区别,因为是你自己在手动决定哪些对象值得作为一个纹理实例存在。

无 Spritesheets 的动画实现

静态的正方形很快就会让人感到乏味,但 Phaser 的动画系统需要一个 spritesheet——通常是一个将帧排列在网格中的单个 PNG 文件。我使用一个离屏 HTML canvas 元素复制了那条纹理带。对于动画的每一帧,我都会清除画布并绘制一个新的姿态:一个简单的挥剑动作、一个两步走的步行循环,或者一个敌人的待机晃动。一旦纹理带完成,我就将其作为 spritesheet 注册到 Phaser 中,并定义帧的宽度和高度,以便引擎知道一个姿态在哪里结束,下一个姿态从哪里开始。

手动进行这些操作揭示了动画在表象之下的本质:共享纹理上的一组帧边界。Phaser 的动画组件需要起始帧、结束帧和帧率。然后,它在游戏时钟的每次 tick 中,通过这些矩形切片移动指针。你不再把 spritesheet 看作是由艺术家制作的神奇资产,而是开始将其视为坐标数学。当你以后需要切割真实的艺术资源,或者调试为什么动画帧播放顺序错误时,这种视角将是无价的。

从无到有的声音

音频文件是被禁止的,所以我直接使用了 Web Audio API。几行 JavaScript 就可以创建一个 oscillator node,将其设置为方波或正弦波,通过一个 gain node 进行传输,并调度一段短促的声音。我为常见事件编写了微型辅助函数:用于脚步声的低频哔哔声,用于捡起战利品的上升啁啾声,以及用于受到伤害的刺耳方波音。

这些合成声音在设计上只是占位符,但它们能立即为游戏提供机械反馈。在决定录制或寻找真实音频之前,你可以先感受时机是否正确。真正的回报在于架构设计。因为你将声音触发器封装在一个简单的函数中,以后将合成的哔哔声更换为加载的 sound buffer 只需改动一行代码。游戏的其余部分——碰撞事件、UI 闪烁、分数增加——都保持不变。你先原型化手感,然后再升级保真度。

管理多个世界

一个真正的游戏需要不止一个屏幕,所以我将项目拆分为独立的 Phaser scenes:Menu、Game、UI 和 Pause。UI scene 与 Game scene 并行运行,同时启动,这样血条和分数计数器就可以在它们自己的沙盒中运行,而地牢探索则在下方进行。它们严格通过事件进行通信。当玩家受到伤害时,Game scene 会发出一个变更事件。UI scene 监听该事件并更新其文本对象。Game scene 不导入 UI,不调用其方法,甚至不检查它是否存在。它只是将数据发送到“虚空”中。这种解耦意味着你可以为了测试而移除 HUD,或者完全替换它,而无需触动核心游戏循环。

对于暂停功能,我使用了一个叠加在 Game scene 之上的 Pause scene。至关重要的一点是,在 Game scene 上调用 scene.pause() 实际上会冻结物理世界并停止计时器。Game scene 停止更新,但 Pause scene 保持活跃,以渲染菜单并等待取消暂停的信号。如果你以前一直只能通过在一个巨大的 update loop 中使用布尔标志来管理暂停状态,那么这种方式感觉就像发现了电灯开关。引擎为你提供了一个真正的暂停生命周期,而不是强迫你在代码中到处充斥着 if (isPaused) return 这样的保护语句。

从真实 Bug 中吸取的教训

如此接近底层的工作暴露了两个我需要改变的习惯。

首先,我尝试使用 Phaser v4 中一个看起来是 public 但并非文档 API 的方法。它在版本之间发生了变化,导致我的构建失败。我进行了重构,改用 getChildren(),这是一个稳定且有文档说明的 public 方法,不稳定性随之消失。教训很直接:如果一个方法不在官方文档中,不要基于它构建你的游戏。内部 API 之之所以是内部的,是有原因的。坚持使用公开的 API 区域,你的项目才能在引擎更新中幸存下来。

其次,我学到了永远不要把一切都交给事件。对于捡起金币或打开宝箱这类低风险的交互,事件回调工作得非常完美。但对于关键的状态转换——尤其是 Game Over——我在主 update loop 中添加了一个冗余检查。如果监听器被移除、场景在尴尬的微秒内暂停,或者在发射与处理之间出现了竞态条件,事件可能会误触发。通过在 update loop 中直接检查玩家的生命值,并在其归零时强制进入游戏结束状态,我确保了如果事件未能触发,游戏永远不会陷入停滞状态。事件仍然处理次要效果——屏幕抖动、声音提示、分数提交——但权威逻辑存在于游戏时钟所在的地方。

为什么你应该尝试这样做

如果你正在学习游戏开发,请在你的下一个项目中施加这个精确的约束:没有外部资源,只有一个 HTML 文件。这听起来很受限,但它消除了所有的借口。你不需要配置 bundler,不需要与本地音频文件的 CORS 错误作斗争,也不需要花一个下午去挑选免费的资源包。你编写代码,刷新浏览器,然后看到结果。

更重要的是,你会理解引擎为什么会表现成那样。你会知道纹理是如何进入 GPU 的,因为你调用了 generateTexture。你会知道动画帧是如何索引的,因为你手动注册了边界。你会知道音频是如何到达扬声器的,因为你连接了振荡器。这些知识可以直接迁移到使用外部资源的大型项目中,因为底层机制永远不会改变——引擎