--- name: godot-dialogue-system description: "Expert patterns for branching dialogue systems including dialogue graphs (Resource-based), character portraits, player choices, conditional dialogue (flags/quests), typewriter effects, localization support, and voice acting integration. Use for narrative games, RPGs, or visual novels. Trigger keywords: DialogueLine, DialogueChoice, DialogueGraph, dialogue_manager, typewriter_effect, branching_dialogue, dialogue_flags, localization, voice_acting." --- # Dialogue System Expert guidance for building flexible, data-driven dialogue systems. ## Available Scripts ### [dialogue_resource.gd](scripts/dialogue_resource.gd) Data-driven conversation tree container using Resources for modular, branching narrative paths. ### [dialogue_node_data.gd](scripts/dialogue_node_data.gd) Serialized data structure for a single line of dialogue, including speaker metadata and portraits. ### [dialogue_option_data.gd](scripts/dialogue_option_data.gd) Interactive player choice definition with branching logic and scriptable availability conditions. ### [dialogue_manager_singleton.gd](scripts/dialogue_manager_singleton.gd) Centralized AutoLoad orchestrator for traversing dialogue trees and broadcasting state signals. ### [dialogue_ui_controller.gd](scripts/dialogue_ui_controller.gd) Reactive UI bridge that maps dialogue data to visual labels and dynamic choice buttons. ### [typebox_effect.gd](scripts/typebox_effect.gd) Polished "Character-by-character" text reveal effect using Godot's built-in Tweens. ### [dialogue_event_bridge.gd](scripts/dialogue_event_bridge.gd) Bridge node for triggering external game events (e.g. starting a quest) from conversation nodes. ### [branching_condition_validator.gd](scripts/branching_condition_validator.gd) Expert logic for evaluating player stats or global flags to toggle dialogue choices. ### [localized_dialogue_resource.gd](scripts/localized_dialogue_resource.gd) Advanced strategy for supporting multi-language conversation text via translation keys. ### [dialogue_portrait_manager.gd](scripts/dialogue_portrait_manager.gd) Visual controller for managing character expressions and entry animations during dialogue. ## NEVER Do in Dialogue Systems - **NEVER hardcode dialogue text directly in your GDScript files** — This makes translation impossible. Store text in Resources or external JSON/CSV files [12]. - **NEVER display choices that the player hasn't met the criteria for** — Hidden choices should stay hidden unless they are "grayed out" intentionally to show a missed path [13]. - **NEVER use loose strings for node transitions without validation** — Typos in `next_node_id` will crash the dialogue mid-convo. Use `assert()` or a central ID registry [14]. - **NEVER force a typewriter effect without a "Skip" option** — Forcing players to read at a fixed speed leads to frustration. Always allow clicking to finish the line [15]. - **NEVER store the current dialogue state inside a UI node** — If the UI is closed or the scene changes, the player loses their place. Use an AutoLoad `DialogueManager` [16]. - **NEVER use `get_node()` to find dialogue UI from the NPC script** — Use signals like `DialogueManager.start_dialogue(res)` to maintain a decoupled architecture. - **NEVER use complex regex for simple text tags** — Godot's `RichTextLabel` supports BBCode tags natively. Use `[b]`, `[i]`, and `[url]` for formatting. - **NEVER perform save/load operations inside a dialogue node** — Conversation nodes should be pure data. Delegate persistence to a dedicated `SaveSystem`. - **NEVER block the main thread for text reveal timing** — Never use `OS.delay_msec()`. Use `create_timer()` or `Tween` to maintain smooth 60fps performance. - **NEVER hardcode portrait paths** — Assign textures directly to the `DialogueNode` resource in the inspector or use a central `PortraitDatabase`. --- ## Available Scripts > **MANDATORY**: Read the appropriate script before implementing the corresponding pattern. ### [dialogue_engine.gd](scripts/dialogue_engine.gd) Graph-based dialogue with BBCode signal tags. Parses [trigger:event_id] tags from text, fires signals, and loads external JSON dialogue graphs. ### [dialogue_manager.gd](scripts/dialogue_manager.gd) Data-driven dialogue engine with branching, variable storage, and conditional choices. --- ## Dialogue Data ```gdscript # dialogue_line.gd class_name DialogueLine extends Resource @export var speaker: String @export_multiline var text: String @export var portrait: Texture2D @export var choices: Array[DialogueChoice] = [] @export var conditions: Array[String] = [] # Quest flags, etc. @export var next_line_id: String = "" ``` ```gdscript # dialogue_choice.gd class_name DialogueChoice extends Resource @export var choice_text: String @export var next_line_id: String @export var conditions: Array[String] = [] @export var effects: Array[String] = [] # Set flags, give items ``` ## Dialogue Manager ```gdscript # dialogue_manager.gd (AutoLoad) extends Node signal dialogue_started signal dialogue_ended signal line_displayed(line: DialogueLine) signal choice_selected(choice: DialogueChoice) var dialogues: Dictionary = {} var flags: Dictionary = {} func load_dialogue(path: String) -> void: var data := load(path) dialogues[path] = data func start_dialogue(dialogue_id: String, start_line: String = "start") -> void: dialogue_started.emit() display_line(dialogue_id, start_line) func display_line(dialogue_id: String, line_id: String) -> void: var line: DialogueLine = dialogues[dialogue_id].lines[line_id] # Check conditions if not check_conditions(line.conditions): # Skip to next if line.next_line_id: display_line(dialogue_id, line.next_line_id) else: end_dialogue() return line_displayed.emit(line) # Auto-advance or wait for player if line.choices.is_empty() and line.next_line_id: # Wait for player to click await get_tree().create_timer(0.1).timeout elif line.choices.is_empty(): end_dialogue() func select_choice(dialogue_id: String, choice: DialogueChoice) -> void: choice_selected.emit(choice) # Apply effects for effect in choice.effects: apply_effect(effect) # Continue to next line if choice.next_line_id: display_line(dialogue_id, choice.next_line_id) else: end_dialogue() func end_dialogue() -> void: dialogue_ended.emit() func check_conditions(conditions: Array[String]) -> bool: for condition in conditions: if not flags.get(condition, false): return false return true func apply_effect(effect: String) -> void: # Parse effect string, e.g., "set_flag:met_npc" var parts := effect.split(":") match parts[0]: "set_flag": flags[parts[1]] = true "give_item": # Integration with inventory pass ``` ## Dialogue UI ```gdscript # dialogue_ui.gd extends Control @onready var speaker_label := $Panel/Speaker @onready var text_label := $Panel/Text @onready var portrait := $Panel/Portrait @onready var choices_container := $Panel/Choices var current_dialogue: String var current_line: DialogueLine func _ready() -> void: DialogueManager.line_displayed.connect(_on_line_displayed) DialogueManager.dialogue_ended.connect(_on_dialogue_ended) visible = false func _on_line_displayed(line: DialogueLine) -> void: visible = true current_line = line speaker_label.text = line.speaker portrait.texture = line.portrait # Typewriter effect text_label.text = "" for char in line.text: text_label.text += char await get_tree().create_timer(0.03).timeout # Show choices if line.choices.is_empty(): # Wait for input to continue pass else: show_choices(line.choices) func show_choices(choices: Array[DialogueChoice]) -> void: # Clear existing for child in choices_container.get_children(): child.queue_free() # Add choice buttons for choice in choices: if not DialogueManager.check_conditions(choice.conditions): continue var button := Button.new() button.text = choice.choice_text button.pressed.connect(func(): _on_choice_selected(choice)) choices_container.add_child(button) func _on_choice_selected(choice: DialogueChoice) -> void: DialogueManager.select_choice(current_dialogue, choice) func _on_dialogue_ended() -> void: visible = false ``` ## NPC Interaction ```gdscript # npc.gd extends CharacterBody2D @export var dialogue_path: String = "res://dialogues/npc_1.tres" @export var start_line: String = "start" func interact() -> void: DialogueManager.start_dialogue(dialogue_path, start_line) ``` ## Dialogue Graph (Resource) ```gdscript # dialogue_graph.gd class_name DialogueGraph extends Resource @export var lines: Dictionary = {} # line_id → DialogueLine func _init() -> void: # Example structure lines["start"] = create_line("Hero", "Hello!") lines["response"] = create_line("NPC", "Greetings, traveler!") func create_line(speaker: String, text: String) -> DialogueLine: var line := DialogueLine.new() line.speaker = speaker line.text = text return line ``` ## Localization ```gdscript # Use Godot's built-in CSV import # dialogue_en.csv: # dialogue_id,speaker,text # npc_1_start,Hero,"Hello!" # npc_1_response,NPC,"Greetings!" func get_localized_line(line_id: String) -> String: return tr(line_id) ``` ## Advanced: Voice Acting ```gdscript @onready var voice_player := $AudioStreamPlayer func play_voice_line(line_id: String) -> void: var audio := load("res://voice/" + line_id + ".mp3") if audio: voice_player.stream = audio voice_player.play() ``` ## Best Practices 1. **Resource-Based** - Store dialogues as resources 2. **Flag System** - Track player choices 3. **Typewriter Effect** - Adds polish 4. **Skip Button** - Let players skip --- ## Elite Godot 4.x Patterns ### 1. Custom Dialogue Graph Editor Leverage `GraphEdit` and `GraphNode` to build visual authoring tools for complex branching narratives. ```gdscript @tool class_name DialogueGraphEditor extends GraphEdit func link_nodes(from: StringName, from_port: int, to: StringName, to_port: int) -> void: # Programmatic connection of visual dialogue blocks var err := connect_node(from, from_port, to, to_port) if err == OK: print_rich("[color=green]Branch linked.[/color]") ``` ### 2. Audio-Driven Dialogue (TTS & Lipsync) Use `DisplayServer` for asynchronous Text-to-Speech and register utterance callbacks to drive mouth animations or viseme changes in real-time. ```gdscript # dialogue_lipsync.gd func start_speaking(text: String) -> void: # 1. Register boundary callback var cb := Callable(self, "_on_tts_boundary") DisplayServer.tts_set_utterance_callback(DisplayServer.TTS_UTTERANCE_BOUNDARY, cb) # 2. Speak asynchronously var voices := DisplayServer.tts_get_voices_for_language("en") DisplayServer.tts_speak(text, voices[0]) func _on_tts_boundary(char_idx: int, _id: int) -> void: # Drive lipsync/animation based on current character index _update_mouth_shape(char_idx) ``` ### 3. Dialogue Analytics Logger Implement a custom `Logger` to intercept and record player choices without cluttering conversation logic with I/O calls. ```gdscript # dialogue_stat_logger.gd class_name DialogueStatLogger extends Logger func _log_message(msg: String, is_error: bool) -> void: if not is_error and msg.begins_with("[CHOICE]"): # Process and record choice analytics (e.g., save to file or send to server) _record_analytics(msg) # Register in an AutoLoad's _init() static func initialize() -> void: OS.add_logger(DialogueStatLogger.new()) ``` ## Reference - Master Skill: [godot-master](../godot-master/SKILL.md)