背景痛点:if-else 地狱长啥样
先放一张“事故现场”照片,看看我最早写的对话代码:
左边是刚上线时的 200 行,右边是迭代三个版本后的 2000 行——全部堆在一个ChatPanel.ts里。
需求只要多一句“如果玩家背包有 A 道具,则出现隐藏选项”,就要在 5 层嵌套的if-else里再挖一个坑。
更惨的是,策划改表后,旧存档里的对话进度直接错位,玩家被迫回档。
维护成本 = 找分支时间 × 测试回归次数 × 背锅人数,指数级上涨。
技术选型:FSM、行为树、事件总线怎么挑
我把三种方案都踩了一遍,结论先给:
| 方案 | 适用场景 | 优点 | 踩坑点 |
|---|---|---|---|
| 有限状态机(FSM) | 单线剧情、状态可数 | 直观、易调试 | 状态爆炸后图比代码还乱 |
| 行为树 | AI 与对话混合 | 可复用节点、并行灵活 | 过度设计,小项目写节点写到哭 |
| 事件总线 | 多系统订阅对话结果 | 解耦彻底、可热插拔 | 事件顺序不可控,得加帧队列 |
最终我把“对话自身”交给 FSM,把“对话副作用”(任务、动画、音效)交给事件总线,两者通过“对话指令层”隔离,后续扩展互不干扰。
核心实现:JSON 驱动 + 状态机
1. 对话脚本格式
策划只维护一张 JSON,不碰代码:
{ "id": "npc001", "nodes": { "0": { "text": { "cn": "来杯咖啡吗?" }, "options": [{"text": "要", "next": 1}, {"text": "不要", "next": 2}] }, "1": { "text": { "cn": "拿铁还是美式?" }, "options": [{"text": "拿铁", "next": 3}, {"text": "美式", "next": 3}] }, "2": { "text": {"cn": "那下次见~"}, "end": true } } }2. 异步加载解析器(TypeScript)
/** * 对话配置加载器 * @description 保证同一路径只加载一次,返回解析后的 DialogueGraph */ export class DialogueLoader { private static cache = new Map<string, DialogueGraph>(); static async load(path: string): Promise<DialogueGraph> { if (this.cache.has(path)) return this.cache.get(path)!; const asset = await new Promise<cc.JsonAsset>((resolve, reject) => { cc.resources.load(path, cc.JsonAsset, (err, asset) => { err ? reject(err) : resolve(asset); }); }); const graph = new DialogueGraph(asset.json as IDialogueJson); this.cache.set(path, graph); return graph; } }3. 对话状态机
状态机只关心“当前节点”与“下一步”,不碰 UI:
/** 纯逻辑状态机,无渲染副作用 */ export class DialogueFSM { private _curr: string = '0'; constructor(private graph: DialogueGraph) {} get currNode(): IDialogueNode { return this.graph.nodes[this._curr]; } /** 选择选项后推进状态 */ transit(optionIndex: number): boolean { const opt = this.currNode.options[optionIndex]; if (!opt) return false; this._curr = opt.next; return true; } /** 是否到达终点 */ get isEnd(): boolean { return !!this.currNode.end; } }4. 与主循环线程安全交互
Cocos 主循环跑在单线程,但资源加载回调可能跨帧。把“用户点击”与“状态推进”拆成两个队列:
/** 对话指令队列,保证一帧最多执行一条,避免竞态 */ export class DialogueCommandQueue { private queue: Array<() => void> = []; push(cmd: () => void) { this.queue.push(cmd); } update() { if (this.queue.length) { const cmd = this.queue.shift()!; cmd(); } } }在组件onLoad注册schedule(this.queue.update),每帧消费一次,UI 与数据永远同步。
性能优化:预加载 + 内存回收
预加载策略
进入场景前,用DialogueLoader.load(path)批量拉取下一张图所需的全部对话配置,走 Cocos 的cc.resources.loadDir,避免玩家点开 NPC 时才去下载 JSON 的卡顿。内存回收
切换章节时,调用DialogueLoader.clearCache(chapterId),按章节前缀清理缓存;同时把对应贴图、音频的引用计数减到 0,让引擎自动release。对象池复用聊天气泡
聊天气泡节点使用cc.NodePool,回收时removeFromParent(false),下次get()直接复用,减少instantiate的 GC 抖动。
避坑指南:三个隐形炸弹
1. 循环引用的 DI 设计
最早我把状态机写成单例,注入到 UI、任务、音效三个管理器,结果它们互相引用,场景切换后destroy不掉。
解决:用依赖倒置+生命周期作用域。状态机由对话根组件ChatRoot私有持有,其余系统通过事件总线监听,不直接import实例。
2. 多语言占位符
中文“获得{0}个金币”在英文可能变成“Got {0} gold coins”,数字位置会换。
策划填表时写成Got {count} gold coins,代码里用String.replace(/{(\w+)}/g, (_, key) => args[key]),避免顺序错位。
3. 对话历史序列化
存档时直接把DialogueFSM._curr存进localStorage,升级后节点 ID 对不上。
解决:存剧情版本号+稳定节点 key。JSON 里给每个节点加key: "coffee_start",代码里用 key 做索引,即使中间插入新节点,旧存档也能找到最接近的 key。
完整可复用模块目录
ChatRoot.ts // 组件入口,管生命周期 DialogueFSM.ts // 纯逻辑状态机 DialogueGraph.ts // JSON 包装器 DialogueLoader.ts // 异步加载 + 缓存 DialogueCommandQueue.ts // 线程安全队列 ChatPanel.ts // 纯 UI,只发事件 ChatEvents.ts // 事件常量定义全部文件遵守 SOLID:
- 单一职责——一个类只干一件事
- 开闭原则——新增剧情只改 JSON,不动代码
- 依赖倒置——UI 与数据通过事件通信,不直接 new 具体类
互动环节:脚本校验小工具
我打包了一个 Node 小脚本,放在 GitHub,可本地跑:
npm i -g dialogue-lint dialogue-lint ./assets/dialogue功能:
- 检测孤立节点
- 发现循环分支
- 校验多语言字段缺失
- 输出可视化 DOT 图,直接拖进 WebGraphviz 看流程图
跑通后再进游戏,策划改表心里也有底,不再“盲盒式”测试。
写在最后
把对话系统拆成“数据驱动 + 状态机 + 事件总线”后,我这两个月新接的需求——分支对话、限时选项、插播动画——都能在 30 分钟内拼完,不再熬夜加班。
如果你也在维护一坨if-else,不妨先试试把节点数据抽出来,再套个 FSM,慢慢把副作用迁到事件层,代码会呼吸,你也会轻松。