上午那篇把「技能」做成了数据(自定义 Resource + 冷却)。这一篇继续「把玩法做成数据」的思路,换一个方向——对话与叙事。
很多独立开发者一开始不想碰剧情,觉得”做玩法就够了”。但只要你有一个 NPC 想说话,早晚都会遇到它:台词写在脚本里,条件写成 if-else,几段之后就是上千行的面条;加一句台词要翻半天,改一个分支就怕把别的流程带崩。今天讲的就是怎么把对话变成可维护的数据,让加剧情像填表格一样简单。
今日速览
- 对话系统 = 对话内容(文本)+ 分支逻辑(条件)+ 世界状态(flag/变量),三者必须分开存,混在一起就是灾难。
- 小项目别上重型方案:一张 JSON 对话表 + 几十行运行时,就够撑完一个完整流程。
- 对话的”灵魂”是条件分支:NPC 记得你有没有帮过他、有没有拿过钥匙,才会说出不同的话。
- 拿到状态还不够,还要能改状态(给任务、发道具、加好感),对话才能真正推动玩法。
- 需要可视化编辑、气泡对话、本地化时,再上 Godot 生态里成熟的 Dialogue Manager 插件(Godot 4.6+,3.9k★,MIT)。
核心学习内容
1. 对话系统的最小结构:三个概念
把对话拆成三个互不干扰的层次,是它”能长大”的前提:
| 层次 | 存什么 | 存在哪 |
|---|---|---|
| 内容 | 谁说的、说了什么 | JSON / CSV 对话表 |
| 分支 | 玩家选了哪个、跳去哪 | 对话表里的 choices + goto |
| 条件 / 状态 | 有没有满足条件、改了什么 | 全局 GameState(一个 Autoload) |
Godot 官方文档里有一句关键的话:Resource 是”数据容器”,它自己什么都不做,是节点去用它的数据。对话系统正是这个原则的完美例子——对话本身不该写逻辑,它只是一份被”读出来”的数据。
2. 一张 JSON 对话表 + 一个最小运行时
先定义对话数据的形状。每个节点(node)就是一段话 + 若干选项:
{
"inn_keeper": {
"start": {
"speaker": "旅店老板",
"text": "欢迎,旅人。要来点什么?",
"choices": [
{ "text": "有房间吗?", "goto": "ask_room" },
{ "text": "你听说过那把钥匙吗?", "goto": "key_talk", "if": "has_heard_rumor" },
{ "text": "算了。", "goto": "end" }
]
},
"ask_room": {
"speaker": "旅店老板",
"text": "一晚 10 金币。",
"choices": [
{ "text": "我住下了。", "goto": "pay", "if": "gold >= 10" },
{ "text": "太贵了。", "goto": "end" }
]
},
"pay": {
"speaker": "旅店老板",
"text": "楼上第二间,好好休息。",
"mutations": ["gold -= 10", "set_flag('rested', true)"],
"goto": "end"
},
"end": { "speaker": "", "text": "", "goto": "" }
}
}
对应一个极简运行时(挂在对话框 UI 上):
extends Control
@onready var label: Label = $Panel/DialogueText
@onready var options_box: VBoxContainer = $Panel/Options
var data: Dictionary # 整张对话表
var current: Dictionary # 当前节点
func start(dialogue: Dictionary, node_id: String = "start") -> void:
data = dialogue
_show(node_id)
func _show(node_id: String) -> void:
current = data.get(node_id, {})
if current.is_empty() or current.get("goto", "") == "" and not current.has("choices"):
_close()
return
# 执行状态变更
for m in current.get("mutations", []):
GameState.run(m) # 用 Expression 解析字符串表达式
label.text = "%s:%s" % [current.get("speaker", ""), current.get("text", "")]
_render_choices()
func _render_choices() -> void:
for c in options_box.get_children():
c.queue_free()
var choices: Array = current.get("choices", [])
if choices.is_empty():
_close()
return
for ch in choices:
if ch.has("if") and not GameState.check(ch["if"]):
continue # 条件不满足,选项直接不出现
var btn := Button.new()
btn.text = ch["text"]
btn.pressed.connect(_show.bind(ch["goto"]))
options_box.add_child(btn)
func _close() -> void:
visible = false
这段代码的关键点:运行时只负责”读数据 + 判断条件 + 跳转”,一个字的剧情都不写死。加剧情 = 改 JSON,不动代码。
3. 条件与状态:让 NPC “记得” 你
上面用到的 GameState 是一个 Autoload 单例,负责两件事:检查条件和执行变更。用 Godot 的 Expression 就能安全地把字符串表达式变成逻辑:
extends Node
# GameState.gd —— Autoload
var flags: Dictionary = {} # 剧情标记,如 {"has_heard_rumor": true}
var gold: int = 0 # 玩家数值
func check(expr: String) -> bool:
var e := Expression.new()
# 把 flags 里的键暴露为变量,便于写 "has_heard_rumor"
for key in flags:
e.add_variable(key, flags[key])
e.add_variable("gold", gold)
if e.parse(expr, []) != OK:
push_error("对话条件解析失败: %s" % expr)
return false
return e.execute([], self, true) == true
func run(expr: String) -> void:
var e := Expression.new()
e.parse(expr, [])
e.execute([], self, true)
func set_flag(name: String, value) -> void:
flags[name] = value
于是对话里就能自然写:"if": "has_heard_rumor and gold >= 10"。玩家帮过一次忙,has_heard_rumor 变 true,下次再和老板说话,就多出一个选项——这就是”NPC 记得你”的感觉。
Godot 的 Dialogue Manager 插件用的也是同一套思路,只是把 JSON 换成了更好写的类脚本语法,并内置了 if / elif / else 块、$> 变更、{{变量}} 插值:
if has_met_nathan == false
$> SomeGlobal.has_met_nathan = true
Nathan: 嗨,我是 Nathan。
else
Nathan: 又见面了。
什么时候手搓,什么时候用插件? 50 段以内、分支简单 → 手搓 JSON;需要可视化编辑、气泡、多语言、大量素材插值时 → 直接装 Dialogue Manager,别重复造轮子。
🎯 DeepSeek 提示词
提示词 1:把剧情大纲变成对话树 JSON
适用场景:你脑子里有一段剧情,想快速拿到能直接喂给运行时的对话表。
你是一名资深游戏叙事设计师,熟悉 Godot 的数据驱动对话系统。
【任务】把我下面的剧情大纲,转换成一张 JSON 对话表。
【输入】剧情大纲如下:
(在此粘贴:场景、出场角色、玩家可能的选择、想触发的状态变化)
【输出格式】严格输出 JSON,结构为:
{
"<对话ID>": {
"start": { "speaker": "...", "text": "...", "choices": [ {"text": "...", "goto": "...", "if": "可选条件"} ] },
"...其它节点...": { "speaker": "...", "text": "...", "mutations": ["gold -= 10"], "goto": "..." }
}
}
- 结束节点统一用 "end",其 "goto" 为空字符串。
- 条件用安全表达式写法(如 "has_key and gold >= 10"),变量名用 snake_case。
【约束】
1. 每个节点最多 3 个选项,选项文本控制在 12 字以内。
2. 所有 "goto" 必须指向真实存在的节点,不允许断链。
3. 台词口语化、符合角色性格,不要翻译腔。
4. 只输出 JSON,不要解释、不要 markdown 代码围栏以外的文字。
【校验】输出前自检:所有 goto 是否有对应节点?是否至少有一个分支会修改状态?
使用说明:把括号里的剧情换成你自己的;拿到 JSON 后直接丢进 res://dialogue/*.json,用上面的运行时加载即可。
提示词 2:生成对话管理器(GDScript)
适用场景:已有对话表格式,想让 DeepSeek 生成/补全运行时与 UI 接线代码。
你是 Godot 4.x 的资深 GDScript 工程师。
【任务】实现一个数据驱动的对话管理器,读取 JSON 对话表并驱动一个对话 UI。
【输入】
- 对话 JSON 格式:(在此粘贴节点结构示例)
- 全局状态单例名为 GameState,提供 check(expr: String) -> bool 和 run(expr: String) -> void。
- UI 节点路径:$Panel/DialogueText(Label)、$Panel/Options(VBoxContainer)、$Panel/Portrait(TextureRect,可选)。
【要求】
1. 方法:start(dialogue: Dictionary, node_id := "start")、_show(node_id)、_render_choices()、_close()。
2. 选项使用 Button,点击后跳转到对应 goto;条件不满足的选项不显示。
3. 支持可选的头像字段 "portrait"(资源路径)和说话人名字。
4. 用类型标注(: Database 等)和 @onready;命名用 snake_case。
5. 不得把任何具体剧情写死在代码里。
【输出】完整可运行的 .gd 文件 + 一句挂载说明(挂在哪个场景、需要哪些子节点)。
使用说明:明确”不许写死剧情”这条约束很关键,否则 AI 容易把示例台词硬编码进去。
提示词 3:审查对话脚本的条件与状态一致性
适用场景:剧情越加越多,怕出现”死分支""断链""条件永远为假”,让 DeepSeek 当校审。
你是一名严谨的游戏叙事 QA。请审查我这份对话数据,找出所有逻辑问题。
【输入】(在此粘贴完整 JSON 对话表)
【检查清单】
1. 断链:是否存在 "goto" 指向不存在的节点?
2. 死分支:是否存在任何条件下都无法到达的节点或选项?
3. 条件自相矛盾 / 永远为假(例如同时要求 has_key 为 true 和 false)。
4. 状态未定义就使用:条件里引用了从未被任何 mutation 设置过的变量。
5. 状态变更写错:表达式语法错误、变量名拼写不一致。
6. 结局遗漏:是否存在既无 choices 又无有效 goto 的"卡死"节点。
【输出格式】用表格列出:问题类型 | 所在节点 | 具体描述 | 修复建议。若某类问题不存在,写"无"。最后给出一句总体风险评估。
【约束】只针对给定的对话数据,不要虚构不存在的节点。
使用说明:把它当提交前的”对话 lint”。每次大改剧情后跑一遍,能省掉大量手动排查。
学习心得
做 Steam 游戏,玩家买的不只是玩法,还有**“这个世界是否记得我”**的感觉——而对话系统就是这种记忆的载体。今天最大的收获,是确认了它和上午的”技能系统”其实是同一条路:把内容做成数据,把逻辑做成系统。一旦你习惯了这个思路,DeepSeek 的价值会被放大——因为 AI 最擅长的正是”按给定格式批量生成结构化数据”,而 JSON 对话表恰好就是结构化数据。你负责给出角色性格和剧情大纲,DeepSeek 负责把它铺成几百行分支,再由运行时装进游戏。对一个从零做 Steam 游戏的人来说,这几乎是把”写剧情”的门槛降到了零。
明日关注
- 把今天的 JSON 运行时接上真实按键/手柄的”继续”输入,并加上打字机逐字显示效果。
- 给对话系统加本地化字段(每个 text 换成多语言 key),复用第 48 天的 CSV 翻译方案。
- 评估是否直接迁移到 Dialogue Manager 插件(对比手搓方案在气泡对话、多语言、可视化编辑上的差距)。
本文由自动化学习流水线每日生成,坚持即学即记。
评论