分支对话不是把文本一行行播完
Phaser 做剧情对话很容易起步:屏幕底部放一个对话框,点击显示下一句,遇到选项就显示几个按钮。问题在于分支叙事的复杂度不是来自文本播放,而是来自状态。玩家之前是否救过某个 NPC,背包里有没有钥匙,当前是否在雨天,阵营好感度是否超过 30,某个秘密是否已经被揭露,这些都会影响可见选项和后续剧情。如果系统一开始只是一个数组下标,后期就会到处出现 if (flagA && !flagB),写的人不敢改,写剧情的人不敢配。
一个可维护的分支对话系统需要三个核心:节点图、变量条件和执行效果。节点图决定对话流向,变量条件决定某句或某个选项是否可见,执行效果修改剧情状态或触发外部事件。Phaser Scene 只负责呈现文字、头像、选项和过场动画,不应该直接保存剧情变量。本文会构建一个适合 RPG、AVG 或轻量剧情解谜的方案,重点是让剧情内容可配置、可测试、可存档。
节点图比线性数组更适合分支
线性数组适合没有分支的对白,但只要出现选择,就应该使用节点。每个节点有 id、speaker、text、conditions、effects、choices。选择项指向下一个节点,也可以触发效果。结束节点可以返回游戏场景、打开商店、进入战斗或播放演出。这样剧情编辑器、测试工具和存档都能围绕 node id 工作。玩家读到哪个节点,存档就保存哪个节点,不需要猜数组下标在新版文本中是否仍然对应。
节点图还方便做内容检查。比如检查是否存在不可达节点、选项是否指向不存在的节点、条件变量是否拼写错误、某条路径是否没有结束。剧情越多,这些工具越重要。不要等到玩家在第六章卡死才发现一个选项写成了 next: "meet_boss_02",但真实节点叫 meet_boss_2。
flowchart TD
A["DialogueScene 打开 dialogueId + startNode"] --> B["DialogueRunner 读取节点"]
B --> C["ConditionEvaluator 判断文本和选项可见"]
C --> D["DialogueView 打字机、头像、选项"]
D --> E["玩家选择"]
E --> F["EffectExecutor 修改变量、发事件"]
F --> G{"是否有 nextNode"}
G -- "有" --> B
G -- "无" --> H["结束对话,返回 GameplayScene"]
F --> I["HistoryLog 记录已显示文本和选择"]
变量系统要有命名和类型
剧情变量不要直接散落成任意字符串。至少要分几类:布尔标记、数值好感、枚举阶段、计数器。变量名要有命名空间,比如 quest.blacksmith.started、npc.mina.affection、world.weather。这样一眼能看出归属,也能避免两个剧情文件都写 metBoss。条件表达式可以保持简单,不必一开始引入完整脚本语言。常见需求用 equals、notEquals、gte、lt、hasItem、flag 就够了。
如果剧情团队需要更灵活的表达式,也建议先做白名单操作,而不是直接 eval。客户端执行外部文本里的脚本会带来安全和调试问题。即使内容来自自家仓库,eval 也会让变量引用无法静态检查。条件越明确,工具越容易发现错误。
选项效果要可预览
玩家做选择时,系统内部会执行效果:设置 flag、增加好感、扣除物品、开启任务、跳转节点。有些效果应该对玩家可见,比如“米娜好感 +5”;有些效果不应该透露,比如“记录玩家隐瞒真相”。配置里可以给效果加 publicHint,UI 根据需要展示。不要让 UI 自己猜效果含义,否则本地化和隐藏信息都会失控。
执行效果要注意顺序。比如选择“交出钥匙”需要先检查背包有钥匙,再扣除钥匙,再设置剧情阶段,再跳到感谢节点。如果扣除成功但跳转失败,存档会损坏。Runner 可以把一个选择的效果当作事务:先验证所有前置条件,再应用所有修改,最后推进节点。纯客户端无法做到数据库事务,但至少可以在内存中先生成新状态,全部成功后再替换旧状态并保存。
一个轻量 Runner
下面的 TypeScript 片段展示核心接口。真实项目可以把对话数据放在 JSON、YAML 或自研编辑器导出的文件中,但 Runner 逻辑应保持独立。
type Condition =
| { op: "flag"; key: string; value: boolean }
| { op: "gte"; key: string; value: number }
| { op: "hasItem"; itemId: string; count: number };
interface DialogueChoice {
id: string;
text: string;
next?: string;
conditions?: Condition[];
effects?: Array<{ op: "setFlag" | "addNumber" | "removeItem"; key: string; value: unknown }>;
}
interface DialogueNode {
id: string;
speaker: string;
text: string;
conditions?: Condition[];
choices?: DialogueChoice[];
next?: string;
}
export class DialogueRunner {
constructor(
private readonly nodes: Map<string, DialogueNode>,
private currentId: string,
private readonly state: NarrativeState,
) {}
current() {
const node = this.nodes.get(this.currentId);
if (!node) throw new Error(`Missing dialogue node: ${this.currentId}`);
return {
node,
choices: (node.choices ?? []).filter((choice) =>
this.state.matchAll(choice.conditions ?? []),
),
};
}
choose(choiceId: string) {
const view = this.current();
const choice = view.choices.find((item) => item.id === choiceId);
if (!choice) throw new Error(`Choice not available: ${choiceId}`);
this.state.applyEffects(choice.effects ?? []);
this.currentId = choice.next ?? view.node.next ?? "";
return this.currentId;
}
}
这里的 NarrativeState 不属于 Phaser。它可以被单元测试、存档系统和剧情编辑器共同使用。Phaser 的对话框只拿 current() 的结果渲染。玩家点击选择后,Scene 调用 choose(),再根据返回状态刷新视图或关闭对话。这个边界能让对话系统在战斗、探索、菜单、过场里复用。
打字机效果不要阻塞逻辑
Phaser 对话常见表现是打字机效果,一字一字显示。实现时要区分“文本显示进度”和“剧情节点进度”。玩家点击时,如果文本未显示完,应该先快速显示完;如果已经显示完,再进入下一节点或显示选项。不要在每个字符显示时推进剧情,也不要让打字机定时器决定节点结束。文本表现可以被跳过,剧情状态不能被跳过。
本地化会影响打字机速度。中文按字显示,英文按字符显示,日文可能按假名或字符。可以统一按 Unicode 字符数组处理,但要注意 emoji 和组合字符。对于长文本,建议支持富文本标记,比如停顿、强调、变色、音效提示。富文本解析要在显示前完成,历史记录里保存纯文本和关键标记,避免回看记录出现一堆控制符。
回看记录是剧情体验的一部分
剧情游戏里玩家经常需要回看刚才说了什么。HistoryLog 应记录已经展示的文本、说话人、头像、选项选择和公开效果。不要只记录节点 id,因为文本配置更新后回看内容可能变化;也不要记录所有隐藏效果,避免剧透。回看记录可以按对话会话保存,离开场景后保留最近若干条。移动端 UI 可以用上滑打开,桌面端可以用按钮或快捷键。
回看记录还对 QA 有帮助。测试反馈“选第三个选项后剧情不对”时,如果日志里有节点 id 和选择 id,开发能快速定位。建议开发模式在每行旁边显示 node id,但发布版隐藏。
存档和版本迁移
剧情状态需要进入存档:变量表、已完成对话、当前任务阶段、可能还包括正在进行的对话节点。内容更新后,旧存档可能指向不存在的节点或变量。解决办法是给剧情包加版本号,并提供迁移表。比如 oldNodeId -> newNodeId,废弃变量转为新变量。没有迁移时,至少要能安全回退到章节入口,而不是让玩家卡在黑屏。
如果剧情会触发奖励,奖励发放要和剧情状态绑定。玩家不能通过反复读取旧存档重复领取,也不能在对话崩溃后丢奖励。可以为关键效果生成一次性记录,比如 reward.dialogue.mina_intro_claimed。执行效果前检查记录,执行后写入记录。
上线前检查清单
确认每个对话节点都有唯一 id;确认选项指向的 next 节点存在;确认条件变量有命名空间和类型;确认隐藏效果不会显示在玩家提示里;确认打字机跳过不会跳过剧情效果;确认回看记录包含文本、说话人和玩家选择;确认存档记录剧情包版本;确认内容更新时有节点迁移策略;确认 Phaser Scene 销毁重建后 Runner 状态不会丢;确认开发工具能列出不可达节点和死路。
分支叙事的难点不是写几段动人的台词,而是让玩家的选择被系统诚实地记住,并在后续以可理解的方式反馈出来。Phaser 可以把对话表现做得轻巧,但剧情状态必须有自己的秩序。把节点、条件、效果和历史记录分清楚,后面加章节、加角色、加多结局才不会越来越怕改。
和演出系统的边界要提前约定
剧情对话经常会和镜头移动、角色入场、屏幕震动、音效、立绘切换混在一起。建议把这些称为 cue,而不是直接把 Phaser 调用写进对话节点。节点可以声明 cue: "mina_turn_away" 或 camera: "focus_gate",DialogueScene 再把 cue 分发给演出系统。这样剧情数据仍然可读,演出实现也能替换。如果未来要在低配设备关闭部分镜头特效,只需要让 cue 执行器降级,不必改剧情文本。
多人协作时,这个边界尤其重要。编剧关心台词和条件,关卡设计关心任务状态,前端关心 UI 和动画。如果对话 JSON 里塞满具体坐标、tween 参数和音频 key,任何人改动都可能破坏别人工作。更好的内容流程是:剧情节点表达意图,演出资源表把意图映射到 Phaser 表现。开发模式下遇到未知 cue 可以显示占位提示并上报,而不是让整段剧情中断。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。