diff --git a/addons/dialogue_manager/DialogueManager.cs b/addons/dialogue_manager/DialogueManager.cs
new file mode 100644
index 0000000..8ce4876
--- /dev/null
+++ b/addons/dialogue_manager/DialogueManager.cs
@@ -0,0 +1,1163 @@
+using Godot;
+using Godot.Collections;
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+
+#nullable enable
+
+namespace DialogueManagerRuntime
+{
+
+ public enum MutationBehaviour
+ {
+ Wait,
+ DoNotWait,
+ Skip
+ }
+
+ public enum TranslationSource
+ {
+ None,
+ Guess,
+ CSV,
+ PO
+ }
+
+ public partial class DialogueManager : RefCounted
+ {
+ public delegate void DialogueStartedEventHandler(Resource dialogueResource);
+ public delegate void PassedCueEventHandler(string cue);
+ public delegate void GotDialogueEventHandler(DialogueLine dialogueLine);
+ public delegate void WaitingForInputEventHandler();
+ public delegate void WaitedForInputEventHandler();
+ public delegate void MutatedEventHandler(Dictionary mutation);
+
+ ///
+ /// Emitted when some dialogue has reached the end.
+ ///
+ /// The calling dialogue resource.
+ public delegate void DialogueEndedEventHandler(Resource dialogueResource);
+
+ ///
+ /// Emitted when a dialogue balloon is created and dialogue starts.
+ ///
+ public static event DialogueStartedEventHandler? DialogueStarted;
+
+ ///
+ /// Emitted when a cue is encountered while traversing dialogue, usually when jumping from a goto line.
+ ///
+ public static event PassedCueEventHandler? PassedCue;
+
+ ///
+ /// Emitted when a line of dialogue is encountered.
+ ///
+ public static event GotDialogueEventHandler? GotDialogue;
+
+ ///
+ /// Emitted when a mutation is encountered.
+ ///
+ public static event MutatedEventHandler? Mutated;
+
+ ///
+ /// Emitted when waiting for input.
+ ///
+ public static event WaitingForInputEventHandler? WaitingForInput;
+
+ ///
+ /// Emitted when waiting for input received input.
+ ///
+ public static event WaitedForInputEventHandler? WaitedForInput;
+
+ ///
+ /// Emitted when some dialogue has reached the end.
+ ///
+ public static event DialogueEndedEventHandler? DialogueEnded;
+
+ [Signal] public delegate void ResolvedEventHandler(double id, Variant value);
+
+ private static Random random = new Random();
+
+ private static readonly System.Collections.Generic.Dictionary> getNextLineRequests = new();
+ private static readonly System.Collections.Generic.Dictionary> getLineRequests = new();
+ private static readonly System.Collections.Generic.Dictionary> mutateRequests = new();
+
+ private static Type[]? cachedAssemblyTypes;
+ private static Type[] AssemblyTypes => cachedAssemblyTypes ??= Assembly.GetExecutingAssembly().GetTypes();
+
+ private static readonly System.Collections.Generic.Dictionary MethodCache = new();
+ private static MethodInfo[] GetMethodsForType(Type type)
+ {
+ if (!MethodCache.TryGetValue(type, out var methods))
+ {
+ methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ MethodCache[type] = methods;
+ }
+ return methods;
+ }
+
+ private static void OnBridgeGetNextDialogueLineCompleted(int callId, RefCounted? line)
+ {
+ if (getNextLineRequests.Remove(callId, out var tcs))
+ {
+ tcs.SetResult(line);
+ }
+ }
+
+ private static void OnBridgeGetLineCompleted(int callId, RefCounted? line)
+ {
+ if (getLineRequests.Remove(callId, out var tcs))
+ {
+ tcs.SetResult(line);
+ }
+ }
+
+ private static void OnBridgeMutated(int callId)
+ {
+ if (mutateRequests.Remove(callId, out var tcs))
+ {
+ tcs.SetResult(true);
+ }
+ }
+
+ private static GodotObject? instance;
+ public static GodotObject Instance => instance ??= InitializeInstance();
+
+ private static GodotObject InitializeInstance()
+ {
+ var dm = Engine.GetSingleton("DialogueManager");
+ dm.Connect("dialogue_started", Callable.From((Resource dialogueResource) => DialogueStarted?.Invoke(dialogueResource)));
+ dm.Connect("passed_cue", Callable.From((string cue) => PassedCue?.Invoke(cue)));
+ dm.Connect("got_dialogue", Callable.From((RefCounted line) => GotDialogue?.Invoke(new DialogueLine(line))));
+ dm.Connect("mutated", Callable.From((Dictionary mutation) => Mutated?.Invoke(mutation)));
+ dm.Connect("waiting_for_input", Callable.From(() => WaitingForInput?.Invoke()));
+ dm.Connect("waited_for_input", Callable.From(() => WaitedForInput?.Invoke()));
+ dm.Connect("dialogue_ended", Callable.From((Resource dialogueResource) => DialogueEnded?.Invoke(dialogueResource)));
+
+ dm.Connect("bridge_get_next_dialogue_line_completed", Callable.From((int callId, RefCounted? line) => OnBridgeGetNextDialogueLineCompleted(callId, line)));
+ dm.Connect("bridge_get_line_completed", Callable.From((int callId, RefCounted? line) => OnBridgeGetLineCompleted(callId, line)));
+ dm.Connect("bridge_mutated", Callable.From((int callId) => OnBridgeMutated(callId)));
+
+ return dm;
+ }
+
+ ///
+ /// The list of globals that dialogue can query.
+ ///
+ public static Godot.Collections.Array GameStates
+ {
+ get => (Godot.Collections.Array)Instance.Get("game_states");
+ set => Instance.Set("game_states", value);
+ }
+
+ ///
+ /// Allow dialogue to call singletons.
+ ///
+ public static bool IncludeSingletons
+ {
+ get => (bool)Instance.Get("include_singletons");
+ set => Instance.Set("include_singletons", value);
+ }
+
+ ///
+ /// Allow dialogue to call static methods/properties on classes
+ ///
+ public static bool IncludeClasses
+ {
+ get => (bool)Instance.Get("include_classes");
+ set => Instance.Set("include_classes", value);
+ }
+
+
+ ///
+ /// Allow dialogue to call methods on the dialogue resource.
+ ///
+ public static bool IncludeDialogueResourceAsSelf
+ {
+ get => (bool)Instance.Get("include_dialogue_resource_as_self");
+ set => Instance.Set("include_dialogue_resource_as_self", value);
+ }
+
+ ///
+ /// A runtime override for the project setting to ignore missing state values.
+ ///
+ public static bool IgnoreMissingStateValues
+ {
+ get => (bool)Instance.Get("ignore_missing_state_values");
+ set => Instance.Set("ignore_missing_state_values", value);
+ }
+
+ ///
+ /// Used to resolve the current scene. Override if your game manages the current scene itself.
+ ///
+ public static Func GetCurrentScene
+ {
+ set => Instance.Set("get_current_scene", Callable.From(value));
+ }
+
+ ///
+ /// Used to resolve the load function used in *.dialogue files. Override this if you need safer loading or custom loading logic.
+ ///
+ public static Func LoadFromWithinDialogue = GD.Load;
+
+ ///
+ /// Set a filter hook for member access on GodotObject things. The delegate is
+ /// stored on the GDScript-side validate_member_access Callable property
+ /// and is invoked whenever a thing's property, method, or string-keyed index is
+ /// about to be accessed at runtime. Return "" to allow, or a non-empty
+ /// error string to deny.
+ /// is one of: "property", "property_set", "method", "index".
+ ///
+ public static Func ValidateMemberAccess
+ {
+ set => Instance.Set("validate_member_access", Callable.From(value));
+ }
+
+ ///
+ /// Set a random seed.
+ ///
+ ///
+ public static void ReseedRandomizer(int nextSeed)
+ {
+ Instance.Call("reseed_randomizer", nextSeed);
+ }
+
+
+ ///
+ /// Step through lines and run any mutations until we either hit some dialogue or the end of the conversation.
+ ///
+ ///
+ ///
+ /// Array of extra nodes to expose as state to the dialogue
+ ///
+ /// The next dialogue line
+ public static async Task GetNextDialogueLine(Resource dialogueResource, string key = "", Array? extraGameStates = null, MutationBehaviour mutation_behaviour = MutationBehaviour.Wait)
+ {
+ int id = random.Next();
+ var tcs = new TaskCompletionSource();
+ getNextLineRequests[id] = tcs;
+
+ Instance.Call("_bridge_get_next_dialogue_line", id, dialogueResource, key, extraGameStates ?? new Array(), (int)mutation_behaviour);
+
+ var line = await tcs.Task;
+ return line == null ? null : new DialogueLine(line);
+ }
+
+
+ ///
+ /// Get a line by its ID
+ ///
+ ///
+ ///
+ /// Array of extra nodes to expose as state to the dialogue
+ ///
+ public static async Task GetLine(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ int id = random.Next();
+ var tcs = new TaskCompletionSource();
+ getLineRequests[id] = tcs;
+
+ Instance.Call("_bridge_get_line", id, dialogueResource, key, extraGameStates ?? new Array());
+
+ var line = await tcs.Task;
+ return line == null ? null : new DialogueLine(line);
+ }
+
+
+ ///
+ /// Generate a dialogue resource on the fly from some text
+ ///
+ ///
+ ///
+ public static Resource CreateResourceFromText(string text)
+ {
+ return (Resource)Instance.Call("create_resource_from_text", text);
+ }
+
+
+
+ ///
+ /// Set the default balloon to use at runtime.
+ ///
+ ///
+ ///
+ public static Error SetDefaultBalloon(Variant balloonScene)
+ {
+ return (Error)(int)Instance.Call("set_default_balloon", balloonScene);
+ }
+
+
+ ///
+ /// Show the example balloon
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static CanvasLayer ShowExampleDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (CanvasLayer)Instance.Call("show_example_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+
+ ///
+ /// Show the configured dialogue balloon
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static Node ShowDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null)
+ {
+ return (Node)Instance.Call("show_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array());
+ }
+
+
+ ///
+ /// Show a given balloon scene
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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());
+ }
+
+ ///
+ /// Show a given balloon scene
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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());
+ }
+
+ ///
+ /// Show a given balloon scene
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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());
+ }
+
+
+ ///
+ /// Resolve a static line ID to an actual line ID
+ ///
+ ///
+ ///
+ ///
+ public static string StaticIdToLineId(Resource dialogueResource, string staticId)
+ {
+ return (string)Instance.Call("static_id_to_line_id", dialogueResource, staticId);
+ }
+
+
+ ///
+ /// Resolve a static line ID to any actual line IDs that match
+ ///
+ ///
+ ///
+ ///
+ public static Array StaticIdToLineIds(Resource dialogueResource, string staticId)
+ {
+ return (Array)Instance.Call("static_id_to_line_ids", dialogueResource, staticId);
+ }
+
+
+ public static async Task Mutate(Dictionary mutation, Array? extraGameStates = null, bool isInlineMutation = false)
+ {
+ int id = random.Next();
+ var tcs = new TaskCompletionSource();
+ mutateRequests[id] = tcs;
+
+ Instance.Call("_bridge_mutate", id, mutation, extraGameStates ?? new Array(), isInlineMutation);
+
+ await tcs.Task;
+ }
+
+
+ public static Array GetMembersForScript(Script script)
+ {
+ string typeName = script.ResourcePath.GetFile().GetBaseName();
+ var matchingType = AssemblyTypes.FirstOrDefault(t => t.Name == typeName);
+
+ if (matchingType == null) return new Array();
+
+ return GetMembersForType(matchingType);
+ }
+
+
+ public static Array GetMembersForPropertyChain(Script script, Array chain)
+ {
+ string typeName = script.ResourcePath.GetFile().GetBaseName();
+ var currentType = AssemblyTypes.FirstOrDefault(t => t.Name == typeName);
+
+ if (currentType == null) return new Array();
+
+ foreach (var segment in chain)
+ {
+ currentType = ResolvePropertyType(currentType, segment);
+ if (currentType == null) return new Array();
+ }
+
+ return GetMembersForType(currentType);
+ }
+
+
+ public static Dictionary GetMethodInfoForPropertyChain(Script script, Array chain, string methodName)
+ {
+ string typeName = script.ResourcePath.GetFile().GetBaseName();
+ var currentType = AssemblyTypes.FirstOrDefault(t => t.Name == typeName);
+
+ if (currentType == null) return new Dictionary();
+
+ foreach (var segment in chain)
+ {
+ currentType = ResolvePropertyType(currentType, segment);
+ if (currentType == null) return new Dictionary();
+ }
+
+ var methodInfo = currentType
+ .GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public)
+ .FirstOrDefault(m => m.Name == methodName && !m.IsSpecialName);
+ if (methodInfo == null) return new Dictionary();
+
+ return BuildMethodDictionary(methodInfo);
+ }
+
+
+ private static Array GetMembersForType(Type type)
+ {
+ Array members = new Array();
+
+ if (type.IsEnum)
+ {
+ foreach (var name in type.GetEnumNames())
+ {
+ members.Add(new Dictionary() {
+ { "name", name },
+ { "type", "enum" }
+ });
+ }
+ return members;
+ }
+
+ var memberInfos = type.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ foreach (var memberInfo in memberInfos)
+ {
+ switch (memberInfo.MemberType)
+ {
+ case MemberTypes.Field:
+ FieldInfo fieldInfo = (FieldInfo)memberInfo;
+ string fieldType;
+ if (fieldInfo.FieldType.ToString().Contains("EventHandler"))
+ {
+ fieldType = "signal";
+ }
+ else if (fieldInfo.IsLiteral)
+ {
+ fieldType = "constant";
+ }
+ else
+ {
+ fieldType = "property";
+ }
+ members.Add(new Dictionary() {
+ { "name", memberInfo.Name },
+ { "type", fieldType },
+ { "class_name", GetFriendlyTypeName(fieldInfo.FieldType) }
+ });
+ break;
+
+ case MemberTypes.Property:
+ PropertyInfo propInfo = (PropertyInfo)memberInfo;
+ members.Add(new Dictionary() {
+ { "name", memberInfo.Name },
+ { "type", "property" },
+ { "class_name", GetFriendlyTypeName(propInfo.PropertyType) }
+ });
+ break;
+
+ case MemberTypes.Method:
+ MethodInfo methodInfo = (MethodInfo)memberInfo;
+ if (methodInfo.IsSpecialName) continue;
+ members.Add(BuildMethodDictionary(methodInfo));
+ break;
+
+ case MemberTypes.NestedType:
+ members.Add(new Dictionary() {
+ { "name", memberInfo.Name },
+ { "type", "constant" }
+ });
+ break;
+
+ default:
+ continue;
+ }
+ }
+
+ return members;
+ }
+
+
+ private static Dictionary BuildMethodDictionary(MethodInfo methodInfo)
+ {
+ var args = new Array();
+ foreach (var param in methodInfo.GetParameters())
+ {
+ args.Add(new Dictionary() {
+ { "name", param.Name ?? "" },
+ { "type", (int)Variant.Type.Nil },
+ { "class_name", GetFriendlyTypeName(param.ParameterType) }
+ });
+ }
+ return new Dictionary() {
+ { "name", methodInfo.Name },
+ { "type", "method" },
+ { "args", args }
+ };
+ }
+
+
+ private static Type? ResolvePropertyType(Type type, string memberName)
+ {
+ var field = type.GetField(memberName, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ if (field != null) return field.FieldType;
+
+ var prop = type.GetProperty(memberName, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ if (prop != null) return prop.PropertyType;
+
+ var nested = type.GetNestedType(memberName, BindingFlags.Public);
+ if (nested != null) return nested;
+
+ return null;
+ }
+
+
+ private static string GetFriendlyTypeName(Type type)
+ {
+ if (type == typeof(int)) return "int";
+ if (type == typeof(long)) return "long";
+ if (type == typeof(float)) return "float";
+ if (type == typeof(double)) return "double";
+ if (type == typeof(bool)) return "bool";
+ if (type == typeof(string)) return "string";
+ if (type == typeof(void)) return "void";
+ if (type == typeof(byte)) return "byte";
+ if (type == typeof(short)) return "short";
+ if (type == typeof(char)) return "char";
+ if (type == typeof(decimal)) return "decimal";
+ return type.Name;
+ }
+
+
+ public bool ThingHasConstant(GodotObject thing, string property)
+ {
+ var memberInfos = thing.GetType().GetMember(property, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ return memberInfos.Length > 0;
+ }
+
+
+ public Variant ResolveThingConstant(GodotObject thing, string property)
+ {
+ var memberInfos = thing.GetType().GetMember(property, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
+ foreach (var memberInfo in memberInfos)
+ {
+ if (memberInfo != null)
+ {
+ try
+ {
+ switch (memberInfo)
+ {
+ case FieldInfo fieldInfo:
+ return ConvertValueToVariant(fieldInfo.GetValue(thing));
+
+ case PropertyInfo propInfo:
+ return ConvertValueToVariant(propInfo.GetValue(thing));
+
+ case Type nestedType when nestedType.IsEnum:
+ return GetEnumAsDictionary(nestedType);
+
+ default:
+ break;
+ }
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"{property} is not supported by Variant.", e);
+ }
+ }
+ }
+
+ throw new Exception($"{property} is not a public constant on {thing}");
+ }
+
+
+ Dictionary GetEnumAsDictionary(Type enumType)
+ {
+ Dictionary dictionary = new Dictionary();
+ foreach (var value in enumType.GetEnumValuesAsUnderlyingType())
+ {
+ var key = enumType.GetEnumName(value);
+ if (key != null)
+ {
+ dictionary.Add(key, ConvertValueToVariant(value));
+ }
+ }
+ return dictionary;
+ }
+
+
+ Variant ConvertValueToVariant(object? value)
+ {
+ if (value == null) return default;
+
+ Type rawType = value.GetType();
+ if (rawType.IsEnum)
+ {
+ value = Convert.ChangeType(value, Enum.GetUnderlyingType(rawType));
+ }
+
+ return value switch
+ {
+ Variant v => v,
+ bool v => Variant.From(v),
+ byte v => Variant.From((long)v),
+ sbyte v => Variant.From((long)v),
+ short v => Variant.From((long)v),
+ ushort v => Variant.From((long)v),
+ int v => Variant.From((long)v),
+ uint v => Variant.From((long)v),
+ long v => Variant.From(v),
+ ulong v => Variant.From((long)v),
+ float v => Variant.From((double)v),
+ double v => Variant.From(v),
+ string v => Variant.From(v),
+ Godot.Collections.Array v => v,
+ Godot.Collections.Dictionary v => v,
+ GodotObject godotObj => Variant.From(godotObj),
+ _ => ConvertOtherValueToVariant(value, rawType)
+ };
+ }
+
+
+ // Anything Variant supports that isn't covered by the fast paths above (typed collections like Array and Dictionary, packed arrays,
+ // Vector2/3/4, Color, etc) is boxed through the generic marshaller so it survives the trip into GDScript instead of silently becoming null.
+ Variant ConvertOtherValueToVariant(object value, Type rawType)
+ {
+ try
+ {
+ MethodInfo from = typeof(Variant)
+ .GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .First(m => m.Name == nameof(Variant.From) && m.IsGenericMethodDefinition)
+ .MakeGenericMethod(rawType);
+ return (Variant)from.Invoke(null, new[] { value })!;
+ }
+ catch (Exception)
+ {
+ return default;
+ }
+ }
+
+
+ public Array GetMethodList(GodotObject thing)
+ {
+ var methodList = new Array();
+
+ if (thing == null) return methodList;
+
+ Type type = thing.GetType();
+ MethodInfo[] methodInfos = GetMethodsForType(type);
+
+ foreach (MethodInfo method in methodInfos)
+ {
+ if (method.IsSpecialName) continue;
+
+ var methodInfo = new Dictionary();
+ methodInfo["name"] = method.Name;
+ methodInfo["flags"] = 0;
+ methodInfo["dotnet"] = true;
+
+ var argsList = new Array();
+ ParameterInfo[] parameters = method.GetParameters();
+
+ foreach (ParameterInfo parameter in parameters)
+ {
+ var paramInfo = new Dictionary() { };
+ Variant.Type godotType = ConvertToVariantType(parameter.ParameterType);
+
+ paramInfo["name"] = parameter.Name ?? "";
+ paramInfo["type"] = (int)godotType;
+ if (godotType == Variant.Type.Object)
+ {
+ paramInfo["class_name"] = parameter.ParameterType.Name;
+ }
+ else
+ {
+ paramInfo["class_name"] = string.Empty;
+ }
+
+ argsList.Add(paramInfo);
+ }
+
+ methodInfo["args"] = argsList;
+ methodList.Add(methodInfo);
+ }
+
+ return methodList;
+ }
+
+
+ private Variant.Type ConvertToVariantType(Type type)
+ {
+ if (type == typeof(void)) return Variant.Type.Nil;
+ if (type == typeof(bool)) return Variant.Type.Bool;
+ if (type == typeof(long) || type == typeof(int) || type == typeof(short) || type == typeof(byte)) return Variant.Type.Int;
+ if (type == typeof(double) || type == typeof(float)) return Variant.Type.Float;
+ if (type == typeof(string)) return Variant.Type.String;
+
+ if (type == typeof(Vector2)) return Variant.Type.Vector2;
+ if (type == typeof(Vector2I)) return Variant.Type.Vector2I;
+ if (type == typeof(Rect2)) return Variant.Type.Rect2;
+ if (type == typeof(Rect2I)) return Variant.Type.Rect2I;
+ if (type == typeof(Vector3)) return Variant.Type.Vector3;
+ if (type == typeof(Vector3I)) return Variant.Type.Vector3I;
+ if (type == typeof(Transform2D)) return Variant.Type.Transform2D;
+ if (type == typeof(Vector4)) return Variant.Type.Vector4;
+ if (type == typeof(Vector4I)) return Variant.Type.Vector4I;
+ if (type == typeof(Plane)) return Variant.Type.Plane;
+ if (type == typeof(Quaternion)) return Variant.Type.Quaternion;
+ if (type == typeof(Aabb)) return Variant.Type.Aabb;
+ if (type == typeof(Basis)) return Variant.Type.Basis;
+ if (type == typeof(Transform3D)) return Variant.Type.Transform3D;
+ if (type == typeof(Projection)) return Variant.Type.Projection;
+
+ if (type == typeof(Color)) return Variant.Type.Color;
+ if (type == typeof(StringName)) return Variant.Type.StringName;
+ if (type == typeof(NodePath)) return Variant.Type.NodePath;
+ if (type == typeof(Rid)) return Variant.Type.Rid;
+
+ if (typeof(GodotObject).IsAssignableFrom(type)) return Variant.Type.Object;
+ if (typeof(Dictionary).IsAssignableFrom(type)) return Variant.Type.Dictionary;
+ if (typeof(Godot.Collections.Array).IsAssignableFrom(type)) return Variant.Type.Array;
+
+ if (type == typeof(byte[])) return Variant.Type.PackedByteArray;
+ if (type == typeof(int[])) return Variant.Type.PackedInt32Array;
+ if (type == typeof(long[])) return Variant.Type.PackedInt64Array;
+ if (type == typeof(float[])) return Variant.Type.PackedFloat32Array;
+ if (type == typeof(double[])) return Variant.Type.PackedFloat64Array;
+ if (type == typeof(string[])) return Variant.Type.PackedStringArray;
+ if (type == typeof(Vector2[])) return Variant.Type.PackedVector2Array;
+ if (type == typeof(Vector3[])) return Variant.Type.PackedVector3Array;
+ if (type == typeof(Color[])) return Variant.Type.PackedColorArray;
+ if (type == typeof(Vector4[])) return Variant.Type.PackedVector4Array;
+
+ return Variant.Type.Nil;
+ }
+
+ private bool IsCompatible(Type expectedType, Type providedType)
+ {
+ if (expectedType.IsAssignableFrom(providedType)) return true;
+
+ Variant.Type expectedVariant = ConvertToVariantType(expectedType);
+ Variant.Type actualVariant = ConvertToVariantType(providedType);
+
+ return expectedVariant == actualVariant && expectedVariant != Variant.Type.Nil;
+ }
+
+
+
+ public bool ThingHasMethod(GodotObject thing, string method, Array args)
+ {
+ return GetMethodInfoFor(thing, method, args) != null;
+ }
+
+
+ public async void ResolveThingMethod(float id, GodotObject thing, string method, Array args)
+ {
+ // Add a single frame wait in case the method returns before signals can listen
+ await ToSignal(Engine.GetMainLoop(), SceneTree.SignalName.ProcessFrame);
+
+ var methodInfo = GetMethodInfoFor(thing, method, args);
+
+ if (methodInfo == null)
+ {
+ EmitSignal(SignalName.Resolved, id, default);
+ return;
+ }
+
+#nullable disable
+ // Convert the method args to something reflection can handle
+ ParameterInfo[] argTypes = methodInfo.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;
+ }
+ }
+
+ // invoke method and handle the result based on return type
+ object result = methodInfo.Invoke(thing, _args);
+
+ if (result is Task taskResult)
+ {
+ await taskResult;
+ try
+ {
+ object value = taskResult.GetType().GetProperty("Result").GetValue(taskResult);
+ EmitSignal(SignalName.Resolved, id, ConvertValueToVariant(value));
+ }
+ catch (Exception)
+ {
+ EmitSignal(SignalName.Resolved, id, default);
+ }
+ }
+ else
+ {
+ EmitSignal(SignalName.Resolved, id, ConvertValueToVariant(result));
+ }
+ }
+#nullable enable
+
+
+ private MethodInfo? GetMethodInfoFor(GodotObject thing, string method, Array args)
+ {
+ return GetMethodsForType(thing.GetType()).Where(m => m.Name == method).FirstOrDefault(m =>
+ {
+ ParameterInfo[] parameters = m.GetParameters();
+
+ if (args.Count > parameters.Length) return false;
+
+ for (int i = args.Count; i < parameters.Length; i++)
+ {
+ if (!parameters[i].IsOptional) return false;
+ }
+
+ Type[] argTypes = args.Select(arg =>
+ {
+ // If the item is boxed inside a Godot Variant, extract its actual type.
+ if (arg is Variant godotVariant)
+ {
+ return godotVariant.VariantType switch
+ {
+ Variant.Type.Nil => typeof(object),
+ Variant.Type.Bool => typeof(bool),
+ Variant.Type.Int => typeof(long), // Godot ints map to C# longs
+ Variant.Type.Float => typeof(double), // Godot floats map to C# doubles
+ Variant.Type.String => typeof(string),
+ Variant.Type.Object => godotVariant.AsGodotObject()?.GetType() ?? typeof(object),
+ _ => godotVariant.Obj?.GetType() ?? typeof(object)
+ };
+ }
+
+ return arg.GetType();
+ }).ToArray();
+
+ // Check each given parameter type against what the method wants.
+ for (int i = 0; i < argTypes.Length; i++)
+ {
+ Type expectedType = parameters[i].ParameterType;
+ Type actualType = argTypes[i];
+
+ // If a parameter isn't compatible skip this method overload.
+ if (!IsCompatible(expectedType, actualType))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }
+
+
+ public static string GetErrorMessage(int error)
+ {
+ return (string)Instance.Call("_bridge_get_error_message", error);
+ }
+
+
+ public static string Translate(string s)
+ {
+ return (string)Instance.Call("_bridge_translate", s);
+ }
+ }
+
+
+ public partial class DialogueLine : RefCounted
+ {
+ ///
+ /// The ID of this line
+ ///
+ public string Id { get; set; } = "";
+
+ ///
+ /// The internal type of this dialogue object.
+ ///
+ public string Type { get; set; } = "dialogue";
+
+ ///
+ /// The next line ID after this line.
+ ///
+ public string NextId { get; set; } = "";
+
+ ///
+ /// The character name that is saying this line.
+ ///
+ public string Character { get; set; } = "";
+
+ ///
+ /// The dialogue being spoken.
+ ///
+ public string Text { get; set; } = "";
+
+ ///
+ /// The key to use for translating this line.
+ ///
+ public string StaticId { get; set; } = "";
+
+ ///
+ /// A list of responses attached to this line of dialogue.
+ ///
+ public Array Responses { get; } = new Array();
+
+ ///
+ /// How long to show this line before advancing to the next. Either a float of seconds (as a string), "auto", or empty string.
+ ///
+ public string? Time { get; private set; }
+
+ ///
+ /// A map for speed changes when typing out the dialogue text.
+ ///
+ public Dictionary Speeds { get; private set; } = new Dictionary();
+
+ ///
+ /// A map of any mutations to run while typing out the dialogue text.
+ ///
+ public Array InlineMutations { get; private set; } = new Array();
+
+ ///
+ /// A list of lines that are spoken simultaneously with this one.
+ ///
+ public Array ConcurrentLines { get; } = new Array();
+
+ ///
+ /// A list of any extra game states to check when resolving variables and mutations.
+ ///
+ public Array ExtraGameStates { get; } = new Array();
+
+ ///
+ /// Any #tags that were included in the line
+ ///
+ public Array Tags { get; private set; } = new Array();
+
+
+ public DialogueLine(RefCounted data)
+ {
+ Id = (string)data.Get("id");
+ Type = (string)data.Get("type");
+ NextId = (string)data.Get("next_id");
+ Character = (string)data.Get("character");
+ Text = (string)data.Get("text");
+ StaticId = (string)data.Get("static_id");
+ Speeds = (Dictionary)data.Get("speeds");
+ InlineMutations = (Array)data.Get("inline_mutations");
+ Time = (string)data.Get("time");
+ Tags = (Array)data.Get("tags");
+
+ foreach (var concurrent_line_data in (Array)data.Get("concurrent_lines"))
+ {
+ ConcurrentLines.Add(new DialogueLine(concurrent_line_data));
+ }
+
+ foreach (var response in (Array)data.Get("responses"))
+ {
+ Responses.Add(new DialogueResponse(response));
+ }
+ }
+
+
+ ///
+ /// Check if a dialogue line has a given tag.
+ ///
+ ///
+ ///
+ public bool HasTag(string tagName)
+ {
+ if (Tags.Contains(tagName))
+ {
+ return true;
+ }
+ else
+ {
+ string wrapped = $"{tagName}=";
+ foreach (var tag in Tags)
+ {
+ if (tag.StartsWith(wrapped))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+
+ ///
+ /// Get the value of a tag if the tag is in the form of tag=value
+ ///
+ ///
+ ///
+ 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
+ {
+ ///
+ /// The ID of this response
+ ///
+ public string NextId { get; set; } = "";
+
+ ///
+ /// true if the condition of this line was met.
+ ///
+ public bool IsAllowed { get; set; } = true;
+
+ ///
+ /// The original condition text.
+ ///
+ public string ConditionAsText { get; set; } = "";
+
+ ///
+ /// The prompt for this response.
+ ///
+ public string Text { get; set; } = "";
+
+ ///
+ /// The key to use for translating the text.
+ ///
+ public string StaticId { get; set; } = "";
+
+ ///
+ /// Any #tags
+ ///
+ public Array Tags { get; private set; } = new Array();
+
+
+ public DialogueResponse(RefCounted data)
+ {
+ NextId = (string)data.Get("next_id");
+ IsAllowed = (bool)data.Get("is_allowed");
+ Text = (string)data.Get("text");
+ StaticId = (string)data.Get("static_id");
+ Tags = (Array)data.Get("tags");
+ }
+
+
+ ///
+ /// Check if a dialogue line has a given tag.
+ ///
+ ///
+ ///
+ public bool HasTag(string tagName)
+ {
+ if (Tags.Contains(tagName))
+ {
+ return true;
+ }
+ else
+ {
+ string wrapped = $"{tagName}=";
+ foreach (var tag in Tags)
+ {
+ if (tag.StartsWith(wrapped))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+
+ ///
+ /// Get the value of a tag if the tag is in the form of tag=value
+ ///
+ ///
+ ///
+ 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 $" bool: return true
+
+# Cue jumps are adjusted as they are parsed so any goto lines might need to be adjusted after they are first seen.
+var _goto_lines: Dictionary = {}
+
+
+#endregion
+
+#region Main
+
+
+## Compile some text.
+func compile(text: String, path: String = ".") -> Error:
+ file_path = path
+ cues = {}
+ character_names = []
+
+ text += "\n=> END"
+
+ # Remove any known static IDs for this file
+ for key: String in DMCache.known_static_ids.keys():
+ if DMCache.known_static_ids.get(key) == file_path:
+ DMCache.known_static_ids.erase(key)
+
+ find_imported_cues(text, file_path)
+ parse_line_tree(build_line_tree(text.split("\n")))
+
+ # Convert the compiles lines to a Dictionary so they can be stored.
+ for id: String in lines:
+ var line: DMCompiledLine = lines[id]
+ data[id] = line.to_data()
+
+ if errors.size() > 0:
+ return ERR_PARSE_ERROR
+
+ return OK
+
+
+## Inject any imported files
+func find_imported_cues(text: String, path: String) -> void:
+ # Work out imports
+ var known_imports: Dictionary = {}
+
+ # Include the base file path so that we can get around circular dependencies
+ known_imports[path] = "."
+
+ var raw_lines: PackedStringArray = text.split("\n")
+
+ for id: int in range(0, raw_lines.size()):
+ var line: String = raw_lines[id]
+
+ if not is_import_line(line): continue
+
+ var import_data: Dictionary = extract_import_path_and_name(line)
+
+ if import_data.size() == 0 or not import_data.has("path"): continue
+
+ if known_imports.has(import_data.path):
+ add_error(id, 0, DMConstants.ERR_FILE_ALREADY_IMPORTED)
+ elif known_imports.values().has(import_data.prefix):
+ add_error(id, 0, DMConstants.ERR_DUPLICATE_IMPORT_NAME)
+ else:
+ # Get cues from other file and map them to the known list of cues.
+ var cached_file_data: Dictionary = DMCache.get_file_data(import_data.path)
+
+ # Guard against failed loads -- namely during reimport cascade.
+ if cached_file_data.is_empty():
+ # Might be worth investigating a better constant here.
+ add_error(id, 0, DMConstants.ERR_ERRORS_IN_IMPORTED_FILE)
+ continue
+
+ var external_cues: Dictionary = cached_file_data.cues
+ if external_cues.is_empty():
+ var content: PackedStringArray = FileAccess.get_file_as_string(import_data.path).split("\n")
+ for i: int in range(0, content.size()):
+ var l: String = content[i]
+ if not "/" in l and get_line_type(l) == DMConstants.TYPE_CUE:
+ external_cues[l.substr(2).strip_edges()] = str(i)
+
+ var uid: String = ResourceUID.path_to_uid(import_data.path).replace("uid://", "")
+ for cue_key: String in external_cues:
+ # Ignore any cues that are already a reference
+ if "/" in cue_key: continue
+ # Create "alias/cue" to "uid@id" mappig
+ var cue_reference: String = "%s/%s" % [import_data.prefix, cue_key]
+ cues[cue_reference] = "%s@%s" % [uid, external_cues.get(cue_key)]
+
+ imported_paths.append(import_data.path)
+ known_imports[import_data.path] = import_data.prefix
+
+
+## Build a tree of parent/child relationships
+func build_line_tree(raw_lines: PackedStringArray) -> DMTreeLine:
+ var root: DMTreeLine = DMTreeLine.new("")
+ var parent_chain: Array[DMTreeLine] = [root]
+ var previous_line: DMTreeLine = null
+ var doc_comments: PackedStringArray = []
+
+ # Get list of known autoloads
+ var autoload_names: PackedStringArray = get_autoload_names()
+
+ for i: int in range(0, raw_lines.size()):
+ var raw_line: String = get_processor()._preprocess_line(raw_lines[i])
+ var tree_line: DMTreeLine = DMTreeLine.new(str(i))
+ var line_without_indent: String = strip_indent(raw_line)
+
+ tree_line.line_number = i + 1
+ tree_line.type = get_line_type(raw_line)
+ tree_line.text = line_without_indent.strip_edges()
+
+ # Handle any "using" directives.
+ if tree_line.type == DMConstants.TYPE_USING:
+ var using_match: RegExMatch = regex.USING_REGEX.search(line_without_indent)
+ 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(tree_line.line_number, 0, DMConstants.ERR_UNKNOWN_USING)
+ elif not using_state in using_states:
+ using_states.append(using_state)
+ continue
+ # Ignore import lines because they've already been processed.
+ elif is_import_line(line_without_indent):
+ continue
+
+ tree_line.indent = get_indent(raw_line)
+
+ # Attach doc comments
+ if tree_line.text.begins_with("##"):
+ doc_comments.append(tree_line.text.replace("##", "").strip_edges())
+ elif tree_line.type == DMConstants.TYPE_DIALOGUE or tree_line.type == DMConstants.TYPE_RESPONSE:
+ tree_line.notes = "\n".join(doc_comments)
+ doc_comments.clear()
+
+ # Empty lines are only kept so that we can work out groupings of things (eg. randomised
+ # lines). Therefore we only need to keep one empty line in a row even if there
+ # are multiple. The indent of an empty line is assumed to be the same as the non-empty line
+ # following it. That way, grouping calculations should work.
+ if tree_line.type in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_COMMENT] and raw_lines.size() > i + 1:
+ var next_line: String = raw_lines[i + 1]
+ if get_line_type(next_line) in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_COMMENT]:
+ continue
+ else:
+ tree_line.type = DMConstants.TYPE_UNKNOWN
+ tree_line.indent = get_indent(next_line)
+
+ # Nothing should be more than a single indent past its parent.
+ if tree_line.indent > parent_chain.size():
+ add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_INDENTATION)
+
+ # Check for indentation changes
+ if tree_line.indent > parent_chain.size() - 1:
+ parent_chain.append(previous_line)
+ elif tree_line.indent < parent_chain.size() - 1:
+ parent_chain.resize(tree_line.indent + 1)
+
+ # Add any cues to the list of known cues
+ if tree_line.type == DMConstants.TYPE_CUE:
+ var cue: String = tree_line.text.substr(2)
+ if cue == "":
+ add_error(i, 2, DMConstants.ERR_EMPTY_CUE)
+ elif cues.has(cue):
+ add_error(i + 1, 2, DMConstants.ERR_DUPLICATE_CUE)
+ else:
+ cues[cue] = tree_line.id
+ if first_cue == "":
+ first_cue = tree_line.id
+
+ # Append the current line to the current parent (note: the root is the most basic parent).
+ var parent: DMTreeLine = parent_chain[parent_chain.size() - 1]
+ tree_line.parent = weakref(parent)
+ parent.children.append(tree_line)
+
+ previous_line = tree_line
+
+ return root
+
+
+#endregion
+
+#region Parsing
+
+
+func parse_line_tree(root: DMTreeLine, parent: DMCompiledLine = null) -> Array[DMCompiledLine]:
+ var compiled_lines: Array[DMCompiledLine] = []
+
+ for i: int in range(0, root.children.size()):
+ var tree_line: DMTreeLine = root.children[i]
+ var line: DMCompiledLine = DMCompiledLine.new(tree_line.id, tree_line.type)
+
+ match line.type:
+ DMConstants.TYPE_UNKNOWN:
+ line.next_id = get_next_matching_sibling_id(root.children, i, parent, _first)
+
+ DMConstants.TYPE_CUE:
+ parse_cue_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_CONDITION:
+ parse_condition_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_WHILE:
+ parse_while_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_MATCH:
+ parse_match_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_WHEN:
+ parse_when_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_MUTATION:
+ parse_mutation_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_GOTO:
+ # Extract any weighted random calls before parsing dialogue
+ if tree_line.text.begins_with("%"):
+ parse_random_line(tree_line, line, root.children, i, parent)
+ parse_goto_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_RESPONSE:
+ parse_response_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_RANDOM:
+ parse_random_line(tree_line, line, root.children, i, parent)
+
+ DMConstants.TYPE_DIALOGUE:
+ # Extract any weighted random calls before parsing dialogue
+ if tree_line.text.begins_with("%"):
+ parse_random_line(tree_line, line, root.children, i, parent)
+ parse_dialogue_line(tree_line, line, root.children, i, parent)
+
+ # Main line map is keyed by ID
+ lines[line.id] = line
+
+ # Apply any post-processing.
+ get_processor()._process_line(line)
+
+ # Returned lines order is preserved so that it can be used for compiling children
+ compiled_lines.append(line)
+
+ return compiled_lines
+
+
+## Parse a cue and apply it to the given line
+func parse_cue_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ # Cues should never have child lines
+ if tree_line.children.size() > 0:
+ for invalid_child: DMTreeLine in tree_line.children:
+ add_error(invalid_child.line_number, invalid_child.indent, DMConstants.ERR_INVALID_INDENTATION)
+
+ line.text = tree_line.text.substr(tree_line.text.find("~ ") + 2).strip_edges()
+
+ # Labels can't have numbers as the first letter
+ if regex.BEGINS_WITH_NUMBER_REGEX.search(line.text):
+ result = add_error(tree_line.line_number, 2, DMConstants.ERR_CUE_BEGINS_WITH_NUMBER)
+
+ # Only import cues are allowed to have "/" in them
+ var valid_cue: RegExMatch = regex.VALID_CUE_REGEX.search(line.text.replace("/", ""))
+ if not valid_cue:
+ result = add_error(tree_line.line_number, 2, DMConstants.ERR_CUE_INVALID_CHARACTERS)
+
+ line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+
+ ## Update the cues reference to point to the actual first line
+ cues[line.text] = line.next_id
+
+ ## Update any lines that point to this cue
+ if _goto_lines.has(line.text):
+ for goto_line: DMCompiledLine in _goto_lines[line.text]:
+ goto_line.next_id = line.next_id
+
+ return result
+
+
+## Parse a goto and apply it to the given line.
+func parse_goto_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ # Work out where this line is jumping to.
+ var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(tree_line.text, cues)
+
+ if goto_data.error:
+ return add_error(tree_line.line_number, tree_line.indent + 2, goto_data.error)
+ if goto_data.next_id or goto_data.expression:
+ line.next_id = goto_data.next_id
+ line.next_id_expression = goto_data.expression
+ add_reference_to_cue(goto_data.cue, line)
+
+ if goto_data.is_snippet:
+ line.is_snippet = true
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+
+ return OK
+
+
+## Parse a condition line and apply to the given line
+func parse_condition_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ # Work out the next IDs before parsing the condition line itself so that the last
+ # child can inherit from the chain.
+
+ # Find the next conditional sibling that is part of this grouping (if there is one).
+ for next_sibling: DMTreeLine in siblings.slice(sibling_index + 1):
+ if not next_sibling.type in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_CONDITION]:
+ break
+ elif next_sibling.type == DMConstants.TYPE_CONDITION:
+ if next_sibling.text.begins_with("el"):
+ line.next_sibling_id = next_sibling.id
+ break
+ else:
+ break
+
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine) -> bool:
+ # The next line that isn't a conditional or is a new "if"
+ return s.type != DMConstants.TYPE_CONDITION or s.text.begins_with("if ")
+ )
+ # Any empty IDs should end the conversation.
+ if line.next_id_after == DMConstants.ID_NULL:
+ line.next_id_after = parent.next_id_after if parent != null and parent.next_id_after else str(DMConstants.ID_END)
+
+ # Having no nested body is an immediate failure.
+ if tree_line.children.size() == 0:
+ return add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION)
+
+ # Try to parse the conditional expression ("else" has no expression).
+ if "if " in tree_line.text:
+ var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent)
+ if condition.has("error"):
+ return add_error(tree_line.line_number, condition.index, condition.error)
+ else:
+ line.expression = condition
+
+ # Parse any nested body lines
+ parse_children(tree_line, line)
+
+ return OK
+
+
+## Parse a while loop and apply it to the given line.
+func parse_while_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+
+ # Parse the while condition
+ var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent)
+ if condition.has("error"):
+ return add_error(tree_line.line_number, condition.index, condition.error)
+ else:
+ line.expression = condition
+
+ # Parse the nested body (it should take care of looping back to this line when it finishes)
+ parse_children(tree_line, line)
+
+ return OK
+
+
+func parse_match_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ # The next line after is the next sibling
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+
+ # Extract the condition to match to
+ var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent)
+ if condition.has("error"):
+ result = add_error(tree_line.line_number, condition.index, condition.error)
+ else:
+ line.expression = condition
+
+ # Match statements should have children
+ if tree_line.children.size() == 0:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION)
+
+ # Check that all children are when or else.
+ for child: DMTreeLine in tree_line.children:
+ if child.type == DMConstants.TYPE_WHEN: continue
+ if child.type == DMConstants.TYPE_UNKNOWN: continue
+ if child.type == DMConstants.TYPE_CONDITION and child.text.begins_with("else"): continue
+
+ result = add_error(child.line_number, child.indent, DMConstants.ERR_EXPECTED_WHEN_OR_ELSE)
+
+ # Each child should be a "when" or "else". We don't need those lines themselves, just their
+ # condition and the line they point to if the conditions passes.
+ var children: Array[DMCompiledLine] = parse_children(tree_line, line)
+ for child: DMCompiledLine in children:
+ # "when" cases
+ if child.type == DMConstants.TYPE_WHEN:
+ line.siblings.append({
+ condition = child.expression,
+ next_id = child.next_id
+ })
+ # "else" case
+ elif child.type == DMConstants.TYPE_CONDITION:
+ if line.siblings.any(func(s: Dictionary) -> bool: return s.has("is_else")):
+ result = add_error(child.line_number, child.indent, DMConstants.ERR_ONLY_ONE_ELSE_ALLOWED)
+ else:
+ line.siblings.append({
+ next_id = child.next_id,
+ is_else = true
+ })
+ # Remove the line from the list of all lines because we don't need it any more.
+ lines.erase(child.id)
+
+ return result
+
+
+@warning_ignore("unused_parameter")
+func parse_when_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ # This when line should be found inside a match line
+ if parent == null or parent.type != DMConstants.TYPE_MATCH:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_WHEN_MUST_BELONG_TO_MATCH)
+
+ # When lines should have children
+ if tree_line.children.size() == 0:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION)
+
+ # The next line after a when is the same as its parent match line
+ if parent != null:
+ line.next_id_after = parent.next_id_after
+
+ # Extract the condition to match to
+ var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent)
+ if condition.has("error"):
+ result = add_error(tree_line.line_number, condition.index, condition.error)
+ else:
+ line.expression = condition
+
+ parse_children(tree_line, line)
+
+ return result
+
+
+## Parse a mutation line and apply it to the given line
+func parse_mutation_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var mutation: Dictionary = extract_mutation(tree_line.text)
+ if mutation.has("error"):
+ return add_error(tree_line.line_number, mutation.index, mutation.error)
+ else:
+ line.expression = mutation
+
+ line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+
+ return OK
+
+
+## Parse a response and apply it to the given line.
+func parse_response_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ # Remove the "- "
+ tree_line.text = tree_line.text.substr(2)
+
+ # Attach any doc comments.
+ line.notes = tree_line.notes
+
+ # Extract the static line ID
+ var static_line_id: String = extract_static_line_id(tree_line.text)
+ if static_line_id:
+ if DMCache.known_static_ids.has(static_line_id):
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_DUPLICATE_ID)
+ else:
+ DMCache.known_static_ids[static_line_id] = file_path
+ tree_line.text = tree_line.text.replace("[ID:%s]" % [static_line_id], "")
+ line.static_id = static_line_id
+
+ # Handle conditional responses and remove them from the prompt text.
+ if "[if " in tree_line.text and " /]" in tree_line.text:
+ var condition: Dictionary = extract_condition(tree_line.text, true, tree_line.indent)
+ if condition.has("error"):
+ result = add_error(tree_line.line_number, condition.index, condition.error)
+ else:
+ line.expression = condition
+ # Extract just the raw condition text
+ var found: RegExMatch = regex.WRAPPED_CONDITION_REGEX.search(tree_line.text)
+ line.expression_text = found.strings[found.names.expression]
+
+ tree_line.text = regex.WRAPPED_CONDITION_REGEX.sub(tree_line.text, "").strip_edges()
+
+ # Find the original response in this group of responses.
+ var original_response: DMTreeLine = tree_line
+ for i: int in range(sibling_index - 1, -1, -1):
+ if siblings[i].type == DMConstants.TYPE_RESPONSE:
+ original_response = siblings[i]
+ elif siblings[i].type != DMConstants.TYPE_UNKNOWN:
+ break
+
+ # If it's the original response then set up an original line.
+ if original_response == tree_line:
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, (func(s: DMTreeLine) -> bool:
+ # The next line that isn't a response.
+ return not s.type in [DMConstants.TYPE_RESPONSE, DMConstants.TYPE_UNKNOWN]
+ ), true)
+ line.responses = [line.id]
+ # If this line has children then the next ID is the first child.
+ if tree_line.children.size() > 0:
+ parse_children(tree_line, line)
+ # Otherwise use the same ID for after the random group.
+ else:
+ line.next_id = line.next_id_after
+ # Otherwise let the original line know about it.
+ else:
+ var original_line: DMCompiledLine = lines[original_response.id]
+ line.next_id_after = original_line.next_id_after
+ line.siblings = original_line.siblings
+ original_line.responses.append(line.id)
+ # If this line has children then the next ID is the first child.
+ if tree_line.children.size() > 0:
+ parse_children(tree_line, line)
+ # Otherwise use the original line's next ID after.
+ else:
+ line.next_id = original_line.next_id_after
+
+ parse_character_and_dialogue(tree_line, line, siblings, sibling_index, parent)
+
+ return result
+
+
+## Parse a randomised line
+func parse_random_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error:
+ # Find the weight
+ var weight: float = 1
+ var found: RegExMatch = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.search(tree_line.text + " ")
+ var condition: Dictionary = {}
+ if found:
+ if found.names.has("weight"):
+ weight = found.strings[found.names.weight].to_float()
+ if found.names.has("condition"):
+ condition = extract_condition("if " + found.strings[found.names.condition], false, tree_line.indent)
+
+ # Find the original random sibling. It will be the jump off point.
+ var original_sibling: DMTreeLine = tree_line
+ for i: int in range(sibling_index - 1, -1, -1):
+ if siblings[i] and siblings[i].is_random:
+ original_sibling = siblings[i]
+ else:
+ break
+
+ var weighted_sibling: Dictionary = { weight = weight, id = line.id, condition = condition }
+
+ # If it's the original sibling then set up an original line.
+ if original_sibling == tree_line:
+ line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, (func(s: DMTreeLine) -> bool:
+ # The next line that isn't a randomised line.
+ # NOTE: DMTreeLine.is_random won't be set at this point so we need to check for the "%" prefix.
+ return not s.text.begins_with("%")
+ ), true)
+ line.siblings = [weighted_sibling]
+ # If this line has children then the next ID is the first child.
+ if tree_line.children.size() > 0:
+ parse_children(tree_line, line)
+ # Otherwise use the same ID for after the random group.
+ else:
+ line.next_id = line.next_id_after
+
+ # Otherwise let the original line know about it.
+ else:
+ var original_line: DMCompiledLine = lines[original_sibling.id]
+ line.next_id_after = original_line.next_id_after
+ line.siblings = original_line.siblings
+ original_line.siblings.append(weighted_sibling)
+ # If this line has children then the next ID is the first child.
+ if tree_line.children.size() > 0:
+ parse_children(tree_line, line)
+ # Otherwise use the original line's next ID after.
+ else:
+ line.next_id = original_line.next_id_after
+
+ # Remove the randomise syntax from the line.
+ tree_line.text = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(tree_line.text, "")
+ tree_line.is_random = true
+
+ return OK
+
+
+## Parse some dialogue and apply it to the given line.
+func parse_dialogue_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ # Append any further dialogue
+ for i: int in range(0, tree_line.children.size()):
+ var child: DMTreeLine = tree_line.children[i]
+ if child.type == DMConstants.TYPE_DIALOGUE:
+ # Nested dialogue lines cannot have further nested dialogue.
+ if child.children.size() > 0:
+ add_error(child.children[0].line_number, child.children[0].indent, DMConstants.ERR_INVALID_INDENTATION)
+ # Mark this as a dialogue child of another dialogue line.
+ child.is_nested_dialogue = true
+ var child_line: DMCompiledLine = DMCompiledLine.new("", DMConstants.TYPE_DIALOGUE)
+ parse_character_and_dialogue(child, child_line, [], 0, parent)
+ var child_static_line_id: String = extract_static_line_id(child.text)
+ if child_line.character != "" or child_static_line_id != "":
+ add_error(child.line_number, child.indent, DMConstants.ERR_UNEXPECTED_SYNTAX_ON_NESTED_DIALOGUE_LINE)
+ # Check that only the last child (or none) has a jump reference
+ if i < tree_line.children.size() - 1 and " =>" in child.text:
+ add_error(child.line_number, child.indent, DMConstants.ERR_NESTED_DIALOGUE_INVALID_JUMP)
+ if i == 0 and " =>" in tree_line.text:
+ add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_NESTED_DIALOGUE_INVALID_JUMP)
+
+ tree_line.text += "\n" + child_line.text
+ elif child.type == DMConstants.TYPE_UNKNOWN:
+ tree_line.text += "\n"
+ else:
+ result = add_error(child.line_number, child.indent, DMConstants.ERR_INVALID_INDENTATION)
+
+ # Extract the static line ID
+ var static_line_id: String = extract_static_line_id(tree_line.text)
+ if static_line_id:
+ if tree_line.text == "[ID:%s]" % [static_line_id]:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_LONELY_STATIC_ID)
+
+ if DMCache.known_static_ids.has(static_line_id):
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_DUPLICATE_ID)
+ else:
+ DMCache.known_static_ids[static_line_id] = file_path
+
+ tree_line.text = tree_line.text.replace(" [ID:", "[ID:").replace("[ID:%s]" % [static_line_id], "")
+ line.static_id = static_line_id
+
+ # Check for simultaneous lines
+ if tree_line.text.begins_with("| "):
+ # Jumps are only allowed on the origin line.
+ if " =>" in tree_line.text:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_GOTO_NOT_ALLOWED_ON_CONCURRECT_LINES)
+ # Check for a valid previous line.
+ tree_line.text = tree_line.text.substr(2)
+ var previous_sibling: DMTreeLine = siblings[sibling_index - 1]
+ if previous_sibling.type != DMConstants.TYPE_DIALOGUE:
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_CONCURRENT_LINE_WITHOUT_ORIGIN)
+ else:
+ # Because the previous line's concurrent_lines array is the same as
+ # any line before that this doesn't need to check any higher up.
+ var previous_line: DMCompiledLine = lines[previous_sibling.id]
+ previous_line.concurrent_lines.append(line.id)
+ line.concurrent_lines = previous_line.concurrent_lines
+
+ parse_character_and_dialogue(tree_line, line, siblings, sibling_index, parent)
+
+ # Check for any inline expression errors
+ var bbcodes: Array[Dictionary] = DMResolvedLineData.find_bbcode_positions_in_string(tree_line.text, true, true)
+ for bbcode: Dictionary in bbcodes:
+ var tag: String = bbcode.code
+ var code: String = bbcode.raw_args
+ if tag.begins_with("$>") or tag.begins_with("do") or tag.begins_with("set") or tag.begins_with("if"):
+ var expression: Array = expression_parser.tokenise(code, DMConstants.TYPE_MUTATION, bbcode.start + bbcode.code.length())
+ if expression.size() == 0:
+ add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_EXPRESSION)
+ elif expression[0].type == DMConstants.TYPE_ERROR:
+ add_error(tree_line.line_number, tree_line.indent + expression[0].i, expression[0].value)
+
+ # If the line isn't part of a weighted random group then make it point to the next
+ # available sibling.
+ if line.next_id == DMConstants.ID_NULL and line.siblings.size() == 0:
+ line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine) -> bool:
+ # Ignore concurrent lines.
+ return not s.text.begins_with("| ")
+ )
+
+ return result
+
+
+## Parse the character name and dialogue and apply it to a given line.
+func parse_character_and_dialogue(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> int:
+ var result: int = OK
+
+ var text: String = tree_line.text
+
+ # Remove escape character
+ if text.begins_with("\\using"): text = text.substr(1)
+ if text.begins_with("\\if"): text = text.substr(1)
+ if text.begins_with("\\elif"): text = text.substr(1)
+ if text.begins_with("\\else"): text = text.substr(1)
+ if text.begins_with("\\while"): text = text.substr(1)
+ if text.begins_with("\\match"): text = text.substr(1)
+ if text.begins_with("\\when"): text = text.substr(1)
+ if text.begins_with("\\do"): text = text.substr(1)
+ if text.begins_with("\\set"): text = text.substr(1)
+ if text.begins_with("\\-"): text = text.substr(1)
+ if text.begins_with("\\~"): text = text.substr(1)
+ if text.begins_with("\\=>"): text = text.substr(1)
+ if text.begins_with("\\%"): text = text.substr(1)
+
+ # Attach any doc comments.
+ line.notes = tree_line.notes
+
+ # Extract tags.
+ var tag_data: DMResolvedTagData = DMResolvedTagData.new(text)
+ line.tags = tag_data.tags
+ text = tag_data.text_without_tags
+
+ # Handle inline gotos and remove them from the prompt text.
+ if " =><" in text:
+ # 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_data: DMResolvedGotoData = DMResolvedGotoData.new(text, cues)
+ if goto_data.error:
+ result = add_error(tree_line.line_number, tree_line.indent + 3, goto_data.error)
+ if goto_data.next_id or goto_data.expression:
+ text = goto_data.text_without_goto
+ var goto_line: DMCompiledLine = DMCompiledLine.new(line.id + ".1", DMConstants.TYPE_GOTO)
+ goto_line.next_id = goto_data.next_id
+ line.next_id_expression = goto_data.expression
+ if line.type == DMConstants.TYPE_RESPONSE:
+ goto_line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine) -> bool:
+ # If this is coming from a response then we want the next non-response line.
+ return s.type != DMConstants.TYPE_RESPONSE
+ )
+ else:
+ goto_line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first)
+ goto_line.is_snippet = true
+ lines[goto_line.id] = goto_line
+ line.next_id = goto_line.id
+ add_reference_to_cue(goto_data.cue, goto_line)
+ elif " =>" in text:
+ var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(text, cues)
+ if goto_data.error:
+ result = add_error(tree_line.line_number, tree_line.indent + 2, goto_data.error)
+ if goto_data.next_id or goto_data.expression:
+ text = goto_data.text_without_goto
+ line.next_id = goto_data.next_id
+ line.next_id_expression = goto_data.expression
+ add_reference_to_cue(goto_data.cue, line)
+
+ # Handle the dialogue.
+ text = text.replace("\\:", "!ESCAPED_COLON!")
+ if ": " in text:
+ # If a character was given then split it out.
+ var bits: Array = Array(text.split(": "))
+ line.character = bits.pop_front().strip_edges().replace("!ESCAPED_COLON!", ":")
+ if not line.character in character_names:
+ character_names.append(line["character"])
+ # Character names can have expressions in them.
+ line.character_replacements = expression_parser.extract_replacements(line.character, tree_line.indent)
+ for replacement: Dictionary in line.character_replacements:
+ if replacement.has("error"):
+ result = add_error(tree_line.line_number, replacement.index, replacement.error)
+ text = ": ".join(bits).replace("!ESCAPED_COLON!", ":")
+ else:
+ line.character = ""
+ text = text.replace("!ESCAPED_COLON!", ":")
+
+ # Extract any expressions in the dialogue.
+ line.text_replacements = expression_parser.extract_replacements(text, line.character.length() + 2 + tree_line.indent)
+ for replacement: Dictionary in line.text_replacements:
+ if replacement.has("error"):
+ result = add_error(tree_line.line_number, replacement.index, replacement.error)
+
+ # Replace any newlines.
+ text = text.replace("\\n", "\n").strip_edges()
+
+ # If there was no manual static ID and we have errors enabled then show an error
+ if not tree_line.is_nested_dialogue \
+ and line.static_id == "" \
+ and DMSettings.get_setting(DMSettings.MISSING_TRANSLATIONS_ARE_ERRORS, false):
+ result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_MISSING_ID)
+
+ line.text = text
+
+ return result
+
+
+#endregion
+
+#region Errors
+
+
+## Add a compilation error to the list. Returns the given error code.
+func add_error(line_number: int, column_number: int, error: int) -> int:
+ errors.append(DMError.new({
+ line_number = line_number,
+ column_number = column_number,
+ error = error
+ }))
+ return error
+
+
+#endregion
+
+#region Helpers
+
+
+## Get the names of any autoloads in the project.
+func get_autoload_names() -> PackedStringArray:
+ var autoloads: PackedStringArray = []
+
+ var project: ConfigFile = ConfigFile.new()
+ project.load("res://project.godot")
+ if project.has_section("autoload"):
+ return Array(project.get_section_keys("autoload")).filter(func(key: String) -> bool: return key != "DialogueManager")
+
+ return autoloads
+
+
+## Check if a line is importing another file.
+func is_import_line(text: String) -> bool:
+ return text.begins_with("import ") and " as " in text
+
+
+## Extract the import information from an import line
+func extract_import_path_and_name(line: String) -> Dictionary:
+ var found: RegExMatch = regex.IMPORT_REGEX.search(line)
+ if found:
+ return {
+ path = found.strings[found.names.path],
+ prefix = found.strings[found.names.prefix]
+ }
+ else:
+ return {}
+
+
+## Load the configured processor (or the default one is none configured).
+func get_processor() -> DMDialogueProcessor:
+ if processor == null:
+ var processor_path: String = DMSettings.get_setting(DMSettings.DIALOGUE_PROCESSOR_PATH, "")
+ processor = DMDialogueProcessor.new() if processor_path.is_empty() else load(processor_path).new()
+ return processor
+
+
+## Get the indent of a raw line
+func get_indent(raw_line: String) -> int:
+ var indent_token: String = get_line_indent_token(raw_line)
+
+ if indent_token.is_empty(): return 0
+
+ var indent: int = 0
+ var remaining: String = raw_line
+ while remaining.begins_with(indent_token):
+ indent += 1
+ remaining = remaining.substr(indent_token.length())
+
+ return indent
+
+
+## Remove any leading indentation token from a raw line.
+func strip_indent(raw_line: String) -> String:
+ var indent_token: String = get_line_indent_token(raw_line)
+
+ if indent_token.is_empty(): return raw_line
+
+ while raw_line.begins_with(indent_token):
+ raw_line = raw_line.substr(indent_token.length())
+
+ return raw_line
+
+
+## Get the indentation token used by a line.
+func get_line_indent_token(raw_line: String) -> String:
+ for prefix: String in ["\t", "\\t", " ", " "]:
+ if raw_line.begins_with(prefix):
+ return prefix
+ return ""
+
+
+## Get the type of a raw line
+func get_line_type(raw_line: String) -> String:
+ raw_line = strip_indent(raw_line).strip_edges()
+ var text: String = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line + " ", "").strip_edges()
+
+ if text.begins_with("import "):
+ return DMConstants.TYPE_IMPORT
+
+ if text.begins_with("using "):
+ return DMConstants.TYPE_USING
+
+ if text.begins_with("#"):
+ return DMConstants.TYPE_COMMENT
+
+ if text.begins_with("~ "):
+ return DMConstants.TYPE_CUE
+
+ if text.begins_with("if ") or text.begins_with("elif") or text.begins_with("else"):
+ return DMConstants.TYPE_CONDITION
+
+ if text.begins_with("while "):
+ return DMConstants.TYPE_WHILE
+
+ if text.begins_with("match "):
+ return DMConstants.TYPE_MATCH
+
+ if text.begins_with("when "):
+ return DMConstants.TYPE_WHEN
+
+ if text.begins_with("do ") or text.begins_with("do! ") or text.begins_with("set ") or text.begins_with("$> ") or text.begins_with("$>> "):
+ return DMConstants.TYPE_MUTATION
+
+ if text.begins_with("=> ") or text.begins_with("=>< "):
+ return DMConstants.TYPE_GOTO
+
+ if text.begins_with("- "):
+ return DMConstants.TYPE_RESPONSE
+
+ if raw_line.begins_with("%") and text.is_empty():
+ return DMConstants.TYPE_RANDOM
+
+ if not text.is_empty():
+ return DMConstants.TYPE_DIALOGUE
+
+ return DMConstants.TYPE_UNKNOWN
+
+
+## Get the next sibling that passes a [Callable] matcher.
+func get_next_matching_sibling_id(siblings: Array[DMTreeLine], from_index: int, parent: DMCompiledLine, matcher: Callable, with_empty_lines: bool = false) -> String:
+ for i: int in range(from_index + 1, siblings.size()):
+ var next_sibling: DMTreeLine = siblings[i]
+
+ if not with_empty_lines:
+ # Ignore empty lines
+ if not next_sibling or next_sibling.type == DMConstants.TYPE_UNKNOWN:
+ continue
+
+ if matcher.call(next_sibling):
+ return next_sibling.id
+
+ # If no next ID can be found then check the parent for where to go next.
+ if parent != null:
+ return parent.id if parent.type == DMConstants.TYPE_WHILE else parent.next_id_after
+
+ return DMConstants.ID_NULL
+
+
+## Extract a static line ID from some text.
+func extract_static_line_id(text: String) -> String:
+ # Find a static ID, eg. [ID:something]
+ var found: RegExMatch = regex.STATIC_LINE_ID_REGEX.search(text)
+ if found:
+ return found.strings[found.names.id]
+ else:
+ return ""
+
+
+## Extract a condition (or inline condition) from some text.
+func extract_condition(text: String, is_wrapped: bool, index: int) -> Dictionary:
+ var condition_regex: RegEx = regex.WRAPPED_CONDITION_REGEX if is_wrapped else regex.CONDITION_REGEX
+ var found: RegExMatch = condition_regex.search(text)
+
+ if found == null:
+ return {
+ index = 0,
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+ var raw_condition: String = found.strings[found.names.expression]
+ if raw_condition.ends_with(":"):
+ raw_condition = raw_condition.substr(0, raw_condition.length() - 1)
+
+ var expression: Array = expression_parser.tokenise(raw_condition, DMConstants.TYPE_CONDITION, index + found.get_start("expression"))
+
+ if expression.size() == 0:
+ return {
+ index = index + found.get_start("expression"),
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DMConstants.TYPE_ERROR:
+ return {
+ index = expression[0].i,
+ error = expression[0].value
+ }
+ else:
+ return {
+ expression = expression
+ }
+
+
+## Extract a mutation from some text.
+func extract_mutation(text: String) -> Dictionary:
+ var found: RegExMatch = regex.MUTATION_REGEX.search(text)
+
+ if not found:
+ return {
+ index = 0,
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+ if found.names.has("expression"):
+ var expression: Array = expression_parser.tokenise(found.strings[found.names.expression], DMConstants.TYPE_MUTATION, found.get_start("expression"))
+ if expression.size() == 0:
+ return {
+ index = found.get_start("expression"),
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DMConstants.TYPE_ERROR:
+ return {
+ index = expression[0].i,
+ error = expression[0].value
+ }
+ else:
+ return {
+ expression = expression,
+ is_blocking = not "!" in found.strings[found.names.keyword] and found.strings[found.names.keyword] != "$>>"
+ }
+
+ else:
+ return {
+ index = found.get_start(),
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+
+
+## Keep track of lines referencing cues because their own next_id might not have been resolved yet.
+func add_reference_to_cue(cue: String, line: DMCompiledLine) -> void:
+ if cue in [DMConstants.ID_END, DMConstants.ID_END_CONVERSATION, DMConstants.ID_NULL]: return
+
+ if not _goto_lines.has(cue):
+ _goto_lines[cue] = []
+ _goto_lines[cue].append(line)
+
+
+## Parse a nested block of child lines
+func parse_children(tree_line: DMTreeLine, line: DMCompiledLine) -> Array[DMCompiledLine]:
+ var children: Array[DMCompiledLine] = parse_line_tree(tree_line, line)
+ if children.size() > 0:
+ line.next_id = children.front().id
+ # The last child should jump to the next line after its parent condition group
+ var last_child: DMCompiledLine = children.back()
+ if last_child.next_id == DMConstants.ID_NULL:
+ last_child.next_id = line.next_id_after
+ if last_child.siblings.size() > 0:
+ for sibling: Dictionary in last_child.siblings:
+ lines.get(sibling.id).next_id = last_child.next_id
+
+ return children
+
+
+#endregion
diff --git a/addons/dialogue_manager/compiler/compilation.gd.uid b/addons/dialogue_manager/compiler/compilation.gd.uid
new file mode 100644
index 0000000..24a13ee
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compilation.gd.uid
@@ -0,0 +1 @@
+uid://dsgpnyqg6cprg
diff --git a/addons/dialogue_manager/compiler/compiled_line.gd b/addons/dialogue_manager/compiler/compiled_line.gd
new file mode 100644
index 0000000..e3d826c
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiled_line.gd
@@ -0,0 +1,161 @@
+## A compiled line of dialogue.
+class_name DMCompiledLine extends RefCounted
+
+
+## The ID of the line
+var id: String
+## The static line ID (or translation key).
+var static_id: String = ""
+## The type of line.
+var type: String = ""
+## The character name.
+var character: String = ""
+## Any interpolation expressions for the character name.
+var character_replacements: Array[Dictionary] = []
+## The text of the line.
+var text: String = ""
+## Any interpolation expressions for the text.
+var text_replacements: Array[Dictionary] = []
+## Any response siblings associated with this line.
+var responses: PackedStringArray = []
+## Any randomise or case siblings for this line.
+var siblings: Array[Dictionary] = []
+## Any lines said simultaneously.
+var concurrent_lines: PackedStringArray = []
+## Any tags on this line.
+var tags: PackedStringArray = []
+## The condition or mutation expression for this line.
+var expression: Dictionary = {}
+## The express as the raw text that was given.
+var expression_text: String = ""
+## The next sequential line to go to after this line.
+var next_id: String = ""
+## The next line to go to after this line if it is unknown and compile time.
+var next_id_expression: Array[Dictionary] = []
+## Whether this jump line should return after the jump target sequence has ended.
+var is_snippet: bool = false
+## The ID of the next sibling line.
+var next_sibling_id: String = ""
+## The ID after this line if it belongs to a block (eg. conditions).
+var next_id_after: String = ""
+## Any doc comments attached to this line.
+var notes: String = ""
+
+
+#region Hooks
+
+
+func _init(initial_id: String, initial_type: String) -> void:
+ id = initial_id
+ type = initial_type
+
+
+func _to_string() -> String:
+ var s: Array = [
+ "[%s]" % [type],
+ "%s:" % [character] if character != "" else "",
+ text if text != "" else "",
+ str(expression) if expression.size() > 0 else "",
+ "[%s]" % [",".join(tags)] if tags.size() > 0 else "",
+ str(siblings) if siblings.size() > 0 else "",
+ str(responses) if responses.size() > 0 else "",
+ "=> END" if "end" in next_id else "=> %s" % [next_id],
+ "(~> %s)" % [next_sibling_id] if next_sibling_id != "" else "",
+ "(==> %s)" % [next_id_after] if next_id_after != "" else "",
+ ].filter(func(item: String) -> bool: return item != "")
+
+ return " ".join(s)
+
+
+#endregion
+
+#region Helpers
+
+
+## Express this line as a [Dictionary] that can be stored in a resource.
+func to_data() -> Dictionary:
+ var d: Dictionary = {
+ id = id,
+ type = type,
+ next_id = next_id
+ }
+
+ if next_id_expression.size() > 0:
+ d.next_id_expression = next_id_expression
+
+ match type:
+ DMConstants.TYPE_CONDITION:
+ d.condition = expression
+ if not next_sibling_id.is_empty():
+ d.next_sibling_id = next_sibling_id
+ d.next_id_after = next_id_after
+
+ DMConstants.TYPE_WHILE:
+ d.condition = expression
+ d.next_id_after = next_id_after
+
+ DMConstants.TYPE_MATCH:
+ d.condition = expression
+ d.next_id_after = next_id_after
+ d.cases = siblings
+
+ DMConstants.TYPE_MUTATION:
+ d.mutation = expression
+
+ DMConstants.TYPE_GOTO:
+ d.is_snippet = is_snippet
+ d.next_id_after = next_id_after
+ if not siblings.is_empty():
+ d.siblings = siblings
+
+ DMConstants.TYPE_RANDOM:
+ d.siblings = siblings
+
+ DMConstants.TYPE_RESPONSE:
+ d.text = text
+
+ if not responses.is_empty():
+ d.responses = responses
+
+ if static_id != text:
+ d.static_id = static_id
+ if not expression.is_empty():
+ d.condition = expression
+ if not character.is_empty():
+ d.character = character
+ if not character_replacements.is_empty():
+ d.character_replacements = character_replacements
+ if not text_replacements.is_empty():
+ d.text_replacements = text_replacements
+ if not tags.is_empty():
+ d.tags = tags
+ if not notes.is_empty():
+ d.notes = notes
+ if not expression_text.is_empty():
+ d.condition_as_text = expression_text
+
+ DMConstants.TYPE_DIALOGUE:
+ d.text = text
+
+ if static_id != text:
+ d.static_id = static_id
+
+ if not character.is_empty():
+ d.character = character
+ if not character_replacements.is_empty():
+ d.character_replacements = character_replacements
+ if not text_replacements.is_empty():
+ d.text_replacements = text_replacements
+ if not tags.is_empty():
+ d.tags = tags
+ if not notes.is_empty():
+ d.notes = notes
+ if not siblings.is_empty():
+ d.siblings = siblings
+ if not concurrent_lines.is_empty():
+ d.concurrent_lines = concurrent_lines
+
+ return d
+
+
+#endregion
diff --git a/addons/dialogue_manager/compiler/compiled_line.gd.uid b/addons/dialogue_manager/compiler/compiled_line.gd.uid
new file mode 100644
index 0000000..17ec55e
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiled_line.gd.uid
@@ -0,0 +1 @@
+uid://dg8j5hudp4210
diff --git a/addons/dialogue_manager/compiler/compiler.gd b/addons/dialogue_manager/compiler/compiler.gd
new file mode 100644
index 0000000..c81da01
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler.gd
@@ -0,0 +1,57 @@
+## A compiler of Dialogue Manager dialogue.
+class_name DMCompiler extends RefCounted
+
+
+## Compile a dialogue script.
+static func compile_string(text: String, path: String) -> DMCompilerResult:
+ var compilation: DMCompilation = DMCompilation.new()
+ compilation.compile(text, path)
+
+ var result: DMCompilerResult = DMCompilerResult.new()
+ result.imported_paths = compilation.imported_paths
+ result.using_states = compilation.using_states
+ result.character_names = compilation.character_names
+ result.cues = compilation.cues
+ result.first_cue = compilation.first_cue
+ result.errors = compilation.errors
+ result.lines = compilation.data
+
+ return result
+
+
+## Get the line type of a string. The returned string will match one of the [code]TYPE_[/code] constants of [DMConstants].
+static func get_line_type(text: String) -> String:
+ var compilation: DMCompilation = DMCompilation.new()
+ return compilation.get_line_type(text)
+
+
+## Get the static line ID (eg. [code][ID:SOMETHING][/code]) of some text.
+static func get_static_line_id(text: String) -> String:
+ var compilation: DMCompilation = DMCompilation.new()
+ return compilation.extract_static_line_id(text)
+
+
+## Get the translatable part of a line.
+static func extract_translatable_string(text: String) -> String:
+ var compilation: DMCompilation = DMCompilation.new()
+
+ var tree_line: DMTreeLine = DMTreeLine.new("")
+ tree_line.text = text
+ var line: DMCompiledLine = DMCompiledLine.new("", compilation.get_line_type(text))
+ compilation.parse_character_and_dialogue(tree_line, line, [tree_line], 0, null)
+
+ return line.text
+
+
+## Extract a mutation from a string.
+static func extract_mutation(text: String) -> Dictionary:
+ var compilation: DMCompilation = DMCompilation.new()
+ return compilation.extract_mutation(text)
+
+
+## Get the known cues in a dialogue script.
+static func get_cues_in_text(text: String, path: String) -> Dictionary:
+ var compilation: DMCompilation = DMCompilation.new()
+ compilation.find_imported_cues(text, path)
+ compilation.build_line_tree(text.split("\n"))
+ return compilation.cues
diff --git a/addons/dialogue_manager/compiler/compiler.gd.uid b/addons/dialogue_manager/compiler/compiler.gd.uid
new file mode 100644
index 0000000..e041f10
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler.gd.uid
@@ -0,0 +1 @@
+uid://chtfdmr0cqtp4
diff --git a/addons/dialogue_manager/compiler/compiler_regex.gd b/addons/dialogue_manager/compiler/compiler_regex.gd
new file mode 100644
index 0000000..5321227
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler_regex.gd
@@ -0,0 +1,50 @@
+## A collection of [RegEx] for use by the [DMCompiler].
+class_name DMCompilerRegEx extends RefCounted
+
+
+var IMPORT_REGEX: RegEx = RegEx.create_from_string("import \"(?[^\"]+)\" as (?[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]+)")
+var USING_REGEX: RegEx = RegEx.create_from_string("^using (?.*)$")
+var INDENT_REGEX: RegEx = RegEx.create_from_string("^\\t+")
+var VALID_CUE_REGEX: RegEx = RegEx.create_from_string("^[a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*$")
+var BEGINS_WITH_NUMBER_REGEX: RegEx = RegEx.create_from_string("^\\d")
+var CONDITION_REGEX: RegEx = RegEx.create_from_string("(if|elif|while|else if|match|when) (?.*)\\:?")
+var WRAPPED_CONDITION_REGEX: RegEx = RegEx.create_from_string("\\[if (?.*) \\/\\]")
+var MUTATION_REGEX: RegEx = RegEx.create_from_string("(?\\$\\>|\\$\\>\\>|do|do!|set) (?.*)")
+var STATIC_LINE_ID_REGEX: RegEx = RegEx.create_from_string("\\[ID:(?.*?)\\]")
+var WEIGHTED_RANDOM_SIBLINGS_REGEX: RegEx = RegEx.create_from_string("^\\%(?[\\d.]+)?( \\[if (?.+?) \\/\\])? ")
+var GOTO_REGEX: RegEx = RegEx.create_from_string("=> (?.*)")
+
+var INLINE_RANDOM_REGEX: RegEx = RegEx.create_from_string("\\[\\[(?.*?)\\]\\]")
+var INLINE_CONDITIONALS_REGEX: RegEx = RegEx.create_from_string("\\[if (?.+?)\\](?.*?)\\[\\/if\\]")
+
+var IMAGE_TAGS_REGEX: RegEx = RegEx.create_from_string("\\[img.*?\\](?.+?)\\[\\/img\\]")
+
+var TAGS_REGEX: RegEx = RegEx.create_from_string("\\[#(?.*?)\\]")
+
+var REPLACEMENTS_REGEX: RegEx = RegEx.create_from_string("{{(.*?)}}")
+
+var TOKEN_DEFINITIONS: Dictionary = {
+ DMConstants.TOKEN_FUNCTION: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*\\("),
+ DMConstants.TOKEN_DICTIONARY_REFERENCE: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*\\["),
+ DMConstants.TOKEN_PARENS_OPEN: RegEx.create_from_string("^\\("),
+ DMConstants.TOKEN_PARENS_CLOSE: RegEx.create_from_string("^\\)"),
+ DMConstants.TOKEN_BRACKET_OPEN: RegEx.create_from_string("^\\["),
+ DMConstants.TOKEN_BRACKET_CLOSE: RegEx.create_from_string("^\\]"),
+ DMConstants.TOKEN_BRACE_OPEN: RegEx.create_from_string("^\\{"),
+ DMConstants.TOKEN_BRACE_CLOSE: RegEx.create_from_string("^\\}"),
+ DMConstants.TOKEN_COLON: RegEx.create_from_string("^:"),
+ DMConstants.TOKEN_COMPARISON: RegEx.create_from_string("^(==|<=|>=|<|>|!=|in )"),
+ DMConstants.TOKEN_ASSIGNMENT: RegEx.create_from_string("^(\\+=|\\-=|\\*=|/=|=)"),
+ DMConstants.TOKEN_NUMBER: RegEx.create_from_string("^\\-?\\d+(\\.\\d+)?"),
+ DMConstants.TOKEN_OPERATOR: RegEx.create_from_string("^(\\+|\\-|\\*|/|%)"),
+ DMConstants.TOKEN_COMMA: RegEx.create_from_string("^,"),
+ DMConstants.TOKEN_NULL_COALESCE: RegEx.create_from_string("^\\?\\."),
+ DMConstants.TOKEN_DOT: RegEx.create_from_string("^\\."),
+ DMConstants.TOKEN_STRING: RegEx.create_from_string("^&?(\".*?\"|\'.*?\')"),
+ DMConstants.TOKEN_NOT: RegEx.create_from_string("^(not( |$)|!)"),
+ DMConstants.TOKEN_AND_OR: RegEx.create_from_string("^(and|or|&&|\\|\\|)( |$)"),
+ DMConstants.TOKEN_VARIABLE: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*"),
+ DMConstants.TOKEN_COMMENT: RegEx.create_from_string("^#.*"),
+ DMConstants.TOKEN_CONDITION: RegEx.create_from_string("^(if|elif|else)"),
+ DMConstants.TOKEN_BOOL: RegEx.create_from_string("^(true|false)")
+}
diff --git a/addons/dialogue_manager/compiler/compiler_regex.gd.uid b/addons/dialogue_manager/compiler/compiler_regex.gd.uid
new file mode 100644
index 0000000..bd969df
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler_regex.gd.uid
@@ -0,0 +1 @@
+uid://d3tvcrnicjibp
diff --git a/addons/dialogue_manager/compiler/compiler_result.gd b/addons/dialogue_manager/compiler/compiler_result.gd
new file mode 100644
index 0000000..5e97f4e
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler_result.gd
@@ -0,0 +1,24 @@
+## The result of using the [DMCompiler] to compile some dialogue.
+class_name DMCompilerResult extends RefCounted
+
+
+## Any paths that were imported into the compiled dialogue file.
+var imported_paths: PackedStringArray = []
+
+## Any "using" directives.
+var using_states: PackedStringArray = []
+
+## All cues in the file and the line they point to.
+var cues: Dictionary = {}
+
+## The first cue in the file.
+var first_cue: String = ""
+
+## All character names.
+var character_names: PackedStringArray = []
+
+## Any compilation errors.
+var errors: Array[DMError] = []
+
+## A map of all compiled lines.
+var lines: Dictionary = {}
diff --git a/addons/dialogue_manager/compiler/compiler_result.gd.uid b/addons/dialogue_manager/compiler/compiler_result.gd.uid
new file mode 100644
index 0000000..f1f76fd
--- /dev/null
+++ b/addons/dialogue_manager/compiler/compiler_result.gd.uid
@@ -0,0 +1 @@
+uid://dmk74tknimqvg
diff --git a/addons/dialogue_manager/compiler/expression_parser.gd b/addons/dialogue_manager/compiler/expression_parser.gd
new file mode 100644
index 0000000..9f2731f
--- /dev/null
+++ b/addons/dialogue_manager/compiler/expression_parser.gd
@@ -0,0 +1,529 @@
+## A class for parsing a condition/mutation expression for use with the [DMCompiler].
+class_name DMExpressionParser extends RefCounted
+
+
+var include_comments: bool = false
+
+
+# Reference to the common [RegEx] that the parser needs.
+var regex: DMCompilerRegEx = DMCompilerRegEx.new()
+
+
+## Break a string down into an expression.
+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: Dictionary = _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([], DMConstants.ERR_INVALID_EXPRESSION, index)
+
+ return _build_token_tree(tokens, line_type, "")[0]
+
+
+## Extract any expressions from some text
+func extract_replacements(text: String, index: int) -> Array[Dictionary]:
+ var founds: Array[RegExMatch] = regex.REPLACEMENTS_REGEX.search_all(text)
+
+ if founds == null or founds.size() == 0:
+ return []
+
+ var replacements: Array[Dictionary] = []
+ for found: RegExMatch in founds:
+ var replacement: Dictionary = {}
+ var value_in_text: String = found.strings[0].substr(0, found.strings[0].length() - 2).substr(2)
+
+ # If there are closing curlie hard-up against the end of a {{...}} block then check for further
+ # curlies just outside of the block.
+ var text_suffix: String = text.substr(found.get_end(0))
+ var expression_suffix: String = ""
+ while text_suffix.begins_with("}"):
+ expression_suffix += "}"
+ text_suffix = text_suffix.substr(1)
+ value_in_text += expression_suffix
+
+ var expression: Array = tokenise(value_in_text, DMConstants.TYPE_DIALOGUE, index + found.get_start(1))
+ if expression.size() == 0:
+ replacement = {
+ index = index + found.get_start(1),
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ }
+ elif expression[0].type == DMConstants.TYPE_ERROR:
+ replacement = {
+ index = expression[0].i,
+ error = expression[0].value
+ }
+ else:
+ replacement = {
+ value_in_text = "{{%s}}" % value_in_text,
+ expression = expression
+ }
+ replacements.append(replacement)
+
+ return replacements
+
+
+#region Helpers
+
+
+# Create a token that represents an error.
+func _build_token_tree_error(tree: Array, error: int, index: int) -> Array:
+ tree.insert(0, {
+ type = DMConstants.TOKEN_ERROR,
+ value = error,
+ i = index
+ })
+ return tree
+
+
+# Convert a list of tokens into an abstract syntax tree.
+func _build_token_tree(tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Array:
+ var tree: Array[Dictionary] = []
+ var limit: int = 0
+ while tokens.size() > 0 and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens.pop_front()
+
+ var error: int = _check_next_token(token, tokens, line_type, expected_close_token)
+ if error != OK:
+ var error_token: Dictionary = tokens[1] if tokens.size() > 1 else token
+ return [_build_token_tree_error(tree, error, error_token.index), tokens]
+
+ match token.type:
+ DMConstants.TOKEN_COMMENT:
+ if include_comments:
+ tree.append({
+ type = DMConstants.TOKEN_COMMENT,
+ value = token.value,
+ i = token.index
+ })
+
+ DMConstants.TOKEN_FUNCTION:
+ var sub_tree: Array = _build_token_tree(tokens, line_type, DMConstants.TOKEN_PARENS_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
+ return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
+
+ tree.append({
+ type = DMConstants.TOKEN_FUNCTION,
+ # Consume the trailing "("
+ function = token.value.substr(0, token.value.length() - 1),
+ value = _tokens_to_list(sub_tree[0]),
+ i = token.index
+ })
+ tokens = sub_tree[1]
+
+ DMConstants.TOKEN_DICTIONARY_REFERENCE:
+ var sub_tree: Array = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACKET_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
+ return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
+
+ var args: Array[Array] = _tokens_to_list(sub_tree[0])
+ if args.size() != 1:
+ return [_build_token_tree_error(tree, DMConstants.ERR_INVALID_INDEX, token.index), tokens]
+
+ tree.append({
+ type = DMConstants.TOKEN_DICTIONARY_REFERENCE,
+ # Consume the trailing "["
+ variable = token.value.substr(0, token.value.length() - 1),
+ value = args[0],
+ i = token.index
+ })
+ tokens = sub_tree[1]
+
+ DMConstants.TOKEN_BRACE_OPEN:
+ var sub_tree: Array = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACE_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
+ return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
+
+ var t: Array = sub_tree[0]
+ for i: int in range(0, t.size() - 2):
+ # Convert Lua style dictionaries to string keys
+ if t[i].type == DMConstants.TOKEN_VARIABLE and t[i+1].type == DMConstants.TOKEN_ASSIGNMENT:
+ t[i].type = DMConstants.TOKEN_STRING
+ t[i+1].type = DMConstants.TOKEN_COLON
+ t[i+1].erase("value")
+
+ tree.append({
+ type = DMConstants.TOKEN_DICTIONARY,
+ value = _tokens_to_dictionary(sub_tree[0]),
+ i = token.index
+ })
+
+ tokens = sub_tree[1]
+
+ DMConstants.TOKEN_BRACKET_OPEN:
+ var sub_tree: Array = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACKET_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
+ return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
+
+ var type: StringName = DMConstants.TOKEN_ARRAY
+ var value: Array[Array] = _tokens_to_list(sub_tree[0])
+
+ # See if this is referencing a nested dictionary value
+ if tree.size() > 0:
+ var previous_token: Dictionary = tree[tree.size() - 1]
+ if previous_token.type in [DMConstants.TOKEN_DICTIONARY_REFERENCE, DMConstants.TOKEN_DICTIONARY_NESTED_REFERENCE]:
+ type = DMConstants.TOKEN_DICTIONARY_NESTED_REFERENCE
+ value = value[0]
+
+ tree.append({
+ type = type,
+ value = value,
+ i = token.index
+ })
+ tokens = sub_tree[1]
+
+ DMConstants.TOKEN_PARENS_OPEN:
+ var sub_tree: Array = _build_token_tree(tokens, line_type, DMConstants.TOKEN_PARENS_CLOSE)
+
+ if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
+ return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
+
+ tree.append({
+ type = DMConstants.TOKEN_GROUP,
+ value = sub_tree[0],
+ i = token.index
+ })
+ tokens = sub_tree[1]
+
+ DMConstants.TOKEN_PARENS_CLOSE, \
+ DMConstants.TOKEN_BRACE_CLOSE, \
+ DMConstants.TOKEN_BRACKET_CLOSE:
+ if token.type != expected_close_token:
+ return [_build_token_tree_error(tree, DMConstants.ERR_UNEXPECTED_CLOSING_BRACKET, token.index), tokens]
+
+ tree.append({
+ type = token.type,
+ i = token.index
+ })
+
+ return [tree, tokens]
+
+ DMConstants.TOKEN_NOT:
+ # Double nots negate each other
+ if tokens.size() > 0 and tokens.front().type == DMConstants.TOKEN_NOT:
+ tokens.pop_front()
+ else:
+ tree.append({
+ type = token.type,
+ i = token.index
+ })
+
+ DMConstants.TOKEN_COMMA, \
+ DMConstants.TOKEN_COLON, \
+ DMConstants.TOKEN_DOT, \
+ DMConstants.TOKEN_NULL_COALESCE:
+ tree.append({
+ type = token.type,
+ i = token.index
+ })
+
+ DMConstants.TOKEN_COMPARISON, \
+ DMConstants.TOKEN_ASSIGNMENT, \
+ DMConstants.TOKEN_OPERATOR, \
+ DMConstants.TOKEN_AND_OR, \
+ DMConstants.TOKEN_VARIABLE:
+ var value: String = token.value.strip_edges()
+ if value == "&&":
+ value = "and"
+ elif value == "||":
+ value = "or"
+ tree.append({
+ type = token.type,
+ value = value,
+ i = token.index
+ })
+
+ DMConstants.TOKEN_STRING:
+ if token.value.begins_with("&"):
+ tree.append({
+ type = token.type,
+ value = StringName(token.value.substr(2, token.value.length() - 3)),
+ i = token.index
+ })
+ else:
+ tree.append({
+ type = token.type,
+ value = token.value.substr(1, token.value.length() - 2),
+ i = token.index
+ })
+
+ DMConstants.TOKEN_CONDITION:
+ return [_build_token_tree_error(tree, DMConstants.ERR_UNEXPECTED_CONDITION, token.index), token]
+
+ DMConstants.TOKEN_BOOL:
+ tree.append({
+ type = token.type,
+ value = token.value.to_lower() == "true",
+ i = token.index
+ })
+
+ DMConstants.TOKEN_NUMBER:
+ var value: Variant = 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 == DMConstants.TOKEN_NUMBER:
+ tree.append(({
+ type = DMConstants.TOKEN_OPERATOR,
+ value = "-",
+ i = token.index
+ }))
+ tree.append({
+ type = token.type,
+ value = -1 * value,
+ i = token.index
+ })
+ else:
+ tree.append({
+ type = token.type,
+ value = value,
+ i = token.index
+ })
+
+ if expected_close_token != "":
+ var index: int = tokens[0].i if tokens.size() > 0 else 0
+ return [_build_token_tree_error(tree, DMConstants.ERR_MISSING_CLOSING_BRACKET, index), tokens]
+
+ return [tree, tokens]
+
+
+# Check the next token to see if it is valid to follow this one.
+func _check_next_token(token: Dictionary, next_tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> int:
+ 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 == DMConstants.TOKEN_ASSIGNMENT and line_type == DMConstants.TYPE_CONDITION and not next_tokens.any(func(t: Dictionary) -> bool: return t.type == expected_close_token):
+ return DMConstants.ERR_UNEXPECTED_ASSIGNMENT
+
+ # Special case for a negative number after this one
+ if token.type == DMConstants.TOKEN_NUMBER and next_token.type == DMConstants.TOKEN_NUMBER and next_token.value.begins_with("-"):
+ return OK
+
+ var expected_token_types: Array = []
+ var unexpected_token_types: Array = []
+ match token.type:
+ DMConstants.TOKEN_FUNCTION, \
+ DMConstants.TOKEN_PARENS_OPEN:
+ unexpected_token_types = [
+ null,
+ DMConstants.TOKEN_COMMA,
+ DMConstants.TOKEN_COLON,
+ DMConstants.TOKEN_COMPARISON,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_OPERATOR,
+ DMConstants.TOKEN_AND_OR,
+ DMConstants.TOKEN_DOT
+ ]
+
+ DMConstants.TOKEN_BRACKET_CLOSE:
+ unexpected_token_types = [
+ DMConstants.TOKEN_NOT,
+ DMConstants.TOKEN_BOOL,
+ DMConstants.TOKEN_STRING,
+ DMConstants.TOKEN_NUMBER,
+ DMConstants.TOKEN_VARIABLE
+ ]
+
+ DMConstants.TOKEN_BRACE_OPEN:
+ expected_token_types = [
+ DMConstants.TOKEN_STRING,
+ DMConstants.TOKEN_VARIABLE,
+ DMConstants.TOKEN_NUMBER,
+ DMConstants.TOKEN_BRACE_CLOSE
+ ]
+
+ DMConstants.TOKEN_PARENS_CLOSE, \
+ DMConstants.TOKEN_BRACE_CLOSE:
+ unexpected_token_types = [
+ DMConstants.TOKEN_NOT,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_BOOL,
+ DMConstants.TOKEN_STRING,
+ DMConstants.TOKEN_NUMBER,
+ DMConstants.TOKEN_VARIABLE
+ ]
+
+ DMConstants.TOKEN_COMPARISON, \
+ DMConstants.TOKEN_OPERATOR, \
+ DMConstants.TOKEN_DOT, \
+ DMConstants.TOKEN_NULL_COALESCE, \
+ DMConstants.TOKEN_NOT, \
+ DMConstants.TOKEN_AND_OR, \
+ DMConstants.TOKEN_DICTIONARY_REFERENCE:
+ unexpected_token_types = [
+ null,
+ DMConstants.TOKEN_COMMA,
+ DMConstants.TOKEN_COLON,
+ DMConstants.TOKEN_COMPARISON,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_OPERATOR,
+ DMConstants.TOKEN_AND_OR,
+ DMConstants.TOKEN_PARENS_CLOSE,
+ DMConstants.TOKEN_BRACE_CLOSE,
+ DMConstants.TOKEN_BRACKET_CLOSE,
+ DMConstants.TOKEN_DOT
+ ]
+
+ DMConstants.TOKEN_COMMA:
+ unexpected_token_types = [
+ null,
+ DMConstants.TOKEN_COMMA,
+ DMConstants.TOKEN_COLON,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_OPERATOR,
+ DMConstants.TOKEN_AND_OR,
+ DMConstants.TOKEN_PARENS_CLOSE,
+ DMConstants.TOKEN_BRACE_CLOSE,
+ DMConstants.TOKEN_BRACKET_CLOSE,
+ DMConstants.TOKEN_DOT
+ ]
+
+ DMConstants.TOKEN_COLON:
+ unexpected_token_types = [
+ DMConstants.TOKEN_COMMA,
+ DMConstants.TOKEN_COLON,
+ DMConstants.TOKEN_COMPARISON,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_OPERATOR,
+ DMConstants.TOKEN_AND_OR,
+ DMConstants.TOKEN_PARENS_CLOSE,
+ DMConstants.TOKEN_BRACE_CLOSE,
+ DMConstants.TOKEN_BRACKET_CLOSE,
+ DMConstants.TOKEN_DOT
+ ]
+
+ DMConstants.TOKEN_BOOL, \
+ DMConstants.TOKEN_STRING, \
+ DMConstants.TOKEN_NUMBER:
+ unexpected_token_types = [
+ DMConstants.TOKEN_NOT,
+ DMConstants.TOKEN_ASSIGNMENT,
+ DMConstants.TOKEN_BOOL,
+ DMConstants.TOKEN_STRING,
+ DMConstants.TOKEN_NUMBER,
+ DMConstants.TOKEN_VARIABLE,
+ DMConstants.TOKEN_FUNCTION,
+ DMConstants.TOKEN_PARENS_OPEN,
+ DMConstants.TOKEN_BRACE_OPEN,
+ DMConstants.TOKEN_BRACKET_OPEN
+ ]
+
+ DMConstants.TOKEN_VARIABLE:
+ unexpected_token_types = [
+ DMConstants.TOKEN_NOT,
+ DMConstants.TOKEN_BOOL,
+ DMConstants.TOKEN_STRING,
+ DMConstants.TOKEN_NUMBER,
+ DMConstants.TOKEN_VARIABLE,
+ DMConstants.TOKEN_FUNCTION,
+ DMConstants.TOKEN_PARENS_OPEN,
+ DMConstants.TOKEN_BRACE_OPEN,
+ DMConstants.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 DMConstants.ERR_UNEXPECTED_END_OF_EXPRESSION
+
+ DMConstants.TOKEN_FUNCTION:
+ return DMConstants.ERR_UNEXPECTED_FUNCTION
+
+ DMConstants.TOKEN_PARENS_OPEN, \
+ DMConstants.TOKEN_PARENS_CLOSE:
+ return DMConstants.ERR_UNEXPECTED_BRACKET
+
+ DMConstants.TOKEN_COMPARISON, \
+ DMConstants.TOKEN_ASSIGNMENT, \
+ DMConstants.TOKEN_OPERATOR, \
+ DMConstants.TOKEN_NOT, \
+ DMConstants.TOKEN_AND_OR:
+ return DMConstants.ERR_UNEXPECTED_OPERATOR
+
+ DMConstants.TOKEN_COMMA:
+ return DMConstants.ERR_UNEXPECTED_COMMA
+ DMConstants.TOKEN_COLON:
+ return DMConstants.ERR_UNEXPECTED_COLON
+ DMConstants.TOKEN_DOT:
+ return DMConstants.ERR_UNEXPECTED_DOT
+
+ DMConstants.TOKEN_BOOL:
+ return DMConstants.ERR_UNEXPECTED_BOOLEAN
+ DMConstants.TOKEN_STRING:
+ return DMConstants.ERR_UNEXPECTED_STRING
+ DMConstants.TOKEN_NUMBER:
+ return DMConstants.ERR_UNEXPECTED_NUMBER
+ DMConstants.TOKEN_VARIABLE:
+ return DMConstants.ERR_UNEXPECTED_VARIABLE
+
+ return DMConstants.ERR_INVALID_EXPRESSION
+
+ return OK
+
+
+# Convert a series of comma separated tokens to an [Array].
+func _tokens_to_list(tokens: Array[Dictionary]) -> Array[Array]:
+ var list: Array[Array] = []
+ var current_item: Array[Dictionary] = []
+ for token: Dictionary in tokens:
+ if token.type == DMConstants.TOKEN_COMMA:
+ list.append(current_item)
+ current_item = []
+ else:
+ current_item.append(token)
+
+ if current_item.size() > 0:
+ list.append(current_item)
+
+ return list
+
+
+# Convert a series of key/value tokens into a [Dictionary]
+func _tokens_to_dictionary(tokens: Array[Dictionary]) -> Dictionary:
+ var dictionary: Dictionary = {}
+ for i: int in range(0, tokens.size()):
+ if tokens[i].type == DMConstants.TOKEN_COLON:
+ if tokens.size() == i + 2:
+ dictionary[tokens[i - 1]] = tokens[i + 1]
+ else:
+ dictionary[tokens[i - 1]] = { type = DMConstants.TOKEN_GROUP, value = tokens.slice(i + 1), i = tokens[0].i }
+
+ return dictionary
+
+
+# Work out what the next token is from a string.
+func _find_match(input: String) -> Dictionary:
+ for key: String in regex.TOKEN_DEFINITIONS.keys():
+ var token_regex: RegEx = regex.TOKEN_DEFINITIONS.get(key)
+ var found: RegExMatch = token_regex.search(input)
+ if found:
+ return {
+ type = key,
+ remaining_text = input.substr(found.strings[0].length()),
+ value = found.strings[0]
+ }
+
+ return {}
+
+
+#endregion
diff --git a/addons/dialogue_manager/compiler/expression_parser.gd.uid b/addons/dialogue_manager/compiler/expression_parser.gd.uid
new file mode 100644
index 0000000..0793701
--- /dev/null
+++ b/addons/dialogue_manager/compiler/expression_parser.gd.uid
@@ -0,0 +1 @@
+uid://dbi4hbar8ubwu
diff --git a/addons/dialogue_manager/compiler/resolved_goto_data.gd b/addons/dialogue_manager/compiler/resolved_goto_data.gd
new file mode 100644
index 0000000..920d12e
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_goto_data.gd
@@ -0,0 +1,70 @@
+## Data associated with a dialogue jump/goto line.
+class_name DMResolvedGotoData extends RefCounted
+
+
+## The cue that was specified
+var cue: String = ""
+## The target line's ID
+var next_id: String = ""
+## An expression to determine the target line at runtime.
+var expression: Array[Dictionary] = []
+## The given line text with the jump syntax removed.
+var text_without_goto: String = ""
+## Whether this is a jump-and-return style jump.
+var is_snippet: bool = false
+## A parse error if there was one.
+var error: int
+## The index in the string where
+var index: int = 0
+
+# An instance of the compiler [RegEx] list.
+var regex: DMCompilerRegEx = DMCompilerRegEx.new()
+
+
+func _init(text: String, cues: Dictionary) -> void:
+ if not "=> " in text and not "=>< " in text: return
+
+ if "=> " in text:
+ text_without_goto = text.substr(0, text.find("=> ")).strip_edges()
+ elif "=>< " in text:
+ is_snippet = true
+ text_without_goto = text.substr(0, text.find("=>< ")).strip_edges()
+
+ var found: RegExMatch = regex.GOTO_REGEX.search(text)
+ if found == null:
+ return
+
+ cue = found.strings[found.names.goto].strip_edges()
+ index = found.get_start(0)
+
+ if cue == "":
+ error = DMConstants.ERR_UNKNOWN_CUE
+ return
+
+ # "=> END!" means end the conversation, ignoring any "=><" chains.
+ if cue == "END!":
+ next_id = DMConstants.ID_END_CONVERSATION
+
+ # "=> END" means end the current cue (and go back to the previous one if there is one
+ # in the stack)
+ elif cue == "END":
+ next_id = DMConstants.ID_END
+
+ elif cues.has(cue):
+ next_id = cues.get(cue)
+ elif cue.begins_with("{{"):
+ var expression_parser: DMExpressionParser = DMExpressionParser.new()
+ var cue_expression: Array[Dictionary] = expression_parser.extract_replacements(cue, 0)
+ if cue_expression.size() == 0:
+ error = DMConstants.ERR_INCOMPLETE_EXPRESSION
+ elif cue_expression[0].has("error"):
+ error = cue_expression[0].error
+ else:
+ expression = cue_expression[0].expression
+ else:
+ next_id = cue
+ error = DMConstants.ERR_UNKNOWN_CUE
+
+
+func _to_string() -> String:
+ return "%s =>%s %s (%s)" % [text_without_goto, "<" if is_snippet else "", cue, next_id]
diff --git a/addons/dialogue_manager/compiler/resolved_goto_data.gd.uid b/addons/dialogue_manager/compiler/resolved_goto_data.gd.uid
new file mode 100644
index 0000000..cb05e08
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_goto_data.gd.uid
@@ -0,0 +1 @@
+uid://llhl5pt47eoq
diff --git a/addons/dialogue_manager/compiler/resolved_line_data.gd b/addons/dialogue_manager/compiler/resolved_line_data.gd
new file mode 100644
index 0000000..9096036
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_line_data.gd
@@ -0,0 +1,170 @@
+## Any data associated with inline dialogue BBCodes.
+class_name DMResolvedLineData extends RefCounted
+
+## The line's text
+var text: String = ""
+## A map of speed changes against where they are found in the text.
+var speeds: Dictionary = {}
+## A list of any mutations to run and where they are found in the text.
+var mutations: Array[Array] = []
+## A duration reference for the line. Represented as "auto" or a stringified number.
+var time: String = ""
+
+
+func _init(line: String) -> void:
+ text = line
+ speeds = {}
+ mutations = []
+ time = ""
+
+ var bbcodes: Array = []
+
+ # Remove any escaped brackets (ie. "\[")
+ var escaped_open_brackets: PackedInt32Array = []
+ var escaped_close_brackets: PackedInt32Array = []
+ for i: int 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: Array[Dictionary] = find_bbcode_positions_in_string(text)
+ var accumulaive_length_offset: int = 0
+ for position: Dictionary 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: Dictionary 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: Array[Dictionary] = find_bbcode_positions_in_string(text, false)
+ var limit: int = 0
+ while next_bbcode_position.size() > 0 and limit < 1000:
+ limit += 1
+
+ var bbcode: Dictionary = next_bbcode_position[0]
+ var index: int = bbcode.start
+ var code: String = bbcode.code
+ var raw_args: String = bbcode.raw_args
+ var args: Dictionary = {}
+ if code in ["$>", "$>>", "do", "do!", "set"]:
+ args["value"] = DMCompiler.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: String in raw_args.strip_edges().split(" "):
+ if "=" in pair:
+ var bits: PackedStringArray = pair.split("=")
+ args[bits[0]] = bits[1]
+
+ match code:
+ "wait":
+ var wait: Dictionary = DMCompiler.extract_mutation("do wait(%s)" % [args.get("value", "null")])
+ mutations.append([index, wait])
+ "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: int = bbcode.bbcode.length()
+ for bb: Dictionary 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: int in range(0, escaped_open_brackets.size()):
+ if escaped_open_brackets[i] > bbcode.start:
+ escaped_open_brackets[i] -= length
+ for i: int 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: Dictionary in bbcodes:
+ text = text.insert(bb.start, bb.bbcode)
+
+ # Put the escaped brackets back in
+ for index: int in escaped_open_brackets:
+ text = text.left(index) + "[" + text.right(text.length() - index - 1)
+ for index: int in escaped_close_brackets:
+ text = text.left(index) + "]" + text.right(text.length() - index - 1)
+
+
+static func find_bbcode_positions_in_string(string: String, find_all: bool = true, include_conditions: bool = false) -> 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_reading_code: bool = false
+
+ var i: int = 0
+ while i < string.length():
+ if string[i] == "[":
+ if open_brace_count == 0:
+ start = i
+ bbcode = ""
+ code = ""
+ is_reading_code = true
+ open_brace_count += 1
+
+ elif is_reading_code:
+ if string[i].to_upper() != string[i] or ["/", "!", "$", ">"].has(string[i]):
+ code += string[i]
+ else:
+ is_reading_code = false
+
+ if open_brace_count > 0:
+ bbcode += string[i]
+
+ if string.substr(i, 2) == "/]":
+ bbcode += "]"
+ i += 1
+
+ if string[i] == "]":
+ open_brace_count -= 1
+ if open_brace_count == 0 and (include_conditions or not ["if", "else", "/if"].has(code)):
+ var raw_args: String = bbcode.substr(code.length() + 1, bbcode.length() - code.length() - 2).strip_edges()
+ if raw_args.ends_with("/"):
+ raw_args = raw_args.substr(0, raw_args.length() - 1)
+ positions.append({
+ bbcode = bbcode,
+ code = code,
+ start = start,
+ end = i,
+ raw_args = raw_args
+ })
+
+ if not find_all:
+ return positions
+
+ i += 1
+
+ return positions
diff --git a/addons/dialogue_manager/compiler/resolved_line_data.gd.uid b/addons/dialogue_manager/compiler/resolved_line_data.gd.uid
new file mode 100644
index 0000000..bbea7d2
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_line_data.gd.uid
@@ -0,0 +1 @@
+uid://0k6q8kukq0qa
diff --git a/addons/dialogue_manager/compiler/resolved_tag_data.gd b/addons/dialogue_manager/compiler/resolved_tag_data.gd
new file mode 100644
index 0000000..14b520b
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_tag_data.gd
@@ -0,0 +1,26 @@
+## Tag data associated with a line of dialogue.
+class_name DMResolvedTagData extends RefCounted
+
+
+## The list of tags.
+var tags: PackedStringArray = []
+## The line with any tag syntax removed.
+var text_without_tags: String = ""
+
+# An instance of the compiler [RegEx].
+var regex: DMCompilerRegEx = DMCompilerRegEx.new()
+
+
+func _init(text: String) -> void:
+ var resolved_tags: PackedStringArray = []
+ var tag_matches: Array[RegExMatch] = regex.TAGS_REGEX.search_all(text)
+ for tag_match: RegExMatch in tag_matches:
+ text = text.replace(tag_match.get_string(), "")
+ var possible_tags: PackedStringArray = tag_match.get_string().replace("[#", "").replace("]", "").replace(", ", ",").split(",")
+ for tag: String in possible_tags:
+ tag = tag.replace("#", "")
+ if not tag in resolved_tags:
+ resolved_tags.append(tag)
+
+ tags = resolved_tags
+ text_without_tags = text
diff --git a/addons/dialogue_manager/compiler/resolved_tag_data.gd.uid b/addons/dialogue_manager/compiler/resolved_tag_data.gd.uid
new file mode 100644
index 0000000..98c6f51
--- /dev/null
+++ b/addons/dialogue_manager/compiler/resolved_tag_data.gd.uid
@@ -0,0 +1 @@
+uid://cqai3ikuilqfq
diff --git a/addons/dialogue_manager/compiler/tree_line.gd b/addons/dialogue_manager/compiler/tree_line.gd
new file mode 100644
index 0000000..c939e64
--- /dev/null
+++ b/addons/dialogue_manager/compiler/tree_line.gd
@@ -0,0 +1,46 @@
+## An intermediate representation of a dialogue line before it gets compiled.
+class_name DMTreeLine extends RefCounted
+
+
+## The line number where this dialogue was found (after imported files have had their content imported).
+var line_number: int = 0
+## The parent [DMTreeLine] of this line.
+## This is stored as a Weak Reference so that this RefCounted can elegantly free itself.
+## Without it being a Weak Reference, this can easily cause a cyclical reference that keeps this resource alive.
+var parent: WeakRef
+## The ID of this line.
+var id: String
+## The type of this line (as a [String] defined in [DMConstants].
+var type: String = ""
+## Is this line part of a randomised group?
+var is_random: bool = false
+## The indent count for this line.
+var indent: int = 0
+## The text of this line.
+var text: String = ""
+## The child [DMTreeLine]s of this line.
+var children: Array[DMTreeLine] = []
+## Any doc comments attached to this line.
+var notes: String = ""
+## Is this a dialogue line that is the child of another dialogue line?
+var is_nested_dialogue: bool = false
+
+
+func _init(initial_id: String) -> void:
+ id = initial_id
+
+
+func _to_string() -> String:
+ var tabs: Array = []
+ tabs.resize(indent)
+ tabs.fill("\t")
+ var tab: String = "".join(tabs)
+
+ return tab.join([tab + "{\n",
+ "\tid: %s\n" % [id],
+ "\ttype: %s\n" % [type],
+ "\tis_random: %s\n" % ["true" if is_random else "false"],
+ "\ttext: %s\n" % [text],
+ "\tnotes: %s\n" % [notes],
+ "\tchildren: []\n" if children.size() == 0 else "\tchildren: [\n" + ",\n".join(children.map(func(child: DMTreeLine) -> String: return str(child))) + "]\n",
+ "}"])
diff --git a/addons/dialogue_manager/compiler/tree_line.gd.uid b/addons/dialogue_manager/compiler/tree_line.gd.uid
new file mode 100644
index 0000000..fe1db3a
--- /dev/null
+++ b/addons/dialogue_manager/compiler/tree_line.gd.uid
@@ -0,0 +1 @@
+uid://dsu4i84dpif14
diff --git a/addons/dialogue_manager/components/blank_slate.gd b/addons/dialogue_manager/components/blank_slate.gd
new file mode 100644
index 0000000..046a8f2
--- /dev/null
+++ b/addons/dialogue_manager/components/blank_slate.gd
@@ -0,0 +1,56 @@
+@tool
+
+extends CenterContainer
+
+
+signal new_button_pressed
+signal quick_open_button_pressed
+
+
+@onready var banner_image: TextureRect = %BannerImage
+@onready var new_button: Button = %NewButton
+@onready var quick_open: Button = %QuickOpen
+@onready var examples: Button = %Examples
+@onready var buy_my_game: Button = %BuyMyGame
+
+
+func apply_theme() -> void:
+ var theme_values: DMThemeValues = DMThemeValues.get_values_from_editor()
+ banner_image.custom_minimum_size = Vector2(300, 200) * theme_values.scale
+
+ new_button.icon = get_theme_icon("New", "EditorIcons")
+ new_button.text = DMConstants.translate(&"start_a_new_file")
+ quick_open.icon = get_theme_icon("Load", "EditorIcons")
+ quick_open.text = DMConstants.translate(&"open.quick_open")
+
+ examples.icon = get_theme_icon("EditorFileDialog", "EditorIcons")
+ examples.text = DMConstants.translate("Get example projects...")
+ buy_my_game.icon = get_theme_icon("CharacterBody2D", "EditorIcons")
+ buy_my_game.text = DMConstants.translate("Buy my game...")
+
+
+#region Signals
+
+
+func _on_banner_image_gui_input(event: InputEvent) -> void:
+ if event.is_pressed():
+ OS.shell_open("https://github.com/nathanhoad/godot_dialogue_manager")
+
+
+func _on_new_button_pressed() -> void:
+ new_button_pressed.emit()
+
+
+func _on_quick_open_pressed() -> void:
+ quick_open_button_pressed.emit()
+
+
+func _on_examples_pressed() -> void:
+ OS.shell_open("https://itch.io/c/5226650/godot-dialogue-manager-example-projects")
+
+
+func _on_buy_my_game_pressed() -> void:
+ OS.shell_open("https://bravestcoconut.com/wishlist")
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/blank_slate.gd.uid b/addons/dialogue_manager/components/blank_slate.gd.uid
new file mode 100644
index 0000000..380eb30
--- /dev/null
+++ b/addons/dialogue_manager/components/blank_slate.gd.uid
@@ -0,0 +1 @@
+uid://e3wa7g2toi2t
diff --git a/addons/dialogue_manager/components/blank_slate.tscn b/addons/dialogue_manager/components/blank_slate.tscn
new file mode 100644
index 0000000..ed1f9ea
--- /dev/null
+++ b/addons/dialogue_manager/components/blank_slate.tscn
@@ -0,0 +1,65 @@
+[gd_scene format=3 uid="uid://s41qxl30184y"]
+
+[ext_resource type="Script" uid="uid://e3wa7g2toi2t" path="res://addons/dialogue_manager/components/blank_slate.gd" id="1_l3w2g"]
+[ext_resource type="Texture2D" uid="uid://cjl44xi6ekrqt" path="res://addons/dialogue_manager/assets/logo.svg" id="2_l3w2g"]
+
+[node name="BlankSlate" type="CenterContainer" unique_id=267921584]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("1_l3w2g")
+
+[node name="PanelContainer" type="VBoxContainer" parent="." unique_id=1637606585]
+layout_mode = 2
+theme_override_constants/separation = 5
+
+[node name="BannerImage" type="TextureRect" parent="PanelContainer" unique_id=1351355759]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(300, 200)
+layout_mode = 2
+mouse_filter = 0
+mouse_default_cursor_shape = 2
+texture = ExtResource("2_l3w2g")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="Spacer" type="Control" parent="PanelContainer" unique_id=847267783]
+custom_minimum_size = Vector2(0, 10)
+layout_mode = 2
+
+[node name="NewButton" type="Button" parent="PanelContainer" unique_id=1627079156]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+text = "New Dialogue..."
+
+[node name="QuickOpen" type="Button" parent="PanelContainer" unique_id=1046857903]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+text = "Quick open..."
+
+[node name="Spacer2" type="Control" parent="PanelContainer" unique_id=1403149639]
+custom_minimum_size = Vector2(0, 10)
+layout_mode = 2
+
+[node name="Examples" type="Button" parent="PanelContainer" unique_id=2130533078]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+text = "Example projects..."
+
+[node name="BuyMyGame" type="Button" parent="PanelContainer" unique_id=555819308]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+text = "Buy my game..."
+
+[connection signal="gui_input" from="PanelContainer/BannerImage" to="." method="_on_banner_image_gui_input"]
+[connection signal="pressed" from="PanelContainer/NewButton" to="." method="_on_new_button_pressed"]
+[connection signal="pressed" from="PanelContainer/QuickOpen" to="." method="_on_quick_open_pressed"]
+[connection signal="pressed" from="PanelContainer/Examples" to="." method="_on_examples_pressed"]
+[connection signal="pressed" from="PanelContainer/BuyMyGame" to="." method="_on_buy_my_game_pressed"]
diff --git a/addons/dialogue_manager/components/code_edit.gd b/addons/dialogue_manager/components/code_edit.gd
new file mode 100644
index 0000000..678232b
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit.gd
@@ -0,0 +1,1504 @@
+@tool
+class_name DMCodeEdit extends CodeEdit
+
+
+signal active_cue_changed(cue: String)
+signal error_clicked(line_number: int)
+signal external_file_requested(path: String, cue: String)
+
+
+const MUTATION_PREFIXES: PackedStringArray = ["$>", "$>>", "do ", "do! ", "set ", "if ", "elif ", "else if ", "match ", "when "]
+const INLINE_MUTATION_PREFIXES: PackedStringArray = ["$> ", "$>> ", "do ", "do! ", "set ", "if ", "elif "]
+
+# A link back to the owner `MainView`
+var main_view: Control
+
+# Theme overrides for syntax highlighting, etc
+var theme_overrides: DMThemeValues:
+ set(value):
+ theme_overrides = value
+
+ syntax_highlighter = DMSyntaxHighlighter.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", int(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(value):
+ errors = value
+ _update_errors()
+ get:
+ return errors
+
+var runtime_error: DMError = null:
+ set(value):
+ runtime_error = value
+ _update_errors()
+ get:
+ return runtime_error
+
+# 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", int(font_size * theme_overrides.scale))
+ get:
+ return font_size
+
+var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
+var STATIC_REGEX: RegEx = RegEx.create_from_string("^static var (?[a-zA-Z_0-9]+)(:\\s?(?[a-zA-Z_0-9]+))?")
+var STATIC_CONTENT_REGEX: RegEx = RegEx.create_from_string("static (var|func)")
+
+var compiler_regex: DMCompilerRegEx = DMCompilerRegEx.new()
+var _autoloads: Dictionary[String, String] = {}
+var _member_cache: Dictionary[String, Dictionary] = {}
+
+var _current_edited_scene_root: Node
+var _context_cache: Dictionary = {}
+
+
+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 = DMSyntaxHighlighter.new()
+
+ # Keep track of any autoloads
+ ProjectSettings.settings_changed.connect(_on_project_settings_changed)
+ _on_project_settings_changed()
+
+
+func _gui_input(event: InputEvent) -> void:
+ # Handle shortcuts that come from the editor
+ if event is InputEventKey and event.is_pressed():
+ var shortcut: String = DMPlugin.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()
+ "make_bold":
+ insert_bbcode("[b]", "[/b]")
+ get_viewport().set_input_as_handled()
+ "make_italic":
+ insert_bbcode("[i]", "[/i]")
+ 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: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY: return false
+ if data.type != "files": return false
+
+ var files: PackedStringArray = Array(data.files)
+ return files.size() > 0
+
+
+func _drop_data(at_position: Vector2, data: Variant) -> void:
+ var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")
+
+ if typeof(data) == TYPE_STRING: return
+
+ var files: PackedStringArray = Array(data.files)
+ for file: String in files:
+ # Don't import the file into itself
+ if file == main_view.current_file_path: continue
+
+ if file.get_extension() == "dialogue":
+ var known_aliases: PackedStringArray = []
+ var path: String = file.replace("res://", "").replace(".dialogue", "")
+ # Find the first non-import line in the file to add our import
+ var lines: PackedStringArray = text.split("\n")
+ for i: int in range(0, lines.size()):
+ if lines[i].begins_with("import "):
+ var found: RegExMatch = compiler_regex.IMPORT_REGEX.search(lines[i])
+ if found:
+ known_aliases.append(found.strings[found.names.prefix])
+ else:
+ var alias: String = ""
+ var bits: PackedStringArray = replace_regex.sub(path, "|", true).split("|")
+ bits.reverse()
+ for end: int in range(1, bits.size() + 1):
+ alias = "_".join(bits.slice(0, end))
+ if not alias in known_aliases:
+ break
+ insert_line_at(i, "import \"%s\" as %s\n" % [file, alias])
+ set_caret_line(i)
+ break
+ else:
+ var cursor: Vector2i = get_line_column_at_pos(at_position)
+ if cursor.x > -1 and cursor.y > -1:
+ set_cursor(cursor)
+ remove_secondary_carets()
+ var resource: Resource = load(file)
+ # If the dropped file is an audio stream then assume it's a voice reference
+ if is_instance_of(resource, AudioStream):
+ var current_voice_regex: RegEx = RegEx.create_from_string("\\[#voice=.+\\]")
+ var path: String = ResourceUID.call("path_to_uid", file) if ResourceUID.has_method("path_to_uid") else file
+ var line_text: String = get_line(cursor.y)
+ var voice_text: String = "[#voice=%s]" % [path]
+ if current_voice_regex.search(line_text):
+ set_line(cursor.y, current_voice_regex.replace(get_line(cursor.y), voice_text))
+ else:
+ insert_text(" " + voice_text, cursor.y, line_text.length())
+ # Other wise it's just a file reference
+ else:
+ insert_text("\"%s\"" % file, cursor.y, cursor.x)
+ grab_focus()
+
+
+func _request_code_completion(_force: bool) -> void:
+ var cursor: Vector2i = get_cursor()
+ var current_line: String = get_line(cursor.y)
+
+ _add_jump_completions(current_line, cursor)
+ _add_character_name_completions(current_line)
+ _add_mutation_completions(current_line, cursor)
+
+ update_code_completion_options(true)
+ if get_code_completion_options().size() == 0:
+ 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: Dictionary = 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: int 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()
+
+ if completion.display_text.ends_with("()"):
+ set_cursor(get_cursor() - Vector2i.RIGHT)
+
+ # Close the autocomplete menu on the next tick
+ call_deferred("cancel_code_completion")
+
+
+func _update_errors() -> void:
+ for i: int in range(0, get_line_count()):
+ var is_error: bool = false
+ for error: DMError in errors:
+ if error.line_number == i:
+ is_error = true
+ if runtime_error != null and runtime_error.line_number == i:
+ is_error = true
+ mark_line_as_error(i, is_error)
+ _on_code_edit_caret_changed()
+
+
+#region Completion Helpers
+
+
+# Add completions for jump targets (=> and =><).
+func _add_jump_completions(current_line: String, cursor: Vector2) -> void:
+ if not ("=> " in current_line or "=>< " in current_line): return
+ if cursor.x <= current_line.find("=>"): return
+
+ 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 cues, including those in imports
+ for cue: String in DMCompiler.get_cues_in_text(text, main_view.current_file_path):
+ if "/" in cue:
+ var bits: PackedStringArray = cue.split("/")
+ if _matches_prompt(prompt, cue) or _matches_prompt(prompt, bits[0]) or _matches_prompt(prompt, bits[1]):
+ add_code_completion_option(CodeEdit.KIND_CLASS, cue, cue.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
+ elif _matches_prompt(prompt, cue):
+ add_code_completion_option(CodeEdit.KIND_CLASS, cue, cue.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))
+
+
+# Add completions for character names at the start of dialogue lines.
+func _add_character_name_completions(current_line: String) -> void:
+ # Ignore names on mutation lines
+ for prefix: String in MUTATION_PREFIXES:
+ if current_line.strip_edges().begins_with(prefix):
+ return
+
+ var text_before_cursor: String = current_line.substr(0, get_caret_column(0))
+ var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(text_before_cursor.strip_edges(), "")
+ if name_so_far == "":
+ return
+
+ var names: PackedStringArray = get_character_names(name_so_far)
+ for character_name: String in names:
+ add_code_completion_option(CodeEdit.KIND_CLASS, character_name + ": ", character_name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))
+
+
+# Add state/mutation completions.
+func _add_mutation_completions(current_line: String, cursor: Vector2i) -> void:
+ # Check for inline mutation context first (e.g., "Nathan: Hello [$> SomeGlobal.")
+ var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
+ var replacement_context: Dictionary = {} if not inline_context.is_empty() else _get_replacement_context(current_line, cursor.x)
+ var mutation_expression: String = ""
+ var is_inline_mutation: bool = not inline_context.is_empty()
+ var is_replacement: bool = not replacement_context.is_empty()
+ var is_using_line: bool = false
+
+ if is_inline_mutation:
+ mutation_expression = inline_context.get("expression", "")
+ elif is_replacement:
+ mutation_expression = replacement_context.get("expression", "")
+ else:
+ # Match autoloads on full mutation lines (MUTATION_PREFIXES + "using ")
+ for prefix: String in MUTATION_PREFIXES + PackedStringArray(["using "]):
+ if current_line.strip_edges().begins_with(prefix) and cursor.x > current_line.find(prefix):
+ mutation_expression = current_line.substr(0, cursor.x).strip_edges().substr(3)
+ is_using_line = current_line.strip_edges().begins_with("using ")
+ break
+
+ if mutation_expression == "" and not is_inline_mutation and not is_replacement:
+ return
+
+ # Find the last token (the part being typed)
+ var possible_prompt: String = mutation_expression.reverse()
+ possible_prompt = possible_prompt.substr(0, possible_prompt.find(" "))
+ possible_prompt = possible_prompt.substr(0, possible_prompt.find("("))
+ possible_prompt = possible_prompt.reverse()
+ var segments: PackedStringArray = possible_prompt.split(".")
+ var auto_completes: Array[Dictionary] = []
+
+ if segments.size() == 1:
+ # Suggest autoloads and state shortcuts
+ auto_completes = _get_top_level_completions(segments[0])
+ elif not is_using_line:
+ if not segments[0] in _autoloads.keys():
+ # See if the first segment is a property of a shortcut
+ var shortcut: String = _find_shortcut_with_member(segments[0])
+ if not shortcut.is_empty():
+ segments.insert(0, shortcut)
+
+ # Suggest members of an autoload or nested property
+ auto_completes = _get_member_completions(segments)
+
+ var prompt: String = segments[-1].to_lower()
+
+ # Add true/false
+ if prompt.length() > 1:
+ var icon: Texture2D = _get_icon_for_type("bool")
+ if "true".contains(prompt):
+ add_code_completion_option(CodeEdit.KIND_CONSTANT, "true", "true".substr(prompt.length()), Color.WHITE, icon)
+ if "false".contains(prompt):
+ add_code_completion_option(CodeEdit.KIND_CONSTANT, "false", "false".substr(prompt.length()), Color.WHITE, icon)
+
+ # Remove duplicates
+ var unique_auto_completes: PackedStringArray = []
+ auto_completes = auto_completes.filter(func(auto_complete: Dictionary) -> bool:
+ if unique_auto_completes.has(auto_complete.text): return false
+
+ unique_auto_completes.append(auto_complete.text)
+ return true
+ )
+
+ auto_completes.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return a.text.to_lower().similarity(prompt) > b.text.to_lower().similarity(prompt)
+ )
+ for auto_complete: Dictionary in auto_completes:
+ var icon: Texture2D = _get_icon_for_type(auto_complete.type)
+ var display_text: String = auto_complete.text
+ if auto_complete.type == "method":
+ display_text += "()"
+ var insert: String = display_text.substr(auto_complete.prompt.length())
+ add_code_completion_option(CodeEdit.KIND_CLASS, display_text, insert, theme_overrides.text_color, icon)
+
+
+# Find the shortcut that a member name belongs to.
+func _find_shortcut_with_member(member_name: String) -> String:
+ for autoload: String in _get_state_shortcuts():
+ for member: Dictionary in _get_members_for_base_script(autoload):
+ if member.name == member_name:
+ return autoload
+ return ""
+
+
+# Get completions for autoload names and state shortcut members.
+func _get_top_level_completions(prompt: String) -> Array[Dictionary]:
+ var completions: Array[Dictionary] = []
+ for autoload: String in _autoloads.keys():
+ if _matches_prompt(prompt, autoload):
+ completions.append({ prompt = prompt, text = autoload, type = "script" })
+ for autoload: String in _get_state_shortcuts():
+ for member: Dictionary in _get_members_for_base_script(autoload):
+ if _matches_prompt(prompt, member.name):
+ completions.append({ prompt = prompt, text = member.name, type = member.type })
+
+ # Check for state context aliases.
+ for alias: String in _context_cache.keys():
+ if _matches_prompt(prompt, alias):
+ completions.append({ prompt = prompt, text = alias, type = "context" })
+
+ return completions
+
+
+# Get completions for members of an autoload or nested property chain.
+func _get_member_completions(segments: PackedStringArray) -> Array[Dictionary]:
+ var completions: Array[Dictionary] = []
+ var prompt: String = segments[-1]
+ var members: Array[Dictionary] = []
+
+ if segments.size() == 2:
+ # Direct autoload property access (e.g., "SomeGlobal.property")
+ members = _get_members_for_base_script(segments[0])
+ else:
+ # Nested property access (e.g., "SomeGlobal.a_class_property.nested")
+ var chain_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
+ var resolved_script: Variant = _resolve_script_for_property_chain(chain_segments)
+ if resolved_script != null:
+ members = _get_members_for_script(resolved_script)
+ else:
+ # GDScript chain resolution failed, try C# fallback
+ members = _get_cs_chain_members(chain_segments)
+
+ for member: Dictionary in members:
+ if _matches_prompt(prompt, member.name):
+ completions.append({ prompt = prompt, text = member.name, type = member.type })
+ return completions
+
+
+# Get the appropriate icon for a member type.
+func _get_icon_for_type(type: String) -> Texture2D:
+ match type:
+ "context":
+ return load("uid://c22t5famre1yo")
+ "bool":
+ return get_theme_icon("bool", "EditorIcons")
+ "keyword":
+ return get_theme_icon("CodeHighlighter", "EditorIcons")
+ "script":
+ return get_theme_icon("Script", "EditorIcons")
+ "property":
+ return get_theme_icon("MemberProperty", "EditorIcons")
+ "method":
+ return get_theme_icon("MemberMethod", "EditorIcons")
+ "signal":
+ return get_theme_icon("MemberSignal", "EditorIcons")
+ "constant":
+ return get_theme_icon("MemberConstant", "EditorIcons")
+ "enum":
+ return get_theme_icon("Enum", "EditorIcons")
+ return null
+
+
+#endregion
+
+#region Cursor Helpers
+
+
+## Get the current caret position as a Vector2 (x=column, y=line).
+func get_cursor() -> Vector2i:
+ return Vector2i(get_caret_column(), get_caret_line())
+
+
+## Set the caret position from a Vector2 (x=column, y=line).
+func set_cursor(from_cursor: Vector2i) -> void:
+ set_caret_line(from_cursor.y, false)
+ set_caret_column(from_cursor.x, false)
+
+
+# Check if a prompt fuzzy-matches a candidate.
+func _matches_prompt(prompt: String, candidate: String) -> bool:
+ if prompt.length() > candidate.length(): return false
+ if prompt.is_empty(): return true
+
+ # Fuzzy match characters in order
+ candidate = candidate.to_lower()
+ var next_index: int = 0
+ for c: String in prompt.to_lower():
+ next_index = candidate.find(c, next_index) + 1
+ if next_index == 0:
+ return false
+ return true
+
+
+#endregion
+
+#region Autoload and Script Helpers
+
+
+# Get autoload shortcuts from settings and "using" clauses.
+func _get_state_shortcuts() -> PackedStringArray:
+ # Get any shortcuts defined in settings
+ var shortcuts: PackedStringArray = DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, [])
+
+ # Check for "using" clauses
+ for line: String in text.split("\n"):
+ var found: RegExMatch = compiler_regex.USING_REGEX.search(line)
+ if found:
+ shortcuts.append(found.strings[found.names.state])
+
+ # Check for any other script sources
+ for extra_script_source: String in DMSettings.get_setting(DMSettings.EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES, []):
+ if extra_script_source:
+ shortcuts.append(extra_script_source)
+
+ var edited_scene_root: Node = EditorInterface.get_edited_scene_root()
+
+ # Check for current open scene
+ if DMSettings.get_user_value("autocomplete_current_scene", false) and is_instance_valid(edited_scene_root):
+ var script: Script = edited_scene_root.get_script()
+ if is_instance_valid(script):
+ shortcuts.append(script.resource_path)
+
+ # Autocomplete from any context nodes in the edited scene tree
+ if edited_scene_root != _current_edited_scene_root:
+ _context_cache.clear()
+ _current_edited_scene_root = edited_scene_root
+ if is_instance_valid(edited_scene_root):
+ var context_nodes: Array = edited_scene_root.find_children("*", "DialogueStateContext", true, false)
+ for context_node: DialogueStateContext in context_nodes:
+ if context_node.target.get_script():
+ _context_cache[context_node.alias] = context_node.target.get_script()
+
+ return shortcuts
+
+
+# Get all members (methods, properties, signals, constants) for an top level object.
+func _get_members_for_base_script(base_script: Variant) -> Array[Dictionary]:
+ var script: Script
+
+ if base_script is String:
+ # Debounce method list lookups
+ if _member_cache.has(base_script) \
+ and _member_cache.get(base_script).get("at") > Time.get_ticks_msec() - 10000:
+ return _member_cache.get(base_script).get("members")
+
+ if _context_cache.has(base_script):
+ script = _context_cache.get(base_script)
+ else:
+ if not _autoloads.has(base_script) \
+ and not base_script.begins_with("res://") \
+ and not base_script.begins_with("uid://"):
+ return []
+
+ var autoload: Variant = load(_autoloads.get(base_script, base_script))
+ if autoload is PackedScene:
+ var node: Node = autoload.instantiate()
+ autoload = node.get_script()
+ node.free()
+
+ if autoload == null: return []
+
+ script = autoload if autoload is Script else autoload.get_script()
+ else:
+ script = base_script
+
+ if not is_instance_valid(script): return []
+
+ var members: Array[Dictionary] = _get_members_for_script(script)
+
+ if base_script is String:
+ _member_cache[base_script] = {
+ at = Time.get_ticks_msec(),
+ members = members
+ }
+
+ return members
+
+
+# Get all members (methods, properties, signals, constants) for a Script.
+func _get_members_for_script(script: Variant) -> Array[Dictionary]:
+ var members: Array[Dictionary] = []
+
+ # Its an enum:
+ if script is Dictionary:
+ for key: String in script.keys():
+ members.append({
+ name = key,
+ type = "enum"
+ })
+ return members
+
+ # Otherwise its a script
+ if not is_instance_valid(script): return []
+
+ if script.resource_path.is_empty() or script.resource_path.ends_with(".gd"):
+ for m: Dictionary in script.get_script_method_list():
+ if not m.name.begins_with("@"):
+ members.append({
+ name = m.name,
+ type = "method"
+ })
+ for m: Dictionary in script.get_script_property_list():
+ if not m.name.ends_with(".gd") and not m.name.contains("Built-in"):
+ members.append({
+ name = m.name,
+ type = "property",
+ "class_name" = m.get("class_name", "")
+ })
+ for m: Dictionary in script.get_script_signal_list():
+ members.append({
+ name = m.name,
+ type = "signal"
+ })
+ for c: String in script.get_script_constant_map():
+ members.append({
+ name = c,
+ type = "constant"
+ })
+
+ # Check for static properties
+ for line: String in script.source_code.split("\n"):
+ var matching: RegExMatch = STATIC_REGEX.search(line)
+ if matching:
+ members.append({
+ name = matching.strings[matching.names.property],
+ type = "property"
+ })
+ elif script.resource_path.ends_with(".cs"):
+ var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
+ for m: Dictionary in dotnet.GetMembersForScript(script):
+ members.append(m)
+
+ return members
+
+
+# Get the Script for a given class name.
+func _get_script_for_class_name(class_name_to_find: String) -> Script:
+ if class_name_to_find == "": return null
+
+ for class_data: Dictionary in ProjectSettings.get_global_class_list():
+ if class_data.get(&"class") == class_name_to_find:
+ return load(class_data.path)
+
+ return null
+
+
+# Get method info (args, return type) for a method in a Script.
+func _get_method_info_from_script(script: Script, method_name: String) -> Dictionary:
+ if not is_instance_valid(script): return {}
+
+ if script.resource_path.ends_with(".gd"):
+ for m: Dictionary in script.get_script_method_list():
+ if m.name == method_name:
+ return m
+ elif script.resource_path.ends_with(".cs"):
+ var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
+ for m: Dictionary in dotnet.GetMembersForScript(script):
+ if m.get("name") == method_name and m.get("type") == "method":
+ return m
+
+ return {}
+
+
+# Format method arguments into a hint string for display.
+func _format_method_hint(method_info: Dictionary) -> String:
+ if method_info.is_empty(): return ""
+
+ var args: Array = method_info.get("args", [])
+ if args.size() == 0: return ""
+
+ var hint_parts: PackedStringArray = []
+ for arg: Dictionary in args:
+ var arg_name: String = arg.get("name", "")
+ var arg_type: int = arg.get("type", TYPE_NIL)
+ var arg_class_name: String = arg.get("class_name", "")
+
+ var type_name: String = ""
+ if arg_class_name != "":
+ type_name = arg_class_name
+ elif arg_type != TYPE_NIL:
+ type_name = type_string(arg_type)
+
+ if type_name != "":
+ hint_parts.append("%s: %s" % [arg_name, type_name])
+ else:
+ hint_parts.append(arg_name)
+
+ return ", ".join(hint_parts)
+
+
+# Resolve the C# script for an autoload name.
+func _resolve_cs_script_for_autoload(base_name: String) -> Script:
+ if not _autoloads.has(base_name): return null
+
+ var autoload: Variant = load(_autoloads[base_name])
+ if autoload is PackedScene:
+ var node: Node = autoload.instantiate()
+ autoload = node.get_script()
+ node.free()
+
+ if autoload == null: return null
+
+ var script: Script = autoload if autoload is Script else autoload.get_script()
+
+ if not is_instance_valid(script): return null
+ if not script.resource_path.ends_with(".cs"): return null
+
+ return script
+
+
+# Resolve members for a C# property chain via reflection fallback.
+func _get_cs_chain_members(chain_segments: PackedStringArray) -> Array[Dictionary]:
+ if chain_segments.size() < 2: return []
+
+ var script: Script = _resolve_cs_script_for_autoload(chain_segments[0])
+ if script == null: return []
+
+ var remaining: Array[String] = []
+ for i: int in range(1, chain_segments.size()):
+ remaining.append(chain_segments[i])
+
+ var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
+ var result: Array[Dictionary] = []
+ for m: Dictionary in dotnet.GetMembersForPropertyChain(script, remaining):
+ result.append(m)
+ return result
+
+
+# Get method info for a C# property chain via reflection fallback.
+func _get_cs_chain_method_info(chain_segments: PackedStringArray, method_name: String) -> Dictionary:
+ if chain_segments.size() < 2: return {}
+
+ var script: Script = _resolve_cs_script_for_autoload(chain_segments[0])
+ if script == null: return {}
+
+ var remaining: Array[String] = []
+ for i: int in range(1, chain_segments.size()):
+ remaining.append(chain_segments[i])
+
+ var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
+ return dotnet.GetMethodInfoForPropertyChain(script, remaining, method_name)
+
+
+#endregion
+
+#region Symbol Resolution Helpers
+
+
+# Find the line number where a member is defined in a script's source code.
+func _find_definition_in_script(script: Script, member_name: String) -> Dictionary:
+ if not is_instance_valid(script): return { script = script, line_number = -1 }
+
+ var lines: PackedStringArray = script.source_code.split("\n")
+
+ if script is GDScript:
+ # Try to find the line in a GDScript file.
+ var method_regex: RegEx = RegEx.create_from_string("^\\s*func\\s+" + member_name + "\\s*\\(")
+ var property_regex: RegEx = RegEx.create_from_string("^(@.*\\s)?\\s*var\\s+" + member_name + "\\s*[:\\s=]")
+ var signal_regex: RegEx = RegEx.create_from_string("^\\s*signal\\s+" + member_name + "\\s*[\\(\\s]")
+ var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+" + member_name + "\\s*[:\\s=]")
+ var enum_regex: RegEx = RegEx.create_from_string("^\\s*enum\\s+" + member_name + "[\\s$]")
+ var inner_class_regex: RegEx = RegEx.create_from_string("^\\s*class\\s+" + member_name + ":")
+
+ for i: int in range(lines.size()):
+ var line: String = lines[i]
+ if method_regex.search(line) \
+ or property_regex.search(line) \
+ or signal_regex.search(line) \
+ or const_regex.search(line) \
+ or enum_regex.search(line) \
+ or inner_class_regex.search(line):
+ # Editor line numbers start at 1
+ return {
+ script = script,
+ line_number = i + 1
+ }
+
+ # Does the script extend another one?
+ var extends_regex: RegEx = RegEx.create_from_string("extends (?.*)")
+ var found_extends: RegExMatch = extends_regex.search(script.source_code)
+ if found_extends:
+ var extends_name: String = found_extends.get_string(found_extends.names.extends)
+ if not "\"" in extends_name:
+ script = _get_script_for_class_name(extends_name)
+ return _find_definition_in_script(script, member_name)
+
+ elif script.resource_path.ends_with(".cs"):
+ # Try to find the line in a C# file.
+ var method_regex: RegEx = RegEx.create_from_string("^\\s*public\\s+.*?" + member_name + "\\s*\\(")
+ var property_regex: RegEx = RegEx.create_from_string("^\\s*(\\[Export\\] )?public\\s+.*?" + member_name)
+ var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+.*?" + member_name)
+ var enum_regex: RegEx = RegEx.create_from_string("^\\s*public enum\\s+" + member_name)
+
+ for i: int in range(lines.size()):
+ var line: String = lines[i]
+ if method_regex.search(line) \
+ or property_regex.search(line) \
+ or const_regex.search(line) \
+ or enum_regex.search(line):
+ # Editor line numbers start at 1
+ return {
+ script = script,
+ line_number = i + 1
+ }
+
+ return {
+ script = script,
+ line_number = -1
+ }
+
+
+# Resolve the symbol at a given position in a mutation line for definition lookup.
+func _resolve_mutation_symbol_at_position(line_text: String, column: int) -> Dictionary:
+ if not _is_in_mutation_context(line_text, column):
+ return {}
+
+ var symbol: String = get_word_at_pos(get_local_mouse_pos())
+ if symbol.is_empty(): return {}
+
+ # Find the full chain by looking backwards from the token start for dots and identifiers
+ var token_start: int = column
+ # NOTE: "a" prefix is so that numbers are counted as valid identifiers.
+ while token_start > 0 and ("a" + line_text[token_start - 1]).is_valid_ascii_identifier():
+ token_start -= 1
+
+ var chain_start: int = token_start
+ while chain_start > 0:
+ var prev_char: String = line_text[chain_start - 1]
+ if prev_char == ".":
+ chain_start -= 1
+ # Continue backwards to get the identifier before the dot
+ while chain_start > 0 and ("a" + line_text[chain_start - 1]).is_valid_ascii_identifier():
+ chain_start -= 1
+ else:
+ break
+
+ var full_chain: String = line_text.substr(chain_start, token_start + symbol.length() - chain_start)
+
+ # Remove any trailing parentheses content
+ if "(" in full_chain:
+ full_chain = full_chain.substr(0, full_chain.find("("))
+
+ var segments: PackedStringArray = full_chain.split(".")
+
+ # Check if it starts with an autoload
+ if not segments[0] in _autoloads.keys() and not segments[0] in _context_cache.keys():
+ var shortcut: String = _find_shortcut_with_member(segments[0])
+ if shortcut.is_empty():
+ return {}
+ else:
+ segments.insert(0, shortcut)
+
+ # The symbol we clicked on is the last segment
+ var member_name: String = segments[-1]
+
+ # Resolve the script that contains this member
+ var target_script: Variant = null
+ if segments.size() == 1 and segments[0] in _autoloads.keys():
+ member_name = "class_name"
+ var target: Variant = load(_autoloads.get(segments[0]))
+ if target is PackedScene:
+ var node: Node = target.instantiate()
+ target = node.get_script()
+ node.free()
+ target_script = target if target is Script else target.get_script()
+ elif segments.size() == 1 and _context_cache.has(segments[0]):
+ return {
+ "script": _context_cache.get(segments[0]),
+ "member_name": "class_name",
+ "symbol": symbol
+ }
+ else:
+ var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
+ target_script = _resolve_script_for_property_chain(object_segments)
+
+ if target_script == null:
+ return {}
+ elif target_script is Dictionary:
+ return {
+ "script": _resolve_script_for_property_chain(segments.slice(0, -2)),
+ "member_name": segments.slice(0, -1)[segments.size() - 2],
+ "symbol": symbol
+ }
+
+ return {
+ "script": target_script,
+ "member_name": member_name,
+ "symbol": symbol
+ }
+
+
+# Update the code hint to show method parameter information.
+func _update_code_hint() -> void:
+ var cursor: Vector2i = get_cursor()
+ var current_line: String = get_line(cursor.y)
+ var text_before_cursor: String = current_line.substr(0, cursor.x)
+
+ # Check if we're in a mutation context (inline, replacement, or full line)
+ var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
+ var replacement_context: Dictionary = {} if not inline_context.is_empty() else _get_replacement_context(current_line, cursor.x)
+ if not _is_in_mutation_context(current_line, cursor.x):
+ set_code_hint("")
+ return
+
+ # For inline mutations or replacements, scope to the bracket content
+ var expression_text: String = text_before_cursor
+ if not inline_context.is_empty():
+ var bracket_start: int = inline_context.get("bracket_start", 0)
+ expression_text = current_line.substr(bracket_start + 1, cursor.x - bracket_start - 1)
+ elif not replacement_context.is_empty():
+ var bracket_start: int = replacement_context.get("bracket_start", 0)
+ expression_text = current_line.substr(bracket_start + 2, cursor.x - bracket_start - 2)
+
+ # Check if cursor is inside parentheses by counting unmatched opening parens
+ var paren_depth: int = 0
+ var last_open_parenthesis_pos: int = -1
+ for i: int in range(expression_text.length()):
+ if expression_text[i] == "(":
+ paren_depth += 1
+ last_open_parenthesis_pos = i
+ elif expression_text[i] == ")":
+ paren_depth -= 1
+
+ if paren_depth <= 0 or last_open_parenthesis_pos == -1:
+ set_code_hint("")
+ return
+
+ # Extract the expression before the opening parenthesis
+ var expression_before_parenthesis: String = expression_text.substr(0, last_open_parenthesis_pos).strip_edges()
+
+ # Find the method chain (last token before the paren)
+ var method_chain: String = ""
+ for i: int in range(expression_before_parenthesis.length() - 1, -1, -1):
+ var c: String = expression_before_parenthesis[i]
+ if c == " " or c == "(" or c == "," or c == "=" or c == ">" or c == "!":
+ method_chain = expression_before_parenthesis.substr(i + 1)
+ break
+ if i == 0:
+ method_chain = expression_before_parenthesis
+
+ if method_chain == "":
+ set_code_hint("")
+ return
+
+ # Parse the method chain into segments
+ var segments: PackedStringArray = method_chain.split(".")
+ if segments.is_empty():
+ set_code_hint("")
+ return
+
+ # The last segment is the method name
+ var method_name: String = segments[-1]
+
+ # Check if it starts with an autoload
+ if not segments[0] in _autoloads.keys():
+ var shortcut: String = _find_shortcut_with_member(segments[0])
+ if shortcut.is_empty():
+ set_code_hint("")
+ return
+ else:
+ segments.insert(0, shortcut)
+
+ # Resolve the script for the object the method is called on
+ var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
+ var target_script: Variant = _resolve_script_for_property_chain(object_segments)
+
+ var method_info: Dictionary = {}
+ if target_script != null and target_script is Script:
+ method_info = _get_method_info_from_script(target_script, method_name)
+ else:
+ # GDScript chain resolution failed, try C# fallback
+ method_info = _get_cs_chain_method_info(object_segments, method_name)
+
+ if method_info.is_empty():
+ set_code_hint("")
+ return
+
+ # Get the method info and format the hint
+ var hint: String = _format_method_hint(method_info)
+
+ set_code_hint(hint)
+
+
+#endregion
+
+#region Mutation Context Helpers
+
+
+# Get the inline mutation context if the cursor is inside an inline mutation bracket.
+# Returns a dictionary with "expression" key containing the text to autocomplete,
+# or an empty dictionary if not in an inline mutation context.
+func _get_inline_mutation_context(line: String, cursor_x: int) -> Dictionary:
+ # Find all bracket positions and determine if cursor is inside one
+ var bracket_depth: int = 0
+ var bracket_start: int = -1
+ var bracket_content_start: int = -1
+
+ for i: int in range(line.length()):
+ if i >= cursor_x:
+ break
+
+ if line[i] == "[":
+ bracket_depth += 1
+ if bracket_depth == 1:
+ bracket_start = i
+ bracket_content_start = i + 1
+ elif line[i] == "]":
+ bracket_depth -= 1
+ if bracket_depth == 0:
+ bracket_start = -1
+ bracket_content_start = -1
+
+ # Not inside brackets
+ if bracket_start == -1 or bracket_content_start == -1:
+ return {}
+
+ # Get the content inside the brackets up to cursor
+ var bracket_content: String = line.substr(bracket_content_start, cursor_x - bracket_content_start)
+
+ # Check if this is a mutation tag
+ for prefix: String in INLINE_MUTATION_PREFIXES:
+ if bracket_content.begins_with(prefix):
+ # Return the expression part (after the tag)
+ var expression: String = bracket_content.substr(prefix.length())
+ return { "expression": expression, "bracket_start": bracket_start }
+
+ return {}
+
+
+# Get the replacement context if the cursor is inside a {{ }} replacement.
+# Returns a dictionary with "expression" key containing the text to autocomplete,
+# or an empty dictionary if not in a replacement context.
+func _get_replacement_context(line: String, cursor_x: int) -> Dictionary:
+ var search_text: String = line.substr(0, cursor_x)
+ var last_open: int = search_text.rfind("{{")
+ if last_open == -1:
+ return {}
+
+ # Check if there's a "}}" between the "{{" and the cursor
+ var between: String = search_text.substr(last_open + 2)
+ if "}}" in between:
+ return {}
+
+ return { "expression": between, "bracket_start": last_open }
+
+
+# Check if the cursor is in a mutation context (either inline, replacement, or full mutation line).
+func _is_in_mutation_context(line: String, cursor_x: int) -> bool:
+ if not _get_inline_mutation_context(line, cursor_x).is_empty():
+ return true
+ if not _get_replacement_context(line, cursor_x).is_empty():
+ return true
+ for prefix: String in MUTATION_PREFIXES:
+ if line.strip_edges().begins_with(prefix):
+ return true
+ return false
+
+
+# Resolve the Script for a chain of property accesses (e.g., "Autoload.prop1.prop2").
+func _resolve_script_for_property_chain(segments: PackedStringArray) -> Variant:
+ if segments.size() == 0: return null
+
+ var thing: Variant = null
+
+ if segments[0].begins_with("uid://") or segments[0].begins_with("res://"):
+ thing = load(segments[0])
+ elif _autoloads.has(segments[0]):
+ thing = load(_autoloads.get(segments[0]))
+ elif _context_cache.has(segments[0]):
+ thing = _context_cache.get(segments[0])
+ else:
+ return null
+
+ if thing is PackedScene:
+ var node: Node = thing.instantiate()
+ thing = node.get_script()
+ node.free()
+ elif thing == null:
+ return null
+ elif not thing is Script:
+ thing = thing.get_script()
+
+ var current_script: Variant = thing
+
+ if not is_instance_valid(current_script): return null
+ if (segments.size() == 1): return current_script
+
+ # Walk through each property in the chain (except the last one which is what we're completing)
+ for i: int in range(1, segments.size()):
+ var property_name: String = segments[i]
+ var found_property: bool = false
+
+ # Regular properties
+ for property_info: Dictionary in current_script.get_script_property_list():
+ if property_info.name == property_name:
+ var prop_class_name: String = property_info.get("class_name", "")
+ if prop_class_name != "":
+ current_script = _get_script_for_class_name(prop_class_name)
+ if current_script == null:
+ return null
+ found_property = true
+ break
+ else:
+ # Property doesn't have a class type, can't go deeper
+ return null
+
+ # Check for inner classes and enums
+ if not found_property:
+ for constant: String in current_script.get_script_constant_map():
+ if constant == property_name:
+ var constant_value: Variant = current_script.get_script_constant_map().get(constant)
+ # Inner class
+ if constant_value is Script:
+ current_script = constant_value
+ found_property = true
+ break
+ # Enum
+ if constant_value is Dictionary:
+ current_script = constant_value
+ found_property = true
+ break
+ else:
+ # Constant isn't an enum or an inner class
+ return null
+
+ # Static properties. NOTE: Godot doesn't programatically find static properties
+ # so we have to manually find them.
+ if not found_property and current_script is Script and current_script.source_code.contains("static var"):
+ for line: String in current_script.source_code.split("\n"):
+ var matched: RegExMatch = STATIC_REGEX.search(line)
+ if matched and matched.strings[matched.names.property] == property_name:
+ if matched.names.has("type"):
+ var type: String = matched.strings[matched.names.type]
+ current_script = _get_script_for_class_name(type)
+ if current_script == null:
+ return null
+ found_property = true
+ break
+ else:
+ return null
+
+ if not found_property:
+ return null
+
+ return current_script
+
+
+#endregion
+
+#region Label and Character Helpers
+
+
+## Get a list of cues from the current text.
+func get_cues(include_regions: bool = false) -> PackedStringArray:
+ var cues: PackedStringArray = PackedStringArray([])
+ var lines: PackedStringArray = text.split("\n")
+ for line: String in lines:
+ if line.strip_edges().begins_with("~ "):
+ cues.append(line.strip_edges().substr(2))
+ elif include_regions and line.strip_edges().begins_with("#region "):
+ cues.append("#" + line.strip_edges().replace("#region ", ""))
+
+ return cues
+
+
+## Work out what the next cue above the current line is
+func check_active_cue() -> void:
+ var line_number: int = get_caret_line()
+ var lines: PackedStringArray = text.split("\n")
+ # Look at each line above this one to find the next cue line
+ for i: int in range(line_number, -1, -1):
+ if lines[i].begins_with("~ ") and "cues" in DMSettings.get_user_value("cue_list_view", "regions+cues"):
+ active_cue_changed.emit(lines[i].replace("~ ", ""))
+ return
+ elif lines[i].begins_with("#region ") and "regions" in DMSettings.get_user_value("cue_list_view", "regions+cues"):
+ active_cue_changed.emit(lines[i].replace("#region ", ""))
+ return
+
+ active_cue_changed.emit("")
+
+
+## Move the caret line to match a given cue.
+func go_to_cue(cue: String, create_if_none: bool = false) -> void:
+ var found_cue: bool = false
+
+ var lines: PackedStringArray = text.split("\n")
+ for i: int in range(0, lines.size()):
+ if lines[i].strip_edges() == "~ " + cue or lines[i].strip_edges() == "#region " + cue:
+ found_cue = true
+ set_caret_line(i)
+ set_caret_column(0)
+ center_viewport_to_caret.call_deferred()
+
+ if create_if_none and not found_cue:
+ text += "\n\n\n~ %s\n\n=> END" % [cue]
+ set_caret_line(text.split("\n").size() - 2)
+ center_viewport_to_caret.call_deferred()
+
+
+## Go to a given line number.
+func go_to_line_number(line_number: int) -> void:
+ set_caret_line(line_number)
+ center_viewport_to_caret.call_deferred()
+
+
+## Get all character names from the dialogue that match the given prefix.
+func get_character_names(beginning_with: String) -> PackedStringArray:
+ var names: PackedStringArray = []
+ var lines: PackedStringArray = text.split("\n")
+ for line: String in lines:
+ if line.strip_edges().begins_with("#"): continue # skip comments
+ if ": " in line:
+ var character_name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")
+ # Checks for first-letter match before doing fuzzy matching
+ if not character_name in names and character_name.length() > 0 and beginning_with.length() > 0 \
+ and character_name.to_lower()[0] == beginning_with.to_lower()[0] \
+ and _matches_prompt(beginning_with, character_name):
+ names.append(character_name)
+ return names
+
+
+#endregion
+
+#region Text Editing Helpers
+
+
+## Mark a line as an error or not.
+func mark_line_as_error(line_number: int, is_error: bool) -> void:
+ # Lines display counting from 1 but are actually indexed from 0
+ line_number -= 1
+
+ if line_number < 0: return
+
+ 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, Color(0, 0, 0, 0))
+ 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: String = 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. Moves caret down 1 line if not "=> END".
+func insert_text_at_cursor(text_to_insert: String) -> void:
+ if text_to_insert != "=> END":
+ insert_text_at_caret(text_to_insert + "\n")
+ set_caret_line(get_caret_line() + 1)
+ else:
+ insert_text_at_caret(text_to_insert)
+ 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: int 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: int 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: int 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: Vector2i = 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 starting_scroll: float = scroll_vertical
+ var cursor: Vector2i = 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: PackedStringArray = 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: int = from - 1 if offset == -1 else to + 1
+ var target_to_index: int = to if offset == -1 else from
+ var line_to_move: String = 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
+ set_cursor(cursor)
+ from += offset
+ to += offset
+ if reselect:
+ select(from, 0, to, get_line_width(to))
+
+ text_changed.emit()
+ scroll_vertical = starting_scroll + offset
+
+
+## Open a given script.
+func open_script(script: Script, line_number: int) -> void:
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
+ var will_open_externally: bool = false
+
+ if script.resource_path.get_extension().to_lower() == "cs":
+ will_open_externally = settings.get_setting("dotnet/editor/external_editor") > 0
+ else:
+ var use_external: bool = settings.get_setting("text_editor/external/use_external_editor")
+ var exec_path: String = settings.get_setting("text_editor/external/exec_path")
+ will_open_externally = use_external and not exec_path.is_empty()
+
+ EditorInterface.edit_script(script, line_number, 0, true)
+
+ if not will_open_externally:
+ EditorInterface.set_main_screen_editor.call_deferred("Script")
+
+#endregion
+
+#region Signals
+
+
+func _on_project_settings_changed() -> void:
+ _autoloads = {}
+
+ # Add any actual autoloads
+ var project: ConfigFile = ConfigFile.new()
+ project.load("res://project.godot")
+ if project.has_section("autoload"):
+ for autoload: String in project.get_section_keys("autoload"):
+ if autoload != "DialogueManager":
+ _autoloads[autoload] = project.get_value("autoload", autoload).substr(1)
+
+ # Add project-defined classes if they contain static properties or methods
+ var plugin_path: String = DMPlugin.get_plugin_path()
+ if not plugin_path.is_empty():
+ for script_info: Dictionary in ProjectSettings.get_global_class_list():
+ if not script_info.path.begins_with(plugin_path):
+ var script: Script = load(script_info.path)
+ if script_info.path.ends_with(".cs"):
+ if "public static" in script.source_code:
+ _autoloads[script_info.class] = script_info.path
+ else:
+ var static_match: RegExMatch = STATIC_CONTENT_REGEX.search(script.source_code)
+ if static_match:
+ _autoloads[script_info.class] = script_info.path
+
+
+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 cue: String in get_cues():
+ if symbol == cue:
+ set_symbol_lookup_word_as_valid(true)
+ return
+
+ var cursor: Vector2i = get_line_column_at_pos(get_local_mouse_pos())
+ var line_text: String = get_line(cursor.y)
+
+ # Check if it's an imported cue (eg. "alias/cue")
+ if "/" in line_text and "=>" in line_text:
+ var goto_marker: String = "=>< " if "=><" in line_text else "=> "
+ var actual_symbol: String = line_text.substr(line_text.find(goto_marker) + goto_marker.length())
+ for cue: String in DMCompiler.get_cues_in_text(text, main_view.current_file_path):
+ if cue == actual_symbol:
+ set_symbol_lookup_word_as_valid(true)
+ return
+
+ # Check if it's a mutation line symbol
+ var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, cursor.x)
+ if not symbol_info.is_empty() and symbol_info.symbol == symbol:
+ var script: Script = symbol_info.script
+ var member_name: String = symbol_info.get("member_name")
+ if member_name == "class_name":
+ set_symbol_lookup_word_as_valid(true)
+ return
+ else:
+ var definition: Dictionary = _find_definition_in_script(script, member_name)
+ if definition.line_number > 0:
+ 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, "")
+ return
+
+ var line_text: String = get_line(line)
+
+ # Check if it's an imported cue (eg. "alias/cue")
+ if "/" in line_text and "=>" in line_text:
+ var goto_marker: String = "=>< " if "=><" in line_text else "=> "
+ var actual_symbol: String = line_text.substr(line_text.find(goto_marker) + goto_marker.length())
+ var prefix: String = actual_symbol.split("/")[0]
+ var cue_name: String = actual_symbol.split("/")[1]
+ var lines: PackedStringArray = text.split("\n")
+ for l: String in lines:
+ if not l.begins_with("import "): continue
+ var found: RegExMatch = compiler_regex.IMPORT_REGEX.search(l)
+ if found and found.strings[found.names.prefix] == prefix:
+ external_file_requested.emit(found.strings[found.names.path], cue_name)
+ return
+
+ # Check if it's a cue
+ for cue: String in get_cues():
+ if symbol == cue:
+ go_to_cue(symbol)
+ return
+
+ # Check if it's a mutation line symbol
+ var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, column)
+ if not symbol_info.is_empty() and symbol_info.symbol == symbol:
+ var script: Script = symbol_info.script
+ var member_name: String = symbol_info.member_name
+ if member_name == "class_name":
+ open_script(script, 1)
+ else:
+ var definition: Dictionary = _find_definition_in_script(script, member_name)
+ if definition.line_number > -1:
+ open_script(definition.script, definition.line_number)
+
+
+func _on_code_edit_text_changed() -> void:
+ request_code_completion(true)
+ _update_code_hint()
+
+
+func _on_code_edit_text_set() -> void:
+ queue_redraw()
+
+
+func _on_code_edit_caret_changed() -> void:
+ check_active_cue()
+ last_selected_text = get_selected_text()
+ _update_code_hint()
+
+
+func _on_code_edit_gutter_clicked(line: int, _gutter: int) -> void:
+ var line_errors: Array = errors.filter(func(error: Dictionary) -> bool: return error.line_number == line)
+ if line_errors.size() > 0:
+ error_clicked.emit(line)
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/code_edit.gd.uid b/addons/dialogue_manager/components/code_edit.gd.uid
new file mode 100644
index 0000000..ab2b9e5
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit.gd.uid
@@ -0,0 +1 @@
+uid://djeybvlb332mp
diff --git a/addons/dialogue_manager/components/code_edit.tscn b/addons/dialogue_manager/components/code_edit.tscn
new file mode 100644
index 0000000..2e0c556
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit.tscn
@@ -0,0 +1,56 @@
+[gd_scene format=3 uid="uid://civ6shmka5e8u"]
+
+[ext_resource type="Script" uid="uid://klpiq4tk3t7a" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="1_58cfo"]
+[ext_resource type="Script" uid="uid://djeybvlb332mp" path="res://addons/dialogue_manager/components/code_edit.gd" id="1_g324i"]
+
+[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_crbvo"]
+script = ExtResource("1_58cfo")
+
+[node name="CodeEdit" type="CodeEdit" unique_id=236286673]
+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"
+scroll_past_end_of_file = true
+minimap_draw = true
+syntax_highlighter = SubResource("SyntaxHighlighter_crbvo")
+highlight_all_occurrences = true
+highlight_current_line = true
+draw_tabs = 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 0000000..413cb17
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd
@@ -0,0 +1,254 @@
+@tool
+class_name DMSyntaxHighlighter extends SyntaxHighlighter
+
+
+var regex: DMCompilerRegEx = DMCompilerRegEx.new()
+var expression_parser: DMExpressionParser = DMExpressionParser.new()
+
+var cache: Dictionary = {}
+
+
+func _clear_highlighting_cache() -> void:
+ cache.clear()
+
+
+func _get_line_syntax_highlighting(line: int) -> Dictionary:
+ expression_parser.include_comments = true
+
+ 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):
+ 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]
+
+ var theme: DMThemeValues = text_edit.theme_overrides
+
+ var index: int = 0
+
+ match DMCompiler.get_line_type(text):
+ DMConstants.TYPE_USING:
+ colors[index] = { color = theme.conditions_color }
+ colors[index + "using ".length()] = { color = theme.text_color }
+
+ DMConstants.TYPE_IMPORT:
+ colors[index] = { color = theme.conditions_color }
+ var import: RegExMatch = regex.IMPORT_REGEX.search(text)
+ if import:
+ colors[index + import.get_start("path") - 1] = { color = theme.strings_color }
+ colors[index + import.get_end("path") + 1] = { color = theme.conditions_color }
+ colors[index + import.get_start("prefix")] = { color = theme.text_color }
+
+ DMConstants.TYPE_COMMENT:
+ colors[index] = { color = theme.comments_color }
+
+ DMConstants.TYPE_CUE:
+ colors[index] = { color = theme.cues_color }
+
+ DMConstants.TYPE_CONDITION, DMConstants.TYPE_WHILE, DMConstants.TYPE_MATCH, DMConstants.TYPE_WHEN:
+ colors[0] = { color = theme.conditions_color }
+ index = text.find(" ")
+ if index > -1:
+ var expression: Array = expression_parser.tokenise(text.substr(index), DMConstants.TYPE_CONDITION, 0)
+ if expression.size() == 0:
+ colors[index] = { color = theme.critical_color }
+ else:
+ _highlight_expression(expression, colors, index)
+
+ DMConstants.TYPE_MUTATION:
+ colors[0] = { color = theme.mutations_line_color }
+ index = text.find(" ")
+ var expression: Array = expression_parser.tokenise(text.substr(index), DMConstants.TYPE_MUTATION, 0)
+ if expression.size() == 0:
+ colors[index] = { color = theme.critical_color }
+ else:
+ _highlight_expression(expression, colors, index)
+
+ DMConstants.TYPE_GOTO:
+ if text.strip_edges().begins_with("%"):
+ colors[index] = { color = theme.numbers_color }
+ index = text.find(" ")
+ _highlight_goto(text, colors, index)
+
+ DMConstants.TYPE_RANDOM:
+ colors[index] = { color = theme.numbers_color }
+
+ DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE:
+ if text.strip_edges().begins_with("- "):
+ colors[index] = { color = theme.cues_color }
+ index = text.find(" ", text.find("- "))
+
+ if text.strip_edges().begins_with("%"):
+ colors[index] = { color = theme.numbers_color }
+ index = text.find(" ", text.find("%"))
+ colors[index] = { color = theme.text_color.lerp(theme.symbols_color, 0.5) }
+
+ var dialogue_text: String = text.substr(index, text.find("=>"))
+
+ # Highlight character name (but ignore ":" within line ID reference)
+ var split_index: int = dialogue_text.replace("\\:", "??").find(":")
+ if dialogue_text.substr(split_index - 3, 3) != "[ID":
+ colors[index + split_index + 1] = { color = theme.text_color }
+ else:
+ # If there's no character name then just highlight the text as dialogue.
+ colors[index] = { color = theme.text_color }
+
+ # Interpolation
+ var replacements: Array[RegExMatch] = regex.REPLACEMENTS_REGEX.search_all(dialogue_text)
+ for replacement: RegExMatch in replacements:
+ var expression_text: String = replacement.get_string().substr(0, replacement.get_string().length() - 2).substr(2)
+ var expression: Array = expression_parser.tokenise(expression_text, DMConstants.TYPE_MUTATION, replacement.get_start())
+ var expression_index: int = index + replacement.get_start()
+ colors[expression_index] = { color = theme.symbols_color }
+ if expression.size() == 0 or expression[0].type == DMConstants.TYPE_ERROR:
+ colors[expression_index] = { color = theme.critical_color }
+ else:
+ _highlight_expression(expression, colors, index + 2)
+ colors[expression_index + expression_text.length() + 2] = { color = theme.symbols_color }
+ colors[expression_index + expression_text.length() + 4] = { color = theme.text_color }
+ # Tags (and inline mutations)
+ var bbcodes: Array[Dictionary] = DMResolvedLineData.find_bbcode_positions_in_string(dialogue_text, true, true)
+ for bbcode: Dictionary in bbcodes:
+ var tag: String = bbcode.code
+ var code: String = bbcode.raw_args
+ if code.begins_with("["):
+ colors[index + bbcode.start] = { color = theme.tags_color }
+ colors[index + bbcode.start + 2] = { color = theme.text_color }
+ var pipe_cursor: int = code.find("|")
+ while pipe_cursor > -1:
+ colors[index + bbcode.start + pipe_cursor + 1] = { color = theme.tags_color }
+ colors[index + bbcode.start + pipe_cursor + 2] = { color = theme.text_color }
+ pipe_cursor = code.find("|", pipe_cursor + 1)
+ colors[index + bbcode.end - 1] = { color = theme.tags_color }
+ colors[index + bbcode.end + 1] = { color = theme.text_color }
+ else:
+ colors[index + bbcode.start] = { color = theme.tags_color }
+ colors[index + bbcode.end] = { color = theme.tags_color }
+ if bbcode.bbcode.ends_with(" /]"):
+ colors[index + bbcode.end - 1] = { color = theme.tags_color }
+ colors[index + bbcode.end + 1] = { color = theme.text_color }
+ else:
+ colors[index + bbcode.end + 1] = { color = theme.text_color }
+
+ if tag.begins_with("$>") or tag.begins_with("do") or tag.begins_with("set") or tag.begins_with("if"):
+ if tag.begins_with("if"):
+ colors[index + bbcode.start] = { color = theme.conditions_color }
+ if bbcode.bbcode.ends_with(" /]"):
+ colors[index + bbcode.end - 1] = { color = theme.conditions_color }
+ colors[index + bbcode.end] = { color = theme.conditions_color }
+ colors[index + bbcode.end + 1] = { color = theme.conditions_color }
+ colors[index + bbcode.end + 1] = { color = theme.text_color }
+ else:
+ colors[index + bbcode.end] = { color = theme.conditions_color }
+ else:
+ colors[index + bbcode.start] = { color = theme.mutations_line_color }
+ colors[index + bbcode.end] = { color = theme.mutations_line_color }
+ var expression: Array = expression_parser.tokenise(code, DMConstants.TYPE_MUTATION, bbcode.start + bbcode.code.length())
+ if expression.size() == 0 or expression[0].type == DMConstants.TYPE_ERROR:
+ colors[index + bbcode.start + tag.length() + 1] = { color = theme.critical_color }
+ else:
+ _highlight_expression(expression, colors, index + 2)
+ # else and closing if have no expression
+ elif tag.begins_with("else") or tag.begins_with("/if"):
+ colors[index + bbcode.start] = { color = theme.conditions_color }
+ colors[index + bbcode.end] = { color = theme.conditions_color }
+
+ # Jumps
+ if "=> " in text or "=>< " in text:
+ _highlight_goto(text, colors, index)
+
+ # Order the dictionary keys to prevent CodeEdit from having issues
+ var ordered_colors: Dictionary = {}
+ var ordered_keys: Array = colors.keys()
+ ordered_keys.sort()
+ for key_index: int in ordered_keys:
+ ordered_colors[key_index] = colors[key_index]
+
+ cache[text] = ordered_colors
+ return ordered_colors
+
+
+func _highlight_expression(tokens: Array, colors: Dictionary, index: int) -> int:
+ var theme: DMThemeValues = get_text_edit().theme_overrides
+ var last_index: int = index
+ for token: Dictionary in tokens:
+ last_index = token.i
+ match token.type:
+ DMConstants.TOKEN_COMMENT:
+ colors[index + token.i] = { color = theme.comments_color }
+
+ DMConstants.TOKEN_CONDITION, DMConstants.TOKEN_AND_OR, DMConstants.TOKEN_NOT:
+ colors[index + token.i] = { color = theme.conditions_color }
+
+ DMConstants.TOKEN_VARIABLE:
+ if token.value in ["true", "false"]:
+ colors[index + token.i] = { color = theme.conditions_color }
+ else:
+ colors[index + token.i] = { color = theme.members_color }
+
+ DMConstants.TOKEN_OPERATOR, DMConstants.TOKEN_COLON, \
+ DMConstants.TOKEN_COMMA, DMConstants.TOKEN_DOT, DMConstants.TOKEN_NULL_COALESCE, \
+ DMConstants.TOKEN_ASSIGNMENT, DMConstants.TOKEN_COMPARISON:
+ if token.get("value", null) == "in":
+ colors[index + token.i] = { color = theme.conditions_color }
+ else:
+ colors[index + token.i] = { color = theme.symbols_color }
+
+ DMConstants.TOKEN_NUMBER:
+ colors[index + token.i] = { color = theme.numbers_color }
+
+ DMConstants.TOKEN_STRING:
+ colors[index + token.i] = { color = theme.strings_color }
+
+ DMConstants.TOKEN_FUNCTION:
+ colors[index + token.i] = { color = theme.mutations_color }
+ colors[index + token.i + token.function.length()] = { color = theme.symbols_color }
+ for parameter: Array in token.value:
+ last_index = _highlight_expression(parameter, colors, index)
+ DMConstants.TOKEN_PARENS_CLOSE:
+ colors[index + token.i] = { color = theme.symbols_color }
+
+ DMConstants.TOKEN_DICTIONARY_REFERENCE:
+ colors[index + token.i] = { color = theme.members_color }
+ colors[index + token.i + token.variable.length()] = { color = theme.symbols_color }
+ last_index = _highlight_expression(token.value, colors, index)
+ DMConstants.TOKEN_ARRAY:
+ colors[index + token.i] = { color = theme.symbols_color }
+ for item: Array in token.value:
+ last_index = _highlight_expression(item, colors, index)
+ DMConstants.TOKEN_BRACKET_CLOSE:
+ colors[index + token.i] = { color = theme.symbols_color }
+
+ DMConstants.TOKEN_DICTIONARY:
+ colors[index + token.i] = { color = theme.symbols_color }
+ last_index = _highlight_expression(token.value.keys() + token.value.values(), colors, index)
+ DMConstants.TOKEN_BRACE_CLOSE:
+ colors[index + token.i] = { color = theme.symbols_color }
+ last_index += 1
+
+ DMConstants.TOKEN_GROUP:
+ last_index = _highlight_expression(token.value, colors, index)
+
+ return last_index
+
+
+func _highlight_goto(text: String, colors: Dictionary, index: int) -> int:
+ var theme: DMThemeValues = get_text_edit().theme_overrides
+ var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(text, {})
+ colors[goto_data.index] = { color = theme.jumps_color }
+ if "{{" in text:
+ index = text.find("{{", goto_data.index)
+ var last_index: int = 0
+ if goto_data.error:
+ colors[index + 2] = { color = theme.critical_color }
+ else:
+ last_index = _highlight_expression(goto_data.expression, colors, index)
+ index = text.find("}}", index + last_index)
+ colors[index] = { color = theme.jumps_color }
+
+ return index
diff --git a/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd.uid b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd.uid
new file mode 100644
index 0000000..9bad8cc
--- /dev/null
+++ b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd.uid
@@ -0,0 +1 @@
+uid://klpiq4tk3t7a
diff --git a/addons/dialogue_manager/components/cue_editor_property/editor_property.gd b/addons/dialogue_manager/components/cue_editor_property/editor_property.gd
new file mode 100644
index 0000000..36417d1
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_editor_property/editor_property.gd
@@ -0,0 +1,43 @@
+@tool
+class_name DMCueEditorProperty extends EditorProperty
+
+
+var DMCuePropertyEditorControl: PackedScene = preload("./editor_property_control.tscn")
+var control: Control = DMCuePropertyEditorControl.instantiate()
+var current_value: String
+var is_updating: bool = false
+
+
+func _init() -> void:
+ add_child(control)
+
+ control.cue = current_value
+
+ control.cue_changed.connect(_on_cue_changed)
+
+
+func _update_property() -> void:
+ control.actionable = get_edited_object()
+
+ var next_value: String = get_edited_object()[get_edited_property()]
+
+ if next_value.is_empty():
+ control.cue = ""
+ return
+
+ if next_value == current_value: return
+
+ is_updating = true
+ current_value = next_value
+ control.cue = current_value
+ is_updating = false
+
+
+#region Signals
+
+
+func _on_cue_changed(next_cue: String) -> void:
+ emit_changed(get_edited_property(), next_cue)
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/cue_editor_property/editor_property.gd.uid b/addons/dialogue_manager/components/cue_editor_property/editor_property.gd.uid
new file mode 100644
index 0000000..3d21d87
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_editor_property/editor_property.gd.uid
@@ -0,0 +1 @@
+uid://bak7go3j7n0qf
diff --git a/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd
new file mode 100644
index 0000000..a5435b4
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd
@@ -0,0 +1,142 @@
+@tool
+
+extends HBoxContainer
+
+
+signal cue_changed(next_cue: String)
+
+
+const CUE_ICON: Texture2D = preload("uid://d1aawtj2vsnxx")
+
+
+var actionable: Node = null
+var cue: String = "":
+ set(value):
+ cue = value
+ if not is_node_ready():
+ await ready
+ _update()
+ get:
+ return cue
+
+var link_action: String = "show"
+
+@onready var button: OptionButton = %Button
+@onready var link_button: Button = %LinkButton
+@onready var confirm_cue_name_dialog: ConfirmationDialog = %ConfirmCueNameDialog
+@onready var cue_name_label: Label = %CueNameLabel
+@onready var cue_name_edit: LineEdit = %CueNameEdit
+
+
+func _ready() -> void:
+ confirm_cue_name_dialog.register_text_enter(cue_name_edit)
+
+ _update()
+
+ button.get_popup().index_pressed.connect(_on_index_pressed)
+ button.get_popup().about_to_popup.connect(_on_menu_about_to_popup)
+
+ DMPlugin.instance.import_plugin.compiled_resource.connect(_on_dialogue_compiled)
+
+
+func _update() -> void:
+ var resource: DialogueResource = _get_resource()
+
+ var cues: PackedStringArray = []
+ if is_instance_valid(resource):
+ cues = Array(resource.get_cues()).filter(func(l: String) -> bool: return not l.contains("/"))
+
+ link_button.disabled = not is_instance_valid(resource)
+
+ var popup: PopupMenu = button.get_popup()
+ popup.clear()
+
+ var cue_icon: Texture2D = DMThemeValues.get_icon_with_color(CUE_ICON, DMThemeValues.get_values_from_editor().cues_color)
+
+ if cues.is_empty() or not is_instance_valid(resource):
+ popup.add_item(DMConstants.translate(""))
+ popup.set_item_disabled(0, true)
+ else:
+ for existing_cue: String in cues:
+ popup.add_icon_item(cue_icon, existing_cue)
+ if not cue.is_empty() and not cues.has(cue):
+ popup.add_icon_item(CUE_ICON, cue)
+
+ if cue.is_empty():
+ button.text = DMConstants.translate("")
+ button.icon = null
+ link_button.icon = get_theme_icon("New", "EditorIcons")
+ link_action = "create"
+ elif cues.has(cue):
+ button.select(-1)
+ button.select(cues.find(cue))
+ link_button.icon = get_theme_icon("ExternalLink", "EditorIcons")
+ link_action = "show"
+ else:
+ button.select(cues.size())
+ link_button.icon = get_theme_icon("New", "EditorIcons")
+ link_action = "create"
+
+
+func show_cue_in_editor(next_cue: String) -> void:
+ var resource: DialogueResource = _get_resource()
+ if is_instance_valid(resource):
+ DMPlugin.open_file_at_cue(resource, next_cue, true)
+
+
+func _get_resource() -> DialogueResource:
+ if not is_instance_valid(actionable): return null
+
+ var resource: DialogueResource
+ for property: Dictionary in actionable.get_property_list():
+ if "dialogue_resource" in property.name or "DialogueResource" in property.name or (property.class_name == "Resource" and actionable.get(property.name) is DialogueResource):
+ resource = actionable.get(property.name)
+
+ if is_instance_valid(resource):
+ return ResourceLoader.load(resource.resource_path, "", ResourceLoader.CACHE_MODE_REPLACE)
+
+ return null
+
+
+#region Signals
+
+
+func _on_dialogue_compiled(_resource: DialogueResource) -> void:
+ _update.call_deferred()
+
+
+func _on_menu_about_to_popup() -> void:
+ _update()
+
+
+func _on_index_pressed(index: int) -> void:
+ cue = button.get_popup().get_item_text(index)
+ cue_changed.emit(cue)
+ _update()
+
+
+func _on_link_button_pressed() -> void:
+ match link_action:
+ "create":
+ if cue.is_empty():
+ cue_name_edit.text = actionable.name.to_snake_case()
+ else:
+ cue_name_edit.text = cue
+ cue_name_label.text = DMConstants.translate("Cue:")
+ confirm_cue_name_dialog.popup_centered()
+ cue_name_edit.grab_focus.call_deferred()
+ cue_name_edit.select_all.call_deferred()
+
+ "show":
+ show_cue_in_editor.call_deferred(cue)
+ _update.call_deferred()
+
+
+func _on_confirm_cue_name_dialog_confirmed() -> void:
+ cue = cue_name_edit.text.to_snake_case()
+ cue_changed.emit(cue)
+ show_cue_in_editor.call_deferred(cue)
+ _update.call_deferred()
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd.uid b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd.uid
new file mode 100644
index 0000000..c6ba9bc
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd.uid
@@ -0,0 +1 @@
+uid://cn5hcy57m088v
diff --git a/addons/dialogue_manager/components/cue_editor_property/editor_property_control.tscn b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.tscn
new file mode 100644
index 0000000..3843e01
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_editor_property/editor_property_control.tscn
@@ -0,0 +1,49 @@
+[gd_scene format=3 uid="uid://molgra7vqxts"]
+
+[ext_resource type="Script" uid="uid://cn5hcy57m088v" path="res://addons/dialogue_manager/components/cue_editor_property/editor_property_control.gd" id="1_4prea"]
+
+[node name="EditorPropertyControl" type="HBoxContainer" unique_id=1721562088]
+offset_right = 40.0
+offset_bottom = 40.0
+script = ExtResource("1_4prea")
+
+[node name="Button" type="OptionButton" parent="." unique_id=1093477450]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+flat = true
+alignment = 1
+
+[node name="LinkButton" type="Button" parent="." unique_id=713900927]
+unique_name_in_owner = true
+layout_mode = 2
+flat = true
+
+[node name="ConfirmCueNameDialog" type="ConfirmationDialog" parent="." unique_id=967805543]
+unique_name_in_owner = true
+oversampling_override = 1.0
+size = Vector2i(200, 115)
+
+[node name="VBoxContainer" type="VBoxContainer" parent="ConfirmCueNameDialog" unique_id=151160442]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="CueNameLabel" type="Label" parent="ConfirmCueNameDialog/VBoxContainer" unique_id=1635294933]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Cue:"
+
+[node name="CueNameEdit" type="LineEdit" parent="ConfirmCueNameDialog/VBoxContainer" unique_id=830071500]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+
+[connection signal="pressed" from="LinkButton" to="." method="_on_link_button_pressed"]
+[connection signal="confirmed" from="ConfirmCueNameDialog" to="." method="_on_confirm_cue_name_dialog_confirmed"]
diff --git a/addons/dialogue_manager/components/cue_list.gd b/addons/dialogue_manager/components/cue_list.gd
new file mode 100644
index 0000000..4339d45
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_list.gd
@@ -0,0 +1,131 @@
+@tool
+extends VBoxContainer
+
+signal cue_selected(cue: String)
+
+
+const REGION_ICON: Texture2D = preload("../assets/region.svg")
+const CUE_ICON: Texture2D = preload("../assets/cue.svg")
+
+const VIEW_REGIONS_CUES: String = "regions+cues"
+const VIEW_REGIONS: String = "regions"
+const VIEW_CUES: String = "cues"
+const VIEW_AUTO: String = "auto"
+
+
+var cues: PackedStringArray:
+ set(value):
+ cues = value
+ apply_filter()
+ get:
+ return cues
+
+var filter: String:
+ set(next_filter):
+ filter = next_filter
+ apply_filter()
+ get:
+ return filter
+
+@onready var filter_edit: LineEdit = %FilterEdit
+@onready var menu_button: MenuButton = %MenuButton
+@onready var list: ItemList = %List
+
+var region_icon: Texture2D
+var cue_icon: Texture2D
+
+
+func _ready() -> void:
+ apply_theme()
+
+ filter_edit.placeholder_text = DMConstants.translate(&"cues_list.filter")
+
+ menu_button.get_popup().id_pressed.connect(_on_menu_button_id_pressed)
+
+
+func select_cue(cue: String) -> void:
+ list.deselect_all()
+ for i: int in range(0, list.get_item_count()):
+ if list.get_item_text(i) == cue.strip_edges():
+ list.select(i)
+
+
+func apply_filter() -> void:
+ list.clear()
+
+ var view_setting: String = DMSettings.get_user_value("cue_list_view", "regions+cues")
+ # If auto then see if there are regions in the list to then default to regions but if there
+ # are none default to cues instead
+ if view_setting == VIEW_AUTO:
+ view_setting = VIEW_CUES
+ for cue: String in cues:
+ if cue.begins_with("#"):
+ view_setting = VIEW_REGIONS
+ break
+
+ for cue: String in cues:
+ if filter == "" or filter.to_lower() in cue.to_lower():
+ if cue.begins_with("#"):
+ if VIEW_REGIONS in view_setting:
+ list.add_item(cue.strip_edges().substr(1), region_icon)
+ elif VIEW_CUES in view_setting:
+ list.add_item(cue.strip_edges(), cue_icon)
+
+
+func apply_theme() -> void:
+ if is_instance_valid(filter_edit):
+ filter_edit.right_icon = get_theme_icon("Search", "EditorIcons")
+ if is_instance_valid(menu_button):
+ menu_button.icon = get_theme_icon("GuiTabMenu", "EditorIcons")
+ if is_instance_valid(list):
+ list.add_theme_stylebox_override("panel", get_theme_stylebox("panel", "Panel"))
+
+ var theme_values: DMThemeValues = DMThemeValues.get_values_from_editor()
+
+ region_icon = DMThemeValues.get_icon_with_color(REGION_ICON, theme_values.comments_color)
+ cue_icon = DMThemeValues.get_icon_with_color(CUE_ICON, theme_values.cues_color)
+
+
+#region 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 cue: String = list.get_item_text(index)
+ cue_selected.emit(cue)
+
+
+func _on_menu_button_about_to_popup() -> void:
+ var menu: PopupMenu = menu_button.get_popup()
+ menu.clear()
+ menu.add_radio_check_item(DMConstants.translate(&"Regions & Cues"), 0)
+ menu.set_item_checked(0, DMSettings.get_user_value("cue_list_view", VIEW_REGIONS_CUES) == VIEW_REGIONS_CUES)
+ menu.add_radio_check_item(DMConstants.translate(&"Regions"), 1)
+ menu.set_item_checked(1, DMSettings.get_user_value("cue_list_view", VIEW_REGIONS_CUES) == VIEW_REGIONS)
+ menu.add_radio_check_item(DMConstants.translate(&"Cues"), 2)
+ menu.set_item_checked(2, DMSettings.get_user_value("cue_list_view", VIEW_REGIONS_CUES) == VIEW_CUES)
+ menu.add_radio_check_item(DMConstants.translate(&"Auto"), 3)
+ menu.set_item_checked(3, DMSettings.get_user_value("cue_list_view", VIEW_REGIONS_CUES) == VIEW_AUTO)
+
+
+func _on_menu_button_id_pressed(id: int) -> void:
+ match id:
+ 0:
+ DMSettings.set_user_value("cue_list_view", VIEW_REGIONS_CUES)
+ 1:
+ DMSettings.set_user_value("cue_list_view", VIEW_REGIONS)
+ 2:
+ DMSettings.set_user_value("cue_list_view", VIEW_CUES)
+ 3:
+ DMSettings.set_user_value("cue_list_view", VIEW_AUTO)
+ apply_filter()
+
+#endregion
diff --git a/addons/dialogue_manager/components/cue_list.gd.uid b/addons/dialogue_manager/components/cue_list.gd.uid
new file mode 100644
index 0000000..325ca61
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_list.gd.uid
@@ -0,0 +1 @@
+uid://d0k2wndjj0ifm
diff --git a/addons/dialogue_manager/components/cue_list.tscn b/addons/dialogue_manager/components/cue_list.tscn
new file mode 100644
index 0000000..6cbc32e
--- /dev/null
+++ b/addons/dialogue_manager/components/cue_list.tscn
@@ -0,0 +1,38 @@
+[gd_scene format=3 uid="uid://ctns6ouwwd68i"]
+
+[ext_resource type="Script" uid="uid://d0k2wndjj0ifm" path="res://addons/dialogue_manager/components/cue_list.gd" id="1_5qqmd"]
+
+[node name="CueList" type="VBoxContainer" unique_id=1743153913]
+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="Toolbar" type="HBoxContainer" parent="." unique_id=1431748116]
+layout_mode = 2
+
+[node name="FilterEdit" type="LineEdit" parent="Toolbar" unique_id=1569476716]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Filter cues"
+clear_button_enabled = true
+
+[node name="MenuButton" type="MenuButton" parent="Toolbar" unique_id=118120877]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="List" type="ItemList" parent="." unique_id=647368384]
+unique_name_in_owner = true
+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="Toolbar/FilterEdit" to="." method="_on_filter_edit_text_changed"]
+[connection signal="about_to_popup" from="Toolbar/MenuButton" to="." method="_on_menu_button_about_to_popup"]
+[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"]
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd b/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd
new file mode 100644
index 0000000..58c0546
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd
@@ -0,0 +1,42 @@
+@tool
+
+class_name DMDialogueEditorProperty extends EditorProperty
+
+
+const DialoguePropertyEditorControl: PackedScene = preload("./editor_property_control.tscn")
+
+
+var control: Control = DialoguePropertyEditorControl.instantiate()
+var current_value: DialogueResource
+
+
+func _init() -> void:
+ add_child(control)
+
+ control.resource = current_value
+
+ control.resource_changed.connect(_on_resource_changed)
+
+
+func _update_property() -> void:
+ var next_value: DialogueResource = 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
+
+ current_value = next_value
+ control.resource = current_value
+
+
+#region Signals
+
+
+func _on_resource_changed(next_resource: DialogueResource) -> void:
+ emit_changed(get_edited_property(), next_resource)
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd.uid b/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd.uid
new file mode 100644
index 0000000..283cc43
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/editor_property.gd.uid
@@ -0,0 +1 @@
+uid://nyypeje1a036
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd
new file mode 100644
index 0000000..10f9641
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd
@@ -0,0 +1,146 @@
+@tool
+
+extends HBoxContainer
+
+
+signal resource_changed(next_resource: DialogueResource)
+
+
+const ITEM_NEW: int = 100
+const ITEM_QUICK_LOAD: int = 200
+const ITEM_LOAD: int = 201
+const ITEM_EDIT: int = 300
+const ITEM_CLEAR: int = 301
+const ITEM_FILESYSTEM: int = 400
+
+
+var resource: Resource:
+ set(next_resource):
+ resource = next_resource
+ if button:
+ button.resource = resource
+ get:
+ return resource
+
+var quick_selected_file: String = ""
+
+@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: Control = $QuickOpenDialog/FilesList
+@onready var new_dialog: FileDialog = $NewDialog
+@onready var open_dialog: FileDialog = $OpenDialog
+
+
+func _ready() -> void:
+ menu_button.icon = get_theme_icon("GuiDropdown", "EditorIcons")
+
+
+func build_menu() -> void:
+ menu.clear()
+
+ menu.add_icon_item(DMPlugin.instance._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
+
+
+#region Signals
+
+
+func _on_new_dialog_file_selected(path: String) -> void:
+ DMPlugin.instance.main_view.new_file(path)
+ if DMCache.has_file(path):
+ resource_changed.emit(load(path))
+ else:
+ var next_resource: DialogueResource = await DMPlugin.instance.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_resource_button_pressed() -> void:
+ if is_instance_valid(resource):
+ DMPlugin.open_file(resource)
+
+ elif menu.visible:
+ menu.hide()
+ else:
+ build_menu()
+ @warning_ignore("narrowing_conversion")
+ 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:
+ if menu.visible:
+ menu.hide()
+ else:
+ build_menu()
+ @warning_ignore("narrowing_conversion")
+ 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:
+ new_dialog.popup_centered()
+
+ ITEM_QUICK_LOAD:
+ quick_selected_file = ""
+ files_list.files = DMCache.get_files()
+ if resource:
+ files_list.select_file(resource.resource_path)
+ quick_open_dialog.popup_centered()
+ files_list.focus_filter()
+
+ ITEM_LOAD:
+ open_dialog.popup_centered()
+
+ ITEM_EDIT:
+ DMPlugin.open_file(resource)
+
+ ITEM_CLEAR:
+ resource_changed.emit(null)
+
+ ITEM_FILESYSTEM:
+ EditorInterface.get_file_system_dock().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))
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd.uid b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd.uid
new file mode 100644
index 0000000..aab7d8d
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd.uid
@@ -0,0 +1 @@
+uid://dooe2pflnqtve
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.tscn b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.tscn
new file mode 100644
index 0000000..14f733c
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.tscn
@@ -0,0 +1,59 @@
+[gd_scene format=3 uid="uid://ycn6uaj7dsrh"]
+
+[ext_resource type="Script" uid="uid://dooe2pflnqtve" path="res://addons/dialogue_manager/components/dialogue_editor_property/editor_property_control.gd" id="1_het12"]
+[ext_resource type="PackedScene" uid="uid://b16uuqjuof3n5" path="res://addons/dialogue_manager/components/dialogue_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" unique_id=94139718]
+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="." unique_id=371241282 instance=ExtResource("2_hh3d4")]
+layout_mode = 2
+text = ""
+flat = true
+text_overrun_behavior = 3
+clip_text = true
+
+[node name="MenuButton" type="Button" parent="." unique_id=1528906238]
+layout_mode = 2
+flat = true
+
+[node name="Menu" type="PopupMenu" parent="." unique_id=1401460802]
+
+[node name="QuickOpenDialog" type="ConfirmationDialog" parent="." unique_id=1059747349]
+title = "Find Dialogue Resource"
+size = Vector2i(400, 600)
+min_size = Vector2i(400, 600)
+ok_button_text = "Open"
+
+[node name="FilesList" parent="QuickOpenDialog" unique_id=950831035 instance=ExtResource("3_l8fp6")]
+
+[node name="NewDialog" type="FileDialog" parent="." unique_id=317264302]
+size = Vector2i(900, 750)
+min_size = Vector2i(900, 750)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="OpenDialog" type="FileDialog" parent="." unique_id=641895417]
+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="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/dialogue_editor_property/resource_button.gd b/addons/dialogue_manager/components/dialogue_editor_property/resource_button.gd
new file mode 100644
index 0000000..82769a8
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_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 = DMPlugin.instance._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: Variant = 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: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY: return false
+ if data.type != "files": return false
+
+ var files: PackedStringArray = Array(data.files).filter(func(f: String) -> bool: return f.get_extension() == "dialogue")
+ return files.size() > 0
+
+
+func _drop_data(_at_position: Vector2, data: Variant) -> void:
+ var files: PackedStringArray = Array(data.files).filter(func(f: String) -> bool: return f.get_extension() == "dialogue")
+
+ if files.size() == 0: return
+
+ resource_dropped.emit(load(files[0]))
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/resource_button.gd.uid b/addons/dialogue_manager/components/dialogue_editor_property/resource_button.gd.uid
new file mode 100644
index 0000000..b1b9d26
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/resource_button.gd.uid
@@ -0,0 +1 @@
+uid://damhqta55t67c
diff --git a/addons/dialogue_manager/components/dialogue_editor_property/resource_button.tscn b/addons/dialogue_manager/components/dialogue_editor_property/resource_button.tscn
new file mode 100644
index 0000000..ff19799
--- /dev/null
+++ b/addons/dialogue_manager/components/dialogue_editor_property/resource_button.tscn
@@ -0,0 +1,9 @@
+[gd_scene format=3 uid="uid://b16uuqjuof3n5"]
+
+[ext_resource type="Script" uid="uid://damhqta55t67c" path="res://addons/dialogue_manager/components/dialogue_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/download_update_panel.gd b/addons/dialogue_manager/components/download_update_panel.gd
new file mode 100644
index 0000000..49d00fd
--- /dev/null
+++ b/addons/dialogue_manager/components/download_update_panel.gd
@@ -0,0 +1,85 @@
+@tool
+extends Control
+
+
+signal failed()
+signal updated(updated_to_version: String)
+
+
+const TEMP_FILE_NAME: String = "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 = DMConstants.translate(&"update.is_available_for_download") % value.tag_name.substr(1)
+ get:
+ return next_version_release
+
+
+func _ready() -> void:
+ $VBox/Center/DownloadButton.text = DMConstants.translate(&"update.download_update")
+ $VBox/Center2/NotesButton.text = DMConstants.translate(&"update.release_notes")
+
+
+#region Signals
+
+
+func _on_download_button_pressed() -> void:
+ # Safeguard the actual dialogue manager repo from accidentally updating itself
+ if FileAccess.file_exists("res://tests/test_basic_dialogue.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 = DMConstants.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: String = files[1]
+ # Remove archive folder
+ files.remove_at(0)
+ # Remove assets folder
+ files.remove_at(0)
+
+ for path: String 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)
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/download_update_panel.gd.uid b/addons/dialogue_manager/components/download_update_panel.gd.uid
new file mode 100644
index 0000000..7910ab4
--- /dev/null
+++ b/addons/dialogue_manager/components/download_update_panel.gd.uid
@@ -0,0 +1 @@
+uid://kpwo418lb2t2
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 0000000..540abd3
--- /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" uid="uid://kpwo418lb2t2" 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/errors_panel.gd b/addons/dialogue_manager/components/errors_panel.gd
new file mode 100644
index 0000000..9c6cf02
--- /dev/null
+++ b/addons/dialogue_manager/components/errors_panel.gd
@@ -0,0 +1,83 @@
+@tool
+extends HBoxContainer
+
+
+signal error_pressed(line_number: int)
+
+
+@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: int 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 = DMConstants.translate(&"n_of_n").format({ index = error_index + 1, total = errors.size() })
+ var error: DMError = errors[error_index]
+ error_button.text = DMConstants.translate(&"errors.line_and_message").format({ line = error.line_number, column = error.column_number, message = DMConstants.get_error_message(error.error) })
+
+
+#region Signals
+
+
+func _on_errors_panel_theme_changed() -> void:
+ apply_theme()
+
+
+func _on_error_button_pressed() -> void:
+ error_pressed.emit(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()
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/errors_panel.gd.uid b/addons/dialogue_manager/components/errors_panel.gd.uid
new file mode 100644
index 0000000..c305a80
--- /dev/null
+++ b/addons/dialogue_manager/components/errors_panel.gd.uid
@@ -0,0 +1 @@
+uid://d2l8nlb6hhrfp
diff --git a/addons/dialogue_manager/components/errors_panel.tscn b/addons/dialogue_manager/components/errors_panel.tscn
new file mode 100644
index 0000000..0b653cc
--- /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" uid="uid://d2l8nlb6hhrfp" path="res://addons/dialogue_manager/components/errors_panel.gd" id="1_nfm3c"]
+
+[sub_resource type="Image" id="Image_w0gko"]
+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_w0gko")
+
+[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 0000000..89eebbb
--- /dev/null
+++ b/addons/dialogue_manager/components/files_list.gd
@@ -0,0 +1,151 @@
+@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 MODIFIED_SUFFIX: String = "(*)"
+
+
+@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 last_selected_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 = DMConstants.translate(&"files_list.filter")
+
+
+func focus_filter() -> void:
+ filter_edit.grab_focus()
+
+
+func select_file(file: String) -> void:
+ list.deselect_all()
+ for i: int in range(0, list.get_item_count()):
+ var item_text: String = list.get_item_text(i).replace(MODIFIED_SUFFIX, "")
+ if item_text == get_nice_file(file, item_text.count("/") + 1):
+ list.select(i)
+ last_selected_file_path = file
+
+
+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: String 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: String in file_map.keys():
+ if file_map[key] == nice_file:
+ var bit_count: int = nice_file.count("/") + 2
+
+ var existing_nice_file: String = 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: PackedStringArray = file_path.replace("res://", "").replace(".dialogue", "").split("/")
+ bits = bits.slice(-path_bit_count)
+ return "/".join(bits)
+
+
+func apply_filter() -> void:
+ list.clear()
+ for file: String in file_map.keys():
+ if filter == "" or filter.to_lower() in file.to_lower():
+ var nice_file: String = file_map[file]
+ if file in unsaved_files:
+ nice_file += MODIFIED_SUFFIX
+ var new_id: int = 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")
+ if is_instance_valid(list):
+ list.add_theme_stylebox_override("panel", get_theme_stylebox("panel", "Panel"))
+
+
+#region 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: String = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
+ var file: String = 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: String = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
+ var file: String = file_map.find_key(item_text)
+ select_file(file)
+ file_double_clicked.emit(file)
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/files_list.gd.uid b/addons/dialogue_manager/components/files_list.gd.uid
new file mode 100644
index 0000000..2a1089a
--- /dev/null
+++ b/addons/dialogue_manager/components/files_list.gd.uid
@@ -0,0 +1 @@
+uid://dqa4a4wwoo0aa
diff --git a/addons/dialogue_manager/components/files_list.tscn b/addons/dialogue_manager/components/files_list.tscn
new file mode 100644
index 0000000..c9e862b
--- /dev/null
+++ b/addons/dialogue_manager/components/files_list.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=3 format=3 uid="uid://dnufpcdrreva3"]
+
+[ext_resource type="Script" uid="uid://dqa4a4wwoo0aa" 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
+size_flags_vertical = 3
+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/preview_generator.gd b/addons/dialogue_manager/components/preview_generator.gd
new file mode 100644
index 0000000..2d2862f
--- /dev/null
+++ b/addons/dialogue_manager/components/preview_generator.gd
@@ -0,0 +1,22 @@
+class_name DMPreviewGenerator extends EditorResourcePreviewGenerator
+
+
+const ICON: Texture2D = preload("../assets/resource.svg")
+
+
+func _handles(type: String) -> bool:
+ return type == "Resource"
+
+
+func _generate(resource: Resource, size: Vector2i, _metadata: Dictionary) -> Texture2D:
+ if resource is DialogueResource:
+ var image: Image = ICON.get_image()
+ image.resize(size.x, size.y, Image.INTERPOLATE_LANCZOS)
+ return ImageTexture.create_from_image(image)
+
+
+ return null
+
+
+func _generate_small_preview_automatically() -> bool:
+ return true
diff --git a/addons/dialogue_manager/components/preview_generator.gd.uid b/addons/dialogue_manager/components/preview_generator.gd.uid
new file mode 100644
index 0000000..bf38054
--- /dev/null
+++ b/addons/dialogue_manager/components/preview_generator.gd.uid
@@ -0,0 +1 @@
+uid://b5te0ncm02f2b
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 0000000..69458c7
--- /dev/null
+++ b/addons/dialogue_manager/components/search_and_replace.gd
@@ -0,0 +1,219 @@
+@tool
+extends VBoxContainer
+
+
+signal open_requested()
+signal close_requested()
+
+
+@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: Array = results[result_index]
+ code_edit.set_caret_line(r[0])
+ code_edit.select(r[0], r[1], r[0], r[1] + r[2])
+ code_edit.center_viewport_to_caret()
+ else:
+ result_index = -1
+ if is_instance_valid(code_edit):
+ code_edit.deselect()
+
+ result_label.text = DMConstants.translate(&"n_of_n").format({ index = result_index + 1, total = results.size() })
+ get:
+ return result_index
+
+
+func _ready() -> void:
+ apply_theme()
+
+ input.placeholder_text = DMConstants.translate(&"search.placeholder")
+ previous_button.tooltip_text = DMConstants.translate(&"search.previous")
+ next_button.tooltip_text = DMConstants.translate(&"search.next")
+ match_case_button.text = DMConstants.translate(&"search.match_case")
+ $Search/ReplaceCheckButton.text = DMConstants.translate(&"search.toggle_replace")
+ replace_button.text = DMConstants.translate(&"search.replace")
+ replace_all_button.text = DMConstants.translate(&"search.replace_all")
+ $Replace/ReplaceLabel.text = DMConstants.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()
+ search()
+
+
+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: PackedStringArray = code_edit.text.split("\n")
+ for line_number: int in range(0, lines.size()):
+ var line: String = lines[line_number]
+
+ var column: int = 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)
+
+
+#region 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: String = 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":
+ if results.size() == 0:
+ search(input.text)
+ self.result_index = wrapi(result_index + 1, 0, results.size())
+ "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]
+ code_edit.begin_complex_operation()
+ 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)
+ code_edit.end_complex_operation()
+ code_edit.text_changed.emit()
+
+ search(input.text, result_index)
+
+
+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()
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/search_and_replace.gd.uid b/addons/dialogue_manager/components/search_and_replace.gd.uid
new file mode 100644
index 0000000..66ec826
--- /dev/null
+++ b/addons/dialogue_manager/components/search_and_replace.gd.uid
@@ -0,0 +1 @@
+uid://cijsmjkq21cdq
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 0000000..52721c4
--- /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" uid="uid://cijsmjkq21cdq" 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/update_button.gd b/addons/dialogue_manager/components/update_button.gd
new file mode 100644
index 0000000..acc0655
--- /dev/null
+++ b/addons/dialogue_manager/components/update_button.gd
@@ -0,0 +1,126 @@
+@tool
+extends Button
+
+
+const REMOTE_RELEASES_URL: String = "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: Control = $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() -> bool: 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: PackedStringArray = 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 DMSettings.get_user_value("check_for_updates", true):
+ http_request.request(REMOTE_RELEASES_URL)
+
+
+#region 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 = DMPlugin.get_version()
+
+ # Work out the next version from the releases information on GitHub
+ var response: Variant = 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: Array = (response as Array).filter(func(release: Dictionary) -> bool:
+ 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 = DMConstants.translate(&"update.available").format({ version = versions[0].tag_name.substr(1) })
+ show()
+
+
+func _on_update_button_pressed() -> void:
+ if needs_reload:
+ var will_refresh: bool = on_before_refresh.call()
+ if will_refresh:
+ EditorInterface.restart_editor(true)
+ else:
+ var dialog_scale: float = EditorInterface.get_editor_scale()
+ download_dialog.min_size = Vector2(300, 250) * dialog_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 = DMConstants.translate(&"update.needs_reload")
+ needs_reload_dialog.ok_button_text = DMConstants.translate(&"update.reload_ok_button")
+ needs_reload_dialog.cancel_button_text = DMConstants.translate(&"update.reload_cancel_button")
+ needs_reload_dialog.popup_centered()
+
+ needs_reload = true
+ text = DMConstants.translate(&"update.reload_project")
+ apply_theme()
+
+
+func _on_download_update_panel_failed() -> void:
+ download_dialog.hide()
+ update_failed_dialog.dialog_text = DMConstants.translate(&"update.failed")
+ update_failed_dialog.popup_centered()
+
+
+func _on_needs_reload_dialog_confirmed() -> void:
+ EditorInterface.restart_editor(true)
+
+
+func _on_timer_timeout() -> void:
+ if not needs_reload:
+ check_for_update()
+
+
+#endregion
diff --git a/addons/dialogue_manager/components/update_button.gd.uid b/addons/dialogue_manager/components/update_button.gd.uid
new file mode 100644
index 0000000..9981132
--- /dev/null
+++ b/addons/dialogue_manager/components/update_button.gd.uid
@@ -0,0 +1 @@
+uid://cr1tt12dh5ecr
diff --git a/addons/dialogue_manager/components/update_button.tscn b/addons/dialogue_manager/components/update_button.tscn
new file mode 100644
index 0000000..6cff347
--- /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" uid="uid://cr1tt12dh5ecr" 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 0000000..297b25a
--- /dev/null
+++ b/addons/dialogue_manager/constants.gd
@@ -0,0 +1,235 @@
+class_name DMConstants extends RefCounted
+
+
+const USER_CONFIG_PATH: String = "user://dialogue_manager_user_config.json"
+
+
+enum MutationBehaviour {
+ Wait,
+ DoNotWait,
+ Skip
+}
+
+# Token types
+
+const TOKEN_FUNCTION: StringName = &"function"
+const TOKEN_DICTIONARY_REFERENCE: StringName = &"dictionary_reference"
+const TOKEN_DICTIONARY_NESTED_REFERENCE: StringName = &"dictionary_nested_reference"
+const TOKEN_GROUP: StringName = &"group"
+const TOKEN_ARRAY: StringName = &"array"
+const TOKEN_DICTIONARY: StringName = &"dictionary"
+const TOKEN_PARENS_OPEN: StringName = &"parens_open"
+const TOKEN_PARENS_CLOSE: StringName = &"parens_close"
+const TOKEN_BRACKET_OPEN: StringName = &"bracket_open"
+const TOKEN_BRACKET_CLOSE: StringName = &"bracket_close"
+const TOKEN_BRACE_OPEN: StringName = &"brace_open"
+const TOKEN_BRACE_CLOSE: StringName = &"brace_close"
+const TOKEN_COLON: StringName = &"colon"
+const TOKEN_COMPARISON: StringName = &"comparison"
+const TOKEN_ASSIGNMENT: StringName = &"assignment"
+const TOKEN_OPERATOR: StringName = &"operator"
+const TOKEN_COMMA: StringName = &"comma"
+const TOKEN_NULL_COALESCE: StringName = &"null_coalesce"
+const TOKEN_DOT: StringName = &"dot"
+const TOKEN_CONDITION: StringName = &"condition"
+const TOKEN_BOOL: StringName = &"bool"
+const TOKEN_NOT: StringName = &"not"
+const TOKEN_AND_OR: StringName = &"and_or"
+const TOKEN_STRING: StringName = &"string"
+const TOKEN_NUMBER: StringName = &"number"
+const TOKEN_VARIABLE: StringName = &"variable"
+const TOKEN_COMMENT: StringName = &"comment"
+
+const TOKEN_VALUE: StringName = &"value"
+const TOKEN_ERROR: StringName = &"error"
+
+# Line types
+
+const TYPE_UNKNOWN: StringName = &""
+const TYPE_IMPORT: StringName = &"import"
+const TYPE_USING: StringName = &"using"
+const TYPE_COMMENT: StringName = &"comment"
+const TYPE_RESPONSE: StringName = &"response"
+const TYPE_CUE: StringName = &"cue"
+const TYPE_CONDITION: StringName = &"condition"
+const TYPE_WHILE: StringName = &"while"
+const TYPE_MATCH: StringName = &"match"
+const TYPE_WHEN: StringName = &"when"
+const TYPE_MUTATION: StringName = &"mutation"
+const TYPE_GOTO: StringName = &"goto"
+const TYPE_DIALOGUE: StringName = &"dialogue"
+const TYPE_RANDOM: StringName = &"random"
+const TYPE_ERROR: StringName = &"error"
+
+# Line IDs
+
+const ID_NULL: StringName = &""
+const ID_ERROR: StringName = &"error"
+const ID_END: StringName = &"end"
+const ID_END_CONVERSATION: StringName = &"end!"
+
+# Errors
+
+const ERR_ERRORS_IN_IMPORTED_FILE: int = 100
+const ERR_FILE_ALREADY_IMPORTED: int = 101
+const ERR_DUPLICATE_IMPORT_NAME: int = 102
+const ERR_EMPTY_CUE: int = 103
+const ERR_DUPLICATE_CUE: int = 104
+const ERR_CUE_INVALID_CHARACTERS: int = 106
+const ERR_UNKNOWN_CUE: int = 107
+const ERR_INVALID_CUE_REFERENCE: int= 108
+const ERR_CUE_REFERENCE_HAS_NO_CONTENT: int = 109
+const ERR_INVALID_EXPRESSION: int = 110
+const ERR_UNEXPECTED_CONDITION: int = 111
+const ERR_DUPLICATE_ID: int = 112
+const ERR_MISSING_ID: int = 113
+const ERR_INVALID_INDENTATION: int = 114
+const ERR_INVALID_CONDITION_INDENTATION: int = 115
+const ERR_INCOMPLETE_EXPRESSION: int = 116
+const ERR_INVALID_EXPRESSION_FOR_VALUE: int = 117
+const ERR_UNKNOWN_ERROR: int = 118
+const ERR_CUE_BEGINS_WITH_NUMBER: int = 119
+const ERR_UNEXPECTED_END_OF_EXPRESSION: int = 120
+const ERR_UNEXPECTED_FUNCTION: int = 121
+const ERR_UNEXPECTED_BRACKET: int = 122
+const ERR_UNEXPECTED_CLOSING_BRACKET: int = 123
+const ERR_MISSING_CLOSING_BRACKET: int = 124
+const ERR_UNEXPECTED_OPERATOR: int = 125
+const ERR_UNEXPECTED_COMMA: int = 126
+const ERR_UNEXPECTED_COLON: int = 127
+const ERR_UNEXPECTED_DOT: int = 128
+const ERR_UNEXPECTED_BOOLEAN: int = 129
+const ERR_UNEXPECTED_STRING: int = 130
+const ERR_UNEXPECTED_NUMBER: int = 131
+const ERR_UNEXPECTED_VARIABLE: int = 132
+const ERR_INVALID_INDEX: int = 133
+const ERR_UNEXPECTED_ASSIGNMENT: int = 134
+const ERR_UNKNOWN_USING: int = 135
+const ERR_EXPECTED_WHEN_OR_ELSE: int = 136
+const ERR_ONLY_ONE_ELSE_ALLOWED: int = 137
+const ERR_WHEN_MUST_BELONG_TO_MATCH: int = 138
+const ERR_CONCURRENT_LINE_WITHOUT_ORIGIN: int = 139
+const ERR_GOTO_NOT_ALLOWED_ON_CONCURRECT_LINES: int = 140
+const ERR_UNEXPECTED_SYNTAX_ON_NESTED_DIALOGUE_LINE: int = 141
+const ERR_NESTED_DIALOGUE_INVALID_JUMP: int = 142
+const ERR_MISSING_RESOURCE_FOR_AUTOSTART: int = 143
+const ERR_LONELY_STATIC_ID: int = 144
+
+
+static var _current_locale: String = ""
+static var _current_translation: Translation
+static var _en_translation: Translation
+
+
+## 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_CUE:
+ return translate(&"errors.empty_cue")
+ ERR_DUPLICATE_CUE:
+ return translate(&"errors.duplicate_cue")
+ ERR_CUE_INVALID_CHARACTERS:
+ return translate(&"errors.invalid_cue_string")
+ ERR_CUE_BEGINS_WITH_NUMBER:
+ return translate(&"errors.invalid_cue_number")
+ ERR_UNKNOWN_CUE:
+ return translate(&"errors.unknown_cue")
+ ERR_INVALID_CUE_REFERENCE:
+ return translate(&"errors.jump_to_invalid_cue")
+ ERR_CUE_REFERENCE_HAS_NO_CONTENT:
+ return translate(&"errors.cue_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")
+ ERR_EXPECTED_WHEN_OR_ELSE:
+ return translate(&"errors.expected_when_or_else")
+ ERR_ONLY_ONE_ELSE_ALLOWED:
+ return translate(&"errors.only_one_else_allowed")
+ ERR_WHEN_MUST_BELONG_TO_MATCH:
+ return translate(&"errors.when_must_belong_to_match")
+ ERR_CONCURRENT_LINE_WITHOUT_ORIGIN:
+ return translate(&"errors.concurrent_line_without_origin")
+ ERR_GOTO_NOT_ALLOWED_ON_CONCURRECT_LINES:
+ return translate(&"errors.goto_not_allowed_on_concurrect_lines")
+ ERR_UNEXPECTED_SYNTAX_ON_NESTED_DIALOGUE_LINE:
+ return translate(&"errors.unexpected_syntax_on_nested_dialogue_line")
+ ERR_NESTED_DIALOGUE_INVALID_JUMP:
+ return translate(&"errors.err_nested_dialogue_invalid_jump")
+ ERR_MISSING_RESOURCE_FOR_AUTOSTART:
+ return translate(&"errors.missing_resource_for_autostart")
+ ERR_LONELY_STATIC_ID:
+ return translate(&"Static IDs can't be on a line with no other content.")
+
+ return translate(&"errors.unknown")
+
+
+static func translate(string: String) -> String:
+ var locale: String = TranslationServer.get_tool_locale()
+ if _current_translation == null or _current_locale != locale:
+ var base_path: String = new().get_script().resource_path.get_base_dir()
+ var translation_path: String = "%s/l10n/%s.po" % [base_path, locale]
+ var fallback_translation_path: String = "%s/l10n/%s.po" % [base_path, locale.substr(0, 2)]
+ var en_translation_path: String = "%s/l10n/en.po" % base_path
+ _current_translation = load(translation_path if FileAccess.file_exists(translation_path) else (fallback_translation_path if FileAccess.file_exists(fallback_translation_path) else en_translation_path))
+ _current_locale = locale
+ _en_translation = load(en_translation_path)
+ var message: StringName = _current_translation.get_message(string)
+ if not message.is_empty():
+ return message
+ elif not _en_translation.get_message(string).is_empty():
+ return _en_translation.get_message(string)
+ else:
+ return string
diff --git a/addons/dialogue_manager/constants.gd.uid b/addons/dialogue_manager/constants.gd.uid
new file mode 100644
index 0000000..f431917
--- /dev/null
+++ b/addons/dialogue_manager/constants.gd.uid
@@ -0,0 +1 @@
+uid://b1oarbmjtyesf
diff --git a/addons/dialogue_manager/debugger_plugin.gd b/addons/dialogue_manager/debugger_plugin.gd
new file mode 100644
index 0000000..b6958af
--- /dev/null
+++ b/addons/dialogue_manager/debugger_plugin.gd
@@ -0,0 +1,83 @@
+@tool
+
+class_name DMDebuggerPlugin extends EditorDebuggerPlugin
+
+
+const DebuggerView: PackedScene = preload("./views/debugger_view.tscn")
+
+
+var debugger_view: DMDebuggerView
+var debug_info: Dictionary = {}
+
+
+func _setup_session(session_id: int) -> void:
+ var session: EditorDebuggerSession = get_session(session_id)
+ session.started.connect(_on_started)
+ session.breaked.connect(_on_breaked)
+ session.continued.connect(_on_continued)
+ session.stopped.connect(_on_stopped)
+
+ debugger_view = DebuggerView.instantiate()
+ debugger_view.name = DMConstants.translate("Dialogue")
+ debugger_view.session = session
+ session.add_session_tab(debugger_view)
+
+
+func _has_capture(capture: String) -> bool:
+ return capture == "dm"
+
+
+func _capture(message: String, data: Array, _session_id: int) -> bool:
+ match message:
+ "dm:debug":
+ debug_info = data[0]
+ return true
+
+ "dm:current_scene":
+ debugger_view.current_scene = data[0]
+ return true
+
+ "dm:state":
+ debugger_view.contexts = data[0]
+ debugger_view.autoloads = data[1]
+ return true
+
+ "dm:get_line":
+ debugger_view.add_line(data[0])
+ return true
+
+ return false
+
+
+#region Signals
+
+
+func _on_started() -> void:
+ debugger_view.start()
+ debug_info = {}
+
+
+func _on_breaked(_can_debug: bool) -> void:
+ if debug_info.is_empty(): return
+
+ DMPlugin.open_file_at_line(debug_info.resource_path, debug_info.line_number)
+
+ await (Engine.get_main_loop() as SceneTree).create_timer(1).timeout
+
+ EditorInterface.set_main_screen_editor("Dialogue")
+ DMPlugin.instance.main_view.code_edit.runtime_error = DMError.new({
+ line_number = debug_info.line_number + 1
+ })
+
+ debug_info = {}
+
+
+func _on_continued() -> void:
+ DMPlugin.instance.main_view.code_edit.runtime_error = null
+ debug_info = {}
+
+
+func _on_stopped() -> void:
+ debugger_view.stop()
+ DMPlugin.instance.main_view.code_edit.runtime_error = null
+ debug_info = {}
diff --git a/addons/dialogue_manager/debugger_plugin.gd.uid b/addons/dialogue_manager/debugger_plugin.gd.uid
new file mode 100644
index 0000000..f165596
--- /dev/null
+++ b/addons/dialogue_manager/debugger_plugin.gd.uid
@@ -0,0 +1 @@
+uid://d0s08isf1ex1n
diff --git a/addons/dialogue_manager/dialogue_line.gd b/addons/dialogue_manager/dialogue_line.gd
new file mode 100644
index 0000000..b98cbd3
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_line.gd
@@ -0,0 +1,147 @@
+## A line of dialogue returned from [code]DialogueManager[/code].
+class_name DialogueLine extends RefCounted
+
+
+## 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 = DMConstants.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 static_id: String = ""
+
+## 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 lines that are spoken simultaneously with this one.
+var concurrent_lines: Array[DialogueLine] = []
+
+## 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 (as a string), [code]"auto"[/code], or empty string.
+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 = {}
+
+
+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:
+ DMConstants.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])
+ static_id = data.get("static_id", data.text)
+ speeds = data.get("speeds", {})
+ inline_mutations = data.get("inline_mutations", [] as Array[Array])
+ time = data.get("time", "")
+ tags = data.get("tags", [])
+ concurrent_lines = data.get("concurrent_lines", [] as Array[DialogueLine])
+
+ DMConstants.TYPE_MUTATION:
+ mutation = data.mutation
+
+
+## Reload this line from it's resource.
+func refresh() -> void:
+ if not "@" in id:
+ push_warning(DMConstants.translate("Cannot refresh dialogue line because its ID is missing a resource UID."))
+ return
+
+ var resource: DialogueResource = load("uid://%s" % id.split("@")[0])
+ var next_dialogue_line: DialogueLine = await resource.get_next_dialogue_line(next_id, extra_game_states)
+ type = next_dialogue_line.type
+ next_id = next_dialogue_line.next_id
+ character = next_dialogue_line.character
+ character_replacements = next_dialogue_line.character_replacements
+ text = next_dialogue_line.text
+ text_replacements = next_dialogue_line.text_replacements
+ static_id = next_dialogue_line.static_id
+ speeds = next_dialogue_line.speeds
+ inline_mutations = next_dialogue_line.inline_mutations
+ responses = next_dialogue_line.responses
+ concurrent_lines = next_dialogue_line.concurrent_lines
+ extra_game_states = next_dialogue_line.extra_game_states
+ time = next_dialogue_line.time
+ tags = next_dialogue_line.tags
+ mutation = next_dialogue_line.mutation
+
+
+## Restore a [DialogueLine] from a [code]to_serialized[/code] string, providing any extra
+## game states that would have been used fetch the line in the first place.
+static func new_from_serialized(serialized_data: String, extra_game_states_: Array = []) -> DialogueLine:
+ var bits: PackedStringArray = serialized_data.split("=>")
+ var id_bits: PackedStringArray = bits[0].split("@")
+ var resource: DialogueResource = load("uid://%s" % id_bits[0])
+ var line: DialogueLine = await resource.get_next_dialogue_line(id_bits[1], extra_game_states_)
+ line.next_id = bits[1]
+ return line
+
+
+## Serialize this [DialogueLine] to a string. Restore with
+## [code]DialogueLine.new_from_serialized(...)[/code].
+func to_serialized() -> String:
+ return "=>".join([id, next_id])
+
+
+func _to_string() -> String:
+ match type:
+ DMConstants.TYPE_DIALOGUE:
+ return "" % [character, text]
+ DMConstants.TYPE_MUTATION:
+ return ""
+ return ""
+
+
+## Check if a dialogue line has a given tag.
+func has_tag(tag_name: String) -> bool:
+ if tags.has(tag_name):
+ return true
+ else:
+ var wrapped: String = "%s=" % tag_name
+ for t: String in tags:
+ if t.begins_with(wrapped):
+ return true
+ return false
+
+
+## Get the value of a tag if the tag is in the form of [code]tag=value[/code]
+func get_tag_value(tag_name: String) -> String:
+ var wrapped: String = "%s=" % tag_name
+ for t: String in tags:
+ if t.begins_with(wrapped):
+ return t.replace(wrapped, "").strip_edges()
+ return ""
diff --git a/addons/dialogue_manager/dialogue_line.gd.uid b/addons/dialogue_manager/dialogue_line.gd.uid
new file mode 100644
index 0000000..7ec7029
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_line.gd.uid
@@ -0,0 +1 @@
+uid://rhuq0eyf8ar2
diff --git a/addons/dialogue_manager/dialogue_manager.gd b/addons/dialogue_manager/dialogue_manager.gd
new file mode 100644
index 0000000..80df08e
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_manager.gd
@@ -0,0 +1,1972 @@
+extends Node
+
+
+## Emitted when a dialogue balloon is created and dialogue starts
+signal dialogue_started(resource: DialogueResource)
+
+## Emitted when a cue is encountered while traversing dialogue, usually when jumping from a
+## goto line
+signal passed_cue(cue: 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 waiting for input.
+signal waiting_for_input()
+
+## Emitted when waiting for input received input.
+signal waited_for_input()
+
+## Emitted when some dialogue has reached the end.
+signal dialogue_ended(resource: DialogueResource)
+
+## Used internally.
+signal bridge_get_next_dialogue_line_completed(call_index: int, line: DialogueLine)
+
+## Used internally.
+signal bridge_get_line_completed(call_index: int, line: DialogueLine)
+
+## Used internally
+signal bridge_mutated(call_index: int)
+
+
+## The list of globals that dialogue can query
+var game_states: Array = []
+
+# Any known state contexts in the current tree.
+var _registered_contexts: Dictionary[String, Node] = {}
+
+## Allow dialogue to call singletons
+var include_singletons: bool = true
+
+## Allow dialogue to call static methods/properties on classes
+var include_classes: bool = true
+
+## Allow dialogue to call methods on [code]DialogueResource[/code]
+var include_dialogue_resource_as_self: bool = true
+
+## A runtime override for the project setting to ignore missing state values.
+var ignore_missing_state_values: bool = false
+
+## Used to resolve the current scene. Override if your game manages the current scene itself.
+var get_current_scene: Callable = func() -> Node:
+ var current_scene: Node = Engine.get_main_loop().current_scene
+ if not is_instance_valid(current_scene):
+ var root: Node = (Engine.get_main_loop() as SceneTree).root
+ current_scene = root.get_child(root.get_child_count() - 1)
+ return current_scene
+
+## Used to resolve the load function used in [code]*.dialogue[/code] files.
+## Override this if you need safer loading or custom loading logic.
+var load_from_within_dialogue: Callable = load
+
+## A filter hook: assign a [Callable] to restrict member access on objects at runtime.
+## [br][br]
+## The Callable is invoked whenever a thing's property, method, or string-keyed index is
+## about to be accessed. It should return [code]""[/code] to allow, or a non-empty error
+## string to deny. The error string is surfaced via [code]assert(false, err)[/code] (in
+## inner resolvers) or [code]show_error_for_missing_state_value(err, extra_game_states)[/code]
+## (where the game states are available).
+## [br][br]
+## It blocks 4 cases of code: [br]
+## 1. [code]thing.property[/code] [br]
+## 2. [code]thing.method()[/code] [br]
+## 3. assignment which is supported by DialogueManager [br]
+## 4. [code](thing as Variant)[lb]'property_name'[rb][/code] for non dictionary values, not only for [code]Object.get[/code] [br]
+## - This is the equivalent to cpp's [code]Variant::get[/code] in gdscript. [br]
+## The default Callable allows everything.
+var validate_member_access: Callable = func(_thing: Variant, _member: StringName, _member_kind: StringName) -> String:
+ return ""
+
+var _has_loaded_autoloads: bool = false
+var _autoloads: Dictionary = {}
+
+var _node_properties: Array = []
+var _method_info_cache: Dictionary = {}
+
+var _dotnet_dialogue_manager: RefCounted
+
+var _expression_parser: DMExpressionParser = DMExpressionParser.new()
+
+var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
+
+
+func _ready() -> void:
+ reseed_randomizer(hash(ProjectSettings.get_setting("application/config/name")))
+
+ # Cache the known Node2D properties
+ _node_properties = ["Script Variables"]
+ var temp_node: Node2D = Node2D.new()
+ for property: Dictionary in temp_node.get_property_list():
+ _node_properties.append(property.name)
+ temp_node.free()
+
+ # Make the dialogue manager available as a singleton
+ if not Engine.has_singleton("DialogueManager"):
+ Engine.register_singleton("DialogueManager", self)
+
+ ignore_missing_state_values = DMSettings.get_setting(DMSettings.IGNORE_MISSING_STATE_VALUES, false)
+
+ if EngineDebugger.is_active():
+ EngineDebugger.register_message_capture("dm", _capture)
+ # If this is a debug build we can preload autoloads (otherwise this is
+ # done on the first state request.
+ _load_autoloads()
+ # If this is a debug build then we need to know when the scene changes.
+ get_tree().scene_changed.connect(func() -> void:
+ _send_current_scene_to_debugger()
+ )
+ _send_current_scene_to_debugger()
+
+
+## Set a random seed.
+func reseed_randomizer(next_seed: int) -> void:
+ _rng.seed = next_seed
+
+
+# Receive messages from the debugger
+func _capture(message: String, data: Array) -> bool:
+ match message:
+ "select_node":
+ var payload: Array = [
+ data[0],
+ instance_from_id(data[0]).get_class(),
+ []
+ ]
+ EngineDebugger.send_message("remote_objects_selected", [payload])
+ return true
+ return false
+
+
+## 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: DMConstants.MutationBehaviour = DMConstants.MutationBehaviour.Wait) -> DialogueLine:
+ var line: DialogueLine = await _get_next_dialogue_line(resource, key, extra_game_states, mutation_behaviour)
+ if line == null:
+ # End the conversation
+ dialogue_ended.emit(resource)
+ return line
+
+
+# Internal line getter.
+func _get_next_dialogue_line(resource: DialogueResource, key: String = "", extra_game_states: Array = [], mutation_behaviour: DMConstants.MutationBehaviour = DMConstants.MutationBehaviour.Wait) -> DialogueLine:
+ # You have to provide a valid dialogue resource
+ if resource == null:
+ assert(false, DMConstants.translate(&"runtime.no_resource"))
+ if resource.lines.size() == 0:
+ assert(false, DMConstants.translate(&"runtime.no_content").format({ file_path = resource.resource_path }))
+
+ # Inject any "using" states into the game_states
+ for state_name: String in resource.using_states:
+ var autoload: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null(state_name)
+ if autoload == null:
+ printerr(DMConstants.translate(&"runtime.unknown_autoload").format({ autoload = state_name }))
+ else:
+ extra_game_states = [autoload] + extra_game_states
+
+ # Inject "self" into the extra game states.
+ if include_dialogue_resource_as_self:
+ _inject_state("self", resource, extra_game_states)
+
+ # Get the line data
+ var dialogue_line: DialogueLine = await get_line(resource, key, extra_game_states)
+
+ # If our dialogue is nothing then we hit the end
+ if not _is_valid(dialogue_line):
+ return null
+
+ # Run the mutation if it is one
+ if dialogue_line.type == DMConstants.TYPE_MUTATION:
+ var actual_next_id: String = dialogue_line.next_id.split("|")[0]
+ match mutation_behaviour:
+ DMConstants.MutationBehaviour.Wait:
+ await _mutate(dialogue_line.mutation, dialogue_line.extra_game_states)
+ DMConstants.MutationBehaviour.DoNotWait:
+ _mutate(dialogue_line.mutation, dialogue_line.extra_game_states)
+ DMConstants.MutationBehaviour.Skip:
+ pass
+ if actual_next_id in [DMConstants.ID_END_CONVERSATION, DMConstants.ID_NULL, null]:
+ return null
+ else:
+ return await _get_next_dialogue_line(resource, dialogue_line.next_id, dialogue_line.extra_game_states, mutation_behaviour)
+ else:
+ got_dialogue.emit.call_deferred(dialogue_line)
+ return dialogue_line
+
+
+## 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)
+
+ # Resolve this line reference to use the correct resource
+ var previous_resource: DialogueResource = resource
+ if "@" in key:
+ var bits: PackedStringArray = key.split("@")
+ if bits[0] != _get_resource_uid(resource):
+ resource = load("uid://" + bits[0])
+ key = bits[1]
+
+ # Key is blank so just use the first cue (or start of file)
+ if key == null or key == "":
+ if resource.first_cue.is_empty():
+ key = resource.lines.keys()[0]
+ else:
+ key = resource.first_cue
+
+ # See if we just ended the conversation
+ if key in [DMConstants.ID_END, DMConstants.ID_NULL, null]:
+ if stack.size() > 0:
+ return await get_line(resource, "|".join(stack), extra_game_states)
+ else:
+ return null
+ elif key == DMConstants.ID_END_CONVERSATION:
+ return null
+
+ # See if it is a cue
+ if key.begins_with("~ "):
+ key = key.substr(2)
+ if resource.cues.has(key):
+ key = resource.cues.get(key)
+ # Handle the resource reference if the cue had one
+ if "@" in key:
+ var bits: PackedStringArray = key.split("@")
+ if bits[0] != _get_resource_uid(resource):
+ resource = load("uid://" + bits[0])
+ key = bits[1]
+
+ if key in resource.cues.values():
+ passed_cue.emit(resource.cues.find_key(key))
+
+ if not resource.lines.has(key):
+ assert(false, DMConstants.translate(&"errors.key_not_found").format({ key = key }))
+
+ var data: Dictionary = resource.lines.get(key)
+
+ # Inject some debugger information into the game states.
+ _inject_state("debugger", {
+ resource_path = resource.resource_path,
+ line_number = int(key)
+ }, extra_game_states)
+
+ # If next_id is an expression we need to resolve it.
+ if data.has(&"next_id_expression"):
+ data.next_id = await _resolve(data.next_id_expression.duplicate(true), extra_game_states)
+
+ # This cue key points to another cue key so we should jump there instead
+ if data.type == DMConstants.TYPE_CUE and data.next_id in resource.cues.values():
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+
+ # Handle match statements
+ if data.type == DMConstants.TYPE_MATCH:
+ var value: Variant = await _resolve_condition_value(data, extra_game_states)
+ var else_cases: Array[Dictionary] = data.cases.filter(func(s: Dictionary) -> bool: return s.has("is_else"))
+ var else_case: Dictionary = {} if else_cases.size() == 0 else else_cases.front()
+ var next_id: String = ""
+ for case: Dictionary in data.cases:
+ if case == else_case:
+ continue
+ elif await _check_case_value(value, case, extra_game_states):
+ next_id = case.next_id
+ break
+ # Nothing matched so check for else case
+ if next_id == "":
+ if not else_case.is_empty():
+ next_id = else_case.next_id
+ else:
+ next_id = data.next_id_after
+ return await get_line(resource, next_id + id_trail, extra_game_states)
+
+ # Check for weighted random lines.
+ if data.has(&"siblings"):
+ # Only count siblings that pass their condition (if they have one).
+ var successful_siblings: Array = data.siblings.filter(func(sibling: Dictionary) -> bool: return not sibling.has("condition") or await _check_condition(sibling, extra_game_states))
+ # If there are no siblings that pass their conditions then just skip over them all.
+ if successful_siblings.size() == 0:
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+ # Otherwise, pick a random one.
+ var target_weight: float = _rng.randf_range(0, successful_siblings.reduce(func(total: float, sibling: Dictionary) -> float: return total + sibling.weight, 0))
+ var cummulative_weight: float = 0
+ for sibling: Dictionary in successful_siblings:
+ if target_weight < cummulative_weight + sibling.weight:
+ data = resource.lines.get(sibling.id)
+ break
+ else:
+ cummulative_weight += sibling.weight
+
+ # If this line is blank and it's the last line then check for returning snippets.
+ if data.type in [DMConstants.TYPE_COMMENT, DMConstants.TYPE_UNKNOWN]:
+ if data.next_id in [DMConstants.ID_END, DMConstants.ID_NULL, null]:
+ if stack.size() > 0:
+ return await get_line(resource, "|".join(stack), extra_game_states)
+ else:
+ return null
+ else:
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+
+ # If the line is a random block then go to the start of the block.
+ elif data.type == DMConstants.TYPE_RANDOM:
+ return await get_line(resource, data.next_id + id_trail, extra_game_states)
+
+ # Check conditions.
+ elif data.type in [DMConstants.TYPE_CONDITION, DMConstants.TYPE_WHILE]:
+ # "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)
+ elif data.has("next_sibling_id") and not data.next_sibling_id.is_empty():
+ return await get_line(resource, data.next_sibling_id + id_trail, extra_game_states)
+ else:
+ return await get_line(resource, data.next_id_after + id_trail, extra_game_states)
+
+ # Evaluate jumps.
+ elif data.type == DMConstants.TYPE_GOTO:
+ if data.is_snippet:
+ # Point the return address at this resource
+ var next_id_after: String = _get_id_with_resource(resource, data.next_id_after)
+ if not id_trail.begins_with("|" + next_id_after):
+ id_trail = "|" + next_id_after + id_trail
+
+ # If next_id has a UID reference then split it to find where to actually go next
+ var next_id: String = data.next_id
+ if "@" in next_id:
+ var bits: PackedStringArray = data.next_id.split("@")
+ resource = load("uid://" + bits[0])
+ next_id = bits[1]
+
+ # If the cue isn't in this resource it might be back in the original one
+ if not resource.lines.has(next_id) and not resource.cues.has(next_id):
+ resource = previous_resource
+
+ return await get_line(resource, next_id + id_trail, extra_game_states)
+
+ elif data.type == DMConstants.TYPE_DIALOGUE:
+ if not data.has(&"id"):
+ data.id = key
+
+ # Set up a line object.
+ data.resource = resource
+ 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
+
+ # Find any simultaneously said lines.
+ if data.has(&"concurrent_lines"):
+ # If the list includes this line then it isn't the origin line so ignore it.
+ if not data.concurrent_lines.has(data.id):
+ # Resolve IDs to their actual lines.
+ for line_id: String in data.concurrent_lines:
+ line.concurrent_lines.append(await get_line(resource, line_id, extra_game_states))
+
+ # If we are the first of a list of responses then get the other ones.
+ if data.type == DMConstants.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 next line is an end and we have an ID trail then see if it points to responses
+ var peeked_at_stack: bool = false
+ if next_line.next_id == DMConstants.ID_END and stack.size() > 0:
+ peeked_at_stack = true
+ var return_to_resource: DialogueResource = resource
+ var return_to_id: String = stack.front()
+ if "@" in return_to_id:
+ var bits: PackedStringArray = return_to_id.split("@")
+ if bits[0] != _get_resource_uid(resource):
+ return_to_resource = load("uid://" + bits[0])
+ return_to_id = bits[1]
+ next_line = return_to_resource.lines.get(return_to_id)
+
+ # If the response line is marked as a cue then make sure to emit the passed_cue signal.
+ if line.next_id in resource.cues.values():
+ passed_cue.emit(resource.cues.find_key(line.next_id))
+
+ # If the responses come from a snippet then we need to come back here afterwards.
+ if not peeked_at_stack and next_line.type == DMConstants.TYPE_GOTO and next_line.is_snippet and not id_trail.begins_with("|" + _get_id_with_resource(resource, next_line.next_id_after)):
+ id_trail = "|" + _get_id_with_resource(resource, next_line.next_id_after) + id_trail
+
+ # If the next line is a cue then check where it points to see if that is a set of responses.
+ while [DMConstants.TYPE_CUE, DMConstants.TYPE_GOTO].has(next_line.type) 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 == DMConstants.TYPE_RESPONSE:
+ # If we peeked the stack and it goes directly to a response type then we've consumed the front
+ # of the stack so drop just that return address from the trail and keep the rest.
+ if peeked_at_stack:
+ id_trail = "" if stack.size() <= 1 else "|" + "|".join(stack.slice(1))
+ # 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 == DMConstants.ID_NULL else _get_id_with_resource(resource, line.next_id) + id_trail
+
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("dm:get_line", [line.id])
+
+ return line
+
+
+# Inject a value into the current states.
+func _inject_state(key: String, value: Variant, extra_game_states: Array) -> void:
+ if extra_game_states.size() > 0 and typeof(extra_game_states[0]) == TYPE_DICTIONARY and extra_game_states[0].has("self"):
+ extra_game_states[0][key] = value
+ else:
+ extra_game_states.insert(0, { key: value })
+
+
+## Replace any variables, etc in the text.
+func get_resolved_line_data(data: Dictionary, extra_game_states: Array = []) -> DMResolvedLineData:
+ var text: String = translate(data)
+
+ # Resolve variables in the text
+ var text_replacements: Array[Dictionary] = data.get(&"text_replacements", [] as Array[Dictionary])
+ text = await _resolve_variables_in_text(text, text_replacements, extra_game_states)
+
+ # Resolve variables in tags
+ if data.has("tags"):
+ for i: int in data.tags.size():
+ data.tags[i] = await _resolve_variables_in_text(data.tags[i], [] , extra_game_states)
+
+ var compilation: DMCompilation = DMCompilation.new()
+
+ # Resolve random groups
+ for found: RegExMatch in compilation.regex.INLINE_RANDOM_REGEX.search_all(text):
+ var options: PackedStringArray = found.get_string(&"options").split(&"|")
+ text = text.replace(&"[[%s]]" % found.get_string(&"options"), options[_rng.randi_range(0, options.size() - 1)])
+
+ # Do a pass on the markers to find any conditionals
+ var markers: DMResolvedLineData = DMResolvedLineData.new(text)
+
+ # Resolve any conditionals and update marker positions as needed
+ if data.type in [DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE]:
+ var resolved_text: String = markers.text
+ var conditionals: Array[RegExMatch] = compilation.regex.INLINE_CONDITIONALS_REGEX.search_all(resolved_text)
+ var replacements: Array = []
+ for conditional: RegExMatch 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: PackedStringArray = body.split(&"[else]")
+ body = bits[0]
+ body_else = bits[1]
+ var condition: Dictionary = compilation.extract_condition("if " + condition_raw, false, 0)
+ var condition_passed: bool = await _check_condition({ condition = condition }, extra_game_states)
+ # If the condition fails then use the else of ""
+ if not condition_passed:
+ body = body_else
+ replacements.append({
+ start = conditional.get_start(),
+ end = conditional.get_end(),
+ string = conditional.get_string(),
+ body = body,
+ condition_passed = condition_passed
+ })
+
+ for i: int 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
+ _shift_markers(markers, r.start, r.end, r.body.length(), r.condition_passed)
+
+ var image_tags: Array[RegExMatch] = compilation.regex.IMAGE_TAGS_REGEX.search_all(resolved_text)
+ for image_tag: RegExMatch in image_tags:
+ # The [img] and [/img] tags have already been accounted for so now we just need to
+ # adjust for the path length.
+ var path_length: int = image_tag.get_string(image_tag.names.path).length()
+ _shift_markers(markers, image_tag.get_start(), image_tag.get_start() + path_length, 0)
+
+ markers.text = resolved_text
+
+ return markers
+
+
+# Resolve any variables found in a string.
+func _resolve_variables_in_text(text: String, text_replacements: Array[Dictionary], extra_game_states: Array = []) -> String:
+ if text_replacements.size() == 0 and "{{" in text:
+ text_replacements = _expression_parser.extract_replacements(text, 0)
+
+ for replacement: Dictionary in text_replacements:
+ if replacement.has("error"):
+ assert(false, "%s \"%s\"" % [DMConstants.get_error_message(replacement.get("error")), text])
+
+ var value: Variant = await _resolve(replacement.expression.duplicate(true), extra_game_states)
+ var index: int = text.find(replacement.value_in_text)
+ if index == -1:
+ # The replacement wasn't found but maybe the regular quotes have been replaced
+ # by special quotes while translating.
+ index = text.replace("“", "\"").replace("”", "\"").find(replacement.value_in_text)
+ if index > -1:
+ if value is Object and "_to_dialogue_string" in value:
+ value = value._to_dialogue_string()
+ text = text.substr(0, index) + str(value) + text.substr(index + replacement.value_in_text.length())
+
+ return text
+
+
+func _shift_markers(markers: DMResolvedLineData, removed_start: int, removed_end: int, body_length: int, keep_inner: bool = true) -> void:
+ # Calculate the offset for markers after the removed range
+ var after_offset: int = removed_end - removed_start - body_length
+
+ var next_speeds: Dictionary = {}
+ for index: int in markers.speeds:
+ if index < removed_start:
+ next_speeds[index] = markers.speeds[index]
+ elif index >= removed_end:
+ next_speeds[index - after_offset] = markers.speeds[index]
+ elif keep_inner:
+ # Marker is inside the conditional range and should be kept
+ # Shift it to account for the [if] tag being removed
+ next_speeds[removed_start] = markers.speeds[index]
+ else:
+ # marker is inside a failed conditional, remove it
+ continue
+ markers.speeds = next_speeds
+
+ var next_mutations: Array[Array] = []
+ for mutation: Array in markers.mutations:
+ var index: int = mutation[0]
+ if index < removed_start:
+ next_mutations.append(mutation)
+ elif index >= removed_end:
+ next_mutations.append([index - after_offset, mutation[1]])
+ elif keep_inner:
+ # Mutation is inside the conditional range and should be kept
+ # Shift it to account for the [if] tag being removed
+ next_mutations.append([removed_start, mutation[1]])
+ else:
+ # mutation is inside a failed conditional, remove it
+ continue
+ markers.mutations = next_mutations
+
+
+## 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: Dictionary in data.get(&"character_replacements", []):
+ var value: Variant = 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: RegExMatch in random_regex.search_all(character):
+ var options: PackedStringArray = found.get_string(&"options").split("|")
+ character = character.replace("[[%s]]" % found.get_string(&"options"), options[_rng.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 result: DMCompilerResult = DMCompiler.compile_string(text, "")
+
+ if result.errors.size() > 0:
+ printerr(DMConstants.translate(&"runtime.errors").format({ count = result.errors.size() }))
+ for error: DMError in result.errors:
+ printerr(DMConstants.translate(&"runtime.error_detail").format({
+ line = error.line_number + 1,
+ message = DMConstants.get_error_message(error.error)
+ }))
+ assert(false, DMConstants.translate(&"runtime.errors_see_details").format({ count = result.errors.size() }))
+
+ var resource: DialogueResource = DialogueResource.new()
+ resource.using_states = result.using_states
+ resource.cues = result.cues
+ resource.first_cue = result.first_cue
+ resource.character_names = result.character_names
+ resource.lines = result.lines
+
+ return resource
+
+
+#region Balloon helpers
+
+
+## Set the default balloon to use at runtime.
+func set_default_balloon(balloon_scene: Variant) -> Error:
+ var balloon_scene_path: String = ""
+
+ if balloon_scene is String:
+ balloon_scene_path = balloon_scene
+ elif balloon_scene is PackedScene:
+ balloon_scene_path = balloon_scene.resource_path
+
+ if not ["tscn", "scn"].has(balloon_scene_path.get_extension()):
+ return ERR_INVALID_DATA
+
+ ProjectSettings.set_setting(DMSettings.BALLOON_PATH, balloon_scene_path)
+
+ return OK
+
+
+## Show the example balloon
+func show_example_dialogue_balloon(resource: DialogueResource, cue: String = "", extra_game_states: Array = []) -> CanvasLayer:
+ var balloon: Node = load(_get_example_balloon_path()).instantiate()
+ _start_balloon.call_deferred(balloon, resource, cue, extra_game_states)
+ return balloon
+
+
+## Show the configured dialogue balloon
+func show_dialogue_balloon(resource: DialogueResource, cue: String = "", extra_game_states: Array = []) -> Node:
+ var balloon_path: String = DMSettings.get_setting(DMSettings.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, cue, extra_game_states)
+
+
+## Show a given balloon scene
+func show_dialogue_balloon_scene(balloon_scene: Variant, resource: DialogueResource, cue: 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
+ _start_balloon.call_deferred(balloon, resource, cue, extra_game_states)
+ return balloon
+
+
+## Resolve a static line ID to an actual line ID
+func static_id_to_line_id(resource: DialogueResource, static_id: String) -> String:
+ var ids: PackedStringArray = static_id_to_line_ids(resource, static_id)
+ if ids.size() == 0: return ""
+ return ids[0]
+
+
+## Resolve a static line ID to any actual line IDs that match
+func static_id_to_line_ids(resource: DialogueResource, static_id: String) -> PackedStringArray:
+ return resource.lines.values().filter(func(l: Dictionary) -> bool:
+ return l.get(&"static_id", "") == static_id
+ ).map(func(l: Dictionary) -> String:
+ return l.id
+ )
+
+
+# Call "start" on the given balloon.
+func _start_balloon(balloon: Node, resource: DialogueResource, cue: String, extra_game_states: Array) -> void:
+ dialogue_started.emit(resource)
+
+ assert(get_current_scene.is_valid())
+
+ var current_scene: Node = get_current_scene.call()
+
+ assert(is_instance_valid(current_scene))
+
+ current_scene.add_child(balloon)
+
+ if balloon.has_method(&"start"):
+ balloon.start(resource, cue, extra_game_states)
+ elif balloon.has_method(&"Start"):
+ balloon.Start(resource, cue, extra_game_states)
+ else:
+ assert(false, DMConstants.translate(&"runtime.dialogue_balloon_missing_start_method"))
+
+
+# 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
+ if DMSettings.check_for_dotnet_solution():
+ balloon_path = "/example_balloon/SmallExampleBalloon.tscn" if is_small_window else "/example_balloon/ExampleBalloon.tscn"
+ else:
+ balloon_path = "/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
+
+
+#endregion
+
+#region dotnet bridge
+
+
+func _get_dotnet_dialogue_manager() -> RefCounted:
+ if not is_instance_valid(_dotnet_dialogue_manager):
+ _dotnet_dialogue_manager = load(get_script().resource_path.get_base_dir() + "/DialogueManager.cs").new()
+ return _dotnet_dialogue_manager
+
+
+func _bridge_get_next_dialogue_line(call_id: int, resource: DialogueResource, key: String, extra_game_states: Array = [], mutation_behaviour: int = DMConstants.MutationBehaviour.Wait) -> void:
+ # dotnet needs at least one await tick of the signal gets called too quickly
+ await Engine.get_main_loop().process_frame
+ var line: DialogueLine = await _get_next_dialogue_line(resource, key, extra_game_states, mutation_behaviour)
+ bridge_get_next_dialogue_line_completed.emit(call_id, line)
+ if line == null:
+ # End the conversation
+ dialogue_ended.emit(resource)
+
+
+func _bridge_get_line(call_id: int, resource: DialogueResource, key: String, extra_game_states: Array = []) -> void:
+ # dotnet needs at least one await tick of the signal gets called too quickly
+ await Engine.get_main_loop().process_frame
+ var line: DialogueLine = await get_line(resource, key, extra_game_states)
+ bridge_get_line_completed.emit(call_id, line)
+
+
+func _bridge_mutate(call_id: int, mutation: Dictionary, extra_game_states: Array, is_inline_mutation: bool = false) -> void:
+ await _mutate(mutation, extra_game_states, is_inline_mutation)
+ bridge_mutated.emit(call_id)
+
+
+func _bridge_get_error_message(error: int) -> String:
+ return DMConstants.get_error_message(error)
+
+
+func _bridge_translate(string: String) -> String:
+ return DMConstants.translate(string)
+
+
+#endregion
+
+#region Internal helpers
+
+
+# Show a message or crash with error
+func show_error_for_missing_state_value(message: String, extra_game_states: Array, will_show: bool = true) -> void:
+ if not will_show: return
+
+ if ignore_missing_state_values:
+ push_error(message)
+ elif will_show:
+ # Let the debugger know before we break
+ if EngineDebugger.is_active() and extra_game_states.size() > 0:
+ EngineDebugger.send_message("dm:debug", [extra_game_states[0].debugger])
+
+ # 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 TranslationServer.get_loaded_locales().size() == 0:
+ return data.text
+
+ var static_id: String = data.get(&"static_id", data.text)
+ if static_id.is_empty() or static_id == data.text:
+ return tr(data.text, "dialogue")
+ else:
+ return tr(static_id, "dialogue")
+
+
+# Create a line of dialogue
+func create_dialogue_line(data: Dictionary, extra_game_states: Array) -> DialogueLine:
+ match data.type:
+ DMConstants.TYPE_DIALOGUE:
+ var resolved_data: DMResolvedLineData = await get_resolved_line_data(data, extra_game_states)
+ return DialogueLine.new({
+ id = _get_id_with_resource(data.resource, data.get(&"id", "")),
+ type = DMConstants.TYPE_DIALOGUE,
+ next_id = data.next_id,
+ 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.get(&"text_replacements", [] as Array[Dictionary]),
+ static_id = data.get(&"static_id", data.text),
+ speeds = resolved_data.speeds,
+ inline_mutations = resolved_data.mutations,
+ time = resolved_data.time,
+ tags = data.get(&"tags", []),
+ extra_game_states = extra_game_states
+ })
+
+ DMConstants.TYPE_RESPONSE:
+ return DialogueLine.new({
+ id = _get_id_with_resource(data.resource, data.get(&"id", "")),
+ type = DMConstants.TYPE_RESPONSE,
+ next_id = data.next_id,
+ tags = data.get(&"tags", []),
+ extra_game_states = extra_game_states
+ })
+
+ DMConstants.TYPE_MUTATION:
+ return DialogueLine.new({
+ id = _get_id_with_resource(data.resource, data.get(&"id", "")),
+ type = DMConstants.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: DMResolvedLineData = await get_resolved_line_data(data, extra_game_states)
+ return DialogueResponse.new({
+ id = data.get(&"id", ""),
+ type = DMConstants.TYPE_RESPONSE,
+ next_id = data.next_id,
+ is_allowed = data.is_allowed,
+ condition_as_text = data.get(&"condition_as_text", ""),
+ 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.get(&"text_replacements", [] as Array[Dictionary]),
+ tags = data.get(&"tags", []),
+ static_id = data.get(&"static_id", data.text),
+ })
+
+
+## Register a state context. This is handled automatically by [DialogueStateContext] nodes.
+func register_state_context(alias: String, target: Node) -> void:
+ if _registered_contexts.has(alias):
+ push_warning(DMConstants.translate("\"{alias}\" will overwrite already registered context alias.").format({ alias = alias }))
+ _registered_contexts[alias] = target
+ _send_state_to_debugger()
+
+
+## Unregister a state context. This is handled automatically by [DialogueStateContext] nodes.
+func unregister_state_context(alias: String) -> void:
+ _registered_contexts.erase(alias)
+ _send_state_to_debugger()
+
+
+# Let the debugger know about the current scene
+func _send_current_scene_to_debugger(current_scene: Node = null) -> void:
+ if not EngineDebugger.is_active(): return
+
+ if not is_instance_valid(current_scene):
+ current_scene = get_current_scene.call() if get_current_scene.is_valid() else null
+
+ var serialized_current_scene: Dictionary = {}
+ if is_instance_valid(current_scene):
+ serialized_current_scene[current_scene.name] = _get_serialised_state_node(
+ current_scene.name,
+ current_scene
+ )
+
+ EngineDebugger.send_message("dm:current_scene", [serialized_current_scene])
+
+
+# Let the debugger know about the latest state
+func _send_state_to_debugger() -> void:
+ if not EngineDebugger.is_active(): return
+
+ var serialised_context: Dictionary = {}
+ for key: String in _registered_contexts.keys():
+ serialised_context[key] = _get_serialised_state_node(
+ key,
+ _registered_contexts.get(key)
+ )
+ var serialised_autoloads: Dictionary = {}
+ for key: String in _autoloads.keys():
+ serialised_autoloads[key] = _get_serialised_state_node(
+ key,
+ _autoloads.get(key)
+ )
+ EngineDebugger.send_message("dm:state", [serialised_context, serialised_autoloads])
+
+
+func _get_serialised_state_node(key: String, node: Node) -> Dictionary:
+ var script: Script = node.get_script()
+ @warning_ignore("incompatible_ternary")
+ return {
+ instance_id = node.get_instance_id(),
+ alias = key,
+ path = node.get_path(),
+ script = script.resource_path if script else "",
+ base_type = node.get_class(),
+ type = script.get_global_name() if node.get_script() else node.get_class(),
+ }
+
+
+# Get the current game states
+func _get_game_states(extra_game_states: Array) -> Array:
+ _load_autoloads()
+
+ var current_scene: Node = get_current_scene.call() if get_current_scene.is_valid() else null
+ _send_current_scene_to_debugger(current_scene)
+
+ var possible_states: Array = extra_game_states
+ possible_states += [_registered_contexts]
+ if is_instance_valid(current_scene):
+ possible_states += [current_scene]
+ possible_states += game_states
+
+ var unique_states: Array = []
+ for state: Variant in possible_states:
+ if state != null and not unique_states.has(state):
+ unique_states.append(state)
+ return unique_states
+
+
+# Add any autoloads to known state.
+func _load_autoloads() -> void:
+ 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: Node in (Engine.get_main_loop() as SceneTree).root.get_children():
+ # Ignore invalid nodes
+ if child is not Node: continue
+ # Ignore the dialogue manager
+ if child.name == &"DialogueManager": continue
+ # Ignore the current main scene
+ if Engine.get_main_loop().current_scene and child.name == Engine.get_main_loop().current_scene.name: continue
+ # Add the node to our known autoloads
+ _autoloads[child.name] = child
+ game_states = [_autoloads]
+
+ _send_state_to_debugger()
+
+ # Add any other state shortcuts from settings
+ for node_name: String in DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, ""):
+ var state: Node = Engine.get_main_loop().root.get_node_or_null(NodePath(node_name))
+ if state:
+ game_states.append(state)
+
+
+# Check if a condition is met
+func _check_condition(data: Dictionary, extra_game_states: Array) -> bool:
+ var result: Variant = await _resolve_condition_value(data, extra_game_states)
+ if typeof(result) in [
+ TYPE_STRING, TYPE_STRING_NAME, \
+ TYPE_DICTIONARY, \
+ TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_COLOR_ARRAY, \
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, \
+ TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
+ TYPE_PACKED_STRING_ARRAY, \
+ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY]:
+ return not (result as String).is_empty()
+
+ if result is Node or result is Resource:
+ return is_instance_valid(result)
+
+ return true if result else false
+
+
+# Resolve a condition's expression value
+func _resolve_condition_value(data: Dictionary, extra_game_states: Array) -> Variant:
+ if data.get(&"condition", null) == null: return true
+ if data.condition.is_empty(): return true
+
+ return await _resolve(data.condition.expression.duplicate(true), extra_game_states)
+
+
+# Check if a match value matches a case value
+func _check_case_value(match_value: Variant, data: Dictionary, extra_game_states: Array) -> bool:
+ if data.get(&"condition", null) == null: return true
+ if data.condition.is_empty(): return true
+
+ var expression: Array[Dictionary] = data.condition.expression.duplicate(true)
+
+ # Check for multiple values
+ var expressions_to_check: Array = []
+ var previous_comma_index: int = 0
+ for i: int in range(0, expression.size()):
+ if expression[i].type == DMConstants.TOKEN_COMMA:
+ expressions_to_check.append(expression.slice(previous_comma_index, i))
+ previous_comma_index = i + 1
+ elif i == expression.size() - 1:
+ expressions_to_check.append(expression.slice(previous_comma_index))
+
+ for expression_to_check: Array in expressions_to_check:
+ # If the when is a comparison when insert the match value as the first value to compare to
+ var already_compared: bool = false
+ if expression_to_check[0].type == DMConstants.TOKEN_COMPARISON:
+ expression_to_check.insert(0, {
+ type = DMConstants.TOKEN_VALUE,
+ value = match_value
+ })
+ already_compared = true
+
+ var resolved_value: Variant = await _resolve(expression_to_check, extra_game_states)
+ if already_compared:
+ if resolved_value:
+ return true
+ else:
+ if match_value == resolved_value:
+ return true
+
+ return false
+
+
+# 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 == DMConstants.TOKEN_FUNCTION and expression[0].function in [&"wait", &"Wait", &"debug", &"Debug"]:
+ var args: Array = await _resolve_each(expression[0].value, extra_game_states)
+ match expression[0].function:
+ &"wait", &"Wait":
+ mutated.emit(mutation.merged({ is_inline = is_inline_mutation }))
+ if [TYPE_FLOAT, TYPE_INT].has(typeof(args[0])):
+ await Engine.get_main_loop().create_timer(float(args[0])).timeout
+ else:
+ var actions: PackedStringArray = PackedStringArray(args[0] if typeof(args[0]) == TYPE_ARRAY else [args[0]])
+ await _wait_for(actions)
+ return
+
+ &"debug", &"Debug":
+ prints("Debug:", args)
+ await Engine.get_main_loop().process_frame
+
+ # Or pass through to the resolver
+ else:
+ if not _mutation_contains_assignment(mutation.expression):
+ mutated.emit(mutation.merged({ is_inline = is_inline_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 Engine.get_main_loop().process_frame
+
+
+# Wait for a given action
+func _wait_for(actions: PackedStringArray) -> void:
+ var waiter: DMWaiter = DMWaiter.new(actions)
+ add_child(waiter)
+
+ waiting_for_input.emit()
+ var action: String = await waiter.waited
+ if is_instance_valid(action):
+ waited_for_input.emit()
+
+ waiter.queue_free()
+
+
+# Check if a mutation contains an assignment token.
+func _mutation_contains_assignment(mutation: Array) -> bool:
+ for token: Dictionary in mutation:
+ if token.type == DMConstants.TOKEN_ASSIGNMENT:
+ return true
+ return false
+
+
+# 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: String in ids:
+ var data: Dictionary = resource.lines.get(id).duplicate(true)
+ data.is_allowed = await _check_condition(data, extra_game_states)
+ var response: DialogueResponse = await create_response(data, extra_game_states)
+ response.next_id = _get_id_with_resource(resource, 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) -> Variant:
+ # 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 == "Quaternion":
+ return Quaternion()
+
+ var expression: Expression = Expression.new()
+ if expression.parse(property) != OK:
+ assert(false, DMConstants.translate(&"runtime.invalid_expression").format({ expression = property, error = expression.get_error_text() }))
+
+ # Warn about possible name collisions
+ _warn_about_state_name_collisions(property, extra_game_states)
+
+ for state: Variant in _get_game_states(extra_game_states):
+ if typeof(state) == TYPE_DICTIONARY:
+ if state.has(property):
+ return state.get(property)
+ else:
+ var is_dotnet_constant: bool = false
+ if state.get_script() and state.get_script().resource_path.ends_with(".cs"):
+ is_dotnet_constant = _get_dotnet_dialogue_manager().ThingHasConstant(state, property)
+
+ # Only consult the access filter if this identifier is actually a member of this state.
+ if is_dotnet_constant or _thing_has_property(state, property, false):
+ var access_err: Variant = validate_member_access.call(state, StringName(property), &"property")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ return null
+
+ # Try for a C# constant first
+ if is_dotnet_constant:
+ return _get_dotnet_dialogue_manager().ResolveThingConstant(state, property)
+
+ # Otherwise just let Godot try and resolve it.
+ var result: Variant = 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: Dictionary in ProjectSettings.get_global_class_list():
+ if class_data.get(&"class") == property:
+ return load(class_data.path)
+
+ show_error_for_missing_state_value(
+ DMConstants.translate(&"runtime.property_not_found").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }),
+ extra_game_states
+ )
+ return null
+
+
+# Print warnings for top-level state name collisions.
+func _warn_about_state_name_collisions(target_key: String, extra_game_states: Array) -> void:
+ # Don't run the check if this is a release build
+ if not EngineDebugger.is_active(): return
+ # Also don't run if the setting is off
+ if not DMSettings.get_setting(DMSettings.WARN_ABOUT_METHOD_PROPERTY_OR_SIGNAL_NAME_CONFLICTS, false): return
+
+ # Get the list of state shortcuts.
+ var state_shortcuts: Array = []
+ for node_name: String in DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, ""):
+ var state: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null(NodePath(node_name))
+ if state:
+ state_shortcuts.append(state)
+
+ var current_scene: Node = get_current_scene.call() if get_current_scene.is_valid() else null
+ var possible_states: Array = extra_game_states
+ if is_instance_valid(current_scene):
+ possible_states += [current_scene]
+ possible_states += state_shortcuts
+
+ # Check any top level names for a collision
+ var states_with_key: Array = []
+ for state: Variant in possible_states:
+ if typeof(state) == TYPE_DICTIONARY:
+ if state.keys().has(target_key):
+ states_with_key.append("Dictionary")
+ else:
+ var script: Script = (state as Object).get_script()
+ if script == null:
+ continue
+
+ for method: Dictionary in script.get_script_method_list():
+ if method.name == target_key and not states_with_key.has(state.name):
+ states_with_key.append(state.name)
+ break
+
+ for property: Dictionary in script.get_script_property_list():
+ if property.name == target_key and not states_with_key.has(state.name):
+ states_with_key.append(state.name)
+ break
+
+ for signal_info: Dictionary in script.get_script_signal_list():
+ if signal_info.name == target_key and not states_with_key.has(state.name):
+ states_with_key.append(state.name)
+ break
+
+ if states_with_key.size() > 1:
+ push_warning(DMConstants.translate(&"runtime.top_level_states_share_name").format({ states = ", ".join(states_with_key), key = target_key }))
+
+
+# Set a value on the current scene or game state
+func _set_state_value(property: String, value: Variant, extra_game_states: Array) -> void:
+ for state: Variant 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):
+ var access_err: Variant = validate_member_access.call(state, StringName(property), &"property_set")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ return
+ state.set(property, value)
+ return
+
+ if property.to_snake_case() != property:
+ show_error_for_missing_state_value(
+ DMConstants.translate(&"runtime.property_not_found_missing_export").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }),
+ extra_game_states
+ )
+ else:
+ show_error_for_missing_state_value(
+ DMConstants.translate(&"runtime.property_not_found").format({ property = property, states = _get_state_shortcut_names(extra_game_states) }),
+ extra_game_states
+ )
+
+
+# Get the list of state shortcut names
+func _get_state_shortcut_names(extra_game_states: Array) -> String:
+ var states: Array = _get_game_states(extra_game_states)
+ states.erase(_autoloads)
+ states.remove_at(0)
+ return ", ".join(states.map(func(s: Variant) -> String: return "\"%s\"" % (s.name if "name" in s else s)))
+
+
+# Resolve an array of expressions.
+func _resolve_each(array: Array, extra_game_states: Array) -> Array:
+ var results: Array = []
+ for item: Array in array:
+ if not item[0].type in [DMConstants.TOKEN_BRACE_CLOSE, DMConstants.TOKEN_BRACKET_CLOSE, DMConstants.TOKEN_PARENS_CLOSE]:
+ results.append(await _resolve(item.duplicate(true), extra_game_states))
+ return results
+
+
+# Collapse any expressions
+func _resolve(tokens: Array, extra_game_states: Array) -> Variant:
+ # Short-circuit evaluation for and/or
+ var has_assignment: bool = false
+ var last_and_or_index: int = -1
+ for j: int in range(0, tokens.size()):
+ if tokens[j].type == DMConstants.TOKEN_ASSIGNMENT:
+ has_assignment = true
+ break
+ if tokens[j].type == DMConstants.TOKEN_AND_OR:
+ last_and_or_index = j
+ if not has_assignment and last_and_or_index > 0 and last_and_or_index < tokens.size() - 1:
+ var left_tokens: Array = tokens.slice(0, last_and_or_index)
+ var operator: String = tokens[last_and_or_index].value
+ var right_tokens: Array = tokens.slice(last_and_or_index + 1)
+
+ var left_value: Variant = await _resolve(left_tokens, extra_game_states)
+
+ if operator == "and" and not left_value:
+ return false
+ if operator == "or" and left_value:
+ return true
+
+ var right_value: Variant = await _resolve(right_tokens, extra_game_states)
+ return _apply_operation(operator, left_value, right_value)
+
+ var i: int = 0
+ var limit: int = 0
+
+ # Handle groups first
+ for token: Dictionary in tokens:
+ if token.type == DMConstants.TOKEN_GROUP:
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = await _resolve(token.value, extra_game_states)
+
+ # Then variables/methods
+ i = 0
+ limit = 0
+ while i < tokens.size() and limit < 1000:
+ limit += 1
+ var token: Dictionary = tokens[i]
+
+ if token.type == DMConstants.TOKEN_NULL_COALESCE:
+ var caller: Dictionary = tokens[i - 1]
+ if caller.value == null:
+ # If the caller is null then the method/property is also null
+ caller.type = DMConstants.TOKEN_VALUE
+ caller.value = null
+ tokens.remove_at(i + 1)
+ tokens.remove_at(i)
+ else:
+ token.type = DMConstants.TOKEN_DOT
+
+ elif token.type == DMConstants.TOKEN_FUNCTION:
+ var function_name: String = token.function
+ var args: Array = await _resolve_each(token.value, extra_game_states)
+ if tokens[i - 1].type == DMConstants.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 DMBuiltins.is_supported(caller.value):
+ caller.type = DMConstants.TOKEN_VALUE
+ caller.value = await DMBuiltins.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 = DMConstants.TOKEN_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(
+ DMConstants.translate(&"runtime.method_not_callable").format({ method = function_name, object = str(caller.value) }),
+ extra_game_states
+ )
+ else:
+ var found: bool = false
+ match function_name:
+ &"str":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = str(args[0])
+ found = true
+ &"Vector2":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector2(args[0], args[1])
+ found = true
+ &"Vector2i":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector2i(args[0], args[1])
+ found = true
+ &"Vector3":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector3(args[0], args[1], args[2])
+ found = true
+ &"Vector3i":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector3i(args[0], args[1], args[2])
+ found = true
+ &"Vector4":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector4(args[0], args[1], args[2], args[3])
+ found = true
+ &"Vector4i":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Vector4i(args[0], args[1], args[2], args[3])
+ found = true
+ &"Quaternion":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = Quaternion(args[0], args[1], args[2], args[3])
+ found = true
+ &"Callable":
+ token.type = DMConstants.TOKEN_VALUE
+ match args.size():
+ 0:
+ token.value = Callable()
+ 1:
+ token.value = Callable(args[0])
+ 2:
+ # Building a Callable to a method is method access by another name so it goes through the filter (otherwise
+ # something like `Callable(thing, "denied").call()` would sidestep it entirely.
+ var access_err: Variant = ""
+ if is_instance_valid(args[0]) and (args[1] is String or args[1] is StringName):
+ access_err = validate_member_access.call(args[0], StringName(args[1]), &"method")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ token.value = Callable()
+ else:
+ token.value = Callable(args[0], args[1])
+ found = true
+ &"Color":
+ token.type = DMConstants.TOKEN_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", &"Load" when load_from_within_dialogue.is_valid():
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = load_from_within_dialogue.call(args[0])
+ found = true
+ &"roll_dice", &"RollDice":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = _rng.randi_range(1, args[0])
+ found = true
+ _:
+ # Check for top level name conflicts
+ _warn_about_state_name_collisions(function_name, extra_game_states)
+
+ for state: Variant in _get_game_states(extra_game_states):
+ if _thing_has_method(state, function_name, args):
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = await _resolve_thing_method(state, function_name, args)
+ found = true
+ break
+
+ show_error_for_missing_state_value(DMConstants.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)
+ }), extra_game_states, not found)
+
+ elif token.type == DMConstants.TOKEN_DICTIONARY_REFERENCE:
+ var value: Variant
+ var was_denied: bool = false
+ if i > 0 and tokens[i - 1].type == DMConstants.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.
+ var parent_value: Variant = tokens[i - 2].value
+ # Only treat `parent[key]` as member access when `parent` is not a Dictionary
+ # AND `key` is a String/StringName (not an int index).
+ var access_err: Variant = null
+ if typeof(parent_value) != TYPE_DICTIONARY \
+ and (token.variable is String or token.variable is StringName):
+ access_err = validate_member_access.call(parent_value, StringName(token.variable), &"index")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ was_denied = true
+ value = null
+ else:
+ value = parent_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: Variant = await _resolve(token.value, extra_game_states)
+ if was_denied:
+ # Access to the parent was denied so collapse to null.
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = null
+ elif typeof(value) == TYPE_DICTIONARY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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 = DMConstants.TOKEN_VALUE
+ token.value = value[index]
+ else:
+ show_error_for_missing_state_value(
+ DMConstants.translate(&"runtime.key_not_found").format({ key = str(index), dictionary = token.variable }),
+ extra_game_states
+ )
+ elif typeof(value) in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_COLOR_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY]:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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 = DMConstants.TOKEN_VALUE
+ token.value = value[index]
+ else:
+ show_error_for_missing_state_value(
+ DMConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = index, array = token.variable }),
+ extra_game_states
+ )
+
+ elif token.type == DMConstants.TOKEN_DICTIONARY_NESTED_REFERENCE:
+ var dictionary: Dictionary = tokens[i - 1]
+ var index: Variant = await _resolve(token.value, extra_game_states)
+ var value: Variant = dictionary.value
+ if typeof(value) == TYPE_DICTIONARY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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(
+ DMConstants.translate(&"runtime.key_not_found").format({ key = str(index), dictionary = value }),
+ extra_game_states
+ )
+ elif typeof(value) == TYPE_ARRAY:
+ if tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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(
+ DMConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = index, array = value }),
+ extra_game_states
+ )
+ elif (index is String or index is StringName) and is_instance_valid(value):
+ var access_err: Variant = validate_member_access.call(value, StringName(index), &"index")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ dictionary.value = null
+ else:
+ dictionary.value = value.get(index)
+ tokens.remove_at(i)
+ i -= 1
+
+ elif token.type == DMConstants.TOKEN_ARRAY:
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = await _resolve_each(token.value, extra_game_states)
+
+ elif token.type == DMConstants.TOKEN_DICTIONARY:
+ token.type = DMConstants.TOKEN_VALUE
+ var dictionary: Dictionary = {}
+ for key: Variant in token.value.keys():
+ var resolved_key: Variant = await _resolve([key], extra_game_states)
+ var preresolved_value: Variant = token.value.get(key)
+ if typeof(preresolved_value) != TYPE_ARRAY:
+ preresolved_value = [preresolved_value]
+ var resolved_value: Variant = await _resolve(preresolved_value, extra_game_states)
+ dictionary[resolved_key] = resolved_value
+ token.value = dictionary
+
+ elif token.type == DMConstants.TOKEN_VARIABLE or token.type == DMConstants.TOKEN_NUMBER:
+ if str(token.value) == "null":
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = null
+ elif str(token.value) == "self" and extra_game_states.size() > 0 and typeof(extra_game_states[0]) == TYPE_DICTIONARY and extra_game_states[0].has("self"):
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = extra_game_states[0].self
+ elif tokens[i - 1].type == DMConstants.TOKEN_DOT:
+ var caller: Dictionary = tokens[i - 2]
+ var property: Variant = token.value
+ if tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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 = DMConstants.TOKEN_VALUE
+ if DMBuiltins.is_supported(caller.value):
+ caller.value = DMBuiltins.resolve_property(caller.value, property)
+ else:
+ caller.value = _resolve_thing_property(caller.value, property)
+ tokens.remove_at(i)
+ tokens.remove_at(i - 1)
+ i -= 2
+ elif tokens.size() > i + 1 and tokens[i + 1].type == DMConstants.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:
+ if token.type == DMConstants.TOKEN_NUMBER:
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = token.value
+ else:
+ token.type = DMConstants.TOKEN_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 == DMConstants.TOKEN_OPERATOR and token.value in ["*", "/", "%"]:
+ token.type = DMConstants.TOKEN_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, DMConstants.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 == DMConstants.TOKEN_OPERATOR and token.value in ["+", "-"]:
+ token.type = DMConstants.TOKEN_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, DMConstants.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 == DMConstants.TOKEN_NOT:
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = not tokens[i + 1].value
+ tokens.remove_at(i + 1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DMConstants.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 == DMConstants.TOKEN_COMPARISON:
+ token.type = DMConstants.TOKEN_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, DMConstants.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 == DMConstants.TOKEN_AND_OR:
+ token.type = DMConstants.TOKEN_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, DMConstants.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 == DMConstants.TOKEN_ASSIGNMENT:
+ var lhs: Dictionary = tokens[i - 1]
+ var value: Variant
+
+ 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":
+ var access_err: Variant = validate_member_access.call(lhs.value, StringName(lhs.property), &"property_set")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, extra_game_states)
+ value = null
+ else:
+ 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(
+ DMConstants.translate(&"runtime.array_index_out_of_bounds").format({ index = lhs.key, array = lhs.value }),
+ extra_game_states,
+ 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(
+ DMConstants.translate(&"runtime.left_hand_size_cannot_be_assigned_to"),
+ extra_game_states
+ )
+
+ token.type = DMConstants.TOKEN_VALUE
+ token.value = value
+ tokens.remove_at(i + 1)
+ tokens.remove_at(i - 1)
+ i -= 1
+ i += 1
+
+ if limit >= 1000:
+ assert(false, DMConstants.translate(&"runtime.something_went_wrong"))
+
+ return tokens[0].value
+
+
+# Compare two values.
+func _compare(operator: String, first_value: Variant, second_value: Variant) -> 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
+
+
+# Apply an operation from one value to another.
+func _apply_operation(operator: String, first_value: Variant, second_value: Variant) -> Variant:
+ 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, DMConstants.translate(&"runtime.unknown_operator"))
+ return null
+
+
+# Check if a dialogue line contains meaningful information.
+func _is_valid(line: DialogueLine) -> bool:
+ if line == null:
+ return false
+ if line.type == DMConstants.TYPE_MUTATION and line.mutation == null:
+ return false
+ if line.type == DMConstants.TYPE_RESPONSE and line.get(&"responses").size() == 0:
+ return false
+ return true
+
+
+# Check that a thing has a given method.
+func _thing_has_method(thing: Variant, method: String, args: Array) -> bool:
+ if not is_instance_valid(thing):
+ return false
+
+ if DMBuiltins.is_supported(thing, method):
+ return thing != _autoloads
+ elif typeof(thing) == TYPE_DICTIONARY:
+ return false
+
+ 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 thing is Script:
+ thing = thing.new()
+ if thing.get_script() and thing.get_script().resource_path.ends_with(".cs"):
+ # 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, args)
+
+ return false
+
+
+# Check if a given property exists
+func _thing_has_property(thing: Object, property: String, ignore_node_properties: bool = true) -> bool:
+ if thing == null:
+ return false
+
+ for p: Dictionary in thing.get_property_list():
+ if ignore_node_properties and _node_properties.has(p.name):
+ # Ignore any properties on the base Node
+ continue
+ if p.name == property:
+ return true
+
+ if thing.get_script() and thing.get_script().resource_path.ends_with(".cs"):
+ # If we get this far then the property might be a C# constant.
+ return _get_dotnet_dialogue_manager().ThingHasConstant(thing, property)
+
+ return false
+
+
+func _get_method_info_for(thing: Variant, method: String, args: Array) -> 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 thing_methods: Array[Dictionary] = []
+ if thing.get_script() and thing.get_script().resource_path.ends_with(".cs"):
+ thing_methods = _get_dotnet_dialogue_manager().GetMethodList(thing)
+ else:
+ thing_methods = thing.get_method_list()
+
+ var method_overloads: Dictionary = {}
+ for m: Dictionary in thing_methods:
+ method_overloads[_get_method_info_key(m.name, m.args)] = m
+ if not method_overloads.has(m.name):
+ method_overloads[m.name] = m
+ _method_info_cache[thing_instance_id] = method_overloads
+
+ var methods: Dictionary = _method_info_cache.get(thing_instance_id, {})
+ var method_key: String = _get_method_info_key(method, args)
+ if methods.has(method_key):
+ return methods.get(method_key)
+ elif methods.has(method):
+ return methods.get(method)
+ else:
+ return _get_method_info_for(thing.new(), method, args)
+
+
+func _get_method_info_key(method: String, args: Array) -> String:
+ return "%s:%s" % [method, ",".join(args.map(func (arg: Variant) -> String:
+ if typeof(arg) == TYPE_DICTIONARY:
+ if arg.has("class_name") and not arg.class_name.is_empty(): return arg.class_name
+ if arg.has("type") and typeof(arg.type) == TYPE_INT: return str(arg.type)
+
+ return str(TYPE_DICTIONARY)
+ else:
+ return str(typeof(arg))
+ ))]
+
+
+func _resolve_thing_method(thing: Variant, method: String, args: Array) -> Variant:
+ if is_instance_valid(thing):
+ var access_err: Variant = validate_member_access.call(thing, StringName(method), &"method")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, [])
+ return null
+
+ if DMBuiltins.is_supported(thing):
+ var result: Variant = await DMBuiltins.resolve_method(thing, method, args)
+ if not DMBuiltins.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, args)
+ var method_args: Array = method_info.args
+ if method_info.flags & METHOD_FLAG_VARARG == 0 and method_args.size() < args.size():
+ assert(false, DMConstants.translate(&"runtime.expected_n_got_n_args").format({ expected = method_args.size(), method = method, received = args.size()}))
+ for i: int in range(0, min(method_args.size(), 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, DMConstants.translate(&"runtime.unsupported_array_type").format({ type = m.hint_string}))
+ if typeof(args[i]) != to_type:
+ args[i] = type_convert(args[i], to_type)
+
+ if method_info.has("dotnet"):
+ return await callv_dotnet(thing, method, args)
+ else:
+ return await thing.callv(method, args)
+
+ # If we get here then it's probably a C# method with a Task return type
+ if thing is Script:
+ thing = thing.new()
+
+ return await callv_dotnet(thing, method, args)
+
+
+func callv_dotnet(thing: Variant, method: String, args: Array) -> Variant:
+ var dotnet_dialogue_manager: RefCounted = _get_dotnet_dialogue_manager()
+ var id: float = _rng.randf()
+ dotnet_dialogue_manager.ResolveThingMethod(id, thing, method, args)
+ var x: int = 0
+ while x < 1000:
+ var result: Array = await dotnet_dialogue_manager.Resolved
+ if result[0] == id:
+ return result[1]
+ x += 1
+
+ return null
+
+
+func _resolve_thing_property(thing: Object, property: String) -> Variant:
+ if thing == null:
+ return false
+
+ var access_err: Variant = validate_member_access.call(thing, StringName(property), &"property")
+ if access_err is String and access_err != "":
+ show_error_for_missing_state_value(access_err as String, [])
+ return null
+
+ if thing.get_script() and thing.get_script().resource_path.ends_with(".cs"):
+ # If we get this far then the property might be a C# constant.
+ return _get_dotnet_dialogue_manager().ResolveThingConstant(thing, property)
+
+ return thing.get(property)
+
+
+func _get_resource_uid(resource: DialogueResource) -> String:
+ return ResourceUID.path_to_uid(resource.resource_path).replace("uid://", "")
+
+
+func _get_id_with_resource(resource: DialogueResource, id: String) -> String:
+ return id if "@" in id else "%s@%s" % [_get_resource_uid(resource), id]
diff --git a/addons/dialogue_manager/dialogue_manager.gd.uid b/addons/dialogue_manager/dialogue_manager.gd.uid
new file mode 100644
index 0000000..d10762e
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_manager.gd.uid
@@ -0,0 +1 @@
+uid://c3rodes2l3gxb
diff --git a/addons/dialogue_manager/dialogue_processor.gd b/addons/dialogue_manager/dialogue_processor.gd
new file mode 100644
index 0000000..1f8f588
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_processor.gd
@@ -0,0 +1,11 @@
+class_name DMDialogueProcessor extends RefCounted
+
+
+## Override to modify the incoming raw string.
+func _preprocess_line(raw_line: String) -> String:
+ return raw_line
+
+
+## Override to modify the outgoing dialogue line.
+func _process_line(_line: DMCompiledLine) -> void:
+ pass
diff --git a/addons/dialogue_manager/dialogue_processor.gd.uid b/addons/dialogue_manager/dialogue_processor.gd.uid
new file mode 100644
index 0000000..a96cc19
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_processor.gd.uid
@@ -0,0 +1 @@
+uid://m3b28rmso14t
diff --git a/addons/dialogue_manager/dialogue_resource.gd b/addons/dialogue_manager/dialogue_resource.gd
new file mode 100644
index 0000000..8fc90b0
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_resource.gd
@@ -0,0 +1,40 @@
+@tool
+@icon("./assets/icon.svg")
+
+## A collection of dialogue lines for use with [code]DialogueManager[/code].
+class_name DialogueResource extends Resource
+
+
+## A list of state shortcuts
+@export var using_states: PackedStringArray = []
+
+## A map of cues and the lines they point to.
+@export var cues: Dictionary = {}
+
+## A list of character names.
+@export var character_names: PackedStringArray = []
+
+## The first cue in the file.
+@export var first_cue: String = ""
+
+## A map of the encoded lines of dialogue.
+@export var lines: Dictionary = {}
+
+
+## Get the next printable line of dialogue, starting from a referenced line ([code]cue[/code] can
+## be a cues 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(cue: String = "", extra_game_states: Array = [], mutation_behaviour: DMConstants.MutationBehaviour = DMConstants.MutationBehaviour.Wait) -> DialogueLine:
+ return await Engine.get_singleton("DialogueManager").get_next_dialogue_line(self, cue, extra_game_states, mutation_behaviour)
+
+
+## Get the list of any cues found in the file.
+func get_cues() -> PackedStringArray:
+ return cues.keys()
+
+
+func _to_string() -> String:
+ if resource_path:
+ return "" % [resource_path]
+ else:
+ return ""
diff --git a/addons/dialogue_manager/dialogue_resource.gd.uid b/addons/dialogue_manager/dialogue_resource.gd.uid
new file mode 100644
index 0000000..27b95d0
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_resource.gd.uid
@@ -0,0 +1 @@
+uid://dbs4435dsf3ry
diff --git a/addons/dialogue_manager/dialogue_response.gd b/addons/dialogue_manager/dialogue_response.gd
new file mode 100644
index 0000000..e843d42
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_response.gd
@@ -0,0 +1,76 @@
+## A response to a line of dialogue, usualy attached to a [code]DialogueLine[/code].
+class_name DialogueResponse extends RefCounted
+
+
+## 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 = DMConstants.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
+
+## The original condition text.
+var condition_as_text: String = ""
+
+## 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 static_id: 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
+ static_id = data.static_id
+ condition_as_text = data.condition_as_text
+
+
+func _to_string() -> String:
+ return "" % text
+
+
+## Check if a dialogue line has a given tag.
+func has_tag(tag_name: String) -> bool:
+ if tags.has(tag_name):
+ return true
+ else:
+ var wrapped: String = "%s=" % tag_name
+ for t: String in tags:
+ if t.begins_with(wrapped):
+ return true
+ return false
+
+
+## Get the value of a tag if the tag is in the form of [code]tag=value[/code]
+func get_tag_value(tag_name: String) -> String:
+ var wrapped: String = "%s=" % tag_name
+ for t: String in tags:
+ if t.begins_with(wrapped):
+ return t.replace(wrapped, "").strip_edges()
+ return ""
diff --git a/addons/dialogue_manager/dialogue_response.gd.uid b/addons/dialogue_manager/dialogue_response.gd.uid
new file mode 100644
index 0000000..9b4532a
--- /dev/null
+++ b/addons/dialogue_manager/dialogue_response.gd.uid
@@ -0,0 +1 @@
+uid://cm0xpfeywpqid
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 0000000..8135a44
--- /dev/null
+++ b/addons/dialogue_manager/editor_translation_parser_plugin.gd
@@ -0,0 +1,72 @@
+class_name DMTranslationParserPlugin extends EditorTranslationParserPlugin
+
+
+## Cached result of parsing a dialogue file.
+var data: DMCompilerResult
+## List of characters that were added.
+var translated_character_names: PackedStringArray = []
+var translated_lines: Array[Dictionary] = []
+
+
+func _parse_file(path: String) -> Array[PackedStringArray]:
+ var msgs: Array[PackedStringArray] = []
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ var text: String = file.get_as_text()
+
+ data = DMCompiler.compile_string(text, path)
+
+ var known_keys: PackedStringArray = PackedStringArray([])
+
+ # Add all character names if settings ask for it
+ if DMSettings.get_setting(DMSettings.INCLUDE_CHARACTERS_IN_TRANSLATABLE_STRINGS_LIST, true):
+ translated_character_names = [] as Array[DialogueLine]
+ for character_name: String in data.character_names:
+ if character_name in known_keys: continue
+
+ known_keys.append(character_name)
+
+ translated_character_names.append(character_name)
+ msgs.append(PackedStringArray([character_name.replace('"', '\"'), "dialogue", "", DMConstants.translate("translation_plugin.character_name")]))
+
+ # Add all dialogue lines and responses
+ var dialogue: Dictionary = data.lines
+ for key: String in dialogue.keys():
+ var line: Dictionary = dialogue.get(key)
+
+ if not line.type in [DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE]: continue
+
+ var static_id: String = line.get(&"static_id", line.text)
+ if static_id.is_empty():
+ static_id = line.text
+
+ if static_id in known_keys: continue
+
+ known_keys.append(static_id)
+ translated_lines.append(line)
+
+ var has_static_id: bool = static_id != "" and static_id != line.text
+
+ var message: String = static_id.replace('"', '\"') if has_static_id else line.text
+ var context: String = "dialogue"
+ var plural: String = ""
+ var extra_details: PackedStringArray = []
+ if line.has("character"):
+ extra_details.append("Character: %s" % line.get("character", ""))
+ if has_static_id and not line.text.is_empty():
+ extra_details.append("Line: %s" % line.text)
+ if line.has("notes"):
+ extra_details.append("Notes: %s" % line.get("notes", ""))
+ var notes: String = "\n".join(extra_details)
+ msgs.append(PackedStringArray([
+ message,
+ context,
+ plural,
+ notes,
+ key
+ ]))
+
+ return msgs
+
+
+func _get_recognized_extensions() -> PackedStringArray:
+ return ["dialogue"]
diff --git a/addons/dialogue_manager/editor_translation_parser_plugin.gd.uid b/addons/dialogue_manager/editor_translation_parser_plugin.gd.uid
new file mode 100644
index 0000000..22ddbe9
--- /dev/null
+++ b/addons/dialogue_manager/editor_translation_parser_plugin.gd.uid
@@ -0,0 +1 @@
+uid://c6bya881h1egb
diff --git a/addons/dialogue_manager/example_balloon/ExampleBalloon.cs b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs
new file mode 100644
index 0000000..39bfc3b
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs
@@ -0,0 +1,278 @@
+using Godot;
+using Godot.Collections;
+
+namespace DialogueManagerRuntime
+{
+ public partial class ExampleBalloon : CanvasLayer
+ {
+ [Export] public Resource DialogueResource;
+ [Export] public string StartFromLabel = "";
+ [Export] public bool AutoStart = false;
+ [Export] public string NextAction = "ui_accept";
+ [Export] public string SkipAction = "ui_cancel";
+
+
+ Control balloon;
+ RichTextLabel characterLabel;
+ DialogueLabel dialogueLabel;
+ DialogueResponsesMenu responsesMenu;
+ Polygon2D progress;
+
+ Array temporaryGameStates = new Array();
+ bool isWaitingForInput = false;
+ bool willHideBalloon = false;
+
+ DialogueLine dialogueLine;
+ DialogueLine DialogueLine
+ {
+ get => dialogueLine;
+ set
+ {
+ // Dialogue has finished so close the balloon
+ if (value == null)
+ {
+ if (Owner == null)
+ {
+ QueueFree();
+ }
+ else
+ {
+ Hide();
+ }
+ return;
+ }
+
+ dialogueLine = value;
+ ApplyDialogueLine();
+ }
+ }
+
+ Timer MutationCooldown = new Timer();
+
+
+ public override void _Ready()
+ {
+ balloon = GetNode("%Balloon");
+ characterLabel = GetNode("%CharacterLabel");
+ dialogueLabel = GetNode("%DialogueLabel");
+ responsesMenu = GetNode("%ResponsesMenu");
+ progress = GetNode("%Progress");
+
+ balloon.Hide();
+
+ balloon.GuiInput += (@event) =>
+ {
+ if (dialogueLabel.IsTyping)
+ {
+ bool mouseWasClicked = @event is InputEventMouseButton && (@event as InputEventMouseButton).ButtonIndex == MouseButton.Left && @event.IsPressed();
+ bool skipButtonWasPressed = @event.IsActionPressed(SkipAction);
+ if (mouseWasClicked || skipButtonWasPressed)
+ {
+ GetViewport().SetInputAsHandled();
+ dialogueLabel.SkipTyping();
+ 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(responsesMenu.NextAction))
+ {
+ responsesMenu.NextAction = NextAction;
+ }
+ responsesMenu.ResponseSelected += (response) =>
+ {
+ Next(response.NextId);
+ };
+
+
+ // Hide the balloon when a mutation is running
+ MutationCooldown.Timeout += () =>
+ {
+ if (willHideBalloon)
+ {
+ willHideBalloon = false;
+ balloon.Hide();
+ }
+ };
+ AddChild(MutationCooldown);
+
+ DialogueManager.Mutated += OnMutated;
+
+ if (AutoStart)
+ {
+ if (!IsInstanceValid(DialogueResource))
+ {
+ throw new System.Exception(DialogueManager.GetErrorMessage(143));
+ }
+ Start();
+ }
+
+ // EXAMPLE MESSAGE
+ var warning = new Button
+ {
+ Text = DialogueManager.Translate("This is an example balloon. Create your own balloon in 'Project > Tools > Dialogue > Create Balloon...'"),
+ Disabled = true
+ };
+ warning.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopWide);
+ balloon.AddChild(warning);
+ balloon.MoveChild(warning, 0);
+ // END EXAMPLE MESSAGE
+ }
+
+
+ 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 && IsInstanceValid(dialogueLabel))
+ {
+ float visibleRatio = dialogueLabel.VisibleRatio;
+ DialogueLine = await DialogueManager.GetNextDialogueLine(DialogueResource, DialogueLine.Id, temporaryGameStates);
+ if (visibleRatio < 1.0f)
+ {
+ dialogueLabel.SkipTyping();
+ }
+ }
+ }
+
+
+ public override void _Process(double delta)
+ {
+ base._Process(delta);
+
+ if (IsInstanceValid(dialogueLine))
+ {
+ progress.Visible = !dialogueLabel.IsTyping && dialogueLine.Responses.Count == 0 && !dialogueLine.HasTag("voice");
+ }
+ }
+
+
+ public async void Start(Resource dialogueResource = null, string label = "", Array extraGameStates = null)
+ {
+ temporaryGameStates = new Array { this } + (extraGameStates ?? new Array());
+ isWaitingForInput = false;
+
+ if (IsInstanceValid(dialogueResource))
+ {
+ DialogueResource = dialogueResource;
+ }
+ if (label != "")
+ {
+ StartFromLabel = label;
+ }
+
+ DialogueLine = await DialogueManager.GetNextDialogueLine(DialogueResource, StartFromLabel, temporaryGameStates);
+ Show();
+ }
+
+
+ public async void Next(string nextId)
+ {
+ DialogueLine = await DialogueManager.GetNextDialogueLine(DialogueResource, nextId, temporaryGameStates);
+ }
+
+
+ #region Helpers
+
+
+ private async void ApplyDialogueLine()
+ {
+ MutationCooldown.Stop();
+
+ isWaitingForInput = false;
+ balloon.FocusMode = Control.FocusModeEnum.All;
+ balloon.GrabFocus();
+
+ // Set up the character name
+ characterLabel.Visible = !string.IsNullOrEmpty(dialogueLine.Character);
+ characterLabel.Text = Tr(dialogueLine.Character, "dialogue");
+
+ // Set up the dialogue
+ dialogueLabel.Hide();
+ dialogueLabel.DialogueLine = dialogueLine;
+
+ // Set up the responses
+ responsesMenu.Hide();
+ responsesMenu.Responses = dialogueLine.Responses;
+
+ // Type out the text
+ balloon.Show();
+ willHideBalloon = false;
+ dialogueLabel.Show();
+ if (!string.IsNullOrEmpty(dialogueLine.Text))
+ {
+ dialogueLabel.TypeOut();
+ await ToSignal(dialogueLabel, DialogueLabel.SignalName.FinishedTyping);
+ }
+
+ // 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), Timer.SignalName.Timeout);
+ Next(dialogueLine.NextId);
+ }
+ else
+ {
+ isWaitingForInput = true;
+ balloon.FocusMode = Control.FocusModeEnum.All;
+ balloon.GrabFocus();
+ }
+ }
+
+
+ #endregion
+
+
+ #region signals
+
+
+ private void OnMutated(Dictionary mutation)
+ {
+ if (!(bool)mutation["is_inline"])
+ {
+ isWaitingForInput = false;
+ willHideBalloon = true;
+ MutationCooldown.Start(0.1f);
+ }
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/example_balloon/ExampleBalloon.cs.uid b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs.uid
new file mode 100644
index 0000000..4b3783a
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs.uid
@@ -0,0 +1 @@
+uid://5b3w40kwakl3
diff --git a/addons/dialogue_manager/example_balloon/ExampleBalloon.tscn b/addons/dialogue_manager/example_balloon/ExampleBalloon.tscn
new file mode 100644
index 0000000..1574805
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/ExampleBalloon.tscn
@@ -0,0 +1,204 @@
+[gd_scene format=3 uid="uid://blvx2ftn6ycqd"]
+
+[ext_resource type="Script" uid="uid://5b3w40kwakl3" path="res://addons/dialogue_manager/example_balloon/ExampleBalloon.cs" id="1_36de5"]
+[ext_resource type="PackedScene" uid="uid://c3qmxhslp0dl7" path="res://addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.tscn" id="2_a8ve6"]
+[ext_resource type="Script" uid="uid://bf0gf4upyxj5l" path="res://addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs" id="3_72ixx"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_spyqn"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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_qkmqt"]
+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
+PanelContainer/styles/panel = SubResource("StyleBoxFlat_qkmqt")
+
+[sub_resource type="Animation" id="Animation_nlsx6"]
+length = 0.001
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0),
+"times": PackedFloat32Array(0)
+}
+
+[sub_resource type="Animation" id="Animation_qkmqt"]
+resource_name = "progress"
+loop_mode = 1
+step = 0.1
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0, 0, 0, 0, 0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.25, 0, 0.2, -0.0157438, -4, -0.2, 0.0112069, 0.25, 0, -4, -0.25, 0, 0.25, 0, 0, -0.2, 0.00299701, 0.25, 0),
+"times": PackedFloat32Array(0, 0.1, 0.5, 0.6, 1)
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_1337t"]
+_data = {
+&"RESET": SubResource("Animation_nlsx6"),
+&"progress": SubResource("Animation_qkmqt")
+}
+
+[node name="ExampleBalloon" type="CanvasLayer" unique_id=615431052]
+layer = 100
+script = ExtResource("1_36de5")
+
+[node name="Balloon" type="Control" parent="." unique_id=197696518]
+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="MarginContainer" type="MarginContainer" parent="Balloon" unique_id=1192375565]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -219.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="PanelContainer" type="PanelContainer" parent="Balloon/MarginContainer" unique_id=66397601]
+clip_children = 2
+layout_mode = 2
+mouse_filter = 1
+
+[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer" unique_id=338276858]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer" unique_id=587923225]
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=141270408]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1867683141]
+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/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1739101537 instance=ExtResource("2_a8ve6")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+
+[node name="Control" type="Control" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=534060310]
+custom_minimum_size = Vector2(20, 10)
+layout_mode = 2
+size_flags_vertical = 8
+
+[node name="Progress" type="Polygon2D" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=838836721]
+unique_name_in_owner = true
+polygon = PackedVector2Array(0, 0, 10, 10, 20, 0)
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=473539640]
+libraries/ = SubResource("AnimationLibrary_1337t")
+autoplay = &"progress"
+
+[node name="CenterContainer" type="CenterContainer" parent="Balloon" unique_id=473552711]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/CenterContainer" unique_id=1321351341 node_paths=PackedStringArray("ResponseTemplate")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/separation = 2
+alignment = 1
+script = ExtResource("3_72ixx")
+ResponseTemplate = NodePath("ResponseExample")
+
+[node name="ResponseExample" type="Button" parent="Balloon/CenterContainer/ResponsesMenu" unique_id=1595872767]
+layout_mode = 2
+text = "Response example"
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=140912110]
+unique_name_in_owner = true
diff --git a/addons/dialogue_manager/example_balloon/SmallExampleBalloon.tscn b/addons/dialogue_manager/example_balloon/SmallExampleBalloon.tscn
new file mode 100644
index 0000000..a3f7ce1
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/SmallExampleBalloon.tscn
@@ -0,0 +1,202 @@
+[gd_scene format=3 uid="uid://dwcmp07fyu5ah"]
+
+[ext_resource type="Script" uid="uid://5b3w40kwakl3" path="res://addons/dialogue_manager/example_balloon/ExampleBalloon.cs" id="1_s2gbs"]
+[ext_resource type="PackedScene" uid="uid://c3qmxhslp0dl7" path="res://addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.tscn" id="2_hfvdi"]
+[ext_resource type="Script" uid="uid://bf0gf4upyxj5l" path="res://addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs" 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_i6nbm"]
+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
+PanelContainer/styles/panel = SubResource("StyleBoxFlat_i6nbm")
+
+[sub_resource type="Animation" id="Animation_i6nbm"]
+resource_name = "progress"
+loop_mode = 1
+step = 0.1
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0, 0, 0, 0, 0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.25, 0, 0.2, -0.0157438, -1, -0.2, 0.0112069, 0.25, 0, -1, -0.25, 0, 0.25, 0, 0, -0.2, 0.00299701, 0.25, 0),
+"times": PackedFloat32Array(0, 0.1, 0.5, 0.6, 1)
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_6b6c8"]
+_data = {
+&"progress": SubResource("Animation_i6nbm")
+}
+
+[node name="ExampleBalloon" type="CanvasLayer" unique_id=1448364161]
+layer = 100
+script = ExtResource("1_s2gbs")
+
+[node name="Balloon" type="Control" parent="." unique_id=1854621134]
+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="MarginContainer" type="MarginContainer" parent="Balloon" unique_id=682848477]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -71.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="PanelContainer" type="PanelContainer" parent="Balloon/MarginContainer" unique_id=1589990687]
+clip_children = 2
+layout_mode = 2
+mouse_filter = 1
+
+[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer" unique_id=1001222780]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer" unique_id=953735783]
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=636314399]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=571791881]
+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/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=388630144 instance=ExtResource("2_hfvdi")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+
+[node name="Control" type="Control" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=2030531972]
+custom_minimum_size = Vector2(5, 3)
+layout_mode = 2
+size_flags_vertical = 8
+
+[node name="Progress" type="Polygon2D" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=1185159698]
+unique_name_in_owner = true
+position = Vector2(0, -0.455998)
+polygon = PackedVector2Array(0, 0, 2.5, 3, 5, 0)
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=1445298592]
+libraries/ = SubResource("AnimationLibrary_6b6c8")
+autoplay = &"progress"
+
+[node name="CenterContainer" type="CenterContainer" parent="Balloon" unique_id=1054790561]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/CenterContainer" unique_id=260756352]
+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/CenterContainer/ResponsesMenu" unique_id=1031268561]
+layout_mode = 2
+text = "Response Example"
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=1157057297]
+unique_name_in_owner = true
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 0000000..ce118f2
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/example_balloon.gd
@@ -0,0 +1,226 @@
+class_name DialogueManagerExampleBalloon extends CanvasLayer
+## A basic dialogue balloon for use with Dialogue Manager.
+
+
+## The dialogue resource
+@export var dialogue_resource: DialogueResource
+
+## Start from a given cue when using balloon as a [Node] in a scene.
+@export var start_from_cue: String = ""
+
+## If running as a [Node] in a scene then auto start the dialogue.
+@export var auto_start: bool = false
+
+## If all other input is blocked as long as dialogue is shown.
+@export var will_block_other_input: bool = true
+
+## 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"
+
+## A sound player for voice lines (if they exist).
+@onready var audio_stream_player: AudioStreamPlayer = %AudioStreamPlayer
+
+## 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
+
+## A dictionary to store any ephemeral variables
+var locals: Dictionary = {}
+
+var _locale: String = TranslationServer.get_locale()
+
+## The current line
+var dialogue_line: DialogueLine:
+ set(value):
+ if value:
+ dialogue_line = value
+ apply_dialogue_line()
+ else:
+ # The dialogue has finished so close the balloon
+ if owner == null:
+ queue_free()
+ else:
+ hide()
+ get:
+ return dialogue_line
+
+## A cooldown timer for delaying the balloon hide when encountering a mutation.
+var mutation_cooldown: Timer = Timer.new()
+
+## The base balloon anchor
+@onready var balloon: Control = %Balloon
+
+## The label showing the name of the currently speaking character
+@onready var character_label: RichTextLabel = %CharacterLabel
+
+## The label showing the currently spoken dialogue
+@onready var dialogue_label: DialogueLabel = %DialogueLabel
+
+## The menu of responses
+@onready var responses_menu: DialogueResponsesMenu = %ResponsesMenu
+
+## Indicator to show that player can progress dialogue.
+@onready var progress: Polygon2D = %Progress
+
+
+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
+
+ mutation_cooldown.timeout.connect(_on_mutation_cooldown_timeout)
+ add_child(mutation_cooldown)
+
+ if auto_start:
+ if not is_instance_valid(dialogue_resource):
+ assert(false, DMConstants.get_error_message(DMConstants.ERR_MISSING_RESOURCE_FOR_AUTOSTART))
+ start()
+
+ # EXAMPLE MESSAGE
+ var warning: Button = Button.new()
+ warning.text = DMConstants.translate("This is an example balloon. Create your own balloon in 'Project > Tools > Dialogue > Create Balloon...'")
+ warning.disabled = true
+ warning.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
+ balloon.add_child(warning)
+ balloon.move_child(warning, 0)
+ # /EXAMPLE MESSAGE
+
+
+func _process(_delta: float) -> void:
+ if is_instance_valid(dialogue_line):
+ progress.visible = not dialogue_label.is_typing and dialogue_line.responses.size() == 0 and not dialogue_line.has_tag("voice")
+
+
+func _unhandled_input(_event: InputEvent) -> void:
+ # Only the balloon is allowed to handle input while it's showing
+ if will_block_other_input:
+ 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: float = dialogue_label.visible_ratio
+ await dialogue_line.refresh()
+ if visible_ratio < 1:
+ dialogue_label.skip_typing()
+
+
+## Start some dialogue
+func start(with_dialogue_resource: DialogueResource = null, cue: String = "", extra_game_states: Array = []) -> void:
+ temporary_game_states = [self] + extra_game_states
+ is_waiting_for_input = false
+ if is_instance_valid(with_dialogue_resource):
+ dialogue_resource = with_dialogue_resource
+ if not cue.is_empty():
+ start_from_cue = cue
+ dialogue_line = await dialogue_resource.get_next_dialogue_line(start_from_cue, temporary_game_states)
+ show()
+
+
+## Apply any changes to the balloon given a new [DialogueLine].
+func apply_dialogue_line() -> void:
+ mutation_cooldown.stop()
+
+ progress.hide()
+ is_waiting_for_input = false
+ balloon.focus_mode = Control.FOCUS_ALL
+ balloon.grab_focus()
+
+ 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.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 next line
+ if dialogue_line.has_tag("voice"):
+ audio_stream_player.stream = load(dialogue_line.get_tag_value("voice"))
+ audio_stream_player.play()
+ await audio_stream_player.finished
+ next(dialogue_line.next_id)
+ elif dialogue_line.responses.size() > 0:
+ balloon.focus_mode = Control.FOCUS_NONE
+ responses_menu.show()
+ elif dialogue_line.time != "":
+ var time: float = 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()
+
+
+## Go to the next line
+func next(next_id: String) -> void:
+ dialogue_line = await dialogue_resource.get_next_dialogue_line(next_id, temporary_game_states)
+
+
+#region Signals
+
+
+func _on_mutation_cooldown_timeout() -> void:
+ if will_hide_balloon:
+ will_hide_balloon = false
+ balloon.hide()
+
+
+func _on_mutated(mutation: Dictionary) -> void:
+ if not mutation.is_inline:
+ is_waiting_for_input = false
+ will_hide_balloon = true
+ mutation_cooldown.start(0.1)
+
+
+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.gd.uid b/addons/dialogue_manager/example_balloon/example_balloon.gd.uid
new file mode 100644
index 0000000..6327f9b
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/example_balloon.gd.uid
@@ -0,0 +1 @@
+uid://d1wt4ma6055l8
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 0000000..cdfd897
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/example_balloon.tscn
@@ -0,0 +1,207 @@
+[gd_scene format=3 uid="uid://73jm5qjy52vq"]
+
+[ext_resource type="Script" uid="uid://d1wt4ma6055l8" 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/nodes/dialogue_label/dialogue_label.tscn" id="2_a8ve6"]
+[ext_resource type="Script" uid="uid://bb52rsfwhkxbn" path="res://addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd" id="3_72ixx"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_spyqn"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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"]
+content_margin_left = 20.0
+content_margin_top = 10.0
+content_margin_right = 20.0
+content_margin_bottom = 10.0
+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_qkmqt"]
+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
+PanelContainer/styles/panel = SubResource("StyleBoxFlat_qkmqt")
+
+[sub_resource type="Animation" id="Animation_nlsx6"]
+length = 0.001
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0),
+"times": PackedFloat32Array(0)
+}
+
+[sub_resource type="Animation" id="Animation_qkmqt"]
+resource_name = "progress"
+loop_mode = 1
+step = 0.1
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0, 0, 0, 0, 0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.25, 0, 0.2, -0.0157438, -4, -0.2, 0.0112069, 0.25, 0, -4, -0.25, 0, 0.25, 0, 0, -0.2, 0.00299701, 0.25, 0),
+"times": PackedFloat32Array(0, 0.1, 0.5, 0.6, 1)
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_1337t"]
+_data = {
+&"RESET": SubResource("Animation_nlsx6"),
+&"progress": SubResource("Animation_qkmqt")
+}
+
+[node name="ExampleBalloon" type="CanvasLayer" unique_id=38036521]
+layer = 100
+script = ExtResource("1_36de5")
+
+[node name="Balloon" type="Control" parent="." unique_id=154399927]
+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="MarginContainer" type="MarginContainer" parent="Balloon" unique_id=1302081376]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -219.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="PanelContainer" type="PanelContainer" parent="Balloon/MarginContainer" unique_id=2145270309]
+clip_children = 2
+layout_mode = 2
+mouse_filter = 1
+
+[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer" unique_id=903640783]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer" unique_id=1685890612]
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=1890134056]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1567782692]
+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/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1301798052 instance=ExtResource("2_a8ve6")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+
+[node name="Control" type="Control" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=418507161]
+custom_minimum_size = Vector2(20, 10)
+layout_mode = 2
+size_flags_vertical = 8
+
+[node name="Progress" type="Polygon2D" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=2091292889]
+unique_name_in_owner = true
+polygon = PackedVector2Array(0, 0, 10, 10, 20, 0)
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=1602634471]
+libraries/ = SubResource("AnimationLibrary_1337t")
+autoplay = &"progress"
+
+[node name="CenterContainer" type="CenterContainer" parent="Balloon" unique_id=1087901387]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/CenterContainer" unique_id=1801308483 node_paths=PackedStringArray("response_template")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/separation = 2
+alignment = 1
+script = ExtResource("3_72ixx")
+response_template = NodePath("ResponseExample")
+
+[node name="ResponseExample" type="Button" parent="Balloon/CenterContainer/ResponsesMenu" unique_id=1446810977]
+layout_mode = 2
+text = "Response example"
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=1162558754]
+unique_name_in_owner = true
+
+[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
+[connection signal="response_selected" from="Balloon/CenterContainer/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 0000000..ecbd715
--- /dev/null
+++ b/addons/dialogue_manager/example_balloon/small_example_balloon.tscn
@@ -0,0 +1,204 @@
+[gd_scene format=3 uid="uid://13s5spsk34qu"]
+
+[ext_resource type="Script" uid="uid://d1wt4ma6055l8" 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/nodes/dialogue_label/dialogue_label.tscn" id="2_hfvdi"]
+[ext_resource type="Script" uid="uid://bb52rsfwhkxbn" path="res://addons/dialogue_manager/nodes/responses_menu/dialogue_responses_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_i6nbm"]
+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
+PanelContainer/styles/panel = SubResource("StyleBoxFlat_i6nbm")
+
+[sub_resource type="Animation" id="Animation_i6nbm"]
+resource_name = "progress"
+loop_mode = 1
+step = 0.1
+tracks/0/type = "bezier"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Progress:position:y")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"handle_modes": PackedInt32Array(0, 0, 0, 0, 0),
+"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.25, 0, 0.2, -0.0157438, -1, -0.2, 0.0112069, 0.25, 0, -1, -0.25, 0, 0.25, 0, 0, -0.2, 0.00299701, 0.25, 0),
+"times": PackedFloat32Array(0, 0.1, 0.5, 0.6, 1)
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_6b6c8"]
+_data = {
+&"progress": SubResource("Animation_i6nbm")
+}
+
+[node name="ExampleBalloon" type="CanvasLayer" unique_id=1912754290]
+layer = 100
+script = ExtResource("1_s2gbs")
+
+[node name="Balloon" type="Control" parent="." unique_id=1177332107]
+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="MarginContainer" type="MarginContainer" parent="Balloon" unique_id=243547760]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -71.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="PanelContainer" type="PanelContainer" parent="Balloon/MarginContainer" unique_id=649738838]
+clip_children = 2
+layout_mode = 2
+mouse_filter = 1
+
+[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer" unique_id=1503769570]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer" unique_id=476747906]
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=2111896767]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1215135358]
+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/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/VBoxContainer" unique_id=1677336664 instance=ExtResource("2_hfvdi")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "Dialogue..."
+
+[node name="Control" type="Control" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer" unique_id=351729490]
+custom_minimum_size = Vector2(5, 3)
+layout_mode = 2
+size_flags_vertical = 8
+
+[node name="Progress" type="Polygon2D" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=570583621]
+unique_name_in_owner = true
+polygon = PackedVector2Array(0, 0, 2.5, 3, 5, 0)
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/HBoxContainer/Control" unique_id=1392989128]
+libraries/ = SubResource("AnimationLibrary_6b6c8")
+autoplay = &"progress"
+
+[node name="CenterContainer" type="CenterContainer" parent="Balloon" unique_id=1047265792]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/CenterContainer" unique_id=930429145]
+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/CenterContainer/ResponsesMenu" unique_id=1555320070]
+layout_mode = 2
+text = "Response Example"
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=963979321]
+unique_name_in_owner = true
+
+[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
+[connection signal="response_selected" from="Balloon/CenterContainer/ResponsesMenu" to="." method="_on_responses_menu_response_selected"]
diff --git a/addons/dialogue_manager/export_plugin.gd b/addons/dialogue_manager/export_plugin.gd
new file mode 100644
index 0000000..34a17bd
--- /dev/null
+++ b/addons/dialogue_manager/export_plugin.gd
@@ -0,0 +1,26 @@
+class_name DMExportPlugin extends EditorExportPlugin
+
+const IGNORED_PATHS: PackedStringArray = [
+ "/assets",
+ "/components",
+ "/views",
+ "inspector_plugin",
+ "test_scene"
+]
+
+
+func _get_name() -> String:
+ return "Dialogue Manager Export Plugin"
+
+
+func _export_file(path: String, _type: String, _features: PackedStringArray) -> void:
+ var plugin_path: String = DMPlugin.get_plugin_path()
+
+ # Ignore any editor stuff
+ for ignored_path: String in IGNORED_PATHS:
+ if path.begins_with(plugin_path + ignored_path):
+ skip()
+
+ # Ignore C# stuff it not using dotnet
+ if path.begins_with(plugin_path) and not DMSettings.check_for_dotnet_solution() and path.ends_with(".cs"):
+ skip()
diff --git a/addons/dialogue_manager/export_plugin.gd.uid b/addons/dialogue_manager/export_plugin.gd.uid
new file mode 100644
index 0000000..efaa0c6
--- /dev/null
+++ b/addons/dialogue_manager/export_plugin.gd.uid
@@ -0,0 +1 @@
+uid://sa55ra11ji2q
diff --git a/addons/dialogue_manager/import_plugin.gd b/addons/dialogue_manager/import_plugin.gd
new file mode 100644
index 0000000..efea19a
--- /dev/null
+++ b/addons/dialogue_manager/import_plugin.gd
@@ -0,0 +1,114 @@
+@tool
+class_name DMImportPlugin extends EditorImportPlugin
+
+
+signal compiled_resource(resource: DialogueResource)
+
+
+const COMPILER_VERSION: int = 18
+
+
+func _init() -> void:
+ var current_version: int = DMSettings.get_user_value("compiler_version", -1)
+ if current_version != COMPILER_VERSION:
+ DMSettings.set_user_value("compiler_version", COMPILER_VERSION)
+ DMCache.reimport_all_files()
+
+
+func _get_importer_name() -> String:
+ return "dialogue_manager"
+
+
+func _get_format_version() -> int:
+ return 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() -> String:
+ return "Resource"
+
+
+func _get_recognized_extensions() -> PackedStringArray:
+ return PackedStringArray(["dialogue"])
+
+
+func _get_save_extension() -> String:
+ 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:
+ # 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()
+
+ DMPlugin.instance.cache_file_content_changed.emit(source_file, raw_text)
+
+ # Compile the text
+ var result: DMCompilerResult = DMCompiler.compile_string(raw_text, source_file)
+ if result.errors.size() > 0:
+ printerr("%d errors found in %s" % [result.errors.size(), source_file])
+ DMCache.add_errors_to_file(source_file, result.errors)
+ return OK
+
+ # 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 = result.using_states
+ resource.cues = result.cues
+ resource.first_cue = result.first_cue
+ resource.character_names = result.character_names
+ resource.lines = result.lines
+
+ # Clear errors and possibly trigger any cascade recompiles
+ DMCache.add_file(source_file, result)
+
+ var err: Error = ResourceSaver.save(resource, "%s.%s" % [save_path, _get_save_extension()])
+
+ compiled_resource.emit(resource)
+
+ # Recompile any dependencies
+ var dependent_paths: PackedStringArray = DMCache.get_dependent_paths_for_reimport(source_file)
+ for path: String in dependent_paths:
+ append_import_external_resource(path)
+
+ return err
diff --git a/addons/dialogue_manager/import_plugin.gd.uid b/addons/dialogue_manager/import_plugin.gd.uid
new file mode 100644
index 0000000..e98bfab
--- /dev/null
+++ b/addons/dialogue_manager/import_plugin.gd.uid
@@ -0,0 +1 @@
+uid://dhwpj6ed8soyq
diff --git a/addons/dialogue_manager/inspector_plugin.gd b/addons/dialogue_manager/inspector_plugin.gd
new file mode 100644
index 0000000..c6d2527
--- /dev/null
+++ b/addons/dialogue_manager/inspector_plugin.gd
@@ -0,0 +1,26 @@
+@tool
+class_name DMInspectorPlugin extends EditorInspectorPlugin
+
+
+func _can_handle(object: Object) -> bool:
+ if object is GDScript: return false
+ if not object is Node and not object is Resource: return false
+ if "name" in object and object.name == "Dialogue Manager": return false
+
+ return true
+
+
+func _parse_property(object: Object, _type: Variant, name: String, _hint_type: PropertyHint, hint_string: String, _usage_flags: int, _wide: bool) -> bool:
+ if ("dialogue_cue" in name.to_lower() or "dialoguecue" in name.to_lower()) and object.get_property_list().filter(func(p: Dictionary) -> bool: return _is_dialogue_resource_property(p.name, p.hint_string)):
+ add_property_editor(name, DMCueEditorProperty.new())
+ return true
+
+ if _is_dialogue_resource_property(name, hint_string):
+ add_property_editor(name, DMDialogueEditorProperty.new())
+ return true
+
+ return false
+
+
+func _is_dialogue_resource_property(name: String, hint_string: String) -> bool:
+ return hint_string == "DialogueResource" or ("dialogue" in name.to_lower() and hint_string == "Resource")
diff --git a/addons/dialogue_manager/inspector_plugin.gd.uid b/addons/dialogue_manager/inspector_plugin.gd.uid
new file mode 100644
index 0000000..00c8db8
--- /dev/null
+++ b/addons/dialogue_manager/inspector_plugin.gd.uid
@@ -0,0 +1 @@
+uid://0x31sbqbikov
diff --git a/addons/dialogue_manager/l10n/en.po b/addons/dialogue_manager/l10n/en.po
new file mode 100644
index 0000000..23f0ce1
--- /dev/null
+++ b/addons/dialogue_manager/l10n/en.po
@@ -0,0 +1,523 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: en\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.quick_open"
+msgstr "Quick 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 "all"
+msgstr "All"
+
+msgid "find_in_files"
+msgstr "Find in Dialogue..."
+
+msgid "test_dialogue"
+msgstr "Test dialogue from start of file"
+
+msgid "test_dialogue_from_line"
+msgstr "Test dialogue from current line"
+
+msgid "search_for_text"
+msgstr "Search for text"
+
+msgid "insert"
+msgstr "Insert"
+
+msgid "translations"
+msgstr "Translations"
+
+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.cue"
+msgstr "Cue"
+
+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 cue"
+
+msgid "insert.end_dialogue"
+msgstr "End dialogue"
+
+msgid "generate_line_ids"
+msgstr "Generate line IDs..."
+
+msgid "generate_ids.warning_title"
+msgstr "Generate line IDs?"
+
+msgid "generate_ids.warning_text"
+msgstr "Generate line IDs for all lines in all dialogue files?\n\n**Make sure to commit any changes to source control BEFORE running this because it cannot be undone.**"
+
+msgid "generate_ids.ok_button"
+msgstr "Generate IDs"
+
+msgid "use_uuid_only_for_ids"
+msgstr "Use UUID only for IDs"
+
+msgid "set_id_prefix_length"
+msgstr "Set ID prefix length"
+
+msgid "id_prefix_length"
+msgstr "ID prefix length:"
+
+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 "confirm_n_unsaved_files"
+msgstr "You have {count} unsaved dialogue files."
+
+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 "n_of_n"
+msgstr "{index} of {total}"
+
+msgid "search.find_in_dialogue"
+msgstr "Find in Dialogue"
+
+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 "cues_list.filter"
+msgstr "Filter cues"
+
+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_cue"
+msgstr "Cues cannot be empty."
+
+msgid "errors.duplicate_cue"
+msgstr "There is already a cue with that name."
+
+msgid "errors.invalid_cue_string"
+msgstr "Cues can only contain alphanumeric characters and numbers."
+
+msgid "errors.invalid_cue_number"
+msgstr "Cues cannot begin with a number."
+
+msgid "errors.unknown_cue"
+msgstr "Unknown cue."
+
+msgid "errors.jump_to_invalid_cue"
+msgstr "This jump is pointing to an invalid cue."
+
+msgid "errors.cue_has_no_content"
+msgstr "That cue 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 in use."
+
+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.expected_when_or_else"
+msgstr "Expecting a when or an else case."
+
+msgid "errors.only_one_else_allowed"
+msgstr "Only one else case is allowed per match."
+
+msgid "errors.when_must_belong_to_match"
+msgstr "When statements can only appear as children of match statements."
+
+msgid "errors.concurrent_line_without_origin"
+msgstr "Concurrent lines need an origin line that doesn't start with \"| \"."
+
+msgid "errors.goto_not_allowed_on_concurrect_lines"
+msgstr "Goto references are not allowed on concurrent dialogue lines."
+
+msgid "errors.unexpected_syntax_on_nested_dialogue_line"
+msgstr "Nested dialogue lines may only contain dialogue."
+
+msgid "errors.err_nested_dialogue_invalid_jump"
+msgstr "Only the last line of nested dialogue is allowed to include a jump."
+
+msgid "errors.missing_resource_for_autostart"
+msgstr "You need to specify a dialogue resource when using auto-start."
+
+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."
+
+msgid "runtime.top_level_states_share_name"
+msgstr "Multiple top-level states ({states}) share method/property/signal name \"{key}\". Only the first occurance is accessible to dialogue."
+
+msgid "translation_plugin.character_name"
+msgstr "Character name"
+
+msgid "Regions & Cues"
+msgstr "Regions & Cues"
+
+msgid "Regions"
+msgstr "Regions"
+
+msgid "Cues"
+msgstr "Cues"
+
+msgid "Auto"
+msgstr "Auto"
+
+msgid ""
+msgstr ""
+
+msgid "Cue:"
+msgstr ""
+
+msgid "Static IDs can't be on a line with no other content."
+msgstr "Static IDs can't be on a line with no other content."
+
+msgid "\"{alias}\" will overwrite already registered context alias."
+msgstr "\"{alias}\" will overwrite already registered context alias."
+
+msgid "Alias cannot be empty."
+msgstr "Alias cannot be empty."
+
+msgid "Target cannot be null."
+msgstr "Target cannot be null."
+
+msgid "Cannot refresh dialogue line because its ID is missing a resource UID."
+msgstr "Cannot refresh dialogue line because its ID is missing a resource UID."
+
+msgid "None"
+msgstr "None"
+
+msgid "Dialogue"
+msgstr "Dialogue"
+
+msgid "{game_name} isn't running."
+msgstr "{game_name} isn't running."
+
+msgid "History"
+msgstr "History"
+
+msgid "Clear"
+msgstr "Clear"
+
+msgid "Context Nodes"
+msgstr "Context Nodes"
+
+msgid "Globals"
+msgstr "Globals"
+
+msgid "This is an example balloon. Create your own balloon in 'Project > Tools > Dialogue > Create Balloon...'"
+msgstr "This is an example balloon. Create your own balloon in 'Project > Tools > Dialogue > Create Balloon...'"
+
+msgid "Get example projects..."
+msgstr "Get example projects..."
+
+msgid "Buy my game..."
+msgstr "Buy my game..."
\ 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 0000000..5aae69b
--- /dev/null
+++ b/addons/dialogue_manager/l10n/es.po
@@ -0,0 +1,387 @@
+#
+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 "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.cue"
+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 "use_uuid_only_for_ids"
+msgstr "Usar solo UUID como ID"
+
+msgid "set_id_prefix_length"
+msgstr "Establecer la longitud del prefijo de ID"
+
+msgid "id_prefix_length"
+msgstr "Longitud del prefijo de ID:"
+
+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 "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 "cues_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_cue"
+msgstr "Los títulos no pueden estar vacíos."
+
+msgid "errors.duplicate_cue"
+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_cue_string"
+msgstr "Los títulos solo pueden contener caracteres alfanuméricos y números."
+
+msgid "errors.invalid_cue_number"
+msgstr "Los títulos no pueden empezar con un número."
+
+msgid "errors.unknown_cue"
+msgstr "Título desconocido."
+
+msgid "errors.jump_to_invalid_cue"
+msgstr "Este salto está apuntando a un título inválido."
+
+msgid "errors.cue_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 0000000..8f1334e
--- /dev/null
+++ b/addons/dialogue_manager/l10n/translations.pot
@@ -0,0 +1,513 @@
+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.quick_open"
+msgstr ""
+
+msgid "open.no_recent_files"
+msgstr ""
+
+msgid "open.clear_recent_files"
+msgstr ""
+
+msgid "save_all_files"
+msgstr ""
+
+msgid "all"
+msgstr ""
+
+msgid "find_in_files"
+msgstr ""
+
+msgid "test_dialogue"
+msgstr ""
+
+msgid "test_dialogue_from_line"
+msgstr ""
+
+msgid "search_for_text"
+msgstr ""
+
+msgid "insert"
+msgstr ""
+
+msgid "translations"
+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.cue"
+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 "generate_ids.warning_title"
+msgstr ""
+
+msgid "generate_ids.warning_text"
+msgstr ""
+
+msgid "generate_ids.ok_button"
+msgstr ""
+
+msgid "use_uuid_only_for_ids"
+msgstr ""
+
+msgid "set_id_prefix_length"
+msgstr ""
+
+msgid "id_prefix_length"
+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 "confirm_n_unsaved_files"
+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 "n_of_n"
+msgstr ""
+
+msgid "search.find_in_dialogue"
+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 "cues_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_cue"
+msgstr ""
+
+msgid "errors.duplicate_cue"
+msgstr ""
+
+msgid "errors.invalid_cue_string"
+msgstr ""
+
+msgid "errors.invalid_cue_number"
+msgstr ""
+
+msgid "errors.unknown_cue"
+msgstr ""
+
+msgid "errors.jump_to_invalid_cue"
+msgstr ""
+
+msgid "errors.cue_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.expected_when_or_else"
+msgstr ""
+
+msgid "errors.only_one_else_allowed"
+msgstr ""
+
+msgid "errors.when_must_belong_to_match"
+msgstr ""
+
+msgid "errors.concurrent_line_without_origin"
+msgstr ""
+
+msgid "errors.goto_not_allowed_on_concurrect_lines"
+msgstr ""
+
+msgid "errors.unexpected_syntax_on_nested_dialogue_line"
+msgstr ""
+
+msgid "errors.err_nested_dialogue_invalid_jump"
+msgstr ""
+
+msgid "errors.missing_resource_for_autostart"
+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 ""
+
+msgid "runtime.top_level_states_share_name"
+msgstr ""
+
+msgid "translation_plugin.character_name"
+msgstr ""
+
+msgid "Regions & Cues"
+msgstr ""
+
+msgid "Regions"
+msgstr ""
+
+msgid "Cues"
+msgstr ""
+
+msgid "Auto"
+msgstr ""
+
+msgid ""
+msgstr ""
+
+msgid "Cue:"
+msgstr ""
+
+msgid "Static IDs can't be on a line with no other content."
+msgstr ""
+
+msgid "\"{alias}\" will overwrite already registered context alias."
+msgstr ""
+
+msgid "Alias cannot be empty."
+msgstr ""
+
+msgid "Target cannot be null."
+msgstr ""
+
+msgid "Cannot refresh dialogue line because its ID is missing a resource UID."
+msgstr ""
+
+msgid "None"
+msgstr ""
+
+msgid "Dialogue"
+msgstr ""
+
+msgid "{game_name} isn't running."
+msgstr ""
+
+msgid "History"
+msgstr ""
+
+msgid "Clear"
+msgstr ""
+
+msgid "Context Nodes"
+msgstr ""
+
+msgid "Globals"
+msgstr ""
+
+msgid "This is an example balloon. Create your own balloon in 'Project > Tools > Dialogue > Create Balloon...'"
+msgstr ""
+
+msgid "Get example projects..."
+msgstr ""
+
+msgid "Buy my game..."
+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 0000000..8c127c3
--- /dev/null
+++ b/addons/dialogue_manager/l10n/uk.po
@@ -0,0 +1,438 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Dialogue Manager\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \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.quick_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 "test_dialogue_from_line"
+msgstr "Протестувати діалог з поточного рядка"
+
+msgid "search_for_text"
+msgstr "Пошук тексту"
+
+msgid "insert"
+msgstr "Вставити"
+
+msgid "translations"
+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.cue"
+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 "use_uuid_only_for_ids"
+msgstr "Використовувати лише UUID як ID"
+
+msgid "set_id_prefix_length"
+msgstr "Встановити довжину префікса ID"
+
+msgid "id_prefix_length"
+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 "Показати у файловій системі"
+
+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 "cues_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 "Невідоме автозавантаження в операторі «using»."
+
+msgid "errors.empty_cue"
+msgstr "Заголовки не можуть бути порожніми."
+
+msgid "errors.duplicate_cue"
+msgstr "Заголовок з такою назвою уже є."
+
+msgid "errors.invalid_cue_string"
+msgstr "Заголовки можуть містити лише алфавітно-цифрові символи та цифри."
+
+msgid "errors.invalid_cue_number"
+msgstr "Заголовки не можуть починатися з цифри."
+
+msgid "errors.unknown_cue"
+msgstr "Невідомий заголовок."
+
+msgid "errors.jump_to_invalid_cue"
+msgstr "Цей перехід вказує на недійсний заголовок."
+
+msgid "errors.cue_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.expected_when_or_else"
+msgstr "Очікувався випадок «when» або «else»."
+
+msgid "errors.only_one_else_allowed"
+msgstr "Для кожного «match» допускається лише один випадок «else»."
+
+msgid "errors.when_must_belong_to_match"
+msgstr "Оператори «when» можуть з’являтися лише як дочірні операторів «match»."
+
+msgid "errors.concurrent_line_without_origin"
+msgstr "Паралельні рядки потребують початкового рядка, який не починається з «|»."
+
+msgid "errors.goto_not_allowed_on_concurrect_lines"
+msgstr "У паралельних діалогових рядках не допускаються Goto посилання."
+
+msgid "errors.unexpected_syntax_on_nested_dialogue_line"
+msgstr "Вкладені рядки діалогу можуть містити лише діалог."
+
+msgid "errors.err_nested_dialogue_invalid_jump"
+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 "Dialogue Manager тепер з версією {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}» не знайдено. Можливо, вам слід додати декоратор «[Export]». Стани з безпосередньо доступними властивостями/методами/сигналами включають {states}. На автозавантаження потрібно посилатися за їхніми назвами для використання їхніх властивостей."
+
+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»."
+
+msgid "runtime.top_level_states_share_name"
+msgstr "Кілька станів верхнього рівня ({states}) мають спільну назву методу/властивості/сигналу «{key}». Для діалогу доступний лише перший випадок."
+
+msgid "translation_plugin.character_name"
+msgstr "Ім’я персонажа"
diff --git a/addons/dialogue_manager/l10n/zh.po b/addons/dialogue_manager/l10n/zh.po
new file mode 100644
index 0000000..9a64866
--- /dev/null
+++ b/addons/dialogue_manager/l10n/zh.po
@@ -0,0 +1,520 @@
+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.9\n"
+
+msgid "start_a_new_file"
+msgstr "创建新文件"
+
+msgid "open_a_file"
+msgstr "打开已有文件"
+
+msgid "open.open"
+msgstr "打开……"
+
+msgid "open.quick_open"
+msgstr "快速打开"
+
+msgid "open.no_recent_files"
+msgstr "无历史记录"
+
+msgid "open.clear_recent_files"
+msgstr "清空历史记录"
+
+msgid "save_all_files"
+msgstr "保存所有文件"
+
+msgid "all"
+msgstr ""
+
+msgid "find_in_files"
+msgstr "在文件中查找"
+
+msgid "test_dialogue"
+msgstr "测试对话"
+
+msgid "test_dialogue_from_line"
+msgstr "从当前行开始测试对话"
+
+msgid "search_for_text"
+msgstr "查找……"
+
+msgid "insert"
+msgstr "插入"
+
+msgid "translations"
+msgstr "翻译"
+
+msgid "sponsor"
+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.cue"
+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 "generate_ids.warning_title"
+msgstr ""
+
+msgid "generate_ids.warning_text"
+msgstr ""
+
+msgid "generate_ids.ok_button"
+msgstr ""
+
+msgid "use_uuid_only_for_ids"
+msgstr "仅使用UUID作为ID"
+
+msgid "set_id_prefix_length"
+msgstr "设置ID前缀长度"
+
+msgid "id_prefix_length"
+msgstr "ID前缀长度:"
+
+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 "confirm_n_unsaved_files"
+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 "n_of_n"
+msgstr "第{index}个,共{total}个"
+
+msgid "search.find_in_dialogue"
+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 "cues_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 "请先解决 Dialogue 中的错误。"
+
+msgid "errors.import_errors"
+msgstr "被导入的文件存在问题。"
+
+msgid "errors.already_imported"
+msgstr "文件已被导入。"
+
+msgid "errors.duplicate_import"
+msgstr "导入名不能重复。"
+
+msgid "errors.unknown_using"
+msgstr ""
+
+msgid "errors.empty_cue"
+msgstr "标题名不能为空。"
+
+msgid "errors.duplicate_cue"
+msgstr "标题名不能重复。"
+
+msgid "errors.invalid_cue_string"
+msgstr "标题名无效。"
+
+msgid "errors.invalid_cue_number"
+msgstr "标题不能以数字开始。"
+
+msgid "errors.unknown_cue"
+msgstr "标题未定义。"
+
+msgid "errors.jump_to_invalid_cue"
+msgstr "标题名无效。"
+
+msgid "errors.cue_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.expected_when_or_else"
+msgstr ""
+
+msgid "errors.only_one_else_allowed"
+msgstr ""
+
+msgid "errors.when_must_belong_to_match"
+msgstr ""
+
+msgid "errors.concurrent_line_without_origin"
+msgstr ""
+
+msgid "errors.goto_not_allowed_on_concurrect_lines"
+msgstr ""
+
+msgid "errors.unexpected_syntax_on_nested_dialogue_line"
+msgstr ""
+
+msgid "errors.err_nested_dialogue_invalid_jump"
+msgstr ""
+
+msgid "errors.missing_resource_for_autostart"
+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 "文件中存在{count}个错误。"
+
+msgid "runtime.error_detail"
+msgstr "第{line}行:{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 "“{signal_name}”不存在。(全局变量:{states})"
+
+msgid "runtime.method_not_callable"
+msgstr "{method}不是对象“{object}”上的函数。"
+
+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 ""
+
+msgid "runtime.top_level_states_share_name"
+msgstr ""
+
+msgid "translation_plugin.character_name"
+msgstr ""
+
+msgid "Regions & Cues"
+msgstr ""
+
+msgid "Regions"
+msgstr ""
+
+msgid "Cues"
+msgstr ""
+
+msgid "Auto"
+msgstr ""
+
+msgid ""
+msgstr ""
+
+msgid "Cue:"
+msgstr ""
+
+msgid "Static IDs can't be on a line with no other content."
+msgstr ""
+
+msgid "\"{alias}\" will overwrite already registered context alias."
+msgstr ""
+
+msgid "Alias cannot be empty."
+msgstr ""
+
+msgid "Target cannot be null."
+msgstr ""
+
+msgid "Cannot refresh dialogue line because its ID is missing a resource UID."
+msgstr ""
+
+msgid "None"
+msgstr ""
+
+msgid "Dialogue"
+msgstr ""
+
+msgid "{game_name} isn't running."
+msgstr ""
+
+msgid "History"
+msgstr ""
+
+msgid "Clear"
+msgstr ""
+
+msgid "Context Nodes"
+msgstr ""
+
+msgid "Globals"
+msgstr ""
+
+msgid ""
+"This is an example balloon. Create your own balloon in 'Project > Tools > "
+"Dialogue > Create Balloon...'"
+msgstr ""
+
+#~ msgid "save_characters_to_csv"
+#~ msgstr "保存角色到 CSV"
diff --git a/addons/dialogue_manager/l10n/zh_TW.po b/addons/dialogue_manager/l10n/zh_TW.po
new file mode 100644
index 0000000..7c7a8fb
--- /dev/null
+++ b/addons/dialogue_manager/l10n/zh_TW.po
@@ -0,0 +1,387 @@
+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 "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.cue"
+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 "use_uuid_only_for_ids"
+msgstr "僅使用 UUID 作為 ID"
+
+msgid "set_id_prefix_length"
+msgstr "設定 ID 前綴長度"
+
+msgid "id_prefix_length"
+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 "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 "cues_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_cue"
+msgstr "標題名不能爲空。"
+
+msgid "errors.duplicate_cue"
+msgstr "標題名不能重複。"
+
+msgid "errors.invalid_cue_string"
+msgstr "標題名無效。"
+
+msgid "errors.invalid_cue_number"
+msgstr "標題不能以數字開始。"
+
+msgid "errors.unknown_cue"
+msgstr "標題未定義。"
+
+msgid "errors.jump_to_invalid_cue"
+msgstr "標題名無效。"
+
+msgid "errors.cue_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/nodes/actionable/DialogueActionable2D.cs b/addons/dialogue_manager/nodes/actionable/DialogueActionable2D.cs
new file mode 100644
index 0000000..4401583
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/DialogueActionable2D.cs
@@ -0,0 +1,141 @@
+using Godot;
+using Godot.Collections;
+using System;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A special node to hold information about starting dialogue.
+ ///
+ /// Assuming DialogueResource and DialogueCue have been configured you can
+ /// call Action() on this node at runtime to start dialogue.
+ ///
+ [Tool, Icon("uid://ja67jst5iqyy")]
+ public partial class DialogueActionable2D : Area2D
+ {
+ ///
+ /// Emitted when this has Action() called on it.
+ ///
+ [Signal] public delegate void ActionedEventHandler();
+
+ ///
+ /// Emitted when the dialogue resource associated with this ends. NOTE: The
+ /// signal is also emitted if the same resource is used for multiple nodes in the tree.
+ ///
+ [Signal] public delegate void DialogueEndedEventHandler();
+
+
+ private Resource dialogueResource = null;
+
+ ///
+ /// The dialogue resource to use when starting dialogue.
+ ///
+ [Export]
+ public Resource DialogueResource
+ {
+ get => dialogueResource;
+ set
+ {
+ dialogueResource = value;
+ if (dialogueResource == null)
+ {
+ DialogueCue = "";
+ }
+ NotifyPropertyListChanged();
+ }
+ }
+
+ ///
+ /// The target cue to start dialogue from.
+ ///
+ [Export] public string DialogueCue = "";
+
+ ///
+ /// The dialogue balloon that was last used by calling Action() (if there was one).
+ ///
+ public Node DialogueBalloon;
+
+ ///
+ /// The method used to start dialogue when Action() is called. Override if you need different logic.
+ ///
+ public static Func, Node> StartDialogue = (withDialogueResource, fromCue, extraGameStates) =>
+ DialogueManager.ShowDialogueBalloon(withDialogueResource, fromCue, extraGameStates);
+
+
+ public override void _Ready()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ AddToGroup("dialogue_actionables");
+ // Touch the instance to make sure the DialogueManager bridge signals are connected
+ _ = DialogueManager.Instance;
+ DialogueManager.DialogueEnded += OnDialogueEnded;
+ }
+ }
+
+
+ public override void _ExitTree()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ DialogueManager.DialogueEnded -= OnDialogueEnded;
+ }
+ }
+
+
+ #region Public
+
+
+ ///
+ /// Action this . If a dialogue resource and cue have been set on this node then it will start dialogue.
+ ///
+ public void Action()
+ {
+ if (IsInstanceValid(DialogueResource) && !string.IsNullOrEmpty(DialogueCue))
+ {
+ DialogueBalloon = StartDialogue(DialogueResource, DialogueCue, new Array { new Dictionary { { "actionable", this } }, Owner });
+ }
+ EmitSignal(SignalName.Actioned);
+ }
+
+
+ ///
+ /// Find the nearest to a given position.
+ ///
+ public static DialogueActionable2D GetNearestActionableTo(Vector2 targetPosition)
+ {
+ float nearestDistance = float.PositiveInfinity;
+ DialogueActionable2D nearestActionable = null;
+ foreach (Node node in ((SceneTree)Engine.GetMainLoop()).GetNodesInGroup("dialogue_actionables"))
+ {
+ if (node is not DialogueActionable2D actionable) continue;
+
+ float distance = actionable.GlobalPosition.DistanceSquaredTo(targetPosition);
+ if (distance < nearestDistance)
+ {
+ nearestDistance = distance;
+ nearestActionable = actionable;
+ }
+ }
+
+ return nearestActionable;
+ }
+
+
+ #endregion
+
+ #region Signals
+
+
+ private void OnDialogueEnded(Resource endingDialogueResource)
+ {
+ if (endingDialogueResource == DialogueResource)
+ {
+ EmitSignal(SignalName.DialogueEnded);
+ }
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/nodes/actionable/DialogueActionable2D.cs.uid b/addons/dialogue_manager/nodes/actionable/DialogueActionable2D.cs.uid
new file mode 100644
index 0000000..49af8c7
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/DialogueActionable2D.cs.uid
@@ -0,0 +1 @@
+uid://d0sxb0wcim1wy
diff --git a/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs b/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs
new file mode 100644
index 0000000..9bc76fe
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs
@@ -0,0 +1,141 @@
+using Godot;
+using Godot.Collections;
+using System;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A special node to hold information about starting dialogue.
+ ///
+ /// Assuming DialogueResource and DialogueCue have been configured you can
+ /// call Action() on this node at runtime to start dialogue.
+ ///
+ [Tool, Icon("uid://dppsuhlvbcbjc")]
+ public partial class DialogueActionable3D : Area3D
+ {
+ ///
+ /// Emitted when this has Action() called on it.
+ ///
+ [Signal] public delegate void ActionedEventHandler();
+
+ ///
+ /// Emitted when the dialogue resource associated with this ends. NOTE: The
+ /// signal is also emitted if the same resource is used for multiple nodes in the tree.
+ ///
+ [Signal] public delegate void DialogueEndedEventHandler();
+
+
+ private Resource dialogueResource = null;
+
+ ///
+ /// The dialogue resource to use when starting dialogue.
+ ///
+ [Export]
+ public Resource DialogueResource
+ {
+ get => dialogueResource;
+ set
+ {
+ dialogueResource = value;
+ if (dialogueResource == null)
+ {
+ DialogueCue = "";
+ }
+ NotifyPropertyListChanged();
+ }
+ }
+
+ ///
+ /// The target cue to start dialogue from.
+ ///
+ [Export] public string DialogueCue = "";
+
+ ///
+ /// The dialogue balloon that was last used by calling Action() (if there was one).
+ ///
+ public Node DialogueBalloon;
+
+ ///
+ /// The method used to start dialogue when Action() is called. Override if you need different logic.
+ ///
+ public static Func, Node> StartDialogue = (withDialogueResource, fromCue, extraGameStates) =>
+ DialogueManager.ShowDialogueBalloon(withDialogueResource, fromCue, extraGameStates);
+
+
+ public override void _Ready()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ AddToGroup("dialogue_actionables");
+ // Touch the instance to make sure the DialogueManager bridge signals are connected
+ _ = DialogueManager.Instance;
+ DialogueManager.DialogueEnded += OnDialogueEnded;
+ }
+ }
+
+
+ public override void _ExitTree()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ DialogueManager.DialogueEnded -= OnDialogueEnded;
+ }
+ }
+
+
+ #region Public
+
+
+ ///
+ /// Action this . If a dialogue resource and cue have been set on this node then it will start dialogue.
+ ///
+ public void Action()
+ {
+ if (IsInstanceValid(DialogueResource) && !string.IsNullOrEmpty(DialogueCue))
+ {
+ DialogueBalloon = StartDialogue(DialogueResource, DialogueCue, new Array { new Dictionary { { "actionable", this } }, Owner });
+ }
+ EmitSignal(SignalName.Actioned);
+ }
+
+
+ ///
+ /// Find the nearest to a given position.
+ ///
+ public static DialogueActionable3D GetNearestActionableTo(Vector3 targetPosition)
+ {
+ float nearestDistance = float.PositiveInfinity;
+ DialogueActionable3D nearestActionable = null;
+ foreach (Node node in ((SceneTree)Engine.GetMainLoop()).GetNodesInGroup("dialogue_actionables"))
+ {
+ if (node is not DialogueActionable3D actionable) continue;
+
+ float distance = actionable.GlobalPosition.DistanceSquaredTo(targetPosition);
+ if (distance < nearestDistance)
+ {
+ nearestDistance = distance;
+ nearestActionable = actionable;
+ }
+ }
+
+ return nearestActionable;
+ }
+
+
+ #endregion
+
+ #region Signals
+
+
+ private void OnDialogueEnded(Resource endingDialogueResource)
+ {
+ if (endingDialogueResource == DialogueResource)
+ {
+ EmitSignal(SignalName.DialogueEnded);
+ }
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs.uid b/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs.uid
new file mode 100644
index 0000000..916f97d
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/DialogueActionable3D.cs.uid
@@ -0,0 +1 @@
+uid://5skdphbgu68t
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd
new file mode 100644
index 0000000..b79a968
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd
@@ -0,0 +1,82 @@
+@tool
+
+@icon("uid://ja67jst5iqyy")
+
+## A special [Area2D] node to hold information about starting dialogue.
+##
+## Assuming [code]dialogue_resource[/code] and [code]cue[/code] have been configured you can
+## call [code]action()[/code] on this node at runtime to start dialogue.
+class_name DialogueActionable2D extends Area2D
+
+
+## Emitted when this [DialogueActionable2D] has [code]action()[/code] called on it.
+signal actioned()
+
+## Emitted when the [DialogueResource] associated with this [DialogueActionable2D] ends. [b]NOTE:[/b] The
+## signal is also emitted if the same resource is used for multiple [DialogueActionable2D] nodes in the tree.
+signal dialogue_ended()
+
+
+## The [DialogueResource] to use when starting dialogue.
+@export var dialogue_resource: DialogueResource = null:
+ set(value):
+ dialogue_resource = value
+ if dialogue_resource == null:
+ dialogue_cue = ""
+ notify_property_list_changed()
+ get:
+ return dialogue_resource
+
+## The target cue to start dialogue from.
+@export var dialogue_cue: String = ""
+
+## The dialogue balloon that was last used by calling [code]action()[/code] (if there was one).
+var dialogue_balloon: Node
+
+## The method used to start dialogue action [code]action()[/code] is called. Override if you need different logic.
+static var start_dialogue: Callable = func(with_dialogue_resource: DialogueResource, from_cue: String, extra_game_states: Array) -> Node:
+ return Engine.get_singleton("DialogueManager").show_dialogue_balloon(with_dialogue_resource, from_cue, extra_game_states)
+
+
+func _ready() -> void:
+ if not Engine.is_editor_hint():
+ add_to_group("dialogue_actionables")
+ Engine.get_singleton("DialogueManager").dialogue_ended.connect(_on_dialogue_ended)
+
+
+#region Public
+
+
+## Action this [DialogueActionable2D]. If a [DialogueResource] and cue have been set on this node then
+## it will start dialogue.
+func action() -> void:
+ if is_instance_valid(dialogue_resource) and not dialogue_cue.is_empty():
+ dialogue_balloon = start_dialogue.call(dialogue_resource, dialogue_cue, [{ actionable = self }, owner])
+ actioned.emit()
+
+
+## Find the nearest [DialogueActionable2D] to a given position.
+static func get_nearest_actionable_to(target_position: Vector2) -> DialogueActionable2D:
+ var nearest_distance: float = INF
+ var nearest_actionable: DialogueActionable2D = null
+ var actionables: Array[Node] = (Engine.get_main_loop() as SceneTree).get_nodes_in_group("dialogue_actionables")
+ for actionable: DialogueActionable2D in actionables:
+ var distance: float = actionable.global_position.distance_squared_to(target_position)
+ if distance < nearest_distance:
+ nearest_distance = distance
+ nearest_actionable = actionable
+
+ return nearest_actionable
+
+
+#endregion
+
+#region Signals
+
+
+func _on_dialogue_ended(ending_dialogue_resource: DialogueResource) -> void:
+ if ending_dialogue_resource == dialogue_resource:
+ dialogue_ended.emit()
+
+
+#endregion
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd.uid b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd.uid
new file mode 100644
index 0000000..48a142a
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.gd.uid
@@ -0,0 +1 @@
+uid://cqcur1rm7433g
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg
new file mode 100644
index 0000000..ddd9c7d
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3db802a2eb10630897cce954d6e4957099e0110932b79dd2d30db23f7e054d8a
+size 2760
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg.import b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg.import
new file mode 100644
index 0000000..b243024
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ja67jst5iqyy"
+path="res://.godot/imported/dialogue_actionable_2d.svg-fab4e58d6b3980b100fb8a5494b4d5cb.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/actionable/dialogue_actionable_2d.svg"
+dest_files=["res://.godot/imported/dialogue_actionable_2d.svg-fab4e58d6b3980b100fb8a5494b4d5cb.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/nodes/actionable/dialogue_actionable_3d.gd b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.gd
new file mode 100644
index 0000000..9e4470c
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.gd
@@ -0,0 +1,82 @@
+@tool
+
+@icon("uid://dppsuhlvbcbjc")
+
+## A special [Area3D] node to hold information about starting dialogue.
+##
+## Assuming [code]dialogue_resource[/code] and [code]cue[/code] have been configured you can
+## call [code]action()[/code] on this node at runtime to start dialogue.
+class_name DialogueActionable3D extends Area3D
+
+
+## Emitted when this [DialogueActionable3D] has [code]action()[/code] called on it.
+signal actioned()
+
+## Emitted when the [DialogueResource] associated with this [DialogueActionable2D] ends. [b]NOTE:[/b] The
+## signal is also emitted if the same resource is used for multiple [DialogueActionable2D] nodes in the tree.
+signal dialogue_ended()
+
+
+## The [DialogueResource] to use when starting dialogue.
+@export var dialogue_resource: DialogueResource = null:
+ set(value):
+ dialogue_resource = value
+ if dialogue_resource == null:
+ dialogue_cue = ""
+ notify_property_list_changed()
+ get:
+ return dialogue_resource
+
+## The target cue to start dialogue from.
+@export var dialogue_cue: String = ""
+
+## The dialogue balloon that was last used by calling [code]action()[/code] (if there was one).
+var dialogue_balloon: Node
+
+## The method used to start dialogue action [code]action()[/code] is called. Override if you need different logic.
+static var start_dialogue: Callable = func(with_dialogue_resource: DialogueResource, from_cue: String, extra_game_states: Array) -> Node:
+ return Engine.get_singleton("DialogueManager").show_dialogue_balloon(with_dialogue_resource, from_cue, extra_game_states)
+
+
+func _ready() -> void:
+ if not Engine.is_editor_hint():
+ add_to_group("dialogue_actionables")
+ Engine.get_singleton("DialogueManager").dialogue_ended.connect(_on_dialogue_ended)
+
+
+#region Public
+
+
+## Action this [DialogueActionable3D]. If a [DialogueResource] and cue have been set on this node then
+## it will start dialogue.
+func action() -> void:
+ if is_instance_valid(dialogue_resource) and not dialogue_cue.is_empty():
+ dialogue_balloon = start_dialogue.call(dialogue_resource, dialogue_cue, [{ actionable = self }, owner])
+ actioned.emit()
+
+
+## Find the nearest [DialogueActionable3D] to a given position.
+static func get_nearest_actionable_to(target_position: Vector3) -> DialogueActionable3D:
+ var nearest_distance: float = INF
+ var nearest_actionable: DialogueActionable3D = null
+ var actionables: Array[Node] = (Engine.get_main_loop() as SceneTree).get_nodes_in_group("dialogue_actionables")
+ for actionable: DialogueActionable3D in actionables:
+ var distance: float = actionable.global_position.distance_squared_to(target_position)
+ if distance < nearest_distance:
+ nearest_distance = distance
+ nearest_actionable = actionable
+
+ return nearest_actionable
+
+
+#endregion
+
+#region Signals
+
+
+func _on_dialogue_ended(ending_dialogue_resource: DialogueResource) -> void:
+ if ending_dialogue_resource == dialogue_resource:
+ dialogue_ended.emit()
+
+
+#endregion
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.gd.uid b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.gd.uid
new file mode 100644
index 0000000..3995c84
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.gd.uid
@@ -0,0 +1 @@
+uid://c5mlp6r4mts0r
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg
new file mode 100644
index 0000000..f8374a2
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e795309007560f7c78c775f1389d34897f1736bc04a4537bf6a7d22f3a98989c
+size 2719
diff --git a/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg.import b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg.import
new file mode 100644
index 0000000..9ec9fc4
--- /dev/null
+++ b/addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dppsuhlvbcbjc"
+path="res://.godot/imported/dialogue_actionable_3d.svg-66fe4924d1964b29285ae978ab731ec3.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/actionable/dialogue_actionable_3d.svg"
+dest_files=["res://.godot/imported/dialogue_actionable_3d.svg-66fe4924d1964b29285ae978ab731ec3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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=0
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=true
diff --git a/addons/dialogue_manager/nodes/context/DialogueStateContext.cs b/addons/dialogue_manager/nodes/context/DialogueStateContext.cs
new file mode 100644
index 0000000..8c10235
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/DialogueStateContext.cs
@@ -0,0 +1,79 @@
+using Godot;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// Add a to a scene to expose a node (usually the base node) to dialogue.
+ ///
+ [Tool, Icon("uid://c22t5famre1yo")]
+ public partial class DialogueStateContext : Node
+ {
+ private string alias = "";
+
+ ///
+ /// The name used in dialogue to refer to the exposed target node.
+ ///
+ [Export]
+ public string Alias
+ {
+ get => alias;
+ set
+ {
+ alias = value;
+ UpdateConfigurationWarnings();
+ }
+ }
+
+ private Node target = null;
+
+ ///
+ /// The target whose values are exposed to dialogue.
+ ///
+ [Export]
+ public Node Target
+ {
+ get => target;
+ set
+ {
+ target = value;
+ UpdateConfigurationWarnings();
+ }
+ }
+
+
+ public override void _EnterTree()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ DialogueManager.Instance.Call("register_state_context", Alias, Target);
+ }
+ }
+
+
+ public override void _ExitTree()
+ {
+ if (!Engine.IsEditorHint())
+ {
+ DialogueManager.Instance.Call("unregister_state_context", Alias);
+ }
+ }
+
+
+ public override string[] _GetConfigurationWarnings()
+ {
+ var warnings = new System.Collections.Generic.List();
+
+ if (string.IsNullOrEmpty(Alias))
+ {
+ warnings.Add("Alias cannot be empty.");
+ }
+
+ if (Target == null)
+ {
+ warnings.Add("Target cannot be null.");
+ }
+
+ return warnings.ToArray();
+ }
+ }
+}
diff --git a/addons/dialogue_manager/nodes/context/DialogueStateContext.cs.uid b/addons/dialogue_manager/nodes/context/DialogueStateContext.cs.uid
new file mode 100644
index 0000000..bd809e8
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/DialogueStateContext.cs.uid
@@ -0,0 +1 @@
+uid://b4hjd43iw4vj8
diff --git a/addons/dialogue_manager/nodes/context/dialogue_state_context.gd b/addons/dialogue_manager/nodes/context/dialogue_state_context.gd
new file mode 100644
index 0000000..1c48c25
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/dialogue_state_context.gd
@@ -0,0 +1,45 @@
+@tool
+
+@icon("uid://c22t5famre1yo")
+
+## Add a [DialogueStateContext] to a scene to expose a node (usually the base node) to dialogue.
+class_name DialogueStateContext extends Node
+
+
+## The name used in dialogue to refer to the exposed target node.
+@export var alias: String = "":
+ set(value):
+ alias = value
+ update_configuration_warnings()
+ get:
+ return alias
+
+## The target who's values are exposed to dialogue.
+@export var target: Node:
+ set(value):
+ target = value
+ update_configuration_warnings()
+ get:
+ return target
+
+
+func _enter_tree() -> void:
+ if not Engine.is_editor_hint():
+ Engine.get_singleton("DialogueManager").register_state_context(alias, target)
+
+
+func _exit_tree() -> void:
+ if not Engine.is_editor_hint():
+ Engine.get_singleton("DialogueManager").unregister_state_context(alias)
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings: PackedStringArray = []
+
+ if alias.is_empty():
+ warnings.append(DMConstants.translate("Alias cannot be empty."))
+
+ if target == null:
+ warnings.append(DMConstants.translate("Target cannot be null."))
+
+ return warnings
diff --git a/addons/dialogue_manager/nodes/context/dialogue_state_context.gd.uid b/addons/dialogue_manager/nodes/context/dialogue_state_context.gd.uid
new file mode 100644
index 0000000..dbdd0a6
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/dialogue_state_context.gd.uid
@@ -0,0 +1 @@
+uid://cd3222nx3y3k5
diff --git a/addons/dialogue_manager/nodes/context/dialogue_state_context.svg b/addons/dialogue_manager/nodes/context/dialogue_state_context.svg
new file mode 100644
index 0000000..649c1a3
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/dialogue_state_context.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c654b8a7e7b643e9705a882a750abd9ec1434d3d35c1a113b58dd7818340d4ef
+size 3415
diff --git a/addons/dialogue_manager/nodes/context/dialogue_state_context.svg.import b/addons/dialogue_manager/nodes/context/dialogue_state_context.svg.import
new file mode 100644
index 0000000..e1cf653
--- /dev/null
+++ b/addons/dialogue_manager/nodes/context/dialogue_state_context.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c22t5famre1yo"
+path="res://.godot/imported/dialogue_state_context.svg-767b17bec3f66ee3adfc460e2805a08f.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/context/dialogue_state_context.svg"
+dest_files=["res://.godot/imported/dialogue_state_context.svg-767b17bec3f66ee3adfc460e2805a08f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/nodes/dialogue_label/DialogueLabel.cs b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs
new file mode 100644
index 0000000..2ff6215
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs
@@ -0,0 +1,356 @@
+using Godot;
+using System.Globalization;
+using System.Threading.Tasks;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A RichTextLabel specifically for use with Dialogue Manager dialogue.
+ ///
+ [Tool, Icon("uid://s0s1348tpm6d")]
+ public partial class DialogueLabel : RichTextLabel
+ {
+ ///
+ /// Emitted for each letter typed out.
+ ///
+ [Signal] public delegate void SpokeEventHandler(string letter, int letterIndex, float speed);
+
+ ///
+ /// Emitted when the player skips the typing of dialogue.
+ ///
+ [Signal] public delegate void SkippedTypingEventHandler();
+
+ ///
+ /// Emitted when typing starts.
+ ///
+ [Signal] public delegate void StartedTypingEventHandler();
+
+ ///
+ /// Emitted when typing finishes.
+ ///
+ [Signal] public delegate void FinishedTypingEventHandler();
+
+
+ ///
+ /// The action to press to skip typing.
+ ///
+ [Export] public StringName SkipAction = "ui_cancel";
+
+ ///
+ /// The speed with which the text types out.
+ ///
+ [Export] public float SecondsPerStep = 0.02f;
+
+ ///
+ /// Automatically have a brief pause when these characters are encountered.
+ ///
+ [Export] public string PauseAtCharacters = ".?!";
+
+ ///
+ /// Don't auto pause if the character after the pause is one of these.
+ ///
+ [Export] public string SkipPauseAtCharacterIfFollowedBy = ")\"";
+
+ ///
+ /// Don't auto pause after these abbreviations (only if "." is in PauseAtCharacters).
+ /// Abbreviations are limited to 5 characters in length.
+ /// Does not support multi-period abbreviations (ex. "p.m.")
+ ///
+ [Export] public string[] SkipPauseAtAbbreviations = { "Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex" };
+
+ ///
+ /// The amount of time to pause when exposing a character present in PauseAtCharacters.
+ ///
+ [Export] public float SecondsPerPauseStep = 0.3f;
+
+ private readonly System.Collections.Generic.List alreadyMutatedIndices = new();
+
+
+ private DialogueLine dialogueLine = null;
+
+ ///
+ /// The current line of dialogue.
+ ///
+ public DialogueLine DialogueLine
+ {
+ get => dialogueLine;
+ set
+ {
+ if (value != dialogueLine)
+ {
+ dialogueLine = value;
+ UpdateText();
+ }
+ }
+ }
+
+ ///
+ /// Whether the label is currently typing itself out.
+ ///
+ public bool IsTyping
+ {
+ get => isTyping && !isAwaitingMutation;
+ set
+ {
+ bool didJustFinish = isTyping != value && value == false && VisibleCharacters == GetTotalCharacterCount();
+ isTyping = value;
+ if (didJustFinish)
+ {
+ EmitSignal(SignalName.FinishedTyping);
+ }
+ }
+ }
+ private bool isTyping = false;
+
+ private int lastWaitIndex = -1;
+ private int lastMutationIndex = -1;
+ private float waitingSeconds = 0;
+ private bool isAwaitingMutation = false;
+ private bool isSkippingMutations = false;
+
+
+ public override void _Process(double delta)
+ {
+ if (isTyping)
+ {
+ // Type out text
+ if (VisibleRatio < 1)
+ {
+ // See if we are waiting
+ if (waitingSeconds > 0)
+ {
+ waitingSeconds -= (float)delta;
+ }
+ // If we are no longer waiting then keep typing
+ if (waitingSeconds <= 0)
+ {
+ TypeNext((float)delta, waitingSeconds);
+ }
+ }
+ else
+ {
+ // Make sure any mutations at the end of the line get run
+ _ = MutateInlineMutations(GetTotalCharacterCount());
+ IsTyping = false;
+ }
+ }
+ }
+
+
+ ///
+ /// Sets the label's text from the current dialogue line. Override if you want
+ /// to do something more interesting in your subclass.
+ ///
+ protected virtual void UpdateText()
+ {
+ if (IsInstanceValid(dialogueLine))
+ {
+ Text = dialogueLine.Text;
+ }
+ else
+ {
+ Text = "";
+ }
+ }
+
+
+ ///
+ /// Start typing out the text.
+ ///
+ public async void TypeOut()
+ {
+ UpdateText();
+ VisibleCharacters = 0;
+ VisibleRatio = 0;
+ waitingSeconds = 0;
+ lastWaitIndex = -1;
+ lastMutationIndex = -1;
+ alreadyMutatedIndices.Clear();
+
+ IsTyping = true;
+ EmitSignal(SignalName.StartedTyping);
+
+ // Allow typing listeners a chance to connect
+ await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
+
+ if (GetTotalCharacterCount() == 0)
+ {
+ IsTyping = false;
+ }
+ else if (SecondsPerStep == 0)
+ {
+ MutateRemainingMutations();
+ VisibleCharacters = GetTotalCharacterCount();
+ IsTyping = false;
+ }
+ }
+
+
+ ///
+ /// Stop typing out the text and jump right to the end.
+ ///
+ public void SkipTyping()
+ {
+ MutateRemainingMutations();
+ VisibleCharacters = GetTotalCharacterCount();
+ IsTyping = false;
+ EmitSignal(SignalName.SkippedTyping);
+ }
+
+
+ // Type out the next character(s)
+ private void TypeNext(float delta, float secondsNeeded)
+ {
+ if (isAwaitingMutation) return;
+
+ if (VisibleCharacters == GetTotalCharacterCount())
+ {
+ return;
+ }
+
+ if (lastMutationIndex != VisibleCharacters)
+ {
+ lastMutationIndex = VisibleCharacters;
+ _ = MutateInlineMutations(VisibleCharacters);
+ if (isAwaitingMutation) return;
+ }
+
+ // Pause on characters like "."
+ float pauseWaitingSeconds = ShouldAutoPause() ? SecondsPerPauseStep : 0f;
+ if (lastWaitIndex != VisibleCharacters && pauseWaitingSeconds > 0)
+ {
+ lastWaitIndex = VisibleCharacters;
+ waitingSeconds += pauseWaitingSeconds;
+ }
+ else
+ {
+ VisibleCharacters += 1;
+ if (VisibleCharacters <= GetTotalCharacterCount())
+ {
+ EmitSignal(SignalName.Spoke, GetParsedText()[VisibleCharacters - 1].ToString(), VisibleCharacters - 1, GetSpeed(VisibleCharacters));
+ }
+ // See if there's time to type out some more in this frame
+ secondsNeeded += SecondsPerStep * (1.0f / GetSpeed(VisibleCharacters));
+ if (secondsNeeded > delta)
+ {
+ waitingSeconds += secondsNeeded;
+ }
+ else
+ {
+ TypeNext(delta, secondsNeeded);
+ }
+ }
+ }
+
+
+ // Get the speed for the current typing position
+ private float GetSpeed(int atIndex)
+ {
+ float speed = 1;
+ foreach (var speedEntry in dialogueLine.Speeds)
+ {
+ if ((int)speedEntry.Key > atIndex)
+ {
+ return speed;
+ }
+ speed = (float)speedEntry.Value;
+ }
+ return speed;
+ }
+
+
+ // Run any inline mutations that haven't been run yet
+ private void MutateRemainingMutations()
+ {
+ isSkippingMutations = true;
+ for (int i = VisibleCharacters; i < GetTotalCharacterCount() + 1; i++)
+ {
+ _ = MutateInlineMutations(i);
+ }
+ isSkippingMutations = false;
+ }
+
+
+ // Run any mutations at the current typing position
+ private async Task MutateInlineMutations(int index)
+ {
+ if (!IsInstanceValid(dialogueLine)) return;
+
+ foreach (Godot.Collections.Array inlineMutation in dialogueLine.InlineMutations)
+ {
+ // Inline mutations are an array of arrays in the form of [character index, resolvable function]
+ if ((int)inlineMutation[0] > index)
+ {
+ return;
+ }
+ if ((int)inlineMutation[0] == index && !alreadyMutatedIndices.Contains(index))
+ {
+ if (isSkippingMutations)
+ {
+ _ = DialogueManager.Mutate(inlineMutation[1].AsGodotDictionary(), dialogueLine.ExtraGameStates, true);
+ }
+ else
+ {
+ isAwaitingMutation = true;
+ await DialogueManager.Mutate(inlineMutation[1].AsGodotDictionary(), dialogueLine.ExtraGameStates, true);
+ isAwaitingMutation = false;
+ }
+ }
+ }
+
+ alreadyMutatedIndices.Add(index);
+ }
+
+
+ // Determine if the current autopause character at the cursor should qualify to pause typing.
+ private bool ShouldAutoPause()
+ {
+ if (VisibleCharacters == 0) return false;
+
+ string parsedText = GetParsedText();
+
+ // Avoid out-of-bounds 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 (VisibleCharacters >= parsedText.Length) return false;
+
+ // Ignore pause characters if they are next to a non-pause character
+ if (SkipPauseAtCharacterIfFollowedBy.Contains(parsedText[VisibleCharacters]))
+ {
+ return false;
+ }
+
+ // Ignore "." if it's between two numbers
+ if (VisibleCharacters > 3 && parsedText[VisibleCharacters - 1] == '.')
+ {
+ string possibleNumber = parsedText.Substring(VisibleCharacters - 2, 3);
+ if (float.TryParse(possibleNumber, NumberStyles.Float, CultureInfo.InvariantCulture, out float number) && number.ToString("0.0", CultureInfo.InvariantCulture) == possibleNumber)
+ {
+ return false;
+ }
+ }
+
+ // Ignore "." if it's used in an abbreviation
+ // Note: does NOT support multi-period abbreviations (ex. p.m.)
+ if (PauseAtCharacters.Contains('.') && parsedText[VisibleCharacters - 1] == '.')
+ {
+ foreach (string abbreviation in SkipPauseAtAbbreviations)
+ {
+ int start = VisibleCharacters - abbreviation.Length - 1;
+ if (VisibleCharacters >= abbreviation.Length && start >= 0 && parsedText.Substring(start, abbreviation.Length) == abbreviation)
+ {
+ return false;
+ }
+ }
+ }
+
+ // Ignore two non-"." characters next to each other
+ string otherPauseCharacters = PauseAtCharacters.Replace(".", "");
+ if (VisibleCharacters > 1 && otherPauseCharacters.Contains(parsedText[VisibleCharacters - 1]) && otherPauseCharacters.Contains(parsedText[VisibleCharacters]))
+ {
+ return false;
+ }
+
+ return PauseAtCharacters.Contains(parsedText[VisibleCharacters - 1]);
+ }
+ }
+}
diff --git a/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs.uid b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs.uid
new file mode 100644
index 0000000..010ea8f
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs.uid
@@ -0,0 +1 @@
+uid://b8beyexrpj6n7
diff --git a/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.tscn b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.tscn
new file mode 100644
index 0000000..23653a8
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.tscn
@@ -0,0 +1,19 @@
+[gd_scene format=3 uid="uid://c3qmxhslp0dl7"]
+
+[ext_resource type="Script" uid="uid://b8beyexrpj6n7" path="res://addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs" 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")
+SkipPauseAtAbbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")
diff --git a/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd
new file mode 100644
index 0000000..c14c5cd
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd
@@ -0,0 +1,228 @@
+@icon("uid://s0s1348tpm6d")
+
+@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 the player skips the typing of dialogue.
+signal skipped_typing()
+
+## Emitted when typing starts
+signal started_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: Variant:
+ set(value):
+ if value != dialogue_line:
+ dialogue_line = value
+ _update_text()
+ get:
+ return dialogue_line
+
+## Whether the label is currently typing itself out.
+var is_typing: bool = false:
+ set(value):
+ var did_just_finish: bool = _is_typing != value and value == false and visible_characters == get_total_character_count()
+ _is_typing = value
+ if did_just_finish:
+ finished_typing.emit()
+ get:
+ return _is_typing and not _is_awaiting_mutation
+var _is_typing: bool = false
+
+var _last_wait_index: int = -1
+var _last_mutation_index: int = -1
+var _waiting_seconds: float = 0
+var _is_awaiting_mutation: bool = false
+var _is_skipping_mutations: bool = false
+
+
+func _process(delta: float) -> void:
+ if _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())
+ is_typing = false
+
+
+## Sets the label's text from the current dialogue line. Override if you want
+## to do something more interesting in your subclass.
+func _update_text() -> void:
+ if is_instance_valid(dialogue_line):
+ text = dialogue_line.text
+ else:
+ text = ""
+
+
+## Start typing out the text
+func type_out() -> void:
+ _update_text()
+ visible_characters = 0
+ visible_ratio = 0
+ _waiting_seconds = 0
+ _last_wait_index = -1
+ _last_mutation_index = -1
+ _already_mutated_indices.clear()
+
+ is_typing = true
+ started_typing.emit()
+
+ # Allow typing listeners a chance to connect
+ await get_tree().process_frame
+
+ if get_total_character_count() == 0:
+ is_typing = false
+ elif seconds_per_step == 0:
+ _mutate_remaining_mutations()
+ visible_characters = get_total_character_count()
+ 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()
+ 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
+
+ # Pause on characters like "."
+ var waiting_seconds: float = seconds_per_pause_step if _should_auto_pause() else 0.0
+ if _last_wait_index != visible_characters and waiting_seconds > 0:
+ _last_wait_index = visible_characters
+ _waiting_seconds += waiting_seconds
+ 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 speed for the current typing position
+func _get_speed(at_index: int) -> float:
+ var speed: float = 1
+ for index: int 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:
+ _is_skipping_mutations = true
+ for i: int in range(visible_characters, get_total_character_count() + 1):
+ _mutate_inline_mutations(i)
+ _is_skipping_mutations = false
+
+
+# Run any mutations at the current typing position
+func _mutate_inline_mutations(index: int) -> void:
+ for inline_mutation: Array 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):
+ if _is_skipping_mutations:
+ Engine.get_singleton("DialogueManager")._mutate(inline_mutation[1], dialogue_line.extra_game_states, true)
+ else:
+ _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
+
+ _already_mutated_indices.append(index)
+
+
+# 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)).pad_decimals(1) == 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: String 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/nodes/dialogue_label/dialogue_label.gd.uid b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd.uid
new file mode 100644
index 0000000..6bf86b1
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd.uid
@@ -0,0 +1 @@
+uid://g32um0mltv5d
diff --git a/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg
new file mode 100644
index 0000000..1647c57
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f83d89f928194677bad7822eed96a6f43d6869b63801fb756328b5bb3c98c30e
+size 1949
diff --git a/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg.import b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg.import
new file mode 100644
index 0000000..d0c47f4
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://s0s1348tpm6d"
+path="res://.godot/imported/dialogue_label.svg-d4dcce28fb3dfdb414d1a2c4579fb6a5.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg"
+dest_files=["res://.godot/imported/dialogue_label.svg-d4dcce28fb3dfdb414d1a2c4579fb6a5.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/nodes/dialogue_label/dialogue_label.tscn b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.tscn
new file mode 100644
index 0000000..3884ce6
--- /dev/null
+++ b/addons/dialogue_manager/nodes/dialogue_label/dialogue_label.tscn
@@ -0,0 +1,19 @@
+[gd_scene format=3 uid="uid://ckvgyvclnwggo"]
+
+[ext_resource type="Script" uid="uid://g32um0mltv5d" path="res://addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd" id="1_cital"]
+
+[node name="DialogueLabel" type="RichTextLabel" unique_id=1840273907]
+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/nodes/marker/DialogueMarker2D.cs b/addons/dialogue_manager/nodes/marker/DialogueMarker2D.cs
new file mode 100644
index 0000000..75bcf8d
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/DialogueMarker2D.cs
@@ -0,0 +1,79 @@
+using Godot;
+using Godot.Collections;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A special marker node that helps locate the in-world representation of the speaking character.
+ /// Generally, you would position a at the mouth of the character and use
+ /// its location as the origin of a speech balloon.
+ ///
+ [Icon("uid://cim0y62o6g36i")]
+ public partial class DialogueMarker2D : Marker2D
+ {
+ ///
+ /// The name of the character that owns this marker.
+ ///
+ [Export] public string CharacterName = "";
+
+
+ #region Static
+
+
+ ///
+ /// Get all nodes.
+ ///
+ public static Array All()
+ {
+ var markers = new Array();
+ foreach (Node node in ((SceneTree)Engine.GetMainLoop()).GetNodesInGroup("2d_dialogue_markers"))
+ {
+ if (node is DialogueMarker2D marker)
+ {
+ markers.Add(marker);
+ }
+ }
+ return markers;
+ }
+
+
+ ///
+ /// Find a marker with a given character name.
+ ///
+ public static DialogueMarker2D FindForCharacter(string targetCharacterName)
+ {
+ foreach (DialogueMarker2D marker in All())
+ {
+ if (marker.CharacterName == targetCharacterName)
+ {
+ return marker;
+ }
+ }
+ return null;
+ }
+
+
+ #endregion
+
+
+ public override void _Ready()
+ {
+ AddToGroup("2d_dialogue_markers");
+ }
+
+
+ #region Helpers
+
+
+ ///
+ /// Get the marker's position relative to the viewport.
+ ///
+ public Vector2 GetPositionInViewport()
+ {
+ return GetGlobalTransformWithCanvas().Origin;
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/nodes/marker/DialogueMarker2D.cs.uid b/addons/dialogue_manager/nodes/marker/DialogueMarker2D.cs.uid
new file mode 100644
index 0000000..0381adc
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/DialogueMarker2D.cs.uid
@@ -0,0 +1 @@
+uid://dnftkt8d8dj8i
diff --git a/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs b/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs
new file mode 100644
index 0000000..ea24029
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs
@@ -0,0 +1,87 @@
+using Godot;
+using Godot.Collections;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A special marker node that helps locate the in-world representation of the speaking character.
+ /// Generally, you would position a at the mouth of the character and use
+ /// its location as the origin of a speech balloon.
+ ///
+ [Icon("uid://bo1hdyjmnjdql")]
+ public partial class DialogueMarker3D : Marker3D
+ {
+ ///
+ /// The name of the character that owns this marker.
+ ///
+ [Export] public string CharacterName = "";
+
+
+ #region Static
+
+
+ ///
+ /// Get all nodes.
+ ///
+ public static Array All()
+ {
+ var markers = new Array();
+ foreach (Node node in ((SceneTree)Engine.GetMainLoop()).GetNodesInGroup("3d_dialogue_markers"))
+ {
+ if (node is DialogueMarker3D marker)
+ {
+ markers.Add(marker);
+ }
+ }
+ return markers;
+ }
+
+
+ ///
+ /// Find a marker with a given character name.
+ ///
+ public static DialogueMarker3D FindForCharacter(string targetCharacterName)
+ {
+ foreach (DialogueMarker3D marker in All())
+ {
+ if (marker.CharacterName == targetCharacterName)
+ {
+ return marker;
+ }
+ }
+ return null;
+ }
+
+
+ #endregion
+
+
+ public override void _Ready()
+ {
+ AddToGroup("3d_dialogue_markers");
+ }
+
+
+ #region Helpers
+
+
+ ///
+ /// Get the marker's position relative to the viewport.
+ ///
+ public Vector2 GetPositionInViewport()
+ {
+ Camera3D camera = GetViewport().GetCamera3D();
+ if (IsInstanceValid(camera))
+ {
+ return camera.UnprojectPosition(GlobalPosition);
+ }
+ else
+ {
+ return Vector2.Zero;
+ }
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs.uid b/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs.uid
new file mode 100644
index 0000000..e6c4e4b
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/DialogueMarker3D.cs.uid
@@ -0,0 +1 @@
+uid://b3hrgs61lvn5x
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd
new file mode 100644
index 0000000..6b9efb9
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd
@@ -0,0 +1,46 @@
+@icon("uid://cim0y62o6g36i")
+
+## A special marker node that helps locate the in-world representation of the speaking character.
+## Generally, you would position a [DialogueMaker2D] at the mouth of the character and use
+## its location as the origin of a speech balloon.
+class_name DialogueMarker2D extends Marker2D
+
+
+## The name of the character that owns this marker.
+@export var character_name: String = ""
+
+
+#region Static
+
+## Get all [DialogueMarker2D] nodes.
+static func all() -> Array[DialogueMarker2D]:
+ var markers: Array[DialogueMarker2D] = []
+ for node: DialogueMarker2D in (Engine.get_main_loop() as SceneTree).get_nodes_in_group("2d_dialogue_markers"):
+ markers.append(node)
+ return markers
+
+
+## Find a marker with a given character name.
+static func find_for_character(target_character_name: String) -> DialogueMarker2D:
+ for marker: DialogueMarker2D in all():
+ if marker.character_name == target_character_name:
+ return marker
+ return null
+
+
+#endregion
+
+
+func _ready() -> void:
+ add_to_group("2d_dialogue_markers")
+
+
+#region Helpers
+
+
+## Get the marker's position relative to the viewport.
+func get_position_in_viewport() -> Vector2:
+ return get_global_transform_with_canvas().origin
+
+
+#endregion
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd.uid b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd.uid
new file mode 100644
index 0000000..edaa21a
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.gd.uid
@@ -0,0 +1 @@
+uid://cxge1wx70prau
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg
new file mode 100644
index 0000000..45c0c8a
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5448dcf2d9c3732df0830c3f12f57b75a0201a2e9033c849d6282c638230d2bc
+size 1161
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg.import b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg.import
new file mode 100644
index 0000000..1404599
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cim0y62o6g36i"
+path="res://.godot/imported/dialogue_marker_2d.svg-1eedb01d2a05819be5ce0e675c93eeab.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/marker/dialogue_marker_2d.svg"
+dest_files=["res://.godot/imported/dialogue_marker_2d.svg-1eedb01d2a05819be5ce0e675c93eeab.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/nodes/marker/dialogue_marker_3d.gd b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.gd
new file mode 100644
index 0000000..1261f89
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.gd
@@ -0,0 +1,50 @@
+@icon("uid://bo1hdyjmnjdql")
+
+## A special marker node that helps locate the in-world representation of the speaking character.
+## Generally, you would position a [DialogueMaker3D] at the mouth of the character and use
+## its location as the origin of a speech balloon.
+class_name DialogueMarker3D extends Marker3D
+
+
+## The name of the character that owns this marker.
+@export var character_name: String = ""
+
+
+#region Static
+
+## Get all [DialogueMarker3D] nodes.
+static func all() -> Array[DialogueMarker3D]:
+ var markers: Array[DialogueMarker3D] = []
+ for node: DialogueMarker3D in (Engine.get_main_loop() as SceneTree).get_nodes_in_group("3d_dialogue_markers"):
+ markers.append(node)
+ return markers
+
+
+## Find a marker with a given character name.
+static func find_for_character(target_character_name: String) -> DialogueMarker3D:
+ for marker: DialogueMarker3D in all():
+ if marker.character_name == target_character_name:
+ return marker
+ return null
+
+
+#endregion
+
+
+func _ready() -> void:
+ add_to_group("3d_dialogue_markers")
+
+
+#region Helpers
+
+
+## Get the marker's position relative to the viewport.
+func get_position_in_viewport() -> Vector2:
+ var camera: Camera3D = get_viewport().get_camera_3d()
+ if is_instance_valid(camera):
+ return camera.unproject_position(global_position)
+ else:
+ return Vector2.ZERO
+
+
+#endregion
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.gd.uid b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.gd.uid
new file mode 100644
index 0000000..1e48b75
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.gd.uid
@@ -0,0 +1 @@
+uid://bb636aic4yoo6
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg
new file mode 100644
index 0000000..bc1ed42
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eab22636c7f6c4c721d4c8d063b80e3b78cef6d50658b6188ba1f16ecf81812b
+size 1104
diff --git a/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg.import b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg.import
new file mode 100644
index 0000000..0592490
--- /dev/null
+++ b/addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bo1hdyjmnjdql"
+path="res://.godot/imported/dialogue_marker_3d.svg-f68ab9ede59096854a7c6ee123dd8e4e.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/marker/dialogue_marker_3d.svg"
+dest_files=["res://.godot/imported/dialogue_marker_3d.svg-f68ab9ede59096854a7c6ee123dd8e4e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/nodes/responses_menu/DialogueResponsesMenu.cs b/addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs
new file mode 100644
index 0000000..2408c65
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs
@@ -0,0 +1,303 @@
+using Godot;
+using Godot.Collections;
+
+namespace DialogueManagerRuntime
+{
+ ///
+ /// A for dialogue responses provided by Dialogue Manager.
+ ///
+ public partial class DialogueResponsesMenu : Container
+ {
+ ///
+ /// Emitted when a response is focused.
+ ///
+ [Signal] public delegate void ResponseFocusedEventHandler(Variant response);
+
+ ///
+ /// Emitted when a response is selected.
+ ///
+ [Signal] public delegate void ResponseSelectedEventHandler(DialogueResponse response);
+
+
+ ///
+ /// Optionally specify a control to duplicate for each response.
+ ///
+ [Export] public Control ResponseTemplate;
+
+ ///
+ /// The action for accepting a response (is possibly overridden by parent dialogue balloon).
+ ///
+ [Export] public StringName NextAction = "";
+
+ ///
+ /// Automatically set up focus neighbours when the responses list changes.
+ ///
+ [Export] public bool AutoConfigureFocus = true;
+
+ ///
+ /// Automatically focus the first item when showing.
+ ///
+ [Export] public bool AutoFocusFirstItem = true;
+
+ ///
+ /// Hide any responses where IsAllowed is false.
+ ///
+ [Export] public bool HideFailedResponses = false;
+
+ private Godot.Collections.Array responses = new();
+
+ ///
+ /// The list of dialogue responses.
+ ///
+ public Godot.Collections.Array Responses
+ {
+ get => responses;
+ set
+ {
+ responses = value;
+ ApplyResponses();
+ }
+ }
+
+ // The previously focused item in this menu.
+ private Control previouslyFocusedItem = null;
+
+
+ public override void _Ready()
+ {
+ VisibilityChanged += () =>
+ {
+ if (AutoFocusFirstItem && Visible && GetMenuItems().Count > 0)
+ {
+ Control firstItem = GetMenuItems()[0];
+ if (firstItem.IsInsideTree())
+ {
+ firstItem.GrabFocus();
+ }
+ }
+ };
+
+ if (IsInstanceValid(ResponseTemplate))
+ {
+ ResponseTemplate.Hide();
+ }
+
+ GetViewport().GuiFocusChanged += OnFocusChanged;
+ }
+
+
+ public override void _ExitTree()
+ {
+ GetViewport().GuiFocusChanged -= OnFocusChanged;
+ }
+
+
+ ///
+ /// Get the selectable items in the menu.
+ ///
+ public Array GetMenuItems()
+ {
+ var items = new Array();
+ foreach (Node child in GetChildren())
+ {
+ if (child is not Control control) continue;
+ if (!control.Visible) continue;
+ if (control.Name.ToString().Contains("Disallowed")) continue;
+ items.Add(control);
+ }
+
+ return items;
+ }
+
+
+ ///
+ /// Prepare the menu for keyboard and mouse navigation.
+ ///
+ public void ConfigureFocus()
+ {
+ Array items = GetMenuItems();
+ for (int i = 0; i < items.Count; i++)
+ {
+ Control item = items[i];
+
+ item.FocusMode = FocusModeEnum.All;
+
+ item.FocusNeighborLeft = item.GetPath();
+ item.FocusNeighborRight = item.GetPath();
+
+ if (i == 0)
+ {
+ item.FocusNeighborTop = item.GetPath();
+ item.FocusNeighborLeft = item.GetPath();
+ item.FocusPrevious = item.GetPath();
+ }
+ else
+ {
+ item.FocusNeighborTop = items[i - 1].GetPath();
+ item.FocusNeighborLeft = items[i - 1].GetPath();
+ item.FocusPrevious = items[i - 1].GetPath();
+ }
+
+ if (i == items.Count - 1)
+ {
+ item.FocusNeighborBottom = item.GetPath();
+ item.FocusNeighborRight = item.GetPath();
+ item.FocusNext = item.GetPath();
+ }
+ else
+ {
+ item.FocusNeighborBottom = items[i + 1].GetPath();
+ item.FocusNeighborRight = items[i + 1].GetPath();
+ item.FocusNext = items[i + 1].GetPath();
+ }
+
+ DialogueResponse response = (DialogueResponse)item.GetMeta("response");
+ item.MouseEntered += () => OnResponseMouseEntered(item);
+ item.GuiInput += (@event) => OnResponseGuiInput(@event, item, response);
+ }
+
+ if (items.Count == 0) return;
+
+ previouslyFocusedItem = items[0];
+
+ if (AutoFocusFirstItem)
+ {
+ items[0].GrabFocus();
+ }
+ }
+
+
+ #region Internal
+
+
+ // Set up the visual side of things.
+ private void ApplyResponses()
+ {
+ // Remove any current items
+ foreach (Node item in GetChildren())
+ {
+ if (item == ResponseTemplate) continue;
+
+ RemoveChild(item);
+ item.QueueFree();
+ }
+
+ // Add new items
+ if (responses.Count > 0)
+ {
+ foreach (DialogueResponse response in responses)
+ {
+ // The response might be tagged with "show_if"
+ if (HideFailedResponses && !response.IsAllowed && response.GetTagValue("show_if") != "true")
+ {
+ continue;
+ }
+
+ if (response.GetTagValue("hide_if") == "true")
+ {
+ continue;
+ }
+
+ Control item;
+ if (IsInstanceValid(ResponseTemplate))
+ {
+ item = (Control)ResponseTemplate.Duplicate((int)(DuplicateFlags.Groups | DuplicateFlags.Scripts | DuplicateFlags.Signals));
+ item.Show();
+ }
+ else
+ {
+ item = new Button();
+ }
+ item.Name = $"Response{GetChildCount()}";
+ if (!response.IsAllowed)
+ {
+ item.Name = $"{item.Name}Disallowed";
+ item.Set("disabled", true);
+ }
+
+ // If the item has a response property then use that
+ string responsePropertyName = GetResponsePropertyName(item);
+ if (responsePropertyName != null)
+ {
+ item.Set(responsePropertyName, response);
+ }
+ // Otherwise assume we can just set the text
+ else
+ {
+ item.Set("text", response.Text);
+ }
+
+ item.SetMeta("response", response);
+
+ AddChild(item);
+ }
+
+ if (AutoConfigureFocus)
+ {
+ ConfigureFocus();
+ }
+ }
+ }
+
+
+ // Find the name of the item's "response" property (if it has one).
+ private static string GetResponsePropertyName(Control item)
+ {
+ foreach (Dictionary property in item.GetPropertyList())
+ {
+ string name = (string)property["name"];
+ if (name == "response" || name == "Response")
+ {
+ return name;
+ }
+ }
+ return null;
+ }
+
+
+ #endregion
+
+ #region Signals
+
+
+ private void OnFocusChanged(Control control)
+ {
+ if (control.Name.ToString().Contains("Disallowed")) return;
+ if (!GetMenuItems().Contains(control)) return;
+
+ if (previouslyFocusedItem != control)
+ {
+ previouslyFocusedItem = control;
+ EmitSignal(SignalName.ResponseFocused, control);
+ }
+ }
+
+
+ private void OnResponseMouseEntered(Control item)
+ {
+ if (item.Name.ToString().Contains("Disallowed")) return;
+
+ item.GrabFocus();
+ }
+
+
+ private void OnResponseGuiInput(InputEvent @event, Control item, DialogueResponse response)
+ {
+ if (item.Name.ToString().Contains("Disallowed")) return;
+
+ if (@event is InputEventMouseButton mouseButtonEvent && mouseButtonEvent.IsPressed() && mouseButtonEvent.ButtonIndex == MouseButton.Left)
+ {
+ GetViewport().SetInputAsHandled();
+ EmitSignal(SignalName.ResponseSelected, response);
+ }
+ else if (@event.IsActionPressed(NextAction.IsEmpty ? "ui_accept" : NextAction) && GetMenuItems().Contains(item))
+ {
+ GetViewport().SetInputAsHandled();
+ EmitSignal(SignalName.ResponseSelected, response);
+ }
+ }
+
+
+ #endregion
+ }
+}
diff --git a/addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs.uid b/addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs.uid
new file mode 100644
index 0000000..0faa5e6
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/DialogueResponsesMenu.cs.uid
@@ -0,0 +1 @@
+uid://bf0gf4upyxj5l
diff --git a/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd
new file mode 100644
index 0000000..d4425a3
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd
@@ -0,0 +1,184 @@
+@icon("uid://drjfciwitjm83")
+
+## A [Container] for dialogue responses provided by [b]Dialogue Manager[/b].
+class_name DialogueResponsesMenu extends Container
+
+
+## Emitted when a response is focused.
+signal response_focused(response: Variant)
+
+## Emitted when a response is selected.
+signal response_selected(response: Variant)
+
+
+## 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 = &""
+
+## Automatically set up focus neighbours when the responses list changes.
+@export var auto_configure_focus: bool = true
+
+## Automatically focus the first item when showing.
+@export var auto_focus_first_item: bool = true
+
+## Hide any responses where [code]is_allowed[/code] is false
+@export var hide_failed_responses: bool = false
+
+## The list of dialogue responses.
+var responses: Array = []:
+ set(value):
+ responses = value
+ _apply_responses()
+ get:
+ return responses
+
+# The previously focused item in this menu.
+var _previously_focused_item: Control = null
+
+
+func _ready() -> void:
+ visibility_changed.connect(func() -> void:
+ if auto_focus_first_item and visible and get_menu_items().size() > 0:
+ var first_item: Control = get_menu_items()[0]
+ if first_item.is_inside_tree():
+ first_item.grab_focus()
+ )
+
+ if is_instance_valid(response_template):
+ response_template.hide()
+
+ get_viewport().gui_focus_changed.connect(_on_focus_changed)
+
+
+## Get the selectable items in the menu.
+func get_menu_items() -> Array:
+ var items: Array = []
+ for child: Node in get_children():
+ if not child.visible: continue
+ if "Disallowed" in child.name: continue
+ items.append(child)
+
+ return items
+
+
+## Prepare the menu for keyboard and mouse navigation.
+func configure_focus() -> void:
+ var items: Array = get_menu_items()
+ for i: int 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_neighbor_left = item.get_path()
+ item.focus_previous = item.get_path()
+ else:
+ item.focus_neighbor_top = items[i - 1].get_path()
+ item.focus_neighbor_left = 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_neighbor_right = item.get_path()
+ item.focus_next = item.get_path()
+ else:
+ item.focus_neighbor_bottom = items[i + 1].get_path()
+ item.focus_neighbor_right = 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")))
+
+ _previously_focused_item = items[0]
+
+ if auto_focus_first_item:
+ items[0].grab_focus()
+
+
+#region Internal
+
+
+# Set up the visual side of things.
+func _apply_responses() -> void:
+ # Remove any current items
+ for item: Node in get_children():
+ if item == response_template: continue
+
+ remove_child(item)
+ item.queue_free()
+
+ # Add new items
+ if responses.size() > 0:
+ for response: Variant in responses:
+ if hide_failed_responses and not response.is_allowed \
+ # The response might be tagged with "show_if"
+ and (not response.has_method("get_tag_value") or response.get_tag_value("show_if") != "true"):
+ continue
+
+ if response.has_method("get_tag_value") and response.get_tag_value("hide_if") == "true":
+ continue
+
+ 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 = 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)
+
+ if auto_configure_focus:
+ configure_focus()
+
+
+#endregion
+
+#region Signals
+
+
+func _on_focus_changed(control: Control) -> void:
+ if "Disallowed" in control.name: return
+ if not control in get_menu_items(): return
+
+ if _previously_focused_item != control:
+ _previously_focused_item = control
+ response_focused.emit(control)
+
+
+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: Variant) -> void:
+ if "Disallowed" in item.name: return
+
+ if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT:
+ get_viewport().set_input_as_handled()
+ 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():
+ get_viewport().set_input_as_handled()
+ response_selected.emit(response)
+
+
+#endregion
diff --git a/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd.uid b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd.uid
new file mode 100644
index 0000000..0ae73d9
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.gd.uid
@@ -0,0 +1 @@
+uid://bb52rsfwhkxbn
diff --git a/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg
new file mode 100644
index 0000000..ab63e4b
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cd748f34361dfdede421a4d058fd916fd6541f860181b1740adade0bad57ef16
+size 1255
diff --git a/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg.import b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg.import
new file mode 100644
index 0000000..3db0c2e
--- /dev/null
+++ b/addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg.import
@@ -0,0 +1,44 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://drjfciwitjm83"
+path="res://.godot/imported/dialogue_responses_menu.svg-c2f1aeac68c19756dc5732e31dba2439.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/dialogue_manager/nodes/responses_menu/dialogue_responses_menu.svg"
+dest_files=["res://.godot/imported/dialogue_responses_menu.svg-c2f1aeac68c19756dc5732e31dba2439.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+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/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+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/plugin.cfg b/addons/dialogue_manager/plugin.cfg
new file mode 100644
index 0000000..5f4a57b
--- /dev/null
+++ b/addons/dialogue_manager/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Dialogue Manager"
+description="A powerful branching dialogue system"
+author="Nathan Hoad"
+version="4.0.3"
+script="plugin.gd"
diff --git a/addons/dialogue_manager/plugin.cfg.uid b/addons/dialogue_manager/plugin.cfg.uid
new file mode 100644
index 0000000..312d0cf
--- /dev/null
+++ b/addons/dialogue_manager/plugin.cfg.uid
@@ -0,0 +1 @@
+uid://hrny2utekhei
diff --git a/addons/dialogue_manager/plugin.gd b/addons/dialogue_manager/plugin.gd
new file mode 100644
index 0000000..bee04a5
--- /dev/null
+++ b/addons/dialogue_manager/plugin.gd
@@ -0,0 +1,508 @@
+@tool
+class_name DMPlugin extends EditorPlugin
+
+
+@warning_ignore("unused_signal")
+signal cache_file_content_changed(path: String, new_content: String)
+
+
+const MainView: PackedScene = preload("./views/main_view.tscn")
+const FindInDialogueView: PackedScene = preload("./views/find_in_dialogue_view.tscn")
+
+
+static var instance: DMPlugin
+
+var import_plugin: DMImportPlugin
+var export_plugin: DMExportPlugin
+var inspector_plugin: DMInspectorPlugin
+var translation_parser_plugin: DMTranslationParserPlugin
+var debugger_plugin: DMDebuggerPlugin
+var main_view: Control
+var find_in_dialogue_view: Control
+var preview_generator: DMPreviewGenerator
+
+
+func _init() -> void:
+ instance = self
+
+
+func _enable_plugin() -> void:
+ add_autoload_singleton("DialogueManager", get_plugin_path() + "/dialogue_manager.gd")
+
+
+func _disable_plugin() -> void:
+ remove_autoload_singleton("DialogueManager")
+
+
+func _enter_tree() -> void:
+ if Engine.is_editor_hint():
+ DMSettings.prepare()
+ DMCache.prepare()
+
+ import_plugin = DMImportPlugin.new()
+ add_import_plugin(import_plugin)
+
+ export_plugin = DMExportPlugin.new()
+ add_export_plugin(export_plugin)
+
+ inspector_plugin = DMInspectorPlugin.new()
+ add_inspector_plugin(inspector_plugin)
+
+ translation_parser_plugin = DMTranslationParserPlugin.new()
+ add_translation_parser_plugin(translation_parser_plugin)
+
+ debugger_plugin = DMDebuggerPlugin.new()
+ add_debugger_plugin(debugger_plugin)
+
+ preview_generator = DMPreviewGenerator.new()
+ EditorInterface.get_resource_previewer().add_preview_generator(preview_generator)
+
+ main_view = MainView.instantiate()
+ EditorInterface.get_editor_main_screen().add_child(main_view)
+ _make_visible(false)
+
+ _update_localization()
+
+ EditorInterface.get_file_system_dock().files_moved.connect(_on_files_moved)
+ EditorInterface.get_file_system_dock().file_removed.connect(_on_file_removed)
+
+ add_tool_submenu_item("Dialogue", _create_translations_tool_menu_item())
+
+
+func _exit_tree() -> void:
+ remove_import_plugin(import_plugin)
+ import_plugin = null
+
+ remove_export_plugin(export_plugin)
+ export_plugin = null
+
+ remove_inspector_plugin(inspector_plugin)
+ inspector_plugin = null
+
+ remove_translation_parser_plugin(translation_parser_plugin)
+ translation_parser_plugin = null
+
+ remove_debugger_plugin(debugger_plugin)
+ debugger_plugin = null
+
+ EditorInterface.get_resource_previewer().remove_preview_generator(preview_generator)
+ preview_generator = null
+
+ if is_instance_valid(main_view):
+ main_view.queue_free()
+
+ _hide_find_in_dialogue()
+
+ EditorInterface.get_file_system_dock().files_moved.disconnect(_on_files_moved)
+ EditorInterface.get_file_system_dock().file_removed.disconnect(_on_file_removed)
+
+ remove_tool_menu_item("Dialogue")
+
+ instance = null
+
+
+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: Object) -> bool:
+ var editor_settings: EditorSettings = EditorInterface.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 DMSettings.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: 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 _get_unsaved_status(for_scene: String) -> String:
+ if not for_scene.is_empty(): return ""
+ if not is_instance_valid(main_view): return ""
+
+ var unsaved_count: int = main_view.count_unsaved_files()
+ if unsaved_count > 0:
+ return DMConstants.translate(&"confirm_n_unsaved_files").format({ count = unsaved_count })
+ else:
+ return ""
+
+
+func _save_external_data() -> void:
+ if is_instance_valid(main_view) and EditorInterface.get_editor_settings().get_setting("run/auto_save/save_before_running"):
+ main_view.apply_changes()
+ _update_localization()
+
+ DMCache.reimport_files()
+
+
+func _build() -> bool:
+ # If this is the dotnet Godot then we need to check if the solution file exists
+ DMSettings.check_for_dotnet_solution()
+
+ # Ignore errors in other files if we are just running the test scene
+ if DMSettings.get_user_value("is_running_test_scene", true): return true
+
+ DMCache.reimport_files()
+
+ var files_with_errors: Array[Dictionary] = DMCache.get_files_with_errors()
+ if files_with_errors.size() > 0:
+ for dialogue_file: Dictionary in files_with_errors:
+ push_error("You have %d error(s) in %s" % [dialogue_file.errors.size(), dialogue_file.path])
+ EditorInterface.edit_resource(load(files_with_errors[0].path))
+ main_view.show_build_error_dialog()
+ return false
+
+ return true
+
+
+## Open a [DialogueResource] in the dialogue editor.
+static func open_file(resource_or_path: Variant) -> void:
+ var resource: DialogueResource = resource_or_path if resource_or_path is DialogueResource else load(resource_or_path)
+ instance.main_view.open_file(resource.resource_path)
+ EditorInterface.set_main_screen_editor("Dialogue")
+
+
+## Open a [DialogueResource] and jump to a given cue.
+static func open_file_at_cue(resource_or_path: Variant, cue: String, create_if_none: bool = false) -> void:
+ open_file(resource_or_path)
+ instance.main_view.go_to_cue(cue, create_if_none)
+
+
+## Open a [DialogueResource] and jump to a given line number.
+static func open_file_at_line(resource_or_path: Variant, line_number: int) -> void:
+ open_file(resource_or_path)
+ instance.main_view.go_to_line_number(line_number)
+
+
+## Show the panel for finding text in dialogue files.
+static func show_find_in_dialogue() -> void:
+ instance._show_find_in_dialogue()
+
+
+func _show_find_in_dialogue() -> void:
+ if not is_instance_valid(find_in_dialogue_view):
+ find_in_dialogue_view = FindInDialogueView.instantiate()
+ find_in_dialogue_view.main_view = main_view
+ find_in_dialogue_view.result_selected.connect(main_view._on_find_in_files_result_selected)
+ add_control_to_bottom_panel(find_in_dialogue_view, DMConstants.translate(&"search.find_in_dialogue"))
+ make_bottom_panel_item_visible(find_in_dialogue_view)
+ find_in_dialogue_view.prepare()
+
+
+func _hide_find_in_dialogue() -> void:
+ if is_instance_valid(find_in_dialogue_view):
+ remove_control_from_bottom_panel(find_in_dialogue_view)
+ find_in_dialogue_view.queue_free()
+
+
+## Get the shortcuts used by the plugin
+static 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")
+ ],
+
+ make_bold = [
+ _create_event("Ctrl+Shift+b")
+ ],
+ make_italic = [
+ _create_event("Ctrl+Shift+i")
+ ]
+ }
+
+ var paths: EditorPaths = EditorInterface.get_editor_paths()
+ var settings: Resource = null
+ for version: String in ["4.5", "4.4", "4.3", "4"]:
+ var path: String = paths.get_config_dir() + "/editor_settings-" + version + ".tres"
+ if FileAccess.file_exists(path):
+ settings = load(path)
+ break
+
+ if settings == null: return shortcuts
+
+ for s: Dictionary in settings.get("shortcuts"):
+ for key: String in shortcuts:
+ if s.name == "script_text_editor/%s" % key or s.name == "script_editor/%s" % key:
+ shortcuts[key] = []
+ for event: InputEvent in s.shortcuts:
+ if event is InputEventKey:
+ shortcuts[key].append(event)
+
+ return shortcuts
+
+
+static func _create_event(string: String) -> InputEventKey:
+ var event: InputEventKey = InputEventKey.new()
+ var bits: PackedStringArray = 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
+static func get_editor_shortcut(event: InputEventKey) -> String:
+ var shortcuts: Dictionary = get_editor_shortcuts()
+ for key: String in shortcuts:
+ for shortcut: InputEvent in shortcuts.get(key, []):
+ if event.as_text().split(" ")[0] == shortcut.as_text().split(" ")[0]:
+ return key
+ return ""
+
+
+## Get the current version
+static 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
+static func get_plugin_path() -> String:
+ if not is_instance_valid(instance):
+ return ""
+ return instance.get_script().resource_path.get_base_dir()
+
+
+## Update references to a moved file
+func _update_import_paths(from_path: String, to_path: String) -> void:
+ DMCache.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: Array = DMCache.get_files_with_dependency(from_path)
+ for dependent: Dictionary 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.open_buffers[main_view.current_file_path].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: String = 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:
+ if not DMSettings.get_setting(DMSettings.UPDATE_TRANSLATION_TEMPLATES_AUTOMATICALLY, true):
+ return
+
+ var dialogue_files: PackedStringArray = DMCache.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: String 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: int 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()
+
+
+func _create_translations_tool_menu_item() -> PopupMenu:
+ var tool_menu: PopupMenu = PopupMenu.new()
+ tool_menu.add_icon_item(_get_plugin_icon(), "Create balloon...")
+ tool_menu.add_icon_item(main_view.get_theme_icon("Translation", "EditorIcons"), DMConstants.translate("generate_line_ids"))
+ tool_menu.index_pressed.connect(func(index: int) -> void:
+ match index:
+ 0: # create balloon
+ _create_dialogue_balloon()
+ 1: # generate IDs
+ var confirm: ConfirmationDialog = ConfirmationDialog.new()
+ confirm.title = DMConstants.translate("generate_ids.warning_title")
+ confirm.dialog_text = DMConstants.translate("generate_ids.warning_text")
+ confirm.ok_button_text = DMConstants.translate("generate_ids.ok_button")
+ confirm.confirmed.connect(func() -> void:
+ confirm.queue_free()
+ DMTranslationUtilities.generate_static_line_ids_for_project()
+ )
+ confirm.canceled.connect(func() -> void:
+ confirm.queue_free()
+ )
+ add_child(confirm)
+ confirm.popup_centered()
+ )
+ return tool_menu
+
+
+#region Callbacks
+
+
+func _create_dialogue_balloon() -> void:
+ var scale: float = EditorInterface.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: String) -> void:
+ var plugin_path: String = get_plugin_path()
+ var is_dotnet: bool = DMSettings.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
+ if is_dotnet:
+ example_balloon_file_name = "SmallExampleBalloon.tscn" if is_small_window else "ExampleBalloon.tscn"
+ else:
+ example_balloon_file_name = "small_example_balloon.tscn" if is_small_window else "example_balloon.tscn"
+ var example_balloon_path: String = plugin_path + "/example_balloon/" + example_balloon_file_name
+ var example_balloon_script_file_name: String = "ExampleBalloon.cs" if is_dotnet else "example_balloon.gd"
+ var example_balloon_script_uid: String = ResourceUID.id_to_text(ResourceLoader.get_resource_uid(plugin_path + "/example_balloon/" + example_balloon_script_file_name))
+ var example_balloon_uid: String = ResourceUID.id_to_text(ResourceLoader.get_resource_uid(example_balloon_path))
+
+ # Copy the script file
+ var file: FileAccess = FileAccess.open(plugin_path + "/example_balloon/" + example_balloon_script_file_name, FileAccess.READ)
+ var file_contents: String = file.get_as_text()
+ if is_dotnet:
+ file_contents = file_contents.replace("class ExampleBalloon", "class DialogueBalloon")
+ else:
+ file_contents = file_contents.replace("class_name DialogueManagerExampleBalloon ", "")
+ # Remove the example warning
+ var lines: PackedStringArray = file_contents.split("\n")
+ var updated_lines: PackedStringArray = []
+ var ignore_line: bool = false
+ for line: String in lines:
+ if line.contains("EXAMPLE MESSAGE"):
+ ignore_line = not ignore_line
+ elif not ignore_line:
+ updated_lines.append(line)
+ file_contents = "\n".join(updated_lines)
+ file = FileAccess.open(balloon_script_path, FileAccess.WRITE)
+ file.store_string(file_contents)
+ file.close()
+ var new_balloon_script_uid_raw: int = ResourceUID.create_id()
+ ResourceUID.add_id(new_balloon_script_uid_raw, balloon_script_path)
+ var new_balloon_script_uid: String = ResourceUID.id_to_text(new_balloon_script_uid_raw)
+
+ # Save the new balloon
+ file_contents = FileAccess.get_file_as_string(example_balloon_path)
+ if "example_balloon.gd" in file_contents:
+ file_contents = file_contents.replace(plugin_path + "/example_balloon/example_balloon.gd", balloon_script_path)
+ else:
+ file_contents = file_contents.replace(plugin_path + "/example_balloon/ExampleBalloon.cs", balloon_script_path)
+ var new_balloon_uid: String = ResourceUID.id_to_text(ResourceUID.create_id())
+ file_contents = file_contents.replace(example_balloon_uid, new_balloon_uid).replace(example_balloon_script_uid, new_balloon_script_uid)
+ file = FileAccess.open(balloon_path, FileAccess.WRITE)
+ file.store_string(file_contents)
+ file.close()
+
+ EditorInterface.get_resource_filesystem().scan()
+ EditorInterface.get_file_system_dock().call_deferred("navigate_to_path", balloon_path)
+
+ DMSettings.set_setting(DMSettings.BALLOON_PATH, balloon_path)
+
+ directory_dialog.queue_free()
+ )
+ EditorInterface.get_base_control().add_child(directory_dialog)
+ directory_dialog.popup_centered()
+
+
+#endregion
+
+#region Signals
+
+
+func _on_files_moved(old_file: String, new_file: String) -> void:
+ _update_import_paths(old_file, new_file)
+ DMSettings.move_recent_file(old_file, new_file)
+
+
+func _on_file_removed(file: String) -> void:
+ DMSettings.forget_path(file)
+ _update_import_paths(file, "")
+ if is_instance_valid(main_view):
+ main_view.close_file(file)
+ _update_localization()
+
+
+#endregion
diff --git a/addons/dialogue_manager/plugin.gd.uid b/addons/dialogue_manager/plugin.gd.uid
new file mode 100644
index 0000000..40573b0
--- /dev/null
+++ b/addons/dialogue_manager/plugin.gd.uid
@@ -0,0 +1 @@
+uid://bpv426rpvrafa
diff --git a/addons/dialogue_manager/settings.gd b/addons/dialogue_manager/settings.gd
new file mode 100644
index 0000000..55fd7e7
--- /dev/null
+++ b/addons/dialogue_manager/settings.gd
@@ -0,0 +1,277 @@
+@tool
+class_name DMSettings extends Node
+
+
+#region Editor
+
+
+
+## Wrap lines in the dialogue editor.
+const WRAP_LONG_LINES: StringName = &"editor/wrap_long_lines"
+## The template to start new dialogue files with.
+const NEW_FILE_TEMPLATE: StringName = &"editor/new_file_template"
+
+## Show lines without statis IDs as errors.
+const MISSING_TRANSLATIONS_ARE_ERRORS: StringName = &"editor/translations/missing_translations_are_errors"
+## Include character names in the list of translatable strings.
+const INCLUDE_CHARACTERS_IN_TRANSLATABLE_STRINGS_LIST: StringName = &"editor/translations/include_characters_in_translatable_strings_list"
+## Automatically update the project's list of translatable files when dialogue files are added or removed
+const UPDATE_TRANSLATION_TEMPLATES_AUTOMATICALLY: StringName = &"editor/translations/UPDATE_TRANSLATION_TEMPLATES_AUTOMATICALLY"
+
+## A processor handling special case compilation.
+const DIALOGUE_PROCESSOR_PATH: StringName = &"editor/advanced/dialogue_processor_path"
+
+## A custom test scene to use when testing dialogue.
+const CUSTOM_TEST_SCENE_PATH: StringName = &"editor/advanced/custom_test_scene_path"
+## Extra script files to include in the auto-complete-able list
+const EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES: StringName = &"editor/advanced/extra_auto_complete_script_sources"
+
+## The custom balloon for this game.
+const BALLOON_PATH: StringName = &"runtime/balloon_path"
+## The names of any autoloads to shortcut into all dialogue files (so you don't have to write `using SomeGlobal` in each file).
+const STATE_AUTOLOAD_SHORTCUTS: StringName = &"runtime/state_autoload_shortcuts"
+## Check for possible naming conflicts in state shortcuts.
+const WARN_ABOUT_METHOD_PROPERTY_OR_SIGNAL_NAME_CONFLICTS: StringName = &"runtime/warn_about_method_property_or_signal_name_conflicts"
+
+## Bypass any missing state when running dialogue.
+const IGNORE_MISSING_STATE_VALUES: StringName = &"runtime/advanced/ignore_missing_state_values"
+## Whether or not the project is utilising dotnet.
+const USES_DOTNET: StringName = &"runtime/advanced/uses_dotnet"
+
+
+static var SETTINGS_CONFIGURATION: Dictionary = {
+ WRAP_LONG_LINES: {
+ value = false,
+ type = TYPE_BOOL,
+ },
+ NEW_FILE_TEMPLATE: {
+ value = "~ start\nNathan: [[Hi|Hello|Howdy]], this is some dialogue.\nNathan: Here are some choices.\n- First one\n\tNathan: You picked the first one.\n- Second one\n\tNathan: You picked the second one.\n- Start again => start\n- End the conversation => END\nNathan: For more information see the online documentation.\n=> END",
+ type = TYPE_STRING,
+ hint = PROPERTY_HINT_MULTILINE_TEXT,
+ },
+
+ MISSING_TRANSLATIONS_ARE_ERRORS: {
+ value = false,
+ type = TYPE_BOOL,
+ is_advanced = true
+ },
+ INCLUDE_CHARACTERS_IN_TRANSLATABLE_STRINGS_LIST: {
+ value = true,
+ type = TYPE_BOOL,
+ },
+ UPDATE_TRANSLATION_TEMPLATES_AUTOMATICALLY: {
+ value = true,
+ type = TYPE_BOOL,
+ is_advanced = true
+ },
+
+ DIALOGUE_PROCESSOR_PATH: {
+ value = "",
+ type = TYPE_STRING,
+ hint = PROPERTY_HINT_FILE,
+ hint_string = "*.gd,*.cs",
+ is_advanced = true
+ },
+
+ CUSTOM_TEST_SCENE_PATH: {
+ value = preload("./test_scene.tscn").resource_path,
+ type = TYPE_STRING,
+ hint = PROPERTY_HINT_FILE,
+ hint_string = "*.tscn,*.scn",
+ is_advanced = true
+ },
+ EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES: {
+ value = [],
+ type = TYPE_PACKED_STRING_ARRAY,
+ hint = PROPERTY_HINT_TYPE_STRING,
+ hint_string = "%d/%d:*.*" % [TYPE_STRING, PROPERTY_HINT_FILE],
+ is_advanced = true
+ },
+
+ BALLOON_PATH: {
+ value = "",
+ type = TYPE_STRING,
+ hint = PROPERTY_HINT_FILE,
+ hint_string = "*.tscn,*.scn",
+ },
+ STATE_AUTOLOAD_SHORTCUTS: {
+ value = [],
+ type = TYPE_PACKED_STRING_ARRAY,
+ hint = PROPERTY_HINT_TYPE_STRING,
+ hint_string = "%d:" % [TYPE_STRING],
+ },
+ WARN_ABOUT_METHOD_PROPERTY_OR_SIGNAL_NAME_CONFLICTS: {
+ value = false,
+ type = TYPE_BOOL,
+ is_advanced = true
+ },
+
+ IGNORE_MISSING_STATE_VALUES: {
+ value = false,
+ type = TYPE_BOOL,
+ is_advanced = true
+ },
+ USES_DOTNET: {
+ value = false,
+ type = TYPE_BOOL,
+ is_hidden = true
+ }
+}
+
+
+static func prepare() -> void:
+ # Set up initial settings
+ for key: String in SETTINGS_CONFIGURATION:
+ var setting_config: Dictionary = SETTINGS_CONFIGURATION[key]
+ var setting_name: String = "dialogue_manager/%s" % key
+ if not ProjectSettings.has_setting(setting_name):
+ ProjectSettings.set_setting(setting_name, setting_config.value)
+ ProjectSettings.set_initial_value(setting_name, setting_config.value)
+ ProjectSettings.add_property_info({
+ "name" = setting_name,
+ "type" = setting_config.type,
+ "hint" = setting_config.get("hint", PROPERTY_HINT_NONE),
+ "hint_string" = setting_config.get("hint_string", "")
+ })
+ ProjectSettings.set_as_basic(setting_name, not setting_config.has("is_advanced"))
+ ProjectSettings.set_as_internal(setting_name, setting_config.has("is_hidden"))
+
+
+static func set_setting(key: String, value: Variant) -> void:
+ if get_setting(key, value) != value:
+ ProjectSettings.set_setting("dialogue_manager/%s" % key, value)
+ ProjectSettings.set_initial_value("dialogue_manager/%s" % key, SETTINGS_CONFIGURATION[key].value)
+ ProjectSettings.save()
+
+
+static func get_setting(key: String, default: Variant) -> Variant:
+ if ProjectSettings.has_setting("dialogue_manager/%s" % key):
+ return ProjectSettings.get_setting("dialogue_manager/%s" % key)
+ else:
+ return default
+
+
+#endregion
+
+#region User
+
+
+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 = "",
+ file_meta = {},
+ run_key = "",
+ run_resource_path = "",
+ is_running_test_scene = false,
+ has_dotnet_solution = false,
+ open_in_external_editor = false
+ }
+
+ if FileAccess.file_exists(DMConstants.USER_CONFIG_PATH):
+ var file: FileAccess = FileAccess.open(DMConstants.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(DMConstants.USER_CONFIG_PATH, FileAccess.WRITE)
+ file.store_string(JSON.stringify(user_config))
+
+
+static func set_user_value(key: String, value: Variant) -> 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: Variant = null) -> Variant:
+ return get_user_config().get(key, default)
+
+
+static func forget_path(path: String) -> void:
+ remove_recent_file(path)
+ var file_meta: Dictionary = get_user_value("file_meta", {})
+ file_meta.erase(path)
+ set_user_value("file_meta", file_meta)
+
+
+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: int 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 file_meta: Dictionary = get_user_value("file_meta", {})
+ file_meta[path] = file_meta.get(path, {}).merged({ cursor = "%d,%d" % [cursor.x, cursor.y] }, true)
+ set_user_value("file_meta", file_meta)
+
+
+static func get_caret(path: String) -> Vector2:
+ var file_meta: Dictionary = get_user_value("file_meta", {})
+ if file_meta.has(path):
+ var cursor: PackedStringArray = file_meta.get(path).get("cursor", "0,0").split(",")
+ return Vector2(cursor[0].to_int(), cursor[1].to_int())
+ else:
+ return Vector2.ZERO
+
+
+static func set_scroll(path: String, scroll_vertical: float) -> void:
+ var file_meta: Dictionary = get_user_value("file_meta", {})
+ file_meta[path] = file_meta.get(path, {}).merged({ scroll_vertical = scroll_vertical }, true)
+ set_user_value("file_meta", file_meta)
+
+
+static func get_scroll(path: String) -> float:
+ var file_meta: Dictionary = get_user_value("file_meta", {})
+ if file_meta.has(path):
+ return file_meta.get(path).get("scroll_vertical", 0)
+ else:
+ return 0
+
+
+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(DMSettings.USES_DOTNET, has_dotnet_solution)
+ return has_dotnet_solution
+
+ return get_setting(DMSettings.USES_DOTNET, false)
+
+
+#endregion
diff --git a/addons/dialogue_manager/settings.gd.uid b/addons/dialogue_manager/settings.gd.uid
new file mode 100644
index 0000000..c93da98
--- /dev/null
+++ b/addons/dialogue_manager/settings.gd.uid
@@ -0,0 +1 @@
+uid://ce1nk88365m52
diff --git a/addons/dialogue_manager/test_scene.gd b/addons/dialogue_manager/test_scene.gd
new file mode 100644
index 0000000..620764b
--- /dev/null
+++ b/addons/dialogue_manager/test_scene.gd
@@ -0,0 +1,33 @@
+class_name BaseDialogueTestScene extends Node2D
+
+
+@onready var key: String = DMSettings.get_user_value("run_key")
+@onready var resource: DialogueResource = load(DMSettings.get_user_value("run_resource_path"))
+
+
+func _ready() -> void:
+ if not Engine.is_embedded_in_editor:
+ var window: Window = get_viewport()
+ var screen_index: int = DisplayServer.get_primary_screen()
+ window.position = Vector2(DisplayServer.screen_get_position(screen_index)) + (DisplayServer.screen_get_size(screen_index) - window.size) * 0.5
+ window.mode = 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: Node = Engine.get_singleton("DialogueManager")
+ dialogue_manager.dialogue_ended.connect(_on_dialogue_ended)
+ dialogue_manager.show_dialogue_balloon(resource, key if not key.is_empty() else resource.first_cue)
+
+
+func _enter_tree() -> void:
+ DMSettings.set_user_value("is_running_test_scene", false)
+
+
+#region Signals
+
+
+func _on_dialogue_ended(_resource: DialogueResource) -> void:
+ get_tree().quit()
+
+
+#endregion
diff --git a/addons/dialogue_manager/test_scene.gd.uid b/addons/dialogue_manager/test_scene.gd.uid
new file mode 100644
index 0000000..1bee7a1
--- /dev/null
+++ b/addons/dialogue_manager/test_scene.gd.uid
@@ -0,0 +1 @@
+uid://c8e16qdgu40wo
diff --git a/addons/dialogue_manager/test_scene.tscn b/addons/dialogue_manager/test_scene.tscn
new file mode 100644
index 0000000..f0786ba
--- /dev/null
+++ b/addons/dialogue_manager/test_scene.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://ugd552efvil0"]
+
+[ext_resource type="Script" uid="uid://c8e16qdgu40wo" 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 0000000..2b0fc72
--- /dev/null
+++ b/addons/dialogue_manager/utilities/builtins.gd
@@ -0,0 +1,532 @@
+class_name DMBuiltins extends RefCounted
+
+
+const SUPPORTED_BUILTIN_TYPES: Array = [
+ TYPE_STRING,
+ TYPE_STRING_NAME,
+ TYPE_ARRAY,
+ TYPE_PACKED_STRING_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: Variant, with_method: String = "") -> bool:
+ if not typeof(thing) in SUPPORTED_BUILTIN_TYPES: return false
+
+ # If given a Dictionary and a method then make sure it's a known Dictionary method.
+ if typeof(thing) == TYPE_DICTIONARY and with_method != "":
+ return with_method in [
+ &"clear",
+ &"duplicate",
+ &"erase",
+ &"find_key",
+ &"get",
+ &"get_or_add",
+ &"has",
+ &"has_all",
+ &"hash",
+ &"is_empty",
+ &"is_read_only",
+ &"keys",
+ &"make_read_only",
+ &"merge",
+ &"merged",
+ &"recursive_equal",
+ &"size",
+ &"values"]
+
+ return true
+
+
+static func resolve_property(builtin: Variant, property: String) -> Variant:
+ match typeof(builtin):
+ TYPE_DICTIONARY:
+ if not builtin.has(property):
+ match property:
+ "Count", "Length":
+ return builtin.size()
+ "IsEmpty":
+ return builtin.is_empty()
+ return builtin.get(property)
+
+ TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY:
+ match property:
+ "Count", "Length":
+ return builtin.size()
+ "IsEmpty":
+ return builtin.is_empty()
+ _:
+ return builtin[property]
+
+ TYPE_STRING:
+ match property:
+ "Length", "Count":
+ return builtin.length()
+ "IsEmpty":
+ return builtin.is_empty()
+ _:
+ return builtin[property]
+
+ TYPE_STRING_NAME, TYPE_QUATERNION:
+ 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)
+
+ return null
+
+
+static func resolve_method(thing: Variant, method_name: String, args: Array) -> Variant:
+ 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: int in range(0, args.size()):
+ references.append("arg%d" % i)
+ var expression: 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: Variant = await 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) -> Variant:
+ 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) -> Variant:
+ 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
+
+ "DOWN_LEFT":
+ return Vector2(-1, 1)
+ "DOWN_RIGHT":
+ return Vector2(1, 1)
+ "UP_LEFT":
+ return Vector2(-1, -1)
+ "UP_RIGHT":
+ return Vector2(1, -1)
+
+ return vector[property]
+
+
+static func resolve_vector3_property(vector: Vector3, property: String) -> Variant:
+ 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) -> Variant:
+ 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/utilities/builtins.gd.uid b/addons/dialogue_manager/utilities/builtins.gd.uid
new file mode 100644
index 0000000..af8698c
--- /dev/null
+++ b/addons/dialogue_manager/utilities/builtins.gd.uid
@@ -0,0 +1 @@
+uid://bnfhuubdv5k20
diff --git a/addons/dialogue_manager/utilities/dialogue_cache.gd b/addons/dialogue_manager/utilities/dialogue_cache.gd
new file mode 100644
index 0000000..12e9774
--- /dev/null
+++ b/addons/dialogue_manager/utilities/dialogue_cache.gd
@@ -0,0 +1,227 @@
+class_name DMCache extends RefCounted
+
+
+## Keep track of errors and dependencies
+## {
+## = {
+## path = ,
+## dependencies = [, ],
+## errors = [, ]
+## }
+## }
+static var _cache: Dictionary = {}
+
+static var _update_dependency_timer: Timer
+static var _update_dependency_paths: PackedStringArray = []
+
+static var _files_marked_for_reimport: PackedStringArray = []
+
+## Keep track of used static IDs
+## {
+## =
+## }
+## Before compiling a file, remove any static IDs with a file path that matches
+## the file
+static var known_static_ids: Dictionary = {}
+
+
+## Build the initial cache for dialogue files
+static func prepare() -> void:
+ _update_dependency_timer = Timer.new()
+ _update_dependency_timer.timeout.connect(_on_dependency_timer_timeout)
+ (Engine.get_main_loop() as SceneTree).root.add_child(_update_dependency_timer)
+
+ var current_files: PackedStringArray = _get_dialogue_files_in_filesystem()
+ for file: String in current_files:
+ add_file(file)
+
+ # Find any static IDs
+ var key_regex: RegEx = RegEx.create_from_string("\\[ID:(?.*?)\\]")
+ for file_path: String in get_files():
+ var text: String = FileAccess.get_file_as_string(file_path)
+ var lines: PackedStringArray = text.split("\n")
+ for i: int in range(0, lines.size()):
+ var line: String = lines[i]
+ var found: RegExMatch = key_regex.search(line)
+ if found:
+ known_static_ids[found.strings[found.names.get("key")]] = file_path
+
+
+## Add some files to the queue for reimporting.
+static func mark_files_for_reimport(files: PackedStringArray) -> void:
+ for file: String in files:
+ if not _files_marked_for_reimport.has(file):
+ _files_marked_for_reimport.append(file)
+
+
+## Attempt to reimport all known dialogue files.
+static func reimport_all_files() -> void:
+ reimport_files(_get_dialogue_files_in_filesystem())
+
+
+## Attempt to reimport any files marked for reimporting.
+static func reimport_files(and_files: PackedStringArray = []) -> void:
+ for file: String in and_files:
+ if not _files_marked_for_reimport.has(file):
+ _files_marked_for_reimport.append(file)
+
+ if _files_marked_for_reimport.is_empty(): return
+
+ # Guard against recursive reimport calls. Don't mark for reimport unless attempted once.
+ var filesystem: Object = Engine.get_singleton("EditorInterface").get_resource_filesystem()
+ if filesystem.is_scanning():
+ # Defer the reimport to the next idle frame.
+ _schedule_deferred_reimport.call_deferred()
+ return
+
+ # Attempt reimport immediately if not busy.
+ Engine.get_singleton("EditorInterface").get_resource_filesystem().reimport_files(_files_marked_for_reimport)
+ _files_marked_for_reimport.clear()
+
+
+## Helper to try and resolve recursive import crashes while importer is busy.
+static func _schedule_deferred_reimport() -> void:
+ # Wait before trying again.
+ if _files_marked_for_reimport.is_empty(): return
+
+ var filesystem: Object = Engine.get_singleton("EditorInterface").get_resource_filesystem()
+ if filesystem.is_scanning():
+ # Still working on it. Try again later.
+ await Engine.get_main_loop().create_timer(0.1).timeout
+ _schedule_deferred_reimport()
+ return
+
+ filesystem.reimport_files(_files_marked_for_reimport)
+ _files_marked_for_reimport.clear()
+
+
+## Add a dialogue file to the cache.
+static func add_file(path: String, compile_result: DMCompilerResult = null) -> void:
+ _cache[path] = {
+ path = path,
+ cues = {},
+ dependencies = [],
+ errors = []
+ }
+
+ if compile_result != null:
+ _cache[path].cues = compile_result.cues
+ _cache[path].dependencies = Array(compile_result.imported_paths).filter(func(d: String) -> bool: return d != path)
+ _cache[path].compiled_at = Time.get_ticks_msec()
+
+ queue_updating_dependencies(path)
+
+
+## Get the file paths in the cache.
+static func get_files() -> PackedStringArray:
+ return _cache.keys()
+
+
+## Get the data for a file path.
+static func get_file_data(key: String) -> Dictionary:
+ return _cache.get(key, {})
+
+
+## Check if a file is known to the cache.
+static func has_file(path: String) -> bool:
+ return _cache.has(path)
+
+
+## Remember any errors in a dialogue file.
+static func add_errors_to_file(path: String, errors: Array[DMError]) -> 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.
+static func get_files_with_errors() -> Array[Dictionary]:
+ var files_with_errors: Array[Dictionary] = []
+ for dialogue_file: Dictionary 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.
+static func queue_updating_dependencies(of_path: String) -> void:
+ if _update_dependency_paths.has(of_path): return
+ if is_instance_valid(_update_dependency_timer):
+ _update_dependency_timer.stop()
+
+ if not _update_dependency_paths.has(of_path):
+ _update_dependency_paths.append(of_path)
+
+ if is_instance_valid(_update_dependency_timer):
+ _update_dependency_timer.start(0.5)
+
+
+## Update any references to a file path that has moved.
+static 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.
+static func get_files_with_dependency(imported_path: String) -> Array:
+ return _cache.values().filter(func(d: Dictionary) -> bool: return d.dependencies.has(imported_path))
+
+
+## Get any paths that are dependent on a given path.
+static func get_dependent_paths_for_reimport(on_path: String) -> PackedStringArray:
+ return get_files_with_dependency(on_path) \
+ .filter(func(d: Dictionary) -> bool: return Time.get_ticks_msec() - d.get("compiled_at", 0) > 3000) \
+ .map(func(d: Dictionary) -> String: return d.path)
+
+
+# Recursively find any dialogue files in a directory.
+static func _get_dialogue_files_in_filesystem(path: String = "res://") -> PackedStringArray:
+ var files: PackedStringArray = []
+
+ if DirAccess.dir_exists_absolute(path):
+ var dir: DirAccess = DirAccess.open(path)
+ dir.list_dir_begin()
+ var file_name: String = 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
+
+
+#region Signals
+
+
+static func _on_dependency_timer_timeout() -> void:
+ if is_instance_valid(_update_dependency_timer):
+ _update_dependency_timer.stop()
+
+ var import_regex: RegEx = RegEx.create_from_string("import \"(?.*?)\"")
+ var file: FileAccess
+ var found_imports: Array[RegExMatch]
+ for path: String 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: RegExMatch in found_imports:
+ dependencies.append(found.strings[found.names.path])
+ _cache[path].dependencies = dependencies
+ _update_dependency_paths.clear()
+
+
+#endregion
diff --git a/addons/dialogue_manager/utilities/dialogue_cache.gd.uid b/addons/dialogue_manager/utilities/dialogue_cache.gd.uid
new file mode 100644
index 0000000..e572006
--- /dev/null
+++ b/addons/dialogue_manager/utilities/dialogue_cache.gd.uid
@@ -0,0 +1 @@
+uid://d3c83yd6bjp43
diff --git a/addons/dialogue_manager/utilities/error.gd b/addons/dialogue_manager/utilities/error.gd
new file mode 100644
index 0000000..b4c1b19
--- /dev/null
+++ b/addons/dialogue_manager/utilities/error.gd
@@ -0,0 +1,11 @@
+class_name DMError extends RefCounted
+
+var line_number: int
+var column_number: int
+var error: int
+
+
+func _init(data: Dictionary) -> void:
+ line_number = data.get("line_number", 0)
+ column_number = data.get("column_number", 0)
+ error = data.get("error", DMConstants.ERR_UNKNOWN_ERROR)
diff --git a/addons/dialogue_manager/utilities/error.gd.uid b/addons/dialogue_manager/utilities/error.gd.uid
new file mode 100644
index 0000000..58385f5
--- /dev/null
+++ b/addons/dialogue_manager/utilities/error.gd.uid
@@ -0,0 +1 @@
+uid://j53u75yx7iom
diff --git a/addons/dialogue_manager/utilities/theme_values.gd b/addons/dialogue_manager/utilities/theme_values.gd
new file mode 100644
index 0000000..6852fb4
--- /dev/null
+++ b/addons/dialogue_manager/utilities/theme_values.gd
@@ -0,0 +1,92 @@
+class_name DMThemeValues extends RefCounted
+
+
+var scale: float = 1
+var background_color: Color = Color.WHITE
+var current_line_color: Color = Color.WHITE
+var error_line_color: Color = Color.WHITE
+
+var critical_color: Color = Color.WHITE
+var notice_color: Color = Color.WHITE
+
+var cues_color: Color = Color.WHITE
+var text_color: Color = Color.WHITE
+var tags_color: Color = Color.WHITE
+var conditions_color: Color = Color.WHITE
+var mutations_color: Color = Color.WHITE
+var mutations_line_color: Color = Color.WHITE
+var members_color: Color = Color.WHITE
+var strings_color: Color = Color.WHITE
+var numbers_color: Color = Color.WHITE
+var symbols_color: Color = Color.WHITE
+var comments_color: Color = Color.WHITE
+var jumps_color: Color = Color.WHITE
+
+var font_size: int = 16
+
+
+func _init(values: Dictionary) -> void:
+ scale = values.scale
+
+ background_color = values.background_color
+ current_line_color = values.current_line_color
+ error_line_color = values.error_line_color
+
+ critical_color = values.critical_color
+ notice_color = values.notice_color
+
+ cues_color = values.cues_color
+ text_color = values.text_color
+ tags_color = values.tags_color
+ conditions_color = values.conditions_color
+ mutations_color = values.mutations_color
+ mutations_line_color = values.mutations_line_color
+ members_color = values.members_color
+ strings_color = values.strings_color
+ numbers_color = values.numbers_color
+ symbols_color = values.symbols_color
+ comments_color = values.comments_color
+ jumps_color = values.jumps_color
+
+ font_size = values.font_size
+
+
+## Get size and colour values used for setting themes.
+static func get_values_from_editor() -> DMThemeValues:
+ var editor_settings: EditorSettings = EditorInterface.get_editor_settings()
+ return DMThemeValues.new({
+ scale = EditorInterface.get_editor_scale(),
+
+ background_color = Color(editor_settings.get_setting("interface/theme/base_color").blend(editor_settings.get_setting("text_editor/theme/highlighting/background_color")), 1),
+ 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"),
+
+ cues_color = editor_settings.get_setting("text_editor/theme/highlighting/gdscript/node_reference_color"),
+ text_color = editor_settings.get_setting("text_editor/theme/highlighting/text_color"),
+ tags_color = editor_settings.get_setting("text_editor/theme/highlighting/string_placeholder_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"),
+ mutations_line_color = Color(editor_settings.get_setting("text_editor/theme/highlighting/function_color"), 0.6),
+ 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/gdscript/node_reference_color"), 0.6),
+
+ font_size = editor_settings.get_setting("interface/editor/code_font_size")
+ })
+
+
+## Return a copy of a texture with a tint applied.
+static func get_icon_with_color(icon: Texture2D, color: Color) -> ImageTexture:
+ var image: Image = icon.get_image().duplicate()
+ for x: int in image.get_width():
+ for y: int in image.get_height():
+ var pixel: Color = image.get_pixel(x, y)
+ pixel *= color
+ image.set_pixel(x, y, pixel)
+ return ImageTexture.create_from_image(image)
diff --git a/addons/dialogue_manager/utilities/theme_values.gd.uid b/addons/dialogue_manager/utilities/theme_values.gd.uid
new file mode 100644
index 0000000..380a848
--- /dev/null
+++ b/addons/dialogue_manager/utilities/theme_values.gd.uid
@@ -0,0 +1 @@
+uid://du8x3k1nth502
diff --git a/addons/dialogue_manager/utilities/translations.gd b/addons/dialogue_manager/utilities/translations.gd
new file mode 100644
index 0000000..272d546
--- /dev/null
+++ b/addons/dialogue_manager/utilities/translations.gd
@@ -0,0 +1,56 @@
+## A collection of utility functions for working with dialogue translations.
+class_name DMTranslationUtilities extends RefCounted
+
+
+## Generate static IDs/translation keys from some text.
+static func generate_static_line_ids_for_project() -> void:
+ var rng: RandomNumberGenerator = RandomNumberGenerator.new()
+ rng.randomize()
+
+ for file_path: String in DMCache.get_files():
+ var text: String = FileAccess.get_file_as_string(file_path)
+
+ text = generate_static_line_ids_for_text(text, file_path)
+
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
+ file.store_string(text)
+ file.close()
+
+
+## Generate static line IDs for some text.
+static func generate_static_line_ids_for_text(text: String, file_path: String) -> String:
+ var lines: PackedStringArray = text.split("\n")
+ var compiled_lines: Dictionary = DMCompiler.compile_string(text, "").lines
+
+ # Add in any that are missing
+ for i: int in lines.size():
+ var line: String = lines[i]
+ var l: String = line.strip_edges()
+
+ if not [DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE].has(DMCompiler.get_line_type(l)): continue
+ if not compiled_lines.has(str(i)): continue
+
+ if "[ID:" in line: continue
+
+ var translatable_text: String = ""
+ if l.begins_with("- "):
+ translatable_text = DMCompiler.extract_translatable_string(l)
+ else:
+ translatable_text = Array(l.replace("\\:", "!ESCAPED_COLON!").split(":")).back().replace("!ESCAPED_COLON!", "\\:")
+
+ var key: String = _generate_id(file_path)
+ while key in DMCache.known_static_ids:
+ key = _generate_id(file_path)
+
+ line = line.replace("\\n", "!NEWLINE!")
+ translatable_text = translatable_text.replace("\\n", "!NEWLINE!")
+ lines[i] = line.replace(translatable_text, translatable_text + " [ID:%s]" % [key]).replace("!NEWLINE!", "\\n")
+
+ DMCache.known_static_ids[key] = file_path
+
+ return "\n".join(lines)
+
+
+## Get a random-ish ID for a line.
+static func _generate_id(file_path: String) -> String:
+ return ResourceUID.path_to_uid(file_path).replace("uid://", "") + "_" + str(randi() % 1000000).sha1_text().substr(0, 6)
diff --git a/addons/dialogue_manager/utilities/translations.gd.uid b/addons/dialogue_manager/utilities/translations.gd.uid
new file mode 100644
index 0000000..4c30f49
--- /dev/null
+++ b/addons/dialogue_manager/utilities/translations.gd.uid
@@ -0,0 +1 @@
+uid://c74q663mmfyk1
diff --git a/addons/dialogue_manager/utilities/waiter.gd b/addons/dialogue_manager/utilities/waiter.gd
new file mode 100644
index 0000000..14652ca
--- /dev/null
+++ b/addons/dialogue_manager/utilities/waiter.gd
@@ -0,0 +1,35 @@
+class_name DMWaiter extends Node
+
+
+signal waited(for_action: String)
+
+
+static var all_waiters: Array[DMWaiter]
+
+var _actions: PackedStringArray
+var _null: String = str(null)
+
+
+## Emit [code]waited[/code] on all waiting [DMWaiter]s.
+static func clear_all() -> void:
+ for waiter: DMWaiter in all_waiters:
+ all_waiters.erase(waiter)
+ waiter.waited.emit(null)
+
+
+func _init(target_actions: PackedStringArray) -> void:
+ all_waiters.append(self)
+ _actions = target_actions
+
+
+func _exit_tree() -> void:
+ all_waiters.erase(self)
+
+
+func _input(event: InputEvent) -> void:
+ for action: String in _actions:
+ if event.is_pressed():
+ if action == _null or (InputMap.has_action(action) and event.is_action(action)):
+ get_viewport().set_input_as_handled()
+ all_waiters.erase(self)
+ waited.emit(action)
diff --git a/addons/dialogue_manager/utilities/waiter.gd.uid b/addons/dialogue_manager/utilities/waiter.gd.uid
new file mode 100644
index 0000000..95646ea
--- /dev/null
+++ b/addons/dialogue_manager/utilities/waiter.gd.uid
@@ -0,0 +1 @@
+uid://bx7dtro7ywali
diff --git a/addons/dialogue_manager/views/debugger_view.gd b/addons/dialogue_manager/views/debugger_view.gd
new file mode 100644
index 0000000..8db2c76
--- /dev/null
+++ b/addons/dialogue_manager/views/debugger_view.gd
@@ -0,0 +1,198 @@
+@tool
+
+class_name DMDebuggerView extends PanelContainer
+
+
+const CONTEXT_ICON: Texture2D = preload("../nodes/context/dialogue_state_context.svg")
+
+
+var session: EditorDebuggerSession
+
+var current_scene: Dictionary = {}:
+ set(value):
+ current_scene = value
+ _update_state_tree()
+ get:
+ return current_scene
+
+var contexts: Dictionary = {}:
+ set(value):
+ contexts = value
+ _update_state_tree()
+ get:
+ return contexts
+
+var autoloads: Dictionary = {}:
+ set(value):
+ autoloads = value
+ _update_state_tree()
+ get:
+ return autoloads
+
+@onready var runtime_warning: Label = %RuntimeWarning
+@onready var content: HSplitContainer = %Content
+@onready var state_tree: Tree = %StateTree
+@onready var history_label: Label = %HistoryLabel
+@onready var log_output: RichTextLabel = %LogOutput
+@onready var clear_button: Button = %ClearButton
+
+
+func _ready() -> void:
+ content.hide()
+ state_tree.clear()
+ runtime_warning.text = DMConstants.translate("{game_name} isn't running.").format({ game_name = ProjectSettings.get_setting("application/config/name")})
+ runtime_warning.add_theme_color_override("font_color", get_theme_color("warning_color", "Editor"))
+
+ history_label.text = DMConstants.translate("History")
+
+ clear_button.icon = get_theme_icon("Clear", "EditorIcons")
+ clear_button.tooltip_text = DMConstants.translate("Clear")
+
+
+func start() -> void:
+ log_output.clear()
+ runtime_warning.hide()
+ content.show()
+
+
+func stop() -> void:
+ runtime_warning.show()
+ content.hide()
+ state_tree.clear()
+
+
+func add_line(id: String) -> void:
+ var resource_and_id: Dictionary = _get_resource_and_id(id)
+
+ if resource_and_id.resource == null: return
+ if not resource_and_id.resource.lines.has(resource_and_id.id): return
+
+ var line: Dictionary = resource_and_id.resource.lines.get(resource_and_id.id)
+
+ var prefix: String = "[color={color}]{time} [url={url}]{file}[/url][/color] ".format({
+ color = DMThemeValues.get_values_from_editor().comments_color.to_html(),
+ time = Time.get_time_string_from_system(),
+ url = id,
+ file = resource_and_id.resource.resource_path.get_basename().get_file()
+ })
+
+ match line.type:
+ DMConstants.TYPE_DIALOGUE:
+ if line.has("character"):
+ log_output.append_text("{prefix}[b]{character}:[/b] {text}\n".format({
+ prefix = prefix,
+ character = line.character,
+ text = line.text
+ }))
+ else:
+ log_output.append_text("{prefix}{text}".format({
+ prefix = prefix,
+ text = line.text
+ }))
+
+ DMConstants.TYPE_MUTATION:
+ var dialogue: String = FileAccess.get_file_as_string(resource_and_id.resource.resource_path)
+ log_output.append_text("{prefix}[color={color}]{mutation}[/color]\n".format({
+ prefix = prefix,
+ color = Color(DMThemeValues.get_values_from_editor().mutations_color, 0.5).to_html(),
+ mutation = dialogue.split("\n")[line.id.to_int()].strip_edges()
+ }))
+
+
+func _update_state_tree() -> void:
+ state_tree.clear()
+
+ var root: TreeItem = state_tree.create_item()
+
+ state_tree.columns = 2
+
+ # Current scene
+ var item: TreeItem = state_tree.create_item(root)
+ item.set_selectable(0, false)
+ item.set_icon(0, get_theme_icon("PackedScene", "EditorIcons"))
+ item.set_text(0, DMConstants.translate("Current scene"))
+ create_state_items(item, current_scene)
+
+ # Context
+ item = state_tree.create_item(root)
+ item.set_selectable(0, false)
+ item.set_icon(0, CONTEXT_ICON)
+ item.set_text(0, DMConstants.translate("Context Nodes"))
+ create_state_items(item, contexts)
+
+ # Globals
+ item = state_tree.create_item(root)
+ item.set_selectable(0, false)
+ item.set_icon(0, get_theme_icon("Object", "EditorIcons"))
+ item.set_text(0, DMConstants.translate("Globals"))
+ create_state_items(item, autoloads)
+
+
+func create_state_items(parent_item: TreeItem, state_data_list: Dictionary) -> void:
+ if state_data_list.is_empty():
+ var item: TreeItem = state_tree.create_item(parent_item)
+ item.set_text(0, DMConstants.translate("None"))
+ item.set_custom_color(0, Color(get_theme_color("font_color", "Editor"), 0.5))
+ else:
+ for data: Dictionary in state_data_list.values():
+ var item: TreeItem = state_tree.create_item(parent_item)
+ item.set_meta("state", data)
+ if has_theme_icon(data.base_type, "EditorIcons"):
+ item.set_icon(0, get_theme_icon(data.base_type, "EditorIcons"))
+ item.set_text(0, data.alias)
+ item.set_text(1, data.path)
+ if not data.script.is_empty():
+ item.add_button(1, get_theme_icon("Script", "EditorIcons"), 0)
+
+
+func _open_script(path: String) -> void:
+ EditorInterface.edit_script(load(path))
+ EditorInterface.set_main_screen_editor("Script")
+
+
+func _get_resource_and_id(id: String) -> Dictionary:
+ var uid_and_id: PackedStringArray = id.split("@")
+ if uid_and_id[0].is_empty():
+ return {
+ resource = null,
+ id = uid_and_id[1]
+ }
+ else:
+ var resource: DialogueResource = load("uid://%s" % [uid_and_id[0]])
+ return {
+ resource = resource,
+ id = uid_and_id[1]
+ }
+
+
+#region Signals
+
+
+func _on_state_tree_item_selected() -> void:
+ var selected_item: TreeItem = state_tree.get_selected()
+
+ if not selected_item.has_meta("state"): return
+
+ var context: Dictionary = selected_item.get_meta("state")
+ session.send_message("dm:select_node", [context.instance_id])
+
+
+func _on_state_tree_button_clicked(item: TreeItem, _column: int, id: int, mouse_button_index: int) -> void:
+ if mouse_button_index != MOUSE_BUTTON_LEFT: return
+
+ match id:
+ 0:
+ _open_script(item.get_meta("state").script)
+
+
+func _on_clear_button_pressed() -> void:
+ log_output.clear()
+
+
+func _on_log_meta_clicked(meta: Variant) -> void:
+ var resource_and_id: Dictionary = _get_resource_and_id(meta)
+ DMPlugin.open_file_at_line(resource_and_id.resource.resource_path, resource_and_id.id.to_int())
+ EditorInterface.set_main_screen_editor("Dialogue")
+
+
+#endregion
diff --git a/addons/dialogue_manager/views/debugger_view.gd.uid b/addons/dialogue_manager/views/debugger_view.gd.uid
new file mode 100644
index 0000000..0db1d0f
--- /dev/null
+++ b/addons/dialogue_manager/views/debugger_view.gd.uid
@@ -0,0 +1 @@
+uid://bwbcna2qcjs3c
diff --git a/addons/dialogue_manager/views/debugger_view.tscn b/addons/dialogue_manager/views/debugger_view.tscn
new file mode 100644
index 0000000..69aa735
--- /dev/null
+++ b/addons/dialogue_manager/views/debugger_view.tscn
@@ -0,0 +1,71 @@
+[gd_scene format=3 uid="uid://iq0auavinobq"]
+
+[ext_resource type="Script" uid="uid://bwbcna2qcjs3c" path="res://addons/dialogue_manager/views/debugger_view.gd" id="1_rkwgn"]
+
+[node name="DebuggerView" type="PanelContainer" unique_id=2089218636]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_rkwgn")
+
+[node name="RuntimeWarning" type="Label" parent="." unique_id=1661318654]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 1
+theme_override_colors/font_color = Color(0, 0, 0, 1)
+text = "Dialogue Manager isn't running."
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="Content" type="HSplitContainer" parent="." unique_id=655238255]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="StateTree" type="Tree" parent="Content" unique_id=1715443994]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+columns = 2
+allow_reselect = true
+hide_root = true
+select_mode = 1
+
+[node name="LogContainer" type="VBoxContainer" parent="Content" unique_id=1181575931]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="LogToolbar" type="HBoxContainer" parent="Content/LogContainer" unique_id=981432217]
+layout_mode = 2
+
+[node name="HistoryLabel" type="Label" parent="Content/LogContainer/LogToolbar" unique_id=901216255]
+unique_name_in_owner = true
+layout_mode = 2
+text = "History"
+
+[node name="Control" type="Control" parent="Content/LogContainer/LogToolbar" unique_id=2099645908]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ClearButton" type="Button" parent="Content/LogContainer/LogToolbar" unique_id=202664467]
+unique_name_in_owner = true
+layout_mode = 2
+flat = true
+
+[node name="LogOutput" type="RichTextLabel" parent="Content/LogContainer" unique_id=149198174]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+focus_mode = 2
+bbcode_enabled = true
+scroll_following = true
+selection_enabled = true
+drag_and_drop_selection_enabled = false
+
+[connection signal="button_clicked" from="Content/StateTree" to="." method="_on_state_tree_button_clicked"]
+[connection signal="item_selected" from="Content/StateTree" to="." method="_on_state_tree_item_selected"]
+[connection signal="pressed" from="Content/LogContainer/LogToolbar/ClearButton" to="." method="_on_clear_button_pressed"]
+[connection signal="meta_clicked" from="Content/LogContainer/LogOutput" to="." method="_on_log_meta_clicked"]
diff --git a/addons/dialogue_manager/views/find_in_dialogue_view.gd b/addons/dialogue_manager/views/find_in_dialogue_view.gd
new file mode 100644
index 0000000..715bcc5
--- /dev/null
+++ b/addons/dialogue_manager/views/find_in_dialogue_view.gd
@@ -0,0 +1,237 @@
+@tool
+extends PanelContainer
+
+signal result_selected(path: String, cursor: Vector2, length: int)
+
+
+var main_view: Control
+
+@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 _ready() -> void:
+ remove_child(result_template)
+
+
+func _exit_tree() -> void:
+ result_template.queue_free()
+
+
+func prepare() -> void:
+ if not is_node_ready():
+ await ready
+
+ input.grab_focus()
+
+ var template_label: RichTextLabel = result_template.get_node("Label")
+ template_label.get_theme_stylebox(&"focus").bg_color = main_view.code_edit.theme_overrides.current_line_color
+ template_label.add_theme_font_override(&"normal_font", main_view.code_edit.get_theme_font(&"font"))
+
+ replace_toggle.set_pressed_no_signal(false)
+ replace_container.hide()
+
+ $VBoxContainer/HBoxContainer/FindContainer/Label.text = DMConstants.translate(&"search.find")
+ input.placeholder_text = DMConstants.translate(&"search.placeholder")
+ input.text = ""
+ search_button.text = DMConstants.translate(&"search.find_all")
+ match_case_button.text = DMConstants.translate(&"search.match_case")
+ replace_toggle.text = DMConstants.translate(&"search.toggle_replace")
+ $VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceLabel.text = DMConstants.translate(&"search.replace_with")
+ replace_input.placeholder_text = DMConstants.translate(&"search.replace_placeholder")
+ replace_input.text = ""
+ replace_all_button.text = DMConstants.translate(&"search.replace_all")
+ replace_selected_button.text = DMConstants.translate(&"search.replace_selected")
+
+ selections.clear()
+ current_results = {}
+
+
+#region helpers
+
+
+func update_results_view() -> void:
+ for child: Node in results_container.get_children():
+ child.queue_free()
+
+ for path: String 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: Dictionary 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: bool) -> void:
+ 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: DMThemeValues = main_view.code_edit.theme_overrides
+ var highlight: String = ""
+ if replace_toggle.button_pressed:
+ var matched_word: String = "[bgcolor=" + Color(colors.critical_color, 0.5).to_html() + "][color=" + colors.text_color.to_html() + "]" + path_result.matched_text + "[/color][/bgcolor]"
+ highlight = "[s]" + matched_word + "[/s][bgcolor=" + Color(colors.notice_color, 0.5).to_html() + "][color=" + colors.text_color.to_html() + "]" + replace_input.text + "[/color][/bgcolor]"
+ else:
+ highlight = "[bgcolor=" + Color(colors.notice_color, 0.5).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 + 1).lpad(4), text]
+ result_label.gui_input.connect(func(event: InputEvent) -> void:
+ 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 file: FileAccess
+ for path: String in DMCache.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: int 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: String 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)
+ 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: Dictionary 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.close()
+ file = FileAccess.open(path, FileAccess.WRITE)
+ file.store_string(replaced_text)
+ file.close()
+ else:
+ main_view.open_buffers.get(path).text = replaced_text
+ if main_view.current_file_path == path:
+ main_view.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/views/find_in_dialogue_view.gd.uid b/addons/dialogue_manager/views/find_in_dialogue_view.gd.uid
new file mode 100644
index 0000000..380a491
--- /dev/null
+++ b/addons/dialogue_manager/views/find_in_dialogue_view.gd.uid
@@ -0,0 +1 @@
+uid://q368fmxxa8sd
diff --git a/addons/dialogue_manager/views/find_in_dialogue_view.tscn b/addons/dialogue_manager/views/find_in_dialogue_view.tscn
new file mode 100644
index 0000000..30582d0
--- /dev/null
+++ b/addons/dialogue_manager/views/find_in_dialogue_view.tscn
@@ -0,0 +1,129 @@
+[gd_scene format=3 uid="uid://0n7hwviyyly4"]
+
+[ext_resource type="Script" uid="uid://q368fmxxa8sd" path="res://addons/dialogue_manager/views/find_in_dialogue_view.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="PanelContainer" unique_id=473596958]
+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="." unique_id=1659451717]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1270203034]
+layout_mode = 2
+
+[node name="FindContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer" unique_id=1274804346]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer/FindContainer" unique_id=1987925563]
+layout_mode = 2
+text = "Find:"
+
+[node name="Input" type="LineEdit" parent="VBoxContainer/HBoxContainer/FindContainer" unique_id=1153613394]
+unique_name_in_owner = true
+layout_mode = 2
+clear_button_enabled = true
+
+[node name="FindToolbar" type="HBoxContainer" parent="VBoxContainer/HBoxContainer/FindContainer" unique_id=1375277403]
+layout_mode = 2
+
+[node name="SearchButton" type="Button" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar" unique_id=111642216]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Find all..."
+
+[node name="MatchCaseButton" type="CheckBox" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar" unique_id=1769803045]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Match case"
+
+[node name="Control" type="Control" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar" unique_id=1341916533]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ReplaceToggle" type="CheckButton" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar" unique_id=147932420]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace"
+
+[node name="ReplaceContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer" unique_id=494667120]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="ReplaceLabel" type="Label" parent="VBoxContainer/HBoxContainer/ReplaceContainer" unique_id=1745047952]
+layout_mode = 2
+text = "Replace with:"
+
+[node name="ReplaceInput" type="LineEdit" parent="VBoxContainer/HBoxContainer/ReplaceContainer" unique_id=1199250877]
+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" unique_id=525743911]
+layout_mode = 2
+
+[node name="ReplaceSelectedButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar" unique_id=1273903541]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace selected"
+
+[node name="ReplaceAllButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar" unique_id=276418265]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Replace all"
+
+[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer" unique_id=1098149814]
+layout_mode = 2
+
+[node name="ReplaceToolbar" type="HBoxContainer" parent="VBoxContainer/VBoxContainer" unique_id=1654793986]
+layout_mode = 2
+
+[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=76967813]
+layout_mode = 2
+size_flags_vertical = 3
+follow_focus = true
+
+[node name="ResultsContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=453744417]
+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_id=1701180897]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CheckBox" type="CheckBox" parent="ResultTemplate" unique_id=78017410]
+layout_mode = 2
+
+[node name="Label" type="RichTextLabel" parent="ResultTemplate" unique_id=332420941]
+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/views/main_view.gd b/addons/dialogue_manager/views/main_view.gd
new file mode 100644
index 0000000..70e6cc7
--- /dev/null
+++ b/addons/dialogue_manager/views/main_view.gd
@@ -0,0 +1,824 @@
+@tool
+extends Control
+
+
+const OPEN_OPEN: int = 100
+const OPEN_QUICK: int = 101
+const OPEN_CLEAR: int = 102
+
+const ITEM_SAVE: int = 100
+const ITEM_SAVE_AS: int = 101
+const ITEM_CLOSE: int = 102
+const ITEM_CLOSE_ALL: int = 103
+const ITEM_CLOSE_OTHERS: int = 104
+const ITEM_COPY_PATH: int = 200
+const ITEM_SHOW_IN_FILESYSTEM: int = 201
+
+
+signal confirmation_closed()
+
+
+@onready var parse_timer: Timer = $ParseTimer
+
+# Blank slate banner
+@onready var blank_slate: CenterContainer = %BlankSlate
+
+# Dialogs
+@onready var new_dialog: FileDialog = $NewDialog
+@onready var save_dialog: FileDialog = $SaveDialog
+@onready var open_dialog: FileDialog = $OpenDialog
+@onready var quick_open_dialog: ConfirmationDialog = $QuickOpenDialog
+@onready var quick_open_files_list: VBoxContainer = $QuickOpenDialog/QuickOpenFilesList
+@onready var errors_dialog: AcceptDialog = $ErrorsDialog
+@onready var build_error_dialog: AcceptDialog = $BuildErrorDialog
+@onready var close_confirmation_dialog: ConfirmationDialog = $CloseConfirmationDialog
+@onready var updated_dialog: AcceptDialog = $UpdatedDialog
+
+# 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 file_name_label: Button = %FilenameLabel
+@onready var test_button: Button = %TestButton
+@onready var test_line_button: Button = %TestLineButton
+@onready var search_button: Button = %SearchButton
+@onready var insert_button: MenuButton = %InsertButton
+@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: Control = %SearchAndReplace
+
+# Code editor
+@onready var content: HSplitContainer = %Content
+@onready var files_list: Control = %FilesList
+@onready var files_popup_menu: PopupMenu = %FilesPopupMenu
+@onready var cue_list: Control = %CueList
+@onready var code_edit: DMCodeEdit = %CodeEdit
+@onready var errors_panel: Control = %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 == "" or not open_buffers.has(current_file_path):
+ file_name_label.text = ""
+ save_all_button.disabled = true
+ test_button.disabled = true
+ test_line_button.disabled = true
+ search_button.disabled = true
+ insert_button.disabled = true
+ content.dragger_visibility = SplitContainer.DRAGGER_HIDDEN
+ files_list.hide()
+ cue_list.hide()
+ code_edit.hide()
+ errors_panel.hide()
+ search_and_replace.hide()
+ blank_slate.show()
+ else:
+ file_name_label.text = current_file_path.get_file()
+ test_button.disabled = false
+ test_line_button.disabled = false
+ search_button.disabled = false
+ insert_button.disabled = false
+ content.dragger_visibility = SplitContainer.DRAGGER_VISIBLE
+ files_list.show()
+ cue_list.show()
+ code_edit.show()
+ blank_slate.hide()
+
+ var cursor: Vector2 = DMSettings.get_caret(current_file_path)
+ var scroll_vertical: float = DMSettings.get_scroll(current_file_path)
+
+ code_edit.text = open_buffers[current_file_path].text
+ code_edit.clear_undo_history()
+ code_edit.set_cursor(cursor)
+ code_edit.scroll_vertical = scroll_vertical
+ code_edit.grab_focus()
+
+ _on_code_edit_text_changed()
+
+ errors_panel.errors = []
+ code_edit.errors = []
+ code_edit.runtime_error = null
+
+ if search_and_replace.visible:
+ search_and_replace.search()
+ get:
+ return current_file_path
+
+# A reference to the currently open files and their last saved text
+var open_buffers: Dictionary = {}
+
+
+func _ready() -> void:
+ apply_theme()
+
+ # Start with nothing open
+ self.current_file_path = ""
+
+ # Set up the update checker
+ version_label.text = "v%s" % DMPlugin.get_version()
+ update_button.on_before_refresh = func on_before_refresh() -> bool:
+ # Save everything
+ DMSettings.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: Variant = DMSettings.get_user_value("just_refreshed", null)
+ if just_refreshed != null:
+ DMSettings.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)
+
+ code_edit.main_view = self
+ var editor_settings: EditorSettings = EditorInterface.get_editor_settings()
+ editor_settings.settings_changed.connect(_on_editor_settings_changed)
+ _on_editor_settings_changed()
+
+ ProjectSettings.settings_changed.connect(_on_project_settings_changed)
+ _on_project_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 = DMSettings.get_user_value("reopen_files", [])
+ for reopen_file: String in reopen_files:
+ open_file(reopen_file)
+
+ self.current_file_path = DMSettings.get_user_value("most_recent_reopen_file", "")
+
+ save_all_button.disabled = true
+
+ close_confirmation_dialog.ok_button_text = DMConstants.translate(&"confirm_close.save")
+ close_confirmation_dialog.add_button(DMConstants.translate(&"confirm_close.discard"), true, "discard")
+
+ errors_dialog.dialog_text = DMConstants.translate(&"errors_in_script")
+
+ # Update the buffer if a file was modified externally (retains undo step)
+ DMPlugin.instance.cache_file_content_changed.connect(_on_cache_file_content_changed)
+
+ EditorInterface.get_file_system_dock().files_moved.connect(_on_files_moved)
+
+ code_edit.get_v_scroll_bar().value_changed.connect(_on_code_edit_scroll_changed)
+
+
+func _exit_tree() -> void:
+ DMSettings.set_user_value("reopen_files", open_buffers.keys())
+ DMSettings.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 = DMPlugin.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()
+
+
+# Check if any open files have unsaved changes.
+func count_unsaved_files() -> int:
+ var count: int = 0
+ for buffer: Dictionary in open_buffers.values():
+ if buffer.text != buffer.pristine_text:
+ count += 1
+ return count
+
+
+# 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
+
+ if just_refreshed.current_file_path != "":
+ EditorInterface.edit_resource(load(just_refreshed.current_file_path))
+ else:
+ EditorInterface.set_main_screen_editor("Dialogue")
+
+ updated_dialog.dialog_text = DMConstants.translate(&"update.success").format({ version = update_button.get_version() })
+ updated_dialog.popup_centered()
+
+
+func new_file(path: String, initial_content: String = "") -> void:
+ if open_buffers.has(path):
+ remove_file_from_open_buffers(path)
+
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
+ if initial_content == "":
+ file.store_string(DMSettings.get_setting(DMSettings.NEW_FILE_TEMPLATE, ""))
+ else:
+ file.store_string(initial_content)
+
+ EditorInterface.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 FileAccess.file_exists(path): return
+
+ if not open_buffers.has(path):
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ var text: String = file.get_as_text()
+
+ open_buffers[path] = {
+ cursor = Vector2.ZERO,
+ text = text,
+ pristine_text = text
+ }
+
+ DMSettings.add_recent_file(path)
+ build_open_menu()
+
+ files_list.files = open_buffers.keys()
+ files_list.select_file(path)
+
+ self.current_file_path = path
+
+
+func quick_open() -> void:
+ quick_open_files_list.files = DMCache.get_files()
+ quick_open_dialog.popup_centered()
+ quick_open_files_list.focus_filter()
+
+
+func show_file_in_filesystem(path: String) -> void:
+ EditorInterface.get_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: String 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:
+ DMCache.mark_files_for_reimport(saved_files)
+
+
+# Save a file
+func save_file(path: String, rescan_file_system: bool = true) -> void:
+ var buffer: Dictionary = 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:
+ EditorInterface.get_resource_filesystem().scan()
+
+
+func close_file(path: String) -> void:
+ if not path in open_buffers.keys(): return
+
+ var buffer: Dictionary = 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 = DMConstants.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: int = 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(code_edit):
+ var theme_values: DMThemeValues = DMThemeValues.get_values_from_editor()
+
+ code_edit.theme_overrides = theme_values
+
+ new_button.icon = get_theme_icon("New", "EditorIcons")
+ new_button.tooltip_text = DMConstants.translate(&"start_a_new_file")
+
+ open_button.icon = get_theme_icon("Load", "EditorIcons")
+ open_button.tooltip_text = DMConstants.translate(&"open_a_file")
+
+ save_all_button.icon = get_theme_icon("Save", "EditorIcons")
+ save_all_button.tooltip_text = DMConstants.translate(&"start_all_files")
+
+ find_in_files_button.icon = get_theme_icon("ViewportZoom", "EditorIcons")
+ find_in_files_button.tooltip_text = DMConstants.translate(&"find_in_files")
+
+ test_button.icon = get_theme_icon("DebugNext", "EditorIcons")
+ test_button.tooltip_text = DMConstants.translate(&"test_dialogue")
+
+ test_line_button.icon = get_theme_icon("DebugStep", "EditorIcons")
+ test_line_button.tooltip_text = DMConstants.translate(&"test_dialogue_from_line")
+
+ search_button.icon = get_theme_icon("Search", "EditorIcons")
+ search_button.tooltip_text = DMConstants.translate(&"search_for_text")
+
+ insert_button.icon = get_theme_icon("RichTextEffect", "EditorIcons")
+ insert_button.tooltip_text = DMConstants.translate(&"insert")
+
+ support_button.icon = get_theme_icon("Heart", "EditorIcons")
+ support_button.tooltip_text = DMConstants.translate(&"show_support")
+
+ docs_button.icon = get_theme_icon("Help", "EditorIcons")
+ docs_button.text = DMConstants.translate(&"docs")
+
+ blank_slate.apply_theme()
+ 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"), DMConstants.translate(&"insert.wave_bbcode"), 0)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.shake_bbcode"), 1)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("Time", "EditorIcons"), DMConstants.translate(&"insert.typing_pause"), 3)
+ popup.add_icon_item(get_theme_icon("ViewportSpeed", "EditorIcons"), DMConstants.translate(&"insert.typing_speed_change"), 4)
+ popup.add_icon_item(get_theme_icon("DebugNext", "EditorIcons"), DMConstants.translate(&"insert.auto_advance"), 5)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.cue"), 6)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.dialogue"), 7)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.response"), 8)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.random_lines"), 9)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.random_text"), 10)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.jump"), 11)
+ popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DMConstants.translate(&"insert.end_dialogue"), 12)
+ popup.add_separator()
+ popup.add_icon_item(get_theme_icon("Translation", "EditorIcons"), DMConstants.translate(&"generate_line_ids"), 13)
+
+ # Dialog sizes
+ new_dialog.min_size = Vector2(600, 500) * theme_values.scale
+ save_dialog.min_size = Vector2(600, 500) * theme_values.scale
+ open_dialog.min_size = Vector2(600, 500) * theme_values.scale
+ quick_open_dialog.min_size = Vector2(400, 600) * theme_values.scale
+
+
+#region Helpers
+
+
+# Move the cursor to a given cue in the dialogue editor
+func go_to_cue(cue: String, create_if_none: bool = false) -> void:
+ code_edit.go_to_cue(cue, create_if_none)
+ code_edit.grab_focus()
+
+
+# Move the cursor to a given line number.
+func go_to_line_number(line_number: int) -> void:
+ code_edit.go_to_line_number(line_number)
+ code_edit.grab_focus()
+
+
+# Refresh the open menu with the latest files
+func build_open_menu() -> void:
+ var menu: PopupMenu = open_button.get_popup()
+ menu.clear()
+ menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), DMConstants.translate(&"open.open"), OPEN_OPEN)
+ menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), DMConstants.translate(&"open.quick_open"), OPEN_QUICK)
+ menu.add_separator()
+
+ var recent_files: Array = DMSettings.get_recent_files()
+ if recent_files.size() == 0:
+ menu.add_item(DMConstants.translate(&"open.no_recent_files"))
+ menu.set_item_disabled(2, true)
+ else:
+ for path: String in recent_files:
+ if FileAccess.file_exists(path):
+ menu.add_item(path)
+
+ menu.add_separator()
+ menu.add_item(DMConstants.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)
+
+
+# Check the current text for errors
+func compile() -> void:
+ # Skip if nothing to parse
+ if current_file_path == "": return
+
+ var result: DMCompilerResult = DMCompiler.compile_string(code_edit.text, current_file_path)
+ code_edit.errors = result.errors
+ errors_panel.errors = result.errors
+ cue_list.cues = code_edit.get_cues(true)
+
+
+func show_build_error_dialog() -> void:
+ build_error_dialog.dialog_text = DMConstants.translate(&"errors_with_build")
+ build_error_dialog.popup_centered()
+
+
+# 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()
+
+
+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()
+
+
+func run_test_scene(from_key: String) -> void:
+ DMSettings.set_user_value("run_key", from_key)
+ DMSettings.set_user_value("is_running_test_scene", true)
+ DMSettings.set_user_value("run_resource_path", current_file_path)
+ var test_scene_path: String = DMSettings.get_setting(DMSettings.CUSTOM_TEST_SCENE_PATH, "res://addons/dialogue_manager/test_scene.tscn")
+ EditorInterface.play_custom_scene(test_scene_path)
+
+
+#endregion
+
+#region Signals
+
+
+func _on_files_moved(old_file: String, new_file_path: String) -> void:
+ if open_buffers.has(old_file):
+ open_buffers[new_file_path] = open_buffers[old_file]
+ open_buffers.erase(old_file)
+
+
+func _on_cache_file_content_changed(path: String, new_content: String) -> void:
+ if open_buffers.has(path):
+ var buffer: Dictionary = open_buffers[path]
+ if buffer.text == buffer.pristine_text and buffer.text != new_content:
+ buffer.text = new_content
+ if path == current_file_path:
+ code_edit.text = new_content
+ cue_list.cues = code_edit.get_cues(true)
+ buffer.pristine_text = new_content
+
+
+func _on_editor_settings_changed() -> void:
+ var editor_settings: EditorSettings = EditorInterface.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_project_settings_changed() -> void:
+ code_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY if DMSettings.get_setting(DMSettings.WRAP_LONG_LINES, false) else TextEdit.LINE_WRAPPING_NONE
+
+
+func _on_open_menu_id_pressed(id: int) -> void:
+ match id:
+ OPEN_OPEN:
+ open_dialog.popup_centered()
+ OPEN_QUICK:
+ quick_open()
+ OPEN_CLEAR:
+ DMSettings.clear_recent_files()
+ build_open_menu()
+ _:
+ var menu: PopupMenu = open_button.get_popup()
+ var item: String = 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("~ cue")
+ 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("=> cue")
+ 12:
+ code_edit.insert_text_at_cursor("=> END")
+ 13:
+ var cursor: Vector2i = code_edit.get_cursor()
+ var scroll_vertical: float = code_edit.scroll_vertical
+ code_edit.text = DMTranslationUtilities.generate_static_line_ids_for_text(code_edit.text, current_file_path)
+ code_edit.set_cursor(cursor)
+ _on_code_edit_text_changed()
+ code_edit.set_deferred("scroll_vertical", scroll_vertical)
+
+
+func _on_main_view_theme_changed() -> void:
+ 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 = "untitled"
+ new_dialog.popup_centered()
+
+
+func _on_new_dialog_confirmed() -> void:
+ var path: String = new_dialog.current_path
+ if path.get_file() == ".dialogue":
+ path = "%s/untitled.dialogue" % path.get_basename()
+ new_file(path)
+ open_file(path)
+
+
+func _on_new_dialog_file_selected(path: String) -> void:
+ new_file(path)
+ open_file(path)
+
+
+func _on_save_dialog_file_selected(path: String) -> void:
+ if path == "": path = "res://untitled.dialogue"
+
+ 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_quick_open_files_list_file_double_clicked(file_path: String) -> void:
+ quick_open_dialog.hide()
+ open_file(file_path)
+
+
+func _on_quick_open_dialog_confirmed() -> void:
+ if quick_open_files_list.last_selected_file_path:
+ open_file(quick_open_files_list.last_selected_file_path)
+
+
+func _on_save_all_button_pressed() -> void:
+ save_files()
+
+
+func _on_find_in_files_button_pressed() -> void:
+ DMPlugin.show_find_in_dialogue()
+
+
+func _on_code_edit_text_changed() -> void:
+ var buffer: Dictionary = 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: Dictionary) -> bool: return d.text != d.pristine_text).size() == 0
+
+ parse_timer.start(1)
+
+
+func _on_code_edit_scroll_changed(_value: int) -> void:
+ DMSettings.set_scroll(current_file_path, code_edit.scroll_vertical)
+
+
+func _on_code_edit_active_cue_changed(cue: String) -> void:
+ cue_list.select_cue(cue)
+
+
+func _on_code_edit_caret_changed() -> void:
+ DMSettings.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_cue_list_cue_selected(cue: String) -> void:
+ go_to_cue(cue)
+
+
+func _on_parse_timer_timeout() -> void:
+ parse_timer.stop()
+ compile()
+
+
+func _on_errors_panel_error_pressed(line_number: int, column_number: int) -> void:
+ code_edit.set_caret_line(line_number - 1)
+ 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_test_button_pressed() -> void:
+ save_file(current_file_path, false)
+ DMCache.reimport_files([current_file_path])
+
+ if errors_panel.errors.size() > 0:
+ errors_dialog.popup_centered()
+ return
+
+ run_test_scene("")
+
+
+func _on_test_line_button_pressed() -> void:
+ save_file(current_file_path)
+
+ if errors_panel.errors.size() > 0:
+ errors_dialog.popup_centered()
+ return
+
+ # Find next non-empty line
+ var line_to_run: int = 0
+ for i: int in range(code_edit.get_cursor().y, code_edit.get_line_count()):
+ if not code_edit.get_line(i).is_empty():
+ line_to_run = i
+ break
+
+ run_test_scene(str(line_to_run))
+
+
+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) -> void:
+ close_file(path)
+
+
+func _on_files_popup_menu_about_to_popup() -> void:
+ files_popup_menu.clear()
+
+ var shortcuts: Dictionary = DMPlugin.get_editor_shortcuts()
+
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.save"), ITEM_SAVE, OS.find_keycode_from_string(shortcuts.get("save")[0].as_text_keycode()))
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.save_as"), ITEM_SAVE_AS)
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.close"), ITEM_CLOSE, OS.find_keycode_from_string(shortcuts.get("close_file")[0].as_text_keycode()))
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.close_all"), ITEM_CLOSE_ALL)
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.close_other_files"), ITEM_CLOSE_OTHERS)
+ files_popup_menu.add_separator()
+ files_popup_menu.add_item(DMConstants.translate(&"buffer.copy_file_path"), ITEM_COPY_PATH)
+ files_popup_menu.add_item(DMConstants.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: String in open_buffers.keys():
+ close_file(path)
+ ITEM_CLOSE_OTHERS:
+ var current_current_file_path: String = current_file_path
+ for path: String 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, cue: String) -> void:
+ open_file(path)
+ if cue != "":
+ code_edit.go_to_cue(cue)
+ 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: Vector2i, length: int) -> void:
+ open_file(path)
+ code_edit.select(cursor.y, cursor.x, cursor.y, cursor.x + length)
+ code_edit.set_line_as_center_visible(cursor.y)
+
+
+func _on_filename_label_pressed() -> void:
+ EditorInterface.get_file_system_dock().navigate_to_path(current_file_path)
+
+
+func _on_blank_slate_new_button_pressed() -> void:
+ new_dialog.current_file = "untitled"
+ new_dialog.popup_centered()
+
+
+func _on_blank_slate_quick_open_button_pressed() -> void:
+ quick_open()
+
+
+#endregion
diff --git a/addons/dialogue_manager/views/main_view.gd.uid b/addons/dialogue_manager/views/main_view.gd.uid
new file mode 100644
index 0000000..10e66f4
--- /dev/null
+++ b/addons/dialogue_manager/views/main_view.gd.uid
@@ -0,0 +1 @@
+uid://cipjcc7bkh1pc
diff --git a/addons/dialogue_manager/views/main_view.tscn b/addons/dialogue_manager/views/main_view.tscn
new file mode 100644
index 0000000..543f0b7
--- /dev/null
+++ b/addons/dialogue_manager/views/main_view.tscn
@@ -0,0 +1,388 @@
+[gd_scene format=3 uid="uid://cbuf1q3xsse3q"]
+
+[ext_resource type="Script" uid="uid://cipjcc7bkh1pc" 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/cue_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" uid="uid://klpiq4tk3t7a" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="7_necsa"]
+[ext_resource type="PackedScene" uid="uid://s41qxl30184y" path="res://addons/dialogue_manager/components/blank_slate.tscn" id="9_faxki"]
+
+[sub_resource type="Image" id="Image_faxki"]
+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_ka3gk"]
+image = SubResource("Image_faxki")
+
+[sub_resource type="Image" id="Image_y6rqu"]
+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_57eek"]
+image = SubResource("Image_y6rqu")
+
+[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_mpdoc"]
+script = ExtResource("7_necsa")
+
+[node name="MainView" type="Control" unique_id=1975859718]
+clip_contents = true
+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="." unique_id=1210958138]
+
+[node name="Margin" type="MarginContainer" parent="." unique_id=424731994]
+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_id=267962742]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+dragger_visibility = 1
+
+[node name="SidePanel" type="VBoxContainer" parent="Margin/Content" unique_id=408822052]
+custom_minimum_size = Vector2(150, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/SidePanel" unique_id=1890446441]
+layout_mode = 2
+
+[node name="NewButton" type="Button" parent="Margin/Content/SidePanel/Toolbar" unique_id=916776070]
+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_id=880106178]
+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_ka3gk")
+popup/item_0/id = 100
+popup/item_1/icon = SubResource("ImageTexture_ka3gk")
+popup/item_1/id = 101
+popup/item_2/id = -1
+popup/item_2/separator = true
+popup/item_3/text = "res://examples/dialogue.dialogue"
+popup/item_3/icon = SubResource("ImageTexture_ka3gk")
+popup/item_3/id = 3
+popup/item_4/text = "res://examples/dialogue_with_input.dialogue"
+popup/item_4/icon = SubResource("ImageTexture_ka3gk")
+popup/item_4/id = 4
+popup/item_5/text = "res://examples/dialogue_for_point_n_click.dialogue"
+popup/item_5/icon = SubResource("ImageTexture_ka3gk")
+popup/item_5/id = 5
+popup/item_6/text = "res://examples/dialogue_for_visual_novel.dialogue"
+popup/item_6/icon = SubResource("ImageTexture_ka3gk")
+popup/item_6/id = 6
+popup/item_7/id = -1
+popup/item_7/separator = true
+popup/item_8/text = "Clear recent files"
+popup/item_8/id = 102
+
+[node name="SaveAllButton" type="Button" parent="Margin/Content/SidePanel/Toolbar" unique_id=940841574]
+unique_name_in_owner = true
+layout_mode = 2
+disabled = true
+flat = true
+
+[node name="FindInFilesButton" type="Button" parent="Margin/Content/SidePanel/Toolbar" unique_id=213493052]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Find in files..."
+flat = true
+
+[node name="Bookmarks" type="VSplitContainer" parent="Margin/Content/SidePanel" unique_id=1966503844]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="FilesList" parent="Margin/Content/SidePanel/Bookmarks" unique_id=1460458494 instance=ExtResource("2_npj2k")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+
+[node name="FilesPopupMenu" type="PopupMenu" parent="Margin/Content/SidePanel/Bookmarks/FilesList" unique_id=253084069]
+unique_name_in_owner = true
+
+[node name="CueList" parent="Margin/Content/SidePanel/Bookmarks" unique_id=2063152064 instance=ExtResource("2_onb4i")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+
+[node name="CodePanel" type="VBoxContainer" parent="Margin/Content" unique_id=2013417278]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_stretch_ratio = 4.0
+
+[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/CodePanel" unique_id=177899511]
+layout_mode = 2
+
+[node name="InsertButton" type="MenuButton" parent="Margin/Content/CodePanel/Toolbar" unique_id=1765432833]
+unique_name_in_owner = true
+layout_mode = 2
+disabled = true
+item_count = 15
+popup/item_0/text = "Wave BBCode"
+popup/item_0/icon = SubResource("ImageTexture_57eek")
+popup/item_0/id = 0
+popup/item_1/text = "Shake BBCode"
+popup/item_1/icon = SubResource("ImageTexture_57eek")
+popup/item_1/id = 1
+popup/item_2/id = -1
+popup/item_2/separator = true
+popup/item_3/text = "Typing pause"
+popup/item_3/icon = SubResource("ImageTexture_57eek")
+popup/item_3/id = 3
+popup/item_4/text = "Typing speed change"
+popup/item_4/icon = SubResource("ImageTexture_57eek")
+popup/item_4/id = 4
+popup/item_5/text = "Auto advance"
+popup/item_5/icon = SubResource("ImageTexture_57eek")
+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_57eek")
+popup/item_7/id = 6
+popup/item_8/text = "Dialogue"
+popup/item_8/icon = SubResource("ImageTexture_57eek")
+popup/item_8/id = 7
+popup/item_9/text = "Response"
+popup/item_9/icon = SubResource("ImageTexture_57eek")
+popup/item_9/id = 8
+popup/item_10/text = "Random lines"
+popup/item_10/icon = SubResource("ImageTexture_57eek")
+popup/item_10/id = 9
+popup/item_11/text = "Random text"
+popup/item_11/icon = SubResource("ImageTexture_57eek")
+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_57eek")
+popup/item_13/id = 11
+popup/item_14/text = "End dialogue"
+popup/item_14/icon = SubResource("ImageTexture_57eek")
+popup/item_14/id = 12
+
+[node name="SearchButton" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=121689358]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Search for text"
+disabled = true
+toggle_mode = true
+flat = true
+
+[node name="TestButton" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=1610822770]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Test dialogue"
+disabled = true
+flat = true
+
+[node name="TestLineButton" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=1575726308]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Test dialogue"
+disabled = true
+flat = true
+
+[node name="Spacer2" type="Control" parent="Margin/Content/CodePanel/Toolbar" unique_id=249482575]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="FilenameLabel" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=1051945501]
+unique_name_in_owner = true
+layout_mode = 2
+flat = true
+
+[node name="Spacer3" type="Control" parent="Margin/Content/CodePanel/Toolbar" unique_id=610372586]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="SupportButton" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=1042093313]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Support Dialogue Manager"
+flat = true
+
+[node name="DocsButton" type="Button" parent="Margin/Content/CodePanel/Toolbar" unique_id=1404920471]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Docs"
+flat = true
+
+[node name="VersionLabel" type="Label" parent="Margin/Content/CodePanel/Toolbar" unique_id=630990714]
+unique_name_in_owner = true
+modulate = Color(1, 1, 1, 0.490196)
+layout_mode = 2
+text = "v2.42.2"
+vertical_alignment = 1
+
+[node name="UpdateButton" parent="Margin/Content/CodePanel/Toolbar" unique_id=1595256128 instance=ExtResource("2_ph3vs")]
+unique_name_in_owner = true
+layout_mode = 2
+text = "v2.44.1 available"
+
+[node name="SearchAndReplace" parent="Margin/Content/CodePanel" unique_id=2132004091 instance=ExtResource("6_ylh0t")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CodeEdit" parent="Margin/Content/CodePanel" unique_id=302141693 instance=ExtResource("2_f73fm")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+theme_override_colors/font_color = Color(0.972549, 0.972549, 0.94902, 1)
+theme_override_colors/background_color = Color(0.156863, 0.164706, 0.211765, 1)
+theme_override_colors/current_line_color = Color(0.266667, 0.278431, 0.352941, 0.243137)
+theme_override_font_sizes/font_size = 14
+theme_override_colors/bookmark_color = Color(1, 0.333333, 0.333333, 1)
+text = "~ start
+
+Nathan: Hi, I'm Nathan and this is Coco.
+Coco: Meow.
+Nathan: Here are some response options.
+- First one
+ Nathan: You picked the first one.
+- Second one
+ Nathan: You picked the second one.
+- Start again => start
+- End the conversation => END
+Nathan: I hope this example is helpful.
+Coco: Meow.
+
+=> END"
+scroll_smooth = true
+syntax_highlighter = SubResource("SyntaxHighlighter_mpdoc")
+
+[node name="ErrorsPanel" parent="Margin/Content/CodePanel" unique_id=1975824789 instance=ExtResource("7_5cvl4")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="BlankSlate" parent="." unique_id=267921584 instance=ExtResource("9_faxki")]
+unique_name_in_owner = true
+layout_mode = 1
+
+[node name="NewDialog" type="FileDialog" parent="." unique_id=458925087]
+size = Vector2i(900, 750)
+min_size = Vector2i(600, 500)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="SaveDialog" type="FileDialog" parent="." unique_id=325906832]
+size = Vector2i(900, 750)
+min_size = Vector2i(600, 500)
+dialog_hide_on_ok = true
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="OpenDialog" type="FileDialog" parent="." unique_id=1252768767]
+title = "Open a File"
+size = Vector2i(900, 750)
+min_size = Vector2i(600, 500)
+ok_button_text = "Open"
+dialog_hide_on_ok = true
+file_mode = 0
+filters = PackedStringArray("*.dialogue ; Dialogue")
+
+[node name="QuickOpenDialog" type="ConfirmationDialog" parent="." unique_id=254740052]
+title = "Quick open"
+size = Vector2i(600, 900)
+min_size = Vector2i(400, 600)
+ok_button_text = "Open"
+
+[node name="QuickOpenFilesList" parent="QuickOpenDialog" unique_id=1813408245 instance=ExtResource("2_npj2k")]
+
+[node name="ErrorsDialog" type="AcceptDialog" parent="." unique_id=2100629189]
+title = "Error"
+dialog_text = "You have errors in your script. Fix them and then try again."
+
+[node name="BuildErrorDialog" type="AcceptDialog" parent="." unique_id=1879686579]
+title = "Errors"
+dialog_text = "You need to fix dialogue errors before you can run your game."
+
+[node name="CloseConfirmationDialog" type="ConfirmationDialog" parent="." unique_id=1048140490]
+title = "Unsaved changes"
+ok_button_text = "Save changes"
+
+[node name="UpdatedDialog" type="AcceptDialog" parent="." unique_id=955879802]
+title = "Updated"
+size = Vector2i(191, 100)
+dialog_text = "You're now up to date!"
+
+[node name="GenerateStaticIdsConfirmationDialog" type="ConfirmationDialog" parent="." unique_id=1311101975]
+
+[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="cue_selected" from="Margin/Content/SidePanel/Bookmarks/CueList" to="." method="_on_cue_list_cue_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/TestLineButton" to="." method="_on_test_line_button_pressed"]
+[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/FilenameLabel" to="." method="_on_filename_label_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_cue_changed" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_active_cue_changed"]
+[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="new_button_pressed" from="BlankSlate" to="." method="_on_blank_slate_new_button_pressed"]
+[connection signal="quick_open_button_pressed" from="BlankSlate" to="." method="_on_blank_slate_quick_open_button_pressed"]
+[connection signal="confirmed" from="NewDialog" to="." method="_on_new_dialog_confirmed"]
+[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="confirmed" from="QuickOpenDialog" to="." method="_on_quick_open_dialog_confirmed"]
+[connection signal="file_double_clicked" from="QuickOpenDialog/QuickOpenFilesList" to="." method="_on_quick_open_files_list_file_double_clicked"]
+[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="confirmed" from="GenerateStaticIdsConfirmationDialog" to="." method="_on_generate_static_ids_confirmation_dialog_confirmed"]