第七部分:Dialogue 对话系统、对话框 UI、逐字显示、暂停移动

2026-05-19
385713 分钟
...

这一部分会把上一节的 NPC 交互接起来。

上一部分我们已经有了:

玩家靠近 NPC

显示 E 对话

玩家按 E

NPC interact(player) 被调用

现在要做的是:

NPC 不再只是 print 一句话
而是打开真正的对话框 UI
逐字显示文本
按键继续下一句
对话期间玩家不能移动
对话结束后恢复移动

这就是 RPG 最基础的对话系统。

1. 对话系统的核心目标

先不要一上来做复杂剧情编辑器。

第一阶段目标非常明确:

1. 有一个对话框 UI
2. 能显示 NPC 名字
3. 能显示一段文字
4. 文字可以逐字出现
5. E Enter 可以跳过/继续
6. 对话时玩家不能移动
7. 对话结束后玩家恢复移动

这就已经够用了。

比如:

村民
欢迎来到雾林村

村民
最近森林里出现了奇怪的怪物

村民
如果你要去那边最好先准备一些药草

2. 对话系统推荐结构

现在建议你的场景结构变成这样:

Main Node
├── World Node2D
│   ├── Ground TileMapLayer
│   ├── Obstacles TileMapLayer
│   ├── Interactables Node2D
│   │   └── NPC Area2D
│   ├── Player CharacterBody2D
│   └── Foreground TileMapLayer
└── UI CanvasLayer
    ├── InteractLabel Label
    └── DialogueBox Control
        ├── Panel
        ├── NameLabel Label
        ├── TextLabel RichTextLabel
        └── ContinueLabel Label

注意这里:

DialogueBox UI
所以应该放在 CanvasLayer 下面

不要把对话框放在 World 里。

否则 Camera2D 移动时,对话框可能会跟着地图跑。

CanvasLayer 可以让 UI 独立于世界地图的相机移动显示,适合放血条、提示、对话框这类屏幕 UI。(Godot Engine documentation)

3. Control 是什么

Godot 的 UI 节点大多继承自 Control

比如:

Label
Button
Panel
TextureRect
RichTextLabel
ProgressBar
VBoxContainer
HBoxContainer
MarginContainer

Control 是所有 UI 相关节点的基础类,它有矩形区域、锚点、偏移等布局属性。(Godot Engine documentation)

你可以把它类比成前端里的普通 UI 元素:

<div class="dialogue-box">
  <div class="name">村民</div>
  <div class="text">欢迎来到村子。</div>
</div>

Godot 里就是:

DialogueBox Control
├── NameLabel Label
└── TextLabel RichTextLabel

4. RichTextLabel 是什么

普通 Label 可以显示文字。

RichTextLabel 更适合对话文本,因为它支持更丰富的文本能力,比如格式化、颜色、图片、BBCode 等。Godot 文档里也说明,RichTextLabel 是用于显示文本的 Control,可以包含自定义字体、图片和基础格式。(Godot Engine documentation)

比如后面你可以做:

普通文字
彩色关键词
加粗 NPC 名字
物品名称变色
带图标的奖励文本

第一阶段你不用搞复杂格式。

但对话正文建议直接用 RichTextLabel,后面扩展空间更大。

5. 对话框 UI 怎么搭

建议结构:

DialogueBox Control
├── Panel
├── NameLabel Label
├── TextLabel RichTextLabel
└── ContinueLabel Label

更实际一点可以这样:

DialogueBox Control
└── Panel Panel
    ├── NameLabel Label
    ├── TextLabel RichTextLabel
    └── ContinueLabel Label

Panel 负责背景。

NameLabel 显示说话人名字。

TextLabel 显示对话正文。

ContinueLabel 显示提示,比如:

 E 继续

默认让 DialogueBox.visible = false

只有开始对话时才显示。

6. 对话框的位置

一般放在屏幕底部。

可以在编辑器中选中 DialogueBox,设置 Layout 预设为底部拉伸一块区域。

比如大概这样:

屏幕
┌──────────────────────┐
│                      │
游戏画面
│                      │
│                      │
├──────────────────────┤
村民
欢迎来到雾林村。     │
 E 继续
└──────────────────────┘

Godot 的 UI 节点通过 anchors 和 offsets 控制位置与大小,锚点决定 UI 相对父级矩形的位置,offset 决定从锚点偏移多少。(Godot Engine documentation)

