Phaser + TypeScript 项目结构:Scene 之外也要有业务边界

介绍 Phaser TypeScript 项目中 Scene、服务层、配置、事件、测试入口和模块边界的组织方式,避免小游戏越写越散。

从一个真实问题开始

很多 Phaser 项目起步只有一个 main.ts 和几个 Scene,迭代两周以后,背包逻辑在 HudScene,怪物配置在 GameScene,广告回调直接改金币,测试入口靠手动点。代码不是不能跑,而是每次加功能都像在抽积木。小游戏也需要业务边界。

这个问题发生在一款持续运营的活动小游戏合集里。当时我的角色是负责工程化整理的客户端负责人,最先做的不是马上改代码,而是把玩家路径、设备环境、资源状态和场景切换顺序重新走了一遍。Phaser 项目很容易给人一种“代码都在前端,问题应该很好定位”的错觉;实际到了线上,浏览器、渠道容器、资源缓存、输入焦点和玩家习惯会一起参与结果。

这篇文章讨论的核心是:Scene 是运行时容器,不应该成为所有业务代码的归宿。如果只看 API,很容易把 Phaser 学成一组函数;如果从项目交付看,就必须关心边界、生命周期、失败兜底和调试证据。下面会围绕 活动小游戏从单关卡扩展到多模式、多皮肤、多渠道投放 展开,把经验落到可执行的工程判断上。

先看整体结构

flowchart TD
    A[main.ts 启动 Phaser] --> B[Scenes 运行时编排]
    B --> C[World/Entities 表现对象]
    B --> D[Services 业务规则]
    D --> E[Config 配置数据]
    D --> F[Save/Network/Ads 适配器]
    B --> G[Event Bus 语义事件]
    H[Test Harness] --> D
    H --> E

这张图不是为了显得复杂,而是提醒我们:玩家看到的是一个连续体验,工程上却是多个系统串起来的结果。TypeScript、Scene、service、domain、config、event bus、test harness 都有自己的职责,任何一个环节偷懒,最后都会变成“怎么偶尔不对”的线上问题。

一段可以落地的代码切口

下面这段示例不是完整框架,只是为了说明 TypeScript 模块边界 应该如何从一开始就留下边界。真实项目里可以继续封装,但不要在还没说清职责前就追求抽象。

export class ScoreService {
  private combo = 0;
  addKill(base: number) {
    this.combo += 1;
    return base * Math.min(4, this.combo);
  }
  resetCombo() {
    this.combo = 0;
  }
}

// GameScene 只负责调用和展示结果。
const gained = scoreService.addKill(enemy.score);

代码里的重点不是语法,而是控制权。Phaser 的对象和插件都很好调用,难点是不要让每个回调都直接修改全局状态。只要控制权分散,后续就会出现“这个字段到底是谁改的”“为什么第二次进入场景不一样”“为什么关闭弹窗后玩法状态变了”之类的问题。

小项目也会长大

Phaser 非常适合快速做原型,这也是它的魅力。但原型一旦进入投放、活动复用或长期维护,原来的写法就会开始拖后腿。最常见的症状是 Scene 过胖:create 里初始化一切,update 里处理一切,回调里修改一切。

TypeScript 的价值不只是类型提示,而是帮团队表达边界。哪些代码是运行时表现,哪些是业务规则,哪些是平台适配,哪些是配置读取,都应该有位置。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

Scene 负责生命周期和编排

Scene 适合处理资源加载、对象创建、输入绑定、时间循环、镜头和场景切换。它不适合长期保存复杂经济规则、关卡生成算法、广告奖励校验或存档迁移逻辑。后者放在 Scene 里,测试和复用都会变得困难。

一个健康的 GameScene 可以很忙,但不应该无所不知。它可以调用 LevelService 生成敌人,可以调用 ScoreService 计算分数,可以把 SaveService 的结果展示给 HUD,但规则本身不需要写在 Scene 方法里。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

目录结构按变化原因拆

不要只按 Phaser 类型拆成 scenes、sprites、assets。还要按业务变化原因拆。比如 domain 存纯规则,services 存可测试业务服务,adapters 存浏览器、广告、存储、网络实现,ui 存 HUD 和弹窗组件,config 存配置解析和校验。

这样改广告 SDK 不会影响分数规则,改关卡配置不会影响存档迁移,换一套 UI 皮肤也不会动核心战斗服务。模块边界不是为了漂亮,而是为了减少连锁修改。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

事件总线要有类型

Phaser 自带 EventEmitter 很方便,但字符串事件用多了会失控。scoreChanged、score.change、score:update 混在一起,重构时很难发现错误。TypeScript 项目最好给事件总线定义事件表,让 payload 类型可检查。

事件也要有语义。不要发布 rawClick 或 objectCreated 这种过于底层的事件给业务层,应该发布 player.damaged、level.completed、reward.claimed 这类意图清楚的事件。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

配置要解析和校验

