diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..13b5d000
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+ko_fi: rubonnek
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..5ac77cc7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2024 Wilson E. Alvarez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..d04ab32d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# Dialogue Engine
+
+
+
+A minimalistic dialogue engine for the Godot Game Engine.
+
+## ✨ Features
+
+- 🌲 Create dialogue trees with multiple choices and conditions
+- 📚 Simple to use -- just write the dialogue in GDScript
+- 🎨 Easy to customize -- bring your own GUI nodes
+- 🛠️ Automated dialogue graphing for easy debugging
+
+## ⚡️ Requirements
+
+- Godot 4.2.1+
+
+## 🚀 Getting Started
+
+- Clone/[download](https://github.com/Rubonnek/godot-dialogue-engine/archive/refs/heads/master.zip) the repository and check out the demos!
+
+## 📦 Installation
+
+[Download](https://github.com/Rubonnek/godot-dialogue-engine/archive/refs/heads/master.zip) or clone this repository and copy the contents of the
+`addons` folder to your own project's `addons` folder, and enable the `Dialogue Engine Viewer` plugin in the Project Settings.
diff --git a/addons/dialogue_manager/DialogueManager.cs b/addons/dialogue_manager/DialogueManager.cs
new file mode 100644
index 00000000..1cb84b69
--- /dev/null
+++ b/addons/dialogue_manager/DialogueManager.cs
@@ -0,0 +1,423 @@
+using Godot;
+using Godot.Collections;
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+
+#nullable enable
+
+namespace DialogueManagerRuntime
+{
+ public enum TranslationSource
+ {
+ None,
+ Guess,
+ CSV,
+ PO
+ }
+
+ public partial class DialogueManager : Node
+ {
+ public delegate void PassedTitleEventHandler(string title);
+ public delegate void GotDialogueEventHandler(DialogueLine dialogueLine);
+ public delegate void MutatedEventHandler(Dictionary mutation);
+ public delegate void DialogueEndedEventHandler(Resource dialogueResource);
+
+ public static PassedTitleEventHandler? PassedTitle;
+ public static GotDialogueEventHandler? GotDialogue;
+ public static MutatedEventHandler? Mutated;
+ public static DialogueEndedEventHandler? DialogueEnded;
+
+ [Signal] public delegate void ResolvedEventHandler(Variant value);
+
+ private static GodotObject? instance;
+ public static GodotObject Instance
+ {
+ get
+ {
+ if (instance == null)
+ {
+ instance = Engine.GetSingleton("DialogueManager");
+ }
+ return instance;
+ }
+ }
+
+
+ public static Godot.Collections.Array GameStates
+ {
+ get => (Godot.Collections.Array)Instance.Get("game_states");
+ set => Instance.Set("game_states", value);
+ }
+
+
+ public static bool IncludeSingletons
+ {
+ get => (bool)Instance.Get("include_singletons");
+ set => Instance.Set("include_singletons", value);
+ }
+
+
+ public static bool IncludeClasses
+ {
+ get => (bool)Instance.Get("include_classes");
+ set => Instance.Set("include_classes", value);
+ }
+
+
+ public static TranslationSource TranslationSource
+ {
+ get => (TranslationSource)(int)Instance.Get("translation_source");
+ set => Instance.Set("translation_source", (int)value);
+ }
+
+
+ public static Func GetCurrentScene
+ {
+ set => Instance.Set("get_current_scene", Callable.From(value));
+ }
+
+
+ public void Prepare()
+ {
+ Instance.Connect("passed_title", Callable.From((string title) => PassedTitle?.Invoke(title)));
+ Instance.Connect("got_dialogue", Callable.From((RefCounted line) => GotDialogue?.Invoke(new DialogueLine(line))));
+ Instance.Connect("mutated", Callable.From((Dictionary mutation) => Mutated?.Invoke(mutation)));
+ Instance.Connect("dialogue_ended", Callable.From((Resource dialogueResource) => DialogueEnded?.Invoke(dialogueResource)));
+ }
+
+
+ public static async Task GetSingleton()
+ {
+ if (instance != null) return instance;
+
+ var tree = Engine.GetMainLoop();
+ int x = 0;
+
+ // Try and find the singleton for a few seconds
+ while (!Engine.HasSingleton("DialogueManager") && x < 300)
+ {
+ await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame);
+ x++;
+ }
+
+ // If it times out something is wrong
+ if (x >= 300)
+ {
+ throw new Exception("The DialogueManager singleton is missing.");
+ }
+
+ instance = Engine.GetSingleton("DialogueManager");
+ return instance;
+ }
+
+ public static async Task GetNextDialogueLine(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ Instance.Call("_bridge_get_next_dialogue_line", dialogueResource, key, extraGameStates ?? new Array());
+ var result = await Instance.ToSignal(Instance, "bridge_get_next_dialogue_line_completed");
+
+ if ((RefCounted)result[0] == null) return null;
+
+ return new DialogueLine((RefCounted)result[0]);
+ }
+
+
+ public static CanvasLayer ShowExampleDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (CanvasLayer)Instance.Call("show_example_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+
+ public static Node ShowDialogueBalloonScene(string balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+ public static Node ShowDialogueBalloonScene(PackedScene balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+ public static Node ShowDialogueBalloonScene(Node balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+
+ public static Node ShowDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (Node)Instance.Call("show_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+
+ public static async void Mutate(Dictionary mutation, Array? extraGameStates = null, bool isInlineMutation = false)
+ {
+ Instance.Call("_bridge_mutate", mutation, extraGameStates ?? new Array(), isInlineMutation);
+ await Instance.ToSignal(Instance, "bridge_mutated");
+ }
+
+
+ public bool ThingHasMethod(GodotObject thing, string method)
+ {
+ MethodInfo? info = thing.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ return info != null;
+ }
+
+
+ public async void ResolveThingMethod(GodotObject thing, string method, Array args)
+ {
+ MethodInfo? info = thing.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+
+ if (info == null) return;
+
+#nullable disable
+ // Convert the method args to something reflection can handle
+ ParameterInfo[] argTypes = info.GetParameters();
+ object[] _args = new object[argTypes.Length];
+ for (int i = 0; i < argTypes.Length; i++)
+ {
+ // check if args is assignable from derived type
+ if (i < args.Count && args[i].Obj != null)
+ {
+ if (argTypes[i].ParameterType.IsAssignableFrom(args[i].Obj.GetType()))
+ {
+ _args[i] = args[i].Obj;
+ }
+ // fallback to assigning primitive types
+ else
+ {
+ _args[i] = Convert.ChangeType(args[i].Obj, argTypes[i].ParameterType);
+ }
+ }
+ else if (argTypes[i].DefaultValue != null)
+ {
+ _args[i] = argTypes[i].DefaultValue;
+ }
+ }
+
+ // Add a single frame wait in case the method returns before signals can listen
+ await ToSignal(Engine.GetMainLoop(), SceneTree.SignalName.ProcessFrame);
+
+ // invoke method and handle the result based on return type
+ object result = info.Invoke(thing, _args);
+
+ if (result is Task taskResult)
+ {
+ // await Tasks and handle result if it is a Task
+ await taskResult;
+ var taskType = taskResult.GetType();
+ if (taskType.IsGenericType && taskType.GetGenericTypeDefinition() == typeof(Task<>))
+ {
+ var resultProperty = taskType.GetProperty("Result");
+ var taskResultValue = resultProperty.GetValue(taskResult);
+ EmitSignal(SignalName.Resolved, (Variant)taskResultValue);
+ }
+ else
+ {
+ EmitSignal(SignalName.Resolved, null);
+ }
+ }
+ else
+ {
+ EmitSignal(SignalName.Resolved, (Variant)result);
+ }
+ }
+#nullable enable
+ }
+
+
+ public partial class DialogueLine : RefCounted
+ {
+ private string id = "";
+ public string Id
+ {
+ get => id;
+ set => id = value;
+ }
+
+ private string type = "dialogue";
+ public string Type
+ {
+ get => type;
+ set => type = value;
+ }
+
+ private string next_id = "";
+ public string NextId
+ {
+ get => next_id;
+ set => next_id = value;
+ }
+
+ private string character = "";
+ public string Character
+ {
+ get => character;
+ set => character = value;
+ }
+
+ private string text = "";
+ public string Text
+ {
+ get => text;
+ set => text = value;
+ }
+
+ private string translation_key = "";
+ public string TranslationKey
+ {
+ get => translation_key;
+ set => translation_key = value;
+ }
+
+ private Array responses = new Array();
+ public Array Responses
+ {
+ get => responses;
+ }
+
+ private string? time = null;
+ public string? Time
+ {
+ get => time;
+ }
+
+ private Dictionary pauses = new Dictionary();
+ public Dictionary Pauses
+ {
+ get => pauses;
+ }
+
+ private Dictionary speeds = new Dictionary();
+ public Dictionary Speeds
+ {
+ get => speeds;
+ }
+
+ private Array inline_mutations = new Array();
+ public Array InlineMutations
+ {
+ get => inline_mutations;
+ }
+
+ private Array extra_game_states = new Array();
+
+ private Array tags = new Array();
+ public Array Tags
+ {
+ get => tags;
+ }
+
+ public DialogueLine(RefCounted data)
+ {
+ type = (string)data.Get("type");
+ next_id = (string)data.Get("next_id");
+ character = (string)data.Get("character");
+ text = (string)data.Get("text");
+ translation_key = (string)data.Get("translation_key");
+ pauses = (Dictionary)data.Get("pauses");
+ speeds = (Dictionary)data.Get("speeds");
+ inline_mutations = (Array)data.Get("inline_mutations");
+ time = (string)data.Get("time");
+ tags = (Array)data.Get("tags");
+
+ foreach (var response in (Array)data.Get("responses"))
+ {
+ responses.Add(new DialogueResponse(response));
+ }
+ }
+
+
+ public string GetTagValue(string tagName)
+ {
+ string wrapped = $"{tagName}=";
+ foreach (var tag in tags)
+ {
+ if (tag.StartsWith(wrapped))
+ {
+ return tag.Substring(wrapped.Length);
+ }
+ }
+ return "";
+ }
+
+ public override string ToString()
+ {
+ switch (type)
+ {
+ case "dialogue":
+ return $"";
+ case "mutation":
+ return "";
+ default:
+ return "";
+ }
+ }
+ }
+
+
+ public partial class DialogueResponse : RefCounted
+ {
+ private string next_id = "";
+ public string NextId
+ {
+ get => next_id;
+ set => next_id = value;
+ }
+
+ private bool is_allowed = true;
+ public bool IsAllowed
+ {
+ get => is_allowed;
+ set => is_allowed = value;
+ }
+
+ private string text = "";
+ public string Text
+ {
+ get => text;
+ set => text = value;
+ }
+
+ private string translation_key = "";
+ public string TranslationKey
+ {
+ get => translation_key;
+ set => translation_key = value;
+ }
+
+ private Array tags = new Array();
+ public Array Tags
+ {
+ get => tags;
+ }
+
+ public DialogueResponse(RefCounted data)
+ {
+ next_id = (string)data.Get("next_id");
+ is_allowed = (bool)data.Get("is_allowed");
+ text = (string)data.Get("text");
+ translation_key = (string)data.Get("translation_key");
+ tags = (Array)data.Get("tags");
+ }
+
+ public string GetTagValue(string tagName)
+ {
+ string wrapped = $"{tagName}=";
+ foreach (var tag in tags)
+ {
+ if (tag.StartsWith(wrapped))
+ {
+ return tag.Substring(wrapped.Length);
+ }
+ }
+ return "";
+ }
+
+ public override string ToString()
+ {
+ return $"
+
+
+
diff --git a/addons/dialogue_manager/assets/icon.svg.import b/addons/dialogue_manager/assets/icon.svg.import
new file mode 100644
index 00000000..3b6fd5ec
--- /dev/null
+++ b/addons/dialogue_manager/assets/icon.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3lr2uas6ax8v"
+path="res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/assets/icon.svg"
+dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=true
diff --git a/addons/dialogue_manager/assets/responses_menu.svg b/addons/dialogue_manager/assets/responses_menu.svg
new file mode 100644
index 00000000..4e4089d6
--- /dev/null
+++ b/addons/dialogue_manager/assets/responses_menu.svg
@@ -0,0 +1,52 @@
+
+
+
+
diff --git a/addons/dialogue_manager/assets/responses_menu.svg.import b/addons/dialogue_manager/assets/responses_menu.svg.import
new file mode 100644
index 00000000..83355fc7
--- /dev/null
+++ b/addons/dialogue_manager/assets/responses_menu.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://drjfciwitjm83"
+path="res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/assets/responses_menu.svg"
+dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=true
diff --git a/addons/dialogue_manager/assets/update.svg b/addons/dialogue_manager/assets/update.svg
new file mode 100644
index 00000000..a5b80ee7
--- /dev/null
+++ b/addons/dialogue_manager/assets/update.svg
@@ -0,0 +1,71 @@
+
+
+
+
diff --git a/addons/dialogue_manager/assets/update.svg.import b/addons/dialogue_manager/assets/update.svg.import
new file mode 100644
index 00000000..2d8171a3
--- /dev/null
+++ b/addons/dialogue_manager/assets/update.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3baj6rygkb3f"
+path="res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/assets/update.svg"
+dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/dialogue_manager/components/code_edit.gd b/addons/dialogue_manager/components/code_edit.gd
new file mode 100644
index 00000000..05ef00ff
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit.gd
@@ -0,0 +1,444 @@
+@tool
+extends CodeEdit
+
+
+signal active_title_change(title: String)
+signal error_clicked(line_number: int)
+signal external_file_requested(path: String, title: String)
+
+
+const DialogueManagerParser = preload("./parser.gd")
+const DialogueSyntaxHighlighter = preload("./code_edit_syntax_highlighter.gd")
+
+
+# A link back to the owner `MainView`
+var main_view
+
+# Theme overrides for syntax highlighting, etc
+var theme_overrides: Dictionary:
+ set(value):
+ theme_overrides = value
+
+ syntax_highlighter = DialogueSyntaxHighlighter.new()
+
+ # General UI
+ add_theme_color_override("font_color", theme_overrides.text_color)
+ add_theme_color_override("background_color", theme_overrides.background_color)
+ add_theme_color_override("current_line_color", theme_overrides.current_line_color)
+ add_theme_font_override("font", get_theme_font("source", "EditorFonts"))
+ add_theme_font_size_override("font_size", theme_overrides.font_size * theme_overrides.scale)
+ font_size = round(theme_overrides.font_size)
+ get:
+ return theme_overrides
+
+# Any parse errors
+var errors: Array:
+ set(next_errors):
+ errors = next_errors
+ for i in range(0, get_line_count()):
+ var is_error: bool = false
+ for error in errors:
+ if error.line_number == i:
+ is_error = true
+ mark_line_as_error(i, is_error)
+ _on_code_edit_caret_changed()
+ get:
+ return errors
+
+# The last selection (if there was one) so we can remember it for refocusing
+var last_selected_text: String
+
+var font_size: int:
+ set(value):
+ font_size = value
+ add_theme_font_size_override("font_size", font_size * theme_overrides.scale)
+ get:
+ return font_size
+
+var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
+
+
+func _ready() -> void:
+ # Add error gutter
+ add_gutter(0)
+ set_gutter_type(0, TextEdit.GUTTER_TYPE_ICON)
+
+ # Add comment delimiter
+ if not has_comment_delimiter("#"):
+ add_comment_delimiter("#", "", true)
+
+ syntax_highlighter = DialogueSyntaxHighlighter.new()
+
+
+func _gui_input(event: InputEvent) -> void:
+ # Handle shortcuts that come from the editor
+ if event is InputEventKey and event.is_pressed():
+ var shortcut: String = Engine.get_meta("DialogueManagerPlugin").get_editor_shortcut(event)
+ match shortcut:
+ "toggle_comment":
+ toggle_comment()
+ get_viewport().set_input_as_handled()
+ "delete_line":
+ delete_current_line()
+ get_viewport().set_input_as_handled()
+ "move_up":
+ move_line(-1)
+ get_viewport().set_input_as_handled()
+ "move_down":
+ move_line(1)
+ get_viewport().set_input_as_handled()
+ "text_size_increase":
+ self.font_size += 1
+ get_viewport().set_input_as_handled()
+ "text_size_decrease":
+ self.font_size -= 1
+ get_viewport().set_input_as_handled()
+ "text_size_reset":
+ self.font_size = theme_overrides.font_size
+ get_viewport().set_input_as_handled()
+
+ elif event is InputEventMouse:
+ match event.as_text():
+ "Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up":
+ self.font_size += 1
+ get_viewport().set_input_as_handled()
+ "Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down":
+ self.font_size -= 1
+ get_viewport().set_input_as_handled()
+
+
+func _can_drop_data(at_position: Vector2, data) -> bool:
+ if typeof(data) != TYPE_DICTIONARY: return false
+ if data.type != "files": return false
+
+ var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
+ return files.size() > 0
+
+
+func _drop_data(at_position: Vector2, data) -> void:
+ var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")
+
+ var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
+ for file in files:
+ # Don't import the file into itself
+ if file == main_view.current_file_path: continue
+
+ var path = file.replace("res://", "").replace(".dialogue", "")
+ # Find the first non-import line in the file to add our import
+ var lines = text.split("\n")
+ for i in range(0, lines.size()):
+ if not lines[i].begins_with("import "):
+ insert_line_at(i, "import \"%s\" as %s\n" % [file, replace_regex.sub(path, "_", true)])
+ set_caret_line(i)
+ break
+
+
+func _request_code_completion(force: bool) -> void:
+ var cursor: Vector2 = get_cursor()
+ var current_line: String = get_line(cursor.y)
+
+ if ("=> " in current_line or "=>< " in current_line) and (cursor.x > current_line.find("=>")):
+ var prompt: String = current_line.split("=>")[1]
+ if prompt.begins_with("< "):
+ prompt = prompt.substr(2)
+ else:
+ prompt = prompt.substr(1)
+
+ if "=> " in current_line:
+ if matches_prompt(prompt, "end"):
+ add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
+ if matches_prompt(prompt, "end!"):
+ add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
+
+ # Get all titles, including those in imports
+ var parser: DialogueManagerParser = DialogueManagerParser.new()
+ parser.prepare(text, main_view.current_file_path, false)
+ for title in parser.titles:
+ if "/" in title:
+ var bits = title.split("/")
+ if matches_prompt(prompt, bits[0]) or matches_prompt(prompt, bits[1]):
+ add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
+ elif matches_prompt(prompt, title):
+ add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))
+ update_code_completion_options(true)
+ parser.free()
+ return
+
+ var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "")
+ if name_so_far != "" and name_so_far[0].to_upper() == name_so_far[0]:
+ # Only show names starting with that character
+ var names: PackedStringArray = get_character_names(name_so_far)
+ if names.size() > 0:
+ for name in names:
+ add_code_completion_option(CodeEdit.KIND_CLASS, name + ": ", name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))
+ update_code_completion_options(true)
+ else:
+ cancel_code_completion()
+
+
+func _filter_code_completion_candidates(candidates: Array) -> Array:
+ # Not sure why but if this method isn't overridden then all completions are wrapped in quotes.
+ return candidates
+
+
+func _confirm_code_completion(replace: bool) -> void:
+ var completion = get_code_completion_option(get_code_completion_selected_index())
+ begin_complex_operation()
+ # Delete any part of the text that we've already typed
+ for i in range(0, completion.display_text.length() - completion.insert_text.length()):
+ backspace()
+ # Insert the whole match
+ insert_text_at_caret(completion.display_text)
+ end_complex_operation()
+
+ # Close the autocomplete menu on the next tick
+ call_deferred("cancel_code_completion")
+
+
+### Helpers
+
+
+# Get the current caret as a Vector2
+func get_cursor() -> Vector2:
+ return Vector2(get_caret_column(), get_caret_line())
+
+
+# Set the caret from a Vector2
+func set_cursor(from_cursor: Vector2) -> void:
+ set_caret_line(from_cursor.y)
+ set_caret_column(from_cursor.x)
+
+
+# Check if a prompt is the start of a string without actually being that string
+func matches_prompt(prompt: String, matcher: String) -> bool:
+ return prompt.length() < matcher.length() and matcher.to_lower().begins_with(prompt.to_lower())
+
+
+## Get a list of titles from the current text
+func get_titles() -> PackedStringArray:
+ var titles = PackedStringArray([])
+ var lines = text.split("\n")
+ for line in lines:
+ if line.begins_with("~ "):
+ titles.append(line.substr(2).strip_edges())
+ return titles
+
+
+## Work out what the next title above the current line is
+func check_active_title() -> void:
+ var line_number = get_caret_line()
+ var lines = text.split("\n")
+ # Look at each line above this one to find the next title line
+ for i in range(line_number, -1, -1):
+ if lines[i].begins_with("~ "):
+ active_title_change.emit(lines[i].replace("~ ", ""))
+ return
+
+ active_title_change.emit("")
+
+
+# Move the caret line to match a given title
+func go_to_title(title: String) -> void:
+ var lines = text.split("\n")
+ for i in range(0, lines.size()):
+ if lines[i].strip_edges() == "~ " + title:
+ set_caret_line(i)
+ center_viewport_to_caret()
+
+
+func get_character_names(beginning_with: String) -> PackedStringArray:
+ var names: PackedStringArray = []
+ var lines = text.split("\n")
+ for line in lines:
+ if ": " in line:
+ var name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")
+ if not name in names and matches_prompt(beginning_with, name):
+ names.append(name)
+ return names
+
+
+# Mark a line as an error or not
+func mark_line_as_error(line_number: int, is_error: bool) -> void:
+ if is_error:
+ set_line_background_color(line_number, theme_overrides.error_line_color)
+ set_line_gutter_icon(line_number, 0, get_theme_icon("StatusError", "EditorIcons"))
+ else:
+ set_line_background_color(line_number, theme_overrides.background_color)
+ set_line_gutter_icon(line_number, 0, null)
+
+
+# Insert or wrap some bbcode at the caret/selection
+func insert_bbcode(open_tag: String, close_tag: String = "") -> void:
+ if close_tag == "":
+ insert_text_at_caret(open_tag)
+ grab_focus()
+ else:
+ var selected_text = get_selected_text()
+ insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag])
+ grab_focus()
+ set_caret_column(get_caret_column() - close_tag.length())
+
+# Insert text at current caret position
+# Move Caret down 1 line if not => END
+func insert_text_at_cursor(text: String) -> void:
+ if text != "=> END":
+ insert_text_at_caret(text+"\n")
+ set_caret_line(get_caret_line()+1)
+ else:
+ insert_text_at_caret(text)
+ grab_focus()
+
+
+# Toggle the selected lines as comments
+func toggle_comment() -> void:
+ begin_complex_operation()
+
+ var comment_delimiter: String = delimiter_comments[0]
+ var is_first_line: bool = true
+ var will_comment: bool = true
+ var selections: Array = []
+ var line_offsets: Dictionary = {}
+
+ for caret_index in range(0, get_caret_count()):
+ var from_line: int = get_caret_line(caret_index)
+ var from_column: int = get_caret_column(caret_index)
+ var to_line: int = get_caret_line(caret_index)
+ var to_column: int = get_caret_column(caret_index)
+
+ if has_selection(caret_index):
+ from_line = get_selection_from_line(caret_index)
+ to_line = get_selection_to_line(caret_index)
+ from_column = get_selection_from_column(caret_index)
+ to_column = get_selection_to_column(caret_index)
+
+ selections.append({
+ from_line = from_line,
+ from_column = from_column,
+ to_line = to_line,
+ to_column = to_column
+ })
+
+ for line_number in range(from_line, to_line + 1):
+ if line_offsets.has(line_number): continue
+
+ var line_text: String = get_line(line_number)
+
+ # The first line determines if we are commenting or uncommentingg
+ if is_first_line:
+ is_first_line = false
+ will_comment = not line_text.strip_edges().begins_with(comment_delimiter)
+
+ # Only comment/uncomment if the current line needs to
+ if will_comment:
+ set_line(line_number, comment_delimiter + line_text)
+ line_offsets[line_number] = 1
+ elif line_text.begins_with(comment_delimiter):
+ set_line(line_number, line_text.substr(comment_delimiter.length()))
+ line_offsets[line_number] = -1
+ else:
+ line_offsets[line_number] = 0
+
+ for caret_index in range(0, get_caret_count()):
+ var selection: Dictionary = selections[caret_index]
+ select(
+ selection.from_line,
+ selection.from_column + line_offsets[selection.from_line],
+ selection.to_line,
+ selection.to_column + line_offsets[selection.to_line],
+ caret_index
+ )
+ set_caret_column(selection.from_column + line_offsets[selection.from_line], false, caret_index)
+
+ end_complex_operation()
+
+ text_set.emit()
+ text_changed.emit()
+
+
+# Remove the current line
+func delete_current_line() -> void:
+ var cursor = get_cursor()
+ if get_line_count() == 1:
+ select_all()
+ elif cursor.y == 0:
+ select(0, 0, 1, 0)
+ else:
+ select(cursor.y - 1, get_line_width(cursor.y - 1), cursor.y, get_line_width(cursor.y))
+ delete_selection()
+ text_changed.emit()
+
+
+# Move the selected lines up or down
+func move_line(offset: int) -> void:
+ offset = clamp(offset, -1, 1)
+
+ var cursor = get_cursor()
+ var reselect: bool = false
+ var from: int = cursor.y
+ var to: int = cursor.y
+ if has_selection():
+ reselect = true
+ from = get_selection_from_line()
+ to = get_selection_to_line()
+
+ var lines := text.split("\n")
+
+ # Prevent the lines from being out of bounds
+ if from + offset < 0 or to + offset >= lines.size(): return
+
+ var target_from_index = from - 1 if offset == -1 else to + 1
+ var target_to_index = to if offset == -1 else from
+ var line_to_move = lines[target_from_index]
+ lines.remove_at(target_from_index)
+ lines.insert(target_to_index, line_to_move)
+
+ text = "\n".join(lines)
+
+ cursor.y += offset
+ from += offset
+ to += offset
+ if reselect:
+ select(from, 0, to, get_line_width(to))
+ set_cursor(cursor)
+ text_changed.emit()
+
+
+### Signals
+
+
+func _on_code_edit_symbol_validate(symbol: String) -> void:
+ if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
+ set_symbol_lookup_word_as_valid(true)
+ return
+
+ for title in get_titles():
+ if symbol == title:
+ set_symbol_lookup_word_as_valid(true)
+ return
+ set_symbol_lookup_word_as_valid(false)
+
+
+func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void:
+ if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
+ external_file_requested.emit(symbol, "")
+ else:
+ go_to_title(symbol)
+
+
+func _on_code_edit_text_changed() -> void:
+ request_code_completion(true)
+
+
+func _on_code_edit_text_set() -> void:
+ queue_redraw()
+
+
+func _on_code_edit_caret_changed() -> void:
+ check_active_title()
+ last_selected_text = get_selected_text()
+
+
+func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void:
+ var line_errors = errors.filter(func(error): return error.line_number == line)
+ if line_errors.size() > 0:
+ error_clicked.emit(line)
diff --git a/addons/dialogue_manager/components/code_edit.tscn b/addons/dialogue_manager/components/code_edit.tscn
new file mode 100644
index 00000000..a974ea34
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit.tscn
@@ -0,0 +1,56 @@
+[gd_scene load_steps=4 format=3 uid="uid://civ6shmka5e8u"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="1_58cfo"]
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit.gd" id="1_g324i"]
+
+[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_cobxx"]
+script = ExtResource("1_58cfo")
+
+[node name="CodeEdit" type="CodeEdit"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+text = "~ title_thing
+
+if this = \"that\" or 'this'
+Nathan: Something
+- Then [if test.thing() == 2.0] => somewhere
+- Other => END!
+
+~ somewhere
+
+set has_something = true
+=> END"
+highlight_all_occurrences = true
+highlight_current_line = true
+draw_tabs = true
+syntax_highlighter = SubResource("SyntaxHighlighter_cobxx")
+scroll_past_end_of_file = true
+minimap_draw = true
+symbol_lookup_on_click = true
+line_folding = true
+gutters_draw_line_numbers = true
+gutters_draw_fold_gutter = true
+delimiter_strings = Array[String](["\" \""])
+delimiter_comments = Array[String](["#"])
+code_completion_enabled = true
+code_completion_prefixes = Array[String]([">", "<"])
+indent_automatic = true
+auto_brace_completion_enabled = true
+auto_brace_completion_highlight_matching = true
+auto_brace_completion_pairs = {
+"\"": "\"",
+"(": ")",
+"[": "]",
+"{": "}"
+}
+script = ExtResource("1_g324i")
+
+[connection signal="caret_changed" from="." to="." method="_on_code_edit_caret_changed"]
+[connection signal="gutter_clicked" from="." to="." method="_on_code_edit_gutter_clicked"]
+[connection signal="symbol_lookup" from="." to="." method="_on_code_edit_symbol_lookup"]
+[connection signal="symbol_validate" from="." to="." method="_on_code_edit_symbol_validate"]
+[connection signal="text_changed" from="." to="." method="_on_code_edit_text_changed"]
+[connection signal="text_set" from="." to="." method="_on_code_edit_text_set"]
diff --git a/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd
new file mode 100644
index 00000000..ce5535f5
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd
@@ -0,0 +1,385 @@
+@tool
+extends SyntaxHighlighter
+
+
+const DialogueManagerParser = preload("./parser.gd")
+
+
+enum ExpressionType {DO, SET, IF}
+
+
+var dialogue_manager_parser: DialogueManagerParser = DialogueManagerParser.new()
+
+var regex_titles: RegEx = RegEx.create_from_string("^\\s*(?~\\s+[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+)")
+var regex_comments: RegEx = RegEx.create_from_string("(?:(?>\"(?:\\\\\"|[^\"\\n])*\")[^\"\\n]*?\\s*(?#[^\\n]*)$|^[^\"#\\n]*?\\s*(?#[^\\n]*))")
+var regex_mutation: RegEx = RegEx.create_from_string("^\\s*(do|do!|set) (?.*)")
+var regex_condition: RegEx = RegEx.create_from_string("^\\s*(if|elif|while|else if) (?.*)")
+var regex_wcondition: RegEx = RegEx.create_from_string("\\[if (?((?:[^\\[\\]]*)|(?:\\[(?1)\\]))*?)\\]")
+var regex_wendif: RegEx = RegEx.create_from_string("\\[(\\/if|else)\\]")
+var regex_rgroup: RegEx = RegEx.create_from_string("\\[\\[(?.*?)\\]\\]")
+var regex_endconditions: RegEx = RegEx.create_from_string("^\\s*(endif|else):?\\s*$")
+var regex_tags: RegEx = RegEx.create_from_string("\\[(?(?!(?:ID:.*)|if)[a-zA-Z_][a-zA-Z0-9_]*!?)(?:[= ](?[^\\[\\]]+))?\\](?:(?(?!\\[\\/\\k\\]).*?)?(?\\[\\/\\k\\]))?")
+var regex_dialogue: RegEx = RegEx.create_from_string("^\\s*(?:(?\\%[\\d.]* )|(?- ))?(?:(?[^#:]*): )?(?.*)$")
+var regex_goto: RegEx = RegEx.create_from_string("=> (?:(?[^\\/]+)\\/)?(?[^\\/]*)")
+var regex_string: RegEx = RegEx.create_from_string("^&?(?[\"'])(?(?:\\\\{2})*|(?:.*?[^\\\\](?:\\\\{2})*))\\1$")
+var regex_escape: RegEx = RegEx.create_from_string("\\\\.")
+var regex_number: RegEx = RegEx.create_from_string("^-?(?:(?:0x(?:[0-9A-Fa-f]{2})+)|(?:0b[01]+)|(?:\\d+(?:(?:[\\.]\\d*)?(?:e\\d+)?)|(?:_\\d+)+)?)$")
+var regex_array: RegEx = RegEx.create_from_string("\\[((?>[^\\[\\]]+|(?R))*)\\]")
+var regex_dict: RegEx = RegEx.create_from_string("^\\{((?>[^\\{\\}]+|(?R))*)\\}$")
+var regex_kvdict: RegEx = RegEx.create_from_string("^\\s*(?.*?)\\s*(?:|=)\\s*(?[^\\/]+)$")
+var regex_commas: RegEx = RegEx.create_from_string("([^,]+)(?:\\s*,\\s*)?")
+var regex_assignment: RegEx = RegEx.create_from_string("^\\s*(?[a-zA-Z_][a-zA-Z_0-9]*)(?:(?(?:\\.[a-zA-Z_][a-zA-Z_0-9]*)+)|(?:\\[(?[^\\]]+)\\]))?\\s*(?(?:\\/|\\*|-|\\+)?=)\\s*(?.*)$")
+var regex_varname: RegEx = RegEx.create_from_string("^\\s*(?!true|false|and|or|&&|\\|\\|not|in|null)(?[a-zA-Z_][a-zA-Z_0-9]*)(?:(?(?:\\.[a-zA-Z_][a-zA-Z_0-9]*)+)|(?:\\[(?[^\\]]+)\\]))?\\s*$")
+var regex_keyword: RegEx = RegEx.create_from_string("^\\s*(true|false|null)\\s*$")
+var regex_function: RegEx = RegEx.create_from_string("^\\s*([a-zA-Z_][a-zA-Z_0-9]*\\s*)\\(")
+var regex_comparison: RegEx = RegEx.create_from_string("^(?.*?)\\s*(?==|>=|<=|<|>|!=)\\s*(?.*)$")
+var regex_blogical: RegEx = RegEx.create_from_string("^(?.*?)\\s+(?and|or|in|&&|\\|\\|)\\s+(?.*)$")
+var regex_ulogical: RegEx = RegEx.create_from_string("^\\s*(?not)\\s+(?.*)$")
+var regex_paren: RegEx = RegEx.create_from_string("\\((?((?:[^\\(\\)]*)|(?:\\((?1)\\)))*?)\\)")
+
+var cache: Dictionary = {}
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ dialogue_manager_parser.free()
+
+
+func _clear_highlighting_cache() -> void:
+ cache = {}
+
+
+## Returns the syntax coloring for a dialogue file line
+func _get_line_syntax_highlighting(line: int) -> Dictionary:
+ var colors: Dictionary = {}
+ var text_edit: TextEdit = get_text_edit()
+ var text: String = text_edit.get_line(line)
+
+ # Prevent an error from popping up while developing
+ if not is_instance_valid(text_edit) or text_edit.theme_overrides.is_empty():
+ return colors
+
+ # Disable this, as well as the line at the bottom of this function to remove the cache.
+ if text in cache:
+ return cache[text]
+
+ # Comments have to be removed to make the remaining processing easier.
+ # Count both end-of-line and single-line comments
+ # Comments are not allowed within dialogue lines or response lines, so we ask the parser what it thinks the current line is
+ if not (dialogue_manager_parser.is_dialogue_line(text) or dialogue_manager_parser.is_response_line(text)) or dialogue_manager_parser.is_line_empty(text) or dialogue_manager_parser.is_import_line(text):
+ var comment_matches: Array[RegExMatch] = regex_comments.search_all(text)
+ for comment_match in comment_matches:
+ for i in ["comment", "comment2"]:
+ if i in comment_match.names:
+ colors[comment_match.get_start(i)] = {"color": text_edit.theme_overrides.comments_color}
+ text = text.substr(0, comment_match.get_start(i))
+
+ # Dialogues
+ var dialogue_matches: Array[RegExMatch] = regex_dialogue.search_all(text)
+ for dialogue_match in dialogue_matches:
+ if "random" in dialogue_match.names:
+ colors[dialogue_match.get_start("random")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[dialogue_match.get_end("random")] = {"color": text_edit.theme_overrides.text_color}
+ if "response" in dialogue_match.names:
+ colors[dialogue_match.get_start("response")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[dialogue_match.get_end("response")] = {"color": text_edit.theme_overrides.text_color}
+ if "character" in dialogue_match.names:
+ colors[dialogue_match.get_start("character")] = {"color": text_edit.theme_overrides.members_color}
+ colors[dialogue_match.get_end("character")] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_dialogue_syntax_highlighting(dialogue_match.get_start("dialogue"), dialogue_match.get_string("dialogue")), true)
+
+ # Title lines
+ if dialogue_manager_parser.is_title_line(text):
+ var title_matches: Array[RegExMatch] = regex_titles.search_all(text)
+ for title_match in title_matches:
+ colors[title_match.get_start("title")] = {"color": text_edit.theme_overrides.titles_color}
+
+ # Import lines
+ var import_matches: Array[RegExMatch] = dialogue_manager_parser.IMPORT_REGEX.search_all(text)
+ for import_match in import_matches:
+ colors[import_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[import_match.get_start("path") - 1] = {"color": text_edit.theme_overrides.strings_color}
+ colors[import_match.get_end("path") + 1] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[import_match.get_start("prefix")] = {"color": text_edit.theme_overrides.members_color}
+ colors[import_match.get_end("prefix")] = {"color": text_edit.theme_overrides.conditions_color}
+
+ # Using clauses
+ var using_matches: Array[RegExMatch] = dialogue_manager_parser.USING_REGEX.search_all(text)
+ for using_match in using_matches:
+ colors[using_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[using_match.get_start("state") - 1] = {"color": text_edit.theme_overrides.text_color}
+
+ # Condition keywords and expressions
+ var condition_matches: Array[RegExMatch] = regex_condition.search_all(text)
+ for condition_match in condition_matches:
+ colors[condition_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[condition_match.get_end(1)] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_expression_syntax_highlighting(condition_match.get_start("condition"), ExpressionType.IF, condition_match.get_string("condition")), true)
+ # endif/else
+ var endcondition_matches: Array[RegExMatch] = regex_endconditions.search_all(text)
+ for endcondition_match in endcondition_matches:
+ colors[endcondition_match.get_start(1)] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[endcondition_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Mutations
+ var mutation_matches: Array[RegExMatch] = regex_mutation.search_all(text)
+ for mutation_match in mutation_matches:
+ colors[mutation_match.get_start(0)] = {"color": text_edit.theme_overrides.mutations_color}
+ colors.merge(_get_expression_syntax_highlighting(mutation_match.get_start("mutation"), ExpressionType.DO if mutation_match.strings[1] == "do" else ExpressionType.SET, mutation_match.get_string("mutation")), true)
+
+ # Order the dictionary keys to prevent CodeEdit from having issues
+ var new_colors: Dictionary = {}
+ var ordered_keys: Array = colors.keys()
+ ordered_keys.sort()
+ for index in ordered_keys:
+ new_colors[index] = colors[index]
+
+ cache[text] = new_colors
+ return new_colors
+
+
+## Return the syntax highlighting for a dialogue line
+func _get_dialogue_syntax_highlighting(start_index: int, text: String) -> Dictionary:
+ var text_edit: TextEdit = get_text_edit()
+ var colors: Dictionary = {}
+
+ # #tag style tags
+ var hashtag_matches: Array[RegExMatch] = dialogue_manager_parser.TAGS_REGEX.search_all(text)
+ for hashtag_match in hashtag_matches:
+ colors[start_index + hashtag_match.get_start(0)] = { "color": text_edit.theme_overrides.comments_color }
+ colors[start_index + hashtag_match.get_end(0)] = { "color": text_edit.theme_overrides.text_color }
+
+ # bbcode-like global tags
+ var tag_matches: Array[RegExMatch] = regex_tags.search_all(text)
+ for tag_match in tag_matches:
+ colors[start_index + tag_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ if "val" in tag_match.names:
+ colors.merge(_get_literal_syntax_highlighting(start_index + tag_match.get_start("val"), tag_match.get_string("val")), true)
+ colors[start_index + tag_match.get_end("val")] = {"color": text_edit.theme_overrides.symbols_color}
+ # Show the text color straight in the editor for better ease-of-use
+ if tag_match.get_string("tag") == "color":
+ colors[start_index + tag_match.get_start("val")] = {"color": Color.from_string(tag_match.get_string("val"), text_edit.theme_overrides.text_color)}
+ if "text" in tag_match.names:
+ colors[start_index + tag_match.get_start("text")] = {"color": text_edit.theme_overrides.text_color}
+ # Text can still contain tags if several effects are applied ([center][b]Something[/b][/center], so recursing
+ colors.merge(_get_dialogue_syntax_highlighting(start_index + tag_match.get_start("text"), tag_match.get_string("text")), true)
+ colors[start_index + tag_match.get_end("text")] = {"color": text_edit.theme_overrides.symbols_color}
+ if "end" in tag_match.names:
+ colors[start_index + tag_match.get_start("end")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + tag_match.get_end("end")] = {"color": text_edit.theme_overrides.text_color}
+ colors[start_index + tag_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ # ID tag
+ var translation_matches: Array[RegExMatch] = dialogue_manager_parser.TRANSLATION_REGEX.search_all(text)
+ for translation_match in translation_matches:
+ colors[start_index + translation_match.get_start(0)] = {"color": text_edit.theme_overrides.comments_color}
+ colors[start_index + translation_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ # Replacements
+ var replacement_matches: Array[RegExMatch] = dialogue_manager_parser.REPLACEMENTS_REGEX.search_all(text)
+ for replacement_match in replacement_matches:
+ colors[start_index + replacement_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + replacement_match.get_start(1)] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + replacement_match.get_start(1), replacement_match.strings[1]), true)
+ colors[start_index + replacement_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + replacement_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ # Jump at the end of a response
+ var goto_matches: Array[RegExMatch] = regex_goto.search_all(text)
+ for goto_match in goto_matches:
+ colors[start_index + goto_match.get_start(0)] = {"color": text_edit.theme_overrides.jumps_color}
+ if "file" in goto_match.names:
+ colors[start_index + goto_match.get_start("file")] = {"color": text_edit.theme_overrides.jumps_color}
+ colors[start_index + goto_match.get_end("file")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + goto_match.get_start("title")] = {"color": text_edit.theme_overrides.jumps_color}
+ colors[start_index + goto_match.get_end("title")] = {"color": text_edit.theme_overrides.jumps_color}
+ colors[start_index + goto_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ # Wrapped condition
+ var wcondition_matches: Array[RegExMatch] = regex_wcondition.search_all(text)
+ for wcondition_match in wcondition_matches:
+ colors[start_index + wcondition_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + wcondition_match.get_start(0) + 1] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[start_index + wcondition_match.get_start(0) + 3] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + wcondition_match.get_start("condition"), wcondition_match.get_string("condition")), true)
+ colors[start_index + wcondition_match.get_end("condition")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + wcondition_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+ # [/if] tag for color matching with the opening tag
+ var wendif_matches: Array[RegExMatch] = regex_wendif.search_all(text)
+ for wendif_match in wendif_matches:
+ colors[start_index + wendif_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + wendif_match.get_start(1)] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[start_index + wendif_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + wendif_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ # Random groups
+ var rgroup_matches: Array[RegExMatch] = regex_rgroup.search_all(text)
+ for rgroup_match in rgroup_matches:
+ colors[start_index + rgroup_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + rgroup_match.get_start("options")] = {"color": text_edit.theme_overrides.text_color}
+ var separator_matches: Array[RegExMatch] = RegEx.create_from_string("\\|").search_all(rgroup_match.get_string("options"))
+ for separator_match in separator_matches:
+ colors[start_index + rgroup_match.get_start("options") + separator_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + rgroup_match.get_start("options") + separator_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+ colors[start_index + rgroup_match.get_end("options")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + rgroup_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color}
+
+ return colors
+
+
+## Returns the syntax highlighting for an expression (mutation set/do, or condition)
+func _get_expression_syntax_highlighting(start_index: int, type: ExpressionType, text: String) -> Dictionary:
+ var text_edit: TextEdit = get_text_edit()
+ var colors: Dictionary = {}
+
+ if type == ExpressionType.SET:
+ var assignment_matches: Array[RegExMatch] = regex_assignment.search_all(text)
+ for assignment_match in assignment_matches:
+ colors[start_index + assignment_match.get_start("var")] = {"color": text_edit.theme_overrides.text_color}
+ if "attr" in assignment_match.names:
+ colors[start_index + assignment_match.get_start("attr")] = {"color": text_edit.theme_overrides.members_color}
+ colors[start_index + assignment_match.get_end("attr")] = {"color": text_edit.theme_overrides.text_color}
+ if "key" in assignment_match.names:
+ # Braces are outside of the key, so coloring them symbols_color
+ colors[start_index + assignment_match.get_start("key") - 1] = {"color": text_edit.theme_overrides.symbols_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + assignment_match.get_start("key"), assignment_match.get_string("key")), true)
+ colors[start_index + assignment_match.get_end("key")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + assignment_match.get_end("key") + 1] = {"color": text_edit.theme_overrides.text_color}
+
+ colors[start_index + assignment_match.get_start("op")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + assignment_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + assignment_match.get_start("val"), assignment_match.get_string("val")), true)
+ else:
+ colors.merge(_get_literal_syntax_highlighting(start_index, text), true)
+
+ return colors
+
+
+## Return the syntax highlighting for a literal
+## For this purpose, "literal" refers to a regular code line that could be used to get a value out of:
+## - function calls
+## - real literals (bool, string, int, float, etc.)
+## - logical operators (>, <, >=, or, and, not, etc.)
+func _get_literal_syntax_highlighting(start_index: int, text: String) -> Dictionary:
+ var text_edit: TextEdit = get_text_edit()
+ var colors: Dictionary = {}
+
+ # Remove spaces at start/end of the literal
+ var text_length: int = text.length()
+ text = text.lstrip(" ")
+ start_index += text_length - text.length()
+ text = text.rstrip(" ")
+
+ # Parenthesis expression.
+ var paren_matches: Array[RegExMatch] = regex_paren.search_all(text)
+ for paren_match in paren_matches:
+ colors[start_index + paren_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + paren_match.get_start(0) + 1] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + paren_match.get_start("paren"), paren_match.get_string("paren")), true)
+ colors[start_index + paren_match.get_end(0) - 1] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Strings
+ var string_matches: Array[RegExMatch] = regex_string.search_all(text)
+ for string_match in string_matches:
+ colors[start_index + string_match.get_start(0)] = {"color": text_edit.theme_overrides.strings_color}
+ if "content" in string_match.names:
+ var escape_matches: Array[RegExMatch] = regex_escape.search_all(string_match.get_string("content"))
+ for escape_match in escape_matches:
+ colors[start_index + string_match.get_start("content") + escape_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + string_match.get_start("content") + escape_match.get_end(0)] = {"color": text_edit.theme_overrides.strings_color}
+
+ # Numbers
+ var number_matches: Array[RegExMatch] = regex_number.search_all(text)
+ for number_match in number_matches:
+ colors[start_index + number_match.get_start(0)] = {"color": text_edit.theme_overrides.numbers_color}
+
+ # Arrays
+ var array_matches: Array[RegExMatch] = regex_array.search_all(text)
+ for array_match in array_matches:
+ colors[start_index + array_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors.merge(_get_list_syntax_highlighting(start_index + array_match.get_start(1), array_match.strings[1]), true)
+ colors[start_index + array_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Dictionaries
+ var dict_matches: Array[RegExMatch] = regex_dict.search_all(text)
+ for dict_match in dict_matches:
+ colors[start_index + dict_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors.merge(_get_list_syntax_highlighting(start_index + dict_match.get_start(1), dict_match.strings[1]), true)
+ colors[start_index + dict_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Dictionary key: value pairs
+ var kvdict_matches: Array[RegExMatch] = regex_kvdict.search_all(text)
+ for kvdict_match in kvdict_matches:
+ colors.merge(_get_literal_syntax_highlighting(start_index + kvdict_match.get_start("left"), kvdict_match.get_string("left")), true)
+ colors[start_index + kvdict_match.get_start("colon")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + kvdict_match.get_end("colon")] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + kvdict_match.get_start("right"), kvdict_match.get_string("right")), true)
+
+ # Booleans
+ var bool_matches: Array[RegExMatch] = regex_keyword.search_all(text)
+ for bool_match in bool_matches:
+ colors[start_index + bool_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color}
+
+ # Functions
+ var function_matches: Array[RegExMatch] = regex_function.search_all(text)
+ for function_match in function_matches:
+ var last_brace_index: int = text.rfind(")")
+ colors[start_index + function_match.get_start(1)] = {"color": text_edit.theme_overrides.mutations_color}
+ colors[start_index + function_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color}
+ colors.merge(_get_list_syntax_highlighting(start_index + function_match.get_end(0), text.substr(function_match.get_end(0), last_brace_index - function_match.get_end(0))), true)
+ colors[start_index + last_brace_index] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Variables
+ var varname_matches: Array[RegExMatch] = regex_varname.search_all(text)
+ for varname_match in varname_matches:
+ colors[start_index + varname_match.get_start("var")] = {"color": text_edit.theme_overrides.text_color}
+ if "attr" in varname_match.names:
+ colors[start_index + varname_match.get_start("attr")] = {"color": text_edit.theme_overrides.members_color}
+ colors[start_index + varname_match.get_end("attr")] = {"color": text_edit.theme_overrides.text_color}
+ if "key" in varname_match.names:
+ # Braces are outside of the key, so coloring them symbols_color
+ colors[start_index + varname_match.get_start("key") - 1] = {"color": text_edit.theme_overrides.symbols_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + varname_match.get_start("key"), varname_match.get_string("key")), true)
+ colors[start_index + varname_match.get_end("key")] = {"color": text_edit.theme_overrides.symbols_color}
+
+ # Comparison operators
+ var comparison_matches: Array[RegExMatch] = regex_comparison.search_all(text)
+ for comparison_match in comparison_matches:
+ colors.merge(_get_literal_syntax_highlighting(start_index + comparison_match.get_start("left"), comparison_match.get_string("left")), true)
+ colors[start_index + comparison_match.get_start("op")] = {"color": text_edit.theme_overrides.symbols_color}
+ colors[start_index + comparison_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color}
+ var right = comparison_match.get_string("right")
+ if right.ends_with(":"):
+ right = right.substr(0, right.length() - 1)
+ colors.merge(_get_literal_syntax_highlighting(start_index + comparison_match.get_start("right"), right), true)
+ colors[start_index + comparison_match.get_start("right") + right.length()] = { "color": text_edit.theme_overrides.symbols_color }
+
+ # Logical binary operators
+ var blogical_matches: Array[RegExMatch] = regex_blogical.search_all(text)
+ for blogical_match in blogical_matches:
+ colors.merge(_get_literal_syntax_highlighting(start_index + blogical_match.get_start("left"), blogical_match.get_string("left")), true)
+ colors[start_index + blogical_match.get_start("op")] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[start_index + blogical_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + blogical_match.get_start("right"), blogical_match.get_string("right")), true)
+
+ # Logical unary operators
+ var ulogical_matches: Array[RegExMatch] = regex_ulogical.search_all(text)
+ for ulogical_match in ulogical_matches:
+ colors[start_index + ulogical_match.get_start("op")] = {"color": text_edit.theme_overrides.conditions_color}
+ colors[start_index + ulogical_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color}
+ colors.merge(_get_literal_syntax_highlighting(start_index + ulogical_match.get_start("right"), ulogical_match.get_string("right")), true)
+
+ return colors
+
+
+## Returns the syntax coloring for a list of literals separated by commas
+func _get_list_syntax_highlighting(start_index: int, text: String) -> Dictionary:
+ var text_edit: TextEdit = get_text_edit()
+ var colors: Dictionary = {}
+
+ # Comma-separated list of literals (for arrays and function arguments)
+ var element_matches: Array[RegExMatch] = regex_commas.search_all(text)
+ for element_match in element_matches:
+ colors.merge(_get_literal_syntax_highlighting(start_index + element_match.get_start(1), element_match.strings[1]), true)
+
+ return colors
diff --git a/addons/dialogue_manager/components/dialogue_cache.gd b/addons/dialogue_manager/components/dialogue_cache.gd
new file mode 100644
index 00000000..0a309356
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_cache.gd
@@ -0,0 +1,168 @@
+extends Node
+
+
+const DialogueConstants = preload("../constants.gd")
+const DialogueSettings = preload("../settings.gd")
+const DialogueManagerParseResult = preload("./parse_result.gd")
+
+
+signal file_content_changed(path: String, new_content: String)
+
+
+# Keep track of errors and dependencies
+# {
+# = {
+# path = ,
+# dependencies = [, ],
+# errors = [, ]
+# }
+# }
+var _cache: Dictionary = {}
+
+var _update_dependency_timer: Timer = Timer.new()
+var _update_dependency_paths: PackedStringArray = []
+
+
+func _ready() -> void:
+ add_child(_update_dependency_timer)
+ _update_dependency_timer.timeout.connect(_on_update_dependency_timeout)
+
+ _build_cache()
+
+
+func reimport_files(files: PackedStringArray = []) -> void:
+ if files.is_empty(): files = get_files()
+
+ var file_system: EditorFileSystem = Engine.get_meta("DialogueManagerPlugin") \
+ .get_editor_interface() \
+ .get_resource_filesystem()
+
+ # NOTE: Godot 4.2rc1 has an issue with reimporting more than one
+ # file at a time so we do them one by one
+ for file in files:
+ file_system.reimport_files([file])
+ await get_tree().create_timer(0.2)
+
+
+## Add a dialogue file to the cache.
+func add_file(path: String, parse_results: DialogueManagerParseResult = null) -> void:
+ _cache[path] = {
+ path = path,
+ dependencies = [],
+ errors = []
+ }
+
+ if parse_results != null:
+ _cache[path].dependencies = Array(parse_results.imported_paths).filter(func(d): return d != path)
+ _cache[path].parsed_at = Time.get_ticks_msec()
+
+ # If this is a fresh cache entry, check for dependencies
+ if parse_results == null and not _update_dependency_paths.has(path):
+ queue_updating_dependencies(path)
+
+
+## Get the file paths in the cache
+func get_files() -> PackedStringArray:
+ return _cache.keys()
+
+
+## Check if a file is known to the cache
+func has_file(path: String) -> bool:
+ return _cache.has(path)
+
+
+## Remember any errors in a dialogue file
+func add_errors_to_file(path: String, errors: Array[Dictionary]) -> void:
+ if _cache.has(path):
+ _cache[path].errors = errors
+ else:
+ _cache[path] = {
+ path = path,
+ resource_path = "",
+ dependencies = [],
+ errors = errors
+ }
+
+
+## Get a list of files that have errors
+func get_files_with_errors() -> Array[Dictionary]:
+ var files_with_errors: Array[Dictionary] = []
+ for dialogue_file in _cache.values():
+ if dialogue_file and dialogue_file.errors.size() > 0:
+ files_with_errors.append(dialogue_file)
+ return files_with_errors
+
+
+## Queue a file to have its dependencies checked
+func queue_updating_dependencies(of_path: String) -> void:
+ _update_dependency_timer.stop()
+ if not _update_dependency_paths.has(of_path):
+ _update_dependency_paths.append(of_path)
+ _update_dependency_timer.start(0.5)
+
+
+## Update any references to a file path that has moved
+func move_file_path(from_path: String, to_path: String) -> void:
+ if not _cache.has(from_path): return
+
+ if to_path != "":
+ _cache[to_path] = _cache[from_path].duplicate()
+ _cache.erase(from_path)
+
+
+## Get every dialogue file that imports on a file of a given path
+func get_files_with_dependency(imported_path: String) -> Array:
+ return _cache.values().filter(func(d): return d.dependencies.has(imported_path))
+
+
+## Get any paths that are dependent on a given path
+func get_dependent_paths_for_reimport(on_path: String) -> PackedStringArray:
+ return get_files_with_dependency(on_path) \
+ .filter(func(d): return Time.get_ticks_msec() - d.get("parsed_at", 0) > 3000) \
+ .map(func(d): return d.path)
+
+
+# Build the initial cache for dialogue files
+func _build_cache() -> void:
+ var current_files: PackedStringArray = _get_dialogue_files_in_filesystem()
+ for file in current_files:
+ add_file(file)
+
+
+# Recursively find any dialogue files in a directory
+func _get_dialogue_files_in_filesystem(path: String = "res://") -> PackedStringArray:
+ var files: PackedStringArray = []
+
+ if DirAccess.dir_exists_absolute(path):
+ var dir = DirAccess.open(path)
+ dir.list_dir_begin()
+ var file_name = dir.get_next()
+ while file_name != "":
+ var file_path: String = (path + "/" + file_name).simplify_path()
+ if dir.current_is_dir():
+ if not file_name in [".godot", ".tmp"]:
+ files.append_array(_get_dialogue_files_in_filesystem(file_path))
+ elif file_name.get_extension() == "dialogue":
+ files.append(file_path)
+ file_name = dir.get_next()
+
+ return files
+
+
+### Signals
+
+
+func _on_update_dependency_timeout() -> void:
+ _update_dependency_timer.stop()
+ var import_regex: RegEx = RegEx.create_from_string("import \"(?.*?)\"")
+ var file: FileAccess
+ var found_imports: Array[RegExMatch]
+ for path in _update_dependency_paths:
+ # Open the file and check for any "import" lines
+ file = FileAccess.open(path, FileAccess.READ)
+ found_imports = import_regex.search_all(file.get_as_text())
+ var dependencies: PackedStringArray = []
+ for found in found_imports:
+ dependencies.append(found.strings[found.names.path])
+ _cache[path].dependencies = dependencies
+ _update_dependency_paths.clear()
diff --git a/addons/dialogue_manager/components/download_update_panel.gd b/addons/dialogue_manager/components/download_update_panel.gd
new file mode 100644
index 00000000..617d3082
--- /dev/null
+++ b/addons/dialogue_manager/components/download_update_panel.gd
@@ -0,0 +1,84 @@
+@tool
+extends Control
+
+
+signal failed()
+signal updated(updated_to_version: String)
+
+
+const DialogueConstants = preload("../constants.gd")
+
+const TEMP_FILE_NAME = "user://temp.zip"
+
+
+@onready var logo: TextureRect = %Logo
+@onready var label: Label = $VBox/Label
+@onready var http_request: HTTPRequest = $HTTPRequest
+@onready var download_button: Button = %DownloadButton
+
+var next_version_release: Dictionary:
+ set(value):
+ next_version_release = value
+ label.text = DialogueConstants.translate(&"update.is_available_for_download") % value.tag_name.substr(1)
+ get:
+ return next_version_release
+
+
+func _ready() -> void:
+ $VBox/Center/DownloadButton.text = DialogueConstants.translate(&"update.download_update")
+ $VBox/Center2/NotesButton.text = DialogueConstants.translate(&"update.release_notes")
+
+
+### Signals
+
+
+func _on_download_button_pressed() -> void:
+ # Safeguard the actual dialogue manager repo from accidentally updating itself
+ if FileAccess.file_exists("res://examples/test_scenes/test_scene.gd"):
+ prints("You can't update the addon from within itself.")
+ failed.emit()
+ return
+
+ http_request.request(next_version_release.zipball_url)
+ download_button.disabled = true
+ download_button.text = DialogueConstants.translate(&"update.downloading")
+
+
+func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
+ if result != HTTPRequest.RESULT_SUCCESS:
+ failed.emit()
+ return
+
+ # Save the downloaded zip
+ var zip_file: FileAccess = FileAccess.open(TEMP_FILE_NAME, FileAccess.WRITE)
+ zip_file.store_buffer(body)
+ zip_file.close()
+
+ OS.move_to_trash(ProjectSettings.globalize_path("res://addons/dialogue_manager"))
+
+ var zip_reader: ZIPReader = ZIPReader.new()
+ zip_reader.open(TEMP_FILE_NAME)
+ var files: PackedStringArray = zip_reader.get_files()
+
+ var base_path = files[1]
+ # Remove archive folder
+ files.remove_at(0)
+ # Remove assets folder
+ files.remove_at(0)
+
+ for path in files:
+ var new_file_path: String = path.replace(base_path, "")
+ if path.ends_with("/"):
+ DirAccess.make_dir_recursive_absolute("res://addons/%s" % new_file_path)
+ else:
+ var file: FileAccess = FileAccess.open("res://addons/%s" % new_file_path, FileAccess.WRITE)
+ file.store_buffer(zip_reader.read_file(path))
+
+ zip_reader.close()
+ DirAccess.remove_absolute(TEMP_FILE_NAME)
+
+ updated.emit(next_version_release.tag_name.substr(1))
+
+
+func _on_notes_button_pressed() -> void:
+ OS.shell_open(next_version_release.html_url)
diff --git a/addons/dialogue_manager/components/download_update_panel.tscn b/addons/dialogue_manager/components/download_update_panel.tscn
new file mode 100644
index 00000000..f3f45236
--- /dev/null
+++ b/addons/dialogue_manager/components/download_update_panel.tscn
@@ -0,0 +1,60 @@
+[gd_scene load_steps=3 format=3 uid="uid://qdxrxv3c3hxk"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/download_update_panel.gd" id="1_4tm1k"]
+[ext_resource type="Texture2D" uid="uid://d3baj6rygkb3f" path="res://addons/dialogue_manager/assets/update.svg" id="2_4o2m6"]
+
+[node name="DownloadUpdatePanel" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_4tm1k")
+
+[node name="HTTPRequest" type="HTTPRequest" parent="."]
+
+[node name="VBox" type="VBoxContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = -1.0
+offset_top = 9.0
+offset_right = -1.0
+offset_bottom = 9.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/separation = 10
+
+[node name="Logo" type="TextureRect" parent="VBox"]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(300, 80)
+layout_mode = 2
+texture = ExtResource("2_4o2m6")
+stretch_mode = 5
+
+[node name="Label" type="Label" parent="VBox"]
+layout_mode = 2
+text = "v1.2.3 is available for download."
+horizontal_alignment = 1
+
+[node name="Center" type="CenterContainer" parent="VBox"]
+layout_mode = 2
+
+[node name="DownloadButton" type="Button" parent="VBox/Center"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Download update"
+
+[node name="Center2" type="CenterContainer" parent="VBox"]
+layout_mode = 2
+
+[node name="NotesButton" type="LinkButton" parent="VBox/Center2"]
+layout_mode = 2
+text = "Read release notes"
+
+[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"]
+[connection signal="pressed" from="VBox/Center/DownloadButton" to="." method="_on_download_button_pressed"]
+[connection signal="pressed" from="VBox/Center2/NotesButton" to="." method="_on_notes_button_pressed"]
diff --git a/addons/dialogue_manager/components/editor_property/editor_property.gd b/addons/dialogue_manager/components/editor_property/editor_property.gd
new file mode 100644
index 00000000..5deef659
--- /dev/null
+++ b/addons/dialogue_manager/components/editor_property/editor_property.gd
@@ -0,0 +1,48 @@
+@tool
+extends EditorProperty
+
+
+const DialoguePropertyEditorControl = preload("./editor_property_control.tscn")
+
+
+var editor_plugin: EditorPlugin
+
+var control = DialoguePropertyEditorControl.instantiate()
+var current_value: Resource
+var is_updating: bool = false
+
+
+func _init() -> void:
+ add_child(control)
+
+ control.resource = current_value
+
+ control.pressed.connect(_on_button_pressed)
+ control.resource_changed.connect(_on_resource_changed)
+
+
+func _update_property() -> void:
+ var next_value = get_edited_object()[get_edited_property()]
+
+ # The resource might have been deleted elsewhere so check that it's not in a weird state
+ if is_instance_valid(next_value) and not next_value.resource_path.ends_with(".dialogue"):
+ emit_changed(get_edited_property(), null)
+ return
+
+ if next_value == current_value: return
+
+ is_updating = true
+ current_value = next_value
+ control.resource = current_value
+ is_updating = false
+
+
+### Signals
+
+
+func _on_button_pressed() -> void:
+ editor_plugin.edit(current_value)
+
+
+func _on_resource_changed(next_resource: Resource) -> void:
+ emit_changed(get_edited_property(), next_resource)
diff --git a/addons/dialogue_manager/components/editor_property/editor_property_control.gd b/addons/dialogue_manager/components/editor_property/editor_property_control.gd
new file mode 100644
index 00000000..ca52c79e
--- /dev/null
+++ b/addons/dialogue_manager/components/editor_property/editor_property_control.gd
@@ -0,0 +1,147 @@
+@tool
+extends HBoxContainer
+
+
+signal pressed()
+signal resource_changed(next_resource: Resource)
+
+
+const ITEM_NEW = 100
+const ITEM_QUICK_LOAD = 200
+const ITEM_LOAD = 201
+const ITEM_EDIT = 300
+const ITEM_CLEAR = 301
+const ITEM_FILESYSTEM = 400
+
+
+@onready var button: Button = $ResourceButton
+@onready var menu_button: Button = $MenuButton
+@onready var menu: PopupMenu = $Menu
+@onready var quick_open_dialog: ConfirmationDialog = $QuickOpenDialog
+@onready var files_list = $QuickOpenDialog/FilesList
+@onready var new_dialog: FileDialog = $NewDialog
+@onready var open_dialog: FileDialog = $OpenDialog
+
+var editor_plugin: EditorPlugin
+
+var resource: Resource:
+ set(next_resource):
+ resource = next_resource
+ if button:
+ button.resource = resource
+ get:
+ return resource
+
+var is_waiting_for_file: bool = false
+var quick_selected_file: String = ""
+
+
+func _ready() -> void:
+ menu_button.icon = get_theme_icon("GuiDropdown", "EditorIcons")
+ editor_plugin = Engine.get_meta("DialogueManagerPlugin")
+
+
+func build_menu() -> void:
+ menu.clear()
+
+ menu.add_icon_item(editor_plugin._get_plugin_icon(), "New Dialogue", ITEM_NEW)
+ menu.add_separator()
+ menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Quick Load", ITEM_QUICK_LOAD)
+ menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Load", ITEM_LOAD)
+ if resource:
+ menu.add_icon_item(get_theme_icon("Edit", "EditorIcons"), "Edit", ITEM_EDIT)
+ menu.add_icon_item(get_theme_icon("Clear", "EditorIcons"), "Clear", ITEM_CLEAR)
+ menu.add_separator()
+ menu.add_item("Show in FileSystem", ITEM_FILESYSTEM)
+
+ menu.size = Vector2.ZERO
+
+
+### Signals
+
+
+func _on_new_dialog_file_selected(path: String) -> void:
+ editor_plugin.main_view.new_file(path)
+ is_waiting_for_file = false
+ if Engine.get_meta("DialogueCache").has_file(path):
+ resource_changed.emit(load(path))
+ else:
+ var next_resource: Resource = await editor_plugin.import_plugin.compiled_resource
+ next_resource.resource_path = path
+ resource_changed.emit(next_resource)
+
+
+func _on_open_dialog_file_selected(file: String) -> void:
+ resource_changed.emit(load(file))
+
+
+func _on_file_dialog_canceled() -> void:
+ is_waiting_for_file = false
+
+
+func _on_resource_button_pressed() -> void:
+ if is_instance_valid(resource):
+ editor_plugin.get_editor_interface().call_deferred("edit_resource", resource)
+ else:
+ build_menu()
+ menu.position = get_viewport().position + Vector2i(
+ button.global_position.x + button.size.x - menu.size.x,
+ 2 + menu_button.global_position.y + button.size.y
+ )
+ menu.popup()
+
+
+func _on_resource_button_resource_dropped(next_resource: Resource) -> void:
+ resource_changed.emit(next_resource)
+
+
+func _on_menu_button_pressed() -> void:
+ build_menu()
+ menu.position = get_viewport().position + Vector2i(
+ menu_button.global_position.x + menu_button.size.x - menu.size.x,
+ 2 + menu_button.global_position.y + menu_button.size.y
+ )
+ menu.popup()
+
+
+func _on_menu_id_pressed(id: int) -> void:
+ match id:
+ ITEM_NEW:
+ is_waiting_for_file = true
+ new_dialog.popup_centered()
+
+ ITEM_QUICK_LOAD:
+ quick_selected_file = ""
+ files_list.files = Engine.get_meta("DialogueCache").get_files()
+ if resource:
+ files_list.select_file(resource.resource_path)
+ quick_open_dialog.popup_centered()
+ files_list.focus_filter()
+
+ ITEM_LOAD:
+ is_waiting_for_file = true
+ open_dialog.popup_centered()
+
+ ITEM_EDIT:
+ editor_plugin.get_editor_interface().call_deferred("edit_resource", resource)
+
+ ITEM_CLEAR:
+ resource_changed.emit(null)
+
+ ITEM_FILESYSTEM:
+ var file_system = editor_plugin.get_editor_interface().get_file_system_dock()
+ file_system.navigate_to_path(resource.resource_path)
+
+
+func _on_files_list_file_double_clicked(file_path: String) -> void:
+ resource_changed.emit(load(file_path))
+ quick_open_dialog.hide()
+
+
+func _on_files_list_file_selected(file_path: String) -> void:
+ quick_selected_file = file_path
+
+
+func _on_quick_open_dialog_confirmed() -> void:
+ if quick_selected_file != "":
+ resource_changed.emit(load(quick_selected_file))
diff --git a/addons/dialogue_manager/components/editor_property/editor_property_control.tscn b/addons/dialogue_manager/components/editor_property/editor_property_control.tscn
new file mode 100644
index 00000000..8adb6a1c
--- /dev/null
+++ b/addons/dialogue_manager/components/editor_property/editor_property_control.tscn
@@ -0,0 +1,58 @@
+[gd_scene load_steps=4 format=3 uid="uid://ycn6uaj7dsrh"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/editor_property/editor_property_control.gd" id="1_het12"]
+[ext_resource type="PackedScene" uid="uid://b16uuqjuof3n5" path="res://addons/dialogue_manager/components/editor_property/resource_button.tscn" id="2_hh3d4"]
+[ext_resource type="PackedScene" uid="uid://dnufpcdrreva3" path="res://addons/dialogue_manager/components/files_list.tscn" id="3_l8fp6"]
+
+[node name="PropertyEditorButton" type="HBoxContainer"]
+offset_right = 40.0
+offset_bottom = 40.0
+size_flags_horizontal = 3
+theme_override_constants/separation = 0
+script = ExtResource("1_het12")
+
+[node name="ResourceButton" parent="." instance=ExtResource("2_hh3d4")]
+layout_mode = 2
+text = ""
+text_overrun_behavior = 3
+clip_text = true
+
+[node name="MenuButton" type="Button" parent="."]
+layout_mode = 2
+
+[node name="Menu" type="PopupMenu" parent="."]
+
+[node name="QuickOpenDialog" type="ConfirmationDialog" parent="."]
+title = "Find Dialogue Resource"
+size = Vector2i(400, 600)
+min_size = Vector2i(400, 600)
+ok_button_text = "Open"
+
+[node name="FilesList" parent="QuickOpenDialog" instance=ExtResource("3_l8fp6")]
+
+[node name="NewDialog" type="FileDialog" parent="."]
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="OpenDialog" type="FileDialog" parent="."]
+title = "Open a File"
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+ok_button_text = "Open"
+dialog_hide_on_ok = true
+file_mode = 0
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[connection signal="pressed" from="ResourceButton" to="." method="_on_resource_button_pressed"]
+[connection signal="resource_dropped" from="ResourceButton" to="." method="_on_resource_button_resource_dropped"]
+[connection signal="pressed" from="MenuButton" to="." method="_on_menu_button_pressed"]
+[connection signal="id_pressed" from="Menu" to="." method="_on_menu_id_pressed"]
+[connection signal="confirmed" from="QuickOpenDialog" to="." method="_on_quick_open_dialog_confirmed"]
+[connection signal="file_double_clicked" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_double_clicked"]
+[connection signal="file_selected" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_selected"]
+[connection signal="canceled" from="NewDialog" to="." method="_on_file_dialog_canceled"]
+[connection signal="file_selected" from="NewDialog" to="." method="_on_new_dialog_file_selected"]
+[connection signal="canceled" from="OpenDialog" to="." method="_on_file_dialog_canceled"]
+[connection signal="file_selected" from="OpenDialog" to="." method="_on_open_dialog_file_selected"]
diff --git a/addons/dialogue_manager/components/editor_property/resource_button.gd b/addons/dialogue_manager/components/editor_property/resource_button.gd
new file mode 100644
index 00000000..5ba33dc6
--- /dev/null
+++ b/addons/dialogue_manager/components/editor_property/resource_button.gd
@@ -0,0 +1,48 @@
+@tool
+extends Button
+
+
+signal resource_dropped(next_resource: Resource)
+
+
+var resource: Resource:
+ set(next_resource):
+ resource = next_resource
+ if resource:
+ icon = Engine.get_meta("DialogueManagerPlugin")._get_plugin_icon()
+ text = resource.resource_path.get_file().replace(".dialogue", "")
+ else:
+ icon = null
+ text = ""
+ get:
+ return resource
+
+
+func _notification(what: int) -> void:
+ match what:
+ NOTIFICATION_DRAG_BEGIN:
+ var data = get_viewport().gui_get_drag_data()
+ if typeof(data) == TYPE_DICTIONARY and data.type == "files" and data.files.size() > 0 and data.files[0].ends_with(".dialogue"):
+ add_theme_stylebox_override("normal", get_theme_stylebox("focus", "LineEdit"))
+ add_theme_stylebox_override("hover", get_theme_stylebox("focus", "LineEdit"))
+
+ NOTIFICATION_DRAG_END:
+ self.resource = resource
+ remove_theme_stylebox_override("normal")
+ remove_theme_stylebox_override("hover")
+
+
+func _can_drop_data(at_position: Vector2, data) -> bool:
+ if typeof(data) != TYPE_DICTIONARY: return false
+ if data.type != "files": return false
+
+ var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
+ return files.size() > 0
+
+
+func _drop_data(at_position: Vector2, data) -> void:
+ var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
+
+ if files.size() == 0: return
+
+ resource_dropped.emit(load(files[0]))
diff --git a/addons/dialogue_manager/components/editor_property/resource_button.tscn b/addons/dialogue_manager/components/editor_property/resource_button.tscn
new file mode 100644
index 00000000..d68d8513
--- /dev/null
+++ b/addons/dialogue_manager/components/editor_property/resource_button.tscn
@@ -0,0 +1,9 @@
+[gd_scene load_steps=2 format=3 uid="uid://b16uuqjuof3n5"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/editor_property/resource_button.gd" id="1_7u2i7"]
+
+[node name="ResourceButton" type="Button"]
+offset_right = 8.0
+offset_bottom = 8.0
+size_flags_horizontal = 3
+script = ExtResource("1_7u2i7")
diff --git a/addons/dialogue_manager/components/errors_panel.gd b/addons/dialogue_manager/components/errors_panel.gd
new file mode 100644
index 00000000..f3e294d9
--- /dev/null
+++ b/addons/dialogue_manager/components/errors_panel.gd
@@ -0,0 +1,85 @@
+@tool
+extends HBoxContainer
+
+
+signal error_pressed(line_number)
+
+
+const DialogueConstants = preload("../constants.gd")
+
+
+@onready var error_button: Button = $ErrorButton
+@onready var next_button: Button = $NextButton
+@onready var count_label: Label = $CountLabel
+@onready var previous_button: Button = $PreviousButton
+
+## The index of the current error being shown
+var error_index: int = 0:
+ set(next_error_index):
+ error_index = wrap(next_error_index, 0, errors.size())
+ show_error()
+ get:
+ return error_index
+
+## The list of all errors
+var errors: Array = []:
+ set(next_errors):
+ errors = next_errors
+ self.error_index = 0
+ get:
+ return errors
+
+
+func _ready() -> void:
+ apply_theme()
+ hide()
+
+
+## Set up colors and icons
+func apply_theme() -> void:
+ error_button.add_theme_color_override("font_color", get_theme_color("error_color", "Editor"))
+ error_button.add_theme_color_override("font_hover_color", get_theme_color("error_color", "Editor"))
+ error_button.icon = get_theme_icon("StatusError", "EditorIcons")
+ previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons")
+ next_button.icon = get_theme_icon("ArrowRight", "EditorIcons")
+
+
+## Move the error index to match a given line
+func show_error_for_line_number(line_number: int) -> void:
+ for i in range(0, errors.size()):
+ if errors[i].line_number == line_number:
+ self.error_index = i
+
+
+## Show the current error
+func show_error() -> void:
+ if errors.size() == 0:
+ hide()
+ else:
+ show()
+ count_label.text = DialogueConstants.translate(&"n_of_n").format({ index = error_index + 1, total = errors.size() })
+ var error = errors[error_index]
+ error_button.text = DialogueConstants.translate(&"errors.line_and_message").format({ line = error.line_number + 1, column = error.column_number, message = DialogueConstants.get_error_message(error.error) })
+ if error.has("external_error"):
+ error_button.text += " " + DialogueConstants.get_error_message(error.external_error)
+
+
+### Signals
+
+
+func _on_errors_panel_theme_changed() -> void:
+ apply_theme()
+
+
+func _on_error_button_pressed() -> void:
+ emit_signal("error_pressed", errors[error_index].line_number, errors[error_index].column_number)
+
+
+func _on_previous_button_pressed() -> void:
+ self.error_index -= 1
+ _on_error_button_pressed()
+
+
+func _on_next_button_pressed() -> void:
+ self.error_index += 1
+ _on_error_button_pressed()
diff --git a/addons/dialogue_manager/components/errors_panel.tscn b/addons/dialogue_manager/components/errors_panel.tscn
new file mode 100644
index 00000000..956552b1
--- /dev/null
+++ b/addons/dialogue_manager/components/errors_panel.tscn
@@ -0,0 +1,56 @@
+[gd_scene load_steps=4 format=3 uid="uid://cs8pwrxr5vxix"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/errors_panel.gd" id="1_nfm3c"]
+
+[sub_resource type="Image" id="Image_wy5pj"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 93, 93, 55, 255, 97, 97, 58, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 98, 98, 47, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 94, 94, 46, 255, 93, 93, 236, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_s6fxl"]
+image = SubResource("Image_wy5pj")
+
+[node name="ErrorsPanel" type="HBoxContainer"]
+visible = false
+offset_right = 1024.0
+offset_bottom = 600.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_nfm3c")
+metadata/_edit_layout_mode = 1
+
+[node name="ErrorButton" type="Button" parent="."]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_colors/font_color = Color(0, 0, 0, 1)
+theme_override_colors/font_hover_color = Color(0, 0, 0, 1)
+theme_override_constants/h_separation = 3
+icon = SubResource("ImageTexture_s6fxl")
+flat = true
+alignment = 0
+text_overrun_behavior = 4
+
+[node name="Spacer" type="Control" parent="."]
+custom_minimum_size = Vector2(40, 0)
+layout_mode = 2
+
+[node name="PreviousButton" type="Button" parent="."]
+layout_mode = 2
+icon = SubResource("ImageTexture_s6fxl")
+flat = true
+
+[node name="CountLabel" type="Label" parent="."]
+layout_mode = 2
+
+[node name="NextButton" type="Button" parent="."]
+layout_mode = 2
+icon = SubResource("ImageTexture_s6fxl")
+flat = true
+
+[connection signal="pressed" from="ErrorButton" to="." method="_on_error_button_pressed"]
+[connection signal="pressed" from="PreviousButton" to="." method="_on_previous_button_pressed"]
+[connection signal="pressed" from="NextButton" to="." method="_on_next_button_pressed"]
diff --git a/addons/dialogue_manager/components/files_list.gd b/addons/dialogue_manager/components/files_list.gd
new file mode 100644
index 00000000..d778c733
--- /dev/null
+++ b/addons/dialogue_manager/components/files_list.gd
@@ -0,0 +1,146 @@
+@tool
+extends VBoxContainer
+
+
+signal file_selected(file_path: String)
+signal file_popup_menu_requested(at_position: Vector2)
+signal file_double_clicked(file_path: String)
+signal file_middle_clicked(file_path: String)
+
+
+const DialogueConstants = preload("../constants.gd")
+
+const MODIFIED_SUFFIX = "(*)"
+
+
+@export var icon: Texture2D
+
+@onready var filter_edit: LineEdit = $FilterEdit
+@onready var list: ItemList = $List
+
+var file_map: Dictionary = {}
+
+var current_file_path: String = ""
+
+var files: PackedStringArray = []:
+ set(next_files):
+ files = next_files
+ files.sort()
+ update_file_map()
+ apply_filter()
+ get:
+ return files
+
+var unsaved_files: Array[String] = []
+
+var filter: String:
+ set(next_filter):
+ filter = next_filter
+ apply_filter()
+ get:
+ return filter
+
+
+func _ready() -> void:
+ apply_theme()
+
+ filter_edit.placeholder_text = DialogueConstants.translate(&"files_list.filter")
+
+
+func focus_filter() -> void:
+ filter_edit.grab_focus()
+
+
+func select_file(file: String) -> void:
+ list.deselect_all()
+ for i in range(0, list.get_item_count()):
+ var item_text = list.get_item_text(i).replace(MODIFIED_SUFFIX, "")
+ if item_text == get_nice_file(file, item_text.count("/") + 1):
+ list.select(i)
+
+
+func mark_file_as_unsaved(file: String, is_unsaved: bool) -> void:
+ if not file in unsaved_files and is_unsaved:
+ unsaved_files.append(file)
+ elif file in unsaved_files and not is_unsaved:
+ unsaved_files.erase(file)
+ apply_filter()
+
+
+func update_file_map() -> void:
+ file_map = {}
+ for file in files:
+ var nice_file: String = get_nice_file(file)
+
+ # See if a value with just the file name is already in the map
+ for key in file_map.keys():
+ if file_map[key] == nice_file:
+ var bit_count = nice_file.count("/") + 2
+
+ var existing_nice_file = get_nice_file(key, bit_count)
+ nice_file = get_nice_file(file, bit_count)
+
+ while nice_file == existing_nice_file:
+ bit_count += 1
+ existing_nice_file = get_nice_file(key, bit_count)
+ nice_file = get_nice_file(file, bit_count)
+
+ file_map[key] = existing_nice_file
+
+ file_map[file] = nice_file
+
+
+func get_nice_file(file_path: String, path_bit_count: int = 1) -> String:
+ var bits = file_path.replace("res://", "").replace(".dialogue", "").split("/")
+ bits = bits.slice(-path_bit_count)
+ return "/".join(bits)
+
+
+func apply_filter() -> void:
+ list.clear()
+ for file in file_map.keys():
+ if filter == "" or filter.to_lower() in file.to_lower():
+ var nice_file = file_map[file]
+ if file in unsaved_files:
+ nice_file += MODIFIED_SUFFIX
+ var new_id := list.add_item(nice_file)
+ list.set_item_icon(new_id, icon)
+
+ select_file(current_file_path)
+
+
+func apply_theme() -> void:
+ if is_instance_valid(filter_edit):
+ filter_edit.right_icon = get_theme_icon("Search", "EditorIcons")
+
+
+### Signals
+
+
+func _on_theme_changed() -> void:
+ apply_theme()
+
+
+func _on_filter_edit_text_changed(new_text: String) -> void:
+ self.filter = new_text
+
+
+func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
+ var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
+ var file = file_map.find_key(item_text)
+
+ if mouse_button_index == MOUSE_BUTTON_LEFT or mouse_button_index == MOUSE_BUTTON_RIGHT:
+ select_file(file)
+ file_selected.emit(file)
+ if mouse_button_index == MOUSE_BUTTON_RIGHT:
+ file_popup_menu_requested.emit(at_position)
+
+ if mouse_button_index == MOUSE_BUTTON_MIDDLE:
+ file_middle_clicked.emit(file)
+
+
+func _on_list_item_activated(index: int) -> void:
+ var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
+ var file = file_map.find_key(item_text)
+ select_file(file)
+ file_double_clicked.emit(file)
diff --git a/addons/dialogue_manager/components/files_list.tscn b/addons/dialogue_manager/components/files_list.tscn
new file mode 100644
index 00000000..9d6f8547
--- /dev/null
+++ b/addons/dialogue_manager/components/files_list.tscn
@@ -0,0 +1,28 @@
+[gd_scene load_steps=3 format=3 uid="uid://dnufpcdrreva3"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/files_list.gd" id="1_cytii"]
+[ext_resource type="Texture2D" uid="uid://d3lr2uas6ax8v" path="res://addons/dialogue_manager/assets/icon.svg" id="2_3ijx1"]
+
+[node name="FilesList" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_cytii")
+icon = ExtResource("2_3ijx1")
+
+[node name="FilterEdit" type="LineEdit" parent="."]
+layout_mode = 2
+placeholder_text = "Filter files"
+clear_button_enabled = true
+
+[node name="List" type="ItemList" parent="."]
+layout_mode = 2
+size_flags_vertical = 3
+allow_rmb_select = true
+
+[connection signal="theme_changed" from="." to="." method="_on_theme_changed"]
+[connection signal="text_changed" from="FilterEdit" to="." method="_on_filter_edit_text_changed"]
+[connection signal="item_activated" from="List" to="." method="_on_list_item_activated"]
+[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"]
diff --git a/addons/dialogue_manager/components/find_in_files.gd b/addons/dialogue_manager/components/find_in_files.gd
new file mode 100644
index 00000000..916b744a
--- /dev/null
+++ b/addons/dialogue_manager/components/find_in_files.gd
@@ -0,0 +1,229 @@
+@tool
+extends Control
+
+signal result_selected(path: String, cursor: Vector2, length: int)
+
+
+const DialogueConstants = preload("../constants.gd")
+
+
+@export var main_view: Control
+@export var code_edit: CodeEdit
+
+@onready var input: LineEdit = %Input
+@onready var search_button: Button = %SearchButton
+@onready var match_case_button: CheckBox = %MatchCaseButton
+@onready var replace_toggle: CheckButton = %ReplaceToggle
+@onready var replace_container: VBoxContainer = %ReplaceContainer
+@onready var replace_input: LineEdit = %ReplaceInput
+@onready var replace_selected_button: Button = %ReplaceSelectedButton
+@onready var replace_all_button: Button = %ReplaceAllButton
+@onready var results_container: VBoxContainer = %ResultsContainer
+@onready var result_template: HBoxContainer = %ResultTemplate
+
+var current_results: Dictionary = {}:
+ set(value):
+ current_results = value
+ update_results_view()
+ if current_results.size() == 0:
+ replace_selected_button.disabled = true
+ replace_all_button.disabled = true
+ else:
+ replace_selected_button.disabled = false
+ replace_all_button.disabled = false
+ get:
+ return current_results
+
+var selections: PackedStringArray = []
+
+
+func prepare() -> void:
+ input.grab_focus()
+
+ var template_label = result_template.get_node("Label")
+ template_label.get_theme_stylebox(&"focus").bg_color = code_edit.theme_overrides.current_line_color
+ template_label.add_theme_font_override(&"normal_font", code_edit.get_theme_font(&"font"))
+
+ replace_toggle.set_pressed_no_signal(false)
+ replace_container.hide()
+
+ $VBoxContainer/HBoxContainer/FindContainer/Label.text = DialogueConstants.translate(&"search.find")
+ input.placeholder_text = DialogueConstants.translate(&"search.placeholder")
+ input.text = ""
+ search_button.text = DialogueConstants.translate(&"search.find_all")
+ match_case_button.text = DialogueConstants.translate(&"search.match_case")
+ replace_toggle.text = DialogueConstants.translate(&"search.toggle_replace")
+ $VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceLabel.text = DialogueConstants.translate(&"search.replace_with")
+ replace_input.placeholder_text = DialogueConstants.translate(&"search.replace_placeholder")
+ replace_input.text = ""
+ replace_all_button.text = DialogueConstants.translate(&"search.replace_all")
+ replace_selected_button.text = DialogueConstants.translate(&"search.replace_selected")
+
+ selections.clear()
+ self.current_results = {}
+
+#region helpers
+
+
+func update_results_view() -> void:
+ for child in results_container.get_children():
+ child.queue_free()
+
+ for path in current_results.keys():
+ var path_label: Label = Label.new()
+ path_label.text = path
+ # Show open files
+ if main_view.open_buffers.has(path):
+ path_label.text += "(*)"
+ results_container.add_child(path_label)
+ for path_result in current_results.get(path):
+ var result_item: HBoxContainer = result_template.duplicate()
+
+ var checkbox: CheckBox = result_item.get_node("CheckBox") as CheckBox
+ var key: String = get_selection_key(path, path_result)
+ checkbox.toggled.connect(func(is_pressed):
+ if is_pressed:
+ if not selections.has(key):
+ selections.append(key)
+ else:
+ if selections.has(key):
+ selections.remove_at(selections.find(key))
+ )
+ checkbox.set_pressed_no_signal(selections.has(key))
+ checkbox.visible = replace_toggle.button_pressed
+
+ var result_label: RichTextLabel = result_item.get_node("Label") as RichTextLabel
+ var colors: Dictionary = code_edit.theme_overrides
+ var highlight: String = ""
+ if replace_toggle.button_pressed:
+ var matched_word: String = "[bgcolor=" + colors.critical_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + path_result.matched_text + "[/color][/bgcolor]"
+ highlight = "[s]" + matched_word + "[/s][bgcolor=" + colors.notice_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + replace_input.text + "[/color][/bgcolor]"
+ else:
+ highlight = "[bgcolor=" + colors.symbols_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + path_result.matched_text + "[/color][/bgcolor]"
+ var text: String = path_result.text.substr(0, path_result.index) + highlight + path_result.text.substr(path_result.index + path_result.query.length())
+ result_label.text = "%s: %s" % [str(path_result.line).lpad(4), text]
+ result_label.gui_input.connect(func(event):
+ if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).double_click:
+ result_selected.emit(path, Vector2(path_result.index, path_result.line), path_result.query.length())
+ )
+
+ results_container.add_child(result_item)
+
+
+func find_in_files() -> Dictionary:
+ var results: Dictionary = {}
+
+ var q: String = input.text
+ var cache = Engine.get_meta("DialogueCache")
+ var file: FileAccess
+ for path in cache.get_files():
+ var path_results: Array = []
+ var lines: PackedStringArray = []
+
+ if main_view.open_buffers.has(path):
+ lines = main_view.open_buffers.get(path).text.split("\n")
+ else:
+ file = FileAccess.open(path, FileAccess.READ)
+ lines = file.get_as_text().split("\n")
+
+ for i in range(0, lines.size()):
+ var index: int = find_in_line(lines[i], q)
+ while index > -1:
+ path_results.append({
+ line = i,
+ index = index,
+ text = lines[i],
+ matched_text = lines[i].substr(index, q.length()),
+ query = q
+ })
+ index = find_in_line(lines[i], q, index + q.length())
+
+ if file != null and file.is_open():
+ file.close()
+
+ if path_results.size() > 0:
+ results[path] = path_results
+
+ return results
+
+
+func get_selection_key(path: String, path_result: Dictionary) -> String:
+ return "%s-%d-%d" % [path, path_result.line, path_result.index]
+
+
+func find_in_line(line: String, query: String, from_index: int = 0) -> int:
+ if match_case_button.button_pressed:
+ return line.find(query, from_index)
+ else:
+ return line.findn(query, from_index)
+
+
+func replace_results(only_selected: bool) -> void:
+ var file: FileAccess
+ var lines: PackedStringArray = []
+ for path in current_results:
+ if main_view.open_buffers.has(path):
+ lines = main_view.open_buffers.get(path).text.split("\n")
+ else:
+ file = FileAccess.open(path, FileAccess.READ_WRITE)
+ lines = file.get_as_text().split("\n")
+
+ # Read the results in reverse because we're going to be modifying them as we go
+ var path_results: Array = current_results.get(path).duplicate()
+ path_results.reverse()
+ for path_result in path_results:
+ var key: String = get_selection_key(path, path_result)
+ if not only_selected or (only_selected and selections.has(key)):
+ lines[path_result.line] = lines[path_result.line].substr(0, path_result.index) + replace_input.text + lines[path_result.line].substr(path_result.index + path_result.matched_text.length())
+
+ var replaced_text: String = "\n".join(lines)
+ if file != null and file.is_open():
+ file.seek(0)
+ file.store_string(replaced_text)
+ file.close()
+ else:
+ main_view.open_buffers.get(path).text = replaced_text
+ if main_view.current_file_path == path:
+ code_edit.text = replaced_text
+
+ current_results = find_in_files()
+
+
+#endregion
+
+#region signals
+
+
+func _on_search_button_pressed() -> void:
+ selections.clear()
+ self.current_results = find_in_files()
+
+
+func _on_input_text_submitted(new_text: String) -> void:
+ _on_search_button_pressed()
+
+
+func _on_replace_toggle_toggled(toggled_on: bool) -> void:
+ replace_container.visible = toggled_on
+ if toggled_on:
+ replace_input.grab_focus()
+ update_results_view()
+
+
+func _on_replace_input_text_changed(new_text: String) -> void:
+ update_results_view()
+
+
+func _on_replace_selected_button_pressed() -> void:
+ replace_results(true)
+
+
+func _on_replace_all_button_pressed() -> void:
+ replace_results(false)
+
+
+func _on_match_case_button_toggled(toggled_on: bool) -> void:
+ _on_search_button_pressed()
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/find_in_files.tscn b/addons/dialogue_manager/components/find_in_files.tscn
new file mode 100644
index 00000000..8aaef4b9
--- /dev/null
+++ b/addons/dialogue_manager/components/find_in_files.tscn
@@ -0,0 +1,139 @@
+[gd_scene load_steps=3 format=3 uid="uid://0n7hwviyyly4"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/find_in_files.gd" id="1_3xicy"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_owohg"]
+bg_color = Color(0.266667, 0.278431, 0.352941, 0.243137)
+corner_detail = 1
+
+[node name="FindInFiles" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_3xicy")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="FindContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer/FindContainer"]
+layout_mode = 2
+text = "Find:"
+
+[node name="Input" type="LineEdit" parent="VBoxContainer/HBoxContainer/FindContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+clear_button_enabled = true
+
+[node name="FindToolbar" type="HBoxContainer" parent="VBoxContainer/HBoxContainer/FindContainer"]
+layout_mode = 2
+
+[node name="SearchButton" type="Button" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Find all..."
+
+[node name="MatchCaseButton" type="CheckBox" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Match case"
+
+[node name="Control" type="Control" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ReplaceToggle" type="CheckButton" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace"
+
+[node name="ReplaceContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ReplaceLabel" type="Label" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
+layout_mode = 2
+text = "Replace with:"
+
+[node name="ReplaceInput" type="LineEdit" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+clear_button_enabled = true
+
+[node name="ReplaceToolbar" type="HBoxContainer" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
+layout_mode = 2
+
+[node name="ReplaceSelectedButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace selected"
+
+[node name="ReplaceAllButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace all"
+
+[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="ReplaceToolbar" type="HBoxContainer" parent="VBoxContainer/VBoxContainer"]
+layout_mode = 2
+
+[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+follow_focus = true
+
+[node name="ResultsContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+theme_override_constants/separation = 0
+
+[node name="ResultTemplate" type="HBoxContainer" parent="."]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 155.0
+offset_top = -74.0
+offset_right = 838.0
+offset_bottom = -51.0
+
+[node name="CheckBox" type="CheckBox" parent="ResultTemplate"]
+layout_mode = 2
+
+[node name="Label" type="RichTextLabel" parent="ResultTemplate"]
+layout_mode = 2
+size_flags_horizontal = 3
+focus_mode = 2
+theme_override_styles/focus = SubResource("StyleBoxFlat_owohg")
+bbcode_enabled = true
+text = "Result"
+fit_content = true
+scroll_active = false
+
+[connection signal="text_submitted" from="VBoxContainer/HBoxContainer/FindContainer/Input" to="." method="_on_input_text_submitted"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/SearchButton" to="." method="_on_search_button_pressed"]
+[connection signal="toggled" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/MatchCaseButton" to="." method="_on_match_case_button_toggled"]
+[connection signal="toggled" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/ReplaceToggle" to="." method="_on_replace_toggle_toggled"]
+[connection signal="text_changed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceInput" to="." method="_on_replace_input_text_changed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar/ReplaceSelectedButton" to="." method="_on_replace_selected_button_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar/ReplaceAllButton" to="." method="_on_replace_all_button_pressed"]
diff --git a/addons/dialogue_manager/components/parse_result.gd b/addons/dialogue_manager/components/parse_result.gd
new file mode 100644
index 00000000..d467cb9a
--- /dev/null
+++ b/addons/dialogue_manager/components/parse_result.gd
@@ -0,0 +1,10 @@
+class_name DialogueManagerParseResult extends RefCounted
+
+var imported_paths: PackedStringArray = []
+var using_states: PackedStringArray = []
+var titles: Dictionary = {}
+var character_names: PackedStringArray = []
+var first_title: String = ""
+var lines: Dictionary = {}
+var errors: Array[Dictionary] = []
+var raw_text: String = ""
diff --git a/addons/dialogue_manager/components/parser.gd b/addons/dialogue_manager/components/parser.gd
new file mode 100644
index 00000000..295ccf84
--- /dev/null
+++ b/addons/dialogue_manager/components/parser.gd
@@ -0,0 +1,1798 @@
+@tool
+
+class_name DialogueManagerParser extends Object
+
+
+const DialogueConstants = preload("../constants.gd")
+const DialogueSettings = preload("../settings.gd")
+const ResolvedLineData = preload("./resolved_line_data.gd")
+const ResolvedTagData = preload("./resolved_tag_data.gd")
+const DialogueManagerParseResult = preload("./parse_result.gd")
+
+
+var IMPORT_REGEX: RegEx = RegEx.create_from_string("import \"(?[^\"]+)\" as (?[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+)")
+var USING_REGEX: RegEx = RegEx.create_from_string("^using (?.*)$")
+var VALID_TITLE_REGEX: RegEx = RegEx.create_from_string("^[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+$")
+var BEGINS_WITH_NUMBER_REGEX: RegEx = RegEx.create_from_string("^\\d")
+var TRANSLATION_REGEX: RegEx = RegEx.create_from_string("\\[ID:(?.*?)\\]")
+var TAGS_REGEX: RegEx = RegEx.create_from_string("\\[#(?.*?)\\]")
+var MUTATION_REGEX: RegEx = RegEx.create_from_string("(?do|do!|set) (?.*)")
+var CONDITION_REGEX: RegEx = RegEx.create_from_string("(if|elif|while|else if) (?.*)")
+var WRAPPED_CONDITION_REGEX: RegEx = RegEx.create_from_string("\\[if (?.*)\\]")
+var REPLACEMENTS_REGEX: RegEx = RegEx.create_from_string("{{(.*?)}}")
+var GOTO_REGEX: RegEx = RegEx.create_from_string("=> (?.*)")
+var INDENT_REGEX: RegEx = RegEx.create_from_string("^\\t+")
+var INLINE_RANDOM_REGEX: RegEx = RegEx.create_from_string("\\[\\[(?.*?)\\]\\]")
+var INLINE_CONDITIONALS_REGEX: RegEx = RegEx.create_from_string("\\[if (?.+?)\\](?.*?)\\[\\/if\\]")
+
+var TOKEN_DEFINITIONS: Dictionary = {
+ DialogueConstants.TOKEN_FUNCTION: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*\\("),
+ DialogueConstants.TOKEN_DICTIONARY_REFERENCE: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*\\["),
+ DialogueConstants.TOKEN_PARENS_OPEN: RegEx.create_from_string("^\\("),
+ DialogueConstants.TOKEN_PARENS_CLOSE: RegEx.create_from_string("^\\)"),
+ DialogueConstants.TOKEN_BRACKET_OPEN: RegEx.create_from_string("^\\["),
+ DialogueConstants.TOKEN_BRACKET_CLOSE: RegEx.create_from_string("^\\]"),
+ DialogueConstants.TOKEN_BRACE_OPEN: RegEx.create_from_string("^\\{"),
+ DialogueConstants.TOKEN_BRACE_CLOSE: RegEx.create_from_string("^\\}"),
+ DialogueConstants.TOKEN_COLON: RegEx.create_from_string("^:"),
+ DialogueConstants.TOKEN_COMPARISON: RegEx.create_from_string("^(==|<=|>=|<|>|!=|in )"),
+ DialogueConstants.TOKEN_ASSIGNMENT: RegEx.create_from_string("^(\\+=|\\-=|\\*=|/=|=)"),
+ DialogueConstants.TOKEN_NUMBER: RegEx.create_from_string("^\\-?\\d+(\\.\\d+)?"),
+ DialogueConstants.TOKEN_OPERATOR: RegEx.create_from_string("^(\\+|\\-|\\*|/|%)"),
+ DialogueConstants.TOKEN_COMMA: RegEx.create_from_string("^,"),
+ DialogueConstants.TOKEN_DOT: RegEx.create_from_string("^\\."),
+ DialogueConstants.TOKEN_STRING: RegEx.create_from_string("^&?(\".*?\"|\'.*?\')"),
+ DialogueConstants.TOKEN_NOT: RegEx.create_from_string("^(not( |$)|!)"),
+ DialogueConstants.TOKEN_AND_OR: RegEx.create_from_string("^(and|or|&&|\\|\\|)( |$)"),
+ DialogueConstants.TOKEN_VARIABLE: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*"),
+ DialogueConstants.TOKEN_COMMENT: RegEx.create_from_string("^#.*"),
+ DialogueConstants.TOKEN_CONDITION: RegEx.create_from_string("^(if|elif|else)"),
+ DialogueConstants.TOKEN_BOOL: RegEx.create_from_string("^(true|false)")
+}
+
+var WEIGHTED_RANDOM_SIBLINGS_REGEX: RegEx = RegEx.create_from_string("^\\%(?[\\d.]+)? ")
+
+var raw_lines: PackedStringArray = []
+var parent_stack: Array[String] = []
+
+var parsed_lines: Dictionary = {}
+var imported_paths: PackedStringArray = []
+var using_states: PackedStringArray = []
+var titles: Dictionary = {}
+var character_names: PackedStringArray = []
+var first_title: String = ""
+var errors: Array[Dictionary] = []
+var raw_text: String = ""
+
+var _imported_line_map: Dictionary = {}
+var _imported_line_count: int = 0
+
+var while_loopbacks: Array[String] = []
+
+
+## Parse some raw dialogue text and return a dictionary containing parse results
+static func parse_string(string: String, path: String) -> DialogueManagerParseResult:
+ var parser = new()
+ var error: Error = parser.parse(string, path)
+ var data: DialogueManagerParseResult = parser.get_data()
+ parser.free()
+
+ if error == OK:
+ return data
+ else:
+ return null
+
+
+## Extract bbcode and other markers from a string
+static func extract_markers_from_string(string: String) -> ResolvedLineData:
+ var parser = new()
+ var markers: ResolvedLineData = parser.extract_markers(string)
+ parser.free()
+
+ return markers
+
+
+## Parse some raw dialogue text and return a dictionary containing parse results
+func parse(text: String, path: String) -> Error:
+ prepare(text, path)
+ raw_text = text
+
+ # Parse all of the content
+ var known_translations = {}
+
+ # Get list of known autoloads
+ var autoload_names: PackedStringArray = get_autoload_names()
+
+ # Keep track of the last doc comment
+ var doc_comments: Array[String] = []
+
+ # Then parse all lines
+ for id in range(0, raw_lines.size()):
+ var raw_line: String = raw_lines[id]
+
+ var line: Dictionary = {
+ id = str(id),
+ next_id = DialogueConstants.ID_NULL
+ }
+
+ # Work out if we are inside a conditional or option or if we just
+ # indented back out of one
+ var indent_size: int = get_indent(raw_line)
+ if indent_size < parent_stack.size() and not is_line_empty(raw_line):
+ for _tab in range(0, parent_stack.size() - indent_size):
+ parent_stack.pop_back()
+
+ # If we are indented then this line should know about its parent
+ if parent_stack.size() > 0:
+ line["parent_id"] = parent_stack.back()
+
+ # Trim any indentation (now that we've calculated it) so we can check
+ # the begining of each line for its type
+ raw_line = raw_line.strip_edges(true, false)
+
+ # Grab translations
+ var translation_key: String = extract_translation(raw_line)
+ if translation_key != "":
+ line["translation_key"] = translation_key
+ raw_line = raw_line.replace("[ID:%s]" % translation_key, "")
+
+ # Check for each kind of line
+
+ # Start shortcuts
+ if raw_line.begins_with("using "):
+ var using_match: RegExMatch = USING_REGEX.search(raw_line)
+ if "state" in using_match.names:
+ var using_state: String = using_match.strings[using_match.names.state].strip_edges()
+ if not using_state in autoload_names:
+ add_error(id, 0, DialogueConstants.ERR_UNKNOWN_USING)
+ elif not using_state in using_states:
+ using_states.append(using_state)
+ continue
+
+ # Response
+ elif is_response_line(raw_line):
+ # Add any doc notes
+ line["notes"] = "\n".join(doc_comments)
+ doc_comments = []
+
+ parent_stack.append(str(id))
+ line["type"] = DialogueConstants.TYPE_RESPONSE
+
+ # Extract any #tags
+ var tag_data: ResolvedTagData = extract_tags(raw_line)
+ line["tags"] = tag_data.tags
+ raw_line = tag_data.line_without_tags
+
+ if " [if " in raw_line:
+ line["condition"] = extract_condition(raw_line, true, indent_size)
+ if " =>" in raw_line:
+ line["next_id"] = extract_goto(raw_line)
+ if " =><" in raw_line:
+ # Because of when the return point needs to be known at runtime we need to split
+ # this line into two (otherwise the return point would be dependent on the balloon)
+ var goto_line: Dictionary = {
+ type = DialogueConstants.TYPE_GOTO,
+ next_id = extract_goto(raw_line),
+ next_id_after = find_next_line_after_responses(id),
+ is_snippet = true
+ }
+ parsed_lines[str(id) + ".1"] = goto_line
+ line["next_id"] = str(id) + ".1"
+
+ # Make sure the added goto line can actually go to somewhere
+ if goto_line.next_id in [DialogueConstants.ID_ERROR, DialogueConstants.ID_ERROR_INVALID_TITLE, DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY]:
+ line["next_id"] = goto_line.next_id
+
+ line["character"] = ""
+ line["character_replacements"] = [] as Array[Dictionary]
+ line["text"] = extract_response_prompt(raw_line)
+
+ var previous_response_id = find_previous_response_id(id)
+ if parsed_lines.has(previous_response_id):
+ var previous_response = parsed_lines[previous_response_id]
+ # Add this response to the list on the first response so that it is the
+ # authority on what is in the list of responses
+ previous_response["responses"] = previous_response["responses"] + PackedStringArray([str(id)])
+ else:
+ # No previous response so this is the first in the list
+ line["responses"] = PackedStringArray([str(id)])
+
+ line["next_id_after"] = find_next_line_after_responses(id)
+
+ # If this response has no body then the next id is the next id after
+ if not line.has("next_id") or line.next_id == DialogueConstants.ID_NULL:
+ var next_nonempty_line_id = get_next_nonempty_line_id(id)
+ if next_nonempty_line_id != DialogueConstants.ID_NULL:
+ if get_indent(raw_lines[next_nonempty_line_id.to_int()]) <= indent_size:
+ line["next_id"] = line.next_id_after
+ else:
+ line["next_id"] = next_nonempty_line_id
+
+ line["text_replacements"] = extract_dialogue_replacements(line.get("text"), indent_size + 2)
+ for replacement in line.text_replacements:
+ if replacement.has("error"):
+ add_error(id, replacement.index, replacement.error)
+
+ # If this response has a character name in it then it will automatically be
+ # injected as a line of dialogue if the player selects it
+ var response_text: String = line.text.replace("\\:", "!ESCAPED_COLON!")
+ if ": " in response_text:
+ if DialogueSettings.get_setting("create_lines_for_responses_with_characters", true):
+ var first_child: Dictionary = {
+ type = DialogueConstants.TYPE_DIALOGUE,
+ next_id = line.next_id,
+ next_id_after = line.next_id_after,
+ text_replacements = line.text_replacements,
+ tags = line.tags,
+ translation_key = line.get("translation_key")
+ }
+ parse_response_character_and_text(id, response_text, first_child, indent_size, parsed_lines)
+ line["character"] = first_child.character
+ line["character_replacements"] = first_child.character_replacements
+ line["text"] = first_child.text
+ line["text_replacements"] = extract_dialogue_replacements(line.text, indent_size + 2)
+ line["translation_key"] = first_child.translation_key
+ parsed_lines[str(id) + ".2"] = first_child
+ line["next_id"] = str(id) + ".2"
+ else:
+ parse_response_character_and_text(id, response_text, line, indent_size, parsed_lines)
+ else:
+ line["text"] = response_text.replace("!ESCAPED_COLON!", ":")
+
+ # Title
+ elif is_title_line(raw_line):
+ line["type"] = DialogueConstants.TYPE_TITLE
+ if not raw_lines[id].begins_with("~"):
+ add_error(id, indent_size + 2, DialogueConstants.ERR_NESTED_TITLE)
+ else:
+ line["text"] = extract_title(raw_line)
+ # Titles can't have numbers as the first letter (unless they are external titles which get replaced with hashes)
+ if id >= _imported_line_count and BEGINS_WITH_NUMBER_REGEX.search(line.text):
+ add_error(id, 2, DialogueConstants.ERR_TITLE_BEGINS_WITH_NUMBER)
+ # Only import titles are allowed to have "/" in them
+ var valid_title = VALID_TITLE_REGEX.search(raw_line.replace("/", "").substr(2).strip_edges())
+ if not valid_title:
+ add_error(id, 2, DialogueConstants.ERR_TITLE_INVALID_CHARACTERS)
+
+ # Condition
+ elif is_condition_line(raw_line, false):
+ parent_stack.append(str(id))
+ line["type"] = DialogueConstants.TYPE_CONDITION
+ line["condition"] = extract_condition(raw_line, false, indent_size)
+ line["next_id_after"] = find_next_line_after_conditions(id)
+ var next_sibling_id = find_next_condition_sibling(id)
+ line["next_conditional_id"] = next_sibling_id if is_valid_id(next_sibling_id) else line.next_id_after
+
+ elif is_condition_line(raw_line, true):
+ parent_stack.append(str(id))
+ line["type"] = DialogueConstants.TYPE_CONDITION
+ line["next_id_after"] = find_next_line_after_conditions(id)
+ line["next_conditional_id"] = line["next_id_after"]
+
+ elif is_while_condition_line(raw_line):
+ parent_stack.append(str(id))
+ line["type"] = DialogueConstants.TYPE_CONDITION
+ line["condition"] = extract_condition(raw_line, false, indent_size)
+ line["next_id_after"] = find_next_line_after_conditions(id)
+ while_loopbacks.append(find_last_line_within_conditions(id))
+ line["next_conditional_id"] = line["next_id_after"]
+
+ # Mutation
+ elif is_mutation_line(raw_line):
+ line["type"] = DialogueConstants.TYPE_MUTATION
+ line["mutation"] = extract_mutation(raw_line)
+
+ # Goto
+ elif is_goto_line(raw_line):
+ line["type"] = DialogueConstants.TYPE_GOTO
+
+ if raw_line.begins_with("%"):
+ apply_weighted_random(id, raw_line, indent_size, line)
+
+ line["next_id"] = extract_goto(raw_line)
+ if is_goto_snippet_line(raw_line):
+ line["is_snippet"] = true
+ line["next_id_after"] = get_line_after_line(id, indent_size, line)
+ else:
+ line["is_snippet"] = false
+
+ # Nested dialogue
+ elif is_nested_dialogue_line(raw_line, parsed_lines, raw_lines, indent_size):
+ var parent_line: Dictionary = parsed_lines.values().back()
+ var parent_indent_size: int = get_indent(raw_lines[parent_line.id.to_int()])
+ var should_update_translation_key: bool = parent_line.translation_key == parent_line.text
+ var suffix: String = raw_line.strip_edges(true, false)
+ if suffix == "":
+ suffix = " "
+ parent_line["text"] += "\n" + suffix
+ parent_line["text_replacements"] = extract_dialogue_replacements(parent_line.text, parent_line.character.length() + 2 + parent_indent_size)
+ for replacement in parent_line.text_replacements:
+ if replacement.has("error"):
+ add_error(id, replacement.index, replacement.error)
+
+ if should_update_translation_key:
+ parent_line["translation_key"] = parent_line.text
+
+ parent_line["next_id"] = get_line_after_line(id, parent_indent_size, parent_line)
+
+ # Ignore this line when checking for indent errors
+ remove_error(parent_line.id.to_int(), DialogueConstants.ERR_INVALID_INDENTATION)
+
+ var next_line = raw_lines[parent_line.next_id.to_int()]
+ if not is_dialogue_line(next_line) and get_indent(next_line) >= indent_size:
+ add_error(parent_line.next_id.to_int(), indent_size, DialogueConstants.ERR_INVALID_INDENTATION)
+
+ continue
+
+ elif raw_line.strip_edges().begins_with("##"):
+ doc_comments.append(raw_line.replace("##", "").strip_edges())
+ continue
+
+ elif is_line_empty(raw_line) or is_import_line(raw_line):
+ continue
+
+ # Regular dialogue
+ else:
+ # Remove escape character
+ if raw_line.begins_with("\\using"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\if"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\elif"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\else"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\while"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\-"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\~"): raw_line = raw_line.substr(1)
+ if raw_line.begins_with("\\=>"): raw_line = raw_line.substr(1)
+
+ # Check for jumps
+ if " => " in raw_line:
+ line["next_id"] = extract_goto(raw_line)
+ raw_line = raw_line.split(" => ")[0]
+
+ # Add any doc notes
+ line["notes"] = "\n".join(doc_comments)
+ doc_comments = []
+
+ # Work out any weighted random siblings
+ if raw_line.begins_with("%"):
+ apply_weighted_random(id, raw_line, indent_size, line)
+ raw_line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line, "")
+
+ line["type"] = DialogueConstants.TYPE_DIALOGUE
+
+ # Extract any tags before we process the line
+ var tag_data: ResolvedTagData = extract_tags(raw_line)
+ line["tags"] = tag_data.tags
+ raw_line = tag_data.line_without_tags
+
+ var l = raw_line.replace("\\:", "!ESCAPED_COLON!")
+ if ": " in l:
+ var bits = Array(l.strip_edges().split(": "))
+ line["character"] = bits.pop_front().strip_edges()
+ if not line["character"] in character_names:
+ character_names.append(line["character"])
+ # You can use variables in the character's name
+ line["character_replacements"] = extract_dialogue_replacements(line.character, indent_size)
+ for replacement in line.character_replacements:
+ if replacement.has("error"):
+ add_error(id, replacement.index, replacement.error)
+ line["text"] = ": ".join(bits).replace("!ESCAPED_COLON!", ":")
+ else:
+ line["character"] = ""
+ line["character_replacements"] = [] as Array[Dictionary]
+ line["text"] = l.replace("!ESCAPED_COLON!", ":")
+
+ line["text_replacements"] = extract_dialogue_replacements(line.text, line.character.length() + 2 + indent_size)
+ for replacement in line.text_replacements:
+ if replacement.has("error"):
+ add_error(id, replacement.index, replacement.error)
+
+ # Unescape any newlines
+ line["text"] = line.text.replace("\\n", "\n").strip_edges()
+
+ # Work out where to go after this line
+ if line.next_id == DialogueConstants.ID_NULL:
+ line["next_id"] = get_line_after_line(id, indent_size, line)
+
+ # Check for duplicate translation keys
+ if line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]:
+ if line.has("translation_key"):
+ if known_translations.has(line.translation_key) and known_translations.get(line.translation_key) != line.text:
+ add_error(id, indent_size, DialogueConstants.ERR_DUPLICATE_ID)
+ else:
+ known_translations[line.translation_key] = line.text
+ else:
+ # Default translations key
+ if DialogueSettings.get_setting("missing_translations_are_errors", false):
+ add_error(id, indent_size, DialogueConstants.ERR_MISSING_ID)
+ else:
+ line["translation_key"] = line.text
+
+ ## Error checking
+
+ # Can't find goto
+ var jump_index: int = raw_line.find("=>")
+ match line.next_id:
+ DialogueConstants.ID_ERROR:
+ add_error(id, jump_index, DialogueConstants.ERR_UNKNOWN_TITLE)
+ DialogueConstants.ID_ERROR_INVALID_TITLE:
+ add_error(id, jump_index, DialogueConstants.ERR_INVALID_TITLE_REFERENCE)
+ DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY:
+ add_error(id, jump_index, DialogueConstants.ERR_TITLE_REFERENCE_HAS_NO_CONTENT)
+
+ # Line after condition isn't indented once to the right
+ if line.type == DialogueConstants.TYPE_CONDITION:
+ if is_valid_id(line.next_id):
+ var next_line: String = raw_lines[line.next_id.to_int()]
+ var next_indent: int = get_indent(next_line)
+ if next_indent != indent_size + 1:
+ add_error(line.next_id.to_int(), next_indent, DialogueConstants.ERR_INVALID_INDENTATION)
+ else:
+ add_error(id, indent_size, DialogueConstants.ERR_INVALID_CONDITION_INDENTATION)
+
+ # Line after normal line is indented to the right
+ elif line.type in [
+ DialogueConstants.TYPE_TITLE,
+ DialogueConstants.TYPE_DIALOGUE,
+ DialogueConstants.TYPE_MUTATION,
+ DialogueConstants.TYPE_GOTO
+ ] and is_valid_id(line.next_id):
+ var next_line = raw_lines[line.next_id.to_int()]
+ if next_line != null and get_indent(next_line) > indent_size:
+ add_error(id, indent_size, DialogueConstants.ERR_INVALID_INDENTATION)
+
+ # Parsing condition failed
+ if line.has("condition") and line.condition.has("error"):
+ add_error(id, line.condition.index, line.condition.error)
+
+ # Parsing mutation failed
+ elif line.has("mutation") and line.mutation.has("error"):
+ add_error(id, line.mutation.index, line.mutation.error)
+
+ # Line failed to parse at all
+ if line.get("type") == DialogueConstants.TYPE_UNKNOWN:
+ add_error(id, 0, DialogueConstants.ERR_UNKNOWN_LINE_SYNTAX)
+
+ # If there are no titles then use the first actual line
+ if first_title == "" and not is_import_line(raw_line):
+ first_title = str(id)
+
+ # If this line is the last line of a while loop, edit the id of its next line
+ if str(id) in while_loopbacks:
+ if is_goto_snippet_line(raw_line):
+ line["next_id_after"] = line["parent_id"]
+ elif is_condition_line(raw_line, true) or is_while_condition_line(raw_line):
+ line["next_conditional_id"] = line["parent_id"]
+ line["next_id_after"] = line["parent_id"]
+ elif is_goto_line(raw_line) or is_title_line(raw_line):
+ pass
+ else:
+ line["next_id"] = line["parent_id"]
+
+ # Done!
+ parsed_lines[str(id)] = line
+
+ # Assume the last line ends the dialogue
+ var last_line: Dictionary = parsed_lines.values()[parsed_lines.values().size() - 1]
+ if last_line.next_id == "":
+ last_line.next_id = DialogueConstants.ID_END
+
+ if errors.size() > 0:
+ return ERR_PARSE_ERROR
+
+ return OK
+
+
+func get_data() -> DialogueManagerParseResult:
+ var data: DialogueManagerParseResult = DialogueManagerParseResult.new()
+ data.imported_paths = imported_paths
+ data.using_states = using_states
+ data.titles = titles
+ data.character_names = character_names
+ data.first_title = first_title
+ data.lines = parsed_lines
+ data.errors = errors
+ data.raw_text = raw_text
+ return data
+
+
+## Get the last parse errors
+func get_errors() -> Array[Dictionary]:
+ return errors
+
+
+## Prepare the parser by collecting all lines and titles
+func prepare(text: String, path: String, include_imported_titles_hashes: bool = true) -> void:
+ using_states = []
+ errors = []
+ imported_paths = []
+ _imported_line_map = {}
+ while_loopbacks = []
+ titles = {}
+ character_names = []
+ first_title = ""
+ raw_lines = text.split("\n")
+
+ # Work out imports
+ var known_imports: Dictionary = {}
+
+ # Include the base file path so that we can get around circular dependencies
+ known_imports[path.hash()] = "."
+
+ var imported_titles: Dictionary = {}
+ for id in range(0, raw_lines.size()):
+ var line = raw_lines[id]
+ if is_import_line(line):
+ var import_data = extract_import_path_and_name(line)
+ var import_hash: int = import_data.path.hash()
+ if import_data.size() > 0:
+ # Keep track of titles so we can add imported ones later
+ if str(import_hash) in imported_titles.keys():
+ add_error(id, 0, DialogueConstants.ERR_FILE_ALREADY_IMPORTED)
+ if import_data.prefix in imported_titles.values():
+ add_error(id, 0, DialogueConstants.ERR_DUPLICATE_IMPORT_NAME)
+ imported_titles[str(import_hash)] = import_data.prefix
+
+ # Import the file content
+ if not known_imports.has(import_hash):
+ var error: Error = import_content(import_data.path, import_data.prefix, _imported_line_map, known_imports)
+ if error != OK:
+ add_error(id, 0, error)
+
+ # Make a map so we can refer compiled lines to where they were imported from
+ if not _imported_line_map.has(import_hash):
+ _imported_line_map[import_hash] = {
+ hash = import_hash,
+ imported_on_line_number = id,
+ from_line = 0,
+ to_line = 0
+ }
+
+ var imported_content: String = ""
+ var cummulative_line_number: int = 0
+ for item in _imported_line_map.values():
+ item["from_line"] = cummulative_line_number
+ if known_imports.has(item.hash):
+ cummulative_line_number += known_imports[item.hash].split("\n").size()
+ item["to_line"] = cummulative_line_number
+ if known_imports.has(item.hash):
+ imported_content += known_imports[item.hash] + "\n"
+
+ _imported_line_count = cummulative_line_number + 1
+
+ # Join it with the actual content
+ raw_lines = (imported_content + "\n" + text).split("\n")
+
+ # Find all titles first
+ for id in range(0, raw_lines.size()):
+ if raw_lines[id].begins_with("~ "):
+ var title: String = extract_title(raw_lines[id])
+ if title == "":
+ add_error(id, 2, DialogueConstants.ERR_EMPTY_TITLE)
+ elif titles.has(title):
+ add_error(id, 2, DialogueConstants.ERR_DUPLICATE_TITLE)
+ else:
+ var next_nonempty_line_id: String = get_next_nonempty_line_id(id)
+ if next_nonempty_line_id != DialogueConstants.ID_NULL:
+ titles[title] = next_nonempty_line_id
+ if "/" in title:
+ if include_imported_titles_hashes == false:
+ titles.erase(title)
+ var bits: PackedStringArray = title.split("/")
+ if imported_titles.has(bits[0]):
+ title = imported_titles[bits[0]] + "/" + bits[1]
+ titles[title] = next_nonempty_line_id
+ elif first_title == "":
+ first_title = next_nonempty_line_id
+ else:
+ titles[title] = DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY
+
+
+func add_error(line_number: int, column_number: int, error: int) -> void:
+ # See if the error was in an imported file
+ for item in _imported_line_map.values():
+ if line_number < item.to_line:
+ errors.append({
+ line_number = item.imported_on_line_number,
+ column_number = 0,
+ error = DialogueConstants.ERR_ERRORS_IN_IMPORTED_FILE,
+ external_error = error,
+ external_line_number = line_number
+ })
+ return
+
+ # Otherwise, it's in this file
+ errors.append({
+ line_number = line_number - _imported_line_count,
+ column_number = column_number,
+ error = error
+ })
+
+
+func remove_error(line_number: int, error: int) -> void:
+ for i in range(errors.size() - 1, -1, -1):
+ var err = errors[i]
+ var is_native_error = err.line_number == line_number - _imported_line_count and err.error == error
+ var is_external_error = err.get("external_line_number") == line_number and err.get("external_error") == error
+ if is_native_error or is_external_error:
+ errors.remove_at(i)
+ return
+
+
+func is_import_line(line: String) -> bool:
+ return line.begins_with("import ") and " as " in line
+
+
+func is_title_line(line: String) -> bool:
+ return line.strip_edges(true, false).begins_with("~ ")
+
+
+func is_condition_line(line: String, include_else: bool = true) -> bool:
+ line = line.strip_edges(true, false)
+ if line.begins_with("if ") or line.begins_with("elif ") or line.begins_with("else if"): return true
+ if include_else and line.begins_with("else"): return true
+ return false
+
+func is_while_condition_line(line: String) -> bool:
+ line = line.strip_edges(true, false)
+ if line.begins_with("while "): return true
+ return false
+
+
+func is_mutation_line(line: String) -> bool:
+ line = line.strip_edges(true, false)
+ return line.begins_with("do ") or line.begins_with("do! ") or line.begins_with("set ")
+
+
+func is_goto_line(line: String) -> bool:
+ line = line.strip_edges(true, false)
+ line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(line, "")
+ return line.begins_with("=> ") or line.begins_with("=>< ")
+
+
+func is_goto_snippet_line(line: String) -> bool:
+ line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(line.strip_edges(), "")
+ return line.begins_with("=>< ")
+
+
+func is_nested_dialogue_line(raw_line: String, parsed_lines: Dictionary, raw_lines: PackedStringArray, indent_size: int) -> bool:
+ if parsed_lines.values().is_empty(): return false
+ if raw_line.strip_edges().begins_with("#"): return false
+
+ var parent_line: Dictionary = parsed_lines.values().back()
+ if parent_line.type != DialogueConstants.TYPE_DIALOGUE: return false
+ if get_indent(raw_lines[parent_line.id.to_int()]) >= indent_size: return false
+ return true
+
+
+func is_dialogue_line(line: String) -> bool:
+ if line == null: return false
+ if is_response_line(line): return false
+ if is_title_line(line): return false
+ if is_condition_line(line, true): return false
+ if is_mutation_line(line): return false
+ if is_goto_line(line): return false
+ return true
+
+
+func is_response_line(line: String) -> bool:
+ return line.strip_edges(true, false).begins_with("- ")
+
+
+func is_valid_id(id: String) -> bool:
+ return false if id in [DialogueConstants.ID_NULL, DialogueConstants.ID_ERROR, DialogueConstants.ID_END_CONVERSATION] else true
+
+
+func is_line_empty(line: String) -> bool:
+ line = line.strip_edges()
+
+ if line == "": return true
+ if line == "endif": return true
+ if line.begins_with("#"): return true
+
+ return false
+
+
+func get_line_after_line(id: int, indent_size: int, line: Dictionary) -> String:
+ # Unless the next line is an outdent we can assume it comes next
+ var next_nonempty_line_id = get_next_nonempty_line_id(id)
+ if next_nonempty_line_id != DialogueConstants.ID_NULL \
+ and indent_size <= get_indent(raw_lines[next_nonempty_line_id.to_int()]):
+ return next_nonempty_line_id
+ # Otherwise, we grab the ID from the parents next ID after children
+ elif line.has("parent_id") and parsed_lines.has(line.parent_id):
+ return parsed_lines[line.parent_id].next_id_after
+
+ else:
+ return DialogueConstants.ID_NULL
+
+
+func get_indent(line: String) -> int:
+ var tabs: RegExMatch = INDENT_REGEX.search(line)
+ if tabs:
+ return tabs.get_string().length()
+ else:
+ return 0
+
+
+func get_next_nonempty_line_id(line_number: int) -> String:
+ for i in range(line_number + 1, raw_lines.size()):
+ if not is_line_empty(raw_lines[i]):
+ return str(i)
+ return DialogueConstants.ID_NULL
+
+
+func find_previous_response_id(line_number: int) -> String:
+ var line = raw_lines[line_number]
+ var indent_size = get_indent(line)
+
+ # Look back up the list to find the previous response
+ var last_found_response_id: String = str(line_number)
+
+ for i in range(line_number - 1, -1, -1):
+ line = raw_lines[i]
+
+ if is_line_empty(line): continue
+
+ # If its a response at the same indent level then its a match
+ elif get_indent(line) == indent_size:
+ if line.strip_edges().begins_with("- "):
+ last_found_response_id = str(i)
+ else:
+ return last_found_response_id
+
+ # Return itself if nothing was found
+ return last_found_response_id
+
+
+func apply_weighted_random(id: int, raw_line: String, indent_size: int, line: Dictionary) -> void:
+ var weight: float = 1
+ var found = WEIGHTED_RANDOM_SIBLINGS_REGEX.search(raw_line)
+ if found and found.names.has("weight"):
+ weight = found.strings[found.names.weight].to_float()
+
+ # Look back up the list to find the first weighted random line in this group
+ var original_random_line: Dictionary = {}
+ for i in range(id, 0, -1):
+ # Ignore doc comment lines
+ if raw_lines[i].strip_edges().begins_with("##"):
+ continue
+ # Lines that aren't prefixed with the random token are a dead end
+ if not raw_lines[i].strip_edges().begins_with("%") or get_indent(raw_lines[i]) != indent_size:
+ break
+ # Make sure we group random dialogue and random lines separately
+ elif WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line.strip_edges(), "").begins_with("=") != WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_lines[i].strip_edges(), "").begins_with("="):
+ break
+ # Otherwise we've found the origin
+ elif parsed_lines.has(str(i)) and parsed_lines[str(i)].has("siblings"):
+ original_random_line = parsed_lines[str(i)]
+ break
+
+ # Attach it to the original random line and work out where to go after the line
+ if original_random_line.size() > 0:
+ original_random_line["siblings"] += [{ weight = weight, id = str(id) }]
+ if original_random_line.type != DialogueConstants.TYPE_GOTO:
+ # Update the next line for all siblings (not goto lines, though, they manage their
+ # own next ID)
+ original_random_line["next_id"] = get_line_after_line(id, indent_size, line)
+ for sibling in original_random_line["siblings"]:
+ if sibling.id in parsed_lines:
+ parsed_lines[sibling.id]["next_id"] = original_random_line["next_id"]
+ elif original_random_line.has("next_id_after"):
+ original_random_line["next_id_after"] = get_line_after_line(id, indent_size, line)
+ for sibling in original_random_line["siblings"]:
+ if sibling.id in parsed_lines:
+ parsed_lines[sibling.id]["next_id_after"] = original_random_line["next_id_after"]
+
+ line["next_id"] = original_random_line.next_id
+ # Or set up this line as the original
+ else:
+ line["siblings"] = [{ weight = weight, id = str(id) }]
+ line["next_id"] = get_line_after_line(id, indent_size, line)
+
+ if line.next_id == DialogueConstants.ID_NULL:
+ line["next_id"] = DialogueConstants.ID_END
+
+
+func find_next_condition_sibling(line_number: int) -> String:
+ var line = raw_lines[line_number]
+ var expected_indent = get_indent(line)
+
+ # Look down the list and find an elif or else at the same indent level
+ for i in range(line_number + 1, raw_lines.size()):
+ line = raw_lines[i]
+ if is_line_empty(line): continue
+
+ var l = line.strip_edges()
+ if l.begins_with("~ "):
+ return DialogueConstants.ID_END_CONVERSATION
+
+ elif get_indent(line) < expected_indent:
+ return DialogueConstants.ID_NULL
+
+ elif get_indent(line) == expected_indent:
+ # Found an if, which begins a different block
+ if l.begins_with("if"):
+ return DialogueConstants.ID_NULL
+
+ # Found what we're looking for
+ elif (l.begins_with("elif ") or l.begins_with("else")):
+ return str(i)
+
+ return DialogueConstants.ID_NULL
+
+
+func find_next_line_after_conditions(line_number: int) -> String:
+ var line = raw_lines[line_number]
+ var expected_indent = get_indent(line)
+
+ # Look down the list for the first non condition line at the same or less indent level
+ for i in range(line_number + 1, raw_lines.size()):
+ line = raw_lines[i]
+
+ if is_line_empty(line): continue
+
+ var line_indent = get_indent(line)
+ line = line.strip_edges()
+
+ if is_title_line(line):
+ return get_next_nonempty_line_id(i)
+
+ elif line_indent > expected_indent:
+ continue
+
+ elif line_indent == expected_indent:
+ if line.begins_with("elif ") or line.begins_with("else"):
+ continue
+ else:
+ return str(i)
+
+ elif line_indent < expected_indent:
+ # We have to check the parent of this block
+ for p in range(line_number - 1, -1, -1):
+ line = raw_lines[p]
+
+ if is_line_empty(line): continue
+
+ line_indent = get_indent(line)
+ if line_indent < expected_indent:
+ return parsed_lines[str(p)].get("next_id_after", DialogueConstants.ID_NULL)
+
+ return DialogueConstants.ID_END_CONVERSATION
+
+func find_last_line_within_conditions(line_number: int) -> String:
+ var line = raw_lines[line_number]
+ var expected_indent = get_indent(line)
+
+ var candidate = DialogueConstants.ID_NULL
+
+ # Look down the list for the last line that has an indent level 1 more than this line
+ # Ending the search when you find a line the same or less indent level
+ for i in range(line_number + 1, raw_lines.size()):
+ line = raw_lines[i]
+
+ if is_line_empty(line): continue
+
+ var line_indent = get_indent(line)
+ line = line.strip_edges()
+
+ if line_indent > expected_indent + 1:
+ continue
+ elif line_indent == (expected_indent + 1):
+ candidate = i
+ else:
+ break
+
+ return str(candidate)
+
+func find_next_line_after_responses(line_number: int) -> String:
+ var line = raw_lines[line_number]
+ var expected_indent = get_indent(line)
+
+ # Find the first line after this one that has a smaller indent that isn't another option
+ # If we hit the eof then we give up
+ for i in range(line_number + 1, raw_lines.size()):
+ line = raw_lines[i]
+
+ if is_line_empty(line): continue
+
+ var indent = get_indent(line)
+
+ line = line.strip_edges()
+
+ # We hit a title so the next line is a new start
+ if is_title_line(line):
+ return get_next_nonempty_line_id(i)
+
+ # Another option
+ elif line.begins_with("- "):
+ if indent == expected_indent:
+ # ...at the same level so we continue
+ continue
+ elif indent < expected_indent:
+ # ...outdented so check the previous parent
+ var previous_parent = parent_stack[parent_stack.size() - 2]
+ if parsed_lines.has(str(previous_parent)):
+ return parsed_lines[str(previous_parent)].next_id_after
+ else:
+ return DialogueConstants.ID_NULL
+
+ # We're at the end of a conditional so jump back up to see what's after it
+ elif line.begins_with("elif ") or line.begins_with("else"):
+ for p in range(line_number - 1, -1, -1):
+ line = raw_lines[p]
+
+ if is_line_empty(line): continue
+
+ var line_indent = get_indent(line)
+ if line_indent < expected_indent:
+ return parsed_lines[str(p)].next_id_after
+
+ # Otherwise check the indent for an outdent
+ else:
+ line_number = i
+ line = raw_lines[line_number]
+ if get_indent(line) <= expected_indent:
+ return str(line_number)
+
+ # EOF so it's also the end of a block
+ return DialogueConstants.ID_END
+
+
+## Get the names of any autoloads in the project
+func get_autoload_names() -> PackedStringArray:
+ var autoloads: PackedStringArray = []
+
+ var project = ConfigFile.new()
+ project.load("res://project.godot")
+ if project.has_section("autoload"):
+ return Array(project.get_section_keys("autoload")).filter(func(key): return key != "DialogueManager")
+
+ return autoloads
+
+
+## Import content from another dialogue file or return an ERR
+func import_content(path: String, prefix: String, imported_line_map: Dictionary, known_imports: Dictionary) -> Error:
+ if FileAccess.file_exists(path):
+ var file = FileAccess.open(path, FileAccess.READ)
+ var content: PackedStringArray = file.get_as_text().split("\n")
+
+ var imported_titles: Dictionary = {}
+
+ for index in range(0, content.size()):
+ var line = content[index]
+ if is_import_line(line):
+ var import = extract_import_path_and_name(line)
+ if import.size() > 0:
+ if not known_imports.has(import.path.hash()):
+ # Add an empty record into the keys just so we don't end up with cyclic dependencies
+ known_imports[import.path.hash()] = ""
+ if import_content(import.path, import.prefix, imported_line_map, known_imports) != OK:
+ return ERR_LINK_FAILED
+
+ if not imported_line_map.has(import.path.hash()):
+ # Make a map so we can refer compiled lines to where they were imported from
+ imported_line_map[import.path.hash()] = {
+ hash = import.path.hash(),
+ imported_on_line_number = index,
+ from_line = 0,
+ to_line = 0
+ }
+
+ imported_titles[import.prefix] = import.path.hash()
+
+ var origin_hash: int = -1
+ for hash_value in known_imports.keys():
+ if known_imports[hash_value] == ".":
+ origin_hash = hash_value
+
+ # Replace any titles or jump points with references to the files they point to (event if they point to their own file)
+ for i in range(0, content.size()):
+ var line = content[i]
+ if is_title_line(line):
+ var title = extract_title(line)
+ if "/" in line:
+ var bits = title.split("/")
+ content[i] = "~ %s/%s" % [imported_titles[bits[0]], bits[1]]
+ else:
+ content[i] = "~ %s/%s" % [str(path.hash()), title]
+
+ elif "=>< " in line:
+ var jump: String = line.substr(line.find("=>< ") + "=>< ".length()).strip_edges()
+ if "/" in jump:
+ var bits: PackedStringArray = jump.split("/")
+ var title_hash: int = imported_titles[bits[0]]
+ if title_hash == origin_hash:
+ content[i] = "%s=>< %s" % [line.split("=>< ")[0], bits[1]]
+ else:
+ content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], title_hash, bits[1]]
+
+ elif not jump in ["END", "END!"]:
+ content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], str(path.hash()), jump]
+
+ elif "=> " in line:
+ var jump: String = line.substr(line.find("=> ") + "=> ".length()).strip_edges()
+ if "/" in jump:
+ var bits: PackedStringArray = jump.split("/")
+ var title_hash: int = imported_titles[bits[0]]
+ if title_hash == origin_hash:
+ content[i] = "%s=> %s" % [line.split("=> ")[0], bits[1]]
+ else:
+ content[i] = "%s=> %s/%s" % [line.split("=> ")[0], title_hash, bits[1]]
+
+ elif not jump in ["END", "END!"]:
+ content[i] = "%s=> %s/%s" % [line.split("=> ")[0], str(path.hash()), jump]
+
+ imported_paths.append(path)
+ known_imports[path.hash()] = "\n".join(content) + "\n=> END\n"
+ return OK
+ else:
+ return ERR_FILE_NOT_FOUND
+
+
+func extract_import_path_and_name(line: String) -> Dictionary:
+ var found: RegExMatch = IMPORT_REGEX.search(line)
+ if found:
+ return {
+ path = found.strings[found.names.path],
+ prefix = found.strings[found.names.prefix]
+ }
+ else:
+ return {}
+
+
+func extract_title(line: String) -> String:
+ return line.substr(2).strip_edges()
+
+
+func extract_translation(line: String) -> String:
+ # Find a static translation key, eg. [ID:something]
+ var found: RegExMatch = TRANSLATION_REGEX.search(line)
+ if found:
+ return found.strings[found.names.tr]
+ else:
+ return ""
+
+
+func extract_response_prompt(line: String) -> String:
+ # Find just the text prompt from a response, ignoring any conditions or gotos
+ line = line.substr(2)
+ if " [if " in line:
+ line = line.substr(0, line.find(" [if "))
+ if " =>" in line:
+ line = line.substr(0, line.find(" =>"))
+
+ # Without the translation key if there is one
+ var translation_key: String = extract_translation(line)
+ if translation_key:
+ line = line.replace("[ID:%s]" % translation_key, "")
+
+ return line.replace("\\n", "\n").strip_edges()
+
+
+func parse_response_character_and_text(id: int, text: String, line: Dictionary, indent_size: int, parsed_lines: Dictionary) -> void:
+ var bits = Array(text.strip_edges().split(": "))
+ line["character"] = bits.pop_front().strip_edges()
+ line["character_replacements"] = extract_dialogue_replacements(line.character, line.character.length() + 2 + indent_size)
+ for replacement in line.character_replacements:
+ if replacement.has("error"):
+ add_error(id, replacement.index, replacement.error)
+
+ if not line["character"] in character_names:
+ character_names.append(line["character"])
+
+ line["text"] = ": ".join(bits).replace("!ESCAPED_COLON!", ":").strip_edges()
+
+ if line.get("translation_key", null) == null:
+ line["translation_key"] = line.text
+
+
+func extract_mutation(line: String) -> Dictionary:
+ var found: RegExMatch = MUTATION_REGEX.search(line)
+
+ if not found:
+ return {
+ index = 0,
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+ if found.names.has("mutation"):
+ var expression: Array = tokenise(found.strings[found.names.mutation], DialogueConstants.TYPE_MUTATION, found.get_start("mutation"))
+ if expression.size() == 0:
+ return {
+ index = found.get_start("mutation"),
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DialogueConstants.TYPE_ERROR:
+ return {
+ index = expression[0].index,
+ error = expression[0].value
+ }
+ else:
+ return {
+ expression = expression,
+ is_blocking = not "!" in found.strings[found.names.keyword]
+ }
+
+ else:
+ return {
+ index = found.get_start(),
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+
+func extract_condition(raw_line: String, is_wrapped: bool, index: int) -> Dictionary:
+ var condition: Dictionary = {}
+
+ var regex: RegEx = WRAPPED_CONDITION_REGEX if is_wrapped else CONDITION_REGEX
+ var found: RegExMatch = regex.search(raw_line)
+
+ if found == null:
+ return {
+ index = 0,
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+ var raw_condition: String = found.strings[found.names.condition]
+ var expression: Array = tokenise(raw_condition, DialogueConstants.TYPE_CONDITION, index + found.get_start("condition"))
+
+ if expression.size() == 0:
+ return {
+ index = index + found.get_start("condition"),
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DialogueConstants.TYPE_ERROR:
+ return {
+ index = expression[0].index,
+ error = expression[0].value
+ }
+ else:
+ return {
+ expression = expression
+ }
+
+
+func extract_dialogue_replacements(text: String, index: int) -> Array[Dictionary]:
+ var founds: Array[RegExMatch] = REPLACEMENTS_REGEX.search_all(text)
+
+ if founds == null or founds.size() == 0:
+ return []
+
+ var replacements: Array[Dictionary] = []
+ for found in founds:
+ var replacement: Dictionary = {}
+ var value_in_text: String = found.strings[1]
+ var expression: Array = tokenise(value_in_text, DialogueConstants.TYPE_DIALOGUE, index + found.get_start(1))
+ if expression.size() == 0:
+ replacement = {
+ index = index + found.get_start(1),
+ error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DialogueConstants.TYPE_ERROR:
+ replacement = {
+ index = expression[0].index,
+ error = expression[0].value
+ }
+ else:
+ replacement = {
+ value_in_text = "{{%s}}" % value_in_text,
+ expression = expression
+ }
+ replacements.append(replacement)
+
+ return replacements
+
+
+func extract_goto(line: String) -> String:
+ var found: RegExMatch = GOTO_REGEX.search(line)
+
+ if found == null: return DialogueConstants.ID_ERROR
+
+ var title: String = found.strings[found.names.jump_to_title].strip_edges()
+
+ if " " in title or title == "":
+ return DialogueConstants.ID_ERROR_INVALID_TITLE
+
+ # "=> END!" means end the conversation
+ if title == "END!":
+ return DialogueConstants.ID_END_CONVERSATION
+ # "=> END" means end the current title (and go back to the previous one if there is one
+ # in the stack)
+ elif title == "END":
+ return DialogueConstants.ID_END
+
+ elif titles.has(title):
+ return titles.get(title)
+ else:
+ return DialogueConstants.ID_ERROR
+
+
+func extract_tags(line: String) -> ResolvedTagData:
+ var resolved_tags: PackedStringArray = []
+ var tag_matches: Array[RegExMatch] = TAGS_REGEX.search_all(line)
+ for tag_match in tag_matches:
+ line = line.replace(tag_match.get_string(), "")
+ var tags = tag_match.get_string().replace("[#", "").replace("]", "").replace(", ", ",").split(",")
+ for tag in tags:
+ tag = tag.replace("#", "")
+ if not tag in resolved_tags:
+ resolved_tags.append(tag)
+
+ return ResolvedTagData.new({
+ tags = resolved_tags,
+ line_without_tags = line
+ })
+
+
+func extract_markers(line: String) -> ResolvedLineData:
+ var text: String = line
+ var pauses: Dictionary = {}
+ var speeds: Dictionary = {}
+ var mutations: Array[Array] = []
+ var bbcodes: Array = []
+ var time: String = ""
+
+ # Remove any escaped brackets (ie. "\[")
+ var escaped_open_brackets: PackedInt32Array = []
+ var escaped_close_brackets: PackedInt32Array = []
+ for i in range(0, text.length() - 1):
+ if text.substr(i, 2) == "\\[":
+ text = text.substr(0, i) + "!" + text.substr(i + 2)
+ escaped_open_brackets.append(i)
+ elif text.substr(i, 2) == "\\]":
+ text = text.substr(0, i) + "!" + text.substr(i + 2)
+ escaped_close_brackets.append(i)
+
+ # Extract all of the BB codes so that we know the actual text (we could do this easier with
+ # a RichTextLabel but then we'd need to await idle_frame which is annoying)
+ var bbcode_positions = find_bbcode_positions_in_string(text)
+ var accumulaive_length_offset = 0
+ for position in bbcode_positions:
+ # Ignore our own markers
+ if position.code in ["wait", "speed", "/speed", "do", "do!", "set", "next", "if", "else", "/if"]:
+ continue
+
+ bbcodes.append({
+ bbcode = position.bbcode,
+ start = position.start,
+ offset_start = position.start - accumulaive_length_offset
+ })
+ accumulaive_length_offset += position.bbcode.length()
+
+ for bb in bbcodes:
+ text = text.substr(0, bb.offset_start) + text.substr(bb.offset_start + bb.bbcode.length())
+
+ # Now find any dialogue markers
+ var next_bbcode_position = find_bbcode_positions_in_string(text, false)
+ var limit = 0
+ while next_bbcode_position.size() > 0 and limit < 1000:
+ limit += 1
+
+ var bbcode = next_bbcode_position[0]
+
+ var index = bbcode.start
+ var code = bbcode.code
+ var raw_args = bbcode.raw_args
+ var args = {}
+ if code in ["do", "do!", "set"]:
+ args["value"] = extract_mutation("%s %s" % [code, raw_args])
+ else:
+ # Could be something like:
+ # "=1.0"
+ # " rate=20 level=10"
+ if raw_args and raw_args[0] == "=":
+ raw_args = "value" + raw_args
+ for pair in raw_args.strip_edges().split(" "):
+ if "=" in pair:
+ var bits = pair.split("=")
+ args[bits[0]] = bits[1]
+
+ match code:
+ "wait":
+ if pauses.has(index):
+ pauses[index] += args.get("value").to_float()
+ else:
+ pauses[index] = args.get("value").to_float()
+ "speed":
+ speeds[index] = args.get("value").to_float()
+ "/speed":
+ speeds[index] = 1.0
+ "do", "do!", "set":
+ mutations.append([index, args.get("value")])
+ "next":
+ time = args.get("value") if args.has("value") else "0"
+
+ # Find any BB codes that are after this index and remove the length from their start
+ var length = bbcode.bbcode.length()
+ for bb in bbcodes:
+ if bb.offset_start > bbcode.start:
+ bb.offset_start -= length
+ bb.start -= length
+
+ # Find any escaped brackets after this that need moving
+ for i in range(0, escaped_open_brackets.size()):
+ if escaped_open_brackets[i] > bbcode.start:
+ escaped_open_brackets[i] -= length
+ for i in range(0, escaped_close_brackets.size()):
+ if escaped_close_brackets[i] > bbcode.start:
+ escaped_close_brackets[i] -= length
+
+ text = text.substr(0, index) + text.substr(index + length)
+ next_bbcode_position = find_bbcode_positions_in_string(text, false)
+
+ # Put the BB Codes back in
+ for bb in bbcodes:
+ text = text.insert(bb.start, bb.bbcode)
+
+ # Put the escaped brackets back in
+ for index in escaped_open_brackets:
+ text = text.left(index) + "[" + text.right(text.length() - index - 1)
+ for index in escaped_close_brackets:
+ text = text.left(index) + "]" + text.right(text.length() - index - 1)
+
+ return ResolvedLineData.new({
+ text = text,
+ pauses = pauses,
+ speeds = speeds,
+ mutations = mutations,
+ time = time
+ })
+
+
+func find_bbcode_positions_in_string(string: String, find_all: bool = true) -> Array[Dictionary]:
+ if not "[" in string: return []
+
+ var positions: Array[Dictionary] = []
+
+ var open_brace_count: int = 0
+ var start: int = 0
+ var bbcode: String = ""
+ var code: String = ""
+ var is_finished_code: bool = false
+ for i in range(0, string.length()):
+ if string[i] == "[":
+ if open_brace_count == 0:
+ start = i
+ bbcode = ""
+ code = ""
+ is_finished_code = false
+ open_brace_count += 1
+
+ else:
+ if not is_finished_code and (string[i].to_upper() != string[i] or string[i] == "/" or string[i] == "!"):
+ code += string[i]
+ else:
+ is_finished_code = true
+
+ if open_brace_count > 0:
+ bbcode += string[i]
+
+ if string[i] == "]":
+ open_brace_count -= 1
+ if open_brace_count == 0 and not code in ["if", "else", "/if"]:
+ positions.append({
+ bbcode = bbcode,
+ code = code,
+ start = start,
+ raw_args = bbcode.substr(code.length() + 1, bbcode.length() - code.length() - 2).strip_edges()
+ })
+
+ if not find_all:
+ return positions
+
+ return positions
+
+
+func tokenise(text: String, line_type: String, index: int) -> Array:
+ var tokens: Array[Dictionary] = []
+ var limit: int = 0
+ while text.strip_edges() != "" and limit < 1000:
+ limit += 1
+ var found = find_match(text)
+ if found.size() > 0:
+ tokens.append({
+ index = index,
+ type = found.type,
+ value = found.value
+ })
+ index += found.value.length()
+ text = found.remaining_text
+ elif text.begins_with(" "):
+ index += 1
+ text = text.substr(1)
+ else:
+ return build_token_tree_error(DialogueConstants.ERR_INVALID_EXPRESSION, index)
+
+ return build_token_tree(tokens, line_type, "")[0]
+
+
+func build_token_tree_error(error: int, index: int) -> Array:
+ return [{ type = DialogueConstants.TOKEN_ERROR, value = error, index = index }]
+
+
+func build_token_tree(tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Array:
+ var tree: Array[Dictionary] = []
+ var limit = 0
+ while tokens.size() > 0 and limit < 1000:
+ limit += 1
+ var token = tokens.pop_front()
+
+ var error = check_next_token(token, tokens, line_type, expected_close_token)
+ if error != OK:
+ return [build_token_tree_error(error, token.index), tokens]
+
+ match token.type:
+ DialogueConstants.TOKEN_FUNCTION:
+ var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_PARENS_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR:
+ return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens]
+
+ tree.append({
+ type = DialogueConstants.TOKEN_FUNCTION,
+ # Consume the trailing "("
+ function = token.value.substr(0, token.value.length() - 1),
+ value = tokens_to_list(sub_tree[0])
+ })
+ tokens = sub_tree[1]
+
+ DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
+ var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACKET_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR:
+ return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens]
+
+ var args = tokens_to_list(sub_tree[0])
+ if args.size() != 1:
+ return [build_token_tree_error(DialogueConstants.ERR_INVALID_INDEX, token.index), tokens]
+
+ tree.append({
+ type = DialogueConstants.TOKEN_DICTIONARY_REFERENCE,
+ # Consume the trailing "["
+ variable = token.value.substr(0, token.value.length() - 1),
+ value = args[0]
+ })
+ tokens = sub_tree[1]
+
+ DialogueConstants.TOKEN_BRACE_OPEN:
+ var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACE_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR:
+ return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens]
+
+ var t = sub_tree[0]
+ for i in range(0, t.size() - 2):
+ # Convert Lua style dictionaries to string keys
+ if t[i].type == DialogueConstants.TOKEN_VARIABLE and t[i+1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ t[i].type = DialogueConstants.TOKEN_STRING
+ t[i+1].type = DialogueConstants.TOKEN_COLON
+ t[i+1].erase("value")
+
+ tree.append({
+ type = DialogueConstants.TOKEN_DICTIONARY,
+ value = tokens_to_dictionary(sub_tree[0])
+ })
+
+ tokens = sub_tree[1]
+
+ DialogueConstants.TOKEN_BRACKET_OPEN:
+ var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACKET_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR:
+ return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens]
+
+ var type = DialogueConstants.TOKEN_ARRAY
+ var value = tokens_to_list(sub_tree[0])
+
+ # See if this is referencing a nested dictionary value
+ if tree.size() > 0:
+ var previous_token = tree[tree.size() - 1]
+ if previous_token.type in [DialogueConstants.TOKEN_DICTIONARY_REFERENCE, DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE]:
+ type = DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE
+ value = value[0]
+
+ tree.append({
+ type = type,
+ value = value
+ })
+ tokens = sub_tree[1]
+
+ DialogueConstants.TOKEN_PARENS_OPEN:
+ var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_PARENS_CLOSE)
+
+ if sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR:
+ return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens]
+
+ tree.append({
+ type = DialogueConstants.TOKEN_GROUP,
+ value = sub_tree[0]
+ })
+ tokens = sub_tree[1]
+
+ DialogueConstants.TOKEN_PARENS_CLOSE, \
+ DialogueConstants.TOKEN_BRACE_CLOSE, \
+ DialogueConstants.TOKEN_BRACKET_CLOSE:
+ if token.type != expected_close_token:
+ return [build_token_tree_error(DialogueConstants.ERR_UNEXPECTED_CLOSING_BRACKET, token.index), tokens]
+
+ return [tree, tokens]
+
+ DialogueConstants.TOKEN_NOT:
+ # Double nots negate each other
+ if tokens.size() > 0 and tokens.front().type == DialogueConstants.TOKEN_NOT:
+ tokens.pop_front()
+ else:
+ tree.append({
+ type = token.type
+ })
+
+ DialogueConstants.TOKEN_COMMA, \
+ DialogueConstants.TOKEN_COLON, \
+ DialogueConstants.TOKEN_DOT:
+ tree.append({
+ type = token.type
+ })
+
+ DialogueConstants.TOKEN_COMPARISON, \
+ DialogueConstants.TOKEN_ASSIGNMENT, \
+ DialogueConstants.TOKEN_OPERATOR, \
+ DialogueConstants.TOKEN_AND_OR, \
+ DialogueConstants.TOKEN_VARIABLE:
+ var value = token.value.strip_edges()
+ if value == "&&":
+ value = "and"
+ elif value == "||":
+ value = "or"
+ tree.append({
+ type = token.type,
+ value = value
+ })
+
+ DialogueConstants.TOKEN_STRING:
+ if token.value.begins_with("&"):
+ tree.append({
+ type = token.type,
+ value = StringName(token.value.substr(2, token.value.length() - 3))
+ })
+ else:
+ tree.append({
+ type = token.type,
+ value = token.value.substr(1, token.value.length() - 2)
+ })
+
+ DialogueConstants.TOKEN_CONDITION:
+ return [build_token_tree_error(DialogueConstants.ERR_UNEXPECTED_CONDITION, token.index), token]
+
+ DialogueConstants.TOKEN_BOOL:
+ tree.append({
+ type = token.type,
+ value = token.value.to_lower() == "true"
+ })
+
+ DialogueConstants.TOKEN_NUMBER:
+ var value = token.value.to_float() if "." in token.value else token.value.to_int()
+ # If previous token is a number and this one is a negative number then
+ # inject a minus operator token in between them.
+ if tree.size() > 0 and token.value.begins_with("-") and tree[tree.size() - 1].type == DialogueConstants.TOKEN_NUMBER:
+ tree.append(({
+ type = DialogueConstants.TOKEN_OPERATOR,
+ value = "-"
+ }))
+ tree.append({
+ type = token.type,
+ value = -1 * value
+ })
+ else:
+ tree.append({
+ type = token.type,
+ value = value
+ })
+
+ if expected_close_token != "":
+ var index: int = tokens[0].index if tokens.size() > 0 else 0
+ return [build_token_tree_error(DialogueConstants.ERR_MISSING_CLOSING_BRACKET, index), tokens]
+
+ return [tree, tokens]
+
+
+func check_next_token(token: Dictionary, next_tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Error:
+ var next_token: Dictionary = { type = null }
+ if next_tokens.size() > 0:
+ next_token = next_tokens.front()
+
+ # Guard for assigning in a condition. If the assignment token isn't inside a Lua dictionary
+ # then it's an unexpected assignment in a condition line.
+ if token.type == DialogueConstants.TOKEN_ASSIGNMENT and line_type == DialogueConstants.TYPE_CONDITION and not next_tokens.any(func(t): return t.type == expected_close_token):
+ return DialogueConstants.ERR_UNEXPECTED_ASSIGNMENT
+
+ # Special case for a negative number after this one
+ if token.type == DialogueConstants.TOKEN_NUMBER and next_token.type == DialogueConstants.TOKEN_NUMBER and next_token.value.begins_with("-"):
+ return OK
+
+ var expected_token_types = []
+ var unexpected_token_types = []
+ match token.type:
+ DialogueConstants.TOKEN_FUNCTION, \
+ DialogueConstants.TOKEN_PARENS_OPEN:
+ unexpected_token_types = [
+ null,
+ DialogueConstants.TOKEN_COMMA,
+ DialogueConstants.TOKEN_COLON,
+ DialogueConstants.TOKEN_COMPARISON,
+ DialogueConstants.TOKEN_ASSIGNMENT,
+ DialogueConstants.TOKEN_OPERATOR,
+ DialogueConstants.TOKEN_AND_OR,
+ DialogueConstants.TOKEN_DOT
+ ]
+
+ DialogueConstants.TOKEN_BRACKET_CLOSE:
+ unexpected_token_types = [
+ DialogueConstants.TOKEN_NOT,
+ DialogueConstants.TOKEN_BOOL,
+ DialogueConstants.TOKEN_STRING,
+ DialogueConstants.TOKEN_NUMBER,
+ DialogueConstants.TOKEN_VARIABLE
+ ]
+
+ DialogueConstants.TOKEN_BRACE_OPEN:
+ expected_token_types = [
+ DialogueConstants.TOKEN_STRING,
+ DialogueConstants.TOKEN_VARIABLE,
+ DialogueConstants.TOKEN_NUMBER,
+ DialogueConstants.TOKEN_BRACE_CLOSE
+ ]
+
+ DialogueConstants.TOKEN_PARENS_CLOSE, \
+ DialogueConstants.TOKEN_BRACE_CLOSE:
+ unexpected_token_types = [
+ DialogueConstants.TOKEN_NOT,
+ DialogueConstants.TOKEN_ASSIGNMENT,
+ DialogueConstants.TOKEN_BOOL,
+ DialogueConstants.TOKEN_STRING,
+ DialogueConstants.TOKEN_NUMBER,
+ DialogueConstants.TOKEN_VARIABLE
+ ]
+
+ DialogueConstants.TOKEN_COMPARISON, \
+ DialogueConstants.TOKEN_OPERATOR, \
+ DialogueConstants.TOKEN_COMMA, \
+ DialogueConstants.TOKEN_DOT, \
+ DialogueConstants.TOKEN_NOT, \
+ DialogueConstants.TOKEN_AND_OR, \
+ DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
+ unexpected_token_types = [
+ null,
+ DialogueConstants.TOKEN_COMMA,
+ DialogueConstants.TOKEN_COLON,
+ DialogueConstants.TOKEN_COMPARISON,
+ DialogueConstants.TOKEN_ASSIGNMENT,
+ DialogueConstants.TOKEN_OPERATOR,
+ DialogueConstants.TOKEN_AND_OR,
+ DialogueConstants.TOKEN_PARENS_CLOSE,
+ DialogueConstants.TOKEN_BRACE_CLOSE,
+ DialogueConstants.TOKEN_BRACKET_CLOSE,
+ DialogueConstants.TOKEN_DOT
+ ]
+
+ DialogueConstants.TOKEN_COLON:
+ unexpected_token_types = [
+ DialogueConstants.TOKEN_COMMA,
+ DialogueConstants.TOKEN_COLON,
+ DialogueConstants.TOKEN_COMPARISON,
+ DialogueConstants.TOKEN_ASSIGNMENT,
+ DialogueConstants.TOKEN_OPERATOR,
+ DialogueConstants.TOKEN_AND_OR,
+ DialogueConstants.TOKEN_PARENS_CLOSE,
+ DialogueConstants.TOKEN_BRACE_CLOSE,
+ DialogueConstants.TOKEN_BRACKET_CLOSE,
+ DialogueConstants.TOKEN_DOT
+ ]
+
+ DialogueConstants.TOKEN_BOOL, \
+ DialogueConstants.TOKEN_STRING, \
+ DialogueConstants.TOKEN_NUMBER:
+ unexpected_token_types = [
+ DialogueConstants.TOKEN_NOT,
+ DialogueConstants.TOKEN_ASSIGNMENT,
+ DialogueConstants.TOKEN_BOOL,
+ DialogueConstants.TOKEN_STRING,
+ DialogueConstants.TOKEN_NUMBER,
+ DialogueConstants.TOKEN_VARIABLE,
+ DialogueConstants.TOKEN_FUNCTION,
+ DialogueConstants.TOKEN_PARENS_OPEN,
+ DialogueConstants.TOKEN_BRACE_OPEN,
+ DialogueConstants.TOKEN_BRACKET_OPEN
+ ]
+
+ DialogueConstants.TOKEN_VARIABLE:
+ unexpected_token_types = [
+ DialogueConstants.TOKEN_NOT,
+ DialogueConstants.TOKEN_BOOL,
+ DialogueConstants.TOKEN_STRING,
+ DialogueConstants.TOKEN_NUMBER,
+ DialogueConstants.TOKEN_VARIABLE,
+ DialogueConstants.TOKEN_FUNCTION,
+ DialogueConstants.TOKEN_PARENS_OPEN,
+ DialogueConstants.TOKEN_BRACE_OPEN,
+ DialogueConstants.TOKEN_BRACKET_OPEN
+ ]
+
+ if (expected_token_types.size() > 0 and not next_token.type in expected_token_types or unexpected_token_types.size() > 0 and next_token.type in unexpected_token_types):
+ match next_token.type:
+ null:
+ return DialogueConstants.ERR_UNEXPECTED_END_OF_EXPRESSION
+
+ DialogueConstants.TOKEN_FUNCTION:
+ return DialogueConstants.ERR_UNEXPECTED_FUNCTION
+
+ DialogueConstants.TOKEN_PARENS_OPEN, \
+ DialogueConstants.TOKEN_PARENS_CLOSE:
+ return DialogueConstants.ERR_UNEXPECTED_BRACKET
+
+ DialogueConstants.TOKEN_COMPARISON, \
+ DialogueConstants.TOKEN_ASSIGNMENT, \
+ DialogueConstants.TOKEN_OPERATOR, \
+ DialogueConstants.TOKEN_NOT, \
+ DialogueConstants.TOKEN_AND_OR:
+ return DialogueConstants.ERR_UNEXPECTED_OPERATOR
+
+ DialogueConstants.TOKEN_COMMA:
+ return DialogueConstants.ERR_UNEXPECTED_COMMA
+ DialogueConstants.TOKEN_COLON:
+ return DialogueConstants.ERR_UNEXPECTED_COLON
+ DialogueConstants.TOKEN_DOT:
+ return DialogueConstants.ERR_UNEXPECTED_DOT
+
+ DialogueConstants.TOKEN_BOOL:
+ return DialogueConstants.ERR_UNEXPECTED_BOOLEAN
+ DialogueConstants.TOKEN_STRING:
+ return DialogueConstants.ERR_UNEXPECTED_STRING
+ DialogueConstants.TOKEN_NUMBER:
+ return DialogueConstants.ERR_UNEXPECTED_NUMBER
+ DialogueConstants.TOKEN_VARIABLE:
+ return DialogueConstants.ERR_UNEXPECTED_VARIABLE
+
+ return DialogueConstants.ERR_INVALID_EXPRESSION
+
+ return OK
+
+
+func tokens_to_list(tokens: Array[Dictionary]) -> Array[Array]:
+ var list: Array[Array] = []
+ var current_item: Array[Dictionary] = []
+ for token in tokens:
+ if token.type == DialogueConstants.TOKEN_COMMA:
+ list.append(current_item)
+ current_item = []
+ else:
+ current_item.append(token)
+
+ if current_item.size() > 0:
+ list.append(current_item)
+
+ return list
+
+
+func tokens_to_dictionary(tokens: Array[Dictionary]) -> Dictionary:
+ var dictionary = {}
+ for i in range(0, tokens.size()):
+ if tokens[i].type == DialogueConstants.TOKEN_COLON:
+ if tokens.size() == i + 2:
+ dictionary[tokens[i-1]] = tokens[i+1]
+ else:
+ dictionary[tokens[i-1]] = { type = DialogueConstants.TOKEN_GROUP, value = tokens.slice(i+1) }
+
+ return dictionary
+
+
+func find_match(input: String) -> Dictionary:
+ for key in TOKEN_DEFINITIONS.keys():
+ var regex = TOKEN_DEFINITIONS.get(key)
+ var found = regex.search(input)
+ if found:
+ return {
+ type = key,
+ remaining_text = input.substr(found.strings[0].length()),
+ value = found.strings[0]
+ }
+
+ return {}
diff --git a/addons/dialogue_manager/components/resolved_line_data.gd b/addons/dialogue_manager/components/resolved_line_data.gd
new file mode 100644
index 00000000..10735862
--- /dev/null
+++ b/addons/dialogue_manager/components/resolved_line_data.gd
@@ -0,0 +1,15 @@
+extends RefCounted
+
+var text: String = ""
+var pauses: Dictionary = {}
+var speeds: Dictionary = {}
+var mutations: Array[Array] = []
+var time: String = ""
+
+
+func _init(data: Dictionary) -> void:
+ text = data.text
+ pauses = data.pauses
+ speeds = data.speeds
+ mutations = data.mutations
+ time = data.time
diff --git a/addons/dialogue_manager/components/resolved_tag_data.gd b/addons/dialogue_manager/components/resolved_tag_data.gd
new file mode 100644
index 00000000..728cc423
--- /dev/null
+++ b/addons/dialogue_manager/components/resolved_tag_data.gd
@@ -0,0 +1,10 @@
+extends RefCounted
+
+
+var tags: PackedStringArray = []
+var line_without_tags: String = ""
+
+
+func _init(data: Dictionary) -> void:
+ tags = data.tags
+ line_without_tags = data.line_without_tags
diff --git a/addons/dialogue_manager/components/search_and_replace.gd b/addons/dialogue_manager/components/search_and_replace.gd
new file mode 100644
index 00000000..e91574e9
--- /dev/null
+++ b/addons/dialogue_manager/components/search_and_replace.gd
@@ -0,0 +1,212 @@
+@tool
+extends VBoxContainer
+
+
+signal open_requested()
+signal close_requested()
+
+
+const DialogueConstants = preload("../constants.gd")
+
+
+@onready var input: LineEdit = $Search/Input
+@onready var result_label: Label = $Search/ResultLabel
+@onready var previous_button: Button = $Search/PreviousButton
+@onready var next_button: Button = $Search/NextButton
+@onready var match_case_button: CheckBox = $Search/MatchCaseCheckBox
+@onready var replace_check_button: CheckButton = $Search/ReplaceCheckButton
+@onready var replace_panel: HBoxContainer = $Replace
+@onready var replace_input: LineEdit = $Replace/Input
+@onready var replace_button: Button = $Replace/ReplaceButton
+@onready var replace_all_button: Button = $Replace/ReplaceAllButton
+
+# The code edit we will be affecting (for some reason exporting this didn't work)
+var code_edit: CodeEdit:
+ set(next_code_edit):
+ code_edit = next_code_edit
+ code_edit.gui_input.connect(_on_text_edit_gui_input)
+ code_edit.text_changed.connect(_on_text_edit_text_changed)
+ get:
+ return code_edit
+
+var results: Array = []
+var result_index: int = -1:
+ set(next_result_index):
+ result_index = next_result_index
+ if results.size() > 0:
+ var r = results[result_index]
+ code_edit.set_caret_line(r[0])
+ code_edit.select(r[0], r[1], r[0], r[1] + r[2])
+ else:
+ result_index = -1
+ if is_instance_valid(code_edit):
+ code_edit.deselect()
+
+ result_label.text = DialogueConstants.translate(&"n_of_n").format({ index = result_index + 1, total = results.size() })
+ get:
+ return result_index
+
+
+func _ready() -> void:
+ apply_theme()
+
+ input.placeholder_text = DialogueConstants.translate(&"search.placeholder")
+ previous_button.tooltip_text = DialogueConstants.translate(&"search.previous")
+ next_button.tooltip_text = DialogueConstants.translate(&"search.next")
+ match_case_button.text = DialogueConstants.translate(&"search.match_case")
+ $Search/ReplaceCheckButton.text = DialogueConstants.translate(&"search.toggle_replace")
+ replace_button.text = DialogueConstants.translate(&"search.replace")
+ replace_all_button.text = DialogueConstants.translate(&"search.replace_all")
+ $Replace/ReplaceLabel.text = DialogueConstants.translate(&"search.replace_with")
+
+ self.result_index = -1
+
+ replace_panel.hide()
+ replace_button.disabled = true
+ replace_all_button.disabled = true
+
+ hide()
+
+
+func focus_line_edit() -> void:
+ input.grab_focus()
+ input.select_all()
+
+
+func apply_theme() -> void:
+ if is_instance_valid(previous_button):
+ previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons")
+ if is_instance_valid(next_button):
+ next_button.icon = get_theme_icon("ArrowRight", "EditorIcons")
+
+
+# Find text in the code
+func search(text: String = "", default_result_index: int = 0) -> void:
+ results.clear()
+
+ if text == "":
+ text = input.text
+
+ var lines = code_edit.text.split("\n")
+ for line_number in range(0, lines.size()):
+ var line = lines[line_number]
+
+ var column = find_in_line(line, text, 0)
+ while column > -1:
+ results.append([line_number, column, text.length()])
+ column = find_in_line(line, text, column + 1)
+
+ if results.size() > 0:
+ replace_button.disabled = false
+ replace_all_button.disabled = false
+ else:
+ replace_button.disabled = true
+ replace_all_button.disabled = true
+
+ self.result_index = clamp(default_result_index, 0, results.size() - 1)
+
+
+# Find text in a string and match case if requested
+func find_in_line(line: String, text: String, from_index: int = 0) -> int:
+ if match_case_button.button_pressed:
+ return line.find(text, from_index)
+ else:
+ return line.findn(text, from_index)
+
+
+### Signals
+
+
+func _on_text_edit_gui_input(event: InputEvent) -> void:
+ if event is InputEventKey and event.is_pressed():
+ match event.as_text():
+ "Ctrl+F", "Command+F":
+ open_requested.emit()
+ get_viewport().set_input_as_handled()
+ "Ctrl+Shift+R", "Command+Shift+R":
+ replace_check_button.set_pressed(true)
+ open_requested.emit()
+ get_viewport().set_input_as_handled()
+
+
+func _on_text_edit_text_changed() -> void:
+ results.clear()
+
+
+func _on_search_and_replace_theme_changed() -> void:
+ apply_theme()
+
+
+func _on_input_text_changed(new_text: String) -> void:
+ search(new_text)
+
+
+func _on_previous_button_pressed() -> void:
+ self.result_index = wrapi(result_index - 1, 0, results.size())
+
+
+func _on_next_button_pressed() -> void:
+ self.result_index = wrapi(result_index + 1, 0, results.size())
+
+
+func _on_search_and_replace_visibility_changed() -> void:
+ if is_instance_valid(input):
+ if visible:
+ input.grab_focus()
+ var selection = code_edit.get_selected_text()
+ if input.text == "" and selection != "":
+ input.text = selection
+ search(selection)
+ else:
+ search()
+ else:
+ input.text = ""
+
+
+func _on_input_gui_input(event: InputEvent) -> void:
+ if event is InputEventKey and event.is_pressed():
+ match event.as_text():
+ "Enter":
+ search(input.text)
+ "Escape":
+ emit_signal("close_requested")
+
+
+func _on_replace_button_pressed() -> void:
+ if result_index == -1: return
+
+ # Replace the selection at result index
+ var r: Array = results[result_index]
+ var lines: PackedStringArray = code_edit.text.split("\n")
+ var line: String = lines[r[0]]
+ line = line.substr(0, r[1]) + replace_input.text + line.substr(r[1] + r[2])
+ lines[r[0]] = line
+ code_edit.text = "\n".join(lines)
+ search(input.text, result_index)
+ code_edit.text_changed.emit()
+
+
+func _on_replace_all_button_pressed() -> void:
+ if match_case_button.button_pressed:
+ code_edit.text = code_edit.text.replace(input.text, replace_input.text)
+ else:
+ code_edit.text = code_edit.text.replacen(input.text, replace_input.text)
+ search()
+ code_edit.text_changed.emit()
+
+
+func _on_replace_check_button_toggled(button_pressed: bool) -> void:
+ replace_panel.visible = button_pressed
+ if button_pressed:
+ replace_input.grab_focus()
+
+
+func _on_input_focus_entered() -> void:
+ if results.size() == 0:
+ search()
+ else:
+ self.result_index = result_index
+
+
+func _on_match_case_check_box_toggled(button_pressed: bool) -> void:
+ search()
diff --git a/addons/dialogue_manager/components/search_and_replace.tscn b/addons/dialogue_manager/components/search_and_replace.tscn
new file mode 100644
index 00000000..82dd60d7
--- /dev/null
+++ b/addons/dialogue_manager/components/search_and_replace.tscn
@@ -0,0 +1,87 @@
+[gd_scene load_steps=2 format=3 uid="uid://gr8nakpbrhby"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/search_and_replace.gd" id="1_8oj1f"]
+
+[node name="SearchAndReplace" type="VBoxContainer"]
+visible = false
+anchors_preset = 10
+anchor_right = 1.0
+offset_bottom = 31.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+script = ExtResource("1_8oj1f")
+
+[node name="Search" type="HBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="Input" type="LineEdit" parent="Search"]
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Text to search for"
+metadata/_edit_use_custom_anchors = true
+
+[node name="MatchCaseCheckBox" type="CheckBox" parent="Search"]
+layout_mode = 2
+text = "Match case"
+
+[node name="VSeparator" type="VSeparator" parent="Search"]
+layout_mode = 2
+
+[node name="PreviousButton" type="Button" parent="Search"]
+layout_mode = 2
+tooltip_text = "Previous"
+flat = true
+
+[node name="ResultLabel" type="Label" parent="Search"]
+layout_mode = 2
+text = "0 of 0"
+
+[node name="NextButton" type="Button" parent="Search"]
+layout_mode = 2
+tooltip_text = "Next"
+flat = true
+
+[node name="VSeparator2" type="VSeparator" parent="Search"]
+layout_mode = 2
+
+[node name="ReplaceCheckButton" type="CheckButton" parent="Search"]
+layout_mode = 2
+text = "Replace"
+
+[node name="Replace" type="HBoxContainer" parent="."]
+visible = false
+layout_mode = 2
+
+[node name="ReplaceLabel" type="Label" parent="Replace"]
+layout_mode = 2
+text = "Replace with:"
+
+[node name="Input" type="LineEdit" parent="Replace"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ReplaceButton" type="Button" parent="Replace"]
+layout_mode = 2
+disabled = true
+text = "Replace"
+flat = true
+
+[node name="ReplaceAllButton" type="Button" parent="Replace"]
+layout_mode = 2
+disabled = true
+text = "Replace all"
+flat = true
+
+[connection signal="theme_changed" from="." to="." method="_on_search_and_replace_theme_changed"]
+[connection signal="visibility_changed" from="." to="." method="_on_search_and_replace_visibility_changed"]
+[connection signal="focus_entered" from="Search/Input" to="." method="_on_input_focus_entered"]
+[connection signal="gui_input" from="Search/Input" to="." method="_on_input_gui_input"]
+[connection signal="text_changed" from="Search/Input" to="." method="_on_input_text_changed"]
+[connection signal="toggled" from="Search/MatchCaseCheckBox" to="." method="_on_match_case_check_box_toggled"]
+[connection signal="pressed" from="Search/PreviousButton" to="." method="_on_previous_button_pressed"]
+[connection signal="pressed" from="Search/NextButton" to="." method="_on_next_button_pressed"]
+[connection signal="toggled" from="Search/ReplaceCheckButton" to="." method="_on_replace_check_button_toggled"]
+[connection signal="focus_entered" from="Replace/Input" to="." method="_on_input_focus_entered"]
+[connection signal="gui_input" from="Replace/Input" to="." method="_on_input_gui_input"]
+[connection signal="pressed" from="Replace/ReplaceButton" to="." method="_on_replace_button_pressed"]
+[connection signal="pressed" from="Replace/ReplaceAllButton" to="." method="_on_replace_all_button_pressed"]
diff --git a/addons/dialogue_manager/components/title_list.gd b/addons/dialogue_manager/components/title_list.gd
new file mode 100644
index 00000000..ee7cd139
--- /dev/null
+++ b/addons/dialogue_manager/components/title_list.gd
@@ -0,0 +1,67 @@
+@tool
+extends VBoxContainer
+
+signal title_selected(title: String)
+
+
+const DialogueConstants = preload("../constants.gd")
+
+
+@onready var filter_edit: LineEdit = $FilterEdit
+@onready var list: ItemList = $List
+
+var titles: PackedStringArray:
+ set(next_titles):
+ titles = next_titles
+ apply_filter()
+ get:
+ return titles
+
+var filter: String:
+ set(next_filter):
+ filter = next_filter
+ apply_filter()
+ get:
+ return filter
+
+
+func _ready() -> void:
+ apply_theme()
+
+ filter_edit.placeholder_text = DialogueConstants.translate(&"titles_list.filter")
+
+
+func select_title(title: String) -> void:
+ list.deselect_all()
+ for i in range(0, list.get_item_count()):
+ if list.get_item_text(i) == title.strip_edges():
+ list.select(i)
+
+
+func apply_filter() -> void:
+ list.clear()
+ for title in titles:
+ if filter == "" or filter.to_lower() in title.to_lower():
+ list.add_item(title.strip_edges())
+
+
+func apply_theme() -> void:
+ if is_instance_valid(filter_edit):
+ filter_edit.right_icon = get_theme_icon("Search", "EditorIcons")
+
+
+### Signals
+
+
+func _on_theme_changed() -> void:
+ apply_theme()
+
+
+func _on_filter_edit_text_changed(new_text: String) -> void:
+ self.filter = new_text
+
+
+func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
+ if mouse_button_index == MOUSE_BUTTON_LEFT:
+ var title = list.get_item_text(index)
+ title_selected.emit(title)
diff --git a/addons/dialogue_manager/components/title_list.tscn b/addons/dialogue_manager/components/title_list.tscn
new file mode 100644
index 00000000..6273122e
--- /dev/null
+++ b/addons/dialogue_manager/components/title_list.tscn
@@ -0,0 +1,27 @@
+[gd_scene load_steps=2 format=3 uid="uid://ctns6ouwwd68i"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/title_list.gd" id="1_5qqmd"]
+
+[node name="TitleList" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_5qqmd")
+
+[node name="FilterEdit" type="LineEdit" parent="."]
+layout_mode = 2
+placeholder_text = "Filter titles"
+clear_button_enabled = true
+
+[node name="List" type="ItemList" parent="."]
+layout_mode = 2
+size_flags_vertical = 3
+allow_reselect = true
+
+[connection signal="theme_changed" from="." to="." method="_on_theme_changed"]
+[connection signal="text_changed" from="FilterEdit" to="." method="_on_filter_edit_text_changed"]
+[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"]
diff --git a/addons/dialogue_manager/components/update_button.gd b/addons/dialogue_manager/components/update_button.gd
new file mode 100644
index 00000000..2f77c63d
--- /dev/null
+++ b/addons/dialogue_manager/components/update_button.gd
@@ -0,0 +1,125 @@
+@tool
+extends Button
+
+const DialogueConstants = preload("../constants.gd")
+const DialogueSettings = preload("../settings.gd")
+
+const REMOTE_RELEASES_URL = "https://api.github.com/repos/nathanhoad/godot_dialogue_manager/releases"
+
+
+@onready var http_request: HTTPRequest = $HTTPRequest
+@onready var download_dialog: AcceptDialog = $DownloadDialog
+@onready var download_update_panel = $DownloadDialog/DownloadUpdatePanel
+@onready var needs_reload_dialog: AcceptDialog = $NeedsReloadDialog
+@onready var update_failed_dialog: AcceptDialog = $UpdateFailedDialog
+@onready var timer: Timer = $Timer
+
+var needs_reload: bool = false
+
+# A lambda that gets called just before refreshing the plugin. Return false to stop the reload.
+var on_before_refresh: Callable = func(): return true
+
+
+func _ready() -> void:
+ hide()
+ apply_theme()
+
+ # Check for updates on GitHub
+ check_for_update()
+
+ # Check again every few hours
+ timer.start(60 * 60 * 12)
+
+
+# Convert a version number to an actually comparable number
+func version_to_number(version: String) -> int:
+ var bits = version.split(".")
+ return bits[0].to_int() * 1000000 + bits[1].to_int() * 1000 + bits[2].to_int()
+
+
+func apply_theme() -> void:
+ var color: Color = get_theme_color("success_color", "Editor")
+
+ if needs_reload:
+ color = get_theme_color("error_color", "Editor")
+ icon = get_theme_icon("Reload", "EditorIcons")
+ add_theme_color_override("icon_normal_color", color)
+ add_theme_color_override("icon_focus_color", color)
+ add_theme_color_override("icon_hover_color", color)
+
+ add_theme_color_override("font_color", color)
+ add_theme_color_override("font_focus_color", color)
+ add_theme_color_override("font_hover_color", color)
+
+
+func check_for_update() -> void:
+ if DialogueSettings.get_user_value("check_for_updates", true):
+ http_request.request(REMOTE_RELEASES_URL)
+
+
+### Signals
+
+
+func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
+ if result != HTTPRequest.RESULT_SUCCESS: return
+
+ var current_version: String = Engine.get_meta("DialogueManagerPlugin").get_version()
+
+ # Work out the next version from the releases information on GitHub
+ var response = JSON.parse_string(body.get_string_from_utf8())
+ if typeof(response) != TYPE_ARRAY: return
+
+ # GitHub releases are in order of creation, not order of version
+ var versions = (response as Array).filter(func(release):
+ var version: String = release.tag_name.substr(1)
+ var major_version: int = version.split(".")[0].to_int()
+ var current_major_version: int = current_version.split(".")[0].to_int()
+ return major_version == current_major_version and version_to_number(version) > version_to_number(current_version)
+ )
+ if versions.size() > 0:
+ download_update_panel.next_version_release = versions[0]
+ text = DialogueConstants.translate(&"update.available").format({ version = versions[0].tag_name.substr(1) })
+ show()
+
+
+func _on_update_button_pressed() -> void:
+ if needs_reload:
+ var will_refresh = on_before_refresh.call()
+ if will_refresh:
+ Engine.get_meta("DialogueManagerPlugin").get_editor_interface().restart_editor(true)
+ else:
+ var scale: float = Engine.get_meta("DialogueManagerPlugin").get_editor_interface().get_editor_scale()
+ download_dialog.min_size = Vector2(300, 250) * scale
+ download_dialog.popup_centered()
+
+
+func _on_download_dialog_close_requested() -> void:
+ download_dialog.hide()
+
+
+func _on_download_update_panel_updated(updated_to_version: String) -> void:
+ download_dialog.hide()
+
+ needs_reload_dialog.dialog_text = DialogueConstants.translate(&"update.needs_reload")
+ needs_reload_dialog.ok_button_text = DialogueConstants.translate(&"update.reload_ok_button")
+ needs_reload_dialog.cancel_button_text = DialogueConstants.translate(&"update.reload_cancel_button")
+ needs_reload_dialog.popup_centered()
+
+ needs_reload = true
+ text = DialogueConstants.translate(&"update.reload_project")
+ apply_theme()
+
+
+func _on_download_update_panel_failed() -> void:
+ download_dialog.hide()
+ update_failed_dialog.dialog_text = DialogueConstants.translate(&"update.failed")
+ update_failed_dialog.popup_centered()
+
+
+func _on_needs_reload_dialog_confirmed() -> void:
+ Engine.get_meta("DialogueManagerPlugin").get_editor_interface().restart_editor(true)
+
+
+func _on_timer_timeout() -> void:
+ if not needs_reload:
+ check_for_update()
diff --git a/addons/dialogue_manager/components/update_button.tscn b/addons/dialogue_manager/components/update_button.tscn
new file mode 100644
index 00000000..533a94e1
--- /dev/null
+++ b/addons/dialogue_manager/components/update_button.tscn
@@ -0,0 +1,42 @@
+[gd_scene load_steps=3 format=3 uid="uid://co8yl23idiwbi"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/update_button.gd" id="1_d2tpb"]
+[ext_resource type="PackedScene" uid="uid://qdxrxv3c3hxk" path="res://addons/dialogue_manager/components/download_update_panel.tscn" id="2_iwm7r"]
+
+[node name="UpdateButton" type="Button"]
+visible = false
+offset_right = 8.0
+offset_bottom = 8.0
+theme_override_colors/font_color = Color(0, 0, 0, 1)
+theme_override_colors/font_hover_color = Color(0, 0, 0, 1)
+theme_override_colors/font_focus_color = Color(0, 0, 0, 1)
+text = "v2.9.0 available"
+flat = true
+script = ExtResource("1_d2tpb")
+
+[node name="HTTPRequest" type="HTTPRequest" parent="."]
+
+[node name="DownloadDialog" type="AcceptDialog" parent="."]
+title = "Download update"
+size = Vector2i(400, 300)
+unresizable = true
+min_size = Vector2i(300, 250)
+ok_button_text = "Close"
+
+[node name="DownloadUpdatePanel" parent="DownloadDialog" instance=ExtResource("2_iwm7r")]
+
+[node name="UpdateFailedDialog" type="AcceptDialog" parent="."]
+dialog_text = "You have been updated to version 2.4.3"
+
+[node name="NeedsReloadDialog" type="ConfirmationDialog" parent="."]
+
+[node name="Timer" type="Timer" parent="."]
+wait_time = 14400.0
+
+[connection signal="pressed" from="." to="." method="_on_update_button_pressed"]
+[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"]
+[connection signal="close_requested" from="DownloadDialog" to="." method="_on_download_dialog_close_requested"]
+[connection signal="failed" from="DownloadDialog/DownloadUpdatePanel" to="." method="_on_download_update_panel_failed"]
+[connection signal="updated" from="DownloadDialog/DownloadUpdatePanel" to="." method="_on_download_update_panel_updated"]
+[connection signal="confirmed" from="NeedsReloadDialog" to="." method="_on_needs_reload_dialog_confirmed"]
+[connection signal="timeout" from="Timer" to="." method="_on_timer_timeout"]
diff --git a/addons/dialogue_manager/constants.gd b/addons/dialogue_manager/constants.gd
new file mode 100644
index 00000000..cb556838
--- /dev/null
+++ b/addons/dialogue_manager/constants.gd
@@ -0,0 +1,189 @@
+extends Node
+
+
+const USER_CONFIG_PATH = "user://dialogue_manager_user_config.json"
+const CACHE_PATH = "user://dialogue_manager_cache.json"
+
+# Token types
+
+const TOKEN_FUNCTION = &"function"
+const TOKEN_DICTIONARY_REFERENCE = &"dictionary_reference"
+const TOKEN_DICTIONARY_NESTED_REFERENCE = &"dictionary_nested_reference"
+const TOKEN_GROUP = &"group"
+const TOKEN_ARRAY = &"array"
+const TOKEN_DICTIONARY = &"dictionary"
+const TOKEN_PARENS_OPEN = &"parens_open"
+const TOKEN_PARENS_CLOSE = &"parens_close"
+const TOKEN_BRACKET_OPEN = &"bracket_open"
+const TOKEN_BRACKET_CLOSE = &"bracket_close"
+const TOKEN_BRACE_OPEN = &"brace_open"
+const TOKEN_BRACE_CLOSE = &"brace_close"
+const TOKEN_COLON = &"colon"
+const TOKEN_COMPARISON = &"comparison"
+const TOKEN_ASSIGNMENT = &"assignment"
+const TOKEN_OPERATOR = &"operator"
+const TOKEN_COMMA = &"comma"
+const TOKEN_DOT = &"dot"
+const TOKEN_CONDITION = &"condition"
+const TOKEN_BOOL = &"bool"
+const TOKEN_NOT = &"not"
+const TOKEN_AND_OR = &"and_or"
+const TOKEN_STRING = &"string"
+const TOKEN_NUMBER = &"number"
+const TOKEN_VARIABLE = &"variable"
+const TOKEN_COMMENT = &"comment"
+
+const TOKEN_ERROR = &"error"
+
+# Line types
+
+const TYPE_UNKNOWN = &"unknown"
+const TYPE_RESPONSE = &"response"
+const TYPE_TITLE = &"title"
+const TYPE_CONDITION = &"condition"
+const TYPE_MUTATION = &"mutation"
+const TYPE_GOTO = &"goto"
+const TYPE_DIALOGUE = &"dialogue"
+const TYPE_ERROR = &"error"
+
+const TYPE_ELSE = &"else"
+
+# Line IDs
+
+const ID_NULL = &""
+const ID_ERROR = &"error"
+const ID_ERROR_INVALID_TITLE = &"invalid title"
+const ID_ERROR_TITLE_HAS_NO_BODY = &"title has no body"
+const ID_END = &"end"
+const ID_END_CONVERSATION = &"end!"
+
+# Errors
+
+const ERR_ERRORS_IN_IMPORTED_FILE = 100
+const ERR_FILE_ALREADY_IMPORTED = 101
+const ERR_DUPLICATE_IMPORT_NAME = 102
+const ERR_EMPTY_TITLE = 103
+const ERR_DUPLICATE_TITLE = 104
+const ERR_NESTED_TITLE = 105
+const ERR_TITLE_INVALID_CHARACTERS = 106
+const ERR_UNKNOWN_TITLE = 107
+const ERR_INVALID_TITLE_REFERENCE = 108
+const ERR_TITLE_REFERENCE_HAS_NO_CONTENT = 109
+const ERR_INVALID_EXPRESSION = 110
+const ERR_UNEXPECTED_CONDITION = 111
+const ERR_DUPLICATE_ID = 112
+const ERR_MISSING_ID = 113
+const ERR_INVALID_INDENTATION = 114
+const ERR_INVALID_CONDITION_INDENTATION = 115
+const ERR_INCOMPLETE_EXPRESSION = 116
+const ERR_INVALID_EXPRESSION_FOR_VALUE = 117
+const ERR_UNKNOWN_LINE_SYNTAX = 118
+const ERR_TITLE_BEGINS_WITH_NUMBER = 119
+const ERR_UNEXPECTED_END_OF_EXPRESSION = 120
+const ERR_UNEXPECTED_FUNCTION = 121
+const ERR_UNEXPECTED_BRACKET = 122
+const ERR_UNEXPECTED_CLOSING_BRACKET = 123
+const ERR_MISSING_CLOSING_BRACKET = 124
+const ERR_UNEXPECTED_OPERATOR = 125
+const ERR_UNEXPECTED_COMMA = 126
+const ERR_UNEXPECTED_COLON = 127
+const ERR_UNEXPECTED_DOT = 128
+const ERR_UNEXPECTED_BOOLEAN = 129
+const ERR_UNEXPECTED_STRING = 130
+const ERR_UNEXPECTED_NUMBER = 131
+const ERR_UNEXPECTED_VARIABLE = 132
+const ERR_INVALID_INDEX = 133
+const ERR_UNEXPECTED_ASSIGNMENT = 134
+const ERR_UNKNOWN_USING = 135
+
+
+## Get the error message
+static func get_error_message(error: int) -> String:
+ match error:
+ ERR_ERRORS_IN_IMPORTED_FILE:
+ return translate(&"errors.import_errors")
+ ERR_FILE_ALREADY_IMPORTED:
+ return translate(&"errors.already_imported")
+ ERR_DUPLICATE_IMPORT_NAME:
+ return translate(&"errors.duplicate_import")
+ ERR_EMPTY_TITLE:
+ return translate(&"errors.empty_title")
+ ERR_DUPLICATE_TITLE:
+ return translate(&"errors.duplicate_title")
+ ERR_NESTED_TITLE:
+ return translate(&"errors.nested_title")
+ ERR_TITLE_INVALID_CHARACTERS:
+ return translate(&"errors.invalid_title_string")
+ ERR_TITLE_BEGINS_WITH_NUMBER:
+ return translate(&"errors.invalid_title_number")
+ ERR_UNKNOWN_TITLE:
+ return translate(&"errors.unknown_title")
+ ERR_INVALID_TITLE_REFERENCE:
+ return translate(&"errors.jump_to_invalid_title")
+ ERR_TITLE_REFERENCE_HAS_NO_CONTENT:
+ return translate(&"errors.title_has_no_content")
+ ERR_INVALID_EXPRESSION:
+ return translate(&"errors.invalid_expression")
+ ERR_UNEXPECTED_CONDITION:
+ return translate(&"errors.unexpected_condition")
+ ERR_DUPLICATE_ID:
+ return translate(&"errors.duplicate_id")
+ ERR_MISSING_ID:
+ return translate(&"errors.missing_id")
+ ERR_INVALID_INDENTATION:
+ return translate(&"errors.invalid_indentation")
+ ERR_INVALID_CONDITION_INDENTATION:
+ return translate(&"errors.condition_has_no_content")
+ ERR_INCOMPLETE_EXPRESSION:
+ return translate(&"errors.incomplete_expression")
+ ERR_INVALID_EXPRESSION_FOR_VALUE:
+ return translate(&"errors.invalid_expression_for_value")
+ ERR_FILE_NOT_FOUND:
+ return translate(&"errors.file_not_found")
+ ERR_UNEXPECTED_END_OF_EXPRESSION:
+ return translate(&"errors.unexpected_end_of_expression")
+ ERR_UNEXPECTED_FUNCTION:
+ return translate(&"errors.unexpected_function")
+ ERR_UNEXPECTED_BRACKET:
+ return translate(&"errors.unexpected_bracket")
+ ERR_UNEXPECTED_CLOSING_BRACKET:
+ return translate(&"errors.unexpected_closing_bracket")
+ ERR_MISSING_CLOSING_BRACKET:
+ return translate(&"errors.missing_closing_bracket")
+ ERR_UNEXPECTED_OPERATOR:
+ return translate(&"errors.unexpected_operator")
+ ERR_UNEXPECTED_COMMA:
+ return translate(&"errors.unexpected_comma")
+ ERR_UNEXPECTED_COLON:
+ return translate(&"errors.unexpected_colon")
+ ERR_UNEXPECTED_DOT:
+ return translate(&"errors.unexpected_dot")
+ ERR_UNEXPECTED_BOOLEAN:
+ return translate(&"errors.unexpected_boolean")
+ ERR_UNEXPECTED_STRING:
+ return translate(&"errors.unexpected_string")
+ ERR_UNEXPECTED_NUMBER:
+ return translate(&"errors.unexpected_number")
+ ERR_UNEXPECTED_VARIABLE:
+ return translate(&"errors.unexpected_variable")
+ ERR_INVALID_INDEX:
+ return translate(&"errors.invalid_index")
+ ERR_UNEXPECTED_ASSIGNMENT:
+ return translate(&"errors.unexpected_assignment")
+ ERR_UNKNOWN_USING:
+ return translate(&"errors.unknown_using")
+
+ return translate(&"errors.unknown")
+
+
+static func translate(string: String) -> String:
+ var temp_node = new()
+ var base_path = temp_node.get_script().resource_path.get_base_dir()
+ temp_node.free()
+
+ var language: String = TranslationServer.get_tool_locale()
+ var translations_path: String = "%s/l10n/%s.po" % [base_path, language]
+ var fallback_translations_path: String = "%s/l10n/%s.po" % [base_path, TranslationServer.get_tool_locale().substr(0, 2)]
+ var en_translations_path: String = "%s/l10n/en.po" % base_path
+ var translations: Translation = load(translations_path if FileAccess.file_exists(translations_path) else (fallback_translations_path if FileAccess.file_exists(fallback_translations_path) else en_translations_path))
+ return translations.get_message(string)
diff --git a/addons/dialogue_manager/dialogue_label.gd b/addons/dialogue_manager/dialogue_label.gd
new file mode 100644
index 00000000..37928399
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_label.gd
@@ -0,0 +1,230 @@
+@icon("./assets/icon.svg")
+
+@tool
+
+## A RichTextLabel specifically for use with [b]Dialogue Manager[/b] dialogue.
+class_name DialogueLabel extends RichTextLabel
+
+
+## Emitted for each letter typed out.
+signal spoke(letter: String, letter_index: int, speed: float)
+
+## Emitted when typing paused for a `[wait]`
+signal paused_typing(duration: float)
+
+## Emitted when the player skips the typing of dialogue.
+signal skipped_typing()
+
+## Emitted when typing finishes.
+signal finished_typing()
+
+
+# The action to press to skip typing.
+@export var skip_action: StringName = &"ui_cancel"
+
+## The speed with which the text types out.
+@export var seconds_per_step: float = 0.02
+
+## Automatically have a brief pause when these characters are encountered.
+@export var pause_at_characters: String = ".?!"
+
+## Don't auto pause if the character after the pause is one of these.
+@export var skip_pause_at_character_if_followed_by: String = ")\""
+
+## Don't auto pause after these abbreviations (only if "." is in `pause_at_characters`).[br]
+## Abbreviations are limitted to 5 characters in length [br]
+## Does not support multi-period abbreviations (ex. "p.m.")
+@export var skip_pause_at_abbreviations: PackedStringArray = ["Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex"]
+
+## The amount of time to pause when exposing a character present in `pause_at_characters`.
+@export var seconds_per_pause_step: float = 0.3
+
+var _already_mutated_indices: PackedInt32Array = []
+
+
+## The current line of dialogue.
+var dialogue_line:
+ set(next_dialogue_line):
+ dialogue_line = next_dialogue_line
+ custom_minimum_size = Vector2.ZERO
+ text = dialogue_line.text
+ get:
+ return dialogue_line
+
+## Whether the label is currently typing itself out.
+var is_typing: bool = false:
+ set(value):
+ var is_finished: bool = is_typing != value and value == false
+ is_typing = value
+ if is_finished:
+ finished_typing.emit()
+ get:
+ return is_typing
+
+var _last_wait_index: int = -1
+var _last_mutation_index: int = -1
+var _waiting_seconds: float = 0
+var _is_awaiting_mutation: bool = false
+
+
+func _process(delta: float) -> void:
+ if self.is_typing:
+ # Type out text
+ if visible_ratio < 1:
+ # See if we are waiting
+ if _waiting_seconds > 0:
+ _waiting_seconds = _waiting_seconds - delta
+ # If we are no longer waiting then keep typing
+ if _waiting_seconds <= 0:
+ _type_next(delta, _waiting_seconds)
+ else:
+ # Make sure any mutations at the end of the line get run
+ _mutate_inline_mutations(get_total_character_count())
+ self.is_typing = false
+
+
+func _unhandled_input(event: InputEvent) -> void:
+ # Note: this will no longer be reached if using Dialogue Manager > 2.32.2. To make skip handling
+ # simpler (so all of mouse/keyboard/joypad are together) it is now the responsibility of the
+ # dialogue balloon.
+ if self.is_typing and visible_ratio < 1 and InputMap.has_action(skip_action) and event.is_action_pressed(skip_action):
+ get_viewport().set_input_as_handled()
+ skip_typing()
+
+
+## Start typing out the text
+func type_out() -> void:
+ text = dialogue_line.text
+ visible_characters = 0
+ visible_ratio = 0
+ _waiting_seconds = 0
+ _last_wait_index = -1
+ _last_mutation_index = -1
+ _already_mutated_indices.clear()
+
+ self.is_typing = true
+
+ # Allow typing listeners a chance to connect
+ await get_tree().process_frame
+
+ if get_total_character_count() == 0:
+ self.is_typing = false
+ elif seconds_per_step == 0:
+ _mutate_remaining_mutations()
+ visible_characters = get_total_character_count()
+ self.is_typing = false
+
+
+## Stop typing out the text and jump right to the end
+func skip_typing() -> void:
+ _mutate_remaining_mutations()
+ visible_characters = get_total_character_count()
+ self.is_typing = false
+ skipped_typing.emit()
+
+
+# Type out the next character(s)
+func _type_next(delta: float, seconds_needed: float) -> void:
+ if _is_awaiting_mutation: return
+
+ if visible_characters == get_total_character_count():
+ return
+
+ if _last_mutation_index != visible_characters:
+ _last_mutation_index = visible_characters
+ _mutate_inline_mutations(visible_characters)
+ if _is_awaiting_mutation: return
+
+ var additional_waiting_seconds: float = _get_pause(visible_characters)
+
+ # Pause on characters like "."
+ if _should_auto_pause():
+ additional_waiting_seconds += seconds_per_pause_step
+
+ # Pause at literal [wait] directives
+ if _last_wait_index != visible_characters and additional_waiting_seconds > 0:
+ _last_wait_index = visible_characters
+ _waiting_seconds += additional_waiting_seconds
+ paused_typing.emit(_get_pause(visible_characters))
+ else:
+ visible_characters += 1
+ if visible_characters <= get_total_character_count():
+ spoke.emit(get_parsed_text()[visible_characters - 1], visible_characters - 1, _get_speed(visible_characters))
+ # See if there's time to type out some more in this frame
+ seconds_needed += seconds_per_step * (1.0 / _get_speed(visible_characters))
+ if seconds_needed > delta:
+ _waiting_seconds += seconds_needed
+ else:
+ _type_next(delta, seconds_needed)
+
+
+# Get the pause for the current typing position if there is one
+func _get_pause(at_index: int) -> float:
+ return dialogue_line.pauses.get(at_index, 0)
+
+
+# Get the speed for the current typing position
+func _get_speed(at_index: int) -> float:
+ var speed: float = 1
+ for index in dialogue_line.speeds:
+ if index > at_index:
+ return speed
+ speed = dialogue_line.speeds[index]
+ return speed
+
+
+# Run any inline mutations that haven't been run yet
+func _mutate_remaining_mutations() -> void:
+ for i in range(visible_characters, get_total_character_count() + 1):
+ _mutate_inline_mutations(i)
+
+
+# Run any mutations at the current typing position
+func _mutate_inline_mutations(index: int) -> void:
+ for inline_mutation in dialogue_line.inline_mutations:
+ # inline mutations are an array of arrays in the form of [character index, resolvable function]
+ if inline_mutation[0] > index:
+ return
+ if inline_mutation[0] == index and not _already_mutated_indices.has(index):
+ _already_mutated_indices.append(index)
+ _is_awaiting_mutation = true
+ # The DialogueManager can't be referenced directly here so we need to get it by its path
+ await Engine.get_singleton("DialogueManager").mutate(inline_mutation[1], dialogue_line.extra_game_states, true)
+ _is_awaiting_mutation = false
+
+
+# Determine if the current autopause character at the cursor should qualify to pause typing.
+func _should_auto_pause() -> bool:
+ if visible_characters == 0: return false
+
+ var parsed_text: String = get_parsed_text()
+
+ # Avoid outofbounds when the label auto-translates and the text changes to one shorter while typing out
+ # Note: visible characters can be larger than parsed_text after a translation event
+ if visible_characters >= parsed_text.length(): return false
+
+ # Ignore pause characters if they are next to a non-pause character
+ if parsed_text[visible_characters] in skip_pause_at_character_if_followed_by.split():
+ return false
+
+ # Ignore "." if it's between two numbers
+ if visible_characters > 3 and parsed_text[visible_characters - 1] == ".":
+ var possible_number: String = parsed_text.substr(visible_characters - 2, 3)
+ if str(float(possible_number)) == possible_number:
+ return false
+
+ # Ignore "." if it's used in an abbreviation
+ # Note: does NOT support multi-period abbreviations (ex. p.m.)
+ if "." in pause_at_characters and parsed_text[visible_characters - 1] == ".":
+ for abbreviation in skip_pause_at_abbreviations:
+ if visible_characters >= abbreviation.length():
+ var previous_characters: String = parsed_text.substr(visible_characters - abbreviation.length() - 1, abbreviation.length())
+ if previous_characters == abbreviation:
+ return false
+
+ # Ignore two non-"." characters next to each other
+ var other_pause_characters: PackedStringArray = pause_at_characters.replace(".", "").split()
+ if visible_characters > 1 and parsed_text[visible_characters - 1] in other_pause_characters and parsed_text[visible_characters] in other_pause_characters:
+ return false
+
+ return parsed_text[visible_characters - 1] in pause_at_characters.split()
diff --git a/addons/dialogue_manager/dialogue_label.tscn b/addons/dialogue_manager/dialogue_label.tscn
new file mode 100644
index 00000000..df48b649
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_label.tscn
@@ -0,0 +1,19 @@
+[gd_scene load_steps=2 format=3 uid="uid://ckvgyvclnwggo"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_label.gd" id="1_cital"]
+
+[node name="DialogueLabel" type="RichTextLabel"]
+anchors_preset = 10
+anchor_right = 1.0
+grow_horizontal = 2
+mouse_filter = 1
+bbcode_enabled = true
+fit_content = true
+scroll_active = false
+shortcut_keys_enabled = false
+meta_underlined = false
+hint_underlined = false
+deselect_on_focus_loss_enabled = false
+visible_characters_behavior = 1
+script = ExtResource("1_cital")
+skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")
diff --git a/addons/dialogue_manager/dialogue_line.gd b/addons/dialogue_manager/dialogue_line.gd
new file mode 100644
index 00000000..ced12f21
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_line.gd
@@ -0,0 +1,98 @@
+## A line of dialogue returned from [code]DialogueManager[/code].
+class_name DialogueLine extends RefCounted
+
+
+const _DialogueConstants = preload("./constants.gd")
+
+
+## The ID of this line
+var id: String
+
+## The internal type of this dialogue object. One of [code]TYPE_DIALOGUE[/code] or [code]TYPE_MUTATION[/code]
+var type: String = _DialogueConstants.TYPE_DIALOGUE
+
+## The next line ID after this line.
+var next_id: String = ""
+
+## The character name that is saying this line.
+var character: String = ""
+
+## A dictionary of variable replacements fo the character name. Generally for internal use only.
+var character_replacements: Array[Dictionary] = []
+
+## The dialogue being spoken.
+var text: String = ""
+
+## A dictionary of replacements for the text. Generally for internal use only.
+var text_replacements: Array[Dictionary] = []
+
+## The key to use for translating this line.
+var translation_key: String = ""
+
+## A map for when and for how long to pause while typing out the dialogue text.
+var pauses: Dictionary = {}
+
+## A map for speed changes when typing out the dialogue text.
+var speeds: Dictionary = {}
+
+## A map of any mutations to run while typing out the dialogue text.
+var inline_mutations: Array[Array] = []
+
+## A list of responses attached to this line of dialogue.
+var responses: Array = []
+
+## A list of any extra game states to check when resolving variables and mutations.
+var extra_game_states: Array = []
+
+## How long to show this line before advancing to the next. Either a float (of seconds), [code]"auto"[/code], or [code]null[/code].
+var time: String = ""
+
+## Any #tags that were included in the line
+var tags: PackedStringArray = []
+
+## The mutation details if this is a mutation line (where [code]type == TYPE_MUTATION[/code]).
+var mutation: Dictionary = {}
+
+## The conditions to check before including this line in the flow of dialogue. If failed the line will be skipped over.
+var conditions: Dictionary = {}
+
+
+func _init(data: Dictionary = {}) -> void:
+ if data.size() > 0:
+ id = data.id
+ next_id = data.next_id
+ type = data.type
+ extra_game_states = data.get("extra_game_states", [])
+
+ match type:
+ _DialogueConstants.TYPE_DIALOGUE:
+ character = data.character
+ character_replacements = data.get("character_replacements", [] as Array[Dictionary])
+ text = data.text
+ text_replacements = data.get("text_replacements", [] as Array[Dictionary])
+ translation_key = data.get("translation_key", data.text)
+ pauses = data.get("pauses", {})
+ speeds = data.get("speeds", {})
+ inline_mutations = data.get("inline_mutations", [] as Array[Array])
+ time = data.get("time", "")
+ tags = data.get("tags", [])
+
+ _DialogueConstants.TYPE_MUTATION:
+ mutation = data.mutation
+
+
+func _to_string() -> String:
+ match type:
+ _DialogueConstants.TYPE_DIALOGUE:
+ return "" % [character, text]
+ _DialogueConstants.TYPE_MUTATION:
+ return ""
+ return ""
+
+
+func get_tag_value(tag_name: String) -> String:
+ var wrapped := "%s=" % tag_name
+ for t in tags:
+ if t.begins_with(wrapped):
+ return t.replace(wrapped, "").strip_edges()
+ return ""
diff --git a/addons/dialogue_manager/dialogue_manager.gd b/addons/dialogue_manager/dialogue_manager.gd
new file mode 100644
index 00000000..96e83e88
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_manager.gd
@@ -0,0 +1,1274 @@
+extends Node
+
+
+const DialogueConstants = preload("./constants.gd")
+const Builtins = preload("./utilities/builtins.gd")
+const DialogueSettings = preload("./settings.gd")
+const DialogueResource = preload("./dialogue_resource.gd")
+const DialogueLine = preload("./dialogue_line.gd")
+const DialogueResponse = preload("./dialogue_response.gd")
+const DialogueManagerParser = preload("./components/parser.gd")
+const DialogueManagerParseResult = preload("./components/parse_result.gd")
+const ResolvedLineData = preload("./components/resolved_line_data.gd")
+
+
+## Emitted when a title is encountered while traversing dialogue, usually when jumping from a
+## goto line
+signal passed_title(title: String)
+
+## Emitted when a line of dialogue is encountered.
+signal got_dialogue(line: DialogueLine)
+
+## Emitted when a mutation is encountered.
+signal mutated(mutation: Dictionary)
+
+## Emitted when some dialogue has reached the end.
+signal dialogue_ended(resource: DialogueResource)
+
+## Used internally.
+signal bridge_get_next_dialogue_line_completed(line: DialogueLine)
+
+## Used inernally
+signal bridge_mutated()
+
+
+enum MutationBehaviour {
+ Wait,
+ DoNotWait,
+ Skip
+}
+
+enum TranslationSource {
+ None,
+ Guess,
+ CSV,
+ PO
+}
+
+
+## The list of globals that dialogue can query
+var game_states: Array = []
+
+## Allow dialogue to call singletons
+var include_singletons: bool = true
+
+## Allow dialogue to call static methods/properties on classes
+var include_classes: bool = true
+
+## Manage translation behaviour
+var translation_source: TranslationSource = TranslationSource.Guess
+
+## Used to resolve the current scene. Override if your game manages the current scene itself.
+var get_current_scene: Callable = func():
+ var current_scene: Node = get_tree().current_scene
+ if current_scene == null:
+ current_scene = get_tree().root.get_child(get_tree().root.get_child_count() - 1)
+ return current_scene
+
+var _has_loaded_autoloads: bool = false
+var _autoloads: Dictionary = {}
+
+var _node_properties: Array = []
+var _method_info_cache: Dictionary = {}
+
+
+func _ready() -> void:
+ # Cache the known Node2D properties
+ _node_properties = ["Script Variables"]
+ var temp_node: Node2D = Node2D.new()
+ for property in temp_node.get_property_list():
+ _node_properties.append(property.name)
+ temp_node.free()
+
+ # Make the dialogue manager available as a singleton
+ if Engine.has_singleton("DialogueManager"):
+ Engine.unregister_singleton("DialogueManager")
+ Engine.register_singleton("DialogueManager", self)
+
+ # Connect up the C# signals if need be
+ if DialogueSettings.check_for_dotnet_solution():
+ _get_dotnet_dialogue_manager().Prepare()
+
+
+## Step through lines and run any mutations until we either hit some dialogue or the end of the conversation
+func get_next_dialogue_line(resource: DialogueResource, key: String = "", extra_game_states: Array = [], mutation_behaviour: MutationBehaviour = MutationBehaviour.Wait) -> DialogueLine:
+ # You have to provide a valid dialogue resource
+ if resource == null:
+ assert(false, DialogueConstants.translate(&"runtime.no_resource"))
+ if resource.lines.size() == 0:
+ assert(false, DialogueConstants.translate(&"runtime.no_content").format({ file_path = resource.resource_path }))
+
+ # Inject any "using" states into the game_states
+ for state_name in resource.using_states:
+ var autoload = get_tree().root.get_node_or_null(state_name)
+ if autoload == null:
+ printerr(DialogueConstants.translate(&"runtime.unknown_autoload").format({ autoload = state_name }))
+ else:
+ extra_game_states = [autoload] + extra_game_states
+
+ # Get the line data
+ var dialogue: DialogueLine = await get_line(resource, key, extra_game_states)
+
+ # If our dialogue is nothing then we hit the end
+ if not is_valid(dialogue):
+ (func(): dialogue_ended.emit(resource)).call_deferred()
+ return null
+
+ # Run the mutation if it is one
+ if dialogue.type == DialogueConstants.TYPE_MUTATION:
+ var actual_next_id: String = dialogue.next_id.split(",")[0]
+ match mutation_behaviour:
+ MutationBehaviour.Wait:
+ await mutate(dialogue.mutation, extra_game_states)
+ MutationBehaviour.DoNotWait:
+ mutate(dialogue.mutation, extra_game_states)
+ MutationBehaviour.Skip:
+ pass
+ if actual_next_id in [DialogueConstants.ID_END_CONVERSATION, DialogueConstants.ID_NULL, null]:
+ # End the conversation
+ (func(): dialogue_ended.emit(resource)).call_deferred()
+ return null
+ else:
+ return await get_next_dialogue_line(resource, dialogue.next_id, extra_game_states, mutation_behaviour)
+ else:
+ got_dialogue.emit(dialogue)
+ return dialogue
+
+
+func get_resolved_line_data(data: Dictionary, extra_game_states: Array = []) -> ResolvedLineData:
+ var text: String = translate(data)
+
+ # Resolve variables
+ for replacement in data.text_replacements:
+ var value = await resolve(replacement.expression.duplicate(true), extra_game_states)
+ var index: int = text.find(replacement.value_in_text)
+ if index > -1:
+ text = text.substr(0, index) + str(value) + text.substr(index + replacement.value_in_text.length())
+
+ var parser: DialogueManagerParser = DialogueManagerParser.new()
+
+ # Resolve random groups
+ for found in parser.INLINE_RANDOM_REGEX.search_all(text):
+ var options = found.get_string(&"options").split(&"|")
+ text = text.replace(&"[[%s]]" % found.get_string(&"options"), options[randi_range(0, options.size() - 1)])
+
+ # Do a pass on the markers to find any conditionals
+ var markers: ResolvedLineData = parser.extract_markers(text)
+
+ # Resolve any conditionals and update marker positions as needed
+ if data.type == DialogueConstants.TYPE_DIALOGUE:
+ var resolved_text: String = markers.text
+ var conditionals: Array[RegExMatch] = parser.INLINE_CONDITIONALS_REGEX.search_all(resolved_text)
+ var replacements: Array = []
+ for conditional in conditionals:
+ var condition_raw: String = conditional.strings[conditional.names.condition]
+ var body: String = conditional.strings[conditional.names.body]
+ var body_else: String = ""
+ if &"[else]" in body:
+ var bits = body.split(&"[else]")
+ body = bits[0]
+ body_else = bits[1]
+ var condition: Dictionary = parser.extract_condition("if " + condition_raw, false, 0)
+ # If the condition fails then use the else of ""
+ if not await check_condition({ condition = condition }, extra_game_states):
+ body = body_else
+ replacements.append({
+ start = conditional.get_start(),
+ end = conditional.get_end(),
+ string = conditional.get_string(),
+ body = body
+ })
+
+ for i in range(replacements.size() -1, -1, -1):
+ var r: Dictionary = replacements[i]
+ resolved_text = resolved_text.substr(0, r.start) + r.body + resolved_text.substr(r.end, 9999)
+ # Move any other markers now that the text has changed
+ var offset: int = r.end - r.start - r.body.length()
+ for key in [&"pauses", &"speeds", &"time"]:
+ if markers.get(key) == null: continue
+ var marker = markers.get(key)
+ var next_marker: Dictionary = {}
+ for index in marker:
+ if index < r.start:
+ next_marker[index] = marker[index]
+ elif index > r.start:
+ next_marker[index - offset] = marker[index]
+ markers.set(key, next_marker)
+ var mutations: Array[Array] = markers.mutations
+ var next_mutations: Array[Array] = []
+ for mutation in mutations:
+ var index = mutation[0]
+ if index < r.start:
+ next_mutations.append(mutation)
+ elif index > r.start:
+ next_mutations.append([index - offset, mutation[1]])
+ markers.mutations = next_mutations
+
+ markers.text = resolved_text
+
+ parser.free()
+
+ return markers
+
+
+## Replace any variables, etc in the character name
+func get_resolved_character(data: Dictionary, extra_game_states: Array = []) -> String:
+ var character: String = data.get(&"character", "")
+
+ # Resolve variables
+ for replacement in data.get(&"character_replacements", []):
+ var value = await resolve(replacement.expression.duplicate(true), extra_game_states)
+ var index: int = character.find(replacement.value_in_text)
+ if index > -1:
+ character = character.substr(0, index) + str(value) + character.substr(index + replacement.value_in_text.length())
+
+ # Resolve random groups
+ var random_regex: RegEx = RegEx.new()
+ random_regex.compile("\\[\\[(?.*?)\\]\\]")
+ for found in random_regex.search_all(character):
+ var options = found.get_string(&"options").split("|")
+ character = character.replace("[[%s]]" % found.get_string(&"options"), options[randi_range(0, options.size() - 1)])
+
+ return character
+
+
+## Generate a dialogue resource on the fly from some text
+func create_resource_from_text(text: String) -> Resource:
+ var parser: DialogueManagerParser = DialogueManagerParser.new()
+ parser.parse(text, "")
+ var results: DialogueManagerParseResult = parser.get_data()
+ var errors: Array[Dictionary] = parser.get_errors()
+ parser.free()
+
+ if errors.size() > 0:
+ printerr(DialogueConstants.translate(&"runtime.errors").format({ count = errors.size() }))
+ for error in errors:
+ printerr(DialogueConstants.translate(&"runtime.error_detail").format({
+ line = error.line_number + 1,
+ message = DialogueConstants.get_error_message(error.error)
+ }))
+ assert(false, DialogueConstants.translate(&"runtime.errors_see_details").format({ count = errors.size() }))
+
+ var resource: DialogueResource = DialogueResource.new()
+ resource.using_states = results.using_states
+ resource.titles = results.titles
+ resource.first_title = results.first_title
+ resource.character_names = results.character_names
+ resource.lines = results.lines
+ resource.raw_text = text
+
+ return resource
+
+
+## Show the example balloon
+func show_example_dialogue_balloon(resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> CanvasLayer:
+ var balloon: Node = load(_get_example_balloon_path()).instantiate()
+ get_current_scene.call().add_child(balloon)
+ balloon.start(resource, title, extra_game_states)
+
+ return balloon
+
+
+## Show the configured dialogue balloon
+func show_dialogue_balloon(resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> Node:
+ var balloon_path: String = DialogueSettings.get_setting(&"balloon_path", _get_example_balloon_path())
+ if not ResourceLoader.exists(balloon_path):
+ balloon_path = _get_example_balloon_path()
+ return show_dialogue_balloon_scene(balloon_path, resource, title, extra_game_states)
+
+
+## Show a given balloon scene
+func show_dialogue_balloon_scene(balloon_scene, resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> Node:
+ if balloon_scene is String:
+ balloon_scene = load(balloon_scene)
+ if balloon_scene is PackedScene:
+ balloon_scene = balloon_scene.instantiate()
+
+ var balloon: Node = balloon_scene
+ get_current_scene.call().add_child(balloon)
+ if balloon.has_method(&"start"):
+ balloon.start(resource, title, extra_game_states)
+ elif balloon.has_method(&"Start"):
+ balloon.Start(resource, title, extra_game_states)
+ else:
+ assert(false, DialogueConstants.translate(&"runtime.dialogue_balloon_missing_start_method"))
+ return balloon
+
+
+# Get the path to the example balloon
+func _get_example_balloon_path() -> String:
+ var is_small_window: bool = ProjectSettings.get_setting("display/window/size/viewport_width") < 400
+ var balloon_path: String = "/example_balloon/small_example_balloon.tscn" if is_small_window else "/example_balloon/example_balloon.tscn"
+ return get_script().resource_path.get_base_dir() + balloon_path
+
+
+### Dotnet bridge
+
+
+func _get_dotnet_dialogue_manager() -> Node:
+ return load(get_script().resource_path.get_base_dir() + "/DialogueManager.cs").new()
+
+
+func _bridge_get_next_dialogue_line(resource: DialogueResource, key: String, extra_game_states: Array = []) -> void:
+ # dotnet needs at least one await tick of the signal gets called too quickly
+ await get_tree().process_frame
+
+ var line = await get_next_dialogue_line(resource, key, extra_game_states)
+ bridge_get_next_dialogue_line_completed.emit(line)
+
+
+func _bridge_mutate(mutation: Dictionary, extra_game_states: Array, is_inline_mutation: bool = false) -> void:
+ await mutate(mutation, extra_game_states, is_inline_mutation)
+ bridge_mutated.emit()
+
+
+### Helpers
+
+
+# Get a line by its ID
+func get_line(resource: DialogueResource, key: String, extra_game_states: Array) -> DialogueLine:
+ key = key.strip_edges()
+
+ # See if we were given a stack instead of just the one key
+ var stack: Array = key.split("|")
+ key = stack.pop_front()
+ var id_trail: String = "" if stack.size() == 0 else "|" + "|".join(stack)
+
+ # Key is blank so just use the first title
+ if key == null or key == "":
+ key = resource.first_title
+
+ # See if we just ended the conversation
+ if key in [DialogueConstants.ID_END, DialogueConstants.ID_NULL, null]:
+ if stack.size() > 0:
+ return await get_line(resource, "|".join(stack), extra_game_states)
+ else:
+ return null
+ elif key == DialogueConstants.ID_END_CONVERSATION:
+ return null
+
+ # See if it is a title
+ if key.begins_with("~ "):
+ key = key.substr(2)
+ if resource.titles.has(key):
+ key = resource.titles.get(key)
+
+ if key in resource.titles.values():
+ passed_title.emit(resource.titles.find_key(key))
+
+ if not resource.lines.has(key):
+ assert(false, DialogueConstants.translate(&"errors.key_not_found").format({ key = key }))
+
+ var data: Dictionary = resource.lines.get(key)
+
+ # This title key points to another title key so we should jump there instead
+ if data.type == DialogueConstants.TYPE_TITLE and data.next_id in resource.titles.values():
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+
+ # Check for weighted random lines
+ if data.has(&"siblings"):
+ var target_weight: float = randf_range(0, data.siblings.reduce(func(total, sibling): return total + sibling.weight, 0))
+ var cummulative_weight: float = 0
+ for sibling in data.siblings:
+ if target_weight < cummulative_weight + sibling.weight:
+ data = resource.lines.get(sibling.id)
+ break
+ else:
+ cummulative_weight += sibling.weight
+
+ # Check condtiions
+ if data.type == DialogueConstants.TYPE_CONDITION:
+ # "else" will have no actual condition
+ if await check_condition(data, extra_game_states):
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+ else:
+ return await get_line(resource, data.next_conditional_id + id_trail, extra_game_states)
+
+ # Evaluate jumps
+ elif data.type == DialogueConstants.TYPE_GOTO:
+ if data.is_snippet:
+ id_trail = "|" + data.next_id_after + id_trail
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+
+ elif data.type == DialogueConstants.TYPE_DIALOGUE:
+ if not data.has(&"id"):
+ data.id = key
+
+ # Set up a line object
+ var line: DialogueLine = await create_dialogue_line(data, extra_game_states)
+
+ # If the jump point somehow has no content then just end
+ if not line: return null
+
+ # If we are the first of a list of responses then get the other ones
+ if data.type == DialogueConstants.TYPE_RESPONSE:
+ # Note: For some reason C# has occasional issues with using the responses property directly
+ # so instead we use set and get here.
+ line.set(&"responses", await get_responses(data.get(&"responses", []), resource, id_trail, extra_game_states))
+ return line
+
+ # Inject the next node's responses if they have any
+ if resource.lines.has(line.next_id):
+ var next_line: Dictionary = resource.lines.get(line.next_id)
+
+ # If the response line is marked as a title then make sure to emit the passed_title signal.
+ if line.next_id in resource.titles.values():
+ passed_title.emit(resource.titles.find_key(line.next_id))
+
+ # If the responses come from a snippet then we need to come back here afterwards
+ if next_line.type == DialogueConstants.TYPE_GOTO and next_line.is_snippet:
+ id_trail = "|" + next_line.next_id_after + id_trail
+
+ # If the next line is a title then check where it points to see if that is a set of responses.
+ if next_line.type == DialogueConstants.TYPE_GOTO and resource.lines.has(next_line.next_id):
+ next_line = resource.lines.get(next_line.next_id)
+
+ if next_line != null and next_line.type == DialogueConstants.TYPE_RESPONSE:
+ # Note: For some reason C# has occasional issues with using the responses property directly
+ # so instead we use set and get here.
+ line.set(&"responses", await get_responses(next_line.get(&"responses", []), resource, id_trail, extra_game_states))
+
+ line.next_id = "|".join(stack) if line.next_id == DialogueConstants.ID_NULL else line.next_id + id_trail
+ return line
+
+
+# Show a message or crash with error
+func show_error_for_missing_state_value(message: String, will_show: bool = true) -> void:
+ if not will_show: return
+
+ if DialogueSettings.get_setting(&"ignore_missing_state_values", false):
+ push_error(message)
+ elif will_show:
+ # If you're here then you're missing a method or property in your game state. The error
+ # message down in the debugger will give you some more information.
+ assert(false, message)
+
+
+# Translate a string
+func translate(data: Dictionary) -> String:
+ if translation_source == TranslationSource.None:
+ return data.text
+
+ if data.translation_key == "" or data.translation_key == data.text:
+ return tr(data.text)
+ else:
+ # Line IDs work slightly differently depending on whether the translation came from a
+ # CSV or a PO file. CSVs use the line ID (or the line itself) as the translatable string
+ # whereas POs use the ID as context and the line itself as the translatable string.
+ match translation_source:
+ TranslationSource.PO:
+ return tr(data.text, StringName(data.translation_key))
+
+ TranslationSource.CSV:
+ return tr(data.translation_key)
+
+ TranslationSource.Guess:
+ var translation_files: Array = ProjectSettings.get_setting(&"internationalization/locale/translations")
+ if translation_files.filter(func(f: String): return f.get_extension() in [&"po", &"mo"]).size() > 0:
+ # Assume PO
+ return tr(data.text, StringName(data.translation_key))
+ else:
+ # Assume CSV
+ return tr(data.translation_key)
+
+ return tr(data.translation_key)
+
+
+# Create a line of dialogue
+func create_dialogue_line(data: Dictionary, extra_game_states: Array) -> DialogueLine:
+ match data.type:
+ DialogueConstants.TYPE_DIALOGUE:
+ var resolved_data: ResolvedLineData = await get_resolved_line_data(data, extra_game_states)
+ return DialogueLine.new({
+ id = data.get(&"id", ""),
+ type = DialogueConstants.TYPE_DIALOGUE,
+ next_id = data.next_id,
+ character = await get_resolved_character(data, extra_game_states),
+ character_replacements = data.character_replacements,
+ text = resolved_data.text,
+ text_replacements = data.text_replacements,
+ translation_key = data.translation_key,
+ pauses = resolved_data.pauses,
+ speeds = resolved_data.speeds,
+ inline_mutations = resolved_data.mutations,
+ time = resolved_data.time,
+ tags = data.get(&"tags", []),
+ extra_game_states = extra_game_states
+ })
+
+ DialogueConstants.TYPE_RESPONSE:
+ return DialogueLine.new({
+ id = data.get(&"id", ""),
+ type = DialogueConstants.TYPE_RESPONSE,
+ next_id = data.next_id,
+ tags = data.get(&"tags", []),
+ extra_game_states = extra_game_states
+ })
+
+ DialogueConstants.TYPE_MUTATION:
+ return DialogueLine.new({
+ id = data.get(&"id", ""),
+ type = DialogueConstants.TYPE_MUTATION,
+ next_id = data.next_id,
+ mutation = data.mutation,
+ extra_game_states = extra_game_states
+ })
+
+ return null
+
+
+# Create a response
+func create_response(data: Dictionary, extra_game_states: Array) -> DialogueResponse:
+ var resolved_data: ResolvedLineData = await get_resolved_line_data(data, extra_game_states)
+ return DialogueResponse.new({
+ id = data.get(&"id", ""),
+ type = DialogueConstants.TYPE_RESPONSE,
+ next_id = data.next_id,
+ is_allowed = data.is_allowed,
+ character = await get_resolved_character(data, extra_game_states),
+ character_replacements = data.get(&"character_replacements", [] as Array[Dictionary]),
+ text = resolved_data.text,
+ text_replacements = data.text_replacements,
+ tags = data.get(&"tags", []),
+ translation_key = data.translation_key
+ })
+
+
+# Get the current game states
+func get_game_states(extra_game_states: Array) -> Array:
+ if not _has_loaded_autoloads:
+ _has_loaded_autoloads = true
+ # Add any autoloads to a generic state so we can refer to them by name
+ for child in get_tree().root.get_children():
+ # Ignore the dialogue manager
+ if child.name == &"DialogueManager": continue
+ # Ignore the current main scene
+ if get_tree().current_scene and child.name == get_tree().current_scene.name: continue
+ # Add the node to our known autoloads
+ _autoloads[child.name] = child
+ game_states = [_autoloads]
+ # Add any other state shortcuts from settings
+ for node_name in DialogueSettings.get_setting(&"states", []):
+ var state: Node = get_node_or_null("/root/" + node_name)
+ if state:
+ game_states.append(state)
+
+ var current_scene: Node = get_current_scene.call()
+ var unique_states: Array = []
+ for state in extra_game_states + [current_scene] + game_states:
+ if state != null and not unique_states.has(state):
+ unique_states.append(state)
+ return unique_states
+
+
+# Check if a condition is met
+func check_condition(data: Dictionary, extra_game_states: Array) -> bool:
+ if data.get(&"condition", null) == null: return true
+ if data.condition.size() == 0: return true
+
+ return await resolve(data.condition.expression.duplicate(true), extra_game_states)
+
+
+# Make a change to game state or run a method
+func mutate(mutation: Dictionary, extra_game_states: Array, is_inline_mutation: bool = false) -> void:
+ var expression: Array[Dictionary] = mutation.expression
+
+ # Handle built in mutations
+ if expression[0].type == DialogueConstants.TOKEN_FUNCTION and expression[0].function in [&"wait", &"debug"]:
+ var args: Array = await resolve_each(expression[0].value, extra_game_states)
+ match expression[0].function:
+ &"wait":
+ mutated.emit(mutation)
+ await get_tree().create_timer(float(args[0])).timeout
+ return
+
+ &"debug":
+ prints("Debug:", args)
+ await get_tree().process_frame
+
+ # Or pass through to the resolver
+ else:
+ if not mutation_contains_assignment(mutation.expression) and not is_inline_mutation:
+ mutated.emit(mutation)
+
+ if mutation.get("is_blocking", true):
+ await resolve(mutation.expression.duplicate(true), extra_game_states)
+ return
+ else:
+ resolve(mutation.expression.duplicate(true), extra_game_states)
+
+ # Wait one frame to give the dialogue handler a chance to yield
+ await get_tree().process_frame
+
+
+func mutation_contains_assignment(mutation: Array) -> bool:
+ for token in mutation:
+ if token.type == DialogueConstants.TOKEN_ASSIGNMENT:
+ return true
+ return false
+
+
+func resolve_each(array: Array, extra_game_states: Array) -> Array:
+ var results: Array = []
+ for item in array:
+ results.append(await resolve(item.duplicate(true), extra_game_states))
+ return results
+
+
+# Replace an array of line IDs with their response prompts
+func get_responses(ids: Array, resource: DialogueResource, id_trail: String, extra_game_states: Array) -> Array[DialogueResponse]:
+ var responses: Array[DialogueResponse] = []
+ for id in ids:
+ var data: Dictionary = resource.lines.get(id).duplicate(true)
+ data.is_allowed = await check_condition(data, extra_game_states)
+ if DialogueSettings.get_setting(&"include_all_responses", false) or data.is_allowed:
+ var response: DialogueResponse = await create_response(data, extra_game_states)
+ response.next_id += id_trail
+ responses.append(response)
+
+ return responses
+
+
+# Get a value on the current scene or game state
+func get_state_value(property: String, extra_game_states: Array):
+ # Special case for static primitive calls
+ if property == "Color":
+ return Color()
+ elif property == "Vector2":
+ return Vector2.ZERO
+ elif property == "Vector3":
+ return Vector3.ZERO
+ elif property == "Vector4":
+ return Vector4.ZERO
+ elif property == "Quaternian":
+ return Quaternion()
+
+ var expression = Expression.new()
+ if expression.parse(property) != OK:
+ assert(false, DialogueConstants.translate(&"runtime.invalid_expression").format({ expression = property, error = expression.get_error_text() }))
+
+ for state in get_game_states(extra_game_states):
+ if typeof(state) == TYPE_DICTIONARY:
+ if state.has(property):
+ return state.get(property)
+ else:
+ var result = expression.execute([], state, false)
+ if not expression.has_execute_failed():
+ return result
+
+ if include_singletons and Engine.has_singleton(property):
+ return Engine.get_singleton(property)
+
+ if include_classes:
+ for class_data in ProjectSettings.get_global_class_list():
+ if class_data.get(&"class") == property:
+ return load(class_data.path).new()
+
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.property_not_found").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }))
+
+
+# Set a value on the current scene or game state
+func set_state_value(property: String, value, extra_game_states: Array) -> void:
+ for state in get_game_states(extra_game_states):
+ if typeof(state) == TYPE_DICTIONARY:
+ if state.has(property):
+ state[property] = value
+ return
+ elif thing_has_property(state, property):
+ state.set(property, value)
+ return
+
+ if property.to_snake_case() != property:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.property_not_found_missing_export").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }))
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.property_not_found").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }))
+
+
+# Get the list of state shortcut names
+func _get_state_shortcut_names(extra_game_states: Array) -> String:
+ var states = get_game_states(extra_game_states)
+ states.erase(_autoloads)
+ return ", ".join(states.map(func(s): return "\"%s\"" % (s.name if "name" in s else s)))
+
+
+# Collapse any expressions
+func resolve(tokens: Array, extra_game_states: Array):
+ # Handle groups first
+ for token in tokens:
+ if token.type == DialogueConstants.TOKEN_GROUP:
+ token["type"] = "value"
+ token["value"] = await resolve(token.value, extra_game_states)
+
+ # Then variables/methods
+ var i: int = 0
+ var limit: int = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+
+ if token.type == DialogueConstants.TOKEN_FUNCTION:
+ var function_name: String = token.function
+ var args = await resolve_each(token.value, extra_game_states)
+ if tokens[i - 1].type == DialogueConstants.TOKEN_DOT:
+ # If we are calling a deeper function then we need to collapse the
+ # value into the thing we are calling the function on
+ var caller: Dictionary = tokens[i - 2]
+ if Builtins.is_supported(caller.value):
+ caller["type"] = "value"
+ caller["value"] = Builtins.resolve_method(caller.value, function_name, args)
+ tokens.remove_at(i)
+ tokens.remove_at(i-1)
+ i -= 2
+ elif thing_has_method(caller.value, function_name, args):
+ caller["type"] = "value"
+ caller["value"] = await resolve_thing_method(caller.value, function_name, args)
+ tokens.remove_at(i)
+ tokens.remove_at(i-1)
+ i -= 2
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.method_not_callable").format({ method = function_name, object = str(caller.value) }))
+ else:
+ var found: bool = false
+ match function_name:
+ &"str":
+ token["type"] = "value"
+ token["value"] = str(args[0])
+ found = true
+ &"Vector2":
+ token["type"] = "value"
+ token["value"] = Vector2(args[0], args[1])
+ found = true
+ &"Vector2i":
+ token["type"] = "value"
+ token["value"] = Vector2i(args[0], args[1])
+ found = true
+ &"Vector3":
+ token["type"] = "value"
+ token["value"] = Vector3(args[0], args[1], args[2])
+ found = true
+ &"Vector3i":
+ token["type"] = "value"
+ token["value"] = Vector3i(args[0], args[1], args[2])
+ found = true
+ &"Vector4":
+ token["type"] = "value"
+ token["value"] = Vector4(args[0], args[1], args[2], args[3])
+ found = true
+ &"Vector4i":
+ token["type"] = "value"
+ token["value"] = Vector4i(args[0], args[1], args[2], args[3])
+ found = true
+ &"Quaternion":
+ token["type"] = "value"
+ token["value"] = Quaternion(args[0], args[1], args[2], args[3])
+ found = true
+ &"Callable":
+ token["type"] = "value"
+ match args.size():
+ 0:
+ token["value"] = Callable()
+ 1:
+ token["value"] = Callable(args[0])
+ 2:
+ token["value"] = Callable(args[0], args[1])
+ found = true
+ &"Color":
+ token["type"] = "value"
+ match args.size():
+ 0:
+ token["value"] = Color()
+ 1:
+ token["value"] = Color(args[0])
+ 2:
+ token["value"] = Color(args[0], args[1])
+ 3:
+ token["value"] = Color(args[0], args[1], args[2])
+ 4:
+ token["value"] = Color(args[0], args[1], args[2], args[3])
+ found = true
+ &"load":
+ token["type"] = "value"
+ token["value"] = load(args[0])
+ found = true
+ &"emit":
+ token["type"] = "value"
+ token["value"] = resolve_signal(args, extra_game_states)
+ found = true
+ _:
+ for state in get_game_states(extra_game_states):
+ if thing_has_method(state, function_name, args):
+ token["type"] = "value"
+ token["value"] = await resolve_thing_method(state, function_name, args)
+ found = true
+ break
+
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.method_not_found").format({
+ method = args[0] if function_name in ["call", "call_deferred"] else function_name,
+ states = _get_state_shortcut_names(extra_game_states)
+ }), not found)
+
+ elif token.type == DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
+ var value
+ if i > 0 and tokens[i - 1].type == DialogueConstants.TOKEN_DOT:
+ # If we are deep referencing then we need to get the parent object.
+ # `parent.value` is the actual object and `token.variable` is the name of
+ # the property within it.
+ value = tokens[i - 2].value[token.variable]
+ # Clean up the previous tokens
+ token.erase("variable")
+ tokens.remove_at(i - 1)
+ tokens.remove_at(i - 2)
+ i -= 2
+ else:
+ # Otherwise we can just get this variable as a normal state reference
+ value = get_state_value(token.variable, extra_game_states)
+
+ var index = await resolve(token.value, extra_game_states)
+ if typeof(value) == TYPE_DICTIONARY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # If the next token is an assignment then we need to leave this as a reference
+ # so that it can be resolved once everything ahead of it has been resolved
+ token["type"] = "dictionary"
+ token["value"] = value
+ token["key"] = index
+ else:
+ if value.has(index):
+ token["type"] = "value"
+ token["value"] = value[index]
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.key_not_found").format({ key = str(index), dictionary = token.variable }))
+ elif typeof(value) == TYPE_ARRAY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # If the next token is an assignment then we need to leave this as a reference
+ # so that it can be resolved once everything ahead of it has been resolved
+ token["type"] = "array"
+ token["value"] = value
+ token["key"] = index
+ else:
+ if index >= 0 and index < value.size():
+ token["type"] = "value"
+ token["value"] = value[index]
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = index, array = token.variable }))
+
+ elif token.type == DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE:
+ var dictionary: Dictionary = tokens[i - 1]
+ var index = await resolve(token.value, extra_game_states)
+ var value = dictionary.value
+ if typeof(value) == TYPE_DICTIONARY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # If the next token is an assignment then we need to leave this as a reference
+ # so that it can be resolved once everything ahead of it has been resolved
+ dictionary["type"] = "dictionary"
+ dictionary["key"] = index
+ dictionary["value"] = value
+ tokens.remove_at(i)
+ i -= 1
+ else:
+ if dictionary.value.has(index):
+ dictionary["value"] = value.get(index)
+ tokens.remove_at(i)
+ i -= 1
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.key_not_found").format({ key = str(index), dictionary = value }))
+ elif typeof(value) == TYPE_ARRAY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # If the next token is an assignment then we need to leave this as a reference
+ # so that it can be resolved once everything ahead of it has been resolved
+ dictionary["type"] = "array"
+ dictionary["value"] = value
+ dictionary["key"] = index
+ tokens.remove_at(i)
+ i -= 1
+ else:
+ if index >= 0 and index < value.size():
+ dictionary["value"] = value[index]
+ tokens.remove_at(i)
+ i -= 1
+ else:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = index, array = value }))
+
+ elif token.type == DialogueConstants.TOKEN_ARRAY:
+ token["type"] = "value"
+ token["value"] = await resolve_each(token.value, extra_game_states)
+
+ elif token.type == DialogueConstants.TOKEN_DICTIONARY:
+ token["type"] = "value"
+ var dictionary = {}
+ for key in token.value.keys():
+ var resolved_key = await resolve([key], extra_game_states)
+ var preresolved_value = token.value.get(key)
+ if typeof(preresolved_value) != TYPE_ARRAY:
+ preresolved_value = [preresolved_value]
+ var resolved_value = await resolve(preresolved_value, extra_game_states)
+ dictionary[resolved_key] = resolved_value
+ token["value"] = dictionary
+
+ elif token.type == DialogueConstants.TOKEN_VARIABLE or token.type == DialogueConstants.TOKEN_NUMBER:
+ if str(token.value) == "null":
+ token["type"] = "value"
+ token["value"] = null
+ elif tokens[i - 1].type == DialogueConstants.TOKEN_DOT:
+ var caller: Dictionary = tokens[i - 2]
+ var property = token.value
+ if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # If the next token is an assignment then we need to leave this as a reference
+ # so that it can be resolved once everything ahead of it has been resolved
+ caller["type"] = "property"
+ caller["property"] = property
+ else:
+ # If we are requesting a deeper property then we need to collapse the
+ # value into the thing we are referencing from
+ caller["type"] = "value"
+ if Builtins.is_supported(caller.value):
+ caller["value"] = Builtins.resolve_property(caller.value, property)
+ else:
+ caller["value"] = caller.value.get(property)
+ tokens.remove_at(i)
+ tokens.remove_at(i-1)
+ i -= 2
+ elif tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT:
+ # It's a normal variable but we will be assigning to it so don't resolve
+ # it until everything after it has been resolved
+ token["type"] = "variable"
+ else:
+ token["type"] = "value"
+ token["value"] = get_state_value(str(token.value), extra_game_states)
+
+ i += 1
+
+ # Then multiply and divide
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_OPERATOR and token.value in ["*", "/", "%"]:
+ token["type"] = "value"
+ token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value)
+ tokens.remove_at(i+1)
+ tokens.remove_at(i-1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ # Then addition and subtraction
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_OPERATOR and token.value in ["+", "-"]:
+ token["type"] = "value"
+ token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value)
+ tokens.remove_at(i+1)
+ tokens.remove_at(i-1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ # Then negations
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_NOT:
+ token["type"] = "value"
+ token["value"] = not tokens[i+1].value
+ tokens.remove_at(i+1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ # Then comparisons
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_COMPARISON:
+ token["type"] = "value"
+ token["value"] = compare(token.value, tokens[i-1].value, tokens[i+1].value)
+ tokens.remove_at(i+1)
+ tokens.remove_at(i-1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ # Then and/or
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_AND_OR:
+ token["type"] = "value"
+ token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value)
+ tokens.remove_at(i+1)
+ tokens.remove_at(i-1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ # Lastly, resolve any assignments
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+ if token.type == DialogueConstants.TOKEN_ASSIGNMENT:
+ var lhs: Dictionary = tokens[i - 1]
+ var value
+
+ match lhs.type:
+ &"variable":
+ value = apply_operation(token.value, get_state_value(lhs.value, extra_game_states), tokens[i+1].value)
+ set_state_value(lhs.value, value, extra_game_states)
+ &"property":
+ value = apply_operation(token.value, lhs.value.get(lhs.property), tokens[i+1].value)
+ if typeof(lhs.value) == TYPE_DICTIONARY:
+ lhs.value[lhs.property] = value
+ else:
+ lhs.value.set(lhs.property, value)
+ &"dictionary":
+ value = apply_operation(token.value, lhs.value.get(lhs.key, null), tokens[i+1].value)
+ lhs.value[lhs.key] = value
+ &"array":
+ show_error_for_missing_state_value(
+ DialogueConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = lhs.key, array = lhs.value }),
+ lhs.key >= lhs.value.size()
+ )
+ value = apply_operation(token.value, lhs.value[lhs.key], tokens[i+1].value)
+ lhs.value[lhs.key] = value
+ _:
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.left_hand_size_cannot_be_assigned_to"))
+
+ token["type"] = "value"
+ token["value"] = value
+ tokens.remove_at(i+1)
+ tokens.remove_at(i-1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DialogueConstants.translate(&"runtime.something_went_wrong"))
+
+ return tokens[0].value
+
+
+func compare(operator: String, first_value, second_value) -> bool:
+ match operator:
+ &"in":
+ if first_value == null or second_value == null:
+ return false
+ else:
+ return first_value in second_value
+ &"<":
+ if first_value == null:
+ return true
+ elif second_value == null:
+ return false
+ else:
+ return first_value < second_value
+ &">":
+ if first_value == null:
+ return false
+ elif second_value == null:
+ return true
+ else:
+ return first_value > second_value
+ &"<=":
+ if first_value == null:
+ return true
+ elif second_value == null:
+ return false
+ else:
+ return first_value <= second_value
+ &">=":
+ if first_value == null:
+ return false
+ elif second_value == null:
+ return true
+ else:
+ return first_value >= second_value
+ &"==":
+ if first_value == null:
+ if typeof(second_value) == TYPE_BOOL:
+ return second_value == false
+ else:
+ return second_value == null
+ else:
+ return first_value == second_value
+ &"!=":
+ if first_value == null:
+ if typeof(second_value) == TYPE_BOOL:
+ return second_value == true
+ else:
+ return second_value != null
+ else:
+ return first_value != second_value
+
+ return false
+
+
+func apply_operation(operator: String, first_value, second_value):
+ match operator:
+ &"=":
+ return second_value
+ &"+", &"+=":
+ return first_value + second_value
+ &"-", &"-=":
+ return first_value - second_value
+ &"/", &"/=":
+ return first_value / second_value
+ &"*", &"*=":
+ return first_value * second_value
+ &"%":
+ return first_value % second_value
+ &"and":
+ return first_value and second_value
+ &"or":
+ return first_value or second_value
+
+ assert(false, DialogueConstants.translate(&"runtime.unknown_operator"))
+
+
+# Check if a dialogue line contains meaningful information
+func is_valid(line: DialogueLine) -> bool:
+ if line == null:
+ return false
+ if line.type == DialogueConstants.TYPE_MUTATION and line.mutation == null:
+ return false
+ if line.type == DialogueConstants.TYPE_RESPONSE and line.get(&"responses").size() == 0:
+ return false
+ return true
+
+
+func thing_has_method(thing, method: String, args: Array) -> bool:
+ if Builtins.is_supported(thing):
+ return thing != _autoloads
+
+ if method in [&"call", &"call_deferred"]:
+ return thing.has_method(args[0])
+
+ if method == &"emit_signal":
+ return thing.has_signal(args[0])
+
+ if thing.has_method(method):
+ return true
+
+ if method.to_snake_case() != method and DialogueSettings.check_for_dotnet_solution():
+ # If we get this far then the method might be a C# method with a Task return type
+ return _get_dotnet_dialogue_manager().ThingHasMethod(thing, method)
+
+ return false
+
+
+# Check if a given property exists
+func thing_has_property(thing: Object, property: String) -> bool:
+ if thing == null:
+ return false
+
+ for p in thing.get_property_list():
+ if _node_properties.has(p.name):
+ # Ignore any properties on the base Node
+ continue
+ if p.name == property:
+ return true
+
+ return false
+
+
+func resolve_signal(args: Array, extra_game_states: Array):
+ if args[0] is Signal:
+ args[0] = args[0].get_name()
+
+ for state in get_game_states(extra_game_states):
+ if typeof(state) == TYPE_DICTIONARY:
+ continue
+ elif state.has_signal(args[0]):
+ match args.size():
+ 1:
+ state.emit_signal(args[0])
+ 2:
+ state.emit_signal(args[0], args[1])
+ 3:
+ state.emit_signal(args[0], args[1], args[2])
+ 4:
+ state.emit_signal(args[0], args[1], args[2], args[3])
+ 5:
+ state.emit_signal(args[0], args[1], args[2], args[3], args[4])
+ 6:
+ state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5])
+ 7:
+ state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
+ 8:
+ state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7])
+ return
+
+ # The signal hasn't been found anywhere
+ show_error_for_missing_state_value(DialogueConstants.translate(&"runtime.signal_not_found").format({ signal_name = args[0], states = _get_state_shortcut_names(extra_game_states) }))
+
+
+func get_method_info_for(thing, method: String) -> Dictionary:
+ # Use the thing instance id as a key for the caching dictionary.
+ var thing_instance_id: int = thing.get_instance_id()
+ if not _method_info_cache.has(thing_instance_id):
+ var methods: Dictionary = {}
+ for m in thing.get_method_list():
+ methods[m.name] = m
+ _method_info_cache[thing_instance_id] = methods
+
+ return _method_info_cache.get(thing_instance_id, {}).get(method)
+
+
+func resolve_thing_method(thing, method: String, args: Array):
+ if Builtins.is_supported(thing):
+ var result = Builtins.resolve_method(thing, method, args)
+ if not Builtins.has_resolve_method_failed():
+ return result
+
+ if thing.has_method(method):
+ # Try to convert any literals to the right type
+ var method_info: Dictionary = get_method_info_for(thing, method)
+ var method_args: Array = method_info.args
+ if method_info.flags & METHOD_FLAG_VARARG == 0 and method_args.size() < args.size():
+ assert(false, DialogueConstants.translate(&"runtime.expected_n_got_n_args").format({ expected = method_args.size(), method = method, received = args.size()}))
+ for i in range(0, args.size()):
+ var m: Dictionary = method_args[i]
+ var to_type:int = typeof(args[i])
+ if m.type == TYPE_ARRAY:
+ match m.hint_string:
+ &"String":
+ to_type = TYPE_PACKED_STRING_ARRAY
+ &"int":
+ to_type = TYPE_PACKED_INT64_ARRAY
+ &"float":
+ to_type = TYPE_PACKED_FLOAT64_ARRAY
+ &"Vector2":
+ to_type = TYPE_PACKED_VECTOR2_ARRAY
+ &"Vector3":
+ to_type = TYPE_PACKED_VECTOR3_ARRAY
+ _:
+ if m.hint_string != "":
+ assert(false, DialogueConstants.translate(&"runtime.unsupported_array_type").format({ type = m.hint_string}))
+ if typeof(args[i]) != to_type:
+ args[i] = convert(args[i], to_type)
+
+ return await thing.callv(method, args)
+
+ # If we get here then it's probably a C# method with a Task return type
+ var dotnet_dialogue_manager = _get_dotnet_dialogue_manager()
+ dotnet_dialogue_manager.ResolveThingMethod(thing, method, args)
+ return await dotnet_dialogue_manager.Resolved
diff --git a/addons/dialogue_manager/dialogue_reponses_menu.gd b/addons/dialogue_manager/dialogue_reponses_menu.gd
new file mode 100644
index 00000000..69a56db9
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_reponses_menu.gd
@@ -0,0 +1,141 @@
+@icon("./assets/responses_menu.svg")
+
+## A [Container] for dialogue responses provided by [b]Dialogue Manager[/b].
+class_name DialogueResponsesMenu extends Container
+
+
+## Emitted when a response is selected.
+signal response_selected(response)
+
+
+## Optionally specify a control to duplicate for each response
+@export var response_template: Control
+
+## The action for accepting a response (is possibly overridden by parent dialogue balloon).
+@export var next_action: StringName = &""
+
+## The list of dialogue responses.
+var responses: Array = []:
+ get:
+ return responses
+ set(value):
+ responses = value
+
+ # Remove any current items
+ for item in get_children():
+ if item == response_template: continue
+
+ remove_child(item)
+ item.queue_free()
+
+ # Add new items
+ if responses.size() > 0:
+ for response in responses:
+ var item: Control
+ if is_instance_valid(response_template):
+ item = response_template.duplicate(DUPLICATE_GROUPS | DUPLICATE_SCRIPTS | DUPLICATE_SIGNALS)
+ item.show()
+ else:
+ item = Button.new()
+ item.name = "Response%d" % get_child_count()
+ if not response.is_allowed:
+ item.name = String(item.name) + "Disallowed"
+ item.disabled = true
+
+ # If the item has a response property then use that
+ if "response" in item:
+ item.response = response
+ # Otherwise assume we can just set the text
+ else:
+ item.text = response.text
+
+ item.set_meta("response", response)
+
+ add_child(item)
+
+ _configure_focus()
+
+
+func _ready() -> void:
+ visibility_changed.connect(func():
+ if visible and get_menu_items().size() > 0:
+ get_menu_items()[0].grab_focus()
+ )
+
+ if is_instance_valid(response_template):
+ response_template.hide()
+
+
+## Get the selectable items in the menu.
+func get_menu_items() -> Array:
+ var items: Array = []
+ for child in get_children():
+ if not child.visible: continue
+ if "Disallowed" in child.name: continue
+ items.append(child)
+
+ return items
+
+
+## [b]DEPRECATED[/b]. Do not use.
+func set_responses(next_responses: Array) -> void:
+ self.responses = next_responses
+
+
+#region Internal
+
+
+# Prepare the menu for keyboard and mouse navigation.
+func _configure_focus() -> void:
+ var items = get_menu_items()
+ for i in items.size():
+ var item: Control = items[i]
+
+ item.focus_mode = Control.FOCUS_ALL
+
+ item.focus_neighbor_left = item.get_path()
+ item.focus_neighbor_right = item.get_path()
+
+ if i == 0:
+ item.focus_neighbor_top = item.get_path()
+ item.focus_previous = item.get_path()
+ else:
+ item.focus_neighbor_top = items[i - 1].get_path()
+ item.focus_previous = items[i - 1].get_path()
+
+ if i == items.size() - 1:
+ item.focus_neighbor_bottom = item.get_path()
+ item.focus_next = item.get_path()
+ else:
+ item.focus_neighbor_bottom = items[i + 1].get_path()
+ item.focus_next = items[i + 1].get_path()
+
+ item.mouse_entered.connect(_on_response_mouse_entered.bind(item))
+ item.gui_input.connect(_on_response_gui_input.bind(item, item.get_meta("response")))
+
+ items[0].grab_focus()
+
+
+#endregion
+
+#region Signals
+
+
+func _on_response_mouse_entered(item: Control) -> void:
+ if "Disallowed" in item.name: return
+
+ item.grab_focus()
+
+
+func _on_response_gui_input(event: InputEvent, item: Control, response) -> void:
+ if "Disallowed" in item.name: return
+
+ get_viewport().set_input_as_handled()
+
+ if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT:
+ response_selected.emit(response)
+ elif event.is_action_pressed(&"ui_accept" if next_action.is_empty() else next_action) and item in get_menu_items():
+ response_selected.emit(response)
+
+
+#endregion
diff --git a/addons/dialogue_manager/dialogue_resource.gd b/addons/dialogue_manager/dialogue_resource.gd
new file mode 100644
index 00000000..68f52a02
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_resource.gd
@@ -0,0 +1,43 @@
+@tool
+@icon("./assets/icon.svg")
+
+## A collection of dialogue lines for use with [code]DialogueManager[/code].
+class_name DialogueResource extends Resource
+
+
+const _DialogueManager = preload("./dialogue_manager.gd")
+const DialogueLine = preload("./dialogue_line.gd")
+
+## A list of state shortcuts
+@export var using_states: PackedStringArray = []
+
+## A map of titles and the lines they point to.
+@export var titles: Dictionary = {}
+
+## A list of character names.
+@export var character_names: PackedStringArray = []
+
+## The first title in the file.
+@export var first_title: String = ""
+
+## A map of the encoded lines of dialogue.
+@export var lines: Dictionary = {}
+
+## raw version of the text
+@export var raw_text: String
+
+
+## Get the next printable line of dialogue, starting from a referenced line ([code]title[/code] can
+## be a title string or a stringified line number). Runs any mutations along the way and then returns
+## the first dialogue line encountered.
+func get_next_dialogue_line(title: String, extra_game_states: Array = [], mutation_behaviour: _DialogueManager.MutationBehaviour = _DialogueManager.MutationBehaviour.Wait) -> DialogueLine:
+ return await Engine.get_singleton("DialogueManager").get_next_dialogue_line(self, title, extra_game_states, mutation_behaviour)
+
+
+## Get the list of any titles found in the file.
+func get_titles() -> PackedStringArray:
+ return titles.keys()
+
+
+func _to_string() -> String:
+ return "" % [",".join(titles.keys())]
diff --git a/addons/dialogue_manager/dialogue_response.gd b/addons/dialogue_manager/dialogue_response.gd
new file mode 100644
index 00000000..92cec241
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_response.gd
@@ -0,0 +1,62 @@
+## A response to a line of dialogue, usualy attached to a [code]DialogueLine[/code].
+class_name DialogueResponse extends RefCounted
+
+
+const _DialogueConstants = preload("./constants.gd")
+
+
+## The ID of this response
+var id: String
+
+## The internal type of this dialogue object, always set to [code]TYPE_RESPONSE[/code].
+var type: String = _DialogueConstants.TYPE_RESPONSE
+
+## The next line ID to use if this response is selected by the player.
+var next_id: String = ""
+
+## [code]true[/code] if the condition of this line was met.
+var is_allowed: bool = true
+
+## A character (depending on the "characters in responses" behaviour setting).
+var character: String = ""
+
+## A dictionary of varialbe replaces for the character name. Generally for internal use only.
+var character_replacements: Array[Dictionary] = []
+
+## The prompt for this response.
+var text: String = ""
+
+## A dictionary of variable replaces for the text. Generally for internal use only.
+var text_replacements: Array[Dictionary] = []
+
+## Any #tags
+var tags: PackedStringArray = []
+
+## The key to use for translating the text.
+var translation_key: String = ""
+
+
+func _init(data: Dictionary = {}) -> void:
+ if data.size() > 0:
+ id = data.id
+ type = data.type
+ next_id = data.next_id
+ is_allowed = data.is_allowed
+ character = data.character
+ character_replacements = data.character_replacements
+ text = data.text
+ text_replacements = data.text_replacements
+ tags = data.tags
+ translation_key = data.translation_key
+
+
+func _to_string() -> String:
+ return "" % text
+
+
+func get_tag_value(tag_name: String) -> String:
+ var wrapped := "%s=" % tag_name
+ for t in tags:
+ if t.begins_with(wrapped):
+ return t.replace(wrapped, "").strip_edges()
+ return ""
diff --git a/addons/dialogue_manager/editor_translation_parser_plugin.gd b/addons/dialogue_manager/editor_translation_parser_plugin.gd
new file mode 100644
index 00000000..7c019849
--- /dev/null
+++ b/addons/dialogue_manager/editor_translation_parser_plugin.gd
@@ -0,0 +1,44 @@
+extends EditorTranslationParserPlugin
+
+
+const DialogueConstants = preload("./constants.gd")
+const DialogueSettings = preload("./settings.gd")
+const DialogueManagerParser = preload("./components/parser.gd")
+const DialogueManagerParseResult = preload("./components/parse_result.gd")
+
+
+func _parse_file(path: String, msgids: Array, msgids_context_plural: Array) -> void:
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ var text: String = file.get_as_text()
+
+ var data: DialogueManagerParseResult = DialogueManagerParser.parse_string(text, path)
+ var known_keys: PackedStringArray = PackedStringArray([])
+
+ # Add all character names if settings ask for it
+ if DialogueSettings.get_setting("export_characters_in_translation", true):
+ var character_names: PackedStringArray = data.character_names
+ for character_name in character_names:
+ if character_name in known_keys: continue
+
+ known_keys.append(character_name)
+
+ msgids_context_plural.append([character_name.replace('"', '\\"'), "dialogue", ""])
+
+ # Add all dialogue lines and responses
+ var dialogue: Dictionary = data.lines
+ for key in dialogue.keys():
+ var line: Dictionary = dialogue.get(key)
+
+ if not line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]: continue
+ if line.translation_key in known_keys: continue
+
+ known_keys.append(line.translation_key)
+
+ if line.translation_key == "" or line.translation_key == line.text:
+ msgids_context_plural.append([line.text.replace('"', '\\"'), "", ""])
+ else:
+ msgids_context_plural.append([line.text.replace('"', '\\"'), line.translation_key.replace('"', '\\"'), ""])
+
+
+func _get_recognized_extensions() -> PackedStringArray:
+ return ["dialogue"]
diff --git a/addons/dialogue_manager/example_balloon/ExampleBalloon.cs b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs
new file mode 100644
index 00000000..a21dc636
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs
@@ -0,0 +1,219 @@
+using Godot;
+using Godot.Collections;
+
+namespace DialogueManagerRuntime
+{
+ public partial class ExampleBalloon : CanvasLayer
+ {
+ [Export] public string NextAction = "ui_accept";
+ [Export] public string SkipAction = "ui_cancel";
+
+
+ Control balloon;
+ RichTextLabel characterLabel;
+ RichTextLabel dialogueLabel;
+ VBoxContainer responsesMenu;
+
+ Resource resource;
+ Array temporaryGameStates = new Array();
+ bool isWaitingForInput = false;
+ bool willHideBalloon = false;
+
+ DialogueLine dialogueLine;
+ DialogueLine DialogueLine
+ {
+ get => dialogueLine;
+ set
+ {
+ isWaitingForInput = false;
+ balloon.FocusMode = Control.FocusModeEnum.All;
+ balloon.GrabFocus();
+
+ if (value == null)
+ {
+ QueueFree();
+ return;
+ }
+
+ dialogueLine = value;
+ UpdateDialogue();
+ }
+ }
+
+
+ public override void _Ready()
+ {
+ balloon = GetNode("%Balloon");
+ characterLabel = GetNode("%CharacterLabel");
+ dialogueLabel = GetNode("%DialogueLabel");
+ responsesMenu = GetNode("%ResponsesMenu");
+
+ balloon.Hide();
+
+ balloon.GuiInput += (@event) =>
+ {
+ if ((bool)dialogueLabel.Get("is_typing"))
+ {
+ bool mouseWasClicked = @event is InputEventMouseButton && (@event as InputEventMouseButton).ButtonIndex == MouseButton.Left && @event.IsPressed();
+ bool skipButtonWasPressed = @event.IsActionPressed(SkipAction);
+ if (mouseWasClicked || skipButtonWasPressed)
+ {
+ GetViewport().SetInputAsHandled();
+ dialogueLabel.Call("skip_typing");
+ return;
+ }
+ }
+
+ if (!isWaitingForInput) return;
+ if (dialogueLine.Responses.Count > 0) return;
+
+ GetViewport().SetInputAsHandled();
+
+ if (@event is InputEventMouseButton && @event.IsPressed() && (@event as InputEventMouseButton).ButtonIndex == MouseButton.Left)
+ {
+ Next(dialogueLine.NextId);
+ }
+ else if (@event.IsActionPressed(NextAction) && GetViewport().GuiGetFocusOwner() == balloon)
+ {
+ Next(dialogueLine.NextId);
+ }
+ };
+
+ if (string.IsNullOrEmpty((string)responsesMenu.Get("next_action")))
+ {
+ responsesMenu.Set("next_action", NextAction);
+ }
+ responsesMenu.Connect("response_selected", Callable.From((DialogueResponse response) =>
+ {
+ Next(response.NextId);
+ }));
+
+ DialogueManager.Mutated += OnMutated;
+ }
+
+
+ public override void _ExitTree()
+ {
+ DialogueManager.Mutated -= OnMutated;
+ }
+
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ // Only the balloon is allowed to handle input while it's showing
+ GetViewport().SetInputAsHandled();
+ }
+
+
+ public override async void _Notification(int what)
+ {
+ // Detect a change of locale and update the current dialogue line to show the new language
+ if (what == NotificationTranslationChanged)
+ {
+ float visibleRatio = dialogueLabel.VisibleRatio;
+ DialogueLine = await DialogueManager.GetNextDialogueLine(resource, DialogueLine.Id, temporaryGameStates);
+ if (visibleRatio < 1.0f)
+ {
+ dialogueLabel.Call("skip_typing");
+ }
+ }
+ }
+
+
+ public async void Start(Resource dialogueResource, string title, Array extraGameStates = null)
+ {
+ temporaryGameStates = extraGameStates ?? new Array();
+ isWaitingForInput = false;
+ resource = dialogueResource;
+
+ DialogueLine = await DialogueManager.GetNextDialogueLine(resource, title, temporaryGameStates);
+ }
+
+
+ public async void Next(string nextId)
+ {
+ DialogueLine = await DialogueManager.GetNextDialogueLine(resource, nextId, temporaryGameStates);
+ }
+
+
+ #region Helpers
+
+
+ private async void UpdateDialogue()
+ {
+ if (!IsNodeReady())
+ {
+ await ToSignal(this, SignalName.Ready);
+ }
+
+ // Set up the character name
+ characterLabel.Visible = !string.IsNullOrEmpty(dialogueLine.Character);
+ characterLabel.Text = Tr(dialogueLine.Character, "dialogue");
+
+ // Set up the dialogue
+ dialogueLabel.Hide();
+ dialogueLabel.Set("dialogue_line", dialogueLine);
+
+ // Set up the responses
+ responsesMenu.Hide();
+ responsesMenu.Set("responses", dialogueLine.Responses);
+
+ // Type out the text
+ balloon.Show();
+ willHideBalloon = false;
+ dialogueLabel.Show();
+ if (!string.IsNullOrEmpty(dialogueLine.Text))
+ {
+ dialogueLabel.Call("type_out");
+ await ToSignal(dialogueLabel, "finished_typing");
+ }
+
+ // Wait for input
+ if (dialogueLine.Responses.Count > 0)
+ {
+ balloon.FocusMode = Control.FocusModeEnum.None;
+ responsesMenu.Show();
+ }
+ else if (!string.IsNullOrEmpty(dialogueLine.Time))
+ {
+ float time = 0f;
+ if (!float.TryParse(dialogueLine.Time, out time))
+ {
+ time = dialogueLine.Text.Length * 0.02f;
+ }
+ await ToSignal(GetTree().CreateTimer(time), "timeout");
+ Next(dialogueLine.NextId);
+ }
+ else
+ {
+ isWaitingForInput = true;
+ balloon.FocusMode = Control.FocusModeEnum.All;
+ balloon.GrabFocus();
+ }
+ }
+
+
+ #endregion
+
+
+ #region signals
+
+
+ private void OnMutated(Dictionary _mutation)
+ {
+ isWaitingForInput = false;
+ willHideBalloon = true;
+ GetTree().CreateTimer(0.1f).Timeout += () =>
+ {
+ if (willHideBalloon)
+ {
+ willHideBalloon = false;
+ balloon.Hide();
+ }
+ };
+ }
+
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/addons/dialogue_manager/example_balloon/example_balloon.gd b/addons/dialogue_manager/example_balloon/example_balloon.gd
new file mode 100644
index 00000000..ffeec571
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/example_balloon.gd
@@ -0,0 +1,156 @@
+extends CanvasLayer
+
+## The action to use for advancing the dialogue
+@export var next_action: StringName = &"ui_accept"
+
+## The action to use to skip typing the dialogue
+@export var skip_action: StringName = &"ui_cancel"
+
+@onready var balloon: Control = %Balloon
+@onready var character_label: RichTextLabel = %CharacterLabel
+@onready var dialogue_label: DialogueLabel = %DialogueLabel
+@onready var responses_menu: DialogueResponsesMenu = %ResponsesMenu
+
+## The dialogue resource
+var resource: DialogueResource
+
+## Temporary game states
+var temporary_game_states: Array = []
+
+## See if we are waiting for the player
+var is_waiting_for_input: bool = false
+
+## See if we are running a long mutation and should hide the balloon
+var will_hide_balloon: bool = false
+
+var _locale: String = TranslationServer.get_locale()
+
+## The current line
+var dialogue_line: DialogueLine:
+ set(next_dialogue_line):
+ is_waiting_for_input = false
+ balloon.focus_mode = Control.FOCUS_ALL
+ balloon.grab_focus()
+
+ # The dialogue has finished so close the balloon
+ if not next_dialogue_line:
+ queue_free()
+ return
+
+ # If the node isn't ready yet then none of the labels will be ready yet either
+ if not is_node_ready():
+ await ready
+
+ dialogue_line = next_dialogue_line
+
+ character_label.visible = not dialogue_line.character.is_empty()
+ character_label.text = tr(dialogue_line.character, "dialogue")
+
+ dialogue_label.hide()
+ dialogue_label.dialogue_line = dialogue_line
+
+ responses_menu.hide()
+ responses_menu.set_responses(dialogue_line.responses)
+
+ # Show our balloon
+ balloon.show()
+ will_hide_balloon = false
+
+ dialogue_label.show()
+ if not dialogue_line.text.is_empty():
+ dialogue_label.type_out()
+ await dialogue_label.finished_typing
+
+ # Wait for input
+ if dialogue_line.responses.size() > 0:
+ balloon.focus_mode = Control.FOCUS_NONE
+ responses_menu.show()
+ elif dialogue_line.time != "":
+ var time = dialogue_line.text.length() * 0.02 if dialogue_line.time == "auto" else dialogue_line.time.to_float()
+ await get_tree().create_timer(time).timeout
+ next(dialogue_line.next_id)
+ else:
+ is_waiting_for_input = true
+ balloon.focus_mode = Control.FOCUS_ALL
+ balloon.grab_focus()
+ get:
+ return dialogue_line
+
+
+func _ready() -> void:
+ balloon.hide()
+ Engine.get_singleton("DialogueManager").mutated.connect(_on_mutated)
+
+ # If the responses menu doesn't have a next action set, use this one
+ if responses_menu.next_action.is_empty():
+ responses_menu.next_action = next_action
+
+
+func _unhandled_input(_event: InputEvent) -> void:
+ # Only the balloon is allowed to handle input while it's showing
+ get_viewport().set_input_as_handled()
+
+
+func _notification(what: int) -> void:
+ ## Detect a change of locale and update the current dialogue line to show the new language
+ if what == NOTIFICATION_TRANSLATION_CHANGED and _locale != TranslationServer.get_locale() and is_instance_valid(dialogue_label):
+ _locale = TranslationServer.get_locale()
+ var visible_ratio = dialogue_label.visible_ratio
+ self.dialogue_line = await resource.get_next_dialogue_line(dialogue_line.id)
+ if visible_ratio < 1:
+ dialogue_label.skip_typing()
+
+
+## Start some dialogue
+func start(dialogue_resource: DialogueResource, title: String, extra_game_states: Array = []) -> void:
+ temporary_game_states = [self] + extra_game_states
+ is_waiting_for_input = false
+ resource = dialogue_resource
+ self.dialogue_line = await resource.get_next_dialogue_line(title, temporary_game_states)
+
+
+## Go to the next line
+func next(next_id: String) -> void:
+ self.dialogue_line = await resource.get_next_dialogue_line(next_id, temporary_game_states)
+
+
+#region Signals
+
+
+func _on_mutated(_mutation: Dictionary) -> void:
+ is_waiting_for_input = false
+ will_hide_balloon = true
+ get_tree().create_timer(0.1).timeout.connect(func():
+ if will_hide_balloon:
+ will_hide_balloon = false
+ balloon.hide()
+ )
+
+
+func _on_balloon_gui_input(event: InputEvent) -> void:
+ # See if we need to skip typing of the dialogue
+ if dialogue_label.is_typing:
+ var mouse_was_clicked: bool = event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed()
+ var skip_button_was_pressed: bool = event.is_action_pressed(skip_action)
+ if mouse_was_clicked or skip_button_was_pressed:
+ get_viewport().set_input_as_handled()
+ dialogue_label.skip_typing()
+ return
+
+ if not is_waiting_for_input: return
+ if dialogue_line.responses.size() > 0: return
+
+ # When there are no response options the balloon itself is the clickable thing
+ get_viewport().set_input_as_handled()
+
+ if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT:
+ next(dialogue_line.next_id)
+ elif event.is_action_pressed(next_action) and get_viewport().gui_get_focus_owner() == balloon:
+ next(dialogue_line.next_id)
+
+
+func _on_responses_menu_response_selected(response: DialogueResponse) -> void:
+ next(response.next_id)
+
+
+#endregion
diff --git a/addons/dialogue_manager/example_balloon/example_balloon.tscn b/addons/dialogue_manager/example_balloon/example_balloon.tscn
new file mode 100644
index 00000000..6facc260
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/example_balloon.tscn
@@ -0,0 +1,149 @@
+[gd_scene load_steps=9 format=3 uid="uid://73jm5qjy52vq"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/example_balloon/example_balloon.gd" id="1_36de5"]
+[ext_resource type="PackedScene" uid="uid://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_a8ve6"]
+[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_reponses_menu.gd" id="3_72ixx"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_spyqn"]
+bg_color = Color(0, 0, 0, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.329412, 0.329412, 0.329412, 1)
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ri4m3"]
+bg_color = Color(0.121569, 0.121569, 0.121569, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(1, 1, 1, 1)
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_e0njw"]
+bg_color = Color(0, 0, 0, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.6, 0.6, 0.6, 1)
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uy0d5"]
+bg_color = Color(0, 0, 0, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="Theme" id="Theme_qq3yp"]
+default_font_size = 20
+Button/styles/disabled = SubResource("StyleBoxFlat_spyqn")
+Button/styles/focus = SubResource("StyleBoxFlat_ri4m3")
+Button/styles/hover = SubResource("StyleBoxFlat_e0njw")
+Button/styles/normal = SubResource("StyleBoxFlat_e0njw")
+MarginContainer/constants/margin_bottom = 15
+MarginContainer/constants/margin_left = 30
+MarginContainer/constants/margin_right = 30
+MarginContainer/constants/margin_top = 15
+Panel/styles/panel = SubResource("StyleBoxFlat_uy0d5")
+
+[node name="ExampleBalloon" type="CanvasLayer"]
+layer = 100
+script = ExtResource("1_36de5")
+
+[node name="Balloon" type="Control" parent="."]
+unique_name_in_owner = true
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme = SubResource("Theme_qq3yp")
+
+[node name="Panel" type="Panel" parent="Balloon"]
+clip_children = 2
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 21.0
+offset_top = -183.0
+offset_right = -19.0
+offset_bottom = -19.0
+grow_horizontal = 2
+grow_vertical = 0
+mouse_filter = 1
+
+[node name="Dialogue" type="MarginContainer" parent="Balloon/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel/Dialogue"]
+layout_mode = 2
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/Panel/Dialogue/VBoxContainer"]
+unique_name_in_owner = true
+modulate = Color(1, 1, 1, 0.501961)
+layout_mode = 2
+mouse_filter = 1
+bbcode_enabled = true
+text = "Character"
+fit_content = true
+scroll_active = false
+
+[node name="DialogueLabel" parent="Balloon/Panel/Dialogue/VBoxContainer" instance=ExtResource("2_a8ve6")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+
+[node name="Responses" type="MarginContainer" parent="Balloon"]
+layout_mode = 1
+anchors_preset = 7
+anchor_left = 0.5
+anchor_top = 1.0
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -147.0
+offset_top = -558.0
+offset_right = 494.0
+offset_bottom = -154.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/Responses" node_paths=PackedStringArray("response_template")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/separation = 2
+script = ExtResource("3_72ixx")
+response_template = NodePath("ResponseExample")
+
+[node name="ResponseExample" type="Button" parent="Balloon/Responses/ResponsesMenu"]
+layout_mode = 2
+text = "Response example"
+
+[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
+[connection signal="response_selected" from="Balloon/Responses/ResponsesMenu" to="." method="_on_responses_menu_response_selected"]
diff --git a/addons/dialogue_manager/example_balloon/small_example_balloon.tscn b/addons/dialogue_manager/example_balloon/small_example_balloon.tscn
new file mode 100644
index 00000000..30118337
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/small_example_balloon.tscn
@@ -0,0 +1,173 @@
+[gd_scene load_steps=10 format=3 uid="uid://13s5spsk34qu"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/example_balloon/example_balloon.gd" id="1_s2gbs"]
+[ext_resource type="PackedScene" uid="uid://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_hfvdi"]
+[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_reponses_menu.gd" id="3_1j1j0"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_235ry"]
+content_margin_left = 6.0
+content_margin_top = 3.0
+content_margin_right = 6.0
+content_margin_bottom = 3.0
+bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(0.345098, 0.345098, 0.345098, 1)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ufjut"]
+content_margin_left = 6.0
+content_margin_top = 3.0
+content_margin_right = 6.0
+content_margin_bottom = 3.0
+bg_color = Color(0.227451, 0.227451, 0.227451, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(1, 1, 1, 1)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_fcbqo"]
+content_margin_left = 6.0
+content_margin_top = 3.0
+content_margin_right = 6.0
+content_margin_bottom = 3.0
+bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_t6i7a"]
+content_margin_left = 6.0
+content_margin_top = 3.0
+content_margin_right = 6.0
+content_margin_bottom = 3.0
+bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uy0d5"]
+bg_color = Color(0, 0, 0, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="Theme" id="Theme_qq3yp"]
+default_font_size = 8
+Button/styles/disabled = SubResource("StyleBoxFlat_235ry")
+Button/styles/focus = SubResource("StyleBoxFlat_ufjut")
+Button/styles/hover = SubResource("StyleBoxFlat_fcbqo")
+Button/styles/normal = SubResource("StyleBoxFlat_t6i7a")
+MarginContainer/constants/margin_bottom = 4
+MarginContainer/constants/margin_left = 8
+MarginContainer/constants/margin_right = 8
+MarginContainer/constants/margin_top = 4
+Panel/styles/panel = SubResource("StyleBoxFlat_uy0d5")
+
+[node name="ExampleBalloon" type="CanvasLayer"]
+layer = 100
+script = ExtResource("1_s2gbs")
+
+[node name="Balloon" type="Control" parent="."]
+unique_name_in_owner = true
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme = SubResource("Theme_qq3yp")
+
+[node name="Panel" type="Panel" parent="Balloon"]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 3.0
+offset_top = -62.0
+offset_right = -4.0
+offset_bottom = -4.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="Dialogue" type="MarginContainer" parent="Balloon/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel/Dialogue"]
+layout_mode = 2
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/Panel/Dialogue/VBoxContainer"]
+unique_name_in_owner = true
+modulate = Color(1, 1, 1, 0.501961)
+layout_mode = 2
+mouse_filter = 1
+bbcode_enabled = true
+text = "Character"
+fit_content = true
+scroll_active = false
+
+[node name="DialogueLabel" parent="Balloon/Panel/Dialogue/VBoxContainer" instance=ExtResource("2_hfvdi")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")
+
+[node name="Responses" type="MarginContainer" parent="Balloon"]
+layout_mode = 1
+anchors_preset = 7
+anchor_left = 0.5
+anchor_top = 1.0
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -124.0
+offset_top = -218.0
+offset_right = 125.0
+offset_bottom = -50.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/Responses"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/separation = 2
+script = ExtResource("3_1j1j0")
+
+[node name="ResponseExample" type="Button" parent="Balloon/Responses/ResponsesMenu"]
+layout_mode = 2
+text = "Response Example"
+
+[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
+[connection signal="response_selected" from="Balloon/Responses/ResponsesMenu" to="." method="_on_responses_menu_response_selected"]
diff --git a/addons/dialogue_manager/import_plugin.gd b/addons/dialogue_manager/import_plugin.gd
new file mode 100644
index 00000000..157ea131
--- /dev/null
+++ b/addons/dialogue_manager/import_plugin.gd
@@ -0,0 +1,116 @@
+@tool
+extends EditorImportPlugin
+
+
+signal compiled_resource(resource: Resource)
+
+
+const DialogueResource = preload("./dialogue_resource.gd")
+const DialogueManagerParser = preload("./components/parser.gd")
+const DialogueManagerParseResult = preload("./components/parse_result.gd")
+
+const compiler_version = 12
+
+
+func _get_importer_name() -> String:
+ # NOTE: A change to this forces a re-import of all dialogue
+ return "dialogue_manager_compiler_%s" % compiler_version
+
+
+func _get_visible_name() -> String:
+ return "Dialogue"
+
+
+func _get_import_order() -> int:
+ return -1000
+
+
+func _get_priority() -> float:
+ return 1000.0
+
+
+func _get_resource_type():
+ return "Resource"
+
+
+func _get_recognized_extensions() -> PackedStringArray:
+ return PackedStringArray(["dialogue"])
+
+
+func _get_save_extension():
+ return "tres"
+
+
+func _get_preset_count() -> int:
+ return 0
+
+
+func _get_preset_name(preset_index: int) -> String:
+ return "Unknown"
+
+
+func _get_import_options(path: String, preset_index: int) -> Array:
+ # When the options array is empty there is a misleading error on export
+ # that actually means nothing so let's just have an invisible option.
+ return [{
+ name = "defaults",
+ default_value = true
+ }]
+
+
+func _get_option_visibility(path: String, option_name: StringName, options: Dictionary) -> bool:
+ return false
+
+
+func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> Error:
+ var cache = Engine.get_meta("DialogueCache")
+
+ # Get the raw file contents
+ if not FileAccess.file_exists(source_file): return ERR_FILE_NOT_FOUND
+
+ var file: FileAccess = FileAccess.open(source_file, FileAccess.READ)
+ var raw_text: String = file.get_as_text()
+
+ cache.file_content_changed.emit(source_file, raw_text)
+
+ # Parse the text
+ var parser: DialogueManagerParser = DialogueManagerParser.new()
+ var err: Error = parser.parse(raw_text, source_file)
+ var data: DialogueManagerParseResult = parser.get_data()
+ var errors: Array[Dictionary] = parser.get_errors()
+ parser.free()
+
+ if err != OK:
+ printerr("%d errors found in %s" % [errors.size(), source_file])
+ cache.add_errors_to_file(source_file, errors)
+ return err
+
+ # Get the current addon version
+ var config: ConfigFile = ConfigFile.new()
+ config.load("res://addons/dialogue_manager/plugin.cfg")
+ var version: String = config.get_value("plugin", "version")
+
+ # Save the results to a resource
+ var resource: DialogueResource = DialogueResource.new()
+ resource.set_meta("dialogue_manager_version", version)
+
+ resource.using_states = data.using_states
+ resource.titles = data.titles
+ resource.first_title = data.first_title
+ resource.character_names = data.character_names
+ resource.lines = data.lines
+ resource.raw_text = data.raw_text
+
+ # Clear errors and possibly trigger any cascade recompiles
+ cache.add_file(source_file, data)
+
+ err = ResourceSaver.save(resource, "%s.%s" % [save_path, _get_save_extension()])
+
+ compiled_resource.emit(resource)
+
+ # Recompile any dependencies
+ var dependent_paths: PackedStringArray = cache.get_dependent_paths_for_reimport(source_file)
+ for path in dependent_paths:
+ append_import_external_resource(path)
+
+ return err
diff --git a/addons/dialogue_manager/inspector_plugin.gd b/addons/dialogue_manager/inspector_plugin.gd
new file mode 100644
index 00000000..e3cb7e51
--- /dev/null
+++ b/addons/dialogue_manager/inspector_plugin.gd
@@ -0,0 +1,21 @@
+@tool
+extends EditorInspectorPlugin
+
+
+const DialogueEditorProperty = preload("./components/editor_property/editor_property.gd")
+
+
+func _can_handle(object) -> bool:
+ if object is GDScript: return false
+ if not object is Node: return false
+ if "name" in object and object.name == "Dialogue Manager": return false
+ return true
+
+
+func _parse_property(object: Object, type, name: String, hint_type, hint_string: String, usage_flags: int, wide: bool) -> bool:
+ if hint_string == "DialogueResource" or ("dialogue" in name.to_lower() and hint_string == "Resource"):
+ var property_editor = DialogueEditorProperty.new()
+ add_property_editor(name, property_editor)
+ return true
+
+ return false
diff --git a/addons/dialogue_manager/l10n/en.mo b/addons/dialogue_manager/l10n/en.mo
new file mode 100644
index 00000000..2ab4fdfd
Binary files /dev/null and b/addons/dialogue_manager/l10n/en.mo differ
diff --git a/addons/dialogue_manager/l10n/en.po b/addons/dialogue_manager/l10n/en.po
new file mode 100644
index 00000000..c1b6000a
--- /dev/null
+++ b/addons/dialogue_manager/l10n/en.po
@@ -0,0 +1,481 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"X-Generator: Poedit 3.2.2\n"
+
+msgid "start_a_new_file"
+msgstr "Start a new file"
+
+msgid "open_a_file"
+msgstr "Open a file"
+
+msgid "open.open"
+msgstr "Open..."
+
+msgid "open.no_recent_files"
+msgstr "No recent files"
+
+msgid "open.clear_recent_files"
+msgstr "Clear recent files"
+
+msgid "save_all_files"
+msgstr "Save all files"
+
+msgid "find_in_files"
+msgstr "Find in files..."
+
+msgid "test_dialogue"
+msgstr "Test dialogue"
+
+msgid "search_for_text"
+msgstr "Search for text"
+
+msgid "insert"
+msgstr "Insert"
+
+msgid "translations"
+msgstr "Translations"
+
+msgid "settings"
+msgstr "Settings"
+
+msgid "sponsor"
+msgstr "Sponsor"
+
+msgid "show_support"
+msgstr "Support Dialogue Manager"
+
+msgid "docs"
+msgstr "Docs"
+
+msgid "insert.wave_bbcode"
+msgstr "Wave BBCode"
+
+msgid "insert.shake_bbcode"
+msgstr "Shake BBCode"
+
+msgid "insert.typing_pause"
+msgstr "Typing pause"
+
+msgid "insert.typing_speed_change"
+msgstr "Typing speed change"
+
+msgid "insert.auto_advance"
+msgstr "Auto advance"
+
+msgid "insert.templates"
+msgstr "Templates"
+
+msgid "insert.title"
+msgstr "Title"
+
+msgid "insert.dialogue"
+msgstr "Dialogue"
+
+msgid "insert.response"
+msgstr "Response"
+
+msgid "insert.random_lines"
+msgstr "Random lines"
+
+msgid "insert.random_text"
+msgstr "Random text"
+
+msgid "insert.actions"
+msgstr "Actions"
+
+msgid "insert.jump"
+msgstr "Jump to title"
+
+msgid "insert.end_dialogue"
+msgstr "End dialogue"
+
+msgid "generate_line_ids"
+msgstr "Generate line IDs"
+
+msgid "save_characters_to_csv"
+msgstr "Save character names to CSV..."
+
+msgid "save_to_csv"
+msgstr "Save lines to CSV..."
+
+msgid "import_from_csv"
+msgstr "Import line changes from CSV..."
+
+msgid "confirm_close"
+msgstr "Save changes to '{path}'?"
+
+msgid "confirm_close.save"
+msgstr "Save changes"
+
+msgid "confirm_close.discard"
+msgstr "Discard"
+
+msgid "buffer.save"
+msgstr "Save"
+
+msgid "buffer.save_as"
+msgstr "Save as..."
+
+msgid "buffer.close"
+msgstr "Close"
+
+msgid "buffer.close_all"
+msgstr "Close all"
+
+msgid "buffer.close_other_files"
+msgstr "Close other files"
+
+msgid "buffer.copy_file_path"
+msgstr "Copy file path"
+
+msgid "buffer.show_in_filesystem"
+msgstr "Show in FileSystem"
+
+msgid "settings.invalid_test_scene"
+msgstr "\"{path}\" does not extend BaseDialogueTestScene."
+
+msgid "settings.revert_to_default_test_scene"
+msgstr "Revert to default test scene"
+
+msgid "settings.default_balloon_hint"
+msgstr "Custom balloon to use when calling \"DialogueManager.show_balloon()\""
+
+msgid "settings.revert_to_default_balloon"
+msgstr "Revert to default balloon"
+
+msgid "settings.default_balloon_path"
+msgstr ""
+
+msgid "settings.autoload"
+msgstr "Autoload"
+
+msgid "settings.path"
+msgstr "Path"
+
+msgid "settings.new_template"
+msgstr "New dialogue files will start with template text"
+
+msgid "settings.missing_keys"
+msgstr "Treat missing translation keys as errors"
+
+msgid "settings.missing_keys_hint"
+msgstr "If you are using static translation keys then having this enabled will help you find any lines that you haven't added a key to yet."
+
+msgid "settings.characters_translations"
+msgstr "Export character names in translation files"
+
+msgid "settings.wrap_long_lines"
+msgstr "Wrap long lines"
+
+msgid "settings.include_failed_responses"
+msgstr "Include responses with failed conditions"
+
+msgid "settings.ignore_missing_state_values"
+msgstr "Skip over missing state value errors (not recommended)"
+
+msgid "settings.custom_test_scene"
+msgstr "Custom test scene (must extend BaseDialogueTestScene)"
+
+msgid "settings.default_csv_locale"
+msgstr "Default CSV Locale"
+
+msgid "settings.states_shortcuts"
+msgstr "State Shortcuts"
+
+msgid "settings.states_message"
+msgstr "If an autoload is enabled here you can refer to its properties, methods, and signals without having to use its name."
+
+msgid "settings.states_hint"
+msgstr "ie. Instead of \"SomeState.some_property\" you could just use \"some_property\""
+
+msgid "settings.recompile_warning"
+msgstr "Changing these settings will force a recompile of all dialogue. Only change them if you know what you are doing."
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr "Create child dialogue line for responses with character names in them"
+
+msgid "settings.open_in_external_editor"
+msgstr "Open dialogue files in external editor"
+
+msgid "settings.external_editor_warning"
+msgstr "Note: Syntax highlighting and detailed error checking are not supported in external editors."
+
+msgid "settings.include_characters_in_translations"
+msgstr "Include character names in translation exports"
+
+msgid "settings.include_notes_in_translations"
+msgstr "Include notes (## comments) in translation exports"
+
+msgid "settings.check_for_updates"
+msgstr "Check for updates"
+
+msgid "n_of_n"
+msgstr "{index} of {total}"
+
+msgid "search.find"
+msgstr "Find:"
+
+msgid "search.find_all"
+msgstr "Find all..."
+
+msgid "search.placeholder"
+msgstr "Text to search for"
+
+msgid "search.replace_placeholder"
+msgstr "Text to replace it with"
+
+msgid "search.replace_selected"
+msgstr "Replace selected"
+
+msgid "search.previous"
+msgstr "Previous"
+
+msgid "search.next"
+msgstr "Next"
+
+msgid "search.match_case"
+msgstr "Match case"
+
+msgid "search.toggle_replace"
+msgstr "Replace"
+
+msgid "search.replace_with"
+msgstr "Replace with:"
+
+msgid "search.replace"
+msgstr "Replace"
+
+msgid "search.replace_all"
+msgstr "Replace all"
+
+msgid "files_list.filter"
+msgstr "Filter files"
+
+msgid "titles_list.filter"
+msgstr "Filter titles"
+
+msgid "errors.key_not_found"
+msgstr "Key \"{key}\" not found."
+
+msgid "errors.line_and_message"
+msgstr "Error at {line}, {column}: {message}"
+
+msgid "errors_in_script"
+msgstr "You have errors in your script. Fix them and then try again."
+
+msgid "errors_with_build"
+msgstr "You need to fix dialogue errors before you can run your game."
+
+msgid "errors.import_errors"
+msgstr "There are errors in this imported file."
+
+msgid "errors.already_imported"
+msgstr "File already imported."
+
+msgid "errors.duplicate_import"
+msgstr "Duplicate import name."
+
+msgid "errors.unknown_using"
+msgstr "Unknown autoload in using statement."
+
+msgid "errors.empty_title"
+msgstr "Titles cannot be empty."
+
+msgid "errors.duplicate_title"
+msgstr "There is already a title with that name."
+
+msgid "errors.nested_title"
+msgstr "Titles cannot be indented."
+
+msgid "errors.invalid_title_string"
+msgstr "Titles can only contain alphanumeric characters and numbers."
+
+msgid "errors.invalid_title_number"
+msgstr "Titles cannot begin with a number."
+
+msgid "errors.unknown_title"
+msgstr "Unknown title."
+
+msgid "errors.jump_to_invalid_title"
+msgstr "This jump is pointing to an invalid title."
+
+msgid "errors.title_has_no_content"
+msgstr "That title has no content. Maybe change this to a \"=> END\"."
+
+msgid "errors.invalid_expression"
+msgstr "Expression is invalid."
+
+msgid "errors.unexpected_condition"
+msgstr "Unexpected condition."
+
+msgid "errors.duplicate_id"
+msgstr "This ID is already on another line."
+
+msgid "errors.missing_id"
+msgstr "This line is missing an ID."
+
+msgid "errors.invalid_indentation"
+msgstr "Invalid indentation."
+
+msgid "errors.condition_has_no_content"
+msgstr "A condition line needs an indented line below it."
+
+msgid "errors.incomplete_expression"
+msgstr "Incomplete expression."
+
+msgid "errors.invalid_expression_for_value"
+msgstr "Invalid expression for value."
+
+msgid "errors.file_not_found"
+msgstr "File not found."
+
+msgid "errors.unexpected_end_of_expression"
+msgstr "Unexpected end of expression."
+
+msgid "errors.unexpected_function"
+msgstr "Unexpected function."
+
+msgid "errors.unexpected_bracket"
+msgstr "Unexpected bracket."
+
+msgid "errors.unexpected_closing_bracket"
+msgstr "Unexpected closing bracket."
+
+msgid "errors.missing_closing_bracket"
+msgstr "Missing closing bracket."
+
+msgid "errors.unexpected_operator"
+msgstr "Unexpected operator."
+
+msgid "errors.unexpected_comma"
+msgstr "Unexpected comma."
+
+msgid "errors.unexpected_colon"
+msgstr "Unexpected colon."
+
+msgid "errors.unexpected_dot"
+msgstr "Unexpected dot."
+
+msgid "errors.unexpected_boolean"
+msgstr "Unexpected boolean."
+
+msgid "errors.unexpected_string"
+msgstr "Unexpected string."
+
+msgid "errors.unexpected_number"
+msgstr "Unexpected number."
+
+msgid "errors.unexpected_variable"
+msgstr "Unexpected variable."
+
+msgid "errors.invalid_index"
+msgstr "Invalid index."
+
+msgid "errors.unexpected_assignment"
+msgstr "Unexpected assignment."
+
+msgid "errors.unknown"
+msgstr "Unknown syntax."
+
+msgid "update.available"
+msgstr "v{version} available"
+
+msgid "update.is_available_for_download"
+msgstr "Version %s is available for download!"
+
+msgid "update.downloading"
+msgstr "Downloading..."
+
+msgid "update.download_update"
+msgstr "Download update"
+
+msgid "update.needs_reload"
+msgstr "The project needs to be reloaded to install the update."
+
+msgid "update.reload_ok_button"
+msgstr "Reload project"
+
+msgid "update.reload_cancel_button"
+msgstr "Do it later"
+
+msgid "update.reload_project"
+msgstr "Reload project"
+
+msgid "update.release_notes"
+msgstr "Read release notes"
+
+msgid "update.success"
+msgstr "Dialogue Manager is now v{version}."
+
+msgid "update.failed"
+msgstr "There was a problem downloading the update."
+
+msgid "runtime.no_resource"
+msgstr "No dialogue resource provided."
+
+msgid "runtime.no_content"
+msgstr "\"{file_path}\" has no content."
+
+msgid "runtime.errors"
+msgstr "You have {count} errors in your dialogue text."
+
+msgid "runtime.error_detail"
+msgstr "Line {line}: {message}"
+
+msgid "runtime.errors_see_details"
+msgstr "You have {count} errors in your dialogue text. See Output for details."
+
+msgid "runtime.invalid_expression"
+msgstr "\"{expression}\" is not a valid expression: {error}"
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr "Index {index} out of bounds of array \"{array}\"."
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr "Left hand side of expression cannot be assigned to."
+
+msgid "runtime.key_not_found"
+msgstr "Key \"{key}\" not found in dictionary \"{dictionary}\""
+
+msgid "runtime.property_not_found"
+msgstr "\"{property}\" not found. States with directly referenceable properties/methods/signals include {states}. Autoloads need to be referenced by their name to use their properties."
+
+msgid "runtime.property_not_found_missing_export"
+msgstr "\"{property}\" not found. You might need to add an [Export] decorator. States with directly referenceable properties/methods/signals include {states}. Autoloads need to be referenced by their name to use their properties."
+
+msgid "runtime.method_not_found"
+msgstr "Method \"{method}\" not found. States with directly referenceable properties/methods/signals include {states}. Autoloads need to be referenced by their name to use their properties."
+
+msgid "runtime.signal_not_found"
+msgstr "Signal \"{signal_name}\" not found. States with directly referenceable properties/methods/signals include {states}. Autoloads need to be referenced by their name to use their properties."
+
+msgid "runtime.method_not_callable"
+msgstr "\"{method}\" is not a callable method on \"{object}\""
+
+msgid "runtime.unknown_operator"
+msgstr "Unknown operator."
+
+msgid "runtime.unknown_autoload"
+msgstr "\"{autoload}\" doesn't appear to be a valid autoload."
+
+msgid "runtime.something_went_wrong"
+msgstr "Something went wrong."
+
+msgid "runtime.expected_n_got_n_args"
+msgstr "\"{method}\" was called with {received} arguments but it only has {expected}."
+
+msgid "runtime.unsupported_array_type"
+msgstr "Array[{type}] isn't supported in mutations. Use Array as a type instead."
+
+msgid "runtime.dialogue_balloon_missing_start_method"
+msgstr "Your dialogue balloon is missing a \"start\" or \"Start\" method."
\ No newline at end of file
diff --git a/addons/dialogue_manager/l10n/es.po b/addons/dialogue_manager/l10n/es.po
new file mode 100644
index 00000000..3cbaa32e
--- /dev/null
+++ b/addons/dialogue_manager/l10n/es.po
@@ -0,0 +1,457 @@
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: 2024-02-25 20:58\n"
+"PO-Revision-Date: 2024-02-25 20:58\n"
+"Last-Translator: you \n"
+"Language-Team: Spanish \n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+msgid "start_a_new_file"
+msgstr "Crear un nuevo archivo"
+
+msgid "open_a_file"
+msgstr "Abrir un archivo"
+
+msgid "open.open"
+msgstr "Abrir..."
+
+msgid "open.no_recent_files"
+msgstr "No hay archivos recientes"
+
+msgid "open.clear_recent_files"
+msgstr "Limpiar archivos recientes"
+
+msgid "save_all_files"
+msgstr "Guardar todos los archivos"
+
+msgid "test_dialogue"
+msgstr "Diálogo de prueba"
+
+msgid "search_for_text"
+msgstr "Buscar texto"
+
+msgid "insert"
+msgstr "Insertar"
+
+msgid "translations"
+msgstr "Traducciones"
+
+msgid "settings"
+msgstr "Ajustes"
+
+msgid "show_support"
+msgstr "Contribuye con Dialogue Manager"
+
+msgid "docs"
+msgstr "Docs"
+
+msgid "insert.wave_bbcode"
+msgstr "BBCode ondulado"
+
+msgid "insert.shake_bbcode"
+msgstr "BBCode agitado"
+
+msgid "insert.typing_pause"
+msgstr "Pausa de escritura"
+
+msgid "insert.typing_speed_change"
+msgstr "Cambiar la velocidad de escritura"
+
+msgid "insert.auto_advance"
+msgstr "Avance automático"
+
+msgid "insert.templates"
+msgstr "Plantillas"
+
+msgid "insert.title"
+msgstr "Título"
+
+msgid "insert.dialogue"
+msgstr "Diálogo"
+
+msgid "insert.response"
+msgstr "Respuesta"
+
+msgid "insert.random_lines"
+msgstr "Líneas aleatorias"
+
+msgid "insert.random_text"
+msgstr "Texto aleatorio"
+
+msgid "insert.actions"
+msgstr "Acciones"
+
+msgid "insert.jump"
+msgstr "Ir al título"
+
+msgid "insert.end_dialogue"
+msgstr "Finalizar diálogo"
+
+msgid "generate_line_ids"
+msgstr "Generar IDs de línea"
+
+msgid "save_characters_to_csv"
+msgstr "Guardar los nombres de los personajes en un archivo CSV..."
+
+msgid "save_to_csv"
+msgstr "Guardar líneas en CSV..."
+
+msgid "import_from_csv"
+msgstr "Importar cambios de línea desde CSV..."
+
+msgid "confirm_close"
+msgstr "¿Guardar los cambios en '{path}'?"
+
+msgid "confirm_close.save"
+msgstr "Guardar cambios"
+
+msgid "confirm_close.discard"
+msgstr "Descartar"
+
+msgid "buffer.save"
+msgstr "Guardar"
+
+msgid "buffer.save_as"
+msgstr "Guardar como..."
+
+msgid "buffer.close"
+msgstr "Cerrar"
+
+msgid "buffer.close_all"
+msgstr "Cerrar todo"
+
+msgid "buffer.close_other_files"
+msgstr "Cerrar otros archivos"
+
+msgid "buffer.copy_file_path"
+msgstr "Copiar la ruta del archivo"
+
+msgid "buffer.show_in_filesystem"
+msgstr "Mostrar en el sistema de archivos"
+
+msgid "settings.invalid_test_scene"
+msgstr "\"{path}\" no extiende BaseDialogueTestScene."
+
+msgid "settings.revert_to_default_test_scene"
+msgstr "Revertir a la escena de prueba por defecto"
+
+msgid "settings.default_balloon_hint"
+msgstr ""
+"Globo personalizado para usar al llamar a \"DialogueManager.show_balloon()\""
+
+msgid "settings.revert_to_default_balloon"
+msgstr "Volver al globo predeterminado"
+
+msgid "settings.default_balloon_path"
+msgstr ""
+
+msgid "settings.autoload"
+msgstr "Autocarga"
+
+msgid "settings.path"
+msgstr "Ruta"
+
+msgid "settings.new_template"
+msgstr "Los nuevos archivos de diálogo empezarán con una plantilla"
+
+msgid "settings.missing_keys"
+msgstr "Tratar las claves de traducción faltantes como errores"
+
+msgid "settings.missing_keys_hint"
+msgstr "Si estás utilizando claves de traducción estáticas, tener esta opción habilitada te ayudará a encontrar cualquier línea a la que aún no le hayas añadido una clave."
+
+msgid "settings.characters_translations"
+msgstr "Exportar nombres de personajes en archivos de traducción"
+
+msgid "settings.wrap_long_lines"
+msgstr "Romper líneas largas"
+
+msgid "settings.include_failed_responses"
+msgstr "Incluir respuestas con condiciones fallidas"
+
+msgid "settings.ignore_missing_state_values"
+msgstr "Omitir errores de valores de estado faltantes (no recomendado)"
+
+msgid "settings.custom_test_scene"
+msgstr "Escena de prueba personalizada (debe extender BaseDialogueTestScene)"
+
+msgid "settings.default_csv_locale"
+msgstr "Localización CSV por defecto"
+
+msgid "settings.states_shortcuts"
+msgstr "Atajos de teclado"
+
+msgid "settings.states_message"
+msgstr "Si un autoload está habilitado aquí, puedes referirte a sus propiedades y métodos sin tener que usar su nombre."
+
+msgid "settings.states_hint"
+msgstr "ie. En lugar de \"SomeState.some_property\" podría simplemente usar \"some_property\""
+
+msgid "settings.recompile_warning"
+msgstr "Cambiar estos ajustes obligará a recompilar todo el diálogo. Hazlo solo si sabes lo que estás haciendo."
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr "Crear línea de diálogo para respuestas con nombres de personajes dentro."
+
+msgid "settings.open_in_external_editor"
+msgstr "Abrir archivos de diálogo en el editor externo"
+
+msgid "settings.external_editor_warning"
+msgstr "Nota: El resaltado de sintaxis y la verificación detallada de errores no están soportados en editores externos."
+
+msgid "settings.include_characters_in_translations"
+msgstr "Incluir nombres de personajes en las exportaciones de traducción"
+
+msgid "settings.include_notes_in_translations"
+msgstr "Incluir notas (## comentarios) en las exportaciones de traducción"
+
+msgid "n_of_n"
+msgstr "{index} de {total}"
+
+msgid "search.previous"
+msgstr "Anterior"
+
+msgid "search.next"
+msgstr "Siguiente"
+
+msgid "search.match_case"
+msgstr "Coincidir mayúsculas/minúsculas"
+
+msgid "search.toggle_replace"
+msgstr "Reemplazar"
+
+msgid "search.replace_with"
+msgstr "Reemplazar con:"
+
+msgid "search.replace"
+msgstr "Reemplazar"
+
+msgid "search.replace_all"
+msgstr "Reemplazar todo"
+
+msgid "files_list.filter"
+msgstr "Filtrar archivos"
+
+msgid "titles_list.filter"
+msgstr "Filtrar títulos"
+
+msgid "errors.key_not_found"
+msgstr "La tecla \"{key}\" no se encuentra."
+
+msgid "errors.line_and_message"
+msgstr "Error en {line}, {column}: {message}"
+
+msgid "errors_in_script"
+msgstr "Tienes errores en tu guion. Corrígelos y luego inténtalo de nuevo."
+
+msgid "errors_with_build"
+msgstr "Debes corregir los errores de diálogo antes de poder ejecutar tu juego."
+
+msgid "errors.import_errors"
+msgstr "Hay errores en este archivo importado."
+
+msgid "errors.already_imported"
+msgstr "Archivo ya importado."
+
+msgid "errors.duplicate_import"
+msgstr "Nombre de importación duplicado."
+
+msgid "errors.unknown_using"
+msgstr "Autoload desconocida en la declaración de uso."
+
+msgid "errors.empty_title"
+msgstr "Los títulos no pueden estar vacíos."
+
+msgid "errors.duplicate_title"
+msgstr "Ya hay un título con ese nombre."
+
+msgid "errors.nested_title"
+msgstr "Los títulos no pueden tener sangría."
+
+msgid "errors.invalid_title_string"
+msgstr "Los títulos solo pueden contener caracteres alfanuméricos y números."
+
+msgid "errors.invalid_title_number"
+msgstr "Los títulos no pueden empezar con un número."
+
+msgid "errors.unknown_title"
+msgstr "Título desconocido."
+
+msgid "errors.jump_to_invalid_title"
+msgstr "Este salto está apuntando a un título inválido."
+
+msgid "errors.title_has_no_content"
+msgstr "Ese título no tiene contenido. Quizá cambiarlo a \"=> FIN\"."
+
+msgid "errors.invalid_expression"
+msgstr "La expresión es inválida."
+
+msgid "errors.unexpected_condition"
+msgstr "Condición inesperada."
+
+msgid "errors.duplicate_id"
+msgstr "Este ID ya está en otra línea."
+
+msgid "errors.missing_id"
+msgstr "Esta línea está sin ID."
+
+msgid "errors.invalid_indentation"
+msgstr "Sangría no válida."
+
+msgid "errors.condition_has_no_content"
+msgstr "Una línea de condición necesita una línea sangrada debajo de ella."
+
+msgid "errors.incomplete_expression"
+msgstr "Expresión incompleta."
+
+msgid "errors.invalid_expression_for_value"
+msgstr "Expresión no válida para valor."
+
+msgid "errors.file_not_found"
+msgstr "Archivo no encontrado."
+
+msgid "errors.unexpected_end_of_expression"
+msgstr "Fin de expresión inesperado."
+
+msgid "errors.unexpected_function"
+msgstr "Función inesperada."
+
+msgid "errors.unexpected_bracket"
+msgstr "Corchete inesperado."
+
+msgid "errors.unexpected_closing_bracket"
+msgstr "Bracket de cierre inesperado."
+
+msgid "errors.missing_closing_bracket"
+msgstr "Falta cerrar corchete."
+
+msgid "errors.unexpected_operator"
+msgstr "Operador inesperado."
+
+msgid "errors.unexpected_comma"
+msgstr "Coma inesperada."
+
+msgid "errors.unexpected_colon"
+msgstr "Dos puntos inesperados"
+
+msgid "errors.unexpected_dot"
+msgstr "Punto inesperado."
+
+msgid "errors.unexpected_boolean"
+msgstr "Booleano inesperado."
+
+msgid "errors.unexpected_string"
+msgstr "String inesperado."
+
+msgid "errors.unexpected_number"
+msgstr "Número inesperado."
+
+msgid "errors.unexpected_variable"
+msgstr "Variable inesperada."
+
+msgid "errors.invalid_index"
+msgstr "Índice no válido."
+
+msgid "errors.unexpected_assignment"
+msgstr "Asignación inesperada."
+
+msgid "errors.unknown"
+msgstr "Sintaxis desconocida."
+
+msgid "update.available"
+msgstr "v{version} disponible"
+
+msgid "update.is_available_for_download"
+msgstr "¡La versión %s ya está disponible para su descarga!"
+
+msgid "update.downloading"
+msgstr "Descargando..."
+
+msgid "update.download_update"
+msgstr "Descargar actualización"
+
+msgid "update.needs_reload"
+msgstr "El proyecto debe ser recargado para instalar la actualización."
+
+msgid "update.reload_ok_button"
+msgstr "Recargar proyecto"
+
+msgid "update.reload_cancel_button"
+msgstr "Hazlo más tarde"
+
+msgid "update.reload_project"
+msgstr "Recargar proyecto"
+
+msgid "update.release_notes"
+msgstr "Leer las notas de la versión"
+
+msgid "update.success"
+msgstr "El Gestor de Diálogo ahora es v{versión}."
+
+msgid "update.failed"
+msgstr "Hubo un problema al descargar la actualización."
+
+msgid "runtime.no_resource"
+msgstr "Recurso de diálogo no proporcionado."
+
+msgid "runtime.no_content"
+msgstr "\"{file_path}\" no tiene contenido."
+
+msgid "runtime.errors"
+msgstr "Tienes {count} errores en tu diálogo de texto."
+
+msgid "runtime.error_detail"
+msgstr "Línea {line}: {message}"
+
+msgid "runtime.errors_see_details"
+msgstr "Tienes {count} errores en tu texto de diálogo. Consulta la salida para más detalles."
+
+msgid "runtime.invalid_expression"
+msgstr "\"{expression}\" no es una expresión válida: {error}"
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr "Índice {index} fuera de los límites del array \"{array}\"."
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr "El lado izquierdo de la expresión no se puede asignar."
+
+msgid "runtime.key_not_found"
+msgstr "Clave \"{key}\" no encontrada en el diccionario \"{dictionary}\""
+
+msgid "runtime.property_not_found"
+msgstr "\"{property}\" no es una propiedad en ningún estado del juego ({states})."
+
+msgid "runtime.property_not_found_missing_export"
+msgstr "\"{property}\" no es una propiedad en ningún estado del juego ({states}). Es posible que necesites añadir un decorador [Export]."
+
+msgid "runtime.method_not_found"
+msgstr "\"{method}\" no es un método en ningún estado del juego ({states})"
+
+msgid "runtime.signal_not_found"
+msgstr "\"{signal_name}\" no es una señal en ningún estado del juego ({states})"
+
+msgid "runtime.method_not_callable"
+msgstr "\"{method}\" no es un método llamable en \"{object}\""
+
+msgid "runtime.unknown_operator"
+msgstr "Operador desconocido."
+
+msgid "runtime.unknown_autoload"
+msgstr "\"{autoload}\" parece no ser un autoload válido."
+
+msgid "runtime.something_went_wrong"
+msgstr "Algo salió mal."
+
+msgid "runtime.expected_n_got_n_args"
+msgstr "El método \"{method}\" se llamó con {received} argumentos, pero solo tiene {expected}."
+
+msgid "runtime.unsupported_array_type"
+msgstr "Array[{type}] no está soportado en mutaciones. Utiliza Array como tipo en su lugar."
+
+msgid "runtime.dialogue_balloon_missing_start_method"
+msgstr "Tu globo de diálogo no tiene un método \"start\" o \"Start\"."
diff --git a/addons/dialogue_manager/l10n/translations.pot b/addons/dialogue_manager/l10n/translations.pot
new file mode 100644
index 00000000..4aeec3b2
--- /dev/null
+++ b/addons/dialogue_manager/l10n/translations.pot
@@ -0,0 +1,471 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8-bit\n"
+
+msgid "start_a_new_file"
+msgstr ""
+
+msgid "open_a_file"
+msgstr ""
+
+msgid "open.open"
+msgstr ""
+
+msgid "open.no_recent_files"
+msgstr ""
+
+msgid "open.clear_recent_files"
+msgstr ""
+
+msgid "save_all_files"
+msgstr ""
+
+msgid "find_in_files"
+msgstr ""
+
+msgid "test_dialogue"
+msgstr ""
+
+msgid "search_for_text"
+msgstr ""
+
+msgid "insert"
+msgstr ""
+
+msgid "translations"
+msgstr ""
+
+msgid "settings"
+msgstr ""
+
+msgid "sponsor"
+msgstr ""
+
+msgid "show_support"
+msgstr ""
+
+msgid "docs"
+msgstr ""
+
+msgid "insert.wave_bbcode"
+msgstr ""
+
+msgid "insert.shake_bbcode"
+msgstr ""
+
+msgid "insert.typing_pause"
+msgstr ""
+
+msgid "insert.typing_speed_change"
+msgstr ""
+
+msgid "insert.auto_advance"
+msgstr ""
+
+msgid "insert.templates"
+msgstr ""
+
+msgid "insert.title"
+msgstr ""
+
+msgid "insert.dialogue"
+msgstr ""
+
+msgid "insert.response"
+msgstr ""
+
+msgid "insert.random_lines"
+msgstr ""
+
+msgid "insert.random_text"
+msgstr ""
+
+msgid "insert.actions"
+msgstr ""
+
+msgid "insert.jump"
+msgstr ""
+
+msgid "insert.end_dialogue"
+msgstr ""
+
+msgid "generate_line_ids"
+msgstr ""
+
+msgid "save_to_csv"
+msgstr ""
+
+msgid "import_from_csv"
+msgstr ""
+
+msgid "confirm_close"
+msgstr ""
+
+msgid "confirm_close.save"
+msgstr ""
+
+msgid "confirm_close.discard"
+msgstr ""
+
+msgid "buffer.save"
+msgstr ""
+
+msgid "buffer.save_as"
+msgstr ""
+
+msgid "buffer.close"
+msgstr ""
+
+msgid "buffer.close_all"
+msgstr ""
+
+msgid "buffer.close_other_files"
+msgstr ""
+
+msgid "buffer.copy_file_path"
+msgstr ""
+
+msgid "buffer.show_in_filesystem"
+msgstr ""
+
+msgid "settings.invalid_test_scene"
+msgstr ""
+
+msgid "settings.revert_to_default_test_scene"
+msgstr ""
+
+msgid "settings.default_balloon_hint"
+msgstr ""
+
+msgid "settings.revert_to_default_balloon"
+msgstr ""
+
+msgid "settings.default_balloon_path"
+msgstr ""
+
+msgid "settings.autoload"
+msgstr ""
+
+msgid "settings.path"
+msgstr ""
+
+msgid "settings.new_template"
+msgstr ""
+
+msgid "settings.missing_keys"
+msgstr ""
+
+msgid "settings.missing_keys_hint"
+msgstr ""
+
+msgid "settings.characters_translations"
+msgstr ""
+
+msgid "settings.wrap_long_lines"
+msgstr ""
+
+msgid "settings.include_failed_responses"
+msgstr ""
+
+msgid "settings.ignore_missing_state_values"
+msgstr ""
+
+msgid "settings.custom_test_scene"
+msgstr ""
+
+msgid "settings.default_csv_locale"
+msgstr ""
+
+msgid "settings.states_shortcuts"
+msgstr ""
+
+msgid "settings.states_message"
+msgstr ""
+
+msgid "settings.states_hint"
+msgstr ""
+
+msgid "settings.recompile_warning"
+msgstr ""
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr ""
+
+msgid "settings.open_in_external_editor"
+msgstr ""
+
+msgid "settings.external_editor_warning"
+msgstr ""
+
+msgid "settings.include_characters_in_translations"
+msgstr ""
+
+msgid "settings.include_notes_in_translations"
+msgstr ""
+
+msgid "settings.check_for_updates"
+msgstr ""
+
+msgid "n_of_n"
+msgstr ""
+
+msgid "search.find"
+msgstr ""
+
+msgid "search.find_all"
+msgstr ""
+
+msgid "search.placeholder"
+msgstr ""
+
+msgid "search.replace_placeholder"
+msgstr ""
+
+msgid "search.replace_selected"
+msgstr ""
+
+msgid "search.previous"
+msgstr ""
+
+msgid "search.next"
+msgstr ""
+
+msgid "search.match_case"
+msgstr ""
+
+msgid "search.toggle_replace"
+msgstr ""
+
+msgid "search.replace_with"
+msgstr ""
+
+msgid "search.replace"
+msgstr ""
+
+msgid "search.replace_all"
+msgstr ""
+
+msgid "files_list.filter"
+msgstr ""
+
+msgid "titles_list.filter"
+msgstr ""
+
+msgid "errors.key_not_found"
+msgstr ""
+
+msgid "errors.line_and_message"
+msgstr ""
+
+msgid "errors_in_script"
+msgstr ""
+
+msgid "errors_with_build"
+msgstr ""
+
+msgid "errors.import_errors"
+msgstr ""
+
+msgid "errors.already_imported"
+msgstr ""
+
+msgid "errors.duplicate_import"
+msgstr ""
+
+msgid "errors.unknown_using"
+msgstr ""
+
+msgid "errors.empty_title"
+msgstr ""
+
+msgid "errors.duplicate_title"
+msgstr ""
+
+msgid "errors.nested_title"
+msgstr ""
+
+msgid "errors.invalid_title_string"
+msgstr ""
+
+msgid "errors.invalid_title_number"
+msgstr ""
+
+msgid "errors.unknown_title"
+msgstr ""
+
+msgid "errors.jump_to_invalid_title"
+msgstr ""
+
+msgid "errors.title_has_no_content"
+msgstr ""
+
+msgid "errors.invalid_expression"
+msgstr ""
+
+msgid "errors.unexpected_condition"
+msgstr ""
+
+msgid "errors.duplicate_id"
+msgstr ""
+
+msgid "errors.missing_id"
+msgstr ""
+
+msgid "errors.invalid_indentation"
+msgstr ""
+
+msgid "errors.condition_has_no_content"
+msgstr ""
+
+msgid "errors.incomplete_expression"
+msgstr ""
+
+msgid "errors.invalid_expression_for_value"
+msgstr ""
+
+msgid "errors.file_not_found"
+msgstr ""
+
+msgid "errors.unexpected_end_of_expression"
+msgstr ""
+
+msgid "errors.unexpected_function"
+msgstr ""
+
+msgid "errors.unexpected_bracket"
+msgstr ""
+
+msgid "errors.unexpected_closing_bracket"
+msgstr ""
+
+msgid "errors.missing_closing_bracket"
+msgstr ""
+
+msgid "errors.unexpected_operator"
+msgstr ""
+
+msgid "errors.unexpected_comma"
+msgstr ""
+
+msgid "errors.unexpected_colon"
+msgstr ""
+
+msgid "errors.unexpected_dot"
+msgstr ""
+
+msgid "errors.unexpected_boolean"
+msgstr ""
+
+msgid "errors.unexpected_string"
+msgstr ""
+
+msgid "errors.unexpected_number"
+msgstr ""
+
+msgid "errors.unexpected_variable"
+msgstr ""
+
+msgid "errors.invalid_index"
+msgstr ""
+
+msgid "errors.unexpected_assignment"
+msgstr ""
+
+msgid "errors.unknown"
+msgstr ""
+
+msgid "update.available"
+msgstr ""
+
+msgid "update.is_available_for_download"
+msgstr ""
+
+msgid "update.downloading"
+msgstr ""
+
+msgid "update.download_update"
+msgstr ""
+
+msgid "update.needs_reload"
+msgstr ""
+
+msgid "update.reload_ok_button"
+msgstr ""
+
+msgid "update.reload_cancel_button"
+msgstr ""
+
+msgid "update.reload_project"
+msgstr ""
+
+msgid "update.release_notes"
+msgstr ""
+
+msgid "update.success"
+msgstr ""
+
+msgid "update.failed"
+msgstr ""
+
+msgid "runtime.no_resource"
+msgstr ""
+
+msgid "runtime.no_content"
+msgstr ""
+
+msgid "runtime.errors"
+msgstr ""
+
+msgid "runtime.error_detail"
+msgstr ""
+
+msgid "runtime.errors_see_details"
+msgstr ""
+
+msgid "runtime.invalid_expression"
+msgstr ""
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr ""
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr ""
+
+msgid "runtime.key_not_found"
+msgstr ""
+
+msgid "runtime.property_not_found"
+msgstr ""
+
+msgid "runtime.property_not_found_missing_export"
+msgstr ""
+
+msgid "runtime.method_not_found"
+msgstr ""
+
+msgid "runtime.signal_not_found"
+msgstr ""
+
+msgid "runtime.method_not_callable"
+msgstr ""
+
+msgid "runtime.unknown_operator"
+msgstr ""
+
+msgid "runtime.unknown_autoload"
+msgstr ""
+
+msgid "runtime.something_went_wrong"
+msgstr ""
+
+msgid "runtime.expected_n_got_n_args"
+msgstr ""
+
+msgid "runtime.unsupported_array_type"
+msgstr ""
+
+msgid "runtime.dialogue_balloon_missing_start_method"
+msgstr ""
\ No newline at end of file
diff --git a/addons/dialogue_manager/l10n/uk.po b/addons/dialogue_manager/l10n/uk.po
new file mode 100644
index 00000000..da1c5ab5
--- /dev/null
+++ b/addons/dialogue_manager/l10n/uk.po
@@ -0,0 +1,480 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: Veydzher\n"
+"Language: uk\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 3.2.2\n"
+
+msgid "start_a_new_file"
+msgstr "Створити новий файл"
+
+msgid "open_a_file"
+msgstr "Відкрити файл"
+
+msgid "open.open"
+msgstr "Відкрити..."
+
+msgid "open.no_recent_files"
+msgstr "Немає недавніх файлів"
+
+msgid "open.clear_recent_files"
+msgstr "Очистити недавні файли"
+
+msgid "save_all_files"
+msgstr "Зберегти всі файли"
+
+msgid "find_in_files"
+msgstr "Знайти у файліх..."
+
+msgid "test_dialogue"
+msgstr "Тестувати діалог"
+
+msgid "search_for_text"
+msgstr "Шукати текст"
+
+msgid "insert"
+msgstr "Вставити"
+
+msgid "translations"
+msgstr "Переклади"
+
+msgid "settings"
+msgstr "Налаштування"
+
+msgid "sponsor"
+msgstr "Спонсор"
+
+msgid "show_support"
+msgstr "Підтримка Dialogue Manager"
+
+msgid "docs"
+msgstr "Документація"
+
+msgid "insert.wave_bbcode"
+msgstr "Хвиля BBCode"
+
+msgid "insert.shake_bbcode"
+msgstr "Тряска BBCode"
+
+msgid "insert.typing_pause"
+msgstr "Пауза друку"
+
+msgid "insert.typing_speed_change"
+msgstr "Зміна швидкості друку"
+
+msgid "insert.auto_advance"
+msgstr "Автоматичне просування"
+
+msgid "insert.templates"
+msgstr "Шаблони"
+
+msgid "insert.title"
+msgstr "Заголовок"
+
+msgid "insert.dialogue"
+msgstr "Діалог"
+
+msgid "insert.response"
+msgstr "Відповідь"
+
+msgid "insert.random_lines"
+msgstr "Випадковий рядок"
+
+msgid "insert.random_text"
+msgstr "Випадковий текст"
+
+msgid "insert.actions"
+msgstr "Дії"
+
+msgid "insert.jump"
+msgstr "Перехід до заголовку"
+
+msgid "insert.end_dialogue"
+msgstr "Кінець діалогу"
+
+msgid "generate_line_ids"
+msgstr "Згенерувати ідентифікатори рядків"
+
+msgid "save_characters_to_csv"
+msgstr "Зберегти імена персонажів в CSV..."
+
+msgid "save_to_csv"
+msgstr "Зберегти рядки в CSV..."
+
+msgid "import_from_csv"
+msgstr "Імпортувати зміни в рядках з CSV..."
+
+msgid "confirm_close"
+msgstr "Зберегти зміни до '{path}'?"
+
+msgid "confirm_close.save"
+msgstr "Зберегти зміни"
+
+msgid "confirm_close.discard"
+msgstr "Скасувати"
+
+msgid "buffer.save"
+msgstr "Зберегти"
+
+msgid "buffer.save_as"
+msgstr "Зберегти як..."
+
+msgid "buffer.close"
+msgstr "Закрити"
+
+msgid "buffer.close_all"
+msgstr "Закрити все"
+
+msgid "buffer.close_other_files"
+msgstr "Закрити інші файли"
+
+msgid "buffer.copy_file_path"
+msgstr "Копіювати шлях файлу"
+
+msgid "buffer.show_in_filesystem"
+msgstr "Показати у системі файлів"
+
+msgid "settings.invalid_test_scene"
+msgstr "«{path}» не розширює BaseDialogueTestScene."
+
+msgid "settings.revert_to_default_test_scene"
+msgstr "Повернутися до стандартної тестової сцени"
+
+msgid "settings.default_balloon_hint"
+msgstr "Користувацьке діалогове вікно для використання під час виклику «DialogueManager.show_balloon()»"
+
+msgid "settings.revert_to_default_balloon"
+msgstr "Повернутися до стандартного діалогового вікна"
+
+msgid "settings.default_balloon_path"
+msgstr "<приклад діалогового вікна>"
+
+msgid "settings.autoload"
+msgstr "Авто. завантаження"
+
+msgid "settings.path"
+msgstr "Шлях"
+
+msgid "settings.new_template"
+msgstr "Нові файли діалогів починатимуться з тексту шаблону"
+
+msgid "settings.missing_keys"
+msgstr "Вважати відсутні ключі перекладу як помилками"
+
+msgid "settings.missing_keys_hint"
+msgstr "Якщо ви використовуєте статичні ключі перекладу, увімкнення цього параметра допоможе вам знайти рядки, до яких ви ще не додали ключ."
+
+msgid "settings.characters_translations"
+msgstr "Експорт імен персонажів у файлах перекладу"
+
+msgid "settings.wrap_long_lines"
+msgstr "Переносити довгі рядки"
+
+msgid "settings.include_failed_responses"
+msgstr "Включити відповіді з невдалими умовами"
+
+msgid "settings.ignore_missing_state_values"
+msgstr "Пропускати помилки пропущених значень стану (не рекомендується)"
+
+msgid "settings.custom_test_scene"
+msgstr "Користувацька тестова сцена (повинна розширювати BaseDialogueTestScene)"
+
+msgid "settings.default_csv_locale"
+msgstr "Стандартна мова CSV"
+
+msgid "settings.states_shortcuts"
+msgstr "Скорочення станів"
+
+msgid "settings.states_message"
+msgstr "Якщо автозавантаження увімкнено, ви можете звертатися до його властивостей і методів без необхідності використовувати його назву."
+
+msgid "settings.states_hint"
+msgstr "тобто, замість «ЯкийсьСтан.якась_властивість» ви можете просто використовувати «якусь_властивість»"
+
+msgid "settings.recompile_warning"
+msgstr "Зміна цих параметрів призведе до перекомпіляції усіх діалогів. Змінюйте їх, тільки якщо ви знаєте, що робите."
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr "Створити діалогову лінію для відповідей дочірнього елемента з іменами персонажів"
+
+msgid "settings.open_in_external_editor"
+msgstr "Відкрити файли діалогів у зовнішньому редакторі"
+
+msgid "settings.external_editor_warning"
+msgstr "Примітка: Підсвічування синтаксису та детальна перевірка помилок не підтримуються у зовнішніх редакторах."
+
+msgid "settings.include_characters_in_translations"
+msgstr "Включати імена персонажів до експорту перекладу"
+
+msgid "settings.include_notes_in_translations"
+msgstr "Включати примітки (## коментарі) до експорту перекладу"
+
+msgid "settings.check_for_updates"
+msgstr "Перевірити наявність оновлень"
+
+msgid "n_of_n"
+msgstr "{index} з {total}"
+
+msgid "search.find"
+msgstr "Знайти:"
+
+msgid "search.find_all"
+msgstr "Знайти всі..."
+
+msgid "search.placeholder"
+msgstr "Текст для пошуку"
+
+msgid "search.replace_placeholder"
+msgstr "Текст для заміни"
+
+msgid "search.replace_selected"
+msgstr "Замінити виділене"
+
+msgid "search.previous"
+msgstr "Назад"
+
+msgid "search.next"
+msgstr "Далі"
+
+msgid "search.match_case"
+msgstr "Збіг регістру"
+
+msgid "search.toggle_replace"
+msgstr "Замінити"
+
+msgid "search.replace_with"
+msgstr "Замінити на:"
+
+msgid "search.replace"
+msgstr "Замінити"
+
+msgid "search.replace_all"
+msgstr "Замінити все"
+
+msgid "files_list.filter"
+msgstr "Фільтр файлів"
+
+msgid "titles_list.filter"
+msgstr "Фільтр заголовків"
+
+msgid "errors.key_not_found"
+msgstr "Ключ «{key}» не знайдено."
+
+msgid "errors.line_and_message"
+msgstr "Помилка на {line}, {column}: {message}"
+
+msgid "errors_in_script"
+msgstr "У вашому скрипті є помилки. Виправте їх і спробуйте ще раз."
+
+msgid "errors_with_build"
+msgstr "Вам потрібно виправити помилки в діалогах, перш ніж ви зможете запустити гру."
+
+msgid "errors.import_errors"
+msgstr "В імпортованому файлі є помилки."
+
+msgid "errors.already_imported"
+msgstr "Файл уже імпортовано."
+
+msgid "errors.duplicate_import"
+msgstr "Дублювання назви імпорту."
+
+msgid "errors.unknown_using"
+msgstr "Невідоме автозавантаження в операторі використання."
+
+msgid "errors.empty_title"
+msgstr "Заголовки не можуть бути порожніми."
+
+msgid "errors.duplicate_title"
+msgstr "З такою назвою уже є заголовок."
+
+msgid "errors.nested_title"
+msgstr "Заголовки не повинні мати відступів."
+
+msgid "errors.invalid_title_string"
+msgstr "Заголовки можуть містити лише алфавітно-цифрові символи та цифри."
+
+msgid "errors.invalid_title_number"
+msgstr "Заголовки не можуть починатися з цифри."
+
+msgid "errors.unknown_title"
+msgstr "Невідомий заголовок."
+
+msgid "errors.jump_to_invalid_title"
+msgstr "Цей перехід вказує на недійсну назву."
+
+msgid "errors.title_has_no_content"
+msgstr "Цей заголовок не має змісту. Можливо, варто змінити його на «=> END»."
+
+msgid "errors.invalid_expression"
+msgstr "Вираз є недійсним."
+
+msgid "errors.unexpected_condition"
+msgstr "Несподівана умова."
+
+msgid "errors.duplicate_id"
+msgstr "Цей ідентифікатор вже на іншому рядку."
+
+msgid "errors.missing_id"
+msgstr "У цьому рядку відсутній ідентифікатор."
+
+msgid "errors.invalid_indentation"
+msgstr "Неправильний відступ."
+
+msgid "errors.condition_has_no_content"
+msgstr "Рядок умови потребує відступу під ним."
+
+msgid "errors.incomplete_expression"
+msgstr "Незавершений вираз."
+
+msgid "errors.invalid_expression_for_value"
+msgstr "Недійсний вираз для значення."
+
+msgid "errors.file_not_found"
+msgstr "Файл не знайдено."
+
+msgid "errors.unexpected_end_of_expression"
+msgstr "Несподіваний кінець виразу."
+
+msgid "errors.unexpected_function"
+msgstr "Несподівана функція."
+
+msgid "errors.unexpected_bracket"
+msgstr "Несподівана дужка."
+
+msgid "errors.unexpected_closing_bracket"
+msgstr "Несподівана закриваюча дужка."
+
+msgid "errors.missing_closing_bracket"
+msgstr "Відсутня закриваюча дужка."
+
+msgid "errors.unexpected_operator"
+msgstr "Несподіваний оператор."
+
+msgid "errors.unexpected_comma"
+msgstr "Несподівана кома."
+
+msgid "errors.unexpected_colon"
+msgstr "Несподівана двокрапка."
+
+msgid "errors.unexpected_dot"
+msgstr "Несподівана крапка."
+
+msgid "errors.unexpected_boolean"
+msgstr "Несподіваний логічний вираз."
+
+msgid "errors.unexpected_string"
+msgstr "Несподіваний рядок."
+
+msgid "errors.unexpected_number"
+msgstr "Несподіване число."
+
+msgid "errors.unexpected_variable"
+msgstr "Несподівана змінна."
+
+msgid "errors.invalid_index"
+msgstr "Недійсний індекс."
+
+msgid "errors.unexpected_assignment"
+msgstr "Несподіване призначення."
+
+msgid "errors.unknown"
+msgstr "Невідомий синтаксис."
+
+msgid "update.available"
+msgstr "Доступна версія {version}"
+
+msgid "update.is_available_for_download"
+msgstr "Версія %s доступна для завантаження!"
+
+msgid "update.downloading"
+msgstr "Завантаження..."
+
+msgid "update.download_update"
+msgstr "Завантажити оновлення"
+
+msgid "update.needs_reload"
+msgstr "Щоб встановити оновлення, проєкт потрібно перезавантажити."
+
+msgid "update.reload_ok_button"
+msgstr "Перезавантажити проєкт"
+
+msgid "update.reload_cancel_button"
+msgstr "Пізніше"
+
+msgid "update.reload_project"
+msgstr "Перезавантажити проєкт"
+
+msgid "update.release_notes"
+msgstr "Читати примітки оновлення"
+
+msgid "update.success"
+msgstr "Менеджер діалогів тепер має версію {version}."
+
+msgid "update.failed"
+msgstr "Виникла проблема із завантаженням оновлення."
+
+msgid "runtime.no_resource"
+msgstr "Ресурс для діалогу не надано."
+
+msgid "runtime.no_content"
+msgstr "«{file_path}» не має вмісту."
+
+msgid "runtime.errors"
+msgstr "У тексті діалогу було виявлено помилки ({count})."
+
+msgid "runtime.error_detail"
+msgstr "Рядок {line}: {message}"
+
+msgid "runtime.errors_see_details"
+msgstr "У тексті діалогу було виявлено помилки ({count}). Див. детальніше у розділі «Вивід»."
+
+msgid "runtime.invalid_expression"
+msgstr "«{expression}» не є допустимим виразом: {error}"
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr "Індекс {index} виходить за межі масиву «{array}»."
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr "Ліва частина виразу не може бути присвоєна."
+
+msgid "runtime.key_not_found"
+msgstr "Ключ «{key}» у словнику «{dictionary}»"
+
+msgid "runtime.property_not_found"
+msgstr "«{property}» не є властивістю для жодного стану гри ({states})."
+
+msgid "runtime.property_not_found_missing_export"
+msgstr "«{property}» не є властивістю для жодного стану гри ({states}). Можливо, вам слід додати декоратор [Export]."
+
+msgid "runtime.method_not_found"
+msgstr "«{method}» не є методом на жодному зі станів гри ({states})"
+
+msgid "runtime.signal_not_found"
+msgstr "«{signal_name}» не є сигналом на жодному зі станів гри ({states})"
+
+msgid "runtime.method_not_callable"
+msgstr "«{method}» не є методом, який можна викликати в «{object}»"
+
+msgid "runtime.unknown_operator"
+msgstr "Невідомий оператор."
+
+msgid "runtime.unknown_autoload"
+msgstr "«{autoload}» не є дійсним автозавантаженням."
+
+msgid "runtime.something_went_wrong"
+msgstr "Щось пішло не так."
+
+msgid "runtime.expected_n_got_n_args"
+msgstr "«{method}» було викликано з аргументами «{received}», але він має лише «{expected}»."
+
+msgid "runtime.unsupported_array_type"
+msgstr "Array[{type}] не підтримується в мутаціях. Натомість використовуйте Array як тип."
+
+msgid "runtime.dialogue_balloon_missing_start_method"
+msgstr "У вашому діалоговому вікні відсутній метод «start» або «Start»."
\ No newline at end of file
diff --git a/addons/dialogue_manager/l10n/zh.po b/addons/dialogue_manager/l10n/zh.po
new file mode 100644
index 00000000..887fb1eb
--- /dev/null
+++ b/addons/dialogue_manager/l10n/zh.po
@@ -0,0 +1,447 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: penghao123456、憨憨羊の宇航鸽鸽、ABShinri\n"
+"Language: zh\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 3.4\n"
+
+msgid "start_a_new_file"
+msgstr "创建新文件"
+
+msgid "open_a_file"
+msgstr "打开已有文件"
+
+msgid "open.open"
+msgstr "打开……"
+
+msgid "open.no_recent_files"
+msgstr "无历史记录"
+
+msgid "open.clear_recent_files"
+msgstr "清空历史记录"
+
+msgid "save_all_files"
+msgstr "保存所有文件"
+
+msgid "find_in_files"
+msgstr "在文件中查找"
+
+msgid "test_dialogue"
+msgstr "测试对话"
+
+msgid "search_for_text"
+msgstr "查找……"
+
+msgid "insert"
+msgstr "插入"
+
+msgid "translations"
+msgstr "翻译"
+
+msgid "settings"
+msgstr "设置"
+
+msgid "show_support"
+msgstr "支持 Dialogue Manager"
+
+msgid "docs"
+msgstr "文档"
+
+msgid "insert.wave_bbcode"
+msgstr "波浪效果"
+
+msgid "insert.shake_bbcode"
+msgstr "抖动效果"
+
+msgid "insert.typing_pause"
+msgstr "输入间隔"
+
+msgid "insert.typing_speed_change"
+msgstr "输入速度变更"
+
+msgid "insert.auto_advance"
+msgstr "自动切行"
+
+msgid "insert.templates"
+msgstr "模板"
+
+msgid "insert.title"
+msgstr "标题"
+
+msgid "insert.dialogue"
+msgstr "对话"
+
+msgid "insert.response"
+msgstr "回复选项"
+
+msgid "insert.random_lines"
+msgstr "随机行"
+
+msgid "insert.random_text"
+msgstr "随机文本"
+
+msgid "insert.actions"
+msgstr "操作"
+
+msgid "insert.jump"
+msgstr "标题间跳转"
+
+msgid "insert.end_dialogue"
+msgstr "结束对话"
+
+msgid "generate_line_ids"
+msgstr "生成行 ID"
+
+msgid "save_characters_to_csv"
+msgstr "保存角色到 CSV"
+
+msgid "save_to_csv"
+msgstr "生成 CSV"
+
+msgid "import_from_csv"
+msgstr "从 CSV 导入"
+
+msgid "confirm_close"
+msgstr "是否要保存到“{path}”?"
+
+msgid "confirm_close.save"
+msgstr "保存"
+
+msgid "confirm_close.discard"
+msgstr "不保存"
+
+msgid "buffer.save"
+msgstr "保存"
+
+msgid "buffer.save_as"
+msgstr "另存为……"
+
+msgid "buffer.close"
+msgstr "关闭"
+
+msgid "buffer.close_all"
+msgstr "全部关闭"
+
+msgid "buffer.close_other_files"
+msgstr "关闭其他文件"
+
+msgid "buffer.copy_file_path"
+msgstr "复制文件路径"
+
+msgid "buffer.show_in_filesystem"
+msgstr "在 Godot 侧边栏中显示"
+
+msgid "settings.revert_to_default_test_scene"
+msgstr "重置测试场景设定"
+
+msgid "settings.default_balloon_hint"
+msgstr "设置调用 \"DialogueManager.show_balloon()\" 时使用的对话框"
+
+msgid "settings.autoload"
+msgstr "Autoload"
+
+msgid "settings.path"
+msgstr "路径"
+
+msgid "settings.new_template"
+msgstr "新建文件时自动插入模板"
+
+msgid "settings.missing_keys"
+msgstr "将翻译键缺失视为错误"
+
+msgid "settings.missing_keys_hint"
+msgstr "如果你使用静态键,这将会帮助你寻找未添加至翻译文件的键。"
+
+msgid "settings.characters_translations"
+msgstr "在翻译文件中导出角色名"
+
+msgid "settings.wrap_long_lines"
+msgstr "文本编辑器自动换行"
+
+msgid "settings.include_failed_responses"
+msgstr "在判断条件失败时仍显示回复选项"
+
+msgid "settings.ignore_missing_state_values"
+msgstr "忽略全局变量缺失错误(不建议)"
+
+msgid "settings.custom_test_scene"
+msgstr "自定义测试场景(必须继承自BaseDialogueTestScene)"
+
+msgid "settings.default_csv_locale"
+msgstr "默认 CSV 区域格式"
+
+msgid "settings.states_shortcuts"
+msgstr "全局变量映射"
+
+msgid "settings.states_message"
+msgstr "当一个 Autoload 在这里被勾选,他的所有成员会被映射为全局变量。"
+
+msgid "settings.states_hint"
+msgstr "比如,当你开启对于“Foo”的映射时,你可以将“Foo.bar”简写成“bar”。"
+
+msgid "settings.recompile_warning"
+msgstr "更改这些选项会强制重新编译所有的对话框,当你清楚在做什么的时候更改。"
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr "回复项带角色名时(- char: response),会自动生成为选择后的下一句对话"
+
+msgid "settings.include_characters_in_translations"
+msgstr "导出 CSV 时包括角色名"
+
+msgid "settings.include_notes_in_translations"
+msgstr "导出 CSV 时包括注释(## comments)"
+
+msgid "settings.check_for_updates"
+msgstr "检查升级"
+
+msgid "n_of_n"
+msgstr "第{index}个,共{total}个"
+
+msgid "search.find"
+msgstr "查找:"
+
+msgid "search.find_all"
+msgstr "查找全部..."
+
+msgid "search.placeholder"
+msgstr "请输入查找的内容"
+
+msgid "search.replace_placeholder"
+msgstr "请输入替换的内容"
+
+msgid "search.replace_selected"
+msgstr "替换勾选"
+
+msgid "search.previous"
+msgstr "查找上一个"
+
+msgid "search.next"
+msgstr "查找下一个"
+
+msgid "search.match_case"
+msgstr "大小写敏感"
+
+msgid "search.toggle_replace"
+msgstr "替换"
+
+msgid "search.replace_with"
+msgstr "替换为"
+
+msgid "search.replace"
+msgstr "替换"
+
+msgid "search.replace_all"
+msgstr "全部替换"
+
+msgid "files_list.filter"
+msgstr "查找文件"
+
+msgid "titles_list.filter"
+msgstr "查找标题"
+
+msgid "errors.key_not_found"
+msgstr "键“{key}”未找到"
+
+msgid "errors.line_and_message"
+msgstr "第{line}行第{colume}列发生错误:{message}"
+
+msgid "errors_in_script"
+msgstr "你的脚本中存在错误。请修复错误,然后重试。"
+
+msgid "errors_with_build"
+msgstr "请先解决 Dialogue 中的错误。"
+
+msgid "errors.import_errors"
+msgstr "被导入的文件存在问题。"
+
+msgid "errors.already_imported"
+msgstr "文件已被导入。"
+
+msgid "errors.duplicate_import"
+msgstr "导入名不能重复。"
+
+msgid "errors.empty_title"
+msgstr "标题名不能为空。"
+
+msgid "errors.duplicate_title"
+msgstr "标题名不能重复。"
+
+msgid "errors.nested_title"
+msgstr "标题不能嵌套。"
+
+msgid "errors.invalid_title_string"
+msgstr "标题名无效。"
+
+msgid "errors.invalid_title_number"
+msgstr "标题不能以数字开始。"
+
+msgid "errors.unknown_title"
+msgstr "标题未定义。"
+
+msgid "errors.jump_to_invalid_title"
+msgstr "标题名无效。"
+
+msgid "errors.title_has_no_content"
+msgstr "目标标题为空。请替换为“=> END”。"
+
+msgid "errors.invalid_expression"
+msgstr "表达式无效。"
+
+msgid "errors.unexpected_condition"
+msgstr "未知条件。"
+
+msgid "errors.duplicate_id"
+msgstr "ID 重复。"
+
+msgid "errors.missing_id"
+msgstr "ID 不存在。"
+
+msgid "errors.invalid_indentation"
+msgstr "缩进无效。"
+
+msgid "errors.condition_has_no_content"
+msgstr "条件下方不能为空。"
+
+msgid "errors.incomplete_expression"
+msgstr "不完整的表达式。"
+
+msgid "errors.invalid_expression_for_value"
+msgstr "无效的赋值表达式。"
+
+msgid "errors.file_not_found"
+msgstr "文件不存在。"
+
+msgid "errors.unexpected_end_of_expression"
+msgstr "表达式 end 不应存在。"
+
+msgid "errors.unexpected_function"
+msgstr "函数不应存在。"
+
+msgid "errors.unexpected_bracket"
+msgstr "方括号不应存在。"
+
+msgid "errors.unexpected_closing_bracket"
+msgstr "方括号不应存在。"
+
+msgid "errors.missing_closing_bracket"
+msgstr "闭方括号不存在。"
+
+msgid "errors.unexpected_operator"
+msgstr "操作符不应存在。"
+
+msgid "errors.unexpected_comma"
+msgstr "逗号不应存在。"
+
+msgid "errors.unexpected_colon"
+msgstr "冒号不应存在。"
+
+msgid "errors.unexpected_dot"
+msgstr "句号不应存在。"
+
+msgid "errors.unexpected_boolean"
+msgstr "布尔值不应存在。"
+
+msgid "errors.unexpected_string"
+msgstr "字符串不应存在。"
+
+msgid "errors.unexpected_number"
+msgstr "数字不应存在。"
+
+msgid "errors.unexpected_variable"
+msgstr "标识符不应存在。"
+
+msgid "errors.invalid_index"
+msgstr "索引无效。"
+
+msgid "errors.unexpected_assignment"
+msgstr "不应在条件判断中使用 = ,应使用 == 。"
+
+msgid "errors.unknown"
+msgstr "语法错误。"
+
+msgid "update.available"
+msgstr "v{version} 更新可用。"
+
+msgid "update.is_available_for_download"
+msgstr "v%s 已经可以下载。"
+
+msgid "update.downloading"
+msgstr "正在下载更新……"
+
+msgid "update.download_update"
+msgstr "下载"
+
+msgid "update.needs_reload"
+msgstr "需要重新加载项目以应用更新。"
+
+msgid "update.reload_ok_button"
+msgstr "重新加载"
+
+msgid "update.reload_cancel_button"
+msgstr "暂不重新加载"
+
+msgid "update.reload_project"
+msgstr "重新加载"
+
+msgid "update.release_notes"
+msgstr "查看发行注记"
+
+msgid "update.success"
+msgstr "v{version} 已成功安装并应用。"
+
+msgid "update.failed"
+msgstr "更新失败。"
+
+msgid "runtime.no_resource"
+msgstr "找不到资源。"
+
+msgid "runtime.no_content"
+msgstr "资源“{file_path}”为空。"
+
+msgid "runtime.errors"
+msgstr "文件中存在{errrors}个错误。"
+
+msgid "runtime.error_detail"
+msgstr "第{index}行:{message}"
+
+msgid "runtime.errors_see_details"
+msgstr "文件中存在{errrors}个错误。请查看调试输出。"
+
+msgid "runtime.invalid_expression"
+msgstr "表达式“{expression}”无效:{error}"
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr "数组索引“{index}”越界。(数组名:“{array}”)"
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr "表达式左侧的变量无法被赋值。"
+
+msgid "runtime.key_not_found"
+msgstr "键“{key}”在字典“{dictionary}”中不存在。"
+
+msgid "runtime.property_not_found"
+msgstr "“{property}”不存在。(全局变量:{states})"
+
+msgid "runtime.property_not_found_missing_export"
+msgstr "“{property}”不存在。(全局变量:{states})你可能需要添加一个修饰词 [Export]。"
+
+msgid "runtime.method_not_found"
+msgstr "“{method}”不存在。(全局变量:{states})"
+
+msgid "runtime.signal_not_found"
+msgstr "“{sighal_name}”不存在。(全局变量:{states})"
+
+msgid "runtime.method_not_callable"
+msgstr "{method}不是对象“{object}”上的函数。"
+
+msgid "runtime.unknown_operator"
+msgstr "未知操作符。"
+
+msgid "runtime.something_went_wrong"
+msgstr "有什么出错了。"
diff --git a/addons/dialogue_manager/l10n/zh_TW.po b/addons/dialogue_manager/l10n/zh_TW.po
new file mode 100644
index 00000000..bee270d5
--- /dev/null
+++ b/addons/dialogue_manager/l10n/zh_TW.po
@@ -0,0 +1,447 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: 憨憨羊の宇航鴿鴿、ABShinri\n"
+"Language: zh_TW\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 3.4\n"
+
+msgid "start_a_new_file"
+msgstr "創建新檔案"
+
+msgid "open_a_file"
+msgstr "開啟已有檔案"
+
+msgid "open.open"
+msgstr "開啟……"
+
+msgid "open.no_recent_files"
+msgstr "無歷史記錄"
+
+msgid "open.clear_recent_files"
+msgstr "清空歷史記錄"
+
+msgid "save_all_files"
+msgstr "儲存所有檔案"
+
+msgid "find_in_files"
+msgstr "在檔案中查找"
+
+msgid "test_dialogue"
+msgstr "測試對話"
+
+msgid "search_for_text"
+msgstr "搜尋……"
+
+msgid "insert"
+msgstr "插入"
+
+msgid "translations"
+msgstr "翻譯"
+
+msgid "settings"
+msgstr "設定"
+
+msgid "show_support"
+msgstr "支援 Dialogue Manager"
+
+msgid "docs"
+msgstr "文檔"
+
+msgid "insert.wave_bbcode"
+msgstr "波浪特效"
+
+msgid "insert.shake_bbcode"
+msgstr "震動特效"
+
+msgid "insert.typing_pause"
+msgstr "輸入間隔"
+
+msgid "insert.typing_speed_change"
+msgstr "輸入速度變更"
+
+msgid "insert.auto_advance"
+msgstr "自動切行"
+
+msgid "insert.templates"
+msgstr "模板"
+
+msgid "insert.title"
+msgstr "標題"
+
+msgid "insert.dialogue"
+msgstr "對話"
+
+msgid "insert.response"
+msgstr "回覆選項"
+
+msgid "insert.random_lines"
+msgstr "隨機行"
+
+msgid "insert.random_text"
+msgstr "隨機文本"
+
+msgid "insert.actions"
+msgstr "操作"
+
+msgid "insert.jump"
+msgstr "標題間跳轉"
+
+msgid "insert.end_dialogue"
+msgstr "結束對話"
+
+msgid "generate_line_ids"
+msgstr "生成行 ID"
+
+msgid "save_characters_to_csv"
+msgstr "保存角色到 CSV"
+
+msgid "save_to_csv"
+msgstr "生成 CSV"
+
+msgid "import_from_csv"
+msgstr "從 CSV 匯入"
+
+msgid "confirm_close"
+msgstr "是否要儲存到“{path}”?"
+
+msgid "confirm_close.save"
+msgstr "儲存"
+
+msgid "confirm_close.discard"
+msgstr "不儲存"
+
+msgid "buffer.save"
+msgstr "儲存"
+
+msgid "buffer.save_as"
+msgstr "儲存爲……"
+
+msgid "buffer.close"
+msgstr "關閉"
+
+msgid "buffer.close_all"
+msgstr "全部關閉"
+
+msgid "buffer.close_other_files"
+msgstr "關閉其他檔案"
+
+msgid "buffer.copy_file_path"
+msgstr "複製檔案位置"
+
+msgid "buffer.show_in_filesystem"
+msgstr "在 Godot 側邊欄中顯示"
+
+msgid "settings.revert_to_default_test_scene"
+msgstr "重置測試場景設定"
+
+msgid "settings.default_balloon_hint"
+msgstr "設置使用 \"DialogueManager.show_balloon()\" 时的对话框"
+
+msgid "settings.autoload"
+msgstr "Autoload"
+
+msgid "settings.path"
+msgstr "路徑"
+
+msgid "settings.new_template"
+msgstr "新建檔案時自動插入模板"
+
+msgid "settings.missing_keys"
+msgstr "將翻譯鍵缺失視爲錯誤"
+
+msgid "settings.missing_keys_hint"
+msgstr "如果你使用靜態鍵,這將會幫助你尋找未添加至翻譯檔案的鍵。"
+
+msgid "settings.wrap_long_lines"
+msgstr "自動折行"
+
+msgid "settings.characters_translations"
+msgstr "在翻譯檔案中匯出角色名。"
+
+msgid "settings.include_failed_responses"
+msgstr "在判斷條件失敗時仍顯示回復選項"
+
+msgid "settings.ignore_missing_state_values"
+msgstr "忽略全局變量缺失錯誤(不建議)"
+
+msgid "settings.custom_test_scene"
+msgstr "自訂測試場景(必須繼承自BaseDialogueTestScene)"
+
+msgid "settings.default_csv_locale"
+msgstr "預設 CSV 區域格式"
+
+msgid "settings.states_shortcuts"
+msgstr "全局變量映射"
+
+msgid "settings.states_message"
+msgstr "當一個 Autoload 在這裏被勾選,他的所有成員會被映射爲全局變量。"
+
+msgid "settings.states_hint"
+msgstr "比如,當你開啓對於“Foo”的映射時,你可以將“Foo.bar”簡寫成“bar”。"
+
+msgid "settings.recompile_warning"
+msgstr "更改這些選項會強制重新編譯所有的對話框,當你清楚在做什麼的時候更改。"
+
+msgid "settings.create_lines_for_responses_with_characters"
+msgstr "回覆項目帶角色名稱時(- char: response),會自動產生為選擇後的下一句對話"
+
+msgid "settings.include_characters_in_translations"
+msgstr "匯出 CSV 時包含角色名"
+
+msgid "settings.include_notes_in_translations"
+msgstr "匯出 CSV 時包括註解(## comments)"
+
+msgid "settings.check_for_updates"
+msgstr "檢查升級"
+
+msgid "n_of_n"
+msgstr "第{index}個,共{total}個"
+
+msgid "search.find"
+msgstr "搜尋:"
+
+msgid "search.find_all"
+msgstr "搜尋全部..."
+
+msgid "search.placeholder"
+msgstr "請輸入搜尋的內容"
+
+msgid "search.replace_placeholder"
+msgstr "請輸入替換的內容"
+
+msgid "search.replace_selected"
+msgstr "替換勾選"
+
+msgid "search.previous"
+msgstr "搜尋上一個"
+
+msgid "search.next"
+msgstr "搜尋下一個"
+
+msgid "search.match_case"
+msgstr "大小寫敏感"
+
+msgid "search.toggle_replace"
+msgstr "替換"
+
+msgid "search.replace_with"
+msgstr "替換爲"
+
+msgid "search.replace"
+msgstr "替換"
+
+msgid "search.replace_all"
+msgstr "全部替換"
+
+msgid "files_list.filter"
+msgstr "搜尋檔案"
+
+msgid "titles_list.filter"
+msgstr "搜尋標題"
+
+msgid "errors.key_not_found"
+msgstr "鍵“{key}”未找到"
+
+msgid "errors.line_and_message"
+msgstr "第{line}行第{colume}列發生錯誤:{message}"
+
+msgid "errors_in_script"
+msgstr "你的腳本中存在錯誤。請修復錯誤,然後重試。"
+
+msgid "errors_with_build"
+msgstr "請先解決 Dialogue 中的錯誤。"
+
+msgid "errors.import_errors"
+msgstr "被匯入的檔案存在問題。"
+
+msgid "errors.already_imported"
+msgstr "檔案已被匯入。"
+
+msgid "errors.duplicate_import"
+msgstr "匯入名不能重複。"
+
+msgid "errors.empty_title"
+msgstr "標題名不能爲空。"
+
+msgid "errors.duplicate_title"
+msgstr "標題名不能重複。"
+
+msgid "errors.nested_title"
+msgstr "標題不能嵌套。"
+
+msgid "errors.invalid_title_string"
+msgstr "標題名無效。"
+
+msgid "errors.invalid_title_number"
+msgstr "標題不能以數字開始。"
+
+msgid "errors.unknown_title"
+msgstr "標題未定義。"
+
+msgid "errors.jump_to_invalid_title"
+msgstr "標題名無效。"
+
+msgid "errors.title_has_no_content"
+msgstr "目標標題爲空。請替換爲“=> END”。"
+
+msgid "errors.invalid_expression"
+msgstr "表達式無效。"
+
+msgid "errors.unexpected_condition"
+msgstr "未知條件。"
+
+msgid "errors.duplicate_id"
+msgstr "ID 重複。"
+
+msgid "errors.missing_id"
+msgstr "ID 不存在。"
+
+msgid "errors.invalid_indentation"
+msgstr "縮進無效。"
+
+msgid "errors.condition_has_no_content"
+msgstr "條件下方不能爲空。"
+
+msgid "errors.incomplete_expression"
+msgstr "不完整的表達式。"
+
+msgid "errors.invalid_expression_for_value"
+msgstr "無效的賦值表達式。"
+
+msgid "errors.file_not_found"
+msgstr "檔案不存在。"
+
+msgid "errors.unexpected_end_of_expression"
+msgstr "表達式 end 不應存在。"
+
+msgid "errors.unexpected_function"
+msgstr "函數不應存在。"
+
+msgid "errors.unexpected_bracket"
+msgstr "方括號不應存在。"
+
+msgid "errors.unexpected_closing_bracket"
+msgstr "方括號不應存在。"
+
+msgid "errors.missing_closing_bracket"
+msgstr "閉方括號不存在。"
+
+msgid "errors.unexpected_operator"
+msgstr "操作符不應存在。"
+
+msgid "errors.unexpected_comma"
+msgstr "逗號不應存在。"
+
+msgid "errors.unexpected_colon"
+msgstr "冒號不應存在。"
+
+msgid "errors.unexpected_dot"
+msgstr "句號不應存在。"
+
+msgid "errors.unexpected_boolean"
+msgstr "布爾值不應存在。"
+
+msgid "errors.unexpected_string"
+msgstr "字符串不應存在。"
+
+msgid "errors.unexpected_number"
+msgstr "數字不應存在。"
+
+msgid "errors.unexpected_variable"
+msgstr "標識符不應存在。"
+
+msgid "errors.invalid_index"
+msgstr "索引無效。"
+
+msgid "errors.unexpected_assignment"
+msgstr "不應在條件判斷中使用 = ,應使用 == 。"
+
+msgid "errors.unknown"
+msgstr "語法錯誤。"
+
+msgid "update.available"
+msgstr "v{version} 更新可用。"
+
+msgid "update.is_available_for_download"
+msgstr "v%s 已經可以下載。"
+
+msgid "update.downloading"
+msgstr "正在下載更新……"
+
+msgid "update.download_update"
+msgstr "下載"
+
+msgid "update.needs_reload"
+msgstr "需要重新加載項目以套用更新。"
+
+msgid "update.reload_ok_button"
+msgstr "重新加載"
+
+msgid "update.reload_cancel_button"
+msgstr "暫不重新加載"
+
+msgid "update.reload_project"
+msgstr "重新加載"
+
+msgid "update.release_notes"
+msgstr "查看發行註記"
+
+msgid "update.success"
+msgstr "v{version} 已成功安裝並套用。"
+
+msgid "update.failed"
+msgstr "更新失敗。"
+
+msgid "runtime.no_resource"
+msgstr "找不到資源。"
+
+msgid "runtime.no_content"
+msgstr "資源“{file_path}”爲空。"
+
+msgid "runtime.errors"
+msgstr "檔案中存在{errrors}個錯誤。"
+
+msgid "runtime.error_detail"
+msgstr "第{index}行:{message}"
+
+msgid "runtime.errors_see_details"
+msgstr "檔案中存在{errrors}個錯誤。請查看調試輸出。"
+
+msgid "runtime.invalid_expression"
+msgstr "表達式“{expression}”無效:{error}"
+
+msgid "runtime.array_index_out_of_bounds"
+msgstr "數組索引“{index}”越界。(數組名:“{array}”)"
+
+msgid "runtime.left_hand_size_cannot_be_assigned_to"
+msgstr "表達式左側的變量無法被賦值。"
+
+msgid "runtime.key_not_found"
+msgstr "鍵“{key}”在字典“{dictionary}”中不存在。"
+
+msgid "runtime.property_not_found"
+msgstr "“{property}”不存在。(全局變量:{states})"
+
+msgid "runtime.method_not_found"
+msgstr "“{method}”不存在。(全局變量:{states})"
+
+msgid "runtime.signal_not_found"
+msgstr "“{sighal_name}”不存在。(全局變量:{states})"
+
+msgid "runtime.property_not_found_missing_export"
+msgstr "“{property}”不存在。(全局變量:{states})你可能需要添加一個修飾詞 [Export]。"
+
+msgid "runtime.method_not_callable"
+msgstr "{method}不是對象“{object}”上的函數。"
+
+msgid "runtime.unknown_operator"
+msgstr "未知操作符。"
+
+msgid "runtime.something_went_wrong"
+msgstr "有什麼出錯了。"
diff --git a/addons/dialogue_manager/plugin.cfg b/addons/dialogue_manager/plugin.cfg
new file mode 100644
index 00000000..177fbfb6
--- /dev/null
+++ b/addons/dialogue_manager/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Dialogue Manager"
+description="A simple but powerful branching dialogue system"
+author="Nathan Hoad"
+version="2.41.4"
+script="plugin.gd"
diff --git a/addons/dialogue_manager/plugin.gd b/addons/dialogue_manager/plugin.gd
new file mode 100644
index 00000000..b1435c73
--- /dev/null
+++ b/addons/dialogue_manager/plugin.gd
@@ -0,0 +1,363 @@
+@tool
+extends EditorPlugin
+
+
+const DialogueConstants = preload("./constants.gd")
+const DialogueImportPlugin = preload("./import_plugin.gd")
+const DialogueInspectorPlugin = preload("./inspector_plugin.gd")
+const DialogueTranslationParserPlugin = preload("./editor_translation_parser_plugin.gd")
+const DialogueSettings = preload("./settings.gd")
+const DialogueCache = preload("./components/dialogue_cache.gd")
+const MainView = preload("./views/main_view.tscn")
+const DialogueResource = preload("./dialogue_resource.gd")
+
+
+var import_plugin: DialogueImportPlugin
+var inspector_plugin: DialogueInspectorPlugin
+var translation_parser_plugin: DialogueTranslationParserPlugin
+var main_view
+var dialogue_cache: DialogueCache
+
+
+func _enter_tree() -> void:
+ add_autoload_singleton("DialogueManager", get_plugin_path() + "/dialogue_manager.gd")
+
+ if Engine.is_editor_hint():
+ Engine.set_meta("DialogueManagerPlugin", self)
+
+ DialogueSettings.prepare()
+
+ dialogue_cache = DialogueCache.new()
+ Engine.set_meta("DialogueCache", dialogue_cache)
+
+ import_plugin = DialogueImportPlugin.new()
+ add_import_plugin(import_plugin)
+
+ inspector_plugin = DialogueInspectorPlugin.new()
+ add_inspector_plugin(inspector_plugin)
+
+ translation_parser_plugin = DialogueTranslationParserPlugin.new()
+ add_translation_parser_plugin(translation_parser_plugin)
+
+ main_view = MainView.instantiate()
+ get_editor_interface().get_editor_main_screen().add_child(main_view)
+ _make_visible(false)
+ main_view.add_child(dialogue_cache)
+
+ _update_localization()
+
+ get_editor_interface().get_file_system_dock().files_moved.connect(_on_files_moved)
+ get_editor_interface().get_file_system_dock().file_removed.connect(_on_file_removed)
+
+ add_tool_menu_item("Create copy of dialogue example balloon...", _copy_dialogue_balloon)
+
+ # Prevent the project from showing as unsaved even though it was only just opened
+ if DialogueSettings.get_setting("try_suppressing_startup_unsaved_indicator", false) \
+ and Engine.get_physics_frames() == 0 \
+ and get_editor_interface().has_method("save_all_scenes"):
+ var timer: Timer = Timer.new()
+ var suppress_unsaved_marker: Callable
+ suppress_unsaved_marker = func():
+ if Engine.get_frames_per_second() >= 10:
+ timer.stop()
+ get_editor_interface().call("save_all_scenes")
+ timer.queue_free()
+ timer.timeout.connect(suppress_unsaved_marker)
+ add_child(timer)
+ timer.start(0.1)
+
+
+func _exit_tree() -> void:
+ remove_autoload_singleton("DialogueManager")
+
+ remove_import_plugin(import_plugin)
+ import_plugin = null
+
+ remove_inspector_plugin(inspector_plugin)
+ inspector_plugin = null
+
+ remove_translation_parser_plugin(translation_parser_plugin)
+ translation_parser_plugin = null
+
+ if is_instance_valid(main_view):
+ main_view.queue_free()
+
+ Engine.remove_meta("DialogueManagerPlugin")
+ Engine.remove_meta("DialogueCache")
+
+ get_editor_interface().get_file_system_dock().files_moved.disconnect(_on_files_moved)
+ get_editor_interface().get_file_system_dock().file_removed.disconnect(_on_file_removed)
+
+ remove_tool_menu_item("Create copy of dialogue example balloon...")
+
+
+func _has_main_screen() -> bool:
+ return true
+
+
+func _make_visible(next_visible: bool) -> void:
+ if is_instance_valid(main_view):
+ main_view.visible = next_visible
+
+
+func _get_plugin_name() -> String:
+ return "Dialogue"
+
+
+func _get_plugin_icon() -> Texture2D:
+ return load(get_plugin_path() + "/assets/icon.svg")
+
+
+func _handles(object) -> bool:
+ var editor_settings: EditorSettings = get_editor_interface().get_editor_settings()
+ var external_editor: String = editor_settings.get_setting("text_editor/external/exec_path")
+ var use_external_editor: bool = editor_settings.get_setting("text_editor/external/use_external_editor") and external_editor != ""
+ if object is DialogueResource and use_external_editor and DialogueSettings.get_user_value("open_in_external_editor", false):
+ var project_path: String = ProjectSettings.globalize_path("res://")
+ var file_path: String = ProjectSettings.globalize_path(object.resource_path)
+ OS.create_process(external_editor, [project_path, file_path])
+ return false
+
+ return object is DialogueResource
+
+
+func _edit(object) -> void:
+ if is_instance_valid(main_view) and is_instance_valid(object):
+ main_view.open_resource(object)
+
+
+func _apply_changes() -> void:
+ if is_instance_valid(main_view):
+ main_view.apply_changes()
+ _update_localization()
+
+
+func _build() -> bool:
+ # If this is the dotnet Godot then we need to check if the solution file exists
+ DialogueSettings.check_for_dotnet_solution()
+
+ # Ignore errors in other files if we are just running the test scene
+ if DialogueSettings.get_user_value("is_running_test_scene", true): return true
+
+ if dialogue_cache != null:
+ var files_with_errors = dialogue_cache.get_files_with_errors()
+ if files_with_errors.size() > 0:
+ for dialogue_file in files_with_errors:
+ push_error("You have %d error(s) in %s" % [dialogue_file.errors.size(), dialogue_file.path])
+ get_editor_interface().edit_resource(load(files_with_errors[0].path))
+ main_view.show_build_error_dialog()
+ return false
+
+ return true
+
+
+## Get the shortcuts used by the plugin
+func get_editor_shortcuts() -> Dictionary:
+ var shortcuts: Dictionary = {
+ toggle_comment = [
+ _create_event("Ctrl+K"),
+ _create_event("Ctrl+Slash")
+ ],
+ delete_line = [
+ _create_event("Ctrl+Shift+K")
+ ],
+ move_up = [
+ _create_event("Alt+Up")
+ ],
+ move_down = [
+ _create_event("Alt+Down")
+ ],
+ save = [
+ _create_event("Ctrl+Alt+S")
+ ],
+ close_file = [
+ _create_event("Ctrl+W")
+ ],
+ find_in_files = [
+ _create_event("Ctrl+Shift+F")
+ ],
+
+ run_test_scene = [
+ _create_event("Ctrl+F5")
+ ],
+ text_size_increase = [
+ _create_event("Ctrl+Equal")
+ ],
+ text_size_decrease = [
+ _create_event("Ctrl+Minus")
+ ],
+ text_size_reset = [
+ _create_event("Ctrl+0")
+ ]
+ }
+
+ var paths = get_editor_interface().get_editor_paths()
+ var settings
+ if FileAccess.file_exists(paths.get_config_dir() + "/editor_settings-4.3.tres"):
+ settings = load(paths.get_config_dir() + "/editor_settings-4.3.tres")
+ elif FileAccess.file_exists(paths.get_config_dir() + "/editor_settings-4.tres"):
+ settings = load(paths.get_config_dir() + "/editor_settings-4.tres")
+ else:
+ return shortcuts
+
+ for s in settings.get("shortcuts"):
+ for key in shortcuts:
+ if s.name == "script_text_editor/%s" % key or s.name == "script_editor/%s" % key:
+ shortcuts[key] = []
+ for event in s.shortcuts:
+ if event is InputEventKey:
+ shortcuts[key].append(event)
+
+ return shortcuts
+
+
+func _create_event(string: String) -> InputEventKey:
+ var event: InputEventKey = InputEventKey.new()
+ var bits = string.split("+")
+ event.keycode = OS.find_keycode_from_string(bits[bits.size() - 1])
+ event.shift_pressed = bits.has("Shift")
+ event.alt_pressed = bits.has("Alt")
+ if bits.has("Ctrl") or bits.has("Command"):
+ event.command_or_control_autoremap = true
+ return event
+
+
+## Get the editor shortcut that matches an event
+func get_editor_shortcut(event: InputEventKey) -> String:
+ var shortcuts: Dictionary = get_editor_shortcuts()
+ for key in shortcuts:
+ for shortcut in shortcuts.get(key, []):
+ if event.as_text().split(" ")[0] == shortcut.as_text().split(" ")[0]:
+ return key
+ return ""
+
+
+## Get the current version
+func get_version() -> String:
+ var config: ConfigFile = ConfigFile.new()
+ config.load(get_plugin_path() + "/plugin.cfg")
+ return config.get_value("plugin", "version")
+
+
+## Get the current path of the plugin
+func get_plugin_path() -> String:
+ return get_script().resource_path.get_base_dir()
+
+
+## Update references to a moved file
+func update_import_paths(from_path: String, to_path: String) -> void:
+ dialogue_cache.move_file_path(from_path, to_path)
+
+ # Reopen the file if it's already open
+ if main_view.current_file_path == from_path:
+ if to_path == "":
+ main_view.close_file(from_path)
+ else:
+ main_view.current_file_path = ""
+ main_view.open_file(to_path)
+
+ # Update any other files that import the moved file
+ var dependents = dialogue_cache.get_files_with_dependency(from_path)
+ for dependent in dependents:
+ dependent.dependencies.remove_at(dependent.dependencies.find(from_path))
+ dependent.dependencies.append(to_path)
+
+ # Update the live buffer
+ if main_view.current_file_path == dependent.path:
+ main_view.code_edit.text = main_view.code_edit.text.replace(from_path, to_path)
+ main_view.pristine_text = main_view.code_edit.text
+
+ # Open the file and update the path
+ var file: FileAccess = FileAccess.open(dependent.path, FileAccess.READ)
+ var text = file.get_as_text().replace(from_path, to_path)
+ file.close()
+
+ file = FileAccess.open(dependent.path, FileAccess.WRITE)
+ file.store_string(text)
+ file.close()
+
+
+func _update_localization() -> void:
+ var dialogue_files = dialogue_cache.get_files()
+
+ # Add any new files to POT generation
+ var files_for_pot: PackedStringArray = ProjectSettings.get_setting("internationalization/locale/translations_pot_files", [])
+ var files_for_pot_changed: bool = false
+ for path in dialogue_files:
+ if not files_for_pot.has(path):
+ files_for_pot.append(path)
+ files_for_pot_changed = true
+
+ # Remove any POT references that don't exist any more
+ for i in range(files_for_pot.size() - 1, -1, -1):
+ var file_for_pot: String = files_for_pot[i]
+ if file_for_pot.get_extension() == "dialogue" and not dialogue_files.has(file_for_pot):
+ files_for_pot.remove_at(i)
+ files_for_pot_changed = true
+
+ # Update project settings if POT changed
+ if files_for_pot_changed:
+ ProjectSettings.set_setting("internationalization/locale/translations_pot_files", files_for_pot)
+ ProjectSettings.save()
+
+
+### Callbacks
+
+
+func _copy_dialogue_balloon() -> void:
+ var scale: float = get_editor_interface().get_editor_scale()
+ var directory_dialog: FileDialog = FileDialog.new()
+ var label: Label = Label.new()
+ label.text = "Dialogue balloon files will be copied into chosen directory."
+ directory_dialog.get_vbox().add_child(label)
+ directory_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
+ directory_dialog.min_size = Vector2(600, 500) * scale
+ directory_dialog.dir_selected.connect(func(path):
+ var plugin_path: String = get_plugin_path()
+
+ var is_dotnet: bool = DialogueSettings.check_for_dotnet_solution()
+ var balloon_path: String = path + ("/Balloon.tscn" if is_dotnet else "/balloon.tscn")
+ var balloon_script_path: String = path + ("/DialogueBalloon.cs" if is_dotnet else "/balloon.gd")
+
+ # Copy the balloon scene file and change the script reference
+ var is_small_window: bool = ProjectSettings.get_setting("display/window/size/viewport_width") < 400
+ var example_balloon_file_name: String = "small_example_balloon.tscn" if is_small_window else "example_balloon.tscn"
+ var example_balloon_script_file_name: String = "ExampleBalloon.cs" if is_dotnet else "example_balloon.gd"
+ var file: FileAccess = FileAccess.open(plugin_path + "/example_balloon/" + example_balloon_file_name, FileAccess.READ)
+ var file_contents: String = file.get_as_text().replace(plugin_path + "/example_balloon/example_balloon.gd", balloon_script_path)
+ file = FileAccess.open(balloon_path, FileAccess.WRITE)
+ file.store_string(file_contents)
+ file.close()
+
+ # Copy the script file
+ file = FileAccess.open(plugin_path + "/example_balloon/" + example_balloon_script_file_name, FileAccess.READ)
+ file_contents = file.get_as_text()
+ if is_dotnet:
+ file_contents = file_contents.replace("class ExampleBalloon", "class DialogueBalloon")
+ file = FileAccess.open(balloon_script_path, FileAccess.WRITE)
+ file.store_string(file_contents)
+ file.close()
+
+ get_editor_interface().get_resource_filesystem().scan()
+ get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", balloon_path)
+
+ DialogueSettings.set_setting("balloon_path", balloon_path)
+
+ directory_dialog.queue_free()
+ )
+ get_editor_interface().get_base_control().add_child(directory_dialog)
+ directory_dialog.popup_centered()
+
+
+### Signals
+
+
+func _on_files_moved(old_file: String, new_file: String) -> void:
+ update_import_paths(old_file, new_file)
+ DialogueSettings.move_recent_file(old_file, new_file)
+
+
+func _on_file_removed(file: String) -> void:
+ update_import_paths(file, "")
+ if is_instance_valid(main_view):
+ main_view.close_file(file)
diff --git a/addons/dialogue_manager/settings.gd b/addons/dialogue_manager/settings.gd
new file mode 100644
index 00000000..fb9e6e31
--- /dev/null
+++ b/addons/dialogue_manager/settings.gd
@@ -0,0 +1,187 @@
+@tool
+extends Node
+
+
+const DialogueConstants = preload("./constants.gd")
+
+
+### Editor config
+
+const DEFAULT_SETTINGS = {
+ states = [],
+ missing_translations_are_errors = false,
+ export_characters_in_translation = true,
+ wrap_lines = false,
+ new_with_template = true,
+ include_all_responses = false,
+ ignore_missing_state_values = false,
+ custom_test_scene_path = preload("./test_scene.tscn").resource_path,
+ default_csv_locale = "en",
+ balloon_path = "",
+ create_lines_for_responses_with_characters = true,
+ include_character_in_translation_exports = false,
+ include_notes_in_translation_exports = false,
+ uses_dotnet = false,
+ try_suppressing_startup_unsaved_indicator = false
+}
+
+
+static func prepare() -> void:
+ # Migrate previous keys
+ for key in [
+ "states",
+ "missing_translations_are_errors",
+ "export_characters_in_translation",
+ "wrap_lines",
+ "new_with_template",
+ "include_all_responses",
+ "custom_test_scene_path"
+ ]:
+ if ProjectSettings.has_setting("dialogue_manager/%s" % key):
+ var value = ProjectSettings.get_setting("dialogue_manager/%s" % key)
+ ProjectSettings.set_setting("dialogue_manager/%s" % key, null)
+ set_setting(key, value)
+
+ # Set up initial settings
+ for setting in DEFAULT_SETTINGS:
+ var setting_name: String = "dialogue_manager/general/%s" % setting
+ if not ProjectSettings.has_setting(setting_name):
+ set_setting(setting, DEFAULT_SETTINGS[setting])
+ ProjectSettings.set_initial_value(setting_name, DEFAULT_SETTINGS[setting])
+ if setting.ends_with("_path"):
+ ProjectSettings.add_property_info({
+ "name": setting_name,
+ "type": TYPE_STRING,
+ "hint": PROPERTY_HINT_FILE,
+ })
+
+ # Some settings shouldn't be edited directly in the Project Settings window
+ ProjectSettings.set_as_internal("dialogue_manager/general/states", true)
+ ProjectSettings.set_as_internal("dialogue_manager/general/custom_test_scene_path", true)
+ ProjectSettings.set_as_internal("dialogue_manager/general/uses_dotnet", true)
+
+ ProjectSettings.save()
+
+
+static func set_setting(key: String, value) -> void:
+ ProjectSettings.set_setting("dialogue_manager/general/%s" % key, value)
+ ProjectSettings.set_initial_value("dialogue_manager/general/%s" % key, DEFAULT_SETTINGS[key])
+ ProjectSettings.save()
+
+
+static func get_setting(key: String, default):
+ if ProjectSettings.has_setting("dialogue_manager/general/%s" % key):
+ return ProjectSettings.get_setting("dialogue_manager/general/%s" % key)
+ else:
+ return default
+
+
+static func get_settings(only_keys: PackedStringArray = []) -> Dictionary:
+ var settings: Dictionary = {}
+ for key in DEFAULT_SETTINGS.keys():
+ if only_keys.is_empty() or key in only_keys:
+ settings[key] = get_setting(key, DEFAULT_SETTINGS[key])
+ return settings
+
+
+### User config
+
+
+static func get_user_config() -> Dictionary:
+ var user_config: Dictionary = {
+ check_for_updates = true,
+ just_refreshed = null,
+ recent_files = [],
+ reopen_files = [],
+ most_recent_reopen_file = "",
+ carets = {},
+ run_title = "",
+ run_resource_path = "",
+ is_running_test_scene = false,
+ has_dotnet_solution = false,
+ open_in_external_editor = false
+ }
+
+ if FileAccess.file_exists(DialogueConstants.USER_CONFIG_PATH):
+ var file: FileAccess = FileAccess.open(DialogueConstants.USER_CONFIG_PATH, FileAccess.READ)
+ user_config.merge(JSON.parse_string(file.get_as_text()), true)
+
+ return user_config
+
+
+static func save_user_config(user_config: Dictionary) -> void:
+ var file: FileAccess = FileAccess.open(DialogueConstants.USER_CONFIG_PATH, FileAccess.WRITE)
+ file.store_string(JSON.stringify(user_config))
+
+
+static func set_user_value(key: String, value) -> void:
+ var user_config: Dictionary = get_user_config()
+ user_config[key] = value
+ save_user_config(user_config)
+
+
+static func get_user_value(key: String, default = null):
+ return get_user_config().get(key, default)
+
+
+static func add_recent_file(path: String) -> void:
+ var recent_files: Array = get_user_value("recent_files", [])
+ if path in recent_files:
+ recent_files.erase(path)
+ recent_files.insert(0, path)
+ set_user_value("recent_files", recent_files)
+
+
+static func move_recent_file(from_path: String, to_path: String) -> void:
+ var recent_files: Array = get_user_value("recent_files", [])
+ for i in range(0, recent_files.size()):
+ if recent_files[i] == from_path:
+ recent_files[i] = to_path
+ set_user_value("recent_files", recent_files)
+
+
+static func remove_recent_file(path: String) -> void:
+ var recent_files: Array = get_user_value("recent_files", [])
+ if path in recent_files:
+ recent_files.erase(path)
+ set_user_value("recent_files", recent_files)
+
+
+static func get_recent_files() -> Array:
+ return get_user_value("recent_files", [])
+
+
+static func clear_recent_files() -> void:
+ set_user_value("recent_files", [])
+ set_user_value("carets", {})
+
+
+static func set_caret(path: String, cursor: Vector2) -> void:
+ var carets: Dictionary = get_user_value("carets", {})
+ carets[path] = {
+ x = cursor.x,
+ y = cursor.y
+ }
+ set_user_value("carets", carets)
+
+
+static func get_caret(path: String) -> Vector2:
+ var carets = get_user_value("carets", {})
+ if carets.has(path):
+ var caret = carets.get(path)
+ return Vector2(caret.x, caret.y)
+ else:
+ return Vector2.ZERO
+
+
+static func check_for_dotnet_solution() -> bool:
+ if Engine.is_editor_hint():
+ var has_dotnet_solution: bool = false
+ if ProjectSettings.has_setting("dotnet/project/solution_directory"):
+ var directory: String = ProjectSettings.get("dotnet/project/solution_directory")
+ var file_name: String = ProjectSettings.get("dotnet/project/assembly_name")
+ has_dotnet_solution = FileAccess.file_exists("res://%s/%s.sln" % [directory, file_name])
+ set_setting("uses_dotnet", has_dotnet_solution)
+ return has_dotnet_solution
+
+ return get_setting("uses_dotnet", false)
diff --git a/addons/dialogue_manager/test_scene.gd b/addons/dialogue_manager/test_scene.gd
new file mode 100644
index 00000000..a8089114
--- /dev/null
+++ b/addons/dialogue_manager/test_scene.gd
@@ -0,0 +1,32 @@
+class_name BaseDialogueTestScene extends Node2D
+
+
+const DialogueSettings = preload("./settings.gd")
+const DialogueResource = preload("./dialogue_resource.gd")
+
+
+@onready var title: String = DialogueSettings.get_user_value("run_title")
+@onready var resource: DialogueResource = load(DialogueSettings.get_user_value("run_resource_path"))
+
+
+func _ready():
+ var screen_index: int = DisplayServer.get_primary_screen()
+ DisplayServer.window_set_position(Vector2(DisplayServer.screen_get_position(screen_index)) + (DisplayServer.screen_get_size(screen_index) - DisplayServer.window_get_size()) * 0.5)
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
+
+ # Normally you can just call DialogueManager directly but doing so before the plugin has been
+ # enabled in settings will throw a compiler error here so I'm using `get_singleton` instead.
+ var dialogue_manager = Engine.get_singleton("DialogueManager")
+ dialogue_manager.dialogue_ended.connect(_on_dialogue_ended)
+ dialogue_manager.show_dialogue_balloon(resource, title)
+
+
+func _enter_tree() -> void:
+ DialogueSettings.set_user_value("is_running_test_scene", false)
+
+
+### Signals
+
+
+func _on_dialogue_ended(_resource: DialogueResource):
+ get_tree().quit()
diff --git a/addons/dialogue_manager/test_scene.tscn b/addons/dialogue_manager/test_scene.tscn
new file mode 100644
index 00000000..f2bbd8d3
--- /dev/null
+++ b/addons/dialogue_manager/test_scene.tscn
@@ -0,0 +1,7 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/test_scene.gd" id="1_yupoh"]
+
+
+[node name="TestScene" type="Node2D"]
+script = ExtResource("1_yupoh")
diff --git a/addons/dialogue_manager/utilities/builtins.gd b/addons/dialogue_manager/utilities/builtins.gd
new file mode 100644
index 00000000..56640e75
--- /dev/null
+++ b/addons/dialogue_manager/utilities/builtins.gd
@@ -0,0 +1,471 @@
+extends Object
+
+
+const DialogueConstants = preload("../constants.gd")
+
+const SUPPORTED_BUILTIN_TYPES = [
+ TYPE_STRING,
+ TYPE_STRING_NAME,
+ TYPE_ARRAY,
+ TYPE_VECTOR2,
+ TYPE_VECTOR3,
+ TYPE_VECTOR4,
+ TYPE_DICTIONARY,
+ TYPE_QUATERNION,
+ TYPE_COLOR,
+ TYPE_SIGNAL,
+ TYPE_CALLABLE
+]
+
+
+static var resolve_method_error: Error = OK
+
+
+static func is_supported(thing) -> bool:
+ return typeof(thing) in SUPPORTED_BUILTIN_TYPES
+
+
+static func resolve_property(builtin, property: String):
+ match typeof(builtin):
+ TYPE_ARRAY, TYPE_DICTIONARY, TYPE_QUATERNION, TYPE_STRING, TYPE_STRING_NAME:
+ return builtin[property]
+
+ # Some types have constants that we need to manually resolve
+
+ TYPE_VECTOR2:
+ return resolve_vector2_property(builtin, property)
+ TYPE_VECTOR3:
+ return resolve_vector3_property(builtin, property)
+ TYPE_VECTOR4:
+ return resolve_vector4_property(builtin, property)
+ TYPE_COLOR:
+ return resolve_color_property(builtin, property)
+
+
+static func resolve_method(thing, method_name: String, args: Array):
+ resolve_method_error = OK
+
+ # Resolve static methods manually
+ match typeof(thing):
+ TYPE_VECTOR2:
+ match method_name:
+ "from_angle":
+ return Vector2.from_angle(args[0])
+
+ TYPE_COLOR:
+ match method_name:
+ "from_hsv":
+ return Color.from_hsv(args[0], args[1], args[2]) if args.size() == 3 else Color.from_hsv(args[0], args[1], args[2], args[3])
+ "from_ok_hsl":
+ return Color.from_ok_hsl(args[0], args[1], args[2]) if args.size() == 3 else Color.from_ok_hsl(args[0], args[1], args[2], args[3])
+ "from_rgbe9995":
+ return Color.from_rgbe9995(args[0])
+ "from_string":
+ return Color.from_string(args[0], args[1])
+
+ TYPE_QUATERNION:
+ match method_name:
+ "from_euler":
+ return Quaternion.from_euler(args[0])
+
+ # Anything else can be evaulatated automatically
+ var references: Array = ["thing"]
+ for i in range(0, args.size()):
+ references.append("arg%d" % i)
+ var expression = Expression.new()
+ if expression.parse("thing.%s(%s)" % [method_name, ",".join(references.slice(1))], references) != OK:
+ assert(false, expression.get_error_text())
+ var result = expression.execute([thing] + args, null, false)
+ if expression.has_execute_failed():
+ resolve_method_error = ERR_CANT_RESOLVE
+ return null
+
+ return result
+
+
+static func has_resolve_method_failed() -> bool:
+ return resolve_method_error != OK
+
+
+static func resolve_color_property(color: Color, property: String):
+ match property:
+ "ALICE_BLUE":
+ return Color.ALICE_BLUE
+ "ANTIQUE_WHITE":
+ return Color.ANTIQUE_WHITE
+ "AQUA":
+ return Color.AQUA
+ "AQUAMARINE":
+ return Color.AQUAMARINE
+ "AZURE":
+ return Color.AZURE
+ "BEIGE":
+ return Color.BEIGE
+ "BISQUE":
+ return Color.BISQUE
+ "BLACK":
+ return Color.BLACK
+ "BLANCHED_ALMOND":
+ return Color.BLANCHED_ALMOND
+ "BLUE":
+ return Color.BLUE
+ "BLUE_VIOLET":
+ return Color.BLUE_VIOLET
+ "BROWN":
+ return Color.BROWN
+ "BURLYWOOD":
+ return Color.BURLYWOOD
+ "CADET_BLUE":
+ return Color.CADET_BLUE
+ "CHARTREUSE":
+ return Color.CHARTREUSE
+ "CHOCOLATE":
+ return Color.CHOCOLATE
+ "CORAL":
+ return Color.CORAL
+ "CORNFLOWER_BLUE":
+ return Color.CORNFLOWER_BLUE
+ "CORNSILK":
+ return Color.CORNSILK
+ "CRIMSON":
+ return Color.CRIMSON
+ "CYAN":
+ return Color.CYAN
+ "DARK_BLUE":
+ return Color.DARK_BLUE
+ "DARK_CYAN":
+ return Color.DARK_CYAN
+ "DARK_GOLDENROD":
+ return Color.DARK_GOLDENROD
+ "DARK_GRAY":
+ return Color.DARK_GRAY
+ "DARK_GREEN":
+ return Color.DARK_GREEN
+ "DARK_KHAKI":
+ return Color.DARK_KHAKI
+ "DARK_MAGENTA":
+ return Color.DARK_MAGENTA
+ "DARK_OLIVE_GREEN":
+ return Color.DARK_OLIVE_GREEN
+ "DARK_ORANGE":
+ return Color.DARK_ORANGE
+ "DARK_ORCHID":
+ return Color.DARK_ORCHID
+ "DARK_RED":
+ return Color.DARK_RED
+ "DARK_SALMON":
+ return Color.DARK_SALMON
+ "DARK_SEA_GREEN":
+ return Color.DARK_SEA_GREEN
+ "DARK_SLATE_BLUE":
+ return Color.DARK_SLATE_BLUE
+ "DARK_SLATE_GRAY":
+ return Color.DARK_SLATE_GRAY
+ "DARK_TURQUOISE":
+ return Color.DARK_TURQUOISE
+ "DARK_VIOLET":
+ return Color.DARK_VIOLET
+ "DEEP_PINK":
+ return Color.DEEP_PINK
+ "DEEP_SKY_BLUE":
+ return Color.DEEP_SKY_BLUE
+ "DIM_GRAY":
+ return Color.DIM_GRAY
+ "DODGER_BLUE":
+ return Color.DODGER_BLUE
+ "FIREBRICK":
+ return Color.FIREBRICK
+ "FLORAL_WHITE":
+ return Color.FLORAL_WHITE
+ "FOREST_GREEN":
+ return Color.FOREST_GREEN
+ "FUCHSIA":
+ return Color.FUCHSIA
+ "GAINSBORO":
+ return Color.GAINSBORO
+ "GHOST_WHITE":
+ return Color.GHOST_WHITE
+ "GOLD":
+ return Color.GOLD
+ "GOLDENROD":
+ return Color.GOLDENROD
+ "GRAY":
+ return Color.GRAY
+ "GREEN":
+ return Color.GREEN
+ "GREEN_YELLOW":
+ return Color.GREEN_YELLOW
+ "HONEYDEW":
+ return Color.HONEYDEW
+ "HOT_PINK":
+ return Color.HOT_PINK
+ "INDIAN_RED":
+ return Color.INDIAN_RED
+ "INDIGO":
+ return Color.INDIGO
+ "IVORY":
+ return Color.IVORY
+ "KHAKI":
+ return Color.KHAKI
+ "LAVENDER":
+ return Color.LAVENDER
+ "LAVENDER_BLUSH":
+ return Color.LAVENDER_BLUSH
+ "LAWN_GREEN":
+ return Color.LAWN_GREEN
+ "LEMON_CHIFFON":
+ return Color.LEMON_CHIFFON
+ "LIGHT_BLUE":
+ return Color.LIGHT_BLUE
+ "LIGHT_CORAL":
+ return Color.LIGHT_CORAL
+ "LIGHT_CYAN":
+ return Color.LIGHT_CYAN
+ "LIGHT_GOLDENROD":
+ return Color.LIGHT_GOLDENROD
+ "LIGHT_GRAY":
+ return Color.LIGHT_GRAY
+ "LIGHT_GREEN":
+ return Color.LIGHT_GREEN
+ "LIGHT_PINK":
+ return Color.LIGHT_PINK
+ "LIGHT_SALMON":
+ return Color.LIGHT_SALMON
+ "LIGHT_SEA_GREEN":
+ return Color.LIGHT_SEA_GREEN
+ "LIGHT_SKY_BLUE":
+ return Color.LIGHT_SKY_BLUE
+ "LIGHT_SLATE_GRAY":
+ return Color.LIGHT_SLATE_GRAY
+ "LIGHT_STEEL_BLUE":
+ return Color.LIGHT_STEEL_BLUE
+ "LIGHT_YELLOW":
+ return Color.LIGHT_YELLOW
+ "LIME":
+ return Color.LIME
+ "LIME_GREEN":
+ return Color.LIME_GREEN
+ "LINEN":
+ return Color.LINEN
+ "MAGENTA":
+ return Color.MAGENTA
+ "MAROON":
+ return Color.MAROON
+ "MEDIUM_AQUAMARINE":
+ return Color.MEDIUM_AQUAMARINE
+ "MEDIUM_BLUE":
+ return Color.MEDIUM_BLUE
+ "MEDIUM_ORCHID":
+ return Color.MEDIUM_ORCHID
+ "MEDIUM_PURPLE":
+ return Color.MEDIUM_PURPLE
+ "MEDIUM_SEA_GREEN":
+ return Color.MEDIUM_SEA_GREEN
+ "MEDIUM_SLATE_BLUE":
+ return Color.MEDIUM_SLATE_BLUE
+ "MEDIUM_SPRING_GREEN":
+ return Color.MEDIUM_SPRING_GREEN
+ "MEDIUM_TURQUOISE":
+ return Color.MEDIUM_TURQUOISE
+ "MEDIUM_VIOLET_RED":
+ return Color.MEDIUM_VIOLET_RED
+ "MIDNIGHT_BLUE":
+ return Color.MIDNIGHT_BLUE
+ "MINT_CREAM":
+ return Color.MINT_CREAM
+ "MISTY_ROSE":
+ return Color.MISTY_ROSE
+ "MOCCASIN":
+ return Color.MOCCASIN
+ "NAVAJO_WHITE":
+ return Color.NAVAJO_WHITE
+ "NAVY_BLUE":
+ return Color.NAVY_BLUE
+ "OLD_LACE":
+ return Color.OLD_LACE
+ "OLIVE":
+ return Color.OLIVE
+ "OLIVE_DRAB":
+ return Color.OLIVE_DRAB
+ "ORANGE":
+ return Color.ORANGE
+ "ORANGE_RED":
+ return Color.ORANGE_RED
+ "ORCHID":
+ return Color.ORCHID
+ "PALE_GOLDENROD":
+ return Color.PALE_GOLDENROD
+ "PALE_GREEN":
+ return Color.PALE_GREEN
+ "PALE_TURQUOISE":
+ return Color.PALE_TURQUOISE
+ "PALE_VIOLET_RED":
+ return Color.PALE_VIOLET_RED
+ "PAPAYA_WHIP":
+ return Color.PAPAYA_WHIP
+ "PEACH_PUFF":
+ return Color.PEACH_PUFF
+ "PERU":
+ return Color.PERU
+ "PINK":
+ return Color.PINK
+ "PLUM":
+ return Color.PLUM
+ "POWDER_BLUE":
+ return Color.POWDER_BLUE
+ "PURPLE":
+ return Color.PURPLE
+ "REBECCA_PURPLE":
+ return Color.REBECCA_PURPLE
+ "RED":
+ return Color.RED
+ "ROSY_BROWN":
+ return Color.ROSY_BROWN
+ "ROYAL_BLUE":
+ return Color.ROYAL_BLUE
+ "SADDLE_BROWN":
+ return Color.SADDLE_BROWN
+ "SALMON":
+ return Color.SALMON
+ "SANDY_BROWN":
+ return Color.SANDY_BROWN
+ "SEA_GREEN":
+ return Color.SEA_GREEN
+ "SEASHELL":
+ return Color.SEASHELL
+ "SIENNA":
+ return Color.SIENNA
+ "SILVER":
+ return Color.SILVER
+ "SKY_BLUE":
+ return Color.SKY_BLUE
+ "SLATE_BLUE":
+ return Color.SLATE_BLUE
+ "SLATE_GRAY":
+ return Color.SLATE_GRAY
+ "SNOW":
+ return Color.SNOW
+ "SPRING_GREEN":
+ return Color.SPRING_GREEN
+ "STEEL_BLUE":
+ return Color.STEEL_BLUE
+ "TAN":
+ return Color.TAN
+ "TEAL":
+ return Color.TEAL
+ "THISTLE":
+ return Color.THISTLE
+ "TOMATO":
+ return Color.TOMATO
+ "TRANSPARENT":
+ return Color.TRANSPARENT
+ "TURQUOISE":
+ return Color.TURQUOISE
+ "VIOLET":
+ return Color.VIOLET
+ "WEB_GRAY":
+ return Color.WEB_GRAY
+ "WEB_GREEN":
+ return Color.WEB_GREEN
+ "WEB_MAROON":
+ return Color.WEB_MAROON
+ "WEB_PURPLE":
+ return Color.WEB_PURPLE
+ "WHEAT":
+ return Color.WHEAT
+ "WHITE":
+ return Color.WHITE
+ "WHITE_SMOKE":
+ return Color.WHITE_SMOKE
+ "YELLOW":
+ return Color.YELLOW
+ "YELLOW_GREEN":
+ return Color.YELLOW_GREEN
+
+ return color[property]
+
+
+static func resolve_vector2_property(vector: Vector2, property: String):
+ match property:
+ "AXIS_X":
+ return Vector2.AXIS_X
+ "AXIS_Y":
+ return Vector2.AXIS_Y
+ "ZERO":
+ return Vector2.ZERO
+ "ONE":
+ return Vector2.ONE
+ "INF":
+ return Vector2.INF
+ "LEFT":
+ return Vector2.LEFT
+ "RIGHT":
+ return Vector2.RIGHT
+ "UP":
+ return Vector2.UP
+ "DOWN":
+ return Vector2.DOWN
+
+ return vector[property]
+
+
+static func resolve_vector3_property(vector: Vector3, property: String):
+ match property:
+ "AXIS_X":
+ return Vector3.AXIS_X
+ "AXIS_Y":
+ return Vector3.AXIS_Y
+ "AXIS_Z":
+ return Vector3.AXIS_Z
+ "ZERO":
+ return Vector3.ZERO
+ "ONE":
+ return Vector3.ONE
+ "INF":
+ return Vector3.INF
+ "LEFT":
+ return Vector3.LEFT
+ "RIGHT":
+ return Vector3.RIGHT
+ "UP":
+ return Vector3.UP
+ "DOWN":
+ return Vector3.DOWN
+ "FORWARD":
+ return Vector3.FORWARD
+ "BACK":
+ return Vector3.BACK
+ "MODEL_LEFT":
+ return Vector3(1, 0, 0)
+ "MODEL_RIGHT":
+ return Vector3(-1, 0, 0)
+ "MODEL_TOP":
+ return Vector3(0, 1, 0)
+ "MODEL_BOTTOM":
+ return Vector3(0, -1, 0)
+ "MODEL_FRONT":
+ return Vector3(0, 0, 1)
+ "MODEL_REAR":
+ return Vector3(0, 0, -1)
+
+ return vector[property]
+
+
+static func resolve_vector4_property(vector: Vector4, property: String):
+ match property:
+ "AXIS_X":
+ return Vector4.AXIS_X
+ "AXIS_Y":
+ return Vector4.AXIS_Y
+ "AXIS_Z":
+ return Vector4.AXIS_Z
+ "AXIS_W":
+ return Vector4.AXIS_W
+ "ZERO":
+ return Vector4.ZERO
+ "ONE":
+ return Vector4.ONE
+ "INF":
+ return Vector4.INF
+
+ return vector[property]
diff --git a/addons/dialogue_manager/views/main_view.gd b/addons/dialogue_manager/views/main_view.gd
new file mode 100644
index 00000000..9ef4f44f
--- /dev/null
+++ b/addons/dialogue_manager/views/main_view.gd
@@ -0,0 +1,1134 @@
+@tool
+extends Control
+
+
+const DialogueConstants = preload("../constants.gd")
+const DialogueSettings = preload("../settings.gd")
+const DialogueResource = preload("../dialogue_resource.gd")
+const DialogueManagerParser = preload("../components/parser.gd")
+
+const OPEN_OPEN = 100
+const OPEN_CLEAR = 101
+
+const TRANSLATIONS_GENERATE_LINE_IDS = 100
+const TRANSLATIONS_SAVE_CHARACTERS_TO_CSV = 201
+const TRANSLATIONS_SAVE_TO_CSV = 202
+const TRANSLATIONS_IMPORT_FROM_CSV = 203
+
+const ITEM_SAVE = 100
+const ITEM_SAVE_AS = 101
+const ITEM_CLOSE = 102
+const ITEM_CLOSE_ALL = 103
+const ITEM_CLOSE_OTHERS = 104
+const ITEM_COPY_PATH = 200
+const ITEM_SHOW_IN_FILESYSTEM = 201
+
+enum TranslationSource {
+ CharacterNames,
+ Lines
+}
+
+
+signal confirmation_closed()
+
+
+@onready var parse_timer := $ParseTimer
+
+# Dialogs
+@onready var new_dialog: FileDialog = $NewDialog
+@onready var save_dialog: FileDialog = $SaveDialog
+@onready var open_dialog: FileDialog = $OpenDialog
+@onready var export_dialog: FileDialog = $ExportDialog
+@onready var import_dialog: FileDialog = $ImportDialog
+@onready var errors_dialog: AcceptDialog = $ErrorsDialog
+@onready var settings_dialog: AcceptDialog = $SettingsDialog
+@onready var settings_view := $SettingsDialog/SettingsView
+@onready var build_error_dialog: AcceptDialog = $BuildErrorDialog
+@onready var close_confirmation_dialog: ConfirmationDialog = $CloseConfirmationDialog
+@onready var updated_dialog: AcceptDialog = $UpdatedDialog
+@onready var find_in_files_dialog: AcceptDialog = $FindInFilesDialog
+@onready var find_in_files: Control = $FindInFilesDialog/FindInFiles
+
+# Toolbar
+@onready var new_button: Button = %NewButton
+@onready var open_button: MenuButton = %OpenButton
+@onready var save_all_button: Button = %SaveAllButton
+@onready var find_in_files_button: Button = %FindInFilesButton
+@onready var test_button: Button = %TestButton
+@onready var search_button: Button = %SearchButton
+@onready var insert_button: MenuButton = %InsertButton
+@onready var translations_button: MenuButton = %TranslationsButton
+@onready var settings_button: Button = %SettingsButton
+@onready var support_button: Button = %SupportButton
+@onready var docs_button: Button = %DocsButton
+@onready var version_label: Label = %VersionLabel
+@onready var update_button: Button = %UpdateButton
+
+@onready var search_and_replace := %SearchAndReplace
+
+# Code editor
+@onready var content: HSplitContainer = %Content
+@onready var files_list := %FilesList
+@onready var files_popup_menu: PopupMenu = %FilesPopupMenu
+@onready var title_list := %TitleList
+@onready var code_edit := %CodeEdit
+@onready var errors_panel := %ErrorsPanel
+
+# The currently open file
+var current_file_path: String = "":
+ set(next_current_file_path):
+ current_file_path = next_current_file_path
+ files_list.current_file_path = current_file_path
+ if current_file_path == "":
+ save_all_button.disabled = true
+ test_button.disabled = true
+ search_button.disabled = true
+ insert_button.disabled = true
+ translations_button.disabled = true
+ content.dragger_visibility = SplitContainer.DRAGGER_HIDDEN
+ files_list.hide()
+ title_list.hide()
+ code_edit.hide()
+ errors_panel.hide()
+ else:
+ test_button.disabled = false
+ search_button.disabled = false
+ insert_button.disabled = false
+ translations_button.disabled = false
+ content.dragger_visibility = SplitContainer.DRAGGER_VISIBLE
+ files_list.show()
+ title_list.show()
+ code_edit.show()
+
+ code_edit.text = open_buffers[current_file_path].text
+ code_edit.errors = []
+ code_edit.clear_undo_history()
+ code_edit.set_cursor(DialogueSettings.get_caret(current_file_path))
+ code_edit.grab_focus()
+
+ _on_code_edit_text_changed()
+
+ errors_panel.errors = []
+ code_edit.errors = []
+ get:
+ return current_file_path
+
+# A reference to the currently open files and their last saved text
+var open_buffers: Dictionary = {}
+
+# Which thing are we exporting translations for?
+var translation_source: TranslationSource = TranslationSource.Lines
+
+var plugin: EditorPlugin
+
+
+func _ready() -> void:
+ plugin = Engine.get_meta("DialogueManagerPlugin")
+
+ apply_theme()
+
+ # Start with nothing open
+ self.current_file_path = ""
+
+ # Set up the update checker
+ version_label.text = "v%s" % plugin.get_version()
+ update_button.on_before_refresh = func on_before_refresh():
+ # Save everything
+ DialogueSettings.set_user_value("just_refreshed", {
+ current_file_path = current_file_path,
+ open_buffers = open_buffers
+ })
+ return true
+
+ # Did we just load from an addon version refresh?
+ var just_refreshed = DialogueSettings.get_user_value("just_refreshed", null)
+ if just_refreshed != null:
+ DialogueSettings.set_user_value("just_refreshed", null)
+ call_deferred("load_from_version_refresh", just_refreshed)
+
+ # Hook up the search toolbar
+ search_and_replace.code_edit = code_edit
+
+ # Connect menu buttons
+ insert_button.get_popup().id_pressed.connect(_on_insert_button_menu_id_pressed)
+ translations_button.get_popup().id_pressed.connect(_on_translations_button_menu_id_pressed)
+
+ code_edit.main_view = self
+ code_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY if DialogueSettings.get_setting("wrap_lines", false) else TextEdit.LINE_WRAPPING_NONE
+ var editor_settings: EditorSettings = plugin.get_editor_interface().get_editor_settings()
+ editor_settings.settings_changed.connect(_on_editor_settings_changed)
+ _on_editor_settings_changed()
+
+ # Reopen any files that were open when Godot was closed
+ if editor_settings.get_setting("text_editor/behavior/files/restore_scripts_on_load"):
+ var reopen_files: Array = DialogueSettings.get_user_value("reopen_files", [])
+ for reopen_file in reopen_files:
+ open_file(reopen_file)
+
+ self.current_file_path = DialogueSettings.get_user_value("most_recent_reopen_file", "")
+
+ save_all_button.disabled = true
+
+ close_confirmation_dialog.ok_button_text = DialogueConstants.translate(&"confirm_close.save")
+ close_confirmation_dialog.add_button(DialogueConstants.translate(&"confirm_close.discard"), true, "discard")
+
+ errors_dialog.dialog_text = DialogueConstants.translate(&"errors_in_script")
+
+ # Update the buffer if a file was modified externally (retains undo step)
+ Engine.get_meta("DialogueCache").file_content_changed.connect(_on_cache_file_content_changed)
+
+ plugin.get_editor_interface().get_file_system_dock().files_moved.connect(_on_files_moved)
+
+
+func _exit_tree() -> void:
+ DialogueSettings.set_user_value("reopen_files", open_buffers.keys())
+ DialogueSettings.set_user_value("most_recent_reopen_file", self.current_file_path)
+
+
+func _unhandled_input(event: InputEvent) -> void:
+ if not visible: return
+
+ if event is InputEventKey and event.is_pressed():
+ var shortcut: String = plugin.get_editor_shortcut(event)
+ match shortcut:
+ "close_file":
+ get_viewport().set_input_as_handled()
+ close_file(current_file_path)
+ "save":
+ get_viewport().set_input_as_handled()
+ save_file(current_file_path)
+ "find_in_files":
+ get_viewport().set_input_as_handled()
+ _on_find_in_files_button_pressed()
+ "run_test_scene":
+ get_viewport().set_input_as_handled()
+ _on_test_button_pressed()
+
+
+func apply_changes() -> void:
+ save_files()
+
+
+# Load back to the previous buffer regardless of if it was actually saved
+func load_from_version_refresh(just_refreshed: Dictionary) -> void:
+ if just_refreshed.has("current_file_content"):
+ # We just loaded from a version before multiple buffers
+ var file: FileAccess = FileAccess.open(just_refreshed.current_file_path, FileAccess.READ)
+ var file_text: String = file.get_as_text()
+ open_buffers[just_refreshed.current_file_path] = {
+ pristine_text = file_text,
+ text = just_refreshed.current_file_content
+ }
+ else:
+ open_buffers = just_refreshed.open_buffers
+
+ var interface: EditorInterface = plugin.get_editor_interface()
+ if just_refreshed.current_file_path != "":
+ interface.edit_resource(load(just_refreshed.current_file_path))
+ else:
+ interface.set_main_screen_editor("Dialogue")
+
+ updated_dialog.dialog_text = DialogueConstants.translate(&"update.success").format({ version = update_button.get_version() })
+ updated_dialog.popup_centered()
+
+
+func new_file(path: String, content: String = "") -> void:
+ if open_buffers.has(path):
+ remove_file_from_open_buffers(path)
+
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
+ if content == "":
+ if DialogueSettings.get_setting("new_with_template", true):
+ file.store_string("\n".join([
+ "~ this_is_a_node_title",
+ "",
+ "Nathan: [[Hi|Hello|Howdy]], this is some dialogue.",
+ "Nathan: Here are some choices.",
+ "- First one",
+ "\tNathan: You picked the first one.",
+ "- Second one",
+ "\tNathan: You picked the second one.",
+ "- Start again => this_is_a_node_title",
+ "- End the conversation => END",
+ "Nathan: For more information see the online documentation.",
+ "",
+ "=> END"
+ ]))
+ else:
+ file.store_string(content)
+
+ plugin.get_editor_interface().get_resource_filesystem().scan()
+
+
+# Open a dialogue resource for editing
+func open_resource(resource: DialogueResource) -> void:
+ open_file(resource.resource_path)
+
+
+func open_file(path: String) -> void:
+ if not open_buffers.has(path):
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ var text = file.get_as_text()
+
+ open_buffers[path] = {
+ cursor = Vector2.ZERO,
+ text = text,
+ pristine_text = text
+ }
+
+ DialogueSettings.add_recent_file(path)
+ build_open_menu()
+
+ files_list.files = open_buffers.keys()
+ files_list.select_file(path)
+
+ self.current_file_path = path
+
+
+func show_file_in_filesystem(path: String) -> void:
+ var file_system_dock: FileSystemDock = plugin \
+ .get_editor_interface() \
+ .get_file_system_dock()
+
+ file_system_dock.navigate_to_path(path)
+
+
+# Save any open files
+func save_files() -> void:
+ save_all_button.disabled = true
+
+ var saved_files: PackedStringArray = []
+ for path in open_buffers:
+ if open_buffers[path].text != open_buffers[path].pristine_text:
+ saved_files.append(path)
+ save_file(path, false)
+
+ if saved_files.size() > 0:
+ Engine.get_meta("DialogueCache").reimport_files(saved_files)
+
+
+# Save a file
+func save_file(path: String, rescan_file_system: bool = true) -> void:
+ var buffer = open_buffers[path]
+
+ files_list.mark_file_as_unsaved(path, false)
+ save_all_button.disabled = files_list.unsaved_files.size() == 0
+
+ # Don't bother saving if there is nothing to save
+ if buffer.text == buffer.pristine_text:
+ return
+
+ buffer.pristine_text = buffer.text
+
+ # Save the current text
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
+ file.store_string(buffer.text)
+ file.close()
+
+ if rescan_file_system:
+ plugin \
+ .get_editor_interface() \
+ .get_resource_filesystem()\
+ .scan()
+
+
+func close_file(path: String) -> void:
+ if not path in open_buffers.keys(): return
+
+ var buffer = open_buffers[path]
+
+ if buffer.text == buffer.pristine_text:
+ remove_file_from_open_buffers(path)
+ await get_tree().process_frame
+ else:
+ close_confirmation_dialog.dialog_text = DialogueConstants.translate(&"confirm_close").format({ path = path.get_file() })
+ close_confirmation_dialog.popup_centered()
+ await confirmation_closed
+
+
+func remove_file_from_open_buffers(path: String) -> void:
+ if not path in open_buffers.keys(): return
+
+ var current_index = open_buffers.keys().find(current_file_path)
+
+ open_buffers.erase(path)
+ if open_buffers.size() == 0:
+ self.current_file_path = ""
+ else:
+ current_index = clamp(current_index, 0, open_buffers.size() - 1)
+ self.current_file_path = open_buffers.keys()[current_index]
+
+ files_list.files = open_buffers.keys()
+
+
+# Apply theme colors and icons to the UI
+func apply_theme() -> void:
+ if is_instance_valid(plugin) and is_instance_valid(code_edit):
+ var scale: float = plugin.get_editor_interface().get_editor_scale()
+ var editor_settings = plugin.get_editor_interface().get_editor_settings()
+ code_edit.theme_overrides = {
+ scale = scale,
+
+ background_color = editor_settings.get_setting("text_editor/theme/highlighting/background_color"),
+ current_line_color = editor_settings.get_setting("text_editor/theme/highlighting/current_line_color"),
+ error_line_color = editor_settings.get_setting("text_editor/theme/highlighting/mark_color"),
+
+ critical_color = editor_settings.get_setting("text_editor/theme/highlighting/comment_markers/critical_color"),
+ notice_color = editor_settings.get_setting("text_editor/theme/highlighting/comment_markers/notice_color"),
+
+ titles_color = editor_settings.get_setting("text_editor/theme/highlighting/control_flow_keyword_color"),
+ text_color = editor_settings.get_setting("text_editor/theme/highlighting/text_color"),
+ conditions_color = editor_settings.get_setting("text_editor/theme/highlighting/keyword_color"),
+ mutations_color = editor_settings.get_setting("text_editor/theme/highlighting/function_color"),
+ members_color = editor_settings.get_setting("text_editor/theme/highlighting/member_variable_color"),
+ strings_color = editor_settings.get_setting("text_editor/theme/highlighting/string_color"),
+ numbers_color = editor_settings.get_setting("text_editor/theme/highlighting/number_color"),
+ symbols_color = editor_settings.get_setting("text_editor/theme/highlighting/symbol_color"),
+ comments_color = editor_settings.get_setting("text_editor/theme/highlighting/comment_color"),
+ jumps_color = Color(editor_settings.get_setting("text_editor/theme/highlighting/control_flow_keyword_color"), 0.7),
+
+ font_size = editor_settings.get_setting("interface/editor/code_font_size")
+ }
+
+ new_button.icon = get_theme_icon("New", "EditorIcons")
+ new_button.tooltip_text = DialogueConstants.translate(&"start_a_new_file")
+
+ open_button.icon = get_theme_icon("Load", "EditorIcons")
+ open_button.tooltip_text = DialogueConstants.translate(&"open_a_file")
+
+ save_all_button.icon = get_theme_icon("Save", "EditorIcons")
+ save_all_button.tooltip_text = DialogueConstants.translate(&"start_all_files")
+
+ find_in_files_button.icon = get_theme_icon("ViewportZoom", "EditorIcons")
+ find_in_files_button.tooltip_text = DialogueConstants.translate(&"find_in_files")
+
+ test_button.icon = get_theme_icon("PlayScene", "EditorIcons")
+ test_button.tooltip_text = DialogueConstants.translate(&"test_dialogue")
+
+ search_button.icon = get_theme_icon("Search", "EditorIcons")
+ search_button.tooltip_text = DialogueConstants.translate(&"search_for_text")
+
+ insert_button.icon = get_theme_icon("RichTextEffect", "EditorIcons")
+ insert_button.text = DialogueConstants.translate(&"insert")
+
+ translations_button.icon = get_theme_icon("Translation", "EditorIcons")
+ translations_button.text = DialogueConstants.translate(&"translations")
+
+ settings_button.icon = get_theme_icon("Tools", "EditorIcons")
+ settings_button.tooltip_text = DialogueConstants.translate(&"settings")
+
+ support_button.icon = get_theme_icon("Heart", "EditorIcons")
+ support_button.text = DialogueConstants.translate(&"sponsor")
+ support_button.tooltip_text = DialogueConstants.translate(&"show_support")
+
+ docs_button.icon = get_theme_icon("Help", "EditorIcons")
+ docs_button.text = DialogueConstants.translate(&"docs")
+
+ update_button.apply_theme()
+
+ # Set up the effect menu
+ var popup: PopupMenu = insert_button.get_popup()
+ popup.clear()
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.wave_bbcode"), 0)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.shake_bbcode"), 1)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("Time", "EditorIcons"), DialogueConstants.translate(&"insert.typing_pause"), 3)
+ popup.add_icon_item(get_theme_icon("ViewportSpeed", "EditorIcons"), DialogueConstants.translate(&"insert.typing_speed_change"), 4)
+ popup.add_icon_item(get_theme_icon("DebugNext", "EditorIcons"), DialogueConstants.translate(&"insert.auto_advance"), 5)
+ popup.add_separator(DialogueConstants.translate(&"insert.templates"))
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.title"), 6)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.dialogue"), 7)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.response"), 8)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.random_lines"), 9)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.random_text"), 10)
+ popup.add_separator(DialogueConstants.translate(&"insert.actions"))
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.jump"), 11)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate(&"insert.end_dialogue"), 12)
+
+ # Set up the translations menu
+ popup = translations_button.get_popup()
+ popup.clear()
+ popup.add_icon_item(get_theme_icon("Translation", "EditorIcons"), DialogueConstants.translate(&"generate_line_ids"), TRANSLATIONS_GENERATE_LINE_IDS)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("FileList", "EditorIcons"), DialogueConstants.translate(&"save_characters_to_csv"), TRANSLATIONS_SAVE_CHARACTERS_TO_CSV)
+ popup.add_icon_item(get_theme_icon("FileList", "EditorIcons"), DialogueConstants.translate(&"save_to_csv"), TRANSLATIONS_SAVE_TO_CSV)
+ popup.add_icon_item(get_theme_icon("AssetLib", "EditorIcons"), DialogueConstants.translate(&"import_from_csv"), TRANSLATIONS_IMPORT_FROM_CSV)
+
+ # Dialog sizes
+ new_dialog.min_size = Vector2(600, 500) * scale
+ save_dialog.min_size = Vector2(600, 500) * scale
+ open_dialog.min_size = Vector2(600, 500) * scale
+ export_dialog.min_size = Vector2(600, 500) * scale
+ import_dialog.min_size = Vector2(600, 500) * scale
+ settings_dialog.min_size = Vector2(1000, 600) * scale
+ settings_dialog.max_size = Vector2(1000, 600) * scale
+ find_in_files_dialog.min_size = Vector2(800, 600) * scale
+
+
+### Helpers
+
+
+# Refresh the open menu with the latest files
+func build_open_menu() -> void:
+ var menu = open_button.get_popup()
+ menu.clear()
+ menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), DialogueConstants.translate(&"open.open"), OPEN_OPEN)
+ menu.add_separator()
+
+ var recent_files = DialogueSettings.get_recent_files()
+ if recent_files.size() == 0:
+ menu.add_item(DialogueConstants.translate(&"open.no_recent_files"))
+ menu.set_item_disabled(2, true)
+ else:
+ for path in recent_files:
+ if FileAccess.file_exists(path):
+ menu.add_icon_item(get_theme_icon("File", "EditorIcons"), path)
+
+ menu.add_separator()
+ menu.add_item(DialogueConstants.translate(&"open.clear_recent_files"), OPEN_CLEAR)
+ if menu.id_pressed.is_connected(_on_open_menu_id_pressed):
+ menu.id_pressed.disconnect(_on_open_menu_id_pressed)
+ menu.id_pressed.connect(_on_open_menu_id_pressed)
+
+
+# Get the last place a CSV, etc was exported
+func get_last_export_path(extension: String) -> String:
+ var filename = current_file_path.get_file().replace(".dialogue", "." + extension)
+ return DialogueSettings.get_user_value("last_export_path", current_file_path.get_base_dir()) + "/" + filename
+
+
+# Check the current text for errors
+func parse() -> void:
+ # Skip if nothing to parse
+ if current_file_path == "": return
+
+ var parser = DialogueManagerParser.new()
+ var errors: Array[Dictionary] = []
+ if parser.parse(code_edit.text, current_file_path) != OK:
+ errors = parser.get_errors()
+ code_edit.errors = errors
+ errors_panel.errors = errors
+ parser.free()
+
+
+func show_build_error_dialog() -> void:
+ build_error_dialog.dialog_text = DialogueConstants.translate(&"errors_with_build")
+ build_error_dialog.popup_centered()
+
+
+# Generate translation line IDs for any line that doesn't already have one
+func generate_translations_keys() -> void:
+ randomize()
+ seed(Time.get_unix_time_from_system())
+
+ var parser = DialogueManagerParser.new()
+
+ var cursor: Vector2 = code_edit.get_cursor()
+ var lines: PackedStringArray = code_edit.text.split("\n")
+
+ var key_regex = RegEx.new()
+ key_regex.compile("\\[ID:(?.*?)\\]")
+
+ # Make list of known keys
+ var known_keys = {}
+ for i in range(0, lines.size()):
+ var line = lines[i]
+ var found = key_regex.search(line)
+ if found:
+ var text = ""
+ var l = line.replace(found.strings[0], "").strip_edges().strip_edges()
+ if l.begins_with("- "):
+ text = parser.extract_response_prompt(l)
+ elif ":" in l:
+ text = l.split(":")[1]
+ else:
+ text = l
+ known_keys[found.strings[found.names.get("key")]] = text
+
+ # Add in any that are missing
+ for i in lines.size():
+ var line = lines[i]
+ var l = line.strip_edges()
+
+ if parser.is_line_empty(l): continue
+ if parser.is_condition_line(l, true): continue
+ if parser.is_title_line(l): continue
+ if parser.is_mutation_line(l): continue
+ if parser.is_goto_line(l): continue
+ if parser.is_import_line(l): continue
+
+ if "[ID:" in line: continue
+
+ var key = "t" + str(randi() % 1000000).sha1_text().substr(0, 10)
+ while key in known_keys:
+ key = "t" + str(randi() % 1000000).sha1_text().substr(0, 10)
+
+ var text = ""
+ if l.begins_with("- "):
+ text = parser.extract_response_prompt(l)
+ else:
+ text = l.substr(l.find(":") + 1)
+
+ lines[i] = line.replace(text, text + " [ID:%s]" % key)
+ known_keys[key] = text
+
+ code_edit.text = "\n".join(lines)
+ code_edit.set_cursor(cursor)
+ _on_code_edit_text_changed()
+
+ parser.free()
+
+
+# Add a translation file to the project settings
+func add_path_to_project_translations(path: String) -> void:
+ var translations: PackedStringArray = ProjectSettings.get_setting("internationalization/locale/translations")
+ if not path in translations:
+ translations.append(path)
+ ProjectSettings.save()
+
+
+# Export dialogue and responses to CSV
+func export_translations_to_csv(path: String) -> void:
+ var default_locale: String = DialogueSettings.get_setting("default_csv_locale", "en")
+
+ var file: FileAccess
+
+ # If the file exists, open it first and work out which keys are already in it
+ var existing_csv: Dictionary = {}
+ var column_count: int = 2
+ var default_locale_column: int = 1
+ var character_column: int = -1
+ var notes_column: int = -1
+ if FileAccess.file_exists(path):
+ file = FileAccess.open(path, FileAccess.READ)
+ var is_first_line = true
+ var line: Array
+ while !file.eof_reached():
+ line = file.get_csv_line()
+ if is_first_line:
+ is_first_line = false
+ column_count = line.size()
+ for i in range(1, line.size()):
+ if line[i] == default_locale:
+ default_locale_column = i
+ elif line[i] == "_character":
+ character_column = i
+ elif line[i] == "_notes":
+ notes_column = i
+
+ # Make sure the line isn't empty before adding it
+ if line.size() > 0 and line[0].strip_edges() != "":
+ existing_csv[line[0]] = line
+
+ # The character column wasn't found in the existing file but the setting is turned on
+ if character_column == -1 and DialogueSettings.get_setting("include_character_in_translation_exports", false):
+ character_column = column_count
+ column_count += 1
+ existing_csv["keys"].append("_character")
+
+ # The notes column wasn't found in the existing file but the setting is turned on
+ if notes_column == -1 and DialogueSettings.get_setting("include_notes_in_translation_exports", false):
+ notes_column = column_count
+ column_count += 1
+ existing_csv["keys"].append("_notes")
+
+ # Start a new file
+ file = FileAccess.open(path, FileAccess.WRITE)
+
+ if not FileAccess.file_exists(path):
+ var headings: PackedStringArray = ["keys", default_locale]
+ if DialogueSettings.get_setting("include_character_in_translation_exports", false):
+ character_column = headings.size()
+ headings.append("_character")
+ if DialogueSettings.get_setting("include_notes_in_translation_exports", false):
+ notes_column = headings.size()
+ headings.append("_notes")
+ file.store_csv_line(headings)
+ column_count = headings.size()
+
+ # Write our translations to file
+ var known_keys: PackedStringArray = []
+
+ var dialogue: Dictionary = DialogueManagerParser.parse_string(code_edit.text, current_file_path).lines
+
+ # Make a list of stuff that needs to go into the file
+ var lines_to_save = []
+ for key in dialogue.keys():
+ var line: Dictionary = dialogue.get(key)
+
+ if not line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]: continue
+ if line.translation_key in known_keys: continue
+
+ known_keys.append(line.translation_key)
+
+ var line_to_save: PackedStringArray = []
+ if existing_csv.has(line.translation_key):
+ line_to_save = existing_csv.get(line.translation_key)
+ line_to_save.resize(column_count)
+ existing_csv.erase(line.translation_key)
+ else:
+ line_to_save.resize(column_count)
+ line_to_save[0] = line.translation_key
+
+ line_to_save[default_locale_column] = line.text
+ if character_column > -1:
+ line_to_save[character_column] = "(response)" if line.type == DialogueConstants.TYPE_RESPONSE else line.character
+ if notes_column > -1:
+ line_to_save[notes_column] = line.notes
+
+ lines_to_save.append(line_to_save)
+
+ # Store lines in the file, starting with anything that already exists that hasn't been touched
+ for line in existing_csv.values():
+ file.store_csv_line(line)
+ for line in lines_to_save:
+ file.store_csv_line(line)
+
+ file.close()
+
+ plugin.get_editor_interface().get_resource_filesystem().scan()
+ plugin.get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", path)
+
+ # Add it to the project l10n settings if it's not already there
+ var language_code: RegExMatch = RegEx.create_from_string("^[a-z]{2,3}").search(default_locale)
+ var translation_path: String = path.replace(".csv", ".%s.translation" % language_code.get_string())
+ call_deferred("add_path_to_project_translations", translation_path)
+
+
+func export_character_names_to_csv(path: String) -> void:
+ var file: FileAccess
+
+ # If the file exists, open it first and work out which keys are already in it
+ var existing_csv = {}
+ var commas = []
+ if FileAccess.file_exists(path):
+ file = FileAccess.open(path, FileAccess.READ)
+ var is_first_line = true
+ var line: Array
+ while !file.eof_reached():
+ line = file.get_csv_line()
+ if is_first_line:
+ is_first_line = false
+ for i in range(2, line.size()):
+ commas.append("")
+ # Make sure the line isn't empty before adding it
+ if line.size() > 0 and line[0].strip_edges() != "":
+ existing_csv[line[0]] = line
+
+ # Start a new file
+ file = FileAccess.open(path, FileAccess.WRITE)
+
+ if not file.file_exists(path):
+ file.store_csv_line(["keys", DialogueSettings.get_setting("default_csv_locale", "en")])
+
+ # Write our translations to file
+ var known_keys: PackedStringArray = []
+
+ var character_names: PackedStringArray = DialogueManagerParser.parse_string(code_edit.text, current_file_path).character_names
+
+ # Make a list of stuff that needs to go into the file
+ var lines_to_save = []
+ for character_name in character_names:
+ if character_name in known_keys: continue
+
+ known_keys.append(character_name)
+
+ if existing_csv.has(character_name):
+ var existing_line = existing_csv.get(character_name)
+ existing_line[1] = character_name
+ lines_to_save.append(existing_line)
+ existing_csv.erase(character_name)
+ else:
+ lines_to_save.append(PackedStringArray([character_name, character_name] + commas))
+
+ # Store lines in the file, starting with anything that already exists that hasn't been touched
+ for line in existing_csv.values():
+ file.store_csv_line(line)
+ for line in lines_to_save:
+ file.store_csv_line(line)
+
+ file.close()
+
+ plugin.get_editor_interface().get_resource_filesystem().scan()
+ plugin.get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", path)
+
+ # Add it to the project l10n settings if it's not already there
+ var translation_path: String = path.replace(".csv", ".en.translation")
+ call_deferred("add_path_to_project_translations", translation_path)
+
+
+# Import changes back from an exported CSV by matching translation keys
+func import_translations_from_csv(path: String) -> void:
+ var cursor: Vector2 = code_edit.get_cursor()
+
+ if not FileAccess.file_exists(path): return
+
+ # Open the CSV file and build a dictionary of the known keys
+ var keys: Dictionary = {}
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ var csv_line: Array
+ while !file.eof_reached():
+ csv_line = file.get_csv_line()
+ if csv_line.size() > 1:
+ keys[csv_line[0]] = csv_line[1]
+
+ var parser: DialogueManagerParser = DialogueManagerParser.new()
+
+ # Now look over each line in the dialogue and replace the content for matched keys
+ var lines: PackedStringArray = code_edit.text.split("\n")
+ var start_index: int = 0
+ var end_index: int = 0
+ for i in range(0, lines.size()):
+ var line: String = lines[i]
+ var translation_key: String = parser.extract_translation(line)
+ if keys.has(translation_key):
+ if parser.is_dialogue_line(line):
+ start_index = 0
+ # See if we need to skip over a character name
+ line = line.replace("\\:", "!ESCAPED_COLON!")
+ if ": " in line:
+ start_index = line.find(": ") + 2
+ lines[i] = (line.substr(0, start_index) + keys.get(translation_key) + " [ID:" + translation_key + "]").replace("!ESCAPED_COLON!", ":")
+
+ elif parser.is_response_line(line):
+ start_index = line.find("- ") + 2
+ # See if we need to skip over a character name
+ line = line.replace("\\:", "!ESCAPED_COLON!")
+ if ": " in line:
+ start_index = line.find(": ") + 2
+ end_index = line.length()
+ if " =>" in line:
+ end_index = line.find(" =>")
+ if " [if " in line:
+ end_index = line.find(" [if ")
+ lines[i] = (line.substr(0, start_index) + keys.get(translation_key) + " [ID:" + translation_key + "]" + line.substr(end_index)).replace("!ESCAPED_COLON!", ":")
+
+ code_edit.text = "\n".join(lines)
+ code_edit.set_cursor(cursor)
+
+ parser.free()
+
+
+func show_search_form(is_enabled: bool) -> void:
+ if code_edit.last_selected_text:
+ search_and_replace.input.text = code_edit.last_selected_text
+
+ search_and_replace.visible = is_enabled
+ search_button.set_pressed_no_signal(is_enabled)
+ search_and_replace.focus_line_edit()
+
+
+### Signals
+
+
+func _on_files_moved(old_file: String, new_file: String) -> void:
+ if open_buffers.has(old_file):
+ open_buffers[new_file] = open_buffers[old_file]
+ open_buffers.erase(old_file)
+ open_buffers[new_file]
+
+
+func _on_cache_file_content_changed(path: String, new_content: String) -> void:
+ if open_buffers.has(path):
+ var buffer = open_buffers[path]
+ if buffer.text != new_content:
+ buffer.text = new_content
+ buffer.pristine_text = new_content
+ code_edit.text = new_content
+
+
+func _on_editor_settings_changed() -> void:
+ var editor_settings: EditorSettings = plugin.get_editor_interface().get_editor_settings()
+ code_edit.minimap_draw = editor_settings.get_setting("text_editor/appearance/minimap/show_minimap")
+ code_edit.minimap_width = editor_settings.get_setting("text_editor/appearance/minimap/minimap_width")
+ code_edit.scroll_smooth = editor_settings.get_setting("text_editor/behavior/navigation/smooth_scrolling")
+
+
+func _on_open_menu_id_pressed(id: int) -> void:
+ match id:
+ OPEN_OPEN:
+ open_dialog.popup_centered()
+ OPEN_CLEAR:
+ DialogueSettings.clear_recent_files()
+ build_open_menu()
+ _:
+ var menu = open_button.get_popup()
+ var item = menu.get_item_text(menu.get_item_index(id))
+ open_file(item)
+
+
+func _on_files_list_file_selected(file_path: String) -> void:
+ self.current_file_path = file_path
+
+
+func _on_insert_button_menu_id_pressed(id: int) -> void:
+ match id:
+ 0:
+ code_edit.insert_bbcode("[wave amp=25 freq=5]", "[/wave]")
+ 1:
+ code_edit.insert_bbcode("[shake rate=20 level=10]", "[/shake]")
+ 3:
+ code_edit.insert_bbcode("[wait=1]")
+ 4:
+ code_edit.insert_bbcode("[speed=0.2]")
+ 5:
+ code_edit.insert_bbcode("[next=auto]")
+ 6:
+ code_edit.insert_text_at_cursor("~ title")
+ 7:
+ code_edit.insert_text_at_cursor("Nathan: This is Some Dialogue")
+ 8:
+ code_edit.insert_text_at_cursor("Nathan: Choose a Response...\n- Option 1\n\tNathan: You chose option 1\n- Option 2\n\tNathan: You chose option 2")
+ 9:
+ code_edit.insert_text_at_cursor("% Nathan: This is random line 1.\n% Nathan: This is random line 2.\n%1 Nathan: This is weighted random line 3.")
+ 10:
+ code_edit.insert_text_at_cursor("Nathan: [[Hi|Hello|Howdy]]")
+ 11:
+ code_edit.insert_text_at_cursor("=> title")
+ 12:
+ code_edit.insert_text_at_cursor("=> END")
+
+
+func _on_translations_button_menu_id_pressed(id: int) -> void:
+ match id:
+ TRANSLATIONS_GENERATE_LINE_IDS:
+ generate_translations_keys()
+
+ TRANSLATIONS_SAVE_CHARACTERS_TO_CSV:
+ translation_source = TranslationSource.CharacterNames
+ export_dialog.filters = PackedStringArray(["*.csv ; Translation CSV"])
+ export_dialog.current_path = get_last_export_path("csv")
+ export_dialog.popup_centered()
+
+ TRANSLATIONS_SAVE_TO_CSV:
+ translation_source = TranslationSource.Lines
+ export_dialog.filters = PackedStringArray(["*.csv ; Translation CSV"])
+ export_dialog.current_path = get_last_export_path("csv")
+ export_dialog.popup_centered()
+
+ TRANSLATIONS_IMPORT_FROM_CSV:
+ import_dialog.current_path = get_last_export_path("csv")
+ import_dialog.popup_centered()
+
+
+func _on_export_dialog_file_selected(path: String) -> void:
+ DialogueSettings.set_user_value("last_export_path", path.get_base_dir())
+ match path.get_extension():
+ "csv":
+ match translation_source:
+ TranslationSource.CharacterNames:
+ export_character_names_to_csv(path)
+ TranslationSource.Lines:
+ export_translations_to_csv(path)
+
+
+func _on_import_dialog_file_selected(path: String) -> void:
+ DialogueSettings.set_user_value("last_export_path", path.get_base_dir())
+ import_translations_from_csv(path)
+
+
+func _on_main_view_theme_changed():
+ apply_theme()
+
+
+func _on_main_view_visibility_changed() -> void:
+ if visible and is_instance_valid(code_edit):
+ code_edit.grab_focus()
+
+
+func _on_new_button_pressed() -> void:
+ new_dialog.current_file = ""
+ new_dialog.popup_centered()
+
+
+func _on_new_dialog_file_selected(path: String) -> void:
+ new_file(path)
+ open_file(path)
+
+
+func _on_save_dialog_file_selected(path: String) -> void:
+ new_file(path, code_edit.text)
+ open_file(path)
+
+
+func _on_open_button_about_to_popup() -> void:
+ build_open_menu()
+
+
+func _on_open_dialog_file_selected(path: String) -> void:
+ open_file(path)
+
+
+func _on_save_all_button_pressed() -> void:
+ save_files()
+
+
+func _on_find_in_files_button_pressed() -> void:
+ find_in_files_dialog.popup_centered()
+ find_in_files.prepare()
+
+
+func _on_code_edit_text_changed() -> void:
+ title_list.titles = code_edit.get_titles()
+
+ var buffer = open_buffers[current_file_path]
+ buffer.text = code_edit.text
+
+ files_list.mark_file_as_unsaved(current_file_path, buffer.text != buffer.pristine_text)
+ save_all_button.disabled = open_buffers.values().filter(func(d): return d.text != d.pristine_text).size() == 0
+
+ parse_timer.start(1)
+
+
+func _on_code_edit_active_title_change(title: String) -> void:
+ title_list.select_title(title)
+ DialogueSettings.set_user_value("run_title", title)
+
+
+func _on_code_edit_caret_changed() -> void:
+ DialogueSettings.set_caret(current_file_path, code_edit.get_cursor())
+
+
+func _on_code_edit_error_clicked(line_number: int) -> void:
+ errors_panel.show_error_for_line_number(line_number)
+
+
+func _on_title_list_title_selected(title: String) -> void:
+ code_edit.go_to_title(title)
+ code_edit.grab_focus()
+
+
+func _on_parse_timer_timeout() -> void:
+ parse_timer.stop()
+ parse()
+
+
+func _on_errors_panel_error_pressed(line_number: int, column_number: int) -> void:
+ code_edit.set_caret_line(line_number)
+ code_edit.set_caret_column(column_number)
+ code_edit.grab_focus()
+
+
+func _on_search_button_toggled(button_pressed: bool) -> void:
+ show_search_form(button_pressed)
+
+
+func _on_search_and_replace_open_requested() -> void:
+ show_search_form(true)
+
+
+func _on_search_and_replace_close_requested() -> void:
+ search_button.set_pressed_no_signal(false)
+ search_and_replace.visible = false
+ code_edit.grab_focus()
+
+
+func _on_settings_button_pressed() -> void:
+ settings_view.prepare()
+ settings_dialog.popup_centered()
+
+
+func _on_settings_view_script_button_pressed(path: String) -> void:
+ settings_dialog.hide()
+ plugin.get_editor_interface().edit_resource(load(path))
+
+
+func _on_test_button_pressed() -> void:
+ save_file(current_file_path)
+
+ if errors_panel.errors.size() > 0:
+ errors_dialog.popup_centered()
+ return
+
+ DialogueSettings.set_user_value("is_running_test_scene", true)
+ DialogueSettings.set_user_value("run_resource_path", current_file_path)
+ var test_scene_path: String = DialogueSettings.get_setting("custom_test_scene_path", "res://addons/dialogue_manager/test_scene.tscn")
+ plugin.get_editor_interface().play_custom_scene(test_scene_path)
+
+
+func _on_settings_dialog_confirmed() -> void:
+ settings_view.apply_settings_changes()
+ parse()
+ code_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY if DialogueSettings.get_setting("wrap_lines", false) else TextEdit.LINE_WRAPPING_NONE
+ code_edit.grab_focus()
+
+
+func _on_support_button_pressed() -> void:
+ OS.shell_open("https://patreon.com/nathanhoad")
+
+
+func _on_docs_button_pressed() -> void:
+ OS.shell_open("https://github.com/nathanhoad/godot_dialogue_manager")
+
+
+func _on_files_list_file_popup_menu_requested(at_position: Vector2) -> void:
+ files_popup_menu.position = Vector2(get_viewport().position) + files_list.global_position + at_position
+ files_popup_menu.popup()
+
+
+func _on_files_list_file_middle_clicked(path: String):
+ close_file(path)
+
+
+func _on_files_popup_menu_about_to_popup() -> void:
+ files_popup_menu.clear()
+
+ var shortcuts: Dictionary = plugin.get_editor_shortcuts()
+
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.save"), ITEM_SAVE, OS.find_keycode_from_string(shortcuts.get("save")[0].as_text_keycode()))
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.save_as"), ITEM_SAVE_AS)
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.close"), ITEM_CLOSE, OS.find_keycode_from_string(shortcuts.get("close_file")[0].as_text_keycode()))
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.close_all"), ITEM_CLOSE_ALL)
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.close_other_files"), ITEM_CLOSE_OTHERS)
+ files_popup_menu.add_separator()
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.copy_file_path"), ITEM_COPY_PATH)
+ files_popup_menu.add_item(DialogueConstants.translate(&"buffer.show_in_filesystem"), ITEM_SHOW_IN_FILESYSTEM)
+
+
+func _on_files_popup_menu_id_pressed(id: int) -> void:
+ match id:
+ ITEM_SAVE:
+ save_file(current_file_path)
+ ITEM_SAVE_AS:
+ save_dialog.popup_centered()
+ ITEM_CLOSE:
+ close_file(current_file_path)
+ ITEM_CLOSE_ALL:
+ for path in open_buffers.keys():
+ close_file(path)
+ ITEM_CLOSE_OTHERS:
+ var current_current_file_path: String = current_file_path
+ for path in open_buffers.keys():
+ if path != current_current_file_path:
+ await close_file(path)
+
+ ITEM_COPY_PATH:
+ DisplayServer.clipboard_set(current_file_path)
+ ITEM_SHOW_IN_FILESYSTEM:
+ show_file_in_filesystem(current_file_path)
+
+
+func _on_code_edit_external_file_requested(path: String, title: String) -> void:
+ open_file(path)
+ if title != "":
+ code_edit.go_to_title(title)
+ else:
+ code_edit.set_caret_line(0)
+
+
+func _on_close_confirmation_dialog_confirmed() -> void:
+ save_file(current_file_path)
+ remove_file_from_open_buffers(current_file_path)
+ confirmation_closed.emit()
+
+
+func _on_close_confirmation_dialog_custom_action(action: StringName) -> void:
+ if action == "discard":
+ remove_file_from_open_buffers(current_file_path)
+ close_confirmation_dialog.hide()
+ confirmation_closed.emit()
+
+
+func _on_find_in_files_result_selected(path: String, cursor: Vector2, length: int) -> void:
+ open_file(path)
+ code_edit.select(cursor.y, cursor.x, cursor.y, cursor.x + length)
diff --git a/addons/dialogue_manager/views/main_view.tscn b/addons/dialogue_manager/views/main_view.tscn
new file mode 100644
index 00000000..5a0a7336
--- /dev/null
+++ b/addons/dialogue_manager/views/main_view.tscn
@@ -0,0 +1,431 @@
+[gd_scene load_steps=16 format=3 uid="uid://cbuf1q3xsse3q"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/views/main_view.gd" id="1_h6qfq"]
+[ext_resource type="PackedScene" uid="uid://civ6shmka5e8u" path="res://addons/dialogue_manager/components/code_edit.tscn" id="2_f73fm"]
+[ext_resource type="PackedScene" uid="uid://dnufpcdrreva3" path="res://addons/dialogue_manager/components/files_list.tscn" id="2_npj2k"]
+[ext_resource type="PackedScene" uid="uid://ctns6ouwwd68i" path="res://addons/dialogue_manager/components/title_list.tscn" id="2_onb4i"]
+[ext_resource type="PackedScene" uid="uid://co8yl23idiwbi" path="res://addons/dialogue_manager/components/update_button.tscn" id="2_ph3vs"]
+[ext_resource type="PackedScene" uid="uid://gr8nakpbrhby" path="res://addons/dialogue_manager/components/search_and_replace.tscn" id="6_ylh0t"]
+[ext_resource type="PackedScene" uid="uid://cs8pwrxr5vxix" path="res://addons/dialogue_manager/components/errors_panel.tscn" id="7_5cvl4"]
+[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="7_necsa"]
+[ext_resource type="PackedScene" uid="uid://cpg4lg1r3ff6m" path="res://addons/dialogue_manager/views/settings_view.tscn" id="9_8bf36"]
+[ext_resource type="PackedScene" uid="uid://0n7hwviyyly4" path="res://addons/dialogue_manager/components/find_in_files.tscn" id="10_yold3"]
+
+[sub_resource type="Image" id="Image_w5tip"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_wmrmd"]
+image = SubResource("Image_w5tip")
+
+[sub_resource type="Image" id="Image_ki84n"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_r0npg"]
+image = SubResource("Image_ki84n")
+
+[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_4re8k"]
+script = ExtResource("7_necsa")
+
+[node name="MainView" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_h6qfq")
+
+[node name="ParseTimer" type="Timer" parent="."]
+
+[node name="Margin" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_vertical = 3
+theme_override_constants/margin_left = 5
+theme_override_constants/margin_right = 5
+theme_override_constants/margin_bottom = 5
+metadata/_edit_layout_mode = 1
+
+[node name="Content" type="HSplitContainer" parent="Margin"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="SidePanel" type="VBoxContainer" parent="Margin/Content"]
+custom_minimum_size = Vector2(150, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/SidePanel"]
+layout_mode = 2
+
+[node name="NewButton" type="Button" parent="Margin/Content/SidePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Start a new file"
+flat = true
+
+[node name="OpenButton" type="MenuButton" parent="Margin/Content/SidePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Open a file"
+item_count = 9
+popup/item_0/text = "Open..."
+popup/item_0/icon = SubResource("ImageTexture_wmrmd")
+popup/item_0/id = 100
+popup/item_1/text = ""
+popup/item_1/id = -1
+popup/item_1/separator = true
+popup/item_2/text = "res://blah.dialogue"
+popup/item_2/icon = SubResource("ImageTexture_wmrmd")
+popup/item_2/id = 2
+popup/item_3/text = "res://examples/dialogue.dialogue"
+popup/item_3/icon = SubResource("ImageTexture_wmrmd")
+popup/item_3/id = 3
+popup/item_4/text = "res://examples/dialogue_with_input.dialogue"
+popup/item_4/icon = SubResource("ImageTexture_wmrmd")
+popup/item_4/id = 4
+popup/item_5/text = "res://examples/dialogue_for_point_n_click.dialogue"
+popup/item_5/icon = SubResource("ImageTexture_wmrmd")
+popup/item_5/id = 5
+popup/item_6/text = "res://examples/dialogue_for_visual_novel.dialogue"
+popup/item_6/icon = SubResource("ImageTexture_wmrmd")
+popup/item_6/id = 6
+popup/item_7/text = ""
+popup/item_7/id = -1
+popup/item_7/separator = true
+popup/item_8/text = "Clear recent files"
+popup/item_8/id = 101
+
+[node name="SaveAllButton" type="Button" parent="Margin/Content/SidePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+disabled = true
+flat = true
+
+[node name="FindInFilesButton" type="Button" parent="Margin/Content/SidePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Find in files..."
+flat = true
+
+[node name="Bookmarks" type="VSplitContainer" parent="Margin/Content/SidePanel"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="FilesList" parent="Margin/Content/SidePanel/Bookmarks" instance=ExtResource("2_npj2k")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="FilesPopupMenu" type="PopupMenu" parent="Margin/Content/SidePanel/Bookmarks/FilesList"]
+unique_name_in_owner = true
+
+[node name="TitleList" parent="Margin/Content/SidePanel/Bookmarks" instance=ExtResource("2_onb4i")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CodePanel" type="VBoxContainer" parent="Margin/Content"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_stretch_ratio = 4.0
+
+[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/CodePanel"]
+layout_mode = 2
+
+[node name="InsertButton" type="MenuButton" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Insert"
+item_count = 15
+popup/item_0/text = "Wave BBCode"
+popup/item_0/icon = SubResource("ImageTexture_r0npg")
+popup/item_0/id = 0
+popup/item_1/text = "Shake BBCode"
+popup/item_1/icon = SubResource("ImageTexture_r0npg")
+popup/item_1/id = 1
+popup/item_2/text = ""
+popup/item_2/id = -1
+popup/item_2/separator = true
+popup/item_3/text = "Typing pause"
+popup/item_3/icon = SubResource("ImageTexture_r0npg")
+popup/item_3/id = 3
+popup/item_4/text = "Typing speed change"
+popup/item_4/icon = SubResource("ImageTexture_r0npg")
+popup/item_4/id = 4
+popup/item_5/text = "Auto advance"
+popup/item_5/icon = SubResource("ImageTexture_r0npg")
+popup/item_5/id = 5
+popup/item_6/text = "Templates"
+popup/item_6/id = -1
+popup/item_6/separator = true
+popup/item_7/text = "Title"
+popup/item_7/icon = SubResource("ImageTexture_r0npg")
+popup/item_7/id = 6
+popup/item_8/text = "Dialogue"
+popup/item_8/icon = SubResource("ImageTexture_r0npg")
+popup/item_8/id = 7
+popup/item_9/text = "Response"
+popup/item_9/icon = SubResource("ImageTexture_r0npg")
+popup/item_9/id = 8
+popup/item_10/text = "Random lines"
+popup/item_10/icon = SubResource("ImageTexture_r0npg")
+popup/item_10/id = 9
+popup/item_11/text = "Random text"
+popup/item_11/icon = SubResource("ImageTexture_r0npg")
+popup/item_11/id = 10
+popup/item_12/text = "Actions"
+popup/item_12/id = -1
+popup/item_12/separator = true
+popup/item_13/text = "Jump to title"
+popup/item_13/icon = SubResource("ImageTexture_r0npg")
+popup/item_13/id = 11
+popup/item_14/text = "End dialogue"
+popup/item_14/icon = SubResource("ImageTexture_r0npg")
+popup/item_14/id = 12
+
+[node name="TranslationsButton" type="MenuButton" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Translations"
+item_count = 5
+popup/item_0/text = "Generate line IDs"
+popup/item_0/icon = SubResource("ImageTexture_r0npg")
+popup/item_0/id = 100
+popup/item_1/text = ""
+popup/item_1/id = -1
+popup/item_1/separator = true
+popup/item_2/text = "Save character names to CSV..."
+popup/item_2/icon = SubResource("ImageTexture_r0npg")
+popup/item_2/id = 201
+popup/item_3/text = "Save lines to CSV..."
+popup/item_3/icon = SubResource("ImageTexture_r0npg")
+popup/item_3/id = 202
+popup/item_4/text = "Import line changes from CSV..."
+popup/item_4/icon = SubResource("ImageTexture_r0npg")
+popup/item_4/id = 203
+
+[node name="Separator" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"]
+layout_mode = 2
+
+[node name="SearchButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Search for text"
+toggle_mode = true
+flat = true
+
+[node name="TestButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Test dialogue"
+flat = true
+
+[node name="Separator3" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"]
+layout_mode = 2
+
+[node name="SettingsButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Settings"
+flat = true
+
+[node name="Spacer2" type="Control" parent="Margin/Content/CodePanel/Toolbar"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="SupportButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Support Dialogue Manager"
+text = "Sponsor"
+flat = true
+
+[node name="Separator4" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"]
+layout_mode = 2
+
+[node name="DocsButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Docs"
+flat = true
+
+[node name="VersionLabel" type="Label" parent="Margin/Content/CodePanel/Toolbar"]
+unique_name_in_owner = true
+modulate = Color(1, 1, 1, 0.490196)
+layout_mode = 2
+text = "v2.41.3"
+vertical_alignment = 1
+
+[node name="UpdateButton" parent="Margin/Content/CodePanel/Toolbar" instance=ExtResource("2_ph3vs")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="SearchAndReplace" parent="Margin/Content/CodePanel" instance=ExtResource("6_ylh0t")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CodeEdit" parent="Margin/Content/CodePanel" instance=ExtResource("2_f73fm")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+theme_override_colors/background_color = Color(0.156863, 0.164706, 0.211765, 1)
+theme_override_colors/font_color = Color(0.972549, 0.972549, 0.94902, 1)
+theme_override_colors/bookmark_color = Color(1, 0.333333, 0.333333, 1)
+theme_override_colors/current_line_color = Color(0.266667, 0.278431, 0.352941, 0.243137)
+theme_override_font_sizes/font_size = 21
+text = "~ this_is_a_node_title
+
+Nathan: [[Hi|Hello|Howdy]], this is some dialogue.
+Nathan: Here are some choices.
+- First one
+ Nathan: You picked the first one.
+- Second one
+ Nathan: You picked the second one.
+- Start again => this_is_a_node_title
+- End the conversation => END
+Nathan: For more information see the online documentation.
+
+=> END"
+scroll_smooth = true
+syntax_highlighter = SubResource("SyntaxHighlighter_4re8k")
+
+[node name="ErrorsPanel" parent="Margin/Content/CodePanel" instance=ExtResource("7_5cvl4")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="NewDialog" type="FileDialog" parent="."]
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="SaveDialog" type="FileDialog" parent="."]
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="OpenDialog" type="FileDialog" parent="."]
+title = "Open a File"
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+ok_button_text = "Open"
+dialog_hide_on_ok = true
+file_mode = 0
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="ExportDialog" type="FileDialog" parent="."]
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+
+[node name="ImportDialog" type="FileDialog" parent="."]
+title = "Open a File"
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+ok_button_text = "Open"
+file_mode = 0
+filters = PackedStringArray("*.csv ; Translation CSV")
+
+[node name="ErrorsDialog" type="AcceptDialog" parent="."]
+title = "Error"
+dialog_text = "You have errors in your script. Fix them and then try again."
+
+[node name="SettingsDialog" type="AcceptDialog" parent="."]
+title = "Settings"
+size = Vector2i(1500, 900)
+unresizable = true
+min_size = Vector2i(1500, 900)
+max_size = Vector2i(1500, 900)
+ok_button_text = "Done"
+
+[node name="SettingsView" parent="SettingsDialog" instance=ExtResource("9_8bf36")]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+current_tab = 0
+
+[node name="BuildErrorDialog" type="AcceptDialog" parent="."]
+title = "Errors"
+dialog_text = "You need to fix dialogue errors before you can run your game."
+
+[node name="CloseConfirmationDialog" type="ConfirmationDialog" parent="."]
+title = "Unsaved changes"
+ok_button_text = "Save changes"
+
+[node name="UpdatedDialog" type="AcceptDialog" parent="."]
+title = "Updated"
+size = Vector2i(191, 100)
+dialog_text = "You're now up to date!"
+
+[node name="FindInFilesDialog" type="AcceptDialog" parent="."]
+title = "Find in files"
+size = Vector2i(1200, 900)
+min_size = Vector2i(1200, 900)
+ok_button_text = "Done"
+
+[node name="FindInFiles" parent="FindInFilesDialog" node_paths=PackedStringArray("main_view", "code_edit") instance=ExtResource("10_yold3")]
+custom_minimum_size = Vector2(400, 400)
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+main_view = NodePath("../..")
+code_edit = NodePath("../../Margin/Content/CodePanel/CodeEdit")
+
+[connection signal="theme_changed" from="." to="." method="_on_main_view_theme_changed"]
+[connection signal="visibility_changed" from="." to="." method="_on_main_view_visibility_changed"]
+[connection signal="timeout" from="ParseTimer" to="." method="_on_parse_timer_timeout"]
+[connection signal="pressed" from="Margin/Content/SidePanel/Toolbar/NewButton" to="." method="_on_new_button_pressed"]
+[connection signal="about_to_popup" from="Margin/Content/SidePanel/Toolbar/OpenButton" to="." method="_on_open_button_about_to_popup"]
+[connection signal="pressed" from="Margin/Content/SidePanel/Toolbar/SaveAllButton" to="." method="_on_save_all_button_pressed"]
+[connection signal="pressed" from="Margin/Content/SidePanel/Toolbar/FindInFilesButton" to="." method="_on_find_in_files_button_pressed"]
+[connection signal="file_middle_clicked" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_middle_clicked"]
+[connection signal="file_popup_menu_requested" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_popup_menu_requested"]
+[connection signal="file_selected" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_selected"]
+[connection signal="about_to_popup" from="Margin/Content/SidePanel/Bookmarks/FilesList/FilesPopupMenu" to="." method="_on_files_popup_menu_about_to_popup"]
+[connection signal="id_pressed" from="Margin/Content/SidePanel/Bookmarks/FilesList/FilesPopupMenu" to="." method="_on_files_popup_menu_id_pressed"]
+[connection signal="title_selected" from="Margin/Content/SidePanel/Bookmarks/TitleList" to="." method="_on_title_list_title_selected"]
+[connection signal="toggled" from="Margin/Content/CodePanel/Toolbar/SearchButton" to="." method="_on_search_button_toggled"]
+[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/TestButton" to="." method="_on_test_button_pressed"]
+[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/SettingsButton" to="." method="_on_settings_button_pressed"]
+[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/SupportButton" to="." method="_on_support_button_pressed"]
+[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/DocsButton" to="." method="_on_docs_button_pressed"]
+[connection signal="close_requested" from="Margin/Content/CodePanel/SearchAndReplace" to="." method="_on_search_and_replace_close_requested"]
+[connection signal="open_requested" from="Margin/Content/CodePanel/SearchAndReplace" to="." method="_on_search_and_replace_open_requested"]
+[connection signal="active_title_change" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_active_title_change"]
+[connection signal="caret_changed" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_caret_changed"]
+[connection signal="error_clicked" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_error_clicked"]
+[connection signal="external_file_requested" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_external_file_requested"]
+[connection signal="text_changed" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_text_changed"]
+[connection signal="error_pressed" from="Margin/Content/CodePanel/ErrorsPanel" to="." method="_on_errors_panel_error_pressed"]
+[connection signal="file_selected" from="NewDialog" to="." method="_on_new_dialog_file_selected"]
+[connection signal="file_selected" from="SaveDialog" to="." method="_on_save_dialog_file_selected"]
+[connection signal="file_selected" from="OpenDialog" to="." method="_on_open_dialog_file_selected"]
+[connection signal="file_selected" from="ExportDialog" to="." method="_on_export_dialog_file_selected"]
+[connection signal="file_selected" from="ImportDialog" to="." method="_on_import_dialog_file_selected"]
+[connection signal="confirmed" from="SettingsDialog" to="." method="_on_settings_dialog_confirmed"]
+[connection signal="script_button_pressed" from="SettingsDialog/SettingsView" to="." method="_on_settings_view_script_button_pressed"]
+[connection signal="confirmed" from="CloseConfirmationDialog" to="." method="_on_close_confirmation_dialog_confirmed"]
+[connection signal="custom_action" from="CloseConfirmationDialog" to="." method="_on_close_confirmation_dialog_custom_action"]
+[connection signal="result_selected" from="FindInFilesDialog/FindInFiles" to="." method="_on_find_in_files_result_selected"]
diff --git a/addons/dialogue_manager/views/settings_view.gd b/addons/dialogue_manager/views/settings_view.gd
new file mode 100644
index 00000000..1a1e748c
--- /dev/null
+++ b/addons/dialogue_manager/views/settings_view.gd
@@ -0,0 +1,280 @@
+@tool
+extends TabContainer
+
+
+signal script_button_pressed(path: String)
+
+
+const DialogueConstants = preload("../constants.gd")
+const DialogueSettings = preload("../settings.gd")
+const BaseDialogueTestScene = preload("../test_scene.gd")
+
+
+enum PathTarget {
+ CustomTestScene,
+ Balloon
+}
+
+
+# Editor
+@onready var new_template_button: CheckBox = $Editor/NewTemplateButton
+@onready var characters_translations_button: CheckBox = $Editor/CharactersTranslationsButton
+@onready var wrap_lines_button: Button = $Editor/WrapLinesButton
+@onready var default_csv_locale: LineEdit = $Editor/DefaultCSVLocale
+
+# Runtime
+@onready var include_all_responses_button: CheckBox = $Runtime/IncludeAllResponsesButton
+@onready var ignore_missing_state_values: CheckBox = $Runtime/IgnoreMissingStateValues
+@onready var balloon_path_input: LineEdit = $Runtime/CustomBalloon/BalloonPath
+@onready var revert_balloon_button: Button = $Runtime/CustomBalloon/RevertBalloonPath
+@onready var load_balloon_button: Button = $Runtime/CustomBalloon/LoadBalloonPath
+@onready var states_title: Label = $Runtime/StatesTitle
+@onready var globals_list: Tree = $Runtime/GlobalsList
+
+# Advanced
+@onready var check_for_updates: CheckBox = $Advanced/CheckForUpdates
+@onready var include_characters_in_translations: CheckBox = $Advanced/IncludeCharactersInTranslations
+@onready var include_notes_in_translations: CheckBox = $Advanced/IncludeNotesInTranslations
+@onready var open_in_external_editor_button: CheckBox = $Advanced/OpenInExternalEditorButton
+@onready var test_scene_path_input: LineEdit = $Advanced/CustomTestScene/TestScenePath
+@onready var revert_test_scene_button: Button = $Advanced/CustomTestScene/RevertTestScene
+@onready var load_test_scene_button: Button = $Advanced/CustomTestScene/LoadTestScene
+@onready var custom_test_scene_file_dialog: FileDialog = $CustomTestSceneFileDialog
+@onready var create_lines_for_response_characters: CheckBox = $Advanced/CreateLinesForResponseCharacters
+@onready var missing_translations_button: CheckBox = $Advanced/MissingTranslationsButton
+
+var all_globals: Dictionary = {}
+var enabled_globals: Array = []
+var path_target: PathTarget = PathTarget.CustomTestScene
+
+var _default_test_scene_path: String = preload("../test_scene.tscn").resource_path
+
+var _recompile_if_changed_settings: Dictionary
+
+
+func _ready() -> void:
+ new_template_button.text = DialogueConstants.translate(&"settings.new_template")
+ $Editor/MissingTranslationsHint.text = DialogueConstants.translate(&"settings.missing_keys_hint")
+ characters_translations_button.text = DialogueConstants.translate(&"settings.characters_translations")
+ wrap_lines_button.text = DialogueConstants.translate(&"settings.wrap_long_lines")
+ $Editor/DefaultCSVLocaleLabel.text = DialogueConstants.translate(&"settings.default_csv_locale")
+
+ include_all_responses_button.text = DialogueConstants.translate(&"settings.include_failed_responses")
+ ignore_missing_state_values.text = DialogueConstants.translate(&"settings.ignore_missing_state_values")
+ $Runtime/CustomBalloonLabel.text = DialogueConstants.translate(&"settings.default_balloon_hint")
+ states_title.text = DialogueConstants.translate(&"settings.states_shortcuts")
+ $Runtime/StatesMessage.text = DialogueConstants.translate(&"settings.states_message")
+ $Runtime/StatesHint.text = DialogueConstants.translate(&"settings.states_hint")
+
+ check_for_updates.text = DialogueConstants.translate(&"settings.check_for_updates")
+ include_characters_in_translations.text = DialogueConstants.translate(&"settings.include_characters_in_translations")
+ include_notes_in_translations.text = DialogueConstants.translate(&"settings.include_notes_in_translations")
+ open_in_external_editor_button.text = DialogueConstants.translate(&"settings.open_in_external_editor")
+ $Advanced/ExternalWarning.text = DialogueConstants.translate(&"settings.external_editor_warning")
+ $Advanced/CustomTestSceneLabel.text = DialogueConstants.translate(&"settings.custom_test_scene")
+ $Advanced/RecompileWarning.text = DialogueConstants.translate(&"settings.recompile_warning")
+ missing_translations_button.text = DialogueConstants.translate(&"settings.missing_keys")
+ create_lines_for_response_characters.text = DialogueConstants.translate(&"settings.create_lines_for_responses_with_characters")
+
+ current_tab = 0
+
+
+func prepare() -> void:
+ _recompile_if_changed_settings = _get_settings_that_require_recompilation()
+
+ test_scene_path_input.placeholder_text = DialogueSettings.get_setting("custom_test_scene_path", _default_test_scene_path)
+ revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path
+ revert_test_scene_button.icon = get_theme_icon("RotateLeft", "EditorIcons")
+ revert_test_scene_button.tooltip_text = DialogueConstants.translate(&"settings.revert_to_default_test_scene")
+ load_test_scene_button.icon = get_theme_icon("Load", "EditorIcons")
+
+ var balloon_path: String = DialogueSettings.get_setting("balloon_path", "")
+ if not FileAccess.file_exists(balloon_path):
+ DialogueSettings.set_setting("balloon_path", "")
+ balloon_path = ""
+ balloon_path_input.placeholder_text = balloon_path if balloon_path != "" else DialogueConstants.translate(&"settings.default_balloon_path")
+ revert_balloon_button.visible = balloon_path != ""
+ revert_balloon_button.icon = get_theme_icon("RotateLeft", "EditorIcons")
+ revert_balloon_button.tooltip_text = DialogueConstants.translate(&"settings.revert_to_default_balloon")
+ load_balloon_button.icon = get_theme_icon("Load", "EditorIcons")
+
+ var scale: float = Engine.get_meta("DialogueManagerPlugin").get_editor_interface().get_editor_scale()
+ custom_test_scene_file_dialog.min_size = Vector2(600, 500) * scale
+
+ states_title.add_theme_font_override("font", get_theme_font("bold", "EditorFonts"))
+
+ check_for_updates.set_pressed_no_signal(DialogueSettings.get_user_value("check_for_updates", true))
+ characters_translations_button.set_pressed_no_signal(DialogueSettings.get_setting("export_characters_in_translation", true))
+ wrap_lines_button.set_pressed_no_signal(DialogueSettings.get_setting("wrap_lines", false))
+ include_all_responses_button.set_pressed_no_signal(DialogueSettings.get_setting("include_all_responses", false))
+ ignore_missing_state_values.set_pressed_no_signal(DialogueSettings.get_setting("ignore_missing_state_values", false))
+ new_template_button.set_pressed_no_signal(DialogueSettings.get_setting("new_with_template", true))
+ default_csv_locale.text = DialogueSettings.get_setting("default_csv_locale", "en")
+
+ missing_translations_button.set_pressed_no_signal(DialogueSettings.get_setting("missing_translations_are_errors", false))
+ create_lines_for_response_characters.set_pressed_no_signal(DialogueSettings.get_setting("create_lines_for_responses_with_characters", true))
+
+ include_characters_in_translations.set_pressed_no_signal(DialogueSettings.get_setting("include_character_in_translation_exports", false))
+ include_notes_in_translations.set_pressed_no_signal(DialogueSettings.get_setting("include_notes_in_translation_exports", false))
+ open_in_external_editor_button.set_pressed_no_signal(DialogueSettings.get_user_value("open_in_external_editor", false))
+
+ var editor_settings: EditorSettings = Engine.get_meta("DialogueManagerPlugin").get_editor_interface().get_editor_settings()
+ var external_editor: String = editor_settings.get_setting("text_editor/external/exec_path")
+ var use_external_editor: bool = editor_settings.get_setting("text_editor/external/use_external_editor") and external_editor != ""
+ if not use_external_editor:
+ open_in_external_editor_button.hide()
+ $Advanced/ExternalWarning.hide()
+ $Advanced/ExternalSeparator.hide()
+
+ var project = ConfigFile.new()
+ var err = project.load("res://project.godot")
+ assert(err == OK, "Could not find the project file")
+
+ all_globals.clear()
+ if project.has_section("autoload"):
+ for key in project.get_section_keys("autoload"):
+ if key != "DialogueManager":
+ all_globals[key] = project.get_value("autoload", key)
+
+ enabled_globals = DialogueSettings.get_setting("states", []).duplicate()
+ globals_list.clear()
+ var root = globals_list.create_item()
+ for name in all_globals.keys():
+ var item: TreeItem = globals_list.create_item(root)
+ item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
+ item.set_checked(0, name in enabled_globals)
+ item.set_text(0, name)
+ item.add_button(1, get_theme_icon("Edit", "EditorIcons"))
+ item.set_text(2, all_globals.get(name, "").replace("*res://", "res://"))
+
+ globals_list.set_column_expand(0, false)
+ globals_list.set_column_custom_minimum_width(0, 250)
+ globals_list.set_column_expand(1, false)
+ globals_list.set_column_custom_minimum_width(1, 40)
+ globals_list.set_column_titles_visible(true)
+ globals_list.set_column_title(0, DialogueConstants.translate(&"settings.autoload"))
+ globals_list.set_column_title(1, "")
+ globals_list.set_column_title(2, DialogueConstants.translate(&"settings.path"))
+
+
+func apply_settings_changes() -> void:
+ if _recompile_if_changed_settings != _get_settings_that_require_recompilation():
+ Engine.get_meta("DialogueCache").reimport_files()
+
+
+func _get_settings_that_require_recompilation() -> Dictionary:
+ return DialogueSettings.get_settings([
+ "missing_translations_are_errors",
+ "create_lines_for_responses_with_characters"
+ ])
+
+
+### Signals
+
+
+func _on_missing_translations_button_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("missing_translations_are_errors", toggled_on)
+
+
+func _on_characters_translations_button_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("export_characters_in_translation", toggled_on)
+
+
+func _on_wrap_lines_button_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("wrap_lines", toggled_on)
+
+
+func _on_include_all_responses_button_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("include_all_responses", toggled_on)
+
+
+func _on_globals_list_item_selected() -> void:
+ var item = globals_list.get_selected()
+ var is_checked = not item.is_checked(0)
+ item.set_checked(0, is_checked)
+
+ if is_checked:
+ enabled_globals.append(item.get_text(0))
+ else:
+ enabled_globals.erase(item.get_text(0))
+
+ DialogueSettings.set_setting("states", enabled_globals)
+
+
+func _on_globals_list_button_clicked(item: TreeItem, column: int, id: int, mouse_button_index: int) -> void:
+ emit_signal("script_button_pressed", item.get_text(2))
+
+
+func _on_sample_template_toggled(toggled_on):
+ DialogueSettings.set_setting("new_with_template", toggled_on)
+
+
+func _on_revert_test_scene_pressed() -> void:
+ DialogueSettings.set_setting("custom_test_scene_path", _default_test_scene_path)
+ test_scene_path_input.placeholder_text = _default_test_scene_path
+ revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path
+
+
+func _on_load_test_scene_pressed() -> void:
+ path_target = PathTarget.CustomTestScene
+ custom_test_scene_file_dialog.popup_centered()
+
+
+func _on_custom_test_scene_file_dialog_file_selected(path: String) -> void:
+ match path_target:
+ PathTarget.CustomTestScene:
+ # Check that the test scene is a subclass of BaseDialogueTestScene
+ var test_scene: PackedScene = load(path)
+ if test_scene and test_scene.instantiate() is BaseDialogueTestScene:
+ DialogueSettings.set_setting("custom_test_scene_path", path)
+ test_scene_path_input.placeholder_text = path
+ revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path
+ else:
+ var accept: AcceptDialog = AcceptDialog.new()
+ accept.dialog_text = DialogueConstants.translate(&"settings.invalid_test_scene").format({ path = path })
+ add_child(accept)
+ accept.popup_centered.call_deferred()
+
+ PathTarget.Balloon:
+ DialogueSettings.set_setting("balloon_path", path)
+ balloon_path_input.placeholder_text = path
+ revert_balloon_button.visible = balloon_path_input.placeholder_text != ""
+
+
+func _on_ignore_missing_state_values_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("ignore_missing_state_values", toggled_on)
+
+
+func _on_default_csv_locale_text_changed(new_text: String) -> void:
+ DialogueSettings.set_setting("default_csv_locale", new_text)
+
+
+func _on_revert_balloon_path_pressed() -> void:
+ DialogueSettings.set_setting("balloon_path", "")
+ balloon_path_input.placeholder_text = DialogueConstants.translate(&"settings.default_balloon_path")
+ revert_balloon_button.visible = DialogueSettings.get_setting("balloon_path", "") != ""
+
+
+func _on_load_balloon_path_pressed() -> void:
+ path_target = PathTarget.Balloon
+ custom_test_scene_file_dialog.popup_centered()
+
+
+func _on_create_lines_for_response_characters_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("create_lines_for_responses_with_characters", toggled_on)
+
+
+func _on_open_in_external_editor_button_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_user_value("open_in_external_editor", toggled_on)
+
+
+func _on_include_characters_in_translations_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("include_character_in_translation_exports", toggled_on)
+
+
+func _on_include_notes_in_translations_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_setting("include_notes_in_translation_exports", toggled_on)
+
+
+func _on_keep_up_to_date_toggled(toggled_on: bool) -> void:
+ DialogueSettings.set_user_value("check_for_updates", toggled_on)
diff --git a/addons/dialogue_manager/views/settings_view.tscn b/addons/dialogue_manager/views/settings_view.tscn
new file mode 100644
index 00000000..09df0a27
--- /dev/null
+++ b/addons/dialogue_manager/views/settings_view.tscn
@@ -0,0 +1,221 @@
+[gd_scene load_steps=3 format=3 uid="uid://cpg4lg1r3ff6m"]
+
+[ext_resource type="Script" path="res://addons/dialogue_manager/views/settings_view.gd" id="1_06uxa"]
+
+[sub_resource type="Theme" id="Theme_3a8rc"]
+HSeparator/constants/separation = 20
+
+[node name="SettingsView" type="TabContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_right = -206.0
+offset_bottom = -345.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+theme = SubResource("Theme_3a8rc")
+current_tab = 2
+script = ExtResource("1_06uxa")
+
+[node name="Editor" type="VBoxContainer" parent="."]
+visible = false
+layout_mode = 2
+
+[node name="NewTemplateButton" type="CheckBox" parent="Editor"]
+layout_mode = 2
+button_pressed = true
+text = "New dialogue files will start with template text"
+
+[node name="MissingTranslationsHint" type="Label" parent="Editor"]
+modulate = Color(1, 1, 1, 0.501961)
+custom_minimum_size = Vector2(10, 0)
+layout_mode = 2
+text = "If you are using static translation keys then having this enabled will help you find any lines that you haven't added a key to yet."
+autowrap_mode = 3
+
+[node name="CharactersTranslationsButton" type="CheckBox" parent="Editor"]
+layout_mode = 2
+button_pressed = true
+text = "Export character names in translation files"
+
+[node name="WrapLinesButton" type="CheckBox" parent="Editor"]
+layout_mode = 2
+button_pressed = true
+text = "Wrap long lines"
+
+[node name="HSeparator" type="HSeparator" parent="Editor"]
+layout_mode = 2
+
+[node name="DefaultCSVLocaleLabel" type="Label" parent="Editor"]
+layout_mode = 2
+text = "Default CSV Locale"
+
+[node name="DefaultCSVLocale" type="LineEdit" parent="Editor"]
+layout_mode = 2
+
+[node name="Runtime" type="VBoxContainer" parent="."]
+visible = false
+layout_mode = 2
+
+[node name="IncludeAllResponsesButton" type="CheckBox" parent="Runtime"]
+layout_mode = 2
+text = "Include responses with failed conditions"
+
+[node name="IgnoreMissingStateValues" type="CheckBox" parent="Runtime"]
+layout_mode = 2
+text = "Skip over missing state value errors (not recommended)"
+
+[node name="HSeparator" type="HSeparator" parent="Runtime"]
+layout_mode = 2
+
+[node name="CustomBalloonLabel" type="Label" parent="Runtime"]
+layout_mode = 2
+text = "Custom balloon to use when calling \"DialogueManager.show_balloon()\""
+
+[node name="CustomBalloon" type="HBoxContainer" parent="Runtime"]
+layout_mode = 2
+
+[node name="BalloonPath" type="LineEdit" parent="Runtime/CustomBalloon"]
+layout_mode = 2
+size_flags_horizontal = 3
+focus_mode = 0
+editable = false
+shortcut_keys_enabled = false
+middle_mouse_paste_enabled = false
+
+[node name="RevertBalloonPath" type="Button" parent="Runtime/CustomBalloon"]
+visible = false
+layout_mode = 2
+tooltip_text = "Revert to default test scene"
+flat = true
+
+[node name="LoadBalloonPath" type="Button" parent="Runtime/CustomBalloon"]
+layout_mode = 2
+
+[node name="HSeparator2" type="HSeparator" parent="Runtime"]
+layout_mode = 2
+
+[node name="StatesTitle" type="Label" parent="Runtime"]
+layout_mode = 2
+text = "State Shortcuts"
+
+[node name="StatesMessage" type="Label" parent="Runtime"]
+layout_mode = 2
+text = "If an autoload is enabled here you can refer to its properties and methods without having to use its name."
+
+[node name="StatesHint" type="Label" parent="Runtime"]
+modulate = Color(1, 1, 1, 0.501961)
+custom_minimum_size = Vector2(10, 0)
+layout_mode = 2
+text = "ie. Instead of \"SomeState.some_property\" you could just use \"some_property\""
+autowrap_mode = 3
+
+[node name="GlobalsList" type="Tree" parent="Runtime"]
+layout_mode = 2
+size_flags_vertical = 3
+columns = 3
+column_titles_visible = true
+allow_reselect = true
+hide_folding = true
+hide_root = true
+select_mode = 1
+
+[node name="Advanced" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="CheckForUpdates" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Check for updates"
+
+[node name="HSeparator" type="HSeparator" parent="Advanced"]
+layout_mode = 2
+
+[node name="IncludeCharactersInTranslations" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Include character names in translation exports"
+
+[node name="IncludeNotesInTranslations" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Include notes (## comments) in translation exports"
+
+[node name="ExternalSeparator" type="HSeparator" parent="Advanced"]
+layout_mode = 2
+
+[node name="OpenInExternalEditorButton" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Open dialogue files in external editor"
+
+[node name="ExternalWarning" type="Label" parent="Advanced"]
+layout_mode = 2
+text = "Note: Syntax highlighting and detailed error checking are not supported in external editors."
+
+[node name="HSeparator3" type="HSeparator" parent="Advanced"]
+layout_mode = 2
+
+[node name="CustomTestSceneLabel" type="Label" parent="Advanced"]
+layout_mode = 2
+text = "Custom test scene (must extend BaseDialogueTestScene)"
+
+[node name="CustomTestScene" type="HBoxContainer" parent="Advanced"]
+layout_mode = 2
+
+[node name="TestScenePath" type="LineEdit" parent="Advanced/CustomTestScene"]
+layout_mode = 2
+size_flags_horizontal = 3
+focus_mode = 0
+placeholder_text = "res://addons/dialogue_manager/test_scene.tscn"
+editable = false
+shortcut_keys_enabled = false
+middle_mouse_paste_enabled = false
+
+[node name="RevertTestScene" type="Button" parent="Advanced/CustomTestScene"]
+visible = false
+layout_mode = 2
+tooltip_text = "Revert to default test scene"
+flat = true
+
+[node name="LoadTestScene" type="Button" parent="Advanced/CustomTestScene"]
+layout_mode = 2
+
+[node name="HSeparator4" type="HSeparator" parent="Advanced"]
+layout_mode = 2
+
+[node name="RecompileWarning" type="Label" parent="Advanced"]
+layout_mode = 2
+text = "Changing these settings will force a recompile of all dialogue. Only change them if you know what you are doing."
+
+[node name="MissingTranslationsButton" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Treat missing translation keys as errors"
+
+[node name="CreateLinesForResponseCharacters" type="CheckBox" parent="Advanced"]
+layout_mode = 2
+text = "Create child dialogue line for responses with character names in them"
+
+[node name="CustomTestSceneFileDialog" type="FileDialog" parent="."]
+title = "Open a File"
+ok_button_text = "Open"
+file_mode = 0
+filters = PackedStringArray("*.tscn ; Scene")
+
+[connection signal="toggled" from="Editor/NewTemplateButton" to="." method="_on_sample_template_toggled"]
+[connection signal="toggled" from="Editor/CharactersTranslationsButton" to="." method="_on_characters_translations_button_toggled"]
+[connection signal="toggled" from="Editor/WrapLinesButton" to="." method="_on_wrap_lines_button_toggled"]
+[connection signal="text_changed" from="Editor/DefaultCSVLocale" to="." method="_on_default_csv_locale_text_changed"]
+[connection signal="toggled" from="Runtime/IncludeAllResponsesButton" to="." method="_on_include_all_responses_button_toggled"]
+[connection signal="toggled" from="Runtime/IgnoreMissingStateValues" to="." method="_on_ignore_missing_state_values_toggled"]
+[connection signal="pressed" from="Runtime/CustomBalloon/RevertBalloonPath" to="." method="_on_revert_balloon_path_pressed"]
+[connection signal="pressed" from="Runtime/CustomBalloon/LoadBalloonPath" to="." method="_on_load_balloon_path_pressed"]
+[connection signal="button_clicked" from="Runtime/GlobalsList" to="." method="_on_globals_list_button_clicked"]
+[connection signal="item_selected" from="Runtime/GlobalsList" to="." method="_on_globals_list_item_selected"]
+[connection signal="toggled" from="Advanced/CheckForUpdates" to="." method="_on_keep_up_to_date_toggled"]
+[connection signal="toggled" from="Advanced/IncludeCharactersInTranslations" to="." method="_on_include_characters_in_translations_toggled"]
+[connection signal="toggled" from="Advanced/IncludeNotesInTranslations" to="." method="_on_include_notes_in_translations_toggled"]
+[connection signal="toggled" from="Advanced/OpenInExternalEditorButton" to="." method="_on_open_in_external_editor_button_toggled"]
+[connection signal="pressed" from="Advanced/CustomTestScene/RevertTestScene" to="." method="_on_revert_test_scene_pressed"]
+[connection signal="pressed" from="Advanced/CustomTestScene/LoadTestScene" to="." method="_on_load_test_scene_pressed"]
+[connection signal="toggled" from="Advanced/MissingTranslationsButton" to="." method="_on_missing_translations_button_toggled"]
+[connection signal="toggled" from="Advanced/CreateLinesForResponseCharacters" to="." method="_on_create_lines_for_response_characters_toggled"]
+[connection signal="file_selected" from="CustomTestSceneFileDialog" to="." method="_on_custom_test_scene_file_dialog_file_selected"]
diff --git a/demos/0. introduction/introduction.tscn b/demos/0. introduction/introduction.tscn
new file mode 100644
index 00000000..4dca7cba
--- /dev/null
+++ b/demos/0. introduction/introduction.tscn
@@ -0,0 +1,49 @@
+[gd_scene format=3 uid="uid://debwoww4x115m"]
+
+[node name="IntroductionDemoMarginContainer" type="CenterContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="IntroductionDemoVBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="IntroductionDemoExaplanationPanelContainer" type="PanelContainer" parent="IntroductionDemoVBoxContainer"]
+layout_mode = 2
+
+[node name="IntroductionDemoExaplanationLabel" type="Label" parent="IntroductionDemoVBoxContainer/IntroductionDemoExaplanationPanelContainer"]
+layout_mode = 2
+text = "Basic dialogue processing is composed of two parts:"
+horizontal_alignment = 1
+
+[node name="IntroductionDemoPreparationLabel" type="Label" parent="IntroductionDemoVBoxContainer"]
+layout_mode = 2
+text = "1. Dialogue tree preparation:"
+
+[node name="IntroductionDemoPreparationExampleCodeEdit" type="CodeEdit" parent="IntroductionDemoVBoxContainer"]
+layout_mode = 2
+theme_override_colors/font_readonly_color = Color(1, 1, 1, 1)
+text = "extends DialogueEngine
+
+func _setup() -> void:
+ add_text_entry(\"First dialogue message\")
+ add_text_entry(\"Second dialogue message\")
+ add_text_entry(\"Third dialogue message\")
+ # and so on"
+editable = false
+scroll_fit_content_height = true
+symbol_lookup_on_click = true
+auto_brace_completion_highlight_matching = true
+
+[node name="IntroductionDemoTraversalLabel" type="Label" parent="IntroductionDemoVBoxContainer"]
+layout_mode = 2
+text = "2. And tree traversal:"
+
+[node name="IntroductionDemoTraversalExamplePanelContainer" type="PanelContainer" parent="IntroductionDemoVBoxContainer"]
+layout_mode = 2
+
+[node name="IntroductionDemoTraversalExampleLabel" type="Label" parent="IntroductionDemoVBoxContainer/IntroductionDemoTraversalExamplePanelContainer"]
+layout_mode = 2
+text = "dialogue_engine.advance() # called when convenient"
diff --git a/demos/1. simple dialogue/simple_dialogue.gd b/demos/1. simple dialogue/simple_dialogue.gd
new file mode 100644
index 00000000..74cb4d73
--- /dev/null
+++ b/demos/1. simple dialogue/simple_dialogue.gd
@@ -0,0 +1,8 @@
+extends DialogueEngine
+
+func _setup() -> void:
+ add_text_entry("Hey...")
+ add_text_entry("[i]Have [i][b]you[/b][/i] seen the code for this sample?[/i]")
+ add_text_entry("[rainbow freq=1.0 sat=0.4 val=0.8]It's beautiful![/rainbow]")
+ add_text_entry("[i][shake rate=20.0 level=5 connected=1]You won't believe it![/shake][/i]")
+ add_text_entry("[code][i]Press or to exit.[/i][/code]")
diff --git a/demos/1. simple dialogue/simple_log.gd b/demos/1. simple dialogue/simple_log.gd
new file mode 100644
index 00000000..4e39b835
--- /dev/null
+++ b/demos/1. simple dialogue/simple_log.gd
@@ -0,0 +1,33 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
diff --git a/demos/1. simple dialogue/simple_log.tscn b/demos/1. simple dialogue/simple_log.tscn
new file mode 100644
index 00000000..2e9028b8
--- /dev/null
+++ b/demos/1. simple dialogue/simple_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://bl1ul621fkvxw"]
+
+[ext_resource type="Script" path="res://demos/1. simple dialogue/simple_log.gd" id="1_km05v"]
+[ext_resource type="Script" path="res://demos/1. simple dialogue/simple_dialogue.gd" id="2_clmvy"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_km05v")
+dialogue_gdscript = ExtResource("2_clmvy")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/10. dynamic save and load/dynamic_save_dialogue.gd b/demos/10. dynamic save and load/dynamic_save_dialogue.gd
new file mode 100644
index 00000000..f1555646
--- /dev/null
+++ b/demos/10. dynamic save and load/dynamic_save_dialogue.gd
@@ -0,0 +1,56 @@
+extends DialogueEngine
+
+
+const SAVE_PATH : String = "user://save.dat"
+var counter : int = 0
+var start_counting_id : int = 0
+var log_history : Array = []
+
+
+func _setup() -> void:
+ add_text_entry("This is an example of an infinite dynamically generated/saved/loaded dialogue.")
+ add_text_entry("You can save the dialogue progress at any time by clicking the save button above.")
+ add_text_entry("And when you restart this scene, the dialogue will continue from where it left off.")
+ add_text_entry("As the dialogue progresses, the graph in the debugger will update automatically as well.")
+ add_text_entry("Let's count to infinity!!")
+ dialogue_continued.connect(__log_history)
+ dialogue_about_to_finish.connect(__continue_counting)
+
+ # Load previous state if any
+ load_state()
+
+
+func __log_history(p_dialogue_entry : DialogueEntry) -> void:
+ # Always track the log history:
+ log_history.push_back(p_dialogue_entry.get_formatted_text())
+
+func __continue_counting() -> void:
+ counter += 1
+ add_text_entry(str(counter))
+
+
+func save_state() -> void:
+ var file_handle: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
+ file_handle.store_var(counter)
+ file_handle.store_var(get_current_entry().get_id())
+ file_handle.store_var(log_history)
+ print("State Saved")
+
+
+func load_state() -> void:
+ if FileAccess.file_exists(SAVE_PATH):
+ var file_handle: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.READ)
+ counter = file_handle.get_var()
+ var entry_id : int = file_handle.get_var()
+ if has_entry_id(entry_id):
+ set_current_entry(entry_id)
+ else:
+ set_current_entry(add_text_entry("Let's continue counting!!").get_id())
+ log_history = file_handle.get_var()
+ print("State Loaded")
+
+
+func clear_state() -> void:
+ if FileAccess.file_exists(SAVE_PATH):
+ DirAccess.remove_absolute(SAVE_PATH)
+ print("State Cleared")
diff --git a/demos/10. dynamic save and load/dynamic_save_load.gd b/demos/10. dynamic save and load/dynamic_save_load.gd
new file mode 100644
index 00000000..849c1e74
--- /dev/null
+++ b/demos/10. dynamic save and load/dynamic_save_load.gd
@@ -0,0 +1,140 @@
+extends Control
+
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+@onready var dialogue : VBoxContainer = $VBox/Dialogue
+@onready var history : CenterContainer = $History
+@onready var history_log : VBoxContainer = $History/Panel/Margin/HistoryLog
+@onready var animator : AnimationPlayer = $Animator
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ if animator.is_playing():
+ # Player is inpatient -- auto-advance the text
+ var animation_name : StringName = animator.get_current_animation()
+ var animation : Animation = animator.get_animation(animation_name)
+ animator.advance(animation.get_length()) # this will fire the animation_finished signal automatically else:
+ else:
+ # Advance current entry
+ dialogue_engine.advance()
+ accept_event() # accepting input event here to stop it from traversing into into buttons possibly added through the interaction
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ # Add the text to the log:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ if p_dialogue_entry.has_metadata("author"):
+ var author : String = p_dialogue_entry.get_metadata("author")
+ label.set_text(" > " + author + ": " + p_dialogue_entry.get_formatted_text())
+ else:
+ label.set_text(" > " + p_dialogue_entry.get_formatted_text())
+ dialogue.add_child(label, true)
+
+ # Setup the animation:
+ animator.stop(true) # internally some timers do not reset properly unless we do this
+ if not animator.has_animation_library(&"demo"):
+ var new_animation_library : AnimationLibrary = AnimationLibrary.new()
+ animator.add_animation_library(&"demo", new_animation_library)
+ var animation_library : AnimationLibrary = animator.get_animation_library(&"demo")
+ var animation : Animation = create_visible_characters_animation_per_character(label.get_text(), 0.045, true)
+ animator.set_root_node(label.get_path())
+ animation_library.add_animation(&"dialogue", animation)
+ animator.play(&"demo/dialogue")
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
+
+func __on_animation_started(p_animation_name : StringName) -> void:
+ print("Animation started:", p_animation_name)
+
+
+func create_visible_characters_animation_per_character(p_text : String, p_time_per_character : float, p_instant_first_character : bool = false, p_time_whitespace : bool = false) -> Animation:
+ # Do initial calculations
+ var whitespace_regex : RegEx
+ if not p_time_whitespace or p_instant_first_character:
+ whitespace_regex = RegEx.new()
+ whitespace_regex.compile("\\s")
+
+ # Create animation and track
+ var animation : Animation = Animation.new()
+ var track_index : int = animation.add_track(Animation.TYPE_VALUE)
+ animation.track_set_path(track_index, ".:visible_characters")
+ animation.track_set_interpolation_type(track_index, Animation.INTERPOLATION_LINEAR)
+
+ # Configure keys
+ var total_time : float = 0.0
+ var total_visible_characters : int = 0
+ var whitespace_time_offset : float = 0.0
+ animation.track_insert_key(track_index, total_time, 0)
+ var total_animation_length : float = 0.0
+ for character : String in p_text:
+ total_time += p_time_per_character
+ total_visible_characters += 1
+ if not p_time_whitespace and whitespace_regex.sub(character, "", true).is_empty():
+ whitespace_time_offset += p_time_per_character
+ continue
+ total_animation_length = total_time - whitespace_time_offset
+ animation.track_insert_key(track_index, total_animation_length, total_visible_characters)
+ animation.set_length(total_animation_length)
+
+ if p_instant_first_character:
+ if animation.track_get_key_count(track_index) > 0:
+ # Shift all the keys back in time according to the time it took per character
+ for key_index : int in animation.track_get_key_count(track_index):
+ var key_time : float = animation.track_get_key_time(track_index, key_index)
+ animation.track_set_key_time(track_index, key_index, key_time - p_time_per_character)
+ animation.set_length(total_animation_length - p_time_per_character)
+ return animation
+
+
+func _on_history_log_toggled(p_should_be_visible : bool) -> void:
+ # Free all previous history labels
+ for child: Node in history_log.get_children():
+ child.queue_free()
+
+ history.visible = p_should_be_visible
+
+ if p_should_be_visible:
+ @warning_ignore("unsafe_method_access")
+ var log_history : Array = dialogue_engine.get_log_history()
+ for text : String in log_history:
+ var label: Label = Label.new()
+ label.text = text
+ history_log.add_child(label)
+
+
+func _on_save_pressed() -> void:
+ @warning_ignore("unsafe_method_access")
+ dialogue_engine.save_state()
+ get_tree().quit()
+
+
+func _on_clear_pressed() -> void:
+ @warning_ignore("unsafe_method_access")
+ dialogue_engine.clear_state()
+ get_tree().quit()
diff --git a/demos/10. dynamic save and load/dynamic_save_load.tscn b/demos/10. dynamic save and load/dynamic_save_load.tscn
new file mode 100644
index 00000000..4c5d4589
--- /dev/null
+++ b/demos/10. dynamic save and load/dynamic_save_load.tscn
@@ -0,0 +1,84 @@
+[gd_scene load_steps=3 format=3 uid="uid://b0gsgxefiut4g"]
+
+[ext_resource type="Script" path="res://demos/10. dynamic save and load/dynamic_save_load.gd" id="1_75aob"]
+[ext_resource type="Script" path="res://demos/10. dynamic save and load/dynamic_save_dialogue.gd" id="2_lqhmn"]
+
+[node name="DynamicSaveLoad" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_75aob")
+dialogue_gdscript = ExtResource("2_lqhmn")
+
+[node name="VBox" type="VBoxContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="VBox"]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="VBox/DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="Buttons" type="HBoxContainer" parent="VBox"]
+layout_mode = 2
+
+[node name="Save" type="Button" parent="VBox/Buttons"]
+layout_mode = 2
+text = "Save and Quit"
+
+[node name="Clear" type="Button" parent="VBox/Buttons"]
+layout_mode = 2
+text = "Clear State and Quit"
+
+[node name="HistoryLog" type="Button" parent="VBox/Buttons"]
+layout_mode = 2
+toggle_mode = true
+text = "History Log"
+
+[node name="Dialogue" type="VBoxContainer" parent="VBox"]
+layout_mode = 2
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="VBox/Dialogue"]
+layout_mode = 2
+text = "Log:"
+fit_content = true
+
+[node name="History" type="CenterContainer" parent="."]
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="Panel" type="PanelContainer" parent="History"]
+layout_mode = 2
+
+[node name="Margin" type="MarginContainer" parent="History/Panel"]
+layout_mode = 2
+theme_override_constants/margin_left = 16
+theme_override_constants/margin_top = 16
+theme_override_constants/margin_right = 16
+theme_override_constants/margin_bottom = 16
+
+[node name="HistoryLog" type="VBoxContainer" parent="History/Panel/Margin"]
+layout_mode = 2
+
+[node name="Animator" type="AnimationPlayer" parent="."]
+root_node = NodePath("../VBox")
+
+[connection signal="pressed" from="VBox/Buttons/Save" to="." method="_on_save_pressed"]
+[connection signal="pressed" from="VBox/Buttons/Clear" to="." method="_on_clear_pressed"]
+[connection signal="toggled" from="VBox/Buttons/HistoryLog" to="." method="_on_history_log_toggled"]
diff --git a/demos/11. timed options/timed_options.gd b/demos/11. timed options/timed_options.gd
new file mode 100644
index 00000000..04998007
--- /dev/null
+++ b/demos/11. timed options/timed_options.gd
@@ -0,0 +1,107 @@
+extends VBoxContainer
+
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+@onready var progress_bar : ProgressBar = $ProgressBar
+@onready var vbox : VBoxContainer = $VBox
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+ accept_event() # to avoid hidding an button due to the input event travelling through the children
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+var enabled_buttons : Array[Button] = []
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ vbox.add_child(label)
+
+ if p_dialogue_entry.has_options():
+ var dont_show_options : Array = p_dialogue_entry.get_metadata("dont_show_options", [])
+ for option_id : int in range(0, p_dialogue_entry.get_option_count()):
+ if option_id in dont_show_options:
+ continue
+ var option_text : String = p_dialogue_entry.get_option_text(option_id)
+ var button : Button = Button.new()
+ button.set_text(option_text)
+ vbox.add_child(button)
+ var tween: Tween = create_tween()
+ if option_id == 0:
+ button.grab_focus()
+ tween.tween_property(button, "modulate", Color.TRANSPARENT, 3.0)
+ tween.tween_callback(button.hide)
+ else:
+ # Only show other buttons after the tween finishes
+ button.hide()
+ tween.tween_callback(button.show).set_delay(5.0)
+
+ if option_id == 1:
+ tween.tween_callback(button.grab_focus)
+ tween.tween_callback(progress_bar.show)
+ tween.tween_method(progress_bar.set_value, 1.0, 0.0, 2.0)
+
+ # The timer has just finished
+ tween.tween_callback(progress_bar.hide)
+ tween.tween_callback(advance_dialogue_no_answer)
+ button.pressed.connect(__advance_dialogue_with_chosen_option.bind(option_id))
+ enabled_buttons.push_back(button)
+ set_process_input(false)
+
+
+func advance_dialogue_no_answer() -> void:
+ for button : Button in enabled_buttons:
+ button.set_disabled(true)
+
+ var entry : DialogueEntry = dialogue_engine.get_current_entry()
+ var option_id : int = entry.get_metadata("auto_choose")
+ entry.choose_option(option_id)
+ dialogue_engine.advance()
+ set_process_input(true)
+
+
+func __advance_dialogue_with_chosen_option(p_option_id : int) -> void:
+ # Kill all tweens from processing further
+ for tween: Tween in get_tree().get_processed_tweens():
+ tween.kill()
+ for button : Button in enabled_buttons:
+ button.set_disabled(true)
+ # Reset modulate of vanishing button
+ button.modulate = Color.WHITE
+ enabled_buttons.clear()
+ progress_bar.hide()
+
+ var current_entry : DialogueEntry = dialogue_engine.get_current_entry()
+ current_entry.choose_option(p_option_id)
+ dialogue_engine.advance()
+
+ set_process_input(true)
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
diff --git a/demos/11. timed options/timed_options.tscn b/demos/11. timed options/timed_options.tscn
new file mode 100644
index 00000000..9e972b27
--- /dev/null
+++ b/demos/11. timed options/timed_options.tscn
@@ -0,0 +1,36 @@
+[gd_scene load_steps=3 format=3 uid="uid://ctlvurl3kobjf"]
+
+[ext_resource type="Script" path="res://demos/11. timed options/timed_options.gd" id="1_g0ote"]
+[ext_resource type="Script" path="res://demos/11. timed options/timed_options_dialogue.gd" id="2_gqlp8"]
+
+[node name="TimedOptions" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_g0ote")
+dialogue_gdscript = ExtResource("2_gqlp8")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="VBox" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="VBox"]
+layout_mode = 2
+text = "Log:"
+fit_content = true
+
+[node name="ProgressBar" type="ProgressBar" parent="."]
+visible = false
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+max_value = 1.0
+show_percentage = false
diff --git a/demos/11. timed options/timed_options_dialogue.gd b/demos/11. timed options/timed_options_dialogue.gd
new file mode 100644
index 00000000..eecf5d89
--- /dev/null
+++ b/demos/11. timed options/timed_options_dialogue.gd
@@ -0,0 +1,37 @@
+extends DialogueEngine
+
+
+enum {
+DEFAULT_TOPIC = 0, # this is the branch used by default unless set_branch_id() is used
+WATCH_THE_STORM,
+GO_BACK_TO_SLEEP,
+KEEP_WORKING,
+}
+
+
+func _setup() -> void:
+ var entry : DialogueEntry = add_text_entry("The storm rages right outside the window. I should...")
+ var option_id_1 : int = entry.add_option("Wait for storm to finish.")
+ var option_id_2 : int = entry.add_option("Go back to sleep.")
+ var option_id_3 : int = entry.add_option("Get back to work.")
+ var option_id_4 : int = entry.add_option("Hidden option -- this should not be shown on the UI")
+ entry.set_metadata("dont_show_options", [option_id_4])
+ entry.set_metadata("auto_choose", option_id_4)
+
+ var option_id_2_entry : DialogueEntry = add_text_entry("That's right, sleep is for the strong 💪.", GO_BACK_TO_SLEEP)
+ entry.set_option_goto_id(option_id_2, option_id_2_entry.get_id())
+
+ var option_id_3_entry : DialogueEntry = add_text_entry("That's right, let's get back to work 🫡", KEEP_WORKING)
+ entry.set_option_goto_id(option_id_3, option_id_3_entry.get_id())
+
+ var option_id_4_entry : DialogueEntry = add_text_entry("I think I'll enjoy watching the storm for a bit...", WATCH_THE_STORM)
+ entry.set_option_goto_id(option_id_4, option_id_4_entry.get_id())
+
+ # Join branches into the default topic (i.e. branch id 0)
+ var default_topic : DialogueEntry = add_text_entry("Some time passes...")
+ entry.set_option_goto_id(option_id_1, default_topic.get_id())
+ option_id_2_entry.set_goto_id(default_topic.get_id())
+ option_id_3_entry.set_goto_id(default_topic.get_id())
+ option_id_4_entry.set_goto_id(default_topic.get_id())
+
+ add_text_entry("")
diff --git a/demos/2. same branch goto/same_branch_goto_dialogue.gd b/demos/2. same branch goto/same_branch_goto_dialogue.gd
new file mode 100644
index 00000000..284958b0
--- /dev/null
+++ b/demos/2. same branch goto/same_branch_goto_dialogue.gd
@@ -0,0 +1,10 @@
+extends DialogueEngine
+
+
+func _setup() -> void:
+ var first_entry : DialogueEntry = add_text_entry("This is an example of...")
+ add_text_entry("This text will be shown on the debugger connected to branch ID 0")
+ add_text_entry("This text will be shown on the debugger as a separate graph node not connected to branch id 0", 1)
+ var first_entry_goto : DialogueEntry = add_text_entry("a skipped dialogue! Check the debugger out!")
+ first_entry.set_goto_id(first_entry_goto.get_id())
+ add_text_entry("Press or to exit.")
diff --git a/demos/2. same branch goto/same_branch_goto_log.gd b/demos/2. same branch goto/same_branch_goto_log.gd
new file mode 100644
index 00000000..c5896b5e
--- /dev/null
+++ b/demos/2. same branch goto/same_branch_goto_log.gd
@@ -0,0 +1,27 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+
+
+var enabled_buttons : Array[Button] = []
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+func __on_dialogue_finished() -> void:
+ get_tree().quit()
diff --git a/demos/2. same branch goto/same_branch_goto_log.tscn b/demos/2. same branch goto/same_branch_goto_log.tscn
new file mode 100644
index 00000000..c4069855
--- /dev/null
+++ b/demos/2. same branch goto/same_branch_goto_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://dkfjywgk5gykn"]
+
+[ext_resource type="Script" path="res://demos/2. same branch goto/same_branch_goto_log.gd" id="1_biw57"]
+[ext_resource type="Script" path="res://demos/2. same branch goto/same_branch_goto_dialogue.gd" id="2_bn40h"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_biw57")
+dialogue_gdscript = ExtResource("2_bn40h")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/3. different branch goto/different_branch_goto_dialogue.gd b/demos/3. different branch goto/different_branch_goto_dialogue.gd
new file mode 100644
index 00000000..a3860521
--- /dev/null
+++ b/demos/3. different branch goto/different_branch_goto_dialogue.gd
@@ -0,0 +1,16 @@
+extends DialogueEngine
+
+enum { DEFAULT_BRANCH = 0, DIFFERENT_BRANCH_ONE, DIFFERENT_BRANCH_TWO, DIFFERENT_BRANCH_THREE }
+
+
+func _setup() -> void:
+ var first_entry : DialogueEntry = add_text_entry("This is an example of...", DEFAULT_BRANCH)
+ first_entry.set_goto_id(add_text_entry("how gotos work against different branch IDs", DIFFERENT_BRANCH_TWO).get_id())
+ add_text_entry("Once you jump to a different branch ID, the DialogueEngine will only consider entries in that branch ID unless you jump to a different one.", DIFFERENT_BRANCH_TWO)
+ add_text_entry("If, for example, you add another text entry to a branch ID that is empty, it will show up in Debugger/DialogueEngine as such.", DIFFERENT_BRANCH_TWO)
+ add_text_entry("For example, this text will be shown on branch ID %d in the debugger and not connected to anything. It won't show up in the interaction either." % DIFFERENT_BRANCH_ONE, DIFFERENT_BRANCH_ONE)
+ add_text_entry("You can also create full branches in a different branch ID", DIFFERENT_BRANCH_THREE)
+ add_text_entry("But since there's no jump to this branch (i.e. no goto set to this branch ID)", DIFFERENT_BRANCH_THREE)
+ add_text_entry("It won't show up in the interaction", DIFFERENT_BRANCH_THREE)
+ add_text_entry("See the auto-generated graph in Debugger/DialogueEngine.", DIFFERENT_BRANCH_TWO)
+ add_text_entry("Press or to exit.", DIFFERENT_BRANCH_TWO)
diff --git a/demos/3. different branch goto/different_branch_goto_log.gd b/demos/3. different branch goto/different_branch_goto_log.gd
new file mode 100644
index 00000000..c5896b5e
--- /dev/null
+++ b/demos/3. different branch goto/different_branch_goto_log.gd
@@ -0,0 +1,27 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+
+
+var enabled_buttons : Array[Button] = []
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+func __on_dialogue_finished() -> void:
+ get_tree().quit()
diff --git a/demos/3. different branch goto/different_branch_goto_log.tscn b/demos/3. different branch goto/different_branch_goto_log.tscn
new file mode 100644
index 00000000..6c556b1d
--- /dev/null
+++ b/demos/3. different branch goto/different_branch_goto_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://clxd85by8v506"]
+
+[ext_resource type="Script" path="res://demos/3. different branch goto/different_branch_goto_log.gd" id="1_et54s"]
+[ext_resource type="Script" path="res://demos/3. different branch goto/different_branch_goto_dialogue.gd" id="2_x1mbj"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_et54s")
+dialogue_gdscript = ExtResource("2_x1mbj")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/4. branching condition/branching_condition_dialogue.gd b/demos/4. branching condition/branching_condition_dialogue.gd
new file mode 100644
index 00000000..77017ccb
--- /dev/null
+++ b/demos/4. branching condition/branching_condition_dialogue.gd
@@ -0,0 +1,21 @@
+extends DialogueEngine
+
+var have_we_talked_before : bool = false
+
+enum branch {
+ STRANGERS,
+ ACQUAINTANCES,
+}
+
+func __have_we_talked_before() -> bool:
+ return have_we_talked_before
+
+func _setup() -> void:
+ add_text_entry("Hello!")
+ var condition_entry : DialogueEntry = add_conditional_entry(__have_we_talked_before)
+ var if_true : DialogueEntry = add_text_entry("Hey! We meet again!", branch.STRANGERS)
+ var if_false : DialogueEntry = add_text_entry("It's nice to meet you!", branch.ACQUAINTANCES)
+ condition_entry.set_condition_goto_ids(if_true.get_id(), if_false.get_id())
+ add_text_entry("")
+
+ dialogue_finished.connect(func() -> void: have_we_talked_before = true)
diff --git a/demos/4. branching condition/branching_condition_log.gd b/demos/4. branching condition/branching_condition_log.gd
new file mode 100644
index 00000000..df02e864
--- /dev/null
+++ b/demos/4. branching condition/branching_condition_log.gd
@@ -0,0 +1,38 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+var dialogue_finished_count : int = 0
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+
+
+var enabled_buttons : Array[Button] = []
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+
+func __on_dialogue_finished() -> void:
+ if dialogue_finished_count > 0:
+ get_tree().quit()
+ return
+ dialogue_finished_count += 1
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_fit_content(true)
+ label.set_text("")
+ add_child(label)
+
+
diff --git a/demos/4. branching condition/branching_condition_log.tscn b/demos/4. branching condition/branching_condition_log.tscn
new file mode 100644
index 00000000..af3a98cf
--- /dev/null
+++ b/demos/4. branching condition/branching_condition_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://bl1d3ktmm0s7f"]
+
+[ext_resource type="Script" path="res://demos/4. branching condition/branching_condition_log.gd" id="1_7x7u2"]
+[ext_resource type="Script" path="res://demos/4. branching condition/branching_condition_dialogue.gd" id="2_yhcl2"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_7x7u2")
+dialogue_gdscript = ExtResource("2_yhcl2")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/5. branching options dialogue/branching_options_dialogue.gd b/demos/5. branching options dialogue/branching_options_dialogue.gd
new file mode 100644
index 00000000..efb45231
--- /dev/null
+++ b/demos/5. branching options dialogue/branching_options_dialogue.gd
@@ -0,0 +1,31 @@
+extends DialogueEngine
+
+
+enum {
+DEFAULT_TOPIC = 0, # this is the branch used by default unless set_branch_id() is used
+GO_BACK_TO_SLEEP = 1,
+KEEP_WORKING = 2
+}
+
+
+func _setup() -> void:
+ var entry : DialogueEntry = add_text_entry("The storm rages right outside the window. I should...")
+ var option_id_1 : int = entry.add_option("Go back to sleep.")
+ var option_id_2 : int = entry.add_option("Get back to work.")
+
+ var option_id_1_entry : DialogueEntry = add_text_entry("That's right, sleep is for the strong 💪.", GO_BACK_TO_SLEEP)
+ entry.set_option_goto_id(option_id_1, option_id_1_entry.get_id())
+
+ var option_id_2_entry : DialogueEntry = add_text_entry("That's right, let's get back to work 🫡", KEEP_WORKING)
+ entry.set_option_goto_id(option_id_2, option_id_2_entry.get_id())
+
+ # Join branches into the default topic (i.e. branch id 0)
+ var default_topic : DialogueEntry = add_text_entry("Some time passes...")
+ option_id_1_entry.set_goto_id(default_topic.get_id())
+ option_id_2_entry.set_goto_id(default_topic.get_id())
+
+ # None of the following entries will be connected on the graph and won't be shown when advancing the dialogue
+ add_text_entry("A sleep entry skipped due to missing goto against this entry.", GO_BACK_TO_SLEEP)
+ add_text_entry("A working entry due to missing goto against this entry.", KEEP_WORKING)
+
+ add_text_entry("")
diff --git a/demos/5. branching options dialogue/branching_options_dialogue_log.gd b/demos/5. branching options dialogue/branching_options_dialogue_log.gd
new file mode 100644
index 00000000..a9d216f1
--- /dev/null
+++ b/demos/5. branching options dialogue/branching_options_dialogue_log.gd
@@ -0,0 +1,65 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+ accept_event() # to avoid hidding an button due to the input event travelling through the children
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+var enabled_buttons : Array[Button] = []
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+ if p_dialogue_entry.has_options():
+ for option_id : int in range(0, p_dialogue_entry.get_option_count()):
+ var option_text : String = p_dialogue_entry.get_option_text(option_id)
+ var button : Button = Button.new()
+ button.set_text(option_text)
+ add_child(button)
+ if option_id == 0:
+ button.grab_focus()
+ button.pressed.connect(__advance_dialogue_with_chosen_option.bind(option_id))
+ enabled_buttons.push_back(button)
+ set_process_input(false)
+
+
+func __advance_dialogue_with_chosen_option(p_option_id : int) -> void:
+ for button : Button in enabled_buttons:
+ button.set_disabled(true)
+ enabled_buttons.clear()
+
+ var current_entry : DialogueEntry = dialogue_engine.get_current_entry()
+ current_entry.choose_option(p_option_id)
+ dialogue_engine.advance()
+
+ set_process_input(true)
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
diff --git a/demos/5. branching options dialogue/branching_options_dialogue_log.tscn b/demos/5. branching options dialogue/branching_options_dialogue_log.tscn
new file mode 100644
index 00000000..225a2dcf
--- /dev/null
+++ b/demos/5. branching options dialogue/branching_options_dialogue_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://c1803quu2vtn"]
+
+[ext_resource type="Script" path="res://demos/5. branching options dialogue/branching_options_dialogue_log.gd" id="1_0yldt"]
+[ext_resource type="Script" path="res://demos/5. branching options dialogue/branching_options_dialogue.gd" id="2_h7u25"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_0yldt")
+dialogue_gdscript = ExtResource("2_h7u25")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/6. handling metadata/handling_metadata_dialogue.gd b/demos/6. handling metadata/handling_metadata_dialogue.gd
new file mode 100644
index 00000000..036e0a58
--- /dev/null
+++ b/demos/6. handling metadata/handling_metadata_dialogue.gd
@@ -0,0 +1,7 @@
+extends DialogueEngine
+
+func _setup() -> void:
+ # Use DialogueEntry.set_metadata for data that must be available through DialogueEngine.dialogue_continued signal.
+ # The metadata handling per DialogueEntry must be implemented by the user.
+ add_text_entry("[i]We won! Let's goooo!![/i]").set_metadata("author", "Gary")
+ add_text_entry("Press or to exit.")
diff --git a/demos/6. handling metadata/handling_metadata_log.gd b/demos/6. handling metadata/handling_metadata_log.gd
new file mode 100644
index 00000000..1ca0fdd9
--- /dev/null
+++ b/demos/6. handling metadata/handling_metadata_log.gd
@@ -0,0 +1,43 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ if p_dialogue_entry.has_metadata("author"):
+ var author : String = p_dialogue_entry.get_metadata("author")
+ label.set_text(" > " + author + ": " + p_dialogue_entry.get_text())
+ else:
+ label.set_text(" > " + p_dialogue_entry.get_text())
+ add_child(label)
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
diff --git a/demos/6. handling metadata/handling_metadata_log.tscn b/demos/6. handling metadata/handling_metadata_log.tscn
new file mode 100644
index 00000000..3929cf01
--- /dev/null
+++ b/demos/6. handling metadata/handling_metadata_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://jeupskd3oea8"]
+
+[ext_resource type="Script" path="res://demos/6. handling metadata/handling_metadata_log.gd" id="1_0ot0q"]
+[ext_resource type="Script" path="res://demos/6. handling metadata/handling_metadata_dialogue.gd" id="2_tq44t"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_0ot0q")
+dialogue_gdscript = ExtResource("2_tq44t")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/7. handling input/handling_input_dialogue.gd b/demos/7. handling input/handling_input_dialogue.gd
new file mode 100644
index 00000000..5d8da1f4
--- /dev/null
+++ b/demos/7. handling input/handling_input_dialogue.gd
@@ -0,0 +1,8 @@
+extends DialogueEngine
+
+var player_name : String # will be set by the UI code
+
+func _setup() -> void:
+ add_text_entry("Welcome adventurer. May I know you name?").set_metadata(&"get_player_name", "The UI code will act accordingly and inject player_name into DialogueEngine.")
+ add_text_entry("The legendary {player_name}!? Please, follow me this way. I will personally show you our guild.").set_format({"player_name" : get.bind("player_name")}, DialogueEntry.FORMAT_FUNCTION)
+ add_text_entry("Press or to exit.")
diff --git a/demos/7. handling input/handling_input_log.gd b/demos/7. handling input/handling_input_log.gd
new file mode 100644
index 00000000..2762765a
--- /dev/null
+++ b/demos/7. handling input/handling_input_log.gd
@@ -0,0 +1,64 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ dialogue_engine.advance()
+ accept_event()
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ if p_dialogue_entry.has_metadata("author"):
+ var author : String = p_dialogue_entry.get_metadata("author")
+ label.set_text(" > " + author + ": " + p_dialogue_entry.get_formatted_text())
+ else:
+ label.set_text(" > " + p_dialogue_entry.get_formatted_text())
+ add_child(label)
+
+ if p_dialogue_entry.has_metadata(&"get_player_name"):
+ __get_player_name()
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
+# Must return player name to update the variable within DialogueEngine
+func __get_player_name() -> void:
+ var line_edit : LineEdit = LineEdit.new()
+ add_child(line_edit)
+ var p_data : Array = []
+ line_edit.text_submitted.connect(func(text : String) -> void:
+ p_data.push_back(text)
+ )
+ line_edit.grab_focus()
+ line_edit.set_placeholder("Enter your name.")
+ set_process_input(false)
+ await line_edit.text_submitted
+ line_edit.set_editable(false)
+ @warning_ignore("unsafe_property_access")
+ dialogue_engine.player_name = p_data[0]
+ set_process_input(true)
+ dialogue_engine.advance()
diff --git a/demos/7. handling input/handling_input_log.tscn b/demos/7. handling input/handling_input_log.tscn
new file mode 100644
index 00000000..c78215c7
--- /dev/null
+++ b/demos/7. handling input/handling_input_log.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://cc22y2sa14se6"]
+
+[ext_resource type="Script" path="res://demos/7. handling input/handling_input_log.gd" id="1_4r4r1"]
+[ext_resource type="Script" path="res://demos/7. handling input/handling_input_dialogue.gd" id="2_ti25x"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_4r4r1")
+dialogue_gdscript = ExtResource("2_ti25x")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
diff --git a/demos/8. handling animations/handling_animations_dialogue.gd b/demos/8. handling animations/handling_animations_dialogue.gd
new file mode 100644
index 00000000..01a73295
--- /dev/null
+++ b/demos/8. handling animations/handling_animations_dialogue.gd
@@ -0,0 +1,8 @@
+extends DialogueEngine
+
+var player_name : String # will be set by the UI code
+
+func _setup() -> void:
+ add_text_entry("Welcome adventurer. May I know you name?").set_metadata(&"get_player_name", "The UI code will act accordingly and inject player_name into DialogueEngine.")
+ add_text_entry("The legendary %s!? Please, follow me this way. I will personally show you our guild.").set_format([get.bind("player_name")], DialogueEntry.FORMAT_OPERATOR)
+ add_text_entry("Press or to exit.").set_name("Exit")
diff --git a/demos/8. handling animations/handling_animations_log.gd b/demos/8. handling animations/handling_animations_log.gd
new file mode 100644
index 00000000..cfd6b57f
--- /dev/null
+++ b/demos/8. handling animations/handling_animations_log.gd
@@ -0,0 +1,146 @@
+extends VBoxContainer
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+@onready var animation_player : AnimationPlayer = find_child("LogAnimationPlayer")
+
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ if not animation_player.is_playing():
+ dialogue_engine.advance()
+ else:
+ # Player is inpatient -- auto-advance the text
+ var animation_name : StringName = animation_player.get_current_animation()
+ var animation : Animation = animation_player.get_animation(animation_name)
+ animation_player.advance(animation.get_length()) # this will fire the animation_finished signal automatically
+ accept_event() # accepting input event here to stop it from traversing into into buttons possibly added through the interaction
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ # Add the text to the log:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ if p_dialogue_entry.has_metadata("author"):
+ var author : String = p_dialogue_entry.get_metadata("author")
+ label.set_text(" > " + author + ": " + p_dialogue_entry.get_formatted_text())
+ else:
+ label.set_text(" > " + p_dialogue_entry.get_formatted_text())
+ add_child(label, true)
+
+ # Setup the animation:
+ animation_player.stop(true) # internally some timers do not reset properly unless we do this
+ if not animation_player.has_animation_library(&"demo"):
+ var new_animation_library : AnimationLibrary = AnimationLibrary.new()
+ animation_player.add_animation_library(&"demo", new_animation_library)
+ var animation_library : AnimationLibrary = animation_player.get_animation_library(&"demo")
+ var animation : Animation = create_visible_characters_animation_per_character(label.get_text(), 0.045, true)
+ animation_player.set_root_node(label.get_path())
+ animation_library.add_animation(&"dialogue", animation)
+ animation_player.play(&"demo/dialogue")
+
+ # Setup the post dialogue callback
+ if p_dialogue_entry.has_metadata(&"get_player_name"):
+ animation_player.animation_finished.connect(__on_animation_finished.bind(__get_player_name), CONNECT_ONE_SHOT)
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
+
+# Must return player name to update the variable within DialogueEngine
+func __get_player_name() -> void:
+ # Get player name into the current stack:
+ var line_edit : LineEdit = LineEdit.new()
+ add_child(line_edit)
+ var p_data : Array = []
+ line_edit.text_submitted.connect(func(text : String) -> void:
+ p_data.push_back(text)
+ )
+ line_edit.grab_focus()
+ line_edit.set_placeholder("Enter your name.")
+
+ # Disable input processing by this node to avoid calling DialogueEngine.advance if the user presses space or enter
+ set_process_input(false)
+
+ await line_edit.text_submitted
+ line_edit.set_editable(false)
+
+ # Allow the user to progress the dialogue
+ set_process_input(true)
+
+ # Auto-advance the dialogue so the user does not have to press space or enter again
+ @warning_ignore("unsafe_property_access")
+ dialogue_engine.player_name = p_data[0]
+ dialogue_engine.advance()
+
+
+func __on_animation_started(p_animation_name : StringName) -> void:
+ print("Animation started:", p_animation_name)
+
+
+func __on_animation_finished(p_animation_name : StringName, p_post_dialogue_callback : Callable) -> void:
+ if p_animation_name == &"demo/dialogue":
+ p_post_dialogue_callback.call()
+
+
+# Utility function to animate the text at a constant speed
+func create_visible_characters_animation_per_character(p_text : String, p_time_per_character : float, p_instant_first_character : bool = false, p_time_whitespace : bool = false) -> Animation:
+ # Do initial calculations
+ var whitespace_regex : RegEx
+ if not p_time_whitespace or p_instant_first_character:
+ whitespace_regex = RegEx.new()
+ whitespace_regex.compile("\\s")
+
+ # Create animation and track
+ var animation : Animation = Animation.new()
+ var track_index : int = animation.add_track(Animation.TYPE_VALUE)
+ animation.track_set_path(track_index, ".:visible_characters")
+ animation.track_set_interpolation_type(track_index, Animation.INTERPOLATION_LINEAR)
+
+ # Configure keys
+ var total_time : float = 0.0
+ var total_visible_characters : int = 0
+ var whitespace_time_offset : float = 0.0
+ animation.track_insert_key(track_index, total_time, 0)
+ var total_animation_length : float = 0.0
+ for character : String in p_text:
+ total_time += p_time_per_character
+ total_visible_characters += 1
+ if not p_time_whitespace and whitespace_regex.sub(character, "", true).is_empty():
+ whitespace_time_offset += p_time_per_character
+ continue
+ total_animation_length = total_time - whitespace_time_offset
+ animation.track_insert_key(track_index, total_animation_length, total_visible_characters)
+ animation.set_length(total_animation_length)
+
+ if p_instant_first_character:
+ if animation.track_get_key_count(track_index) > 0:
+ # Shift all the keys back in time according to the time it took per character
+ for key_index : int in animation.track_get_key_count(track_index):
+ var key_time : float = animation.track_get_key_time(track_index, key_index)
+ animation.track_set_key_time(track_index, key_index, key_time - p_time_per_character)
+ animation.set_length(total_animation_length - p_time_per_character)
+ return animation
+
+
diff --git a/demos/8. handling animations/handling_animations_log.tscn b/demos/8. handling animations/handling_animations_log.tscn
new file mode 100644
index 00000000..86d0375f
--- /dev/null
+++ b/demos/8. handling animations/handling_animations_log.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=3 format=3 uid="uid://djujyea67gj55"]
+
+[ext_resource type="Script" path="res://demos/8. handling animations/handling_animations_log.gd" id="1_0567n"]
+[ext_resource type="Script" path="res://demos/8. handling animations/handling_animations_dialogue.gd" id="2_4ky6k"]
+
+[node name="DialogueEngineDemoVBoxContainer" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_0567n")
+dialogue_gdscript = ExtResource("2_4ky6k")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="LogRichTextLabel" type="RichTextLabel" parent="."]
+layout_mode = 2
+text = "Log:"
+fit_content = true
+
+[node name="LogAnimationPlayer" type="AnimationPlayer" parent="."]
+root_node = NodePath("../LogRichTextLabel")
diff --git a/demos/9. complex dialogue/complex_dialogue.gd b/demos/9. complex dialogue/complex_dialogue.gd
new file mode 100644
index 00000000..a341d178
--- /dev/null
+++ b/demos/9. complex dialogue/complex_dialogue.gd
@@ -0,0 +1,161 @@
+extends Control
+
+
+@export var dialogue_gdscript : GDScript = null
+var dialogue_engine : DialogueEngine = null
+
+@onready var animator : AnimationPlayer = $Animator
+@onready var vbox : VBoxContainer = $Center/Box/Margin/VBox/VBox
+@onready var peter : TextureRect = $Center/Box/Margin/VBox/Peter
+@onready var john : TextureRect = $Center/Box/Margin/VBox/John
+@onready var audio_player : AudioStreamPlayer = $AudioStreamPlayer
+
+var beep : PackedVector2Array = []
+
+func _ready() -> void:
+ dialogue_engine = dialogue_gdscript.new()
+ dialogue_engine.dialogue_started.connect(__on_dialogue_started)
+ dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
+ dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
+ dialogue_engine.dialogue_cancelled.connect(__on_dialogue_cancelled)
+
+ # Generate a beep to use when displaying the characters
+ var audio_frame_being_filled : int = 2730
+ var total_audio_frames_to_fill : int = audio_frame_being_filled
+ var beep_softener_ratio : float = 0.05
+ var sample_hz : float = 22050.0 # Keep the number of samples to mix low, GDScript is not super fast.
+ var pulse_hz : float = 440.0
+ var phase : float = 0.0
+ var increment : float = pulse_hz / sample_hz
+ while audio_frame_being_filled > 0:
+ var _ignore : bool = beep.push_back(Vector2.ONE * beep_softener_ratio * sin(phase * TAU) * audio_frame_being_filled/total_audio_frames_to_fill)
+ phase = fmod(phase + increment, 1.0)
+ audio_frame_being_filled -= 1
+
+
+func _input(p_input_event : InputEvent) -> void:
+ if p_input_event.is_action_pressed(&"ui_accept"):
+ if not animator.is_playing():
+ dialogue_engine.advance()
+ else:
+ # Player is inpatient -- auto-advance the text
+ var animation_name : StringName = animator.get_current_animation()
+ var animation : Animation = animator.get_animation(animation_name)
+ animator.advance(animation.get_length()) # this will fire the animation_finished signal automatically
+ accept_event() # accepting input event here to stop it from traversing into into buttons possibly added through the interaction
+
+
+func __on_dialogue_started() -> void:
+ print("Dialogue Started!")
+
+
+func __on_dialogue_continued(p_dialogue_entry : DialogueEntry) -> void:
+ # Add the text to the log:
+ var label : RichTextLabel = RichTextLabel.new()
+ label.set_use_bbcode(true)
+ label.set_fit_content(true)
+ if p_dialogue_entry.has_metadata("author"):
+ var author : String = p_dialogue_entry.get_metadata("author")
+ label.set_text(" > " + author + ": " + p_dialogue_entry.get_formatted_text())
+ else:
+ label.set_text(" > " + p_dialogue_entry.get_formatted_text())
+ vbox.add_child(label, true)
+
+ # Setup the animation:
+ animator.stop(true) # internally some timers do not reset properly unless we do this
+ if not animator.has_animation_library(&"demo"):
+ var new_animation_library : AnimationLibrary = AnimationLibrary.new()
+ animator.add_animation_library(&"demo", new_animation_library)
+ var animation_library : AnimationLibrary = animator.get_animation_library(&"demo")
+ var animation : Animation = create_visible_characters_animation_per_character(label.get_text(), 0.045, true)
+ animator.set_root_node(label.get_path())
+ animation_library.add_animation(&"dialogue", animation)
+ animator.play(&"demo/dialogue")
+
+ # Hide all portraits
+ peter.hide()
+ john.hide()
+
+ # Show author portrait
+ if p_dialogue_entry.has_metadata("author"):
+ if p_dialogue_entry.get_metadata("author") == "Peter":
+ peter.show()
+ else:
+ john.show()
+
+
+func __on_dialogue_finished() -> void:
+ print("Dialogue Finished! Exiting...")
+ get_tree().quit()
+
+
+func __on_dialogue_cancelled() -> void:
+ print("Dialogue Cancelled! Exiting...")
+ get_tree().quit()
+
+
+func __on_animation_started(p_animation_name : StringName) -> void:
+ print("Animation started:", p_animation_name)
+
+
+# Utility function to animate the text at a constant speed
+func create_visible_characters_animation_per_character(p_text : String, p_time_per_character : float, p_instant_first_character : bool = false, p_time_whitespace : bool = false) -> Animation:
+ # Do initial calculations
+ var whitespace_regex : RegEx
+ if not p_time_whitespace or p_instant_first_character:
+ whitespace_regex = RegEx.new()
+ whitespace_regex.compile("\\s")
+
+ # Create animation and track
+ var animation : Animation = Animation.new()
+ var track_index : int = animation.add_track(Animation.TYPE_VALUE)
+ animation.track_set_path(track_index, ".:visible_characters")
+ animation.track_set_interpolation_type(track_index, Animation.INTERPOLATION_LINEAR)
+ var sound_track_index : int = animation.add_track(Animation.TYPE_METHOD)
+ # The path provided here points to the root node (i.e. ComplexDialogue node)
+ animation.track_set_path(sound_track_index, "../../../../../..")
+
+ # Configure keys
+ var total_time : float = 0.0
+ var total_visible_characters : int = 0
+ var whitespace_time_offset : float = 0.0
+ animation.track_insert_key(track_index, total_time, 0)
+ var total_animation_length : float = 0.0
+ for character : String in p_text:
+ total_time += p_time_per_character
+ total_visible_characters += 1
+ if not p_time_whitespace and whitespace_regex.sub(character, "", true).is_empty():
+ whitespace_time_offset += p_time_per_character
+ continue
+ total_animation_length = total_time - whitespace_time_offset
+ animation.track_insert_key(track_index, total_animation_length, total_visible_characters)
+ # Simple sound animation:
+ var should_play : bool = total_visible_characters % 3 == 0 and total_visible_characters < p_text.length() - 1
+ if should_play:
+ animation.track_insert_key(sound_track_index, total_animation_length, {"method": "__play_sound", "args": []})
+ animation.set_length(total_animation_length)
+
+ if p_instant_first_character:
+ if animation.track_get_key_count(track_index) > 0:
+ # Shift all the keys back in time according to the time it took per character
+ for key_index : int in animation.track_get_key_count(track_index):
+ var key_time : float = animation.track_get_key_time(track_index, key_index)
+ animation.track_set_key_time(track_index, key_index, key_time - p_time_per_character)
+ animation.set_length(total_animation_length - p_time_per_character)
+ return animation
+
+
+func __play_sound() -> void:
+ # Create the audio stream dynamically
+ var audio_stream : AudioStreamGenerator = AudioStreamGenerator.new()
+ const mix_rate : float = 22050.0
+ audio_stream.set_mix_rate(mix_rate)
+ audio_player.set_stream(audio_stream)
+
+
+ # Fill in the playback audio frames:
+ audio_player.play()
+ var playback : AudioStreamGeneratorPlayback = audio_player.get_stream_playback()
+ var total_available_frames : int = playback.get_frames_available()
+ var _success : bool = playback.push_buffer(beep)
+ var _frames_filled : int = total_available_frames - playback.get_frames_available()
diff --git a/demos/9. complex dialogue/complex_dialogue.tscn b/demos/9. complex dialogue/complex_dialogue.tscn
new file mode 100644
index 00000000..4a82d5d8
--- /dev/null
+++ b/demos/9. complex dialogue/complex_dialogue.tscn
@@ -0,0 +1,84 @@
+[gd_scene load_steps=5 format=3 uid="uid://djkxscwuw617h"]
+
+[ext_resource type="Texture2D" uid="uid://cr0rdb3ab8xwg" path="res://icon.svg" id="1_5ptqw"]
+[ext_resource type="Script" path="res://demos/9. complex dialogue/complex_dialogue.gd" id="1_qenq2"]
+[ext_resource type="Script" path="res://demos/9. complex dialogue/dialogue.gd" id="2_buq0b"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hqwn4"]
+bg_color = Color(0, 0, 0, 1)
+
+[node name="ComplexDialogue" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_qenq2")
+dialogue_gdscript = ExtResource("2_buq0b")
+
+[node name="DialogueEngineDemoQuickStartPanelContainer" type="PanelContainer" parent="."]
+layout_mode = 1
+anchors_preset = 10
+anchor_right = 1.0
+offset_bottom = 23.0
+grow_horizontal = 2
+
+[node name="DialogueEngineDemoQuickStartLabel" type="Label" parent="DialogueEngineDemoQuickStartPanelContainer"]
+layout_mode = 2
+text = "Press or to progress the dialogue."
+horizontal_alignment = 1
+
+[node name="Center" type="CenterContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Box" type="PanelContainer" parent="Center"]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_hqwn4")
+
+[node name="Margin" type="MarginContainer" parent="Center/Box"]
+custom_minimum_size = Vector2(384, 384)
+layout_mode = 2
+theme_override_constants/margin_left = 16
+theme_override_constants/margin_top = 16
+theme_override_constants/margin_right = 16
+theme_override_constants/margin_bottom = 16
+
+[node name="VBox" type="VBoxContainer" parent="Center/Box/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 32
+
+[node name="VBox" type="VBoxContainer" parent="Center/Box/Margin/VBox"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Text" type="RichTextLabel" parent="Center/Box/Margin/VBox/VBox"]
+layout_mode = 2
+text = "Log:"
+fit_content = true
+
+[node name="Peter" type="TextureRect" parent="Center/Box/Margin/VBox"]
+visible = false
+custom_minimum_size = Vector2(96, 96)
+layout_mode = 2
+size_flags_horizontal = 0
+texture = ExtResource("1_5ptqw")
+expand_mode = 1
+
+[node name="John" type="TextureRect" parent="Center/Box/Margin/VBox"]
+visible = false
+modulate = Color(0.980392, 0.631373, 1, 1)
+custom_minimum_size = Vector2(96, 96)
+layout_mode = 2
+size_flags_horizontal = 8
+texture = ExtResource("1_5ptqw")
+expand_mode = 1
+
+[node name="Animator" type="AnimationPlayer" parent="."]
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="."]
diff --git a/demos/9. complex dialogue/dialogue.gd b/demos/9. complex dialogue/dialogue.gd
new file mode 100644
index 00000000..aae9b2d9
--- /dev/null
+++ b/demos/9. complex dialogue/dialogue.gd
@@ -0,0 +1,13 @@
+extends DialogueEngine
+
+
+func _setup() -> void:
+ add_text_entry("Hello, how are you?").set_metadata("author", "Peter")
+ add_text_entry("I'm fine, thank you! And you?").set_metadata("author", "John")
+ add_text_entry("I'm fine too! Thank you!").set_metadata("author", "Peter")
+ add_text_entry("What's your name?").set_metadata("author", "John")
+ add_text_entry("I'm Peter, and you?").set_metadata("author", "Peter")
+ add_text_entry("Nice to meet you Peter! I'm John!").set_metadata("author", "John")
+ var entry: DialogueEntry = add_text_entry("Nice to meet you John!")
+ entry.set_metadata("author", "Peter")
+ entry.set_name("Exit")
diff --git a/docs/.gdignore b/docs/.gdignore
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/icon.png b/docs/icon.png
new file mode 100644
index 00000000..d91bda5c
Binary files /dev/null and b/docs/icon.png differ
diff --git a/icon.svg b/icon.svg
new file mode 100644
index 00000000..bad28e0f
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,75 @@
+
+
diff --git a/icon.svg.import b/icon.svg.import
new file mode 100644
index 00000000..7a1cbb0a
--- /dev/null
+++ b/icon.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cr0rdb3ab8xwg"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/project.godot b/project.godot
index 76743e76..8e0cd54f 100644
--- a/project.godot
+++ b/project.godot
@@ -15,6 +15,14 @@ run/main_scene="res://src/Main.tscn"
config/features=PackedStringArray("4.4", "C#", "GL Compatibility")
boot_splash/show_image=false
+[autoload]
+
+DialogueManager="*res://addons/dialogue_manager/dialogue_manager.gd"
+
+[dialogue_manager]
+
+general/uses_dotnet=true
+
[display]
window/size/viewport_width=1920
@@ -26,7 +34,7 @@ project/assembly_name="GameJamDungeon"
[editor_plugins]
-enabled=PackedStringArray("res://addons/SimpleDungeons/plugin.cfg")
+enabled=PackedStringArray("res://addons/SimpleDungeons/plugin.cfg", "res://addons/dialogue_manager/plugin.cfg")
[global_group]
@@ -87,6 +95,10 @@ Throw={
]
}
+[internationalization]
+
+locale/translations_pot_files=PackedStringArray("res://src/dialog/TestDialogue.dialogue")
+
[layer_names]
3d_physics/layer_1="World"
diff --git a/src/dialog/DialogueTest.cs b/src/dialog/DialogueTest.cs
new file mode 100644
index 00000000..9a5c0c4d
--- /dev/null
+++ b/src/dialog/DialogueTest.cs
@@ -0,0 +1,23 @@
+using Godot;
+using DialogueManagerRuntime;
+using GameJamDungeon;
+
+public partial class DialogueTest : Area3D
+{
+ [Export]
+ public Resource DialogueResource;
+
+ [Export]
+ public string DialougeStart = "start";
+
+ public override void _Process(double delta)
+ {
+ if (Input.IsActionJustPressed(GameInputs.Attack))
+ OnTalk();
+ }
+
+ public void OnTalk()
+ {
+ DialogueManager.ShowDialogueBalloon(DialogueResource, DialougeStart);
+ }
+}
diff --git a/src/dialog/DialogueTest.tscn b/src/dialog/DialogueTest.tscn
new file mode 100644
index 00000000..77e68f06
--- /dev/null
+++ b/src/dialog/DialogueTest.tscn
@@ -0,0 +1,28 @@
+[gd_scene load_steps=5 format=3 uid="uid://xb02opiwelet"]
+
+[ext_resource type="Script" path="res://src/dialog/DialogueTest.cs" id="1_6offx"]
+[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_label.gd" id="1_v3yy4"]
+[ext_resource type="Resource" uid="uid://bw086h2dmhraf" path="res://src/dialog/TestDialogue.dialogue" id="2_c26a0"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_dhaer"]
+size = Vector3(2, 2, 2)
+
+[node name="Panel" type="Area3D"]
+script = ExtResource("1_6offx")
+DialogueResource = ExtResource("2_c26a0")
+
+[node name="DialogueTest" type="RichTextLabel" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+text = "Test"
+scroll_active = false
+scroll_following = true
+script = ExtResource("1_v3yy4")
+skip_pause_at_abbreviations = PackedStringArray("eg", "ex")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.254547, -0.166077, -1.29401)
+shape = SubResource("BoxShape3D_dhaer")
diff --git a/src/dialog/TestDialogue.dialogue b/src/dialog/TestDialogue.dialogue
new file mode 100644
index 00000000..80e37531
--- /dev/null
+++ b/src/dialog/TestDialogue.dialogue
@@ -0,0 +1,3 @@
+~ start
+Hi...
+=> END
\ No newline at end of file
diff --git a/src/dialog/TestDialogue.dialogue.import b/src/dialog/TestDialogue.dialogue.import
new file mode 100644
index 00000000..b3971c03
--- /dev/null
+++ b/src/dialog/TestDialogue.dialogue.import
@@ -0,0 +1,15 @@
+[remap]
+
+importer="dialogue_manager_compiler_12"
+type="Resource"
+uid="uid://bw086h2dmhraf"
+path="res://.godot/imported/TestDialogue.dialogue-ef49d7fb6d53dc49d01e69ca7037845f.tres"
+
+[deps]
+
+source_file="res://src/dialog/TestDialogue.dialogue"
+dest_files=["res://.godot/imported/TestDialogue.dialogue-ef49d7fb6d53dc49d01e69ca7037845f.tres"]
+
+[params]
+
+defaults=true
diff --git a/src/game/Game.tscn b/src/game/Game.tscn
index 3266168e..6214d8d7 100644
--- a/src/game/Game.tscn
+++ b/src/game/Game.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=16 format=3 uid="uid://33ek675mfb5n"]
+[gd_scene load_steps=18 format=3 uid="uid://33ek675mfb5n"]
[ext_resource type="Script" path="res://src/game/Game.cs" id="1_ytcii"]
[ext_resource type="PackedScene" uid="uid://cfecvvav8kkp6" path="res://src/player/Player.tscn" id="3_kk6ly"]
@@ -9,26 +9,13 @@
[ext_resource type="PackedScene" uid="uid://b3r0r22kc67bl" path="res://src/map/dungeon/floors/Floor2.tscn" id="7_1sm5s"]
[ext_resource type="PackedScene" uid="uid://b40sstnic41dw" path="res://src/map/dungeon/floors/Floor3.tscn" id="8_87yk1"]
[ext_resource type="PackedScene" uid="uid://c3ek5i43cl0r5" path="res://src/map/Teleport.tscn" id="9_nwu7r"]
+[ext_resource type="PackedScene" uid="uid://xb02opiwelet" path="res://src/dialog/DialogueTest.tscn" id="10_kejri"]
+[ext_resource type="Resource" uid="uid://bw086h2dmhraf" path="res://src/dialog/TestDialogue.dialogue" id="11_4jbgd"]
[sub_resource type="Environment" id="Environment_fke5g"]
[sub_resource type="NavigationMesh" id="NavigationMesh_xligp"]
-[sub_resource type="Animation" id="Animation_wewlr"]
-resource_name = "load"
-tracks/0/type = "value"
-tracks/0/imported = false
-tracks/0/enabled = true
-tracks/0/path = NodePath("LoadScreen:color")
-tracks/0/interp = 1
-tracks/0/loop_wrap = true
-tracks/0/keys = {
-"times": PackedFloat32Array(0, 1),
-"transitions": PackedFloat32Array(1, 1),
-"update": 0,
-"values": [Color(0, 0.486275, 1, 1), Color(0, 0.486275, 1, 0)]
-}
-
[sub_resource type="Animation" id="Animation_nc1gg"]
length = 0.001
tracks/0/type = "value"
@@ -44,6 +31,21 @@ tracks/0/keys = {
"values": [Color(1, 1, 1, 0)]
}
+[sub_resource type="Animation" id="Animation_wewlr"]
+resource_name = "load"
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("LoadScreen:color")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0, 1),
+"transitions": PackedFloat32Array(1, 1),
+"update": 0,
+"values": [Color(0, 0.486275, 1, 1), Color(0, 0.486275, 1, 0)]
+}
+
[sub_resource type="Animation" id="Animation_ovny8"]
resource_name = "wait_and_load"
length = 3.0
@@ -134,4 +136,7 @@ unique_name_in_owner = true
process_mode = 3
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 900, 900, 900)
disable_mode = 2
-collision_layer = 2
+
+[node name="Panel" parent="." instance=ExtResource("10_kejri")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6.42724, 0, -7.22283)
+DialogueResource = ExtResource("11_4jbgd")
diff --git a/src/map/Overworld.tscn b/src/map/Overworld.tscn
index fcc75acb..4658c630 100644
--- a/src/map/Overworld.tscn
+++ b/src/map/Overworld.tscn
@@ -1,7 +1,9 @@
-[gd_scene load_steps=3 format=3 uid="uid://dvnc26rebk6o0"]
+[gd_scene load_steps=5 format=3 uid="uid://dvnc26rebk6o0"]
[ext_resource type="Script" path="res://src/map/dungeon/floors/Overworld.cs" id="1_5hmt3"]
[ext_resource type="PackedScene" uid="uid://c10nhqq8su6pp" path="res://src/items/weapons/models/RareSword.tscn" id="2_ni2nx"]
+[ext_resource type="PackedScene" uid="uid://d4l4qutp8x40c" path="res://src/npc/goddess/Goddess.tscn" id="3_4sm8u"]
+[ext_resource type="PackedScene" uid="uid://xb02opiwelet" path="res://src/dialog/DialogueTest.tscn" id="4_thkm7"]
[node name="Overworld" type="Node3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.63488, -5.13176)
@@ -22,3 +24,9 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.49531, -3.12363, 0)
[node name="RareSword" parent="." instance=ExtResource("2_ni2nx")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -3.8356, -2.30799)
+
+[node name="Goddess" parent="." instance=ExtResource("3_4sm8u")]
+transform = Transform3D(1.4, 0, 0, 0, 1.4, 0, 0, 0, 1.4, -8.84798, -2.93175, -3.9493)
+
+[node name="Panel" parent="Goddess" instance=ExtResource("4_thkm7")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0604029, 0.440545, 0.961159)
diff --git a/src/map/dungeon/rooms/Room1.tscn b/src/map/dungeon/rooms/Room1.tscn
index 8786286e..a0093e22 100644
--- a/src/map/dungeon/rooms/Room1.tscn
+++ b/src/map/dungeon/rooms/Room1.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=15 format=3 uid="uid://dhpwwqow1ahrc"]
+[gd_scene load_steps=14 format=3 uid="uid://dhpwwqow1ahrc"]
[ext_resource type="Script" path="res://addons/SimpleDungeons/DungeonRoom3D.gd" id="1_0tfda"]
[ext_resource type="Script" path="res://src/map/dungeon/rooms/DungeonRoom.cs" id="1_ti7ur"]
@@ -6,7 +6,6 @@
[ext_resource type="PackedScene" uid="uid://twrj4wixcbu7" path="res://src/items/ItemDatabase.tscn" id="4_2mnb7"]
[ext_resource type="PackedScene" uid="uid://dbvr8ewajja6a" path="res://src/enemy/EnemyDatabase.tscn" id="5_owpbq"]
[ext_resource type="PackedScene" uid="uid://dorr7v1tkeiy0" path="res://src/items/armor/Armor.tscn" id="7_bm50w"]
-[ext_resource type="PackedScene" uid="uid://d4l4qutp8x40c" path="res://src/npc/goddess/Goddess.tscn" id="10_82rsb"]
[sub_resource type="PlaneMesh" id="PlaneMesh_luhnj"]
size = Vector2(10, 10)
@@ -99,8 +98,5 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.655729, 0, 0)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.144196, -4.84337, -0.0752945)
shape = SubResource("BoxShape3D_4exnc")
-[node name="Goddess" parent="." instance=ExtResource("10_82rsb")]
-transform = Transform3D(1.4, 0, 0, 0, 1.4, 0, 0, 0, 1.4, -3.23054, -3.37962, 2.05892)
-
[node name="ExitSpawnLocation" type="Marker3D" parent="." groups=["Exit"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.06499, -3.26251, -2.72249)