初学时不要手写所有锚点。

直接用编辑器的 Layout Preset 更舒服。

7. DialogueBox.gd 基础版

DialogueBox 挂脚本:

extends Control

@onready var name_label: Label = $Panel/NameLabel
@onready var text_label: RichTextLabel = $Panel/TextLabel
@onready var continue_label: Label = $Panel/ContinueLabel

var lines: Array[String] = []
var current_index: int = 0
var speaker_name: String = ""

func _ready():
    visible = false

func start_dialogue(name: String, dialogue_lines: Array[String]):
    speaker_name = name
    lines = dialogue_lines
    current_index = 0

    visible = true
    show_current_line()

func show_current_line():
    if current_index >= lines.size():
        end_dialogue()
        return

    name_label.text = speaker_name
    text_label.text = lines[current_index]
    continue_label.text = "按 E 继续"

func next_line():
    current_index += 1
    show_current_line()

func end_dialogue():
    visible = false

这个版本还没有逐字显示。

但已经能:

打开对话框
显示 NPC 名字
显示当前句子
切换到下一句
结束隐藏

8. UI 负责显示,不负责决定剧情

这点很重要。

DialogueBox 最好只负责:

显示名字
显示文字
逐字播放
下一句
关闭

不要让它负责:

玩家移动
NPC AI
地图切换
任务奖励
背包物品
剧情条件判断

也就是说,DialogueBox 是一个 UI 组件。

它不应该变成游戏总控制器。

前端类比:

<DialogueBox
  speaker="村民"
  lines={["欢迎来到村子。", "最近森林很危险。"]}
/>

它只负责显示。

至于这个对话是谁触发的、结束后给不给奖励,是外面的系统决定。

9. 让 NPC 调用 DialogueBox

现在 NPC 的 interact(player) 可以这样:

extends Area2D

@export var prompt_text: String = "按 E 对话"
@export var npc_name: String = "村民"
@export var dialogue_lines: Array[String] = [
    "欢迎来到雾林村。",
    "最近森林里出现了奇怪的怪物。",
    "如果你要去那边,最好先准备一些药草。"
]

@export var dialogue_box: Control

func interact(player: Node):
    dialogue_box.start_dialogue(npc_name, dialogue_lines)

但是这样有个问题:

每个 NPC 都要手动拖 DialogueBox

短期可以,长期麻烦。

更好的方式后面会讲:用 DialogueManager 统一管理。

现在先用最直观的做法,跑通第一版。

10. 按 E 继续对话

现在 DialogueBox 需要自己处理“继续”输入。

可以写在 DialogueBox.gd

func _unhandled_input(event):
    if not visible:
        return

    if event.is_action_pressed("interact"):
        next_line()
        get_viewport().set_input_as_handled()

这里的逻辑是:

如果对话框不可见不处理输入
如果对话框可见并且玩家按 interact就进入下一句

get_viewport().set_input_as_handled() 的意思是:

这个输入已经被对话框处理了
不要再传给其他地方

这可以避免按 E 继续对话时,同时触发其他交互。

11. 但这里会有一个冲突

上一部分里,Player 也在监听:

func _unhandled_input(event):
    if event.is_action_pressed("interact"):
        try_interact()

现在 DialogueBox 也监听:

func _unhandled_input(event):
    if event.is_action_pressed("interact"):
        next_line()

如果处理不好,可能出现:

 E NPC 对话

对话框打开

同一次 E 又让对话跳到下一句

这种问题非常常见。

所以通常需要一个状态:

当前是否正在对话

对话时:

Player 不再响应普通交互
DialogueBox 接管 interact

12. 玩家暂停移动

在 Player 里加:

var can_move: bool = true

然后移动时判断:

func _physics_process(delta):
    if not can_move:
        velocity = Vector2.ZERO
        move_and_slide()
        update_animation(Vector2.ZERO)
        return

    var direction = Input.get_vector(
        "move_left",
        "move_right",
        "move_up",
        "move_down"
    )

    velocity = direction * speed
    move_and_slide()
    update_animation(direction)

再加两个方法:

func lock_movement():
    can_move = false

func unlock_movement():
    can_move = true

这样对话开始时:

player.lock_movement()

对话结束时:

player.unlock_movement()

13. DialogueBox 发出结束信号

DialogueBox 在对话结束时发出信号。

