Phaser 插件化架构:可复用系统不要靠复制 Scene 代码

讨论 Phaser 项目中全局插件、场景插件、服务层和可复用系统的边界,帮助活动小游戏和多项目代码沉淀。

复制 Scene 是最快的技术债

活动小游戏团队经常同时维护多个 Phaser 项目。第一个项目写了 Loading、音频、弹窗、埋点、广告奖励和调试面板。第二个项目为了赶时间,直接复制一份。第三个项目又从第二个复制,顺手改了几个字段。半年后,三个项目都有类似系统,但 Bug 修复要改三遍,接口还不完全一样。

我见过一个团队,奖励广告回调在 6 个小游戏里各有一份。某个渠道 SDK 改了回调字段,只有两个项目修了,另外四个项目上线后领取奖励失败。问题不是 Phaser 不支持复用,而是团队一直把可复用能力写在具体 Scene 里。

Phaser 提供了插件机制,也可以配合普通 TypeScript 服务层使用。关键不是所有东西都做成插件,而是判断哪些能力应该随游戏全局存在,哪些随 Scene 生命周期存在,哪些只是普通业务服务。插件化的目标是降低复制成本,不是把项目变成内部引擎。

flowchart TD
    A[Phaser Game] --> B[Global Plugins]
    A --> C[Scene Plugins]
    B --> D[Audio/Analytics/Platform]
    C --> E[InputOverlay/Debug/Modal]
    F[Domain Services] --> D
    F --> E
    G[Scenes] --> C
    G --> F

先区分插件和服务

不是所有复用代码都适合 Phaser Plugin。一个分数计算器、掉落表、存档迁移器,本质是业务服务,和 Phaser 生命周期关系不大。它们应该是普通 TypeScript 类,方便测试和复用。音频管理、全局埋点、平台 SDK、调试面板、Scene 注入工具,才更适合作为插件或插件管理的服务。

Global Plugin 适合游戏启动后一直存在的能力,比如平台适配、日志、资源版本、全局音频。Scene Plugin 适合每个 Scene 都可能用到,并且需要接入 Scene 生命周期的能力,比如输入辅助、弹窗层、调试绘制、计时器管理。

如果一个模块需要访问 this.scene、监听 Scene shutdown、创建 Phaser GameObject,它更可能是 Scene Plugin。如果它只是读配置、算数值、发请求,它更适合服务层。边界清楚,复用才不会被 Phaser 运行时绑死。

插件 API 要窄

插件最容易变成新的全局大对象。团队把 audio、save、ads、analytics、modal、config 全部挂到一个 game.services 上,任何地方都能改任何东西。短期方便,长期会失去边界。插件 API 应该窄,只暴露稳定动作,不暴露内部状态。

比如音频插件可以暴露 playSfxplayBgmsetMutedpauseByPage。不要让业务代码直接拿到底层 sound manager 后随意 stopAll。广告插件可以暴露 showRewarded(adUnit),返回明确结果,不要把渠道 SDK 原始对象传给 Scene。

窄 API 也方便做 mock。本地开发没有广告 SDK 时,mock 插件返回成功或取消;自动测试时,音频插件可以不播放声音。插件越像稳定接口,越容易跨项目复用。

生命周期比功能更重要

Phaser 插件接入点很多,真正要关注的是生命周期。Global Plugin 什么时候初始化,什么时候能访问配置,什么时候销毁?Scene Plugin 在 Scene create 前是否可用,Scene shutdown 时是否清理事件?如果生命周期不清楚,复用系统会制造更隐蔽的 Bug。

例如调试绘制插件会监听 Scene 的 postupdate,如果 Scene 关闭时不解绑,下次进入会重复绘制。弹窗插件创建了遮罩和容器,如果 Scene restart 后旧容器还在,就可能挡住新界面。平台插件监听 window 事件,也要在游戏销毁时解绑。

每个插件都应该写清楚 owns 什么资源:Phaser 对象、DOM 事件、window 事件、SDK 回调、定时器、缓存。拥有资源就要负责释放。插件化不是逃避生命周期,而是把生命周期集中管理。

复用系统要有配置入口

跨项目复用时,差异通常来自配置。音频默认音量、广告位 id、埋点 appId、弹窗皮肤、调试开关、资源 CDN 前缀,都不应该写死在插件里。插件启动时读取配置,并对配置做校验。

配置可以来自构建注入、远程配置或项目本地 JSON。无论来源如何,插件看到的应该是稳定结构。缺少广告位 id 时,广告插件应该明确进入 disabled 状态,而不是运行到 show 时才抛错。

可复用系统还要支持按项目覆盖。比如 Modal 插件提供默认按钮和遮罩,某个项目可以传入自定义皮肤工厂。不要为了一个项目的特殊需求改坏所有项目。

Scene 注入要克制

Phaser 允许把插件注入到 Scene 上,用起来像 this.modal.open()。这很方便,但也容易污染 Scene。注入名称要少而稳定,避免每个工具都往 Scene 上挂属性。否则新成员打开 Scene,会看到一堆不知道来自哪里的 this.xxx。

可以只注入少数高频能力,比如 this.uithis.debugTools,其他服务通过构造或上下文获取。TypeScript 项目还要补充类型声明,否则注入属性会失去类型保护。

注入能力也不应该绕过业务边界。this.modal.openReward() 可以展示奖励弹窗,但奖励是否可领仍应由 RewardService 判断。插件负责通用交互,业务服务负责规则。

内部工具也可以插件化