小游戏配置经常来自 JSON、表格导出或远程灰度。直接在 Scene 里读取 config.enemy.speed,遇到缺字段时会在运行时炸。配置进入游戏前应该经过解析、默认值补齐和校验。

TypeScript 类型不能自动保证远程 JSON 正确。可以写轻量 validator,启动时检查关键字段。配置错误时给出可读日志和降级策略,而不是让玩家看到空白场景。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

测试入口不一定复杂

Phaser 游戏很难全量单测,但纯规则服务完全可以测试。分数、掉落、关卡波次、存档迁移、设备档位判断,这些都不依赖 canvas。把它们从 Scene 拿出来,就可以用普通 TypeScript 测试。

还可以做开发专用 test harness:直接进入第 5 关、模拟低端设备、注入旧存档、跳过广告回调、固定随机种子。它比手动从第一关点到问题场景节省太多时间。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

平台适配器要隔离

H5 游戏常接广告、登录、分享、支付、埋点、渠道 SDK。最危险的写法是 SDK 回调直接改 Scene 状态。平台代码应该通过 adapter 封装,返回明确结果,再由业务服务决定如何处理。

这样做还有一个好处:本地开发时可以用 mock adapter。没有真实广告 SDK,也能测试奖励流程;没有登录,也能测试游客存档。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

落地清单

整理 Phaser TypeScript 项目时,先看这些信号:Scene 是否超过千行;业务规则是否能离开 canvas 测试;事件是否有类型;配置是否校验;平台 SDK 是否被封装;开发入口是否能直达问题场景;存档和网络是否不直接依赖具体 Scene。

Phaser 项目不需要一开始就上很重的架构,但需要给增长留位置。Scene 之外有业务边界,小游戏才能从一次性 Demo 变成可维护的产品。

在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。

排查问题时的顺序

遇到相关问题时,不建议先凭经验改参数。更稳的顺序是先复现,再缩小范围,最后才动代码。复现时要记录设备、浏览器、渠道容器、网络、页面可见状态、游戏版本和资源版本。很多 H5 游戏问题只在特定容器里出现,如果只在桌面 Chrome 里验证,很容易得到错误结论。

缩小范围时,可以把链路拆成输入、状态、资源、表现和持久化几段。先确认玩家意图有没有被收到,再确认状态机有没有接受,再确认 Phaser 对象有没有正确执行,再确认表现层有没有被镜头、缩放、缓存或音频策略影响。这样的排查路径比“看哪里像问题就改哪里”慢一点,但能避免改出新问题。

最后是留证据。开发版日志、调试面板、可视化边界、状态快照和小型回放,都比口头描述可靠。尤其是涉及 TypeScript 模块边界 的问题,录屏只能告诉你现象,不能告诉你内部状态。把内部状态展示出来,团队才有共同语言。

团队协作里的责任划分

Phaser 项目经常由少数工程师快速推进,因此容易忽略协作边界。可是一旦项目进入运营,策划会改配置,美术会换资源,运营会调整活动,渠道会接 SDK,测试会覆盖多设备。工程代码如果没有把责任划清,每个角色都会被迫理解太多底层细节。

比较健康的方式是让配置描述意图,让服务层解释规则,让 Scene 编排生命周期,让表现对象执行动画和反馈,让平台适配器处理浏览器或渠道差异。这样策划新增内容时不需要知道 Scene 的内部结构,美术替换资源时不会改变玩法规则,运营关闭活动时不会留下半开半关的 UI 状态。

这不是大团队才需要的流程。越小的团队越需要减少隐性沟通成本。一个清楚的边界,可以让后续每一次临时需求都少一点风险。

上线前最后一轮检查

最后一轮检查不要只点一遍主流程。至少要覆盖首次进入、第二次进入、弱网、低端设备、后台恢复、快速重复点击、资源失败、配置缺字段和旧数据升级。很多 Phaser Bug 都出现在“第二次”或者“恢复后”:第二次开局、第二次打开弹窗、第二次播放音频、第二次加载同一图集。

如果这篇文章讨论的系统已经接近上线,我会要求团队给出三类证据。第一是功能证据,证明主流程确实可用;第二是边界证据,证明失败和异常路径不会把玩家卡死;第三是观测证据,证明线上再出问题时能定位。只有这三类证据都存在,才算不是靠运气发布。

结语

Phaser 的优势是轻、快、直接。它能让一个想法很快变成可以玩的东西,也正因为如此,项目很容易在“先跑起来”之后忽略工程边界。Scene 是运行时容器,不应该成为所有业务代码的归宿。把这个原则落实到代码里,项目就不会因为功能增加而迅速失控。

真正可靠的 Phaser 游戏,不是每个模块都写得很重,而是关键链路有清楚的生命周期、明确的责任、可降级的失败路径和能解释问题的调试证据。做到这些,即使项目仍然保持轻量,也能承受上线后的真实流量和频繁改动。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页