signal dialogue_started
signal dialogue_finished

完整一点:

extends Control

signal dialogue_started
signal dialogue_finished

@onready var name_label: Label = $Panel/NameLabel
@onready var text_label: RichTextLabel = $Panel/TextLabel
@onready var continue_label: Label = $Panel/ContinueLabel

var lines: Array[String] = []
var current_index: int = 0
var speaker_name: String = ""
var is_active: bool = false

func _ready():
    visible = false

func start_dialogue(name: String, dialogue_lines: Array[String]):
    if dialogue_lines.is_empty():
        return

    speaker_name = name
    lines = dialogue_lines
    current_index = 0
    is_active = true

    visible = true
    dialogue_started.emit()
    show_current_line()

func show_current_line():
    if current_index >= lines.size():
        end_dialogue()
        return

    name_label.text = speaker_name
    text_label.text = lines[current_index]
    continue_label.text = "按 E 继续"

func next_line():
    current_index += 1
    show_current_line()

func end_dialogue():
    visible = false
    is_active = false
    dialogue_finished.emit()

func _unhandled_input(event):
    if not is_active:
        return

    if event.is_action_pressed("interact"):
        next_line()
        get_viewport().set_input_as_handled()

这样 UI 就能告诉外面:

对话开始了
对话结束了

14. 在 UI 或 World 里连接信号

比如 UI.gd

extends CanvasLayer

@export var player: Node

@onready var dialogue_box: Control = $DialogueBox

func _ready():
    dialogue_box.dialogue_started.connect(_on_dialogue_started)
    dialogue_box.dialogue_finished.connect(_on_dialogue_finished)

func _on_dialogue_started():
    if player != null and player.has_method("lock_movement"):
        player.lock_movement()

func _on_dialogue_finished():
    if player != null and player.has_method("unlock_movement"):
        player.unlock_movement()

这样职责就清楚了:

DialogueBox发出开始/结束信号
UI收到信号后控制玩家移动锁定
Player提供 lock_movement / unlock_movement

15. 更推荐的结构:DialogueManager

上面的做法能跑,但后面 NPC 多了之后,每个 NPC 都要拖 DialogueBox,不太舒服。

更好的结构是加一个管理器:

UI CanvasLayer
├── DialogueBox
└── InteractLabel

UI.gdDialogueManager.gd 提供一个方法:

func show_dialogue(speaker_name: String, lines: Array[String]):
    dialogue_box.start_dialogue(speaker_name, lines)

然后 NPC 只需要找到 UI 或 DialogueManager 调用它。

第一阶段可以先简单:

Main
├── World
└── UI
    └── DialogueBox

Main.gd 里把 UI 引用传给 NPC。

但为了不把架构搞太复杂,当前你先用:

NPC 导出 dialogue_box然后手动拖

等你能跑了,再重构成 DialogueManager。

16. 逐字显示是什么

逐字显示就是文字不是一下子全部出现,而是一点点打出来。

比如:


欢迎
欢迎来
欢迎来到
欢迎来到雾
欢迎来到雾林
欢迎来到雾林村
欢迎来到雾林村

这会让对话更有 RPG 味道。

如果你直接每帧截字符串也可以,但 RichTextLabel 有一个很好用的属性:

visible_characters

它可以控制当前显示多少个字符。

先设置完整文本:

text_label.text = "欢迎来到雾林村。"

然后:

text_label.visible_characters = 0

逐渐增加:

text_label.visible_characters += 1

RichTextLabel 支持控制可见字符数量,这很适合做 RPG 对话的打字机效果。(Godot Engine documentation)

17. 逐字显示基础版

在 DialogueBox.gd 里加:

@export var type_speed: float = 0.03

var is_typing: bool = false

然后写:

func show_current_line():
    if current_index >= lines.size():
        end_dialogue()
        return

    name_label.text = speaker_name
    text_label.text = lines[current_index]
    text_label.visible_characters = 0
    continue_label.visible = false

    type_current_line()

逐字函数:

func type_current_line():
    is_typing = true

    var total_characters := text_label.get_total_character_count()

    while text_label.visible_characters < total_characters:
        text_label.visible_characters += 1
        await get_tree().create_timer(type_speed).timeout

    is_typing = false
    continue_label.visible = true

get_tree().create_timer(seconds).timeout 常用于等待一小段时间后继续执行,是 Godot 里很常见的异步等待写法。(Godot Forum)

