为什么要把它当成系统来做
叙事冒险游戏里,玩家在雨夜码头和线人谈判。选择一句安抚的话,会让线人第二章愿意提供船票;选择威胁,则当前能拿到情报,但城市声望下降。玩家三小时后回来,需要知道自己为什么被某个 NPC 拒绝。
分支对话不是把几段文字按按钮连起来。真正难的是条件、旗标、后果提示、日志回看、存档和后续剧情引用。Phaser 负责呈现,但对话状态必须能被纯数据解释。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。
核心架构
flowchart TD
N4469616c6f["DialogueGraph"] --> N436f6e6469["ConditionEvaluator"]
N436f6e6469["ConditionEvaluator"] --> Ne58fafe8a7["可见选项"]
Ne58fafe8a7["可见选项"] --> Ne78ea9e5ae["玩家选择"]
Ne78ea9e5ae["玩家选择"] --> N43686f6963["ChoiceResolver"]
N43686f6963["ChoiceResolver"] --> N466c616753["FlagStore"]
N43686f6963["ChoiceResolver"] --> N436f6e7365["ConsequenceLog"]
N436f6e7365["ConsequenceLog"] --> Ne59b9ee79c["回看面板"]
N466c616753["FlagStore"] --> Ne5908ee7bb["后续剧情条件"]
这张图的关键,是把 DialogueGraph、ConditionEvaluator、ChoiceResolver、FlagStore、ConsequenceLog、DialogueUI 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。
节点数据要能离开 Phaser 存活
对话节点应是 JSON 或脚本数据,而不是 Scene 里的 if else。每个节点包含说话人、文本、表情、语音、选项、条件和跳转。UI 可以很华丽:打字机、头像、背景虚化、手柄选择;但这些都不能改变剧情判断。只要数据独立,编辑器、测试脚本和本地化流程都能读取。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
条件判断要可追踪
条件不要写成匿名函数藏在内容里。建议使用有限 DSL,例如 flag.reputation >= 3、inventory.has(’ticket’)、quest.stage == ‘met_informant’。ConditionEvaluator 负责解析并返回结果,同时记录每个条件为什么通过或失败。调试模式下把失败原因显示给开发者,内容团队改剧情时能立刻定位。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
后果提示要分层
有些选择需要立刻提示,例如失去金币;有些后果应该延迟,例如某人记住了你的态度;还有些不应提示,保持叙事悬念。数据里可以为 effect 标注 visibility: immediate、delayed、hidden。UI 只按标记展示,不要自己判断什么是重要后果。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
日志不是聊天记录
回看面板除了展示说过的话,还要记录玩家选了什么、当时写入了哪些旗标、关键道具是否变化。这样玩家回来时能理解世界状态。日志条目应包含 dialogueId、nodeId、choiceId、timestamp、summary 和 effectSummary,存档里保存轻量摘要,不必保存完整文本。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
打字机和跳过要保护状态
玩家快速点击跳过文本时,不能重复触发节点进入事件。建议节点生命周期分成 enter、reveal、awaitChoice、resolve、exit。打字机只影响 reveal 阶段的视觉进度,真正写旗标只在 resolve 里发生一次。给每次 resolve 加 consumed 标记,可以防止双击触发两次奖励。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
本地化会改变按钮布局
中文、英文和日文选项长度差异很大,Phaser 文本按钮不能只按固定宽度裁切。选项列表应支持自动换行、滚动和手柄焦点。条件隐藏后,选项索引会变化,快捷键提示也要重新绑定。把选项 id 与显示顺序分开,后续日志才不会因为本地化重排而错位。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
测试要覆盖剧情路径
叙事系统很适合做路径测试。给每条关键剧情写一组选择序列,运行后断言旗标、日志和最终节点。这样内容越来越多时,不会因为改了前置条件导致第三章某个 NPC 永远不可见。测试不需要启动 Phaser,只要跑 DialogueGraph 和 Resolver。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
TypeScript 实现骨架
type Visibility = "immediate" | "delayed" | "hidden";
interface Effect { flag: string; op: "set" | "inc"; value: string | number | boolean; visibility: Visibility }
interface Choice { id: string; text: string; next: string; conditions?: string[]; effects?: Effect[] }
interface DialogueNode { id: string; speaker: string; text: string; choices: Choice[] }
class ConsequenceLog {
private rows: Array<{ nodeId: string; choiceId: string; summary: string; effects: Effect[] }> = [];
push(node: DialogueNode, choice: Choice) {
this.rows.push({
nodeId: node.id, choiceId: choice.id,
summary: `${node.speaker}: ${choice.text}`,
effects: (choice.effects ?? []).filter(e => e.visibility !== "hidden"),
});
}
recent(count = 20) { return this.rows.slice(-count); }
}
function resolveChoice(node: DialogueNode, choiceId: string, flags: Map<string, unknown>, log: ConsequenceLog) {
const choice = node.choices.find(c => c.id === choiceId);
if (!choice) throw new Error(`Missing choice ${choiceId}`);
for (const effect of choice.effects ?? []) {
if (effect.op === "set") flags.set(effect.flag, effect.value);
if (effect.op === "inc") flags.set(effect.flag, Number(flags.get(effect.flag) ?? 0) + Number(effect.value));
}
log.push(node, choice);
return choice.next;
}
这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。
落地步骤
- 第一,确认 DialogueGraph 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
- 第二,给 ConditionEvaluator 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
- 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
- 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
- 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
- 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。
常见坑
- 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
- 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
- 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。
项目里的验证方式
把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/dialogue-choice-consequence-log。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。
数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。
在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。
最后检查
做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。分支对话后果日志:选择、条件、旗标和回看面板要闭环 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。