调试面板、性能 HUD、对象边界显示、输入录制、关卡跳转,这些开发工具非常适合插件化。它们通常跨项目复用,而且需要接入 Scene 生命周期。把工具写成插件,可以在开发环境启用,生产环境关闭或裁剪。

内部工具插件要尊重性能。不要在生产环境注册大量监听,也不要把 debug 数据打进正式包里。可以通过构建变量或配置开关控制。开发版功能丰富,正式版只保留必要的错误上报。

工具插件还要避免改变业务状态。调试跳关当然会改状态,但它应该通过正式服务接口改,而不是直接改 Scene 内部字段。这样调试行为也能暴露真实流程问题。

版本和兼容

如果复用系统被多个项目使用,就必须考虑版本。插件 API 改名、参数变化、返回值变化,都可能影响旧项目。不要在没有迁移说明的情况下直接改共享插件。至少用语义版本或 changelog 记录破坏性变化。

更实际的做法是把共享插件作为独立包或仓库目录,项目通过明确版本引用。小团队不一定需要发布 npm 包,但也不要复制粘贴。Git submodule、workspace、内部模板都可以,关键是有单一来源。

当 Phaser 3 到 Phaser 4 迁移时,插件层还能降低成本。如果业务 Scene 依赖的是 AudioPlugin 和 ModalPlugin,而不是到处直接调用底层 API,迁移只需要优先处理插件实现。

一个插件边界示例

下面示例展示一个场景弹窗插件的形状。它只负责 UI 层打开关闭,不负责奖励规则。

class ModalPlugin extends Phaser.Plugins.ScenePlugin {
  private stack: Phaser.GameObjects.Container[] = [];

  open(key: string, props: Record<string, unknown>) {
    const modal = createModalView(this.scene, key, props);
    this.stack.push(modal);
    return modal;
  }

  closeTop() {
    const modal = this.stack.pop();
    modal?.destroy();
  }
}

真实项目还需要遮罩、输入阻断、返回键、动画、层级和 shutdown 清理。但接口仍然围绕 open 和 close。业务代码不应该知道 modal 内部用了几个容器。

上线前检查清单

检查插件化系统时,问这些问题:模块是否真的需要 Phaser Plugin,API 是否足够窄,生命周期是否清理资源,配置是否可校验,Scene 注入是否有类型,生产环境是否关闭调试工具,跨项目版本是否可追踪,业务规则是否没有被插件吞掉。

还要做复用演练。把插件接入一个新空项目,看是否需要复制大量旧 Scene 代码。如果接入成本仍然很高,说明插件边界没有切好。复用系统的质量,不看它在原项目多好用,而看它离开原项目后是否还清楚。

从现有项目抽插件的顺序

已经写在 Scene 里的系统,不适合一次性全部抽出来。更稳的顺序是先抽纯服务,再抽生命周期明确的 Scene Plugin,最后才抽全局插件。比如先把奖励规则、配置校验、埋点事件名整理成普通模块;再把弹窗栈、调试绘制、输入辅助抽成 Scene Plugin;最后把平台 SDK、全局音频、错误诊断做成 Global Plugin。

每抽一个模块,都要保留一段适配层。旧 Scene 仍然可以调用原来的方法,但内部转到新插件。这样迁移可以逐步发生,不必一次改完所有场景。等所有调用都迁完,再删除旧入口。这个过程看起来慢,但比大规模替换后到处回归更可靠。

还要给插件写最小示例。一个空 Scene 如何安装插件,如何打开弹窗,如何播放音效,如何销毁。示例比长文档更容易让新项目接入。如果插件没有独立示例,说明它可能仍然依赖原项目的隐含上下文。

插件测试不要只测 happy path

插件一旦复用,失败路径比成功路径更重要。广告插件要测 SDK 不存在、广告加载失败、用户关闭、回调重复、页面切后台;音频插件要测未解锁、静音、恢复、资源缺失;弹窗插件要测连续打开、关闭顺序、Scene shutdown、返回键和遮罩点击。这些场景在单个项目里也许偶尔遇到,跨项目复用后会被放大。

可以给插件准备一个 playground Scene,专门展示所有功能和失败模拟。新项目接入插件前,先跑 playground,确认平台配置、资源和生命周期都正常。这个 Scene 也是插件升级后的回归入口。没有回归入口的共享插件,很快会变成“大家都不敢改”的公共代码。

文档也要记录非目标。比如 ModalPlugin 不负责奖励规则,AudioPlugin 不负责音乐版权策略,PlatformPlugin 不直接写存档。非目标写清楚,使用者才不会把所有需求都塞进插件。插件越克制,生命周期越长。

插件负责人也要明确。共享代码如果人人都能随手改,最后会没人真正维护。哪怕是小团队,也应该约定谁审核插件 API 变化,谁维护示例,谁决定破坏性升级是否值得。

这个约定不用复杂,哪怕只是一个 CODEOWNERS 或 README 里的维护人列表,也比公共插件无人负责更可靠。

复用代码最怕没有主人。

有维护边界的插件,才可能从一次项目沉淀成长期资产。

结语

Phaser 插件化不是为了显得工程化,而是为了让多个小游戏和多个 Scene 不再靠复制生长。把全局能力、Scene 生命周期能力和纯业务服务分开,项目才会越做越轻,而不是越复用越重。

好的插件像稳定工具,提供窄接口、清楚生命周期和可配置行为。它不抢业务规则,不泄漏内部状态,也不强迫所有项目接受同一种玩法。做到这点,Phaser 团队就能把一次项目经验沉淀成下一次项目的起点。

继续阅读

探索更多技术文章

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

全部文章 返回首页