18. 按 E 时:正在打字就跳到完整句子

玩家不一定想等逐字显示。

所以按 E 时应该有两种行为:

如果正在逐字显示
直接显示完整当前句

如果当前句已经显示完
进入下一句

修改输入:

func _unhandled_input(event):
    if not is_active:
        return

    if event.is_action_pressed("interact"):
        if is_typing:
            skip_typing()
        else:
            next_line()

        get_viewport().set_input_as_handled()

跳过打字:

func skip_typing():
    text_label.visible_characters = text_label.get_total_character_count()
    is_typing = false
    continue_label.visible = true

但这里有一个坑:type_current_line() 的 while 还在跑。

所以更严谨一点,需要加一个“打字版本号”。

19. 避免旧的逐字循环继续执行

比如玩家快速按 E:

第一句还在逐字显示
玩家按 E 跳过
马上进入第二句
旧的 while 可能还没结束

为了避免旧循环影响新句子,可以用一个变量:

var typing_id: int = 0

每次开始新句子:

typing_id += 1
var current_typing_id = typing_id

循环里检查:

if current_typing_id != typing_id:
    return

完整逐字函数:

func type_current_line():
    typing_id += 1
    var current_typing_id := typing_id

    is_typing = true

    var total_characters := text_label.get_total_character_count()

    while text_label.visible_characters < total_characters:
        if current_typing_id != typing_id:
            return

        text_label.visible_characters += 1
        await get_tree().create_timer(type_speed).timeout

    if current_typing_id != typing_id:
        return

    is_typing = false
    continue_label.visible = true

跳过时:

func skip_typing():
    typing_id += 1
    text_label.visible_characters = text_label.get_total_character_count()
    is_typing = false
    continue_label.visible = true

这个写法更稳。

20. 完整 DialogueBox.gd

这一版可以直接保存。

extends Control

signal dialogue_started
signal dialogue_finished

@export var type_speed: float = 0.03

@onready var name_label: Label = $Panel/NameLabel
@onready var text_label: RichTextLabel = $Panel/TextLabel
@onready var continue_label: Label = $Panel/ContinueLabel

var lines: Array[String] = []
var current_index: int = 0
var speaker_name: String = ""

var is_active: bool = false
var is_typing: bool = false
var typing_id: int = 0

func _ready():
    visible = false
    continue_label.visible = false

func start_dialogue(name: String, dialogue_lines: Array[String]):
    if dialogue_lines.is_empty():
        return

    speaker_name = name
    lines = dialogue_lines
    current_index = 0

    is_active = true
    visible = true

    dialogue_started.emit()
    show_current_line()

func show_current_line():
    if current_index >= lines.size():
        end_dialogue()
        return

    name_label.text = speaker_name
    text_label.text = lines[current_index]
    text_label.visible_characters = 0
    continue_label.visible = false

    type_current_line()

func type_current_line():
    typing_id += 1
    var current_typing_id := typing_id

    is_typing = true

    var total_characters := text_label.get_total_character_count()

    while text_label.visible_characters < total_characters:
        if current_typing_id != typing_id:
            return

        text_label.visible_characters += 1
        await get_tree().create_timer(type_speed).timeout

    if current_typing_id != typing_id:
        return

    is_typing = false
    continue_label.visible = true

func skip_typing():
    typing_id += 1
    text_label.visible_characters = text_label.get_total_character_count()
    is_typing = false
    continue_label.visible = true

func next_line():
    current_index += 1
    show_current_line()

func end_dialogue():
    typing_id += 1

    visible = false
    is_active = false
    is_typing = false
    continue_label.visible = false

    dialogue_finished.emit()

func _unhandled_input(event):
    if not is_active:
        return

    if event.is_action_pressed("interact"):
        if is_typing:
            skip_typing()
        else:
            next_line()

        get_viewport().set_input_as_handled()

这个版本已经包含:

打开对话
逐字显示
跳过当前句
下一句
结束对话
开始/结束信号

21. NPC.gd 完整版

NPC 场景:

NPC Area2D
├── Sprite2D
└── CollisionShape2D

加入 group:

interactable

脚本:

extends Area2D

@export var prompt_text: String = "按 E 对话"
@export var npc_name: String = "村民"
@export var dialogue_lines: Array[String] = [
    "欢迎来到雾林村。",
    "最近森林里出现了奇怪的怪物。",
    "如果你要去那边,最好先准备一些药草。"
]

