第二十四部分:对话选项与分支系统:购买、聊天、任务、条件对话、任务状态分支

2026-05-19
341912 分钟
...

这一部分开始升级对话系统。

前面我们的 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_iddialogue_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_idlines 等附加数据
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 触发单一事件”的对象,而是真正能提供多个交互入口的角色。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...