第七部分:Dialogue 对话系统、对话框 UI、逐字显示、暂停移动
这一部分会把上一节的 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.gd 或 DialogueManager.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_name、dialogue_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 里有 NameLabel、TextLabel、ContinueLabel
3. NPC 加入 interactable 组
4. NPC 设置 npc_name 和 dialogue_lines
5. Player 靠近 NPC 显示“按 E 对话”
6. 按 E 打开对话框
7. 对话逐字显示
8. 再按 E 跳过当前句或进入下一句
9. 对话时玩家不能移动
10. 对话结束后玩家恢复移动
完成这一部分后,你的 RPG 已经有了一个非常关键的基础系统。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录