@export var dialogue_box: Control

func interact(player: Node):
    if dialogue_box == null:
        print("NPC 没有设置 dialogue_box")
        return

    dialogue_box.start_dialogue(npc_name, dialogue_lines)

这个先够用。

22. Player.gd 需要防止对话中重复交互

Player 里加:

var can_interact: bool = true

try_interact() 改成:

func try_interact():
    if not can_interact:
        return

    if current_interactable == null:
        return

    if current_interactable.has_method("interact"):
        current_interactable.interact(self)

加方法:

func lock_control():
    can_move = false
    can_interact = false

func unlock_control():
    can_move = true
    can_interact = true

也可以分开:

func lock_movement():
    can_move = false

func unlock_movement():
    can_move = true

func lock_interaction():
    can_interact = false

func unlock_interaction():
    can_interact = true

入门阶段推荐直接:

lock_control()
unlock_control()

因为对话时通常移动和交互都要锁住。

23. Player.gd 关键改动版

这里只展示和控制相关的部分:

var can_move: bool = true
var can_interact: bool = true

func _physics_process(delta):
    if not can_move:
        velocity = Vector2.ZERO
        move_and_slide()
        update_animation(Vector2.ZERO)
        return

    var direction = Input.get_vector(
        "move_left",
        "move_right",
        "move_up",
        "move_down"
    )

    velocity = direction * speed
    move_and_slide()
    update_animation(direction)

func try_interact():
    if not can_interact:
        return

    if current_interactable == null:
        return

    if current_interactable.has_method("interact"):
        current_interactable.interact(self)

func lock_control():
    can_move = false
    can_interact = false

func unlock_control():
    can_move = true
    can_interact = true

24. UI.gd 连接 DialogueBox 和 Player

extends CanvasLayer

@export var player: Node

@onready var dialogue_box: Control = $DialogueBox
@onready var interact_label: Label = $InteractLabel

func _ready():
    dialogue_box.dialogue_started.connect(_on_dialogue_started)
    dialogue_box.dialogue_finished.connect(_on_dialogue_finished)

func _on_dialogue_started():
    interact_label.visible = false

    if player != null and player.has_method("lock_control"):
        player.lock_control()

func _on_dialogue_finished():
    if player != null and player.has_method("unlock_control"):
        player.unlock_control()

这样对话一开始:

隐藏交互提示
锁住玩家控制

对话结束:

恢复玩家控制

25. 对话中隐藏“按 E 交互”

如果对话框打开了,InteractLabel 还显示“按 E 对话”,会很怪。

所以对话开始时隐藏:

interact_label.visible = false

对话结束后,是否重新显示交互提示,可以让 Player 的 interactable_changed 再触发。

更简单的方式是:

func _on_dialogue_finished():
    if player != null and player.has_method("unlock_control"):
        player.unlock_control()

    if player != null and player.current_interactable != null:
        interact_label.visible = true

不过这样 UI 直接访问 player.current_interactable,耦合稍微高一点。

第一阶段可以接受。

后面我们会慢慢拆得更舒服。

26. 对话文本数据放在哪里

现在可以先放在 NPC 脚本导出变量里:

@export var dialogue_lines: Array[String]

优点:

简单
编辑器里能直接改
不用额外文件
适合入门

缺点:

文本多了不好管理
不能很好地做分支
不能很好地做多语言
不能很好地和任务系统联动

后面项目变大,可以改成:

JSON 对话文件
Resource 对话资源
DialogueManager
第三方对话插件
自己写剧情编辑器

但现在千万别一上来做复杂系统。

你现在的目标是:

NPC 能说多句话
玩家能按 E 继续
对话期间不能走

就够了。

27. 对话是否要用 JSON

以后可以,但现在不建议。

比如 JSON 会变成:

{
  "npc_name": "村民",
  "lines": [
    "欢迎来到雾林村。",
    "最近森林里出现了奇怪的怪物。",
    "如果你要去那边,最好先准备一些药草。"
  ]
}

看起来很清楚。

但你很快会遇到:

怎么加载文件
怎么解析 JSON
怎么关联 NPC
怎么做编辑工具
怎么热更新
怎么做分支

这些问题会把你从“做游戏”拽回“做系统”。

所以第一阶段别急。

就用:

@export var dialogue_lines: Array[String]

