0

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

Dialog System Godot

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:

  1. 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
  2. JSON: Dễ parse, dễ viết bằng text editor, nhưng không có type safety
  3. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí