第二十四部分:对话选项与分支系统:购买、聊天、任务、条件对话、任务状态分支
这一部分开始升级对话系统。
前面我们的 NPC 已经可以:
显示对话
发布任务
打开商店
根据时间开店或打烊
根据天气改变日程
但目前交互还是比较直线:
玩家按 E
NPC 直接说话
或者直接打开商店
或者直接接任务
现在我们要做成更像 RPG 的形式:
玩家按 E
NPC:你好,需要什么?
选项:
1. 聊天
2. 购买
3. 任务
4. 离开
这就是对话选项系统。
1. 为什么需要对话选项
没有选项时,NPC 只能做一件事。
比如商人 NPC:
按 E 直接打开商店
但真实 RPG 里,商人可能有多个功能:
聊天
购买
出售
询问传闻
接任务
离开
任务 NPC 也可能有多个状态:
未接任务:询问是否帮忙
进行中:查看任务进度
可完成:交任务
已完成:普通聊天
所以对话选项系统要解决的是:
让玩家从多个行为中选择一个
而不是 NPC 自己直接决定全部流程。
2. 第一版对话选项要做什么
第一版建议做这些:
1. DialogueBox 支持显示选项按钮
2. 每个选项有文本
3. 每个选项有 action_type
4. 点击选项后执行对应行为
5. 支持打开商店
6. 支持开始任务
7. 支持完成任务
8. 支持继续普通对话
9. 支持关闭对话
10. 支持根据任务状态显示不同选项
先不要做:
复杂剧情树编辑器
可视化对话编辑器
多层嵌套剧情图
角色好感度条件
复杂变量表达式
脚本指令语言
语音播放系统
打字音效精细控制
第一版目标:
NPC 能弹出几个按钮
玩家点按钮后做不同事情
3. 对话系统现在需要分两层
之前 DialogueBox 可能只负责:
显示 NPC 名字
显示一组 lines
按键继续
结束
现在它要增加:
显示选项
处理选项点击
通知 NPC 玩家选了什么
但是要注意:
DialogueBox 不应该知道商店、任务、NPC 逻辑
更好的分工是:
DialogueBox:
只负责 UI 显示文字和选项
NPC:
决定要显示什么选项
决定点击选项后执行什么行为
也就是说:
DialogueBox 是界面
NPC 是业务逻辑
前端类比:
DialogueBox 像一个组件
NPC 像传 props 和回调的业务容器
4. 对话选项的数据结构
一个选项至少需要:
显示文本
行为类型
行为参数
是否可见
是否禁用
比如:
文本:购买
行为:OPEN_SHOP
文本:接任务
行为:START_QUEST
文本:交任务
行为:COMPLETE_QUEST
文本:离开
行为:CLOSE_DIALOGUE
可以先用普通 Dictionary:
{
"text": "购买",
"action": "open_shop"
}
但更推荐做一个简单 Resource 或 RefCounted 类。
第一版我们先用 RefCounted,因为这些选项是运行时生成的,不一定需要保存成 .tres。
5. DialogueOption.gd
创建:
res://scripts/dialogue/dialogue_option.gd
class_name DialogueOption
extends RefCounted
enum ActionType {
CONTINUE_DIALOGUE,
OPEN_SHOP,
START_QUEST,
COMPLETE_QUEST,
SHOW_QUEST_PROGRESS,
CLOSE_DIALOGUE,
CUSTOM
}
var text: String = ""
var action_type: ActionType = ActionType.CLOSE_DIALOGUE
var payload: Dictionary = {}
var disabled: bool = false
func _init(
p_text: String = "",
p_action_type: ActionType = ActionType.CLOSE_DIALOGUE,
p_payload: Dictionary = {},
p_disabled: bool = false
):
text = p_text
action_type = p_action_type
payload = p_payload
disabled = p_disabled
解释:
text:
按钮显示文本
action_type:
点击后做什么
payload:
附加数据,比如 quest_id、dialogue_lines
disabled:
按钮是否禁用
6. payload 是什么
payload 就是附加参数。
比如继续对话:
DialogueOption.new(
"聊聊村子的事",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"npc_name": "村民",
"lines": [
"最近森林里的史莱姆越来越多了。",
"晚上最好不要独自出门。"
]
}
)
比如打开商店:
DialogueOption.new(
"购买",
DialogueOption.ActionType.OPEN_SHOP
)
比如接任务:
DialogueOption.new(
"我来帮你",
DialogueOption.ActionType.START_QUEST,
{
"quest_id": "collect_slime_gel"
}
)
这样一个选项就可以携带额外信息。
7. DialogueBox 节点结构升级
原来的 DialogueBox 可能是:
DialogueBox Control
└── Panel
├── NameLabel Label
├── TextLabel Label
└── NextIcon
现在加一个选项容器:
DialogueBox Control
└── Panel
├── NameLabel Label
├── TextLabel Label
├── OptionsContainer VBoxContainer
└── NextIcon
OptionsContainer 默认隐藏。
当普通文字还没播完时:
OptionsContainer.visible = false
当文字结束,需要玩家选择时:
OptionsContainer.visible = true
8. DialogueBox 的职责
升级后 DialogueBox 负责这些:
显示对话名字
逐字显示文本
按键进入下一句
显示选项按钮
玩家点击选项后发出信号
关闭对话框
它不负责:
判断任务状态
打开商店
发奖励
扣物品
切地图
这些都交给 NPC 或外部系统。
9. DialogueBox.gd 信号
signal dialogue_finished
signal option_selected(option: DialogueOption)
含义:
dialogue_finished:
普通对话播放完了
option_selected:
玩家点击了某个选项
NPC 可以连接 option_selected,然后处理行为。
10. DialogueBox.gd 基础字段
extends Control
signal dialogue_finished
signal option_selected(option: DialogueOption)
@onready var name_label: Label = $Panel/NameLabel
@onready var text_label: Label = $Panel/TextLabel
@onready var options_container: VBoxContainer = $Panel/OptionsContainer
@onready var next_icon: Control = $Panel/NextIcon
@export var typing_speed: float = 0.03
var lines: Array[String] = []
var current_line_index: int = 0
var is_typing: bool = false
var full_text: String = ""
var typing_token: int = 0
var current_options: Array[DialogueOption] = []
var waiting_for_option: bool = false
字段解释:
lines:
当前对话的文本列表
current_line_index:
当前第几句
is_typing:
是否正在逐字显示
full_text:
当前完整句子
typing_token:
防止旧的 await 循环回来捣乱
current_options:
当前要显示的选项
waiting_for_option:
是否正在等待玩家选择
11. start_dialogue()
支持普通对话:
func start_dialogue(npc_name: String, p_lines: Array[String]):
visible = true
name_label.text = npc_name
lines = p_lines
current_line_index = 0
current_options.clear()
waiting_for_option = false
hide_options()
show_current_line()
普通用法:
dialogue_box.start_dialogue("村民", [
"你好,旅行者。",
"今天的天气真不错。"
])
12. start_dialogue_with_options()
支持对话结束后显示选项:
func start_dialogue_with_options(
npc_name: String,
p_lines: Array[String],
p_options: Array[DialogueOption]
):
visible = true
name_label.text = npc_name
lines = p_lines
current_line_index = 0
current_options = p_options
waiting_for_option = false
hide_options()
show_current_line()
用法:
dialogue_box.start_dialogue_with_options(
"商人",
["欢迎光临,需要什么?"],
[
DialogueOption.new("购买", DialogueOption.ActionType.OPEN_SHOP),
DialogueOption.new("聊聊", DialogueOption.ActionType.CONTINUE_DIALOGUE),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
13. show_current_line()
func show_current_line():
if current_line_index >= lines.size():
finish_lines()
return
full_text = lines[current_line_index]
text_label.text = ""
next_icon.visible = false
hide_options()
start_typing(full_text)
14. start_typing()
func start_typing(text: String):
typing_token += 1
var current_token = typing_token
is_typing = true
for i in range(text.length()):
if current_token != typing_token:
return
text_label.text += text[i]
await get_tree().create_timer(typing_speed).timeout
if current_token != typing_token:
return
is_typing = false
next_icon.visible = true
这个和前面逐字对话逻辑一样。
typing_token 是为了防止玩家快速切换时,旧协程还在继续往 Label 里加字。
15. skip_typing()
玩家按确认键时,如果正在打字,就直接显示整句。
func skip_typing():
typing_token += 1
text_label.text = full_text
is_typing = false
next_icon.visible = true
16. 输入处理
func _unhandled_input(event):
if not visible:
return
if waiting_for_option:
return
if event.is_action_pressed("interact") or event.is_action_pressed("ui_accept"):
if is_typing:
skip_typing()
else:
next_line()
get_viewport().set_input_as_handled()
如果正在等选项,就不要按 E 自动推进。
因为这时应该让玩家点击按钮或用方向键选按钮。
17. next_line()
func next_line():
current_line_index += 1
if current_line_index >= lines.size():
finish_lines()
else:
show_current_line()
18. finish_lines()
func finish_lines():
if current_options.is_empty():
close_dialogue()
dialogue_finished.emit()
return
show_options(current_options)
如果没有选项:
直接关闭对话
发 dialogue_finished
如果有选项:
显示选项
等待玩家选择
19. show_options()
func show_options(options: Array[DialogueOption]):
waiting_for_option = true
next_icon.visible = false
for child in options_container.get_children():
child.queue_free()
options_container.visible = true
for option in options:
var button = Button.new()
button.text = option.text
button.disabled = option.disabled
button.pressed.connect(func():
_on_option_button_pressed(option)
)
options_container.add_child(button)
20. 点击选项
func _on_option_button_pressed(option: DialogueOption):
waiting_for_option = false
hide_options()
option_selected.emit(option)
这里 DialogueBox 不自己处理选项行为。
它只发出:
玩家选择了这个 option
然后 NPC 来处理。
21. hide_options()
func hide_options():
waiting_for_option = false
options_container.visible = false
for child in options_container.get_children():
child.queue_free()
22. close_dialogue()
func close_dialogue():
typing_token += 1
visible = false
is_typing = false
waiting_for_option = false
lines.clear()
current_options.clear()
text_label.text = ""
name_label.text = ""
hide_options()
23. DialogueBox.gd 完整基础版
extends Control
signal dialogue_finished
signal option_selected(option: DialogueOption)
@onready var name_label: Label = $Panel/NameLabel
@onready var text_label: Label = $Panel/TextLabel
@onready var options_container: VBoxContainer = $Panel/OptionsContainer
@onready var next_icon: Control = $Panel/NextIcon
@export var typing_speed: float = 0.03
var lines: Array[String] = []
var current_line_index: int = 0
var is_typing: bool = false
var full_text: String = ""
var typing_token: int = 0
var current_options: Array[DialogueOption] = []
var waiting_for_option: bool = false
func _ready():
visible = false
hide_options()
func start_dialogue(npc_name: String, p_lines: Array[String]):
visible = true
name_label.text = npc_name
lines = p_lines
current_line_index = 0
current_options.clear()
waiting_for_option = false
hide_options()
show_current_line()
func start_dialogue_with_options(
npc_name: String,
p_lines: Array[String],
p_options: Array[DialogueOption]
):
visible = true
name_label.text = npc_name
lines = p_lines
current_line_index = 0
current_options = p_options
waiting_for_option = false
hide_options()
show_current_line()
func show_current_line():
if current_line_index >= lines.size():
finish_lines()
return
full_text = lines[current_line_index]
text_label.text = ""
next_icon.visible = false
hide_options()
start_typing(full_text)
func start_typing(text: String):
typing_token += 1
var current_token = typing_token
is_typing = true
for i in range(text.length()):
if current_token != typing_token:
return
text_label.text += text[i]
await get_tree().create_timer(typing_speed).timeout
if current_token != typing_token:
return
is_typing = false
next_icon.visible = true
func skip_typing():
typing_token += 1
text_label.text = full_text
is_typing = false
next_icon.visible = true
func next_line():
current_line_index += 1
if current_line_index >= lines.size():
finish_lines()
else:
show_current_line()
func finish_lines():
if current_options.is_empty():
close_dialogue()
dialogue_finished.emit()
return
show_options(current_options)
func show_options(options: Array[DialogueOption]):
waiting_for_option = true
next_icon.visible = false
for child in options_container.get_children():
child.queue_free()
options_container.visible = true
for option in options:
var button = Button.new()
button.text = option.text
button.disabled = option.disabled
button.pressed.connect(func():
_on_option_button_pressed(option)
)
options_container.add_child(button)
func _on_option_button_pressed(option: DialogueOption):
waiting_for_option = false
hide_options()
option_selected.emit(option)
func hide_options():
waiting_for_option = false
options_container.visible = false
for child in options_container.get_children():
child.queue_free()
func close_dialogue():
typing_token += 1
visible = false
is_typing = false
waiting_for_option = false
lines.clear()
current_options.clear()
text_label.text = ""
name_label.text = ""
hide_options()
func _unhandled_input(event):
if not visible:
return
if waiting_for_option:
return
if event.is_action_pressed("interact") or event.is_action_pressed("ui_accept"):
if is_typing:
skip_typing()
else:
next_line()
get_viewport().set_input_as_handled()
24. NPC 如何使用对话选项
现在 NPC 需要做两件事:
生成选项
监听 DialogueBox.option_selected
处理玩家选择
比如商人 NPC:
欢迎光临,需要什么?
选项:
购买
聊聊
离开
25. NPC 打开对话时连接信号
注意一个问题:
DialogueBox 是全局 UI
多个 NPC 都会用它
所以 NPC 连接 option_selected 时要小心重复连接。
可以在 NPC interact 时:
func interact(player: Node):
current_player = player
if dialogue_box.option_selected.is_connected(_on_dialogue_option_selected):
dialogue_box.option_selected.disconnect(_on_dialogue_option_selected)
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
open_main_dialogue()
这里 current_player 用来保存当前交互的玩家。
26. NPC 主菜单选项
var current_player: Node = null
func open_main_dialogue():
var options: Array[DialogueOption] = []
options.append(DialogueOption.new(
"聊聊",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"lines": default_dialogue_lines
}
))
if can_open_shop_now():
options.append(DialogueOption.new(
"购买",
DialogueOption.ActionType.OPEN_SHOP
))
options.append(DialogueOption.new(
"离开",
DialogueOption.ActionType.CLOSE_DIALOGUE
))
dialogue_box.start_dialogue_with_options(
npc_name,
["你好,需要什么?"],
options
)
27. can_open_shop_now()
商人是否能开店,取决于:
这个 NPC 有 shop_data
当前日程 can_shop = true
shop_panel 存在
func can_open_shop_now() -> bool:
if shop_data == null:
return false
if shop_panel == null:
return false
if current_entry == null:
return false
return current_entry.can_shop
如果不是日程 NPC,也可以简单写:
return shop_data != null and shop_panel != null
28. 处理选项
func _on_dialogue_option_selected(option: DialogueOption):
match option.action_type:
DialogueOption.ActionType.CONTINUE_DIALOGUE:
handle_continue_dialogue(option)
DialogueOption.ActionType.OPEN_SHOP:
handle_open_shop()
DialogueOption.ActionType.START_QUEST:
handle_start_quest(option)
DialogueOption.ActionType.COMPLETE_QUEST:
handle_complete_quest(option)
DialogueOption.ActionType.SHOW_QUEST_PROGRESS:
handle_show_quest_progress(option)
DialogueOption.ActionType.CLOSE_DIALOGUE:
dialogue_box.close_dialogue()
DialogueOption.ActionType.CUSTOM:
handle_custom_option(option)
29. handle_continue_dialogue()
func handle_continue_dialogue(option: DialogueOption):
var option_lines: Array[String] = option.payload.get("lines", [])
if option_lines.is_empty():
option_lines = ["没什么特别的事。"]
dialogue_box.start_dialogue_with_options(
npc_name,
option_lines,
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
}),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
这里让玩家看完聊天内容后,可以返回主菜单。
30. handle_custom_option()
func handle_custom_option(option: DialogueOption):
var custom_action: String = option.payload.get("custom_action", "")
match custom_action:
"back_to_main":
open_main_dialogue()
_:
dialogue_box.close_dialogue()
这样“返回”按钮就能回到主菜单。
31. handle_open_shop()
func handle_open_shop():
dialogue_box.close_dialogue()
if shop_panel == null:
return
if shop_data == null:
return
if current_player == null:
return
shop_panel.open_shop(shop_data, current_player)
点击“购买”后:
关闭对话框
打开商店
注意:不要让对话框和商店同时开着。
32. 任务选项的难点
任务选项要根据任务状态变化。
同一个 NPC 的任务可能有几种状态:
任务没接:
显示“有什么需要帮忙的吗?”
或者“我来帮你”
任务进行中:
显示“查看任务进度”
任务可完成:
显示“交付任务”
任务已完成:
不显示任务选项,或者显示感谢
所以 NPC 生成 options 时,要判断 QuestManager。
33. QuestNPC 需要的字段
@export var quest: QuestData
@export var quest_manager: QuestManager
然后生成任务选项:
func append_quest_options(options: Array[DialogueOption]):
if quest == null:
return
if quest_manager == null:
return
if quest_manager.is_completed(quest.id):
return
var quest_state = quest_manager.get_active_quest(quest.id)
if quest_state == null:
options.append(DialogueOption.new(
"有什么需要帮忙的吗?",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"lines": [
quest.description
],
"next_options": "quest_offer"
}
))
return
if current_player != null and current_player.has_method("get_inventory"):
quest_manager.update_collect_quest_progress(quest.id, current_player.get_inventory())
if quest_state.status == QuestState.Status.READY_TO_COMPLETE:
options.append(DialogueOption.new(
"交付任务:" + quest.title,
DialogueOption.ActionType.COMPLETE_QUEST,
{
"quest_id": quest.id
}
))
else:
options.append(DialogueOption.new(
"查看任务进度:" + quest.title,
DialogueOption.ActionType.SHOW_QUEST_PROGRESS,
{
"quest_id": quest.id
}
))
这里有一个小技巧:
未接任务时,先不直接 start_quest
先进入任务说明对话
然后给玩家“接受 / 拒绝”的选项
这样更像 RPG。
34. open_main_dialogue 加任务选项
func open_main_dialogue():
var options: Array[DialogueOption] = []
options.append(DialogueOption.new(
"聊聊",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"lines": default_dialogue_lines
}
))
append_quest_options(options)
if can_open_shop_now():
options.append(DialogueOption.new(
"购买",
DialogueOption.ActionType.OPEN_SHOP
))
options.append(DialogueOption.new(
"离开",
DialogueOption.ActionType.CLOSE_DIALOGUE
))
dialogue_box.start_dialogue_with_options(
npc_name,
["你好,需要什么?"],
options
)
现在 NPC 主菜单可能是:
聊聊
有什么需要帮忙的吗?
购买
离开
或者:
聊聊
查看任务进度:收集史莱姆凝胶
购买
离开
或者:
聊聊
交付任务:收集史莱姆凝胶
购买
离开
35. 任务说明选项
前面 append_quest_options() 里用了:
"next_options": "quest_offer"
我们在 handle_continue_dialogue() 里处理它。
func handle_continue_dialogue(option: DialogueOption):
var option_lines: Array[String] = option.payload.get("lines", [])
if option_lines.is_empty():
option_lines = ["没什么特别的事。"]
var next_options_type: String = option.payload.get("next_options", "")
if next_options_type == "quest_offer":
dialogue_box.start_dialogue_with_options(
npc_name,
option_lines,
[
DialogueOption.new("我来帮你", DialogueOption.ActionType.START_QUEST, {
"quest_id": quest.id
}),
DialogueOption.new("现在不行", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
return
dialogue_box.start_dialogue_with_options(
npc_name,
option_lines,
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
}),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
这样玩家可以先看任务说明,再决定是否接受。
36. handle_start_quest()
func handle_start_quest(option: DialogueOption):
if quest_manager == null:
return
var quest_id: String = option.payload.get("quest_id", "")
if quest_id == "":
return
if quest == null or quest.id != quest_id:
return
var success = quest_manager.start_quest(quest)
if success:
dialogue_box.start_dialogue_with_options(
npc_name,
["太好了,那就拜托你了。"],
[
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
else:
dialogue_box.start_dialogue_with_options(
npc_name,
["这个任务好像已经处理过了。"],
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
37. handle_show_quest_progress()
func handle_show_quest_progress(option: DialogueOption):
if quest_manager == null:
return
var quest_id: String = option.payload.get("quest_id", "")
var quest_state: QuestState = quest_manager.get_active_quest(quest_id)
if quest_state == null:
open_main_dialogue()
return
var lines: Array[String] = []
if quest_state.quest.quest_type == QuestData.QuestType.COLLECT_ITEM:
lines.append("你现在的进度是:%d / %d。" % [
quest_state.current_amount,
quest_state.quest.required_quantity
])
if quest_state.current_amount < quest_state.quest.required_quantity:
lines.append("还差一点,继续加油。")
dialogue_box.start_dialogue_with_options(
npc_name,
lines,
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
}),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
38. handle_complete_quest()
func handle_complete_quest(option: DialogueOption):
if quest_manager == null:
return
if current_player == null:
return
var quest_id: String = option.payload.get("quest_id", "")
if quest_id == "":
return
var success = quest_manager.complete_quest(quest_id, current_player)
if success:
dialogue_box.start_dialogue_with_options(
npc_name,
["太好了,你完成了任务。这是你的奖励。"],
[
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
else:
dialogue_box.start_dialogue_with_options(
npc_name,
["你好像还没有完成任务。"],
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
39. NPC 选项版完整核心片段
下面是核心片段,不重复 NPC 移动和日程代码。
@export var npc_name: String = "NPC"
@export var dialogue_box: Control
@export var shop_panel: Control
@export var shop_data: ShopData
@export var quest: QuestData
@export var quest_manager: QuestManager
@export var default_dialogue_lines: Array[String] = [
"你好。"
]
var current_player: Node = null
func interact(player: Node):
current_player = player
if dialogue_box == null:
print("没有设置 dialogue_box")
return
if dialogue_box.option_selected.is_connected(_on_dialogue_option_selected):
dialogue_box.option_selected.disconnect(_on_dialogue_option_selected)
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
open_main_dialogue()
func open_main_dialogue():
var options: Array[DialogueOption] = []
options.append(DialogueOption.new(
"聊聊",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"lines": default_dialogue_lines
}
))
append_quest_options(options)
if can_open_shop_now():
options.append(DialogueOption.new(
"购买",
DialogueOption.ActionType.OPEN_SHOP
))
options.append(DialogueOption.new(
"离开",
DialogueOption.ActionType.CLOSE_DIALOGUE
))
dialogue_box.start_dialogue_with_options(
npc_name,
["你好,需要什么?"],
options
)
func append_quest_options(options: Array[DialogueOption]):
if quest == null:
return
if quest_manager == null:
return
if quest_manager.is_completed(quest.id):
return
var quest_state = quest_manager.get_active_quest(quest.id)
if quest_state == null:
options.append(DialogueOption.new(
"有什么需要帮忙的吗?",
DialogueOption.ActionType.CONTINUE_DIALOGUE,
{
"lines": [
quest.description
],
"next_options": "quest_offer"
}
))
return
if current_player != null and current_player.has_method("get_inventory"):
quest_manager.update_collect_quest_progress(quest.id, current_player.get_inventory())
if quest_state.status == QuestState.Status.READY_TO_COMPLETE:
options.append(DialogueOption.new(
"交付任务:" + quest.title,
DialogueOption.ActionType.COMPLETE_QUEST,
{
"quest_id": quest.id
}
))
else:
options.append(DialogueOption.new(
"查看任务进度:" + quest.title,
DialogueOption.ActionType.SHOW_QUEST_PROGRESS,
{
"quest_id": quest.id
}
))
func can_open_shop_now() -> bool:
if shop_data == null:
return false
if shop_panel == null:
return false
if "current_entry" in self:
if current_entry == null:
return false
return current_entry.can_shop
return true
func _on_dialogue_option_selected(option: DialogueOption):
match option.action_type:
DialogueOption.ActionType.CONTINUE_DIALOGUE:
handle_continue_dialogue(option)
DialogueOption.ActionType.OPEN_SHOP:
handle_open_shop()
DialogueOption.ActionType.START_QUEST:
handle_start_quest(option)
DialogueOption.ActionType.COMPLETE_QUEST:
handle_complete_quest(option)
DialogueOption.ActionType.SHOW_QUEST_PROGRESS:
handle_show_quest_progress(option)
DialogueOption.ActionType.CLOSE_DIALOGUE:
dialogue_box.close_dialogue()
DialogueOption.ActionType.CUSTOM:
handle_custom_option(option)
func handle_continue_dialogue(option: DialogueOption):
var option_lines: Array[String] = option.payload.get("lines", [])
if option_lines.is_empty():
option_lines = ["没什么特别的事。"]
var next_options_type: String = option.payload.get("next_options", "")
if next_options_type == "quest_offer":
dialogue_box.start_dialogue_with_options(
npc_name,
option_lines,
[
DialogueOption.new("我来帮你", DialogueOption.ActionType.START_QUEST, {
"quest_id": quest.id
}),
DialogueOption.new("现在不行", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
return
dialogue_box.start_dialogue_with_options(
npc_name,
option_lines,
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
}),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
func handle_open_shop():
dialogue_box.close_dialogue()
if shop_panel == null:
return
if shop_data == null:
return
if current_player == null:
return
shop_panel.open_shop(shop_data, current_player)
func handle_start_quest(option: DialogueOption):
if quest_manager == null:
return
var quest_id: String = option.payload.get("quest_id", "")
if quest_id == "":
return
if quest == null or quest.id != quest_id:
return
var success = quest_manager.start_quest(quest)
if success:
dialogue_box.start_dialogue_with_options(
npc_name,
["太好了,那就拜托你了。"],
[
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
else:
dialogue_box.start_dialogue_with_options(
npc_name,
["这个任务好像已经处理过了。"],
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
func handle_show_quest_progress(option: DialogueOption):
if quest_manager == null:
return
var quest_id: String = option.payload.get("quest_id", "")
var quest_state: QuestState = quest_manager.get_active_quest(quest_id)
if quest_state == null:
open_main_dialogue()
return
var lines: Array[String] = []
if quest_state.quest.quest_type == QuestData.QuestType.COLLECT_ITEM:
lines.append("你现在的进度是:%d / %d。" % [
quest_state.current_amount,
quest_state.quest.required_quantity
])
if quest_state.current_amount < quest_state.quest.required_quantity:
lines.append("还差一点,继续加油。")
dialogue_box.start_dialogue_with_options(
npc_name,
lines,
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
}),
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
func handle_complete_quest(option: DialogueOption):
if quest_manager == null:
return
if current_player == null:
return
var quest_id: String = option.payload.get("quest_id", "")
if quest_id == "":
return
var success = quest_manager.complete_quest(quest_id, current_player)
if success:
dialogue_box.start_dialogue_with_options(
npc_name,
["太好了,你完成了任务。这是你的奖励。"],
[
DialogueOption.new("离开", DialogueOption.ActionType.CLOSE_DIALOGUE)
]
)
else:
dialogue_box.start_dialogue_with_options(
npc_name,
["你好像还没有完成任务。"],
[
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
]
)
func handle_custom_option(option: DialogueOption):
var custom_action: String = option.payload.get("custom_action", "")
match custom_action:
"back_to_main":
open_main_dialogue()
_:
dialogue_box.close_dialogue()
40. 对话选项和 UI 焦点
如果你想支持键盘选择按钮,需要处理 Focus。
最简单是在 show_options 结束后,让第一个按钮获得焦点:
if options_container.get_child_count() > 0:
var first_button = options_container.get_child(0)
if first_button is Button:
first_button.grab_focus()
加到 show_options() 末尾:
func show_options(options: Array[DialogueOption]):
waiting_for_option = true
next_icon.visible = false
for child in options_container.get_children():
child.queue_free()
options_container.visible = true
for option in options:
var button = Button.new()
button.text = option.text
button.disabled = option.disabled
button.pressed.connect(func():
_on_option_button_pressed(option)
)
options_container.add_child(button)
if options_container.get_child_count() > 0:
var first_button = options_container.get_child(0)
if first_button is Button:
first_button.grab_focus()
这样玩家可以用方向键和确认键选择。
41. 对话打开时锁住玩家
对话打开时玩家应该不能移动。
可以在 NPC.interact 里:
if current_player.has_method("lock_control_for_menu"):
current_player.lock_control_for_menu()
对话结束时恢复。
DialogueBox 可以发:
signal dialogue_closed
close_dialogue 里:
dialogue_closed.emit()
NPC 连接:
dialogue_box.dialogue_closed.connect(_on_dialogue_closed)
func _on_dialogue_closed():
if current_player != null and current_player.has_method("unlock_control_from_menu"):
current_player.unlock_control_from_menu()
但要小心:
打开商店时,对话关闭后商店会继续锁玩家
如果 ShopPanel 自己也会 lock,那问题不大。
第一版可以让:
对话框负责锁
商店负责锁
关闭对话打开商店时,先 unlock 又 lock
短瞬间不会有什么问题。
后面可以用 GameStateManager 统一管理。
42. 条件对话是什么
条件对话就是根据状态显示不同内容。
比如:
任务未接:
“森林里的史莱姆太多了。”
任务进行中:
“你收集得怎么样了?”
任务可完成:
“太好了,你收集齐了!”
任务已完成:
“谢谢你之前的帮助。”
雨天:
“这种天气还是别出门了。”
晚上:
“这么晚了,有什么事吗?”
目前我们已经用代码判断了一部分。
后面可以把条件做成数据。
但第一版写在 NPC 脚本里就够。
43. 简单条件对话函数
func get_greeting_lines() -> Array[String]:
if TimeManager.current_phase == TimeManager.DayPhase.NIGHT:
return ["这么晚了,有什么事吗?"]
var weather = WeatherManager.current_weather
if weather == WeatherManager.Weather.RAIN or weather == WeatherManager.Weather.STORM:
return ["雨下得真大,路上小心。"]
return ["你好,需要什么?"]
然后:
dialogue_box.start_dialogue_with_options(
npc_name,
get_greeting_lines(),
options
)
这样 NPC 开场白就会随时间天气变化。
44. 任务状态开场白
func get_greeting_lines() -> Array[String]:
if quest != null and quest_manager != null:
if quest_manager.is_completed(quest.id):
return ["谢谢你之前的帮助。"]
var quest_state = quest_manager.get_active_quest(quest.id)
if quest_state != null:
if quest_state.status == QuestState.Status.READY_TO_COMPLETE:
return ["你回来了!东西都带齐了吗?"]
return ["任务还顺利吗?"]
if TimeManager.current_phase == TimeManager.DayPhase.NIGHT:
return ["这么晚了,有什么事吗?"]
return ["你好,需要什么?"]
这会比永远一句“你好”自然很多。
45. 用 get_greeting_lines 替换固定开场
dialogue_box.start_dialogue_with_options(
npc_name,
get_greeting_lines(),
options
)
这样 NPC 主菜单会根据任务、时间、天气切换开场白。
46. 选项系统和任务系统的关系
现在任务交互流程变成:
玩家按 E
NPC 显示主菜单
如果任务未接:
显示“有什么需要帮忙的吗?”
点击后显示任务说明
再选择“我来帮你”或“现在不行”
如果任务进行中:
显示“查看任务进度”
点击后显示 3/5
如果任务可完成:
显示“交付任务”
点击后完成任务,发奖励
如果任务已完成:
不显示任务选项,NPC 说感谢
这比之前“一按 E 自动接任务 / 自动交任务”好很多。
47. 选项系统和商店系统的关系
现在商店交互变成:
玩家按 E
NPC 显示:
聊天
购买
离开
点击购买:
关闭对话
打开商店
这样一个商人 NPC 也能聊天,不只是“活体自动售货机”。
48. 选项系统和 NPC 日程的关系
日程可以控制:
can_shop
can_interact
action_type
然后选项系统根据日程生成:
白天:
聊天
购买
离开
打烊后:
聊天
离开
睡觉时:
不能交互,或者只显示“呼……”
这样 NPC 的功能会跟时间联动。
49. 选项系统和天气的关系
雨天可以改变:
开场白
普通聊天内容
可用选项
日程位置
商店是否营业
比如暴雨天商人不开店:
current_entry.can_shop = false
或者:
if WeatherManager.current_weather == WeatherManager.Weather.STORM:
不显示购买选项
第一版建议通过日程控制,不要到处写天气判断。
50. 后面如何做数据化对话树
现在我们是代码生成选项。
以后可以做成 Resource:
DialogueNode
├── npc_name
├── lines
├── options
每个 option:
text
next_node
action_type
condition
这样可以做真正的剧情树。
比如:
node_start
lines: ["你愿意帮我吗?"]
options:
是 -> node_accept
否 -> node_refuse
node_accept
lines: ["太好了。"]
action: START_QUEST
node_refuse
lines: ["好吧,也许下次。"]
但这个系统会复杂很多。
第一版先用代码生成,够你做任务、商店和普通聊天。
51. 常见错误 1:选项按钮不显示
排查:
DialogueBox 节点里是否有 OptionsContainer
OptionsContainer 路径是否正确
finish_lines 是否调用 show_options
current_options 是否为空
start_dialogue_with_options 是否传了 options
OptionsContainer 是否被其他 UI 盖住
临时打印:
print("选项数量:", current_options.size())
52. 常见错误 2:点击选项没反应
排查:
button.pressed 是否连接
_on_option_button_pressed 是否 emit option_selected
NPC 是否连接 dialogue_box.option_selected
是不是连接到了旧的 NPC 方法
临时打印:
func _on_option_button_pressed(option):
print("点击选项:", option.text)
53. 常见错误 3:一个选项触发了多个 NPC
原因通常是多个 NPC 都连接了同一个 DialogueBox 的 option_selected,但没有断开。
解决:
if dialogue_box.option_selected.is_connected(_on_dialogue_option_selected):
dialogue_box.option_selected.disconnect(_on_dialogue_option_selected)
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
但这只能断开当前 NPC 自己的方法。
如果多个 NPC 都连着,还需要更统一的做法。
更稳的是 DialogueBox 保存当前 owner:
var current_owner: Node
或者 NPC 在对话关闭时断开。
第一版先记住:
全局 UI 的 signal 连接要小心重复
54. 常见错误 4:购买选项不显示
排查:
shop_data 是否设置
shop_panel 是否注入
current_entry.can_shop 是否 true
当前时间是否在营业时间
can_open_shop_now 是否返回 true
如果你用了 NPC 日程,最常见是:
current_entry.can_shop = false
55. 常见错误 5:任务选项不显示
排查:
quest 是否设置
quest_manager 是否注入
QuestData.id 是否不为空
quest_manager.is_completed 是否已经 true
append_quest_options 是否被调用
如果任务已经完成,任务选项不显示是正常的。
56. 常见错误 6:接任务后选项没刷新
接任务后,如果还停留在旧菜单,需要重新打开主菜单,或者显示接任务成功后关闭。
当前代码是:
接任务成功
显示“拜托你了”
只有离开按钮
这没问题。
如果你想返回主菜单,就把选项改成:
DialogueOption.new("返回", DialogueOption.ActionType.CUSTOM, {
"custom_action": "back_to_main"
})
57. 常见错误 7:交任务时说没完成
排查:
QuestManager.update_collect_quest_progress 是否调用
背包里 required_item 数量是否够
required_item 是否拖错资源
QuestData.required_quantity 是否正确
QuestState.status 是否 READY_TO_COMPLETE
在生成“交付任务”选项前,最好先刷新任务进度:
quest_manager.update_collect_quest_progress(quest.id, current_player.get_inventory())
58. 常见错误 8:对话结束后玩家不能动
排查:
对话打开时是否 lock_control_for_menu
对话关闭时是否 unlock_control_from_menu
dialogue_closed 信号是否发出
打开商店时是否由 ShopPanel 重新 lock
关闭商店时是否 unlock
后面建议做 GameStateManager 统一处理。
第一版先保证每个打开/关闭成对出现。
59. 这一部分最重要的记忆点
DialogueBox 只负责 UI,不负责任务和商店逻辑
DialogueOption 表示一个可点击选项
选项通过 action_type 区分行为
payload 用来携带 quest_id、lines 等附加数据
NPC 负责生成选项和处理选项
商店可以作为一个 OPEN_SHOP 选项
任务可以根据状态显示接任务、看进度、交任务
对话选项要小心全局 DialogueBox 的 signal 重复连接
第一版代码生成选项即可,后面再做数据化对话树
60. 你现在的小练习
按这个顺序做:
1. 创建 DialogueOption.gd
2. DialogueBox 添加 OptionsContainer
3. DialogueBox 添加 option_selected 信号
4. DialogueBox 添加 start_dialogue_with_options()
5. DialogueBox 播完 lines 后显示选项按钮
6. 点击按钮后 emit option_selected
7. NPC interact 时连接 option_selected
8. NPC 生成主菜单选项:聊聊 / 离开
9. 点击聊聊后显示普通对话
10. 普通对话结束后显示 返回 / 离开
11. 商人 NPC 增加 购买 选项
12. 点击购买后关闭对话并打开 ShopPanel
13. 任务 NPC 增加任务选项
14. 未接任务时显示 有什么需要帮忙的吗
15. 点击后显示任务说明
16. 显示 我来帮你 / 现在不行
17. 点击我来帮你后 start_quest
18. 任务进行中显示 查看任务进度
19. 收集够物品后显示 交付任务
20. 点击交付任务后 complete_quest
21. 任务完成后不再显示任务选项
22. 根据 TimeManager 夜晚显示不同开场白
23. 根据 WeatherManager 雨天显示不同聊天内容
完成这一部分后,NPC 就不再是“按 E 触发单一事件”的对象,而是真正能提供多个交互入口的角色。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录