它很朴素,但能让你继续往前走。

28. 对话框 UI 的基本美化

先不要过度美化。

你可以做这些:

Panel 背景半透明
NameLabel 放左上角
TextLabel 放中间
ContinueLabel 放右下角
DialogueBox 距离屏幕边缘留一点 padding

比如:

NameLabel村民
TextLabel欢迎来到雾林村
ContinueLabel E 继续

Panel 背景可以先用默认 Theme。

后面再换成像素风边框、羊皮纸风格、中世纪木牌风格。

不要现在就为了 UI 边框卡住一整天,Godot 会笑你,温柔但残忍。

29. 逐字显示速度怎么调

这个变量:

@export var type_speed: float = 0.03

意思是每个字符间隔 0.03 秒。

可以试:

0.01很快
0.03比较正常
0.05慢一点有剧情感
0.08很慢适合重要台词

后面可以扩展:

普通句子 0.03
紧张句子 0.015
神秘低语 0.06
标点停顿更久

但现在不用。

30. 标点停顿

如果你想稍微高级一点,可以让逗号、句号停顿更久。

在逐字循环里:

var wait_time := type_speed
var current_char := lines[current_index][text_label.visible_characters - 1]

if current_char in ["。", ",", "!", "?"]:
    wait_time = type_speed * 6.0

await get_tree().create_timer(wait_time).timeout

不过这段要注意索引,别越界。

更完整一点:

while text_label.visible_characters < total_characters:
    if current_typing_id != typing_id:
        return

    text_label.visible_characters += 1

    var wait_time := type_speed
    var char_index := text_label.visible_characters - 1

    if char_index >= 0 and char_index < lines[current_index].length():
        var current_char := lines[current_index][char_index]

        if current_char in ["。", ",", "!", "?"]:
            wait_time = type_speed * 6.0

    await get_tree().create_timer(wait_time).timeout

这个会让对话更像人在说话。

但第一版可以先不用,别给自己加戏加到系统爆炸。

31. 对话结束后触发事件

有时候 NPC 说完话后,需要触发后续逻辑:

给玩家一个道具
开启任务
切换场景
 NPC 走开
播放音效

这时可以在 NPC 里连接 dialogue_finished

简单例子:

func interact(player: Node):
    if dialogue_box == null:
        return

    dialogue_box.dialogue_finished.connect(_on_dialogue_finished, CONNECT_ONE_SHOT)
    dialogue_box.start_dialogue(npc_name, dialogue_lines)

func _on_dialogue_finished():
    print("这段对话结束了,可以触发任务")

CONNECT_ONE_SHOT 表示这个连接只触发一次。

否则你每次对话都 connect,一不小心会重复触发多次。

32. 对话中防止重复打开对话

如果 DialogueBox 已经在显示,就不要再打开一个新的对话。

在 NPC 里:

func interact(player: Node):
    if dialogue_box == null:
        return

    if dialogue_box.is_active:
        return

    dialogue_box.start_dialogue(npc_name, dialogue_lines)

或者在 DialogueBox.start_dialogue() 里:

func start_dialogue(name: String, dialogue_lines: Array[String]):
    if is_active:
        return

    if dialogue_lines.is_empty():
        return

    ...

我更推荐放在 DialogueBox 自己里面,因为它最知道自己是否正在工作。

33. “按 E 对话”和“按 E 继续”共用同一个键可以吗

可以,而且很常见。

但一定要管理好状态:

没有对话时
E = 和当前对象交互

对话打开时
E = 跳过打字 / 下一句

这就是为什么要有:

is_active
is_typing
can_interact
can_move

这些状态变量。

否则同一个键会同时触发多个系统。

34. 为什么对话系统不要写在 NPC 里

你当然可以把逐字显示、UI 控制、输入都写在 NPC 里。

但很快会变成:

每个 NPC 都复制一份对话框逻辑
每个 NPC 都处理输入
每个 NPC 都控制 UI
修改对话框样式要改很多地方

这就是灾难的开端。

更好的职责分工:

NPC提供名字和台词
DialogueBox负责显示台词
Player负责触发交互
UI/Manager负责协调对话开始和结束

这套分工会让你后面做任务、商店、剧情都更稳。

35. 当前推荐最终结构

现在你的项目可以暂时这样:

