剧情系统不是把文字打出来
很多 Phaser 小游戏最初只需要一句提示:“公主被困住了,去救她。”于是工程写一个半透明框、一个 Text、一个点击下一句。后来项目加了角色立绘、打字机效果、镜头移动、角色入场、屏幕震动、选项分支、跳过按钮和断线恢复,原来的对话框就开始撑不住了。
我经历过一个剧情闯关 H5,第一版剧情写在 Scene 里:播放一句台词,延迟 1 秒,移动镜头,再创建敌人。上线后问题集中出现:玩家跳过剧情后敌人没刷,切后台回来文字卡在半句,中文换成英文后按钮挡住台词,复玩关卡时剧情又强制播放。真正的问题不是 UI 不够好,而是对话、演出、玩法触发和存档状态没有边界。
Phaser 做演出系统,不需要一开始就写复杂编辑器,但需要定义时间线、指令、执行器和恢复策略。剧情系统的目标是让内容可以被配置,让演出可以被跳过,让状态可以被恢复,让玩法不会被半段剧情卡死。
flowchart TD
A[剧情脚本 JSON] --> B[脚本解析和校验]
B --> C[演出时间线]
C --> D[指令执行器]
D --> E[对话 UI]
D --> F[角色立绘/动画]
D --> G[镜头和音频]
D --> H[玩法事件]
C --> I{跳过/暂停/恢复}
I --> C
用指令描述演出
最简单的剧情脚本可以是一组指令:显示角色、说话、等待、移动镜头、播放音效、触发玩法事件。每条指令有类型和参数。执行器逐条运行,遇到需要等待的指令就挂起,完成后继续下一条。这样剧情逻辑不再散落在 Scene 的回调里。
指令的好处是可校验。say 必须有 speaker 和 textKey,moveCamera 必须有 x、y、duration,spawnEnemy 必须有 enemyId 和 spawnPoint。脚本加载时就能发现缺字段,而不是玩家看到空白对话框。
不要把指令设计得过于底层。比如 setTextAlpha、setPortraitX 这类细碎命令会让脚本变成代码的另一种写法。更好的指令是语义化的:showSpeaker、say、focusTarget、playStinger、startBattle。语义越清楚,后续换 UI 或动画时越稳。
对话 UI 要和时间线解耦
对话框负责显示文本、头像、名字、选项和按钮。它不应该知道剧情下一步是什么,也不应该直接刷怪或开门。时间线告诉 UI 显示哪句,UI 告诉时间线玩家点击继续或选择了哪个选项。两者通过事件或 Promise 交互。
这样做的价值在本地化时非常明显。中文一句话可能 12 个字,英文变成 60 个字符,日文和韩文又有不同换行习惯。如果 UI 只是显示 textKey 解析后的文本,时间线不关心文本长度,就可以单独优化排版。
打字机效果也要可跳过。玩家第一次点击可以完成当前句子,第二次点击进入下一句。不要让快速点击直接跳过重要选项,也不要让打字机动画阻塞整个时间线。UI 状态要明确:typing、ready、choice、closing。
跳过不是快进那么简单
剧情跳过最容易出事故。玩家点跳过后,镜头是否回到目标位置?已经显示的角色是否清理?应该刷出的敌人是否刷出?应该设置的剧情 flag 是否设置?如果跳过只是把 UI 隐藏,玩法状态就可能停在半路。
每条演出指令都应该声明是否有最终状态。比如移动镜头的最终状态是 camera 到达某个位置;显示角色的最终状态是角色可见;播放音效可以没有强制最终状态;触发玩法事件必须执行。跳过时,时间线不逐帧播放动画,但要应用所有必须的最终状态。
有些指令不能跳过,例如服务端确认、付费奖励、关键存档写入。它们应该被标记为 blocking。点击跳过可以跳过表现,但不能跳过权威结果。剧情系统必须区分表现指令和规则指令。
暂停和恢复要保存进度
移动浏览器里,玩家切后台、锁屏、接电话都很常见。剧情播放到一半时页面暂停,回来后如果时间线继续用旧 delta 推进,可能直接跳过几句;如果声音恢复失败,演出节奏也会乱。剧情系统要支持暂停和恢复。
最少要保存当前脚本 id、指令 index、当前句文本是否打完、选项状态和已经应用的关键 flag。对于纯表现动画,可以恢复到指令开始或直接应用最终状态。对于玩家选择,必须保持原选项等待。
如果剧情和关卡进度强相关,还要考虑刷新页面后恢复。玩家在 Boss 开场演出中刷新,下一次进入是重播演出、从战斗开始,还是回到关卡入口?这不是技术细节,而是产品规则。规则明确后,代码才好写。
镜头和玩法要有锁
演出期间常需要控制镜头、角色和输入。不要让剧情时间线直接到处关输入、改角色速度、暂停物理。更好的方式是向玩法系统申请控制锁:cutscene lock。锁存在期间,玩家输入被忽略或限制,AI 暂停,镜头由演出控制。演出结束或跳过时释放锁。
控制锁要可重入或有来源。暂停菜单、教程、剧情演出都可能想接管输入。如果只用一个 boolean,很容易互相覆盖。可以记录 lockId 和 reason,释放时只释放自己创建的锁。否则剧情结束可能把暂停菜单的输入锁也解开。
镜头控制也要恢复。演出把 camera 移到 NPC 身上,结束后应该回到玩家跟随;Boss 入场锁定房间,跳过后仍应设置房间边界。镜头指令必须有恢复策略,而不是播放完就算了。
分支对话和条件
剧情系统迟早会遇到条件:玩家是否拥有钥匙,是否第一次进入,是否完成支线,是否选择帮助 NPC。条件逻辑不要写在台词文本里,也不要让 UI 判断。脚本可以声明条件节点,由剧情服务读取游戏状态判断走哪条分支。
选项分支要记录选择结果。玩家选择“接受任务”后,存档里应该写下 flag,后续对话才能变化。只在内存里改当前脚本变量,刷新后就会丢。对于多语言,选项显示文本和选项 id 要分开。存档记录 option id,不记录显示文案。
条件越多,越需要校验。脚本可能引用不存在的 flag、跳转到不存在的节点、分支没有默认路径。上线前脚本校验能抓住很多内容错误。
一个轻量时间线接口
下面示例展示指令执行器的形状。真实项目可以把脚本放在 JSON 或表格里,但执行器接口应该保持稳定。
type CutsceneCommand =
| { type: 'say'; speaker: string; textKey: string }
| { type: 'wait'; ms: number }
| { type: 'camera'; target: string; duration: number }
| { type: 'emit'; event: string; payload?: unknown };
class CutsceneRunner {
index = 0;
constructor(private commands: CutsceneCommand[], private context: CutsceneContext) {}
async play() {
while (this.index < this.commands.length) {
await this.context.execute(this.commands[this.index]);
this.index += 1;
}
}
}
重点是 execute 可以由不同系统处理:对话 UI、相机服务、音频服务、玩法事件。Runner 不直接依赖 Phaser Scene 的所有细节,这样脚本可以测试,演出也更容易恢复。
调试和内容协作
剧情内容的调试需求和玩法不同。策划需要从任意节点开始播放,跳到某句台词,模拟某个 flag,查看当前脚本变量。工程需要知道时间线卡在哪条指令,哪个 Promise 没 resolve,哪个资源缺失。
开发版可以提供剧情控制台:选择脚本、跳转 index、快进、跳过、显示当前锁、显示当前对话 key。这样本地化和剧情 QA 不需要每次从关卡开头跑。对于长剧情,这个工具会节省大量时间。
文本还要接入本地化检查。缺少 textKey、文本过长、选项数量超过 UI 支持、包含不允许的换行或标签,都应该在内容阶段发现。不要等到上线后才发现英文版按钮被撑爆。
上线前检查清单
上线前检查:剧情脚本是否校验,跳过是否应用最终状态,演出锁是否正确释放,切后台恢复是否稳定,刷新后剧情进度是否符合规则,选项分支是否写存档,文本 key 是否完整,镜头是否能恢复跟随,剧情触发玩法事件是否幂等。
还要测快速点击、连续跳过、弱网加载立绘、音频解锁失败、低端设备播放长演出、从结算返回重看剧情。剧情 Bug 很多不是崩溃,而是玩家状态卡住。只要有一条路径无法恢复,玩家就可能丢一局进度。
和玩法系统约定事件边界
剧情经常要触发玩法:开门、刷怪、给道具、锁镜头、切换 BGM。不要让剧情脚本直接调用某个 Scene 内部方法。它应该发出语义事件,例如 gate.open、battle.start、item.grant、camera.lock。玩法系统监听这些事件,并按自己的规则执行。这样剧情脚本表达意图,玩法系统保留最终解释权。
事件还要幂等。玩家跳过剧情、页面恢复、脚本重放时,同一个 battle.start 可能被执行两次。如果刷怪服务没有幂等保护,就会刷出两波敌人。每个关键事件都应该带 eventId,玩法系统记录是否已经处理。尤其是奖励、存档 flag 和关卡推进,必须防重复。
剧情和教程也要分开。教程会强引导玩家点击、禁用某些按钮、等待玩家做动作;剧情更多是时间线演出。两者可以共用对话 UI,但控制逻辑不要混在一起。否则教程等待玩家操作时,剧情跳过按钮可能把教程状态跳坏。
本地化会反向影响演出
很多团队先按中文调好演出节奏,再做英文和其他语言。等文本变长后,打字机时间变长,角色入场和镜头移动就对不上了。剧情系统不能把“文字显示完成时间”写死在演出节奏里。更稳的做法是每句台词有最短展示时间,文本打完后等待玩家确认,自动播放只用于短演出或可配置场景。
字幕框也要支持不同语言长度。角色名、选项、旁白、道具名都可能变长。UI 要有最大行数、滚动或缩放策略。选项按钮尤其危险,中文两个字,英文可能变成一整句。脚本校验可以在构建时检查每种语言的文本长度,提前发现溢出风险。
配音项目还要处理音频和文本的关系。玩家跳过一句台词时,当前语音要停止;切换语言后,音频 key 要匹配;语音缺失时,不能让时间线一直等待音频结束。剧情系统应允许文本、语音和演出动作相互独立,而不是强绑在一条不可中断的延迟链上。
本地化测试最好能直接列出所有剧情脚本和语言覆盖状态。哪一句缺文本、哪一句缺语音、哪一个选项超长,都应该在内容检查里出现。剧情越多,人工逐句点击越不现实。
如果剧情会在活动中热更新,脚本版本也要进入诊断信息。玩家反馈某句台词卡住时,工程要知道他看到的是哪一版脚本,而不是只知道客户端版本。
这对限时活动尤其重要,因为剧情脚本常常比客户端更频繁变化。
脚本版本、文本版本和资源版本能对应起来,剧情问题才不会被误判成客户端包问题。
结语
Phaser 剧情对话系统可以很轻,但不能没有结构。对话 UI、演出时间线、指令执行器、玩法锁、跳过策略和恢复策略都要各司其职。把剧情写成一串延迟回调,短期能跑,长期很难维护。
好的演出系统让内容团队能安全添加故事,让玩家能自由跳过已看内容,让工程能解释每一次状态变化。剧情不是把文字打出来,而是在游戏规则和表现之间建立一条可靠的时间线。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。