从一个真实问题开始
团队第一次把试玩版做出来时,所有人都很开心:角色能移动,怪物能刷,结算弹窗也能出现。可是第二周开始,问题集中爆发。重开一局后音效播放两次,暂停菜单偶尔拿不到当前分数,排行榜弹窗关闭后键盘输入失效,最奇怪的是从新手关跳到活动关以后,老场景里的计时器还在后台扣血。
这个问题发生在一款横版 Roguelite H5 小游戏里。当时我的角色是客户端主程,最先做的不是马上改代码,而是把玩家路径、设备环境、资源状态和场景切换顺序重新走了一遍。Phaser 项目很容易给人一种“代码都在前端,问题应该很好定位”的错觉;实际到了线上,浏览器、渠道容器、资源缓存、输入焦点和玩家习惯会一起参与结果。
这篇文章讨论的核心是:Scene 不是“一个页面”,而是一组有生命周期、有职责、有通信方式的运行单元。如果只看 API,很容易把 Phaser 学成一组函数;如果从项目交付看,就必须关心边界、生命周期、失败兜底和调试证据。下面会围绕 从大厅进入战斗、暂停、复活、结算再回到大厅的完整链路 展开,把经验落到可执行的工程判断上。
先看整体结构
flowchart TD
A[BootScene 初始化运行环境] --> B[PreloadScene 加载资源]
B --> C[GameScene 创建世界和玩法对象]
C --> D[HudScene 读取只读状态并显示]
D --> E{玩家操作}
E -->|暂停| F[PauseScene 覆盖输入和时间]
E -->|死亡或通关| G[ResultScene 展示结算]
F --> C
G --> H[停止 Game/Hud 并回到入口]
这张图不是为了显得复杂,而是提醒我们:玩家看到的是一个连续体验,工程上却是多个系统串起来的结果。BootScene、PreloadScene、GameScene、HudScene、PauseScene、ResultScene 都有自己的职责,任何一个环节偷懒,最后都会变成“怎么偶尔不对”的线上问题。
一段可以落地的代码切口
下面这段示例不是完整框架,只是为了说明 场景分层 应该如何从一开始就留下边界。真实项目里可以继续封装,但不要在还没说清职责前就追求抽象。
class BootScene extends Phaser.Scene {
create() {
this.scene.start('PreloadScene');
}
}
class PreloadScene extends Phaser.Scene {
preload() {
this.load.setPath('/assets/game');
this.load.atlas('hero', 'hero.png', 'hero.json');
this.load.json('level-01', 'levels/level-01.json');
}
create() {
this.scene.start('GameScene', { levelId: 'level-01' });
this.scene.launch('HudScene');
}
}
代码里的重点不是语法,而是控制权。Phaser 的对象和插件都很好调用,难点是不要让每个回调都直接修改全局状态。只要控制权分散,后续就会出现“这个字段到底是谁改的”“为什么第二次进入场景不一样”“为什么关闭弹窗后玩法状态变了”之类的问题。
Scene 不是万能容器
Phaser 的 Scene 很容易被误解成传统前端里的页面组件。它确实可以承载一个页面,但它还同时拥有时间循环、显示列表、输入管理、资源引用、物理世界和事件系统。把所有东西写进一个 GameScene,短期看起来直接,长期会让生命周期变得不可控。问题不是代码行数太多,而是每个对象都不知道自己跟着谁出生、跟着谁死亡。
一个比较稳的判断标准是:如果某段逻辑需要独立暂停、独立覆盖输入、独立重启,或者它的视觉层级天然高于玩法世界,就应该考虑拆成独立 Scene。HUD、暂停菜单、结算层、调试面板都很适合这样处理。反过来,普通怪物、子弹、掉落物通常不应该单独成为 Scene,它们属于当前玩法世界。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
Boot 和 Preload 要保持薄
很多项目把初始化、账号、配置、资源加载、埋点、广告 SDK 都塞进 BootScene,结果 Boot 变成第二个主场景。BootScene 的价值是把游戏带到一个可启动状态,不是承担所有启动业务。它最好只做能力检查、基础配置和下一个 Scene 的选择。
PreloadScene 也要保持诚实。它负责加载进入首个可玩体验所必需的资源,不负责把整款游戏全量下载完。对于 Web 游戏,玩家打开页面到看到首屏的时间非常敏感。先进入一个可操作的场景,再在后台补齐非关键资源,通常比把 Loading 条做得更漂亮有效。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
GameScene 管世界,HudScene 管读数
GameScene 应该拥有地图、角色、敌人、碰撞、镜头和玩法时间。HudScene 则应该展示血量、分数、技能冷却、任务提示和临时浮层。两者之间的关系必须克制:HUD 可以订阅状态变化,但不要直接改玩家速度、刷怪节奏或物理碰撞。否则某次 UI 重构就可能改变战斗手感。
在项目实践里,我更愿意让 GameScene 暴露一个窄的状态快照,或者通过事件总线发布语义事件,例如 score.changed、player.damaged、skill.cooldown.updated。HudScene 只消费这些事件,必要时发出 pause.request 或 revive.clicked 这类意图,由玩法层决定是否接受。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
Scene 通信要少而明确
Phaser 提供了 this.scene.get、事件系统、registry、data manager 等多种通信方式。选择太多时,团队反而容易混用。我的经验是:跨 Scene 的一次性启动参数用 scene.start 的 data;全局但低频的设置用 registry;实时玩法事件用专门的事件总线;调试数据允许走更直接的读取路径,但不要混进业务。
最危险的写法是 A Scene 在任意时刻拿到 B Scene 实例并直接调用内部方法。它看起来最方便,也最容易形成隐形依赖。后面只要 B Scene 重启、休眠、替换为新版本,A Scene 就可能拿到过期引用。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
暂停不是简单停时间
暂停看起来只是 physics.pause 或 scene.pause,但真实项目不会这么简单。暂停时需要处理输入焦点、音频、计时器、动画、广告恢复、浏览器失焦、联网小游戏的心跳和结算状态。不同系统对暂停的理解也不同:玩法可以停,UI 动画可以继续,网络请求不能停,诊断日志也不能停。
因此 PauseScene 不应该只是一块半透明遮罩。它应当成为暂停状态的协调者:请求 GameScene 暂停玩法时间,接管输入,标记当前暂停原因,恢复时按顺序解除。尤其在移动浏览器中,页面进入后台再回来,很可能同时触发 visibilitychange、blur、audio suspend,这些都要纳入暂停模型。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
重启场景要检查残留
Scene 重启后最常见的问题是残留事件监听和残留计时器。比如 GameScene 每次 create 都监听一次 window resize,却没有在 shutdown 里解绑;每次进入战斗都注册一次全局事件,离开时没有 off;每次开局都创建一个 setInterval,重开后旧 interval 还在后台运行。玩家看到的是“第二局开始越来越怪”,工程上却是生命周期没有闭合。
建议为每个主 Scene 写一个 shutdown 清单:停止 timer event、解绑外部事件、清理对象池、取消网络回调、释放一次性引用、关闭调试覆盖层。Phaser 会帮你销毁显示对象,但它不会知道你在 DOM、window、SDK 或自定义事件中心上挂了什么。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
可观测性要嵌进场景切换
Scene 问题往往不是崩溃,而是状态错乱。没有观测时,团队只能看录屏猜。可以在开发版加一个 Scene 面板,显示当前活跃 Scene、sleep/pause 状态、对象数量、timer 数量、最近 20 条场景事件。这个面板不会让架构自动变好,但能让问题从玄学变成证据。
线上也可以记录关键链路:启动耗时、资源加载耗时、首个 GameScene create 时间、暂停次数、重启次数、进入结算的原因。小体量 H5 游戏不需要复杂 APM,但至少要知道玩家卡在哪个阶段。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
落地清单
每增加一个 Scene,都要回答四个问题:它是否拥有独立生命周期;它是否需要独立输入;它是否能被暂停或覆盖;它退出时要释放哪些外部资源。如果这四个问题答不清,说明这个 Scene 很可能只是为了代码分文件而创建。
最终的目标不是把 Scene 拆得越多越好,而是让每个 Scene 都有清楚的边界。Boot 负责启动,Preload 负责首屏资源,Game 负责玩法世界,HUD 负责表现读数,Overlay 负责临时控制。这样的项目即使功能继续增长,也不会因为一次结算弹窗改动把战斗逻辑带崩。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
排查问题时的顺序
遇到相关问题时,不建议先凭经验改参数。更稳的顺序是先复现,再缩小范围,最后才动代码。复现时要记录设备、浏览器、渠道容器、网络、页面可见状态、游戏版本和资源版本。很多 H5 游戏问题只在特定容器里出现,如果只在桌面 Chrome 里验证,很容易得到错误结论。
缩小范围时,可以把链路拆成输入、状态、资源、表现和持久化几段。先确认玩家意图有没有被收到,再确认状态机有没有接受,再确认 Phaser 对象有没有正确执行,再确认表现层有没有被镜头、缩放、缓存或音频策略影响。这样的排查路径比“看哪里像问题就改哪里”慢一点,但能避免改出新问题。
最后是留证据。开发版日志、调试面板、可视化边界、状态快照和小型回放,都比口头描述可靠。尤其是涉及 场景分层 的问题,录屏只能告诉你现象,不能告诉你内部状态。把内部状态展示出来,团队才有共同语言。
团队协作里的责任划分
Phaser 项目经常由少数工程师快速推进,因此容易忽略协作边界。可是一旦项目进入运营,策划会改配置,美术会换资源,运营会调整活动,渠道会接 SDK,测试会覆盖多设备。工程代码如果没有把责任划清,每个角色都会被迫理解太多底层细节。
比较健康的方式是让配置描述意图,让服务层解释规则,让 Scene 编排生命周期,让表现对象执行动画和反馈,让平台适配器处理浏览器或渠道差异。这样策划新增内容时不需要知道 Scene 的内部结构,美术替换资源时不会改变玩法规则,运营关闭活动时不会留下半开半关的 UI 状态。
这不是大团队才需要的流程。越小的团队越需要减少隐性沟通成本。一个清楚的边界,可以让后续每一次临时需求都少一点风险。
上线前最后一轮检查
最后一轮检查不要只点一遍主流程。至少要覆盖首次进入、第二次进入、弱网、低端设备、后台恢复、快速重复点击、资源失败、配置缺字段和旧数据升级。很多 Phaser Bug 都出现在“第二次”或者“恢复后”:第二次开局、第二次打开弹窗、第二次播放音频、第二次加载同一图集。
如果这篇文章讨论的系统已经接近上线,我会要求团队给出三类证据。第一是功能证据,证明主流程确实可用;第二是边界证据,证明失败和异常路径不会把玩家卡死;第三是观测证据,证明线上再出问题时能定位。只有这三类证据都存在,才算不是靠运气发布。
结语
Phaser 的优势是轻、快、直接。它能让一个想法很快变成可以玩的东西,也正因为如此,项目很容易在“先跑起来”之后忽略工程边界。Scene 不是“一个页面”,而是一组有生命周期、有职责、有通信方式的运行单元。把这个原则落实到代码里,项目就不会因为功能增加而迅速失控。
真正可靠的 Phaser 游戏,不是每个模块都写得很重,而是关键链路有清楚的生命周期、明确的责任、可降级的失败路径和能解释问题的调试证据。做到这些,即使项目仍然保持轻量,也能承受上线后的真实流量和频繁改动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。