Main Node
├── World Node2D
│   ├── Ground TileMapLayer
│   ├── Obstacles TileMapLayer
│   ├── Interactables Node2D
│   │   ├── NPC Area2D
│   │   ├── Chest Area2D
│   │   └── Door Area2D
│   ├── Player CharacterBody2D
│   │   ├── AnimatedSprite2D
│   │   ├── CollisionShape2D
│   │   ├── Camera2D
│   │   └── InteractArea Area2D
│   │       └── CollisionShape2D
│   └── Foreground TileMapLayer
└── UI CanvasLayer
    ├── InteractLabel Label
    └── DialogueBox Control
        └── Panel
            ├── NameLabel Label
            ├── TextLabel RichTextLabel
            └── ContinueLabel Label

脚本职责:

Player.gd
移动动画检测当前交互对象 E 触发 interact

NPC.gd
保存 npc_namedialogue_lines调用 dialogue_box.start_dialogue()

DialogueBox.gd
显示对话逐字显示下一句结束信号

UI.gd
连接 DialogueBox 信号锁定/解锁 Player 控制

36. 常见错误 1:对话框显示了,但按 E 没反应

检查:

InputMap 里有没有 interact
DialogueBox.gd 有没有 _unhandled_input
DialogueBox is_active 是否为 true
DialogueBox 是否真的挂了脚本
UI 是否挡住输入

可以临时加:

func _unhandled_input(event):
    if event.is_action_pressed("interact"):
        print("DialogueBox 收到 E")

如果没打印,说明输入没有到 DialogueBox。

37. 常见错误 2:按 E 一下跳过好几句

通常是输入被多个地方同时处理了。

检查:

Player 是否还在响应 interact
DialogueBox 是否调用 set_input_as_handled()
有没有多个 DialogueBox
有没有多个脚本都在 next_line()

可以在 Player 的 try_interact() 里加:

if not can_interact:
    return

对话开始时锁住:

player.lock_control()

38. 常见错误 3:对话结束后玩家不能动

检查:

dialogue_finished 有没有 emit
UI.gd 有没有连接 dialogue_finished
player 有没有 unlock_control 方法
player 引用有没有拖进去

临时打印:

func _on_dialogue_finished():
    print("对话结束,恢复玩家控制")

如果这句没打印,说明信号连接有问题。

39. 常见错误 4:逐字显示卡住

检查:

type_speed 是不是太大
text_label 是否是 RichTextLabel
TextLabel 路径是否写错
get_total_character_count() 是否返回 0
是否调用了 skip_typing 后旧循环没停

如果你用了我上面的 typing_id 写法,旧循环问题会少很多。

40. 常见错误 5:TextLabel 路径不对

如果结构是:

DialogueBox
└── Panel
    └── TextLabel

代码应该是:

@onready var text_label: RichTextLabel = $Panel/TextLabel

如果你写成:

@onready var text_label: RichTextLabel = $TextLabel

就找不到。

Godot 的 $路径 是按节点层级和名字找的,名字和路径都要对。

41. 常见错误 6:NPC 找不到 DialogueBox

如果 NPC 脚本里:

@export var dialogue_box: Control

你必须在 Inspector 里把 UI/DialogueBox 拖进去。

如果没拖,运行时就是 null。

所以代码里要防一手:

if dialogue_box == null:
    print("NPC 没有设置 dialogue_box")
    return

42. 这一部分最重要的记忆点

对话框 UI 应该放在 CanvasLayer
DialogueBox 负责显示不负责所有游戏逻辑
NPC 负责提供名字和台词
Player 负责触发交互
对话开始时锁定玩家控制
对话结束时恢复玩家控制
RichTextLabel 适合做对话正文
visible_characters 可以做逐字显示
 E正在打字就跳过打完了就下一句
 signal 通知 dialogue_started / dialogue_finished

43. 你现在的小练习

做一个最小 NPC 对话系统:

1. UI 下创建 DialogueBox
2. DialogueBox 里有 NameLabelTextLabelContinueLabel
3. NPC 加入 interactable
4. NPC 设置 npc_name dialogue_lines
5. Player 靠近 NPC 显示 E 对话
6. E 打开对话框
7. 对话逐字显示
8. 再按 E 跳过当前句或进入下一句
9. 对话时玩家不能移动
10. 对话结束后玩家恢复移动

完成这一部分后,你的 RPG 已经有了一个非常关键的基础系统。

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

分享文章

相关文章

更多文章 →
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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...