Xây dựng hệ thống hội thoại (dialog system) trong Godot 4

Nếu bạn đang làm game RPG, visual novel, hoặc bất kỳ game nào có NPC nói chuyện với người chơi, bạn sẽ cần một hệ thống dialog. Thoạt nghe thì đơn giản: hiện text lên màn hình, chờ player bấm nút, hiện text tiếp theo. Nhưng khi bắt tay vào làm, bạn sẽ nhanh chóng nhận ra có rất nhiều chi tiết cần xử lý: branching, biến, âm thanh, animation text chữ hiện từng chữ, tương thích đa ngôn ngữ, v.v.
Tôi đã làm hệ thống dialog cho 3 game indie trước đây, mỗi lần lại làm khác đi. Bài viết này chia sẻ cách tôi làm nó hiện tại cho game Godot 4 mới nhất của mình, cùng với code bạn có thể copy về dùng ngay.
Mục tiêu thiết kế
Trước khi code, xác định rõ cái mình cần:
- Dễ viết dialog: người viết kịch bản không phải là lập trình viên
- Branching: player chọn lựa dẫn đến kết quả khác nhau
- Biến: dialog phải đọc được state game ("player đã có chìa khóa chưa?")
- Type-on animation: text hiện từng chữ, có thể skip bằng cách bấm nút
- Đa ngôn ngữ: localization dễ dàng
- Không phụ thuộc scene: dialog system phải reusable giữa các scene
Chọn format dữ liệu
Có 3 lựa chọn phổ biến:
- Godot Resource (.tres): Type-safe, tích hợp với editor, nhưng viết kịch bản bằng Inspector rất khó chịu
- JSON: Dễ parse, dễ viết bằng text editor, nhưng không có type safety
- Custom format (.dialogue): Format riêng dễ đọc như Ink hoặc Yarn Spinner
Với game indie vừa và nhỏ, tôi chọn JSON. Đủ linh hoạt, không cần viết parser phức tạp.
Cấu trúc JSON
{
"start": {
"speaker": "Elder",
"text": "Chào mừng đến với làng, lữ khách. Ngươi đến từ đâu?",
"choices": [
{"text": "Từ phía Bắc", "next": "north_response"},
{"text": "Từ phía Đông", "next": "east_response"}
]
},
"north_response": {
"speaker": "Elder",
"text": "Phía Bắc... con đường không dễ dàng gì.",
"next": "final"
},
"final": {
"speaker": "Elder",
"text": "Cẩn thận trên đường đi.",
"end": true
}
}
Mỗi node có speaker, text, choices (optional), next (optional), và end (optional).
DialogManager autoload
Tạo autoload singleton quản lý dialog flow:
extends Node
signal dialog_started(dialog_name: String)
signal dialog_ended()
signal node_changed(node: Dictionary)
var current_dialog: Dictionary = {}
var current_node_id: String = ""
var variables: Dictionary = {}
func start_dialog(dialog_name: String) -> void:
var path = "res://dialogs/" + dialog_name + ".json"
var file = FileAccess.open(path, FileAccess.READ)
current_dialog = JSON.parse_string(file.get_as_text())
file.close()
current_node_id = "start"
dialog_started.emit(dialog_name)
_show_current_node()
func _show_current_node() -> void:
var node = current_dialog.get(current_node_id)
if node == null:
_end_dialog()
return
node_changed.emit(node)
func choose(choice_index: int) -> void:
var node = current_dialog[current_node_id]
if not node.has("choices"):
return
current_node_id = node["choices"][choice_index]["next"]
_show_current_node()
func advance() -> void:
var node = current_dialog[current_node_id]
if node.has("end") and node["end"]:
_end_dialog()
return
if node.has("next"):
current_node_id = node["next"]
_show_current_node()
func _end_dialog() -> void:
current_dialog = {}
current_node_id = ""
dialog_ended.emit()
Đăng ký autoload trong Project Settings > Autoload với tên DialogManager.
DialogBox UI với typing animation
extends CanvasLayer
@onready var speaker_label: Label = $Panel/VBox/SpeakerLabel
@onready var text_label: Label = $Panel/VBox/TextLabel
@onready var choices_container: VBoxContainer = $Panel/VBox/ChoicesContainer
var typing_speed: float = 0.03
var is_typing: bool = false
var full_text: String = ""
func _ready() -> void:
visible = false
DialogManager.dialog_started.connect(_on_dialog_started)
DialogManager.dialog_ended.connect(_on_dialog_ended)
DialogManager.node_changed.connect(_on_node_changed)
func _on_node_changed(node: Dictionary) -> void:
speaker_label.text = node.get("speaker", "")
full_text = node.get("text", "")
_clear_choices()
_type_text(full_text)
if node.has("choices"):
await _wait_for_type_complete()
_show_choices(node["choices"])
func _type_text(text: String) -> void:
is_typing = true
text_label.text = ""
for i in range(text.length()):
text_label.text += text[i]
await get_tree().create_timer(typing_speed).timeout
if not is_typing:
text_label.text = text
break
is_typing = false
func _show_choices(choices: Array) -> void:
for i in range(choices.size()):
var btn = Button.new()
btn.text = choices[i]["text"]
btn.pressed.connect(func(idx=i): DialogManager.choose(idx))
choices_container.add_child(btn)
func _input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_accept"):
if is_typing:
is_typing = false
else:
var node = DialogManager.current_dialog[DialogManager.current_node_id]
if not node.has("choices"):
DialogManager.advance()
Trigger dialog từ NPC
extends StaticBody2D
@export var dialog_name: String = "elder_dialog"
func interact() -> void:
DialogManager.start_dialog(dialog_name)
Player sẽ gọi npc.interact() khi bấm nút tương tác.
Localization
Godot có hệ thống translation built-in. Thay vì viết text trực tiếp trong JSON, dùng translation key rồi gọi tr(full_text) trong code.
Các lỗi tôi đã gặp phải
Lỗi 1: Dialog kẹt giữa chừng khi player bấm nút quá nhanh. Nguyên nhân: race condition giữa typing animation và input handler. Giải quyết bằng cách thêm flag is_typing.
Lỗi 2: Choices button không biến mất sau khi bấm. Vì lambda func(): DialogManager.choose(i) capture biến i sai. Dùng func(idx=i): DialogManager.choose(idx) để force capture.
Lỗi 3: Dialog text bị overflow ra ngoài box. Dùng autowrap_mode = AUTOWRAP_WORD_SMART trên text label.
Lỗi 4: Sau khi dialog kết thúc, player không di chuyển được. Player script vẫn nhận input nhưng dialog box đã hide. Giải quyết bằng emit signal dialog_ended mà player listen để unlock movement.
Kết luận
Hệ thống dialog không phức tạp như bạn nghĩ. Với khoảng 200 dòng code GDScript, bạn có đã có một DialogManager đầy đủ tính năng. Code này tôi dùng được cho nhiều game khác nhau mà không cần sửa nhiều.
Nếu game của bạn cần dialog system phức tạp hơn (ví dụ như dialog tree sâu hàng trăm node), hãy cân nhắc dùng Dialogic, một addon Godot chuyên về dialog với editor graphic đẹp.
Nhưng với hầu hết các game indie nhỏ, tự viết là nhanh hơn và tùy chỉnh được nhiều hơn. Bắt đầu với JSON đơn giản, sau đó thêm tính năng khi cần.
All rights reserved