Add initial implementation of Dialogue Manager; include core scripts, scenes, and resources

This commit is contained in:
2025-08-24 00:48:51 +02:00
parent 1b3657b03a
commit bdcc0d5089
124 changed files with 14802 additions and 2 deletions

View File

@@ -0,0 +1,15 @@
~ start
Narrator: You are a newborn god, a consciousness adrift in the cosmos.
Narrator: Below you, a nascent world teems with life. A small tribe of followers has begun to worship you. Their belief is your existence.
Narrator: Your goal is simple: nurture this civilization until they can reach for the stars and carry you with them.
Narrator: Grant them miracles to grow their population and advance their industry. Click the buttons on the right to see your powers.
Narrator: But be warned... your divine presence is a paradox. Every miracle that helps your followers also poisons their world, corrupting it.
Narrator: If the world's corruption reaches 100%, it will be consumed, and you along with it.
Narrator: Guide them. Grow them. But do not destroy them before they can escape. Their fate, and yours, is in your hands.
=> END

View File

@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://dxgpvgx7axp88"
path="res://.godot/imported/tutorial.dialogue-20e92c929aa826a6ba83a2adbbdba7f9.tres"
[deps]
source_file="res://Dialogue/tutorial.dialogue"
dest_files=["res://.godot/imported/tutorial.dialogue-20e92c929aa826a6ba83a2adbbdba7f9.tres"]
[params]
defaults=true

View File

@@ -3,7 +3,7 @@
[ext_resource type="Script" uid="uid://cbdokimy0qarg" path="res://Scripts/MainMenu.cs" id="1_1ehe0"]
[ext_resource type="FontFile" uid="uid://wofoiaejxgsp" path="res://Fonts/Playful Boxes.otf" id="1_28flt"]
[ext_resource type="Texture2D" uid="uid://d2wi2cs20q2b6" path="res://Parasitic_God.png" id="1_48xlc"]
[ext_resource type="PackedScene" uid="uid://bfil8sd154327" path="res://Scenes/Main/Main.tscn" id="2_ce3w2"]
[ext_resource type="PackedScene" uid="uid://kvpk5wrcp3rv" path="res://Scenes/tutorial_scene.tscn" id="2_ce3w2"]
[sub_resource type="LabelSettings" id="LabelSettings_48xlc"]
font = ExtResource("1_28flt")

View File

@@ -0,0 +1,26 @@
[gd_scene load_steps=5 format=3 uid="uid://kvpk5wrcp3rv"]
[ext_resource type="Script" uid="uid://d08d3pi7sx8k3" path="res://Scripts/UI/TutorialScene.cs" id="1_epmsy"]
[ext_resource type="PackedScene" uid="uid://bfil8sd154327" path="res://Scenes/Main/Main.tscn" id="2_mw53g"]
[ext_resource type="Resource" uid="uid://dxgpvgx7axp88" path="res://Dialogue/tutorial.dialogue" id="3_oaf0i"]
[ext_resource type="Texture2D" uid="uid://dg6ac3jb1366r" path="res://Sprites/globe.svg" id="4_7u0dx"]
[node name="TutorialScene" 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_epmsy")
_mainGameScene = ExtResource("2_mw53g")
_tutorialDialogue = ExtResource("3_oaf0i")
[node name="World" type="TextureRect" parent="."]
layout_mode = 0
offset_left = 195.0
offset_top = -98.0
offset_right = 915.0
offset_bottom = 732.0
texture = ExtResource("4_7u0dx")
expand_mode = 4

View File

@@ -0,0 +1,29 @@
using DialogueManagerRuntime;
using Godot;
namespace ParasiticGod.Scripts.UI;
public partial class TutorialScene : Control
{
[Export] private PackedScene _mainGameScene;
[Export] private Resource _tutorialDialogue;
public override void _Ready()
{
DialogueManager.DialogueEnded += OnDialogueEnded;
DialogueManager.ShowExampleDialogueBalloon(_tutorialDialogue, "start");
}
public override void _ExitTree()
{
DialogueManager.DialogueEnded -= OnDialogueEnded;
}
private void OnDialogueEnded(Resource resource)
{
if (resource == _tutorialDialogue)
{
GetTree().ChangeSceneToPacked(_mainGameScene);
}
}
}

View File

@@ -0,0 +1 @@
uid://d08d3pi7sx8k3

View File

@@ -0,0 +1,576 @@
using Godot;
using Godot.Collections;
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
#nullable enable
namespace DialogueManagerRuntime
{
public enum TranslationSource
{
None,
Guess,
CSV,
PO
}
public partial class DialogueManager : RefCounted
{
public delegate void DialogueStartedEventHandler(Resource dialogueResource);
public delegate void PassedTitleEventHandler(string title);
public delegate void GotDialogueEventHandler(DialogueLine dialogueLine);
public delegate void MutatedEventHandler(Dictionary mutation);
public delegate void DialogueEndedEventHandler(Resource dialogueResource);
public static DialogueStartedEventHandler? DialogueStarted;
public static PassedTitleEventHandler? PassedTitle;
public static GotDialogueEventHandler? GotDialogue;
public static MutatedEventHandler? Mutated;
public static DialogueEndedEventHandler? DialogueEnded;
[Signal] public delegate void ResolvedEventHandler(Variant value);
private static GodotObject? instance;
public static GodotObject Instance
{
get
{
if (instance == null)
{
instance = Engine.GetSingleton("DialogueManager");
instance.Connect("bridge_dialogue_started", Callable.From((Resource dialogueResource) => DialogueStarted?.Invoke(dialogueResource)));
}
return instance;
}
}
public static Godot.Collections.Array GameStates
{
get => (Godot.Collections.Array)Instance.Get("game_states");
set => Instance.Set("game_states", value);
}
public static bool IncludeSingletons
{
get => (bool)Instance.Get("include_singletons");
set => Instance.Set("include_singletons", value);
}
public static bool IncludeClasses
{
get => (bool)Instance.Get("include_classes");
set => Instance.Set("include_classes", value);
}
public static TranslationSource TranslationSource
{
get => (TranslationSource)(int)Instance.Get("translation_source");
set => Instance.Set("translation_source", (int)value);
}
public static Func<Node> GetCurrentScene
{
set => Instance.Set("get_current_scene", Callable.From(value));
}
public static void Prepare(GodotObject instance)
{
instance.Connect("passed_title", Callable.From((string title) => PassedTitle?.Invoke(title)));
instance.Connect("got_dialogue", Callable.From((RefCounted line) => GotDialogue?.Invoke(new DialogueLine(line))));
instance.Connect("mutated", Callable.From((Dictionary mutation) => Mutated?.Invoke(mutation)));
instance.Connect("dialogue_ended", Callable.From((Resource dialogueResource) => DialogueEnded?.Invoke(dialogueResource)));
}
public static async Task<GodotObject> GetSingleton()
{
if (instance != null) return instance;
var tree = Engine.GetMainLoop();
int x = 0;
// Try and find the singleton for a few seconds
while (!Engine.HasSingleton("DialogueManager") && x < 300)
{
await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame);
x++;
}
// If it times out something is wrong
if (x >= 300)
{
throw new Exception("The DialogueManager singleton is missing.");
}
instance = Engine.GetSingleton("DialogueManager");
return instance;
}
public static Resource CreateResourceFromText(string text)
{
return (Resource)Instance.Call("create_resource_from_text", text);
}
public static async Task<DialogueLine?> GetNextDialogueLine(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
var instance = (Node)Instance.Call("_bridge_get_new_instance");
Prepare(instance);
instance.Call("_bridge_get_next_dialogue_line", dialogueResource, key, extraGameStates ?? new Array<Variant>());
var result = await instance.ToSignal(instance, "bridge_get_next_dialogue_line_completed");
instance.QueueFree();
if ((RefCounted)result[0] == null) return null;
return new DialogueLine((RefCounted)result[0]);
}
public static CanvasLayer ShowExampleDialogueBalloon(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (CanvasLayer)Instance.Call("show_example_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(string balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(PackedScene balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(Node balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloon(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Array<string> StaticIdToLineIds(Resource dialogueResource, string staticId)
{
return (Array<string>)Instance.Call("static_id_to_line_ids", dialogueResource, staticId);
}
public static string StaticIdToLineId(Resource dialogueResource, string staticId)
{
return (string)Instance.Call("static_id_to_line_id", dialogueResource, staticId);
}
public static async void Mutate(Dictionary mutation, Array<Variant>? extraGameStates = null, bool isInlineMutation = false)
{
Instance.Call("_bridge_mutate", mutation, extraGameStates ?? new Array<Variant>(), isInlineMutation);
await Instance.ToSignal(Instance, "bridge_mutated");
}
public static Array<Dictionary> GetMembersForAutoload(Script script)
{
Array<Dictionary> members = new Array<Dictionary>();
string typeName = script.ResourcePath.GetFile().GetBaseName();
var matchingTypes = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.Name == typeName);
foreach (var matchingType in matchingTypes)
{
var memberInfos = matchingType.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var memberInfo in memberInfos)
{
string type;
switch (memberInfo.MemberType)
{
case MemberTypes.Field:
FieldInfo fieldInfo = memberInfo as FieldInfo;
if (fieldInfo.FieldType.ToString().Contains("EventHandler"))
{
type = "signal";
}
else if (fieldInfo.IsLiteral)
{
type = "constant";
}
else
{
type = "property";
}
break;
case MemberTypes.Method:
type = "method";
break;
default:
continue;
}
members.Add(new Dictionary() {
{ "name", memberInfo.Name },
{ "type", type }
});
}
}
return members;
}
public bool ThingHasConstant(GodotObject thing, string property)
{
var fieldInfos = thing.GetType().GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var fieldInfo in fieldInfos)
{
if (fieldInfo.Name == property && fieldInfo.IsLiteral)
{
return true;
}
}
return false;
}
public Variant ResolveThingConstant(GodotObject thing, string property)
{
var fieldInfos = thing.GetType().GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var fieldInfo in fieldInfos)
{
if (fieldInfo.Name == property && fieldInfo.IsLiteral)
{
try
{
Variant value = fieldInfo.GetValue(thing) switch
{
int v => Variant.From((long)v),
float v => Variant.From((double)v),
System.String v => Variant.From((string)v),
_ => Variant.From(fieldInfo.GetValue(thing))
};
return value;
}
catch (Exception)
{
throw new Exception($"Constant {property} of type ${fieldInfo.GetValue(thing).GetType()} is not supported by Variant.");
}
}
}
throw new Exception($"{property} is not a public constant on {thing}");
}
public bool ThingHasMethod(GodotObject thing, string method, Array<Variant> args)
{
var methodInfos = thing.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var methodInfo in methodInfos)
{
if (methodInfo.Name == method && args.Count >= methodInfo.GetParameters().Where(p => !p.HasDefaultValue).Count())
{
return true;
}
}
return false;
}
public async void ResolveThingMethod(GodotObject thing, string method, Array<Variant> args)
{
MethodInfo? info = null;
var methodInfos = thing.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var methodInfo in methodInfos)
{
if (methodInfo.Name == method && args.Count >= methodInfo.GetParameters().Where(p => !p.HasDefaultValue).Count())
{
info = methodInfo;
}
}
if (info == null) return;
#nullable disable
// Convert the method args to something reflection can handle
ParameterInfo[] argTypes = info.GetParameters();
object[] _args = new object[argTypes.Length];
for (int i = 0; i < argTypes.Length; i++)
{
// check if args is assignable from derived type
if (i < args.Count && args[i].Obj != null)
{
if (argTypes[i].ParameterType.IsAssignableFrom(args[i].Obj.GetType()))
{
_args[i] = args[i].Obj;
}
// fallback to assigning primitive types
else
{
_args[i] = Convert.ChangeType(args[i].Obj, argTypes[i].ParameterType);
}
}
else if (argTypes[i].DefaultValue != null)
{
_args[i] = argTypes[i].DefaultValue;
}
}
// Add a single frame wait in case the method returns before signals can listen
await ToSignal(Engine.GetMainLoop(), SceneTree.SignalName.ProcessFrame);
// invoke method and handle the result based on return type
object result = info.Invoke(thing, _args);
if (result is Task taskResult)
{
await taskResult;
try
{
Variant value = (Variant)taskResult.GetType().GetProperty("Result").GetValue(taskResult);
EmitSignal(SignalName.Resolved, value);
}
catch (Exception)
{
EmitSignal(SignalName.Resolved);
}
}
else
{
EmitSignal(SignalName.Resolved, (Variant)result);
}
}
#nullable enable
}
public partial class DialogueLine : RefCounted
{
private string id = "";
public string Id
{
get => id;
set => id = value;
}
private string type = "dialogue";
public string Type
{
get => type;
set => type = value;
}
private string next_id = "";
public string NextId
{
get => next_id;
set => next_id = value;
}
private string character = "";
public string Character
{
get => character;
set => character = value;
}
private string text = "";
public string Text
{
get => text;
set => text = value;
}
private string translation_key = "";
public string TranslationKey
{
get => translation_key;
set => translation_key = value;
}
private Array<DialogueResponse> responses = new Array<DialogueResponse>();
public Array<DialogueResponse> Responses
{
get => responses;
}
private string? time = null;
public string? Time
{
get => time;
}
private Dictionary pauses = new Dictionary();
public Dictionary Pauses
{
get => pauses;
}
private Dictionary speeds = new Dictionary();
public Dictionary Speeds
{
get => speeds;
}
private Array<Godot.Collections.Array> inline_mutations = new Array<Godot.Collections.Array>();
public Array<Godot.Collections.Array> InlineMutations
{
get => inline_mutations;
}
private Array<DialogueLine> concurrent_lines = new Array<DialogueLine>();
public Array<DialogueLine> ConcurrentLines
{
get => concurrent_lines;
}
private Array<Variant> extra_game_states = new Array<Variant>();
public Array<Variant> ExtraGameStates
{
get => extra_game_states;
}
private Array<string> tags = new Array<string>();
public Array<string> Tags
{
get => tags;
}
public DialogueLine(RefCounted data)
{
id = (string)data.Get("id");
type = (string)data.Get("type");
next_id = (string)data.Get("next_id");
character = (string)data.Get("character");
text = (string)data.Get("text");
translation_key = (string)data.Get("translation_key");
pauses = (Dictionary)data.Get("pauses");
speeds = (Dictionary)data.Get("speeds");
inline_mutations = (Array<Godot.Collections.Array>)data.Get("inline_mutations");
time = (string)data.Get("time");
tags = (Array<string>)data.Get("tags");
foreach (var concurrent_line_data in (Array<RefCounted>)data.Get("concurrent_lines"))
{
concurrent_lines.Add(new DialogueLine(concurrent_line_data));
}
foreach (var response in (Array<RefCounted>)data.Get("responses"))
{
responses.Add(new DialogueResponse(response));
}
}
public string GetTagValue(string tagName)
{
string wrapped = $"{tagName}=";
foreach (var tag in tags)
{
if (tag.StartsWith(wrapped))
{
return tag.Substring(wrapped.Length);
}
}
return "";
}
public override string ToString()
{
switch (type)
{
case "dialogue":
return $"<DialogueLine character=\"{character}\" text=\"{text}\">";
case "mutation":
return "<DialogueLine mutation>";
default:
return "";
}
}
}
public partial class DialogueResponse : RefCounted
{
private string next_id = "";
public string NextId
{
get => next_id;
set => next_id = value;
}
private bool is_allowed = true;
public bool IsAllowed
{
get => is_allowed;
set => is_allowed = value;
}
private string condition_as_text = "";
public string ConditionAsText
{
get => condition_as_text;
set => condition_as_text = value;
}
private string text = "";
public string Text
{
get => text;
set => text = value;
}
private string translation_key = "";
public string TranslationKey
{
get => translation_key;
set => translation_key = value;
}
private Array<string> tags = new Array<string>();
public Array<string> Tags
{
get => tags;
}
public DialogueResponse(RefCounted data)
{
next_id = (string)data.Get("next_id");
is_allowed = (bool)data.Get("is_allowed");
text = (string)data.Get("text");
translation_key = (string)data.Get("translation_key");
tags = (Array<string>)data.Get("tags");
}
public string GetTagValue(string tagName)
{
string wrapped = $"{tagName}=";
foreach (var tag in tags)
{
if (tag.StartsWith(wrapped))
{
return tag.Substring(wrapped.Length);
}
}
return "";
}
public override string ToString()
{
return $"<DialogueResponse text=\"{text}\"";
}
}
}

View File

@@ -0,0 +1 @@
uid://c4c5lsrwy3opj

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022-present Nathan Hoad and Dialogue Manager contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cnm67htuohhlo"
path="res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/banner.png"
dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -0,0 +1,38 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3lr2uas6ax8v"
path="res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/icon.svg"
dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 4.2333333 4.2333335"
version="1.1"
id="svg291"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="responses_menu.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview293"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="false"
width="1920px"
units="px"
borderlayer="true"
inkscape:showpageshadow="false"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="45.254834"
inkscape:cx="7.8334173"
inkscape:cy="6.5959804"
inkscape:window-width="2560"
inkscape:window-height="1377"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs288" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect181"
style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.77487;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke markers fill"
d="M 1.5875 0.26458334 L 1.5875 0.79375001 L 4.2333334 0.79375001 L 4.2333334 0.26458334 L 1.5875 0.26458334 z M 0 0.83147381 L 0 2.4189738 L 1.3229167 1.6252238 L 0 0.83147381 z M 1.5875 1.3229167 L 1.5875 1.8520834 L 4.2333334 1.8520834 L 4.2333334 1.3229167 L 1.5875 1.3229167 z M 1.5875 2.38125 L 1.5875 2.9104167 L 4.2333334 2.9104167 L 4.2333334 2.38125 L 1.5875 2.38125 z M 1.5875 3.4395834 L 1.5875 3.9687501 L 4.2333334 3.9687501 L 4.2333334 3.4395834 L 1.5875 3.4395834 z "
fill="#E0E0E0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,38 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://drjfciwitjm83"
path="res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/responses_menu.svg"
dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3baj6rygkb3f"
path="res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/update.svg"
dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://dsgpnyqg6cprg

View File

@@ -0,0 +1,161 @@
## A compiled line of dialogue.
class_name DMCompiledLine extends RefCounted
## The ID of the line
var id: String
## The translation key (or static line ID).
var translation_key: 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 null,
text if text != "" else null,
expression if expression.size() > 0 else null,
"[%s]" % [",".join(tags)] if tags.size() > 0 else null,
str(siblings) if siblings.size() > 0 else null,
str(responses) if responses.size() > 0 else null,
"=> END" if "end" in next_id else "=> %s" % [next_id],
"(~> %s)" % [next_sibling_id] if next_sibling_id != "" else null,
"(==> %s)" % [next_id_after] if next_id_after != "" else null,
].filter(func(item): return item != null)
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 translation_key != text:
d.translation_key = translation_key
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 translation_key != text:
d.translation_key = translation_key
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

View File

@@ -0,0 +1 @@
uid://dg8j5hudp4210

View File

@@ -0,0 +1,51 @@
## 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.titles = compilation.titles
result.first_title = compilation.first_title
result.errors = compilation.errors
result.lines = compilation.data
result.raw_text = text
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.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
## Get the known titles in a dialogue script.
static func get_titles_in_text(text: String, path: String) -> Dictionary:
var compilation: DMCompilation = DMCompilation.new()
compilation.build_line_tree(compilation.inject_imported_files(text, path))
return compilation.titles

View File

@@ -0,0 +1 @@
uid://chtfdmr0cqtp4

View File

@@ -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 \"(?<path>[^\"]+)\" as (?<prefix>[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 (?<state>.*)$")
var INDENT_REGEX: RegEx = RegEx.create_from_string("^\\t+")
var VALID_TITLE_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) (?<expression>.*)\\:?")
var WRAPPED_CONDITION_REGEX: RegEx = RegEx.create_from_string("\\[if (?<expression>.*)\\]")
var MUTATION_REGEX: RegEx = RegEx.create_from_string("(?<keyword>do|do!|set) (?<expression>.*)")
var STATIC_LINE_ID_REGEX: RegEx = RegEx.create_from_string("\\[ID:(?<id>.*?)\\]")
var WEIGHTED_RANDOM_SIBLINGS_REGEX: RegEx = RegEx.create_from_string("^\\%(?<weight>[\\d.]+)?( \\[if (?<condition>.+?)\\])? ")
var GOTO_REGEX: RegEx = RegEx.create_from_string("=><? (?<goto>.*)")
var INLINE_RANDOM_REGEX: RegEx = RegEx.create_from_string("\\[\\[(?<options>.*?)\\]\\]")
var INLINE_CONDITIONALS_REGEX: RegEx = RegEx.create_from_string("\\[if (?<condition>.+?)\\](?<body>.*?)\\[\\/if\\]")
var TAGS_REGEX: RegEx = RegEx.create_from_string("\\[#(?<tags>.*?)\\]")
var REPLACEMENTS_REGEX: RegEx = RegEx.create_from_string("{{(.*?)}}")
var ALPHA_NUMERIC: RegEx = RegEx.create_from_string("[^a-zA-Z0-9\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]+")
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)")
}

View File

@@ -0,0 +1 @@
uid://d3tvcrnicjibp

View File

@@ -0,0 +1,27 @@
## 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 titles in the file and the line they point to.
var titles: Dictionary = {}
## The first title in the file.
var first_title: String = ""
## All character names.
var character_names: PackedStringArray = []
## Any compilation errors.
var errors: Array[Dictionary] = []
## A map of all compiled lines.
var lines: Dictionary = {}
## The raw dialogue text.
var raw_text: String = ""

View File

@@ -0,0 +1 @@
uid://dmk74tknimqvg

View File

@@ -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 = _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 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 = 0
while tokens.size() > 0 and limit < 1000:
limit += 1
var token = tokens.pop_front()
var error = _check_next_token(token, tokens, line_type, expected_close_token)
if error != OK:
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 = _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 = _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 = _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 = _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 = sub_tree[0]
for i 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 = _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 = DMConstants.TOKEN_ARRAY
var value = _tokens_to_list(sub_tree[0])
# See if this is referencing a nested dictionary value
if tree.size() > 0:
var previous_token = tree[tree.size() - 1]
if previous_token.type in [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 = _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 = 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 = 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) -> Error:
var next_token: Dictionary = { type = null }
if next_tokens.size() > 0:
next_token = next_tokens.front()
# Guard for assigning in a condition. If the assignment token isn't inside a Lua dictionary
# then it's an unexpected assignment in a condition line.
if token.type == DMConstants.TOKEN_ASSIGNMENT and line_type == DMConstants.TYPE_CONDITION and not next_tokens.any(func(t): 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 = []
var unexpected_token_types = []
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 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 = {}
for i 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 in regex.TOKEN_DEFINITIONS.keys():
var regex = regex.TOKEN_DEFINITIONS.get(key)
var found = regex.search(input)
if found:
return {
type = key,
remaining_text = input.substr(found.strings[0].length()),
value = found.strings[0]
}
return {}
#endregion

View File

@@ -0,0 +1 @@
uid://dbi4hbar8ubwu

View File

@@ -0,0 +1,68 @@
## Data associated with a dialogue jump/goto line.
class_name DMResolvedGotoData extends RefCounted
## The title that was specified
var title: 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, titles: 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
title = found.strings[found.names.goto].strip_edges()
index = found.get_start(0)
if title == "":
error = DMConstants.ERR_UNKNOWN_TITLE
return
# "=> END!" means end the conversation, ignoring any "=><" chains.
if title == "END!":
next_id = DMConstants.ID_END_CONVERSATION
# "=> END" means end the current title (and go back to the previous one if there is one
# in the stack)
elif title == "END":
next_id = DMConstants.ID_END
elif titles.has(title):
next_id = titles.get(title)
elif title.begins_with("{{"):
var expression_parser: DMExpressionParser = DMExpressionParser.new()
var title_expression: Array[Dictionary] = expression_parser.extract_replacements(title, 0)
if title_expression[0].has("error"):
error = title_expression[0].error
else:
expression = title_expression[0].expression
else:
next_id = title
error = DMConstants.ERR_UNKNOWN_TITLE
func _to_string() -> String:
return "%s =>%s %s (%s)" % [text_without_goto, "<" if is_snippet else "", title, next_id]

View File

@@ -0,0 +1 @@
uid://llhl5pt47eoq

View File

@@ -0,0 +1,167 @@
## Any data associated with inline dialogue BBCodes.
class_name DMResolvedLineData extends RefCounted
## The line's text
var text: String = ""
## A map of pauses against where they are found in the text.
var pauses: Dictionary = {}
## 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
pauses = {}
speeds = {}
mutations = []
time = ""
var bbcodes: Array = []
# Remove any escaped brackets (ie. "\[")
var escaped_open_brackets: PackedInt32Array = []
var escaped_close_brackets: PackedInt32Array = []
for i in range(0, text.length() - 1):
if text.substr(i, 2) == "\\[":
text = text.substr(0, i) + "!" + text.substr(i + 2)
escaped_open_brackets.append(i)
elif text.substr(i, 2) == "\\]":
text = text.substr(0, i) + "!" + text.substr(i + 2)
escaped_close_brackets.append(i)
# Extract all of the BB codes so that we know the actual text (we could do this easier with
# a RichTextLabel but then we'd need to await idle_frame which is annoying)
var bbcode_positions = find_bbcode_positions_in_string(text)
var accumulaive_length_offset = 0
for position in bbcode_positions:
# Ignore our own markers
if position.code in ["wait", "speed", "/speed", "do", "do!", "set", "next", "if", "else", "/if"]:
continue
bbcodes.append({
bbcode = position.bbcode,
start = position.start,
offset_start = position.start - accumulaive_length_offset
})
accumulaive_length_offset += position.bbcode.length()
for bb in bbcodes:
text = text.substr(0, bb.offset_start) + text.substr(bb.offset_start + bb.bbcode.length())
# Now find any dialogue markers
var next_bbcode_position = find_bbcode_positions_in_string(text, false)
var limit = 0
while next_bbcode_position.size() > 0 and limit < 1000:
limit += 1
var bbcode = next_bbcode_position[0]
var index = bbcode.start
var code = bbcode.code
var raw_args = bbcode.raw_args
var args = {}
if code in ["do", "do!", "set"]:
var compilation: DMCompilation = DMCompilation.new()
args["value"] = compilation.extract_mutation("%s %s" % [code, raw_args])
else:
# Could be something like:
# "=1.0"
# " rate=20 level=10"
if raw_args and raw_args[0] == "=":
raw_args = "value" + raw_args
for pair in raw_args.strip_edges().split(" "):
if "=" in pair:
var bits = pair.split("=")
args[bits[0]] = bits[1]
match code:
"wait":
if pauses.has(index):
pauses[index] += args.get("value").to_float()
else:
pauses[index] = args.get("value").to_float()
"speed":
speeds[index] = args.get("value").to_float()
"/speed":
speeds[index] = 1.0
"do", "do!", "set":
mutations.append([index, args.get("value")])
"next":
time = args.get("value") if args.has("value") else "0"
# Find any BB codes that are after this index and remove the length from their start
var length = bbcode.bbcode.length()
for bb in bbcodes:
if bb.offset_start > bbcode.start:
bb.offset_start -= length
bb.start -= length
# Find any escaped brackets after this that need moving
for i in range(0, escaped_open_brackets.size()):
if escaped_open_brackets[i] > bbcode.start:
escaped_open_brackets[i] -= length
for i in range(0, escaped_close_brackets.size()):
if escaped_close_brackets[i] > bbcode.start:
escaped_close_brackets[i] -= length
text = text.substr(0, index) + text.substr(index + length)
next_bbcode_position = find_bbcode_positions_in_string(text, false)
# Put the BB Codes back in
for bb in bbcodes:
text = text.insert(bb.start, bb.bbcode)
# Put the escaped brackets back in
for index in escaped_open_brackets:
text = text.left(index) + "[" + text.right(text.length() - index - 1)
for index in escaped_close_brackets:
text = text.left(index) + "]" + text.right(text.length() - index - 1)
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_finished_code: bool = false
for i in range(0, string.length()):
if string[i] == "[":
if open_brace_count == 0:
start = i
bbcode = ""
code = ""
is_finished_code = false
open_brace_count += 1
else:
if not is_finished_code and (string[i].to_upper() != string[i] or string[i] == "/" or string[i] == "!"):
code += string[i]
else:
is_finished_code = true
if open_brace_count > 0:
bbcode += string[i]
if string[i] == "]":
open_brace_count -= 1
if open_brace_count == 0 and (include_conditions or not code in ["if", "else", "/if"]):
positions.append({
bbcode = bbcode,
code = code,
start = start,
end = i,
raw_args = bbcode.substr(code.length() + 1, bbcode.length() - code.length() - 2).strip_edges()
})
if not find_all:
return positions
return positions

View File

@@ -0,0 +1 @@
uid://0k6q8kukq0qa

View File

@@ -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 in tag_matches:
text = text.replace(tag_match.get_string(), "")
var tags = tag_match.get_string().replace("[#", "").replace("]", "").replace(", ", ",").split(",")
for tag in tags:
tag = tag.replace("#", "")
if not tag in resolved_tags:
resolved_tags.append(tag)
tags = resolved_tags
text_without_tags = text

View File

@@ -0,0 +1 @@
uid://cqai3ikuilqfq

View File

@@ -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 = []
tabs.resize(indent)
tabs.fill("\t")
tabs = "".join(tabs)
return tabs.join([tabs + "{\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): return str(child))) + "]\n",
"}"])

View File

@@ -0,0 +1 @@
uid://dsu4i84dpif14

View File

@@ -0,0 +1,610 @@
@tool
class_name DMCodeEdit extends CodeEdit
signal active_title_change(title: String)
signal error_clicked(line_number: int)
signal external_file_requested(path: String, title: String)
# A link back to the owner `MainView`
var main_view
# Theme overrides for syntax highlighting, etc
var theme_overrides: Dictionary:
set(value):
theme_overrides = value
syntax_highlighter = 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", theme_overrides.font_size * theme_overrides.scale)
font_size = round(theme_overrides.font_size)
get:
return theme_overrides
# Any parse errors
var errors: Array:
set(next_errors):
errors = next_errors
for i in range(0, get_line_count()):
var is_error: bool = false
for error in errors:
if error.line_number == i:
is_error = true
mark_line_as_error(i, is_error)
_on_code_edit_caret_changed()
get:
return errors
# The last selection (if there was one) so we can remember it for refocusing
var last_selected_text: String
var font_size: int:
set(value):
font_size = value
add_theme_font_size_override("font_size", font_size * theme_overrides.scale)
get:
return font_size
var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
var compiler_regex: DMCompilerRegEx = DMCompilerRegEx.new()
var _autoloads: Dictionary[String, String] = {}
var _autoload_member_cache: Dictionary[String, 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 = Engine.get_meta("DialogueManagerPlugin").get_editor_shortcut(event)
match shortcut:
"toggle_comment":
toggle_comment()
get_viewport().set_input_as_handled()
"delete_line":
delete_current_line()
get_viewport().set_input_as_handled()
"move_up":
move_line(-1)
get_viewport().set_input_as_handled()
"move_down":
move_line(1)
get_viewport().set_input_as_handled()
"text_size_increase":
self.font_size += 1
get_viewport().set_input_as_handled()
"text_size_decrease":
self.font_size -= 1
get_viewport().set_input_as_handled()
"text_size_reset":
self.font_size = theme_overrides.font_size
get_viewport().set_input_as_handled()
elif event is InputEventMouse:
match event.as_text():
"Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up":
self.font_size += 1
get_viewport().set_input_as_handled()
"Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down":
self.font_size -= 1
get_viewport().set_input_as_handled()
func _can_drop_data(at_position: Vector2, data) -> bool:
if typeof(data) != TYPE_DICTIONARY: return false
if data.type != "files": return false
var files: PackedStringArray = Array(data.files)
return files.size() > 0
func _drop_data(at_position: Vector2, data) -> void:
var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")
var files: PackedStringArray = Array(data.files)
for file in files:
# Don't import the file into itself
if file == main_view.current_file_path: continue
if file.get_extension() == "dialogue":
var path = file.replace("res://", "").replace(".dialogue", "")
# Find the first non-import line in the file to add our import
var lines = text.split("\n")
for i in range(0, lines.size()):
if not lines[i].begins_with("import "):
insert_line_at(i, "import \"%s\" as %s\n" % [file, replace_regex.sub(path, "_", true)])
set_caret_line(i)
break
else:
var cursor: Vector2 = get_line_column_at_pos(at_position)
if cursor.x > -1 and cursor.y > -1:
set_cursor(cursor)
remove_secondary_carets()
insert_text("\"%s\"" % file, cursor.y, cursor.x)
grab_focus()
func _request_code_completion(force: bool) -> void:
var cursor: Vector2 = get_cursor()
var current_line: String = get_line(cursor.y)
# Match jumps
if ("=> " in current_line or "=>< " in current_line) and (cursor.x > current_line.find("=>")):
var prompt: String = current_line.split("=>")[1]
if prompt.begins_with("< "):
prompt = prompt.substr(2)
else:
prompt = prompt.substr(1)
if "=> " in current_line:
if matches_prompt(prompt, "end"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
if matches_prompt(prompt, "end!"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
# Get all titles, including those in imports
for title: String in DMCompiler.get_titles_in_text(text, main_view.current_file_path):
# Ignore any imported titles that aren't resolved to human readable.
if title.to_int() > 0:
continue
elif "/" in title:
var bits = title.split("/")
if matches_prompt(prompt, bits[0]) or matches_prompt(prompt, bits[1]):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
elif matches_prompt(prompt, title):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))
# Match character names
var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "")
if name_so_far != "" and name_so_far[0].to_upper() == name_so_far[0]:
# Only show names starting with that character
var names: PackedStringArray = get_character_names(name_so_far)
if names.size() > 0:
for name in names:
add_code_completion_option(CodeEdit.KIND_CLASS, name + ": ", name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))
# Match autoloads on mutation lines
for prefix in ["do ", "do! ", "set ", "if ", "elif ", "else if ", "match ", "when ", "using "]:
if (current_line.strip_edges().begins_with(prefix) and (cursor.x > current_line.find(prefix))):
var expression: String = current_line.substr(0, cursor.x).strip_edges().substr(3)
# Find the last couple of tokens
var possible_prompt: String = 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(".").slice(-2)
var auto_completes: Array[Dictionary] = []
# Autoloads and state shortcuts
if segments.size() == 1:
var prompt: String = segments[0]
for autoload in _autoloads.keys():
if matches_prompt(prompt, autoload):
auto_completes.append({
prompt = prompt,
text = autoload,
type = "script"
})
for autoload in get_state_shortcuts():
for member: Dictionary in get_members_for_autoload(autoload):
if matches_prompt(prompt, member.name):
auto_completes.append({
prompt = prompt,
text = member.name,
type = member.type
})
# Members of an autoload
elif segments[0] in _autoloads.keys() and not current_line.strip_edges().begins_with("using "):
var prompt: String = segments[1]
for member: Dictionary in get_members_for_autoload(segments[0]):
if matches_prompt(prompt, member.name):
auto_completes.append({
prompt = prompt,
text = member.name,
type = member.type
})
auto_completes.sort_custom(func(a, b): return a.text < b.text)
for auto_complete in auto_completes:
var icon: Texture2D
var text: String = auto_complete.text
match auto_complete.type:
"script":
icon = get_theme_icon("Script", "EditorIcons")
"property":
icon = get_theme_icon("MemberProperty", "EditorIcons")
"method":
icon = get_theme_icon("MemberMethod", "EditorIcons")
text += "()"
"signal":
icon = get_theme_icon("MemberSignal", "EditorIcons")
"constant":
icon = get_theme_icon("MemberConstant", "EditorIcons")
var insert: String = text.substr(auto_complete.prompt.length())
add_code_completion_option(CodeEdit.KIND_CLASS, text, insert, theme_overrides.text_color, icon)
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 = get_code_completion_option(get_code_completion_selected_index())
begin_complex_operation()
# Delete any part of the text that we've already typed
if completion.insert_text.length() > 0:
for i in range(0, completion.display_text.length() - completion.insert_text.length()):
backspace()
# Insert the whole match
insert_text_at_caret(completion.display_text)
end_complex_operation()
if completion.display_text.ends_with("()"):
set_cursor(get_cursor() - Vector2.RIGHT)
# Close the autocomplete menu on the next tick
call_deferred("cancel_code_completion")
#region Helpers
# Get the current caret as a Vector2
func get_cursor() -> Vector2:
return Vector2(get_caret_column(), get_caret_line())
# Set the caret from a Vector2
func set_cursor(from_cursor: Vector2) -> void:
set_caret_line(from_cursor.y, false)
set_caret_column(from_cursor.x, false)
# Check if a prompt is the start of a string without actually being that string
func matches_prompt(prompt: String, matcher: String) -> bool:
return prompt.length() < matcher.length() and matcher.to_lower().begins_with(prompt.to_lower())
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 in DMSettings.get_setting(DMSettings.EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES, []):
shortcuts.append(extra_script_source)
return shortcuts
func get_members_for_autoload(autoload_name: String) -> Array[Dictionary]:
# Debounce method list lookups
if _autoload_member_cache.has(autoload_name) and _autoload_member_cache.get(autoload_name).get("at") > Time.get_ticks_msec() - 5000:
return _autoload_member_cache.get(autoload_name).get("members")
if not _autoloads.has(autoload_name) and not autoload_name.begins_with("res://") and not autoload_name.begins_with("uid://"): return []
var autoload = load(_autoloads.get(autoload_name, autoload_name))
var script: Script = autoload if autoload is Script else autoload.get_script()
if not is_instance_valid(script): return []
var members: Array[Dictionary] = []
if 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():
members.append({
name = m.name,
type = "property"
})
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"
})
elif script.resource_path.ends_with(".cs"):
var dotnet = load(Engine.get_meta("DialogueManagerPlugin").get_plugin_path() + "/DialogueManager.cs").new()
for m: Dictionary in dotnet.GetMembersForAutoload(script):
members.append(m)
_autoload_member_cache[autoload_name] = {
at = Time.get_ticks_msec(),
members = members
}
return members
## Get a list of titles from the current text
func get_titles() -> PackedStringArray:
var titles = PackedStringArray([])
var lines = text.split("\n")
for line in lines:
if line.strip_edges().begins_with("~ "):
titles.append(line.strip_edges().substr(2))
return titles
## Work out what the next title above the current line is
func check_active_title() -> void:
var line_number = get_caret_line()
var lines = text.split("\n")
# Look at each line above this one to find the next title line
for i in range(line_number, -1, -1):
if lines[i].begins_with("~ "):
active_title_change.emit(lines[i].replace("~ ", ""))
return
active_title_change.emit("")
# Move the caret line to match a given title
func go_to_title(title: String) -> void:
var lines = text.split("\n")
for i in range(0, lines.size()):
if lines[i].strip_edges() == "~ " + title:
set_caret_line(i)
center_viewport_to_caret()
func get_character_names(beginning_with: String) -> PackedStringArray:
var names: PackedStringArray = []
var lines = text.split("\n")
for line in lines:
if ": " in line:
var name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")
if not name in names and matches_prompt(beginning_with, name):
names.append(name)
return names
# Mark a line as an error or not
func mark_line_as_error(line_number: int, is_error: bool) -> void:
# 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, theme_overrides.background_color)
set_line_gutter_icon(line_number, 0, null)
# Insert or wrap some bbcode at the caret/selection
func insert_bbcode(open_tag: String, close_tag: String = "") -> void:
if close_tag == "":
insert_text_at_caret(open_tag)
grab_focus()
else:
var selected_text = get_selected_text()
insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag])
grab_focus()
set_caret_column(get_caret_column() - close_tag.length())
# Insert text at current caret position
# Move Caret down 1 line if not => END
func insert_text_at_cursor(text: String) -> void:
if text != "=> END":
insert_text_at_caret(text+"\n")
set_caret_line(get_caret_line()+1)
else:
insert_text_at_caret(text)
grab_focus()
# Toggle the selected lines as comments
func toggle_comment() -> void:
begin_complex_operation()
var comment_delimiter: String = delimiter_comments[0]
var is_first_line: bool = true
var will_comment: bool = true
var selections: Array = []
var line_offsets: Dictionary = {}
for caret_index in range(0, get_caret_count()):
var from_line: int = get_caret_line(caret_index)
var from_column: int = get_caret_column(caret_index)
var to_line: int = get_caret_line(caret_index)
var to_column: int = get_caret_column(caret_index)
if has_selection(caret_index):
from_line = get_selection_from_line(caret_index)
to_line = get_selection_to_line(caret_index)
from_column = get_selection_from_column(caret_index)
to_column = get_selection_to_column(caret_index)
selections.append({
from_line = from_line,
from_column = from_column,
to_line = to_line,
to_column = to_column
})
for line_number in range(from_line, to_line + 1):
if line_offsets.has(line_number): continue
var line_text: String = get_line(line_number)
# The first line determines if we are commenting or uncommentingg
if is_first_line:
is_first_line = false
will_comment = not line_text.strip_edges().begins_with(comment_delimiter)
# Only comment/uncomment if the current line needs to
if will_comment:
set_line(line_number, comment_delimiter + line_text)
line_offsets[line_number] = 1
elif line_text.begins_with(comment_delimiter):
set_line(line_number, line_text.substr(comment_delimiter.length()))
line_offsets[line_number] = -1
else:
line_offsets[line_number] = 0
for caret_index in range(0, get_caret_count()):
var selection: Dictionary = selections[caret_index]
select(
selection.from_line,
selection.from_column + line_offsets[selection.from_line],
selection.to_line,
selection.to_column + line_offsets[selection.to_line],
caret_index
)
set_caret_column(selection.from_column + line_offsets[selection.from_line], false, caret_index)
end_complex_operation()
text_set.emit()
text_changed.emit()
# Remove the current line
func delete_current_line() -> void:
var cursor = get_cursor()
if get_line_count() == 1:
select_all()
elif cursor.y == 0:
select(0, 0, 1, 0)
else:
select(cursor.y - 1, get_line_width(cursor.y - 1), cursor.y, get_line_width(cursor.y))
delete_selection()
text_changed.emit()
# Move the selected lines up or down
func move_line(offset: int) -> void:
offset = clamp(offset, -1, 1)
var starting_scroll := scroll_vertical
var cursor = get_cursor()
var reselect: bool = false
var from: int = cursor.y
var to: int = cursor.y
if has_selection():
reselect = true
from = get_selection_from_line()
to = get_selection_to_line()
var lines := text.split("\n")
# Prevent the lines from being out of bounds
if from + offset < 0 or to + offset >= lines.size(): return
var target_from_index = from - 1 if offset == -1 else to + 1
var target_to_index = to if offset == -1 else from
var line_to_move = lines[target_from_index]
lines.remove_at(target_from_index)
lines.insert(target_to_index, line_to_move)
text = "\n".join(lines)
cursor.y += offset
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
#endregion
#region Signals
func _on_project_settings_changed() -> void:
_autoloads = {}
var project = ConfigFile.new()
project.load("res://project.godot")
for autoload in project.get_section_keys("autoload"):
if autoload != "DialogueManager":
_autoloads[autoload] = project.get_value("autoload", autoload).substr(1)
func _on_code_edit_symbol_validate(symbol: String) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
set_symbol_lookup_word_as_valid(true)
return
for title in get_titles():
if symbol == title:
set_symbol_lookup_word_as_valid(true)
return
set_symbol_lookup_word_as_valid(false)
func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
external_file_requested.emit(symbol, "")
else:
go_to_title(symbol)
func _on_code_edit_text_changed() -> void:
request_code_completion(true)
func _on_code_edit_text_set() -> void:
queue_redraw()
func _on_code_edit_caret_changed() -> void:
check_active_title()
last_selected_text = get_selected_text()
func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void:
var line_errors = errors.filter(func(error): return error.line_number == line)
if line_errors.size() > 0:
error_clicked.emit(line)
#endregion

View File

@@ -0,0 +1 @@
uid://djeybvlb332mp

View File

@@ -0,0 +1,56 @@
[gd_scene load_steps=4 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_cobxx"]
script = ExtResource("1_58cfo")
[node name="CodeEdit" type="CodeEdit"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
text = "~ title_thing
if this = \"that\" or 'this'
Nathan: Something
- Then [if test.thing() == 2.0] => somewhere
- Other => END!
~ somewhere
set has_something = true
=> END"
highlight_all_occurrences = true
highlight_current_line = true
draw_tabs = true
syntax_highlighter = SubResource("SyntaxHighlighter_cobxx")
scroll_past_end_of_file = true
minimap_draw = true
symbol_lookup_on_click = true
line_folding = true
gutters_draw_line_numbers = true
gutters_draw_fold_gutter = true
delimiter_strings = Array[String](["\" \""])
delimiter_comments = Array[String](["#"])
code_completion_enabled = true
code_completion_prefixes = Array[String]([">", "<"])
indent_automatic = true
auto_brace_completion_enabled = true
auto_brace_completion_highlight_matching = true
auto_brace_completion_pairs = {
"\"": "\"",
"(": ")",
"[": "]",
"{": "}"
}
script = ExtResource("1_g324i")
[connection signal="caret_changed" from="." to="." method="_on_code_edit_caret_changed"]
[connection signal="gutter_clicked" from="." to="." method="_on_code_edit_gutter_clicked"]
[connection signal="symbol_lookup" from="." to="." method="_on_code_edit_symbol_lookup"]
[connection signal="symbol_validate" from="." to="." method="_on_code_edit_symbol_validate"]
[connection signal="text_changed" from="." to="." method="_on_code_edit_text_changed"]
[connection signal="text_set" from="." to="." method="_on_code_edit_text_set"]

View File

@@ -0,0 +1,231 @@
@tool
class_name DMSyntaxHighlighter extends SyntaxHighlighter
var regex: DMCompilerRegEx = DMCompilerRegEx.new()
var compilation: DMCompilation = DMCompilation.new()
var expression_parser = 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) or text_edit.theme_overrides.is_empty():
return colors
# Disable this, as well as the line at the bottom of this function to remove the cache.
if text in cache:
return cache[text]
var theme: Dictionary = 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_TITLE:
colors[index] = { color = theme.titles_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_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.symbols_color }
index = text.find(" ")
_highlight_goto(text, colors, index)
DMConstants.TYPE_RANDOM:
colors[index] = { color = theme.symbols_color }
DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE:
if text.strip_edges().begins_with("%"):
colors[index] = { color = theme.symbols_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 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 resolved_line_data: DMResolvedLineData = DMResolvedLineData.new("")
var bbcodes: Array[Dictionary] = resolved_line_data.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.symbols_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.symbols_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.symbols_color }
colors[index + bbcode.end + 1] = { color = theme.text_color }
else:
colors[index + bbcode.start] = { color = theme.symbols_color }
if tag.begins_with("do") or tag.begins_with("set") or tag.begins_with("if"):
if tag.begins_with("if"):
colors[index + bbcode.start + 1] = { color = theme.conditions_color }
else:
colors[index + bbcode.start + 1] = { color = theme.mutations_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 + 1] = { color = theme.conditions_color }
colors[index + bbcode.end] = { color = theme.symbols_color }
colors[index + bbcode.end + 1] = { color = theme.text_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: Dictionary = 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:
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_NUMBER, DMConstants.TOKEN_ASSIGNMENT:
colors[index + token.i] = { color = theme.symbols_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: Dictionary = 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

View File

@@ -0,0 +1 @@
uid://klpiq4tk3t7a

View File

@@ -0,0 +1,84 @@
@tool
extends Control
signal failed()
signal updated(updated_to_version: String)
const DialogueConstants = preload("../constants.gd")
const TEMP_FILE_NAME = "user://temp.zip"
@onready var logo: TextureRect = %Logo
@onready var label: Label = $VBox/Label
@onready var http_request: HTTPRequest = $HTTPRequest
@onready var download_button: Button = %DownloadButton
var next_version_release: Dictionary:
set(value):
next_version_release = value
label.text = DialogueConstants.translate(&"update.is_available_for_download") % value.tag_name.substr(1)
get:
return next_version_release
func _ready() -> void:
$VBox/Center/DownloadButton.text = DialogueConstants.translate(&"update.download_update")
$VBox/Center2/NotesButton.text = DialogueConstants.translate(&"update.release_notes")
### Signals
func _on_download_button_pressed() -> void:
# Safeguard the actual dialogue manager repo from accidentally updating itself
if FileAccess.file_exists("res://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 = DialogueConstants.translate(&"update.downloading")
func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
failed.emit()
return
# Save the downloaded zip
var zip_file: FileAccess = FileAccess.open(TEMP_FILE_NAME, FileAccess.WRITE)
zip_file.store_buffer(body)
zip_file.close()
OS.move_to_trash(ProjectSettings.globalize_path("res://addons/dialogue_manager"))
var zip_reader: ZIPReader = ZIPReader.new()
zip_reader.open(TEMP_FILE_NAME)
var files: PackedStringArray = zip_reader.get_files()
var base_path = files[1]
# Remove archive folder
files.remove_at(0)
# Remove assets folder
files.remove_at(0)
for path in files:
var new_file_path: String = path.replace(base_path, "")
if path.ends_with("/"):
DirAccess.make_dir_recursive_absolute("res://addons/%s" % new_file_path)
else:
var file: FileAccess = FileAccess.open("res://addons/%s" % new_file_path, FileAccess.WRITE)
file.store_buffer(zip_reader.read_file(path))
zip_reader.close()
DirAccess.remove_absolute(TEMP_FILE_NAME)
updated.emit(next_version_release.tag_name.substr(1))
func _on_notes_button_pressed() -> void:
OS.shell_open(next_version_release.html_url)

View File

@@ -0,0 +1 @@
uid://kpwo418lb2t2

View File

@@ -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"]

View File

@@ -0,0 +1,48 @@
@tool
extends EditorProperty
const DialoguePropertyEditorControl = preload("./editor_property_control.tscn")
var editor_plugin: EditorPlugin
var control = DialoguePropertyEditorControl.instantiate()
var current_value: Resource
var is_updating: bool = false
func _init() -> void:
add_child(control)
control.resource = current_value
control.pressed.connect(_on_button_pressed)
control.resource_changed.connect(_on_resource_changed)
func _update_property() -> void:
var next_value = get_edited_object()[get_edited_property()]
# The resource might have been deleted elsewhere so check that it's not in a weird state
if is_instance_valid(next_value) and not next_value.resource_path.ends_with(".dialogue"):
emit_changed(get_edited_property(), null)
return
if next_value == current_value: return
is_updating = true
current_value = next_value
control.resource = current_value
is_updating = false
### Signals
func _on_button_pressed() -> void:
editor_plugin.edit(current_value)
func _on_resource_changed(next_resource: Resource) -> void:
emit_changed(get_edited_property(), next_resource)

View File

@@ -0,0 +1 @@
uid://nyypeje1a036

View File

@@ -0,0 +1,147 @@
@tool
extends HBoxContainer
signal pressed()
signal resource_changed(next_resource: Resource)
const ITEM_NEW = 100
const ITEM_QUICK_LOAD = 200
const ITEM_LOAD = 201
const ITEM_EDIT = 300
const ITEM_CLEAR = 301
const ITEM_FILESYSTEM = 400
@onready var button: Button = $ResourceButton
@onready var menu_button: Button = $MenuButton
@onready var menu: PopupMenu = $Menu
@onready var quick_open_dialog: ConfirmationDialog = $QuickOpenDialog
@onready var files_list = $QuickOpenDialog/FilesList
@onready var new_dialog: FileDialog = $NewDialog
@onready var open_dialog: FileDialog = $OpenDialog
var editor_plugin: EditorPlugin
var resource: Resource:
set(next_resource):
resource = next_resource
if button:
button.resource = resource
get:
return resource
var is_waiting_for_file: bool = false
var quick_selected_file: String = ""
func _ready() -> void:
menu_button.icon = get_theme_icon("GuiDropdown", "EditorIcons")
editor_plugin = Engine.get_meta("DialogueManagerPlugin")
func build_menu() -> void:
menu.clear()
menu.add_icon_item(editor_plugin._get_plugin_icon(), "New Dialogue", ITEM_NEW)
menu.add_separator()
menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Quick Load", ITEM_QUICK_LOAD)
menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Load", ITEM_LOAD)
if resource:
menu.add_icon_item(get_theme_icon("Edit", "EditorIcons"), "Edit", ITEM_EDIT)
menu.add_icon_item(get_theme_icon("Clear", "EditorIcons"), "Clear", ITEM_CLEAR)
menu.add_separator()
menu.add_item("Show in FileSystem", ITEM_FILESYSTEM)
menu.size = Vector2.ZERO
### Signals
func _on_new_dialog_file_selected(path: String) -> void:
editor_plugin.main_view.new_file(path)
is_waiting_for_file = false
if Engine.get_meta("DMCache").has_file(path):
resource_changed.emit(load(path))
else:
var next_resource: Resource = await editor_plugin.import_plugin.compiled_resource
next_resource.resource_path = path
resource_changed.emit(next_resource)
func _on_open_dialog_file_selected(file: String) -> void:
resource_changed.emit(load(file))
func _on_file_dialog_canceled() -> void:
is_waiting_for_file = false
func _on_resource_button_pressed() -> void:
if is_instance_valid(resource):
EditorInterface.call_deferred("edit_resource", resource)
else:
build_menu()
menu.position = get_viewport().position + Vector2i(
button.global_position.x + button.size.x - menu.size.x,
2 + menu_button.global_position.y + button.size.y
)
menu.popup()
func _on_resource_button_resource_dropped(next_resource: Resource) -> void:
resource_changed.emit(next_resource)
func _on_menu_button_pressed() -> void:
build_menu()
menu.position = get_viewport().position + Vector2i(
menu_button.global_position.x + menu_button.size.x - menu.size.x,
2 + menu_button.global_position.y + menu_button.size.y
)
menu.popup()
func _on_menu_id_pressed(id: int) -> void:
match id:
ITEM_NEW:
is_waiting_for_file = true
new_dialog.popup_centered()
ITEM_QUICK_LOAD:
quick_selected_file = ""
files_list.files = Engine.get_meta("DMCache").get_files()
if resource:
files_list.select_file(resource.resource_path)
quick_open_dialog.popup_centered()
files_list.focus_filter()
ITEM_LOAD:
is_waiting_for_file = true
open_dialog.popup_centered()
ITEM_EDIT:
EditorInterface.call_deferred("edit_resource", resource)
ITEM_CLEAR:
resource_changed.emit(null)
ITEM_FILESYSTEM:
var file_system = EditorInterface.get_file_system_dock()
file_system.navigate_to_path(resource.resource_path)
func _on_files_list_file_double_clicked(file_path: String) -> void:
resource_changed.emit(load(file_path))
quick_open_dialog.hide()
func _on_files_list_file_selected(file_path: String) -> void:
quick_selected_file = file_path
func _on_quick_open_dialog_confirmed() -> void:
if quick_selected_file != "":
resource_changed.emit(load(quick_selected_file))

View File

@@ -0,0 +1 @@
uid://dooe2pflnqtve

View File

@@ -0,0 +1,58 @@
[gd_scene load_steps=4 format=3 uid="uid://ycn6uaj7dsrh"]
[ext_resource type="Script" uid="uid://dooe2pflnqtve" path="res://addons/dialogue_manager/components/editor_property/editor_property_control.gd" id="1_het12"]
[ext_resource type="PackedScene" uid="uid://b16uuqjuof3n5" path="res://addons/dialogue_manager/components/editor_property/resource_button.tscn" id="2_hh3d4"]
[ext_resource type="PackedScene" uid="uid://dnufpcdrreva3" path="res://addons/dialogue_manager/components/files_list.tscn" id="3_l8fp6"]
[node name="PropertyEditorButton" type="HBoxContainer"]
offset_right = 40.0
offset_bottom = 40.0
size_flags_horizontal = 3
theme_override_constants/separation = 0
script = ExtResource("1_het12")
[node name="ResourceButton" parent="." instance=ExtResource("2_hh3d4")]
layout_mode = 2
text = "<empty>"
text_overrun_behavior = 3
clip_text = true
[node name="MenuButton" type="Button" parent="."]
layout_mode = 2
[node name="Menu" type="PopupMenu" parent="."]
[node name="QuickOpenDialog" type="ConfirmationDialog" parent="."]
title = "Find Dialogue Resource"
size = Vector2i(400, 600)
min_size = Vector2i(400, 600)
ok_button_text = "Open"
[node name="FilesList" parent="QuickOpenDialog" instance=ExtResource("3_l8fp6")]
[node name="NewDialog" type="FileDialog" parent="."]
size = Vector2i(900, 750)
min_size = Vector2i(900, 750)
dialog_hide_on_ok = true
filters = PackedStringArray("*.dialogue ; Dialogue")
[node name="OpenDialog" type="FileDialog" parent="."]
title = "Open a File"
size = Vector2i(900, 750)
min_size = Vector2i(900, 750)
ok_button_text = "Open"
dialog_hide_on_ok = true
file_mode = 0
filters = PackedStringArray("*.dialogue ; Dialogue")
[connection signal="pressed" from="ResourceButton" to="." method="_on_resource_button_pressed"]
[connection signal="resource_dropped" from="ResourceButton" to="." method="_on_resource_button_resource_dropped"]
[connection signal="pressed" from="MenuButton" to="." method="_on_menu_button_pressed"]
[connection signal="id_pressed" from="Menu" to="." method="_on_menu_id_pressed"]
[connection signal="confirmed" from="QuickOpenDialog" to="." method="_on_quick_open_dialog_confirmed"]
[connection signal="file_double_clicked" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_double_clicked"]
[connection signal="file_selected" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_selected"]
[connection signal="canceled" from="NewDialog" to="." method="_on_file_dialog_canceled"]
[connection signal="file_selected" from="NewDialog" to="." method="_on_new_dialog_file_selected"]
[connection signal="canceled" from="OpenDialog" to="." method="_on_file_dialog_canceled"]
[connection signal="file_selected" from="OpenDialog" to="." method="_on_open_dialog_file_selected"]

View File

@@ -0,0 +1,48 @@
@tool
extends Button
signal resource_dropped(next_resource: Resource)
var resource: Resource:
set(next_resource):
resource = next_resource
if resource:
icon = Engine.get_meta("DialogueManagerPlugin")._get_plugin_icon()
text = resource.resource_path.get_file().replace(".dialogue", "")
else:
icon = null
text = "<empty>"
get:
return resource
func _notification(what: int) -> void:
match what:
NOTIFICATION_DRAG_BEGIN:
var data = get_viewport().gui_get_drag_data()
if typeof(data) == TYPE_DICTIONARY and data.type == "files" and data.files.size() > 0 and data.files[0].ends_with(".dialogue"):
add_theme_stylebox_override("normal", get_theme_stylebox("focus", "LineEdit"))
add_theme_stylebox_override("hover", get_theme_stylebox("focus", "LineEdit"))
NOTIFICATION_DRAG_END:
self.resource = resource
remove_theme_stylebox_override("normal")
remove_theme_stylebox_override("hover")
func _can_drop_data(at_position: Vector2, data) -> bool:
if typeof(data) != TYPE_DICTIONARY: return false
if data.type != "files": return false
var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
return files.size() > 0
func _drop_data(at_position: Vector2, data) -> void:
var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue")
if files.size() == 0: return
resource_dropped.emit(load(files[0]))

View File

@@ -0,0 +1 @@
uid://damhqta55t67c

View File

@@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3 uid="uid://b16uuqjuof3n5"]
[ext_resource type="Script" uid="uid://damhqta55t67c" path="res://addons/dialogue_manager/components/editor_property/resource_button.gd" id="1_7u2i7"]
[node name="ResourceButton" type="Button"]
offset_right = 8.0
offset_bottom = 8.0
size_flags_horizontal = 3
script = ExtResource("1_7u2i7")

View File

@@ -0,0 +1,85 @@
@tool
extends HBoxContainer
signal error_pressed(line_number)
const DialogueConstants = preload("../constants.gd")
@onready var error_button: Button = $ErrorButton
@onready var next_button: Button = $NextButton
@onready var count_label: Label = $CountLabel
@onready var previous_button: Button = $PreviousButton
## The index of the current error being shown
var error_index: int = 0:
set(next_error_index):
error_index = wrap(next_error_index, 0, errors.size())
show_error()
get:
return error_index
## The list of all errors
var errors: Array = []:
set(next_errors):
errors = next_errors
self.error_index = 0
get:
return errors
func _ready() -> void:
apply_theme()
hide()
## Set up colors and icons
func apply_theme() -> void:
error_button.add_theme_color_override("font_color", get_theme_color("error_color", "Editor"))
error_button.add_theme_color_override("font_hover_color", get_theme_color("error_color", "Editor"))
error_button.icon = get_theme_icon("StatusError", "EditorIcons")
previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons")
next_button.icon = get_theme_icon("ArrowRight", "EditorIcons")
## Move the error index to match a given line
func show_error_for_line_number(line_number: int) -> void:
for i in range(0, errors.size()):
if errors[i].line_number == line_number:
self.error_index = i
## Show the current error
func show_error() -> void:
if errors.size() == 0:
hide()
else:
show()
count_label.text = DialogueConstants.translate(&"n_of_n").format({ index = error_index + 1, total = errors.size() })
var error = errors[error_index]
error_button.text = DialogueConstants.translate(&"errors.line_and_message").format({ line = error.line_number, column = error.column_number, message = DialogueConstants.get_error_message(error.error) })
if error.has("external_error"):
error_button.text += " " + DialogueConstants.get_error_message(error.external_error)
### Signals
func _on_errors_panel_theme_changed() -> void:
apply_theme()
func _on_error_button_pressed() -> void:
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()

View File

@@ -0,0 +1 @@
uid://d2l8nlb6hhrfp

View File

@@ -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"]

View File

@@ -0,0 +1,150 @@
@tool
extends VBoxContainer
signal file_selected(file_path: String)
signal file_popup_menu_requested(at_position: Vector2)
signal file_double_clicked(file_path: String)
signal file_middle_clicked(file_path: String)
const DialogueConstants = preload("../constants.gd")
const MODIFIED_SUFFIX = "(*)"
@export var icon: Texture2D
@onready var filter_edit: LineEdit = $FilterEdit
@onready var list: ItemList = $List
var file_map: Dictionary = {}
var current_file_path: String = ""
var 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 = DialogueConstants.translate(&"files_list.filter")
func focus_filter() -> void:
filter_edit.grab_focus()
func select_file(file: String) -> void:
list.deselect_all()
for i in range(0, list.get_item_count()):
var item_text = list.get_item_text(i).replace(MODIFIED_SUFFIX, "")
if item_text == get_nice_file(file, item_text.count("/") + 1):
list.select(i)
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 in files:
var nice_file: String = get_nice_file(file)
# See if a value with just the file name is already in the map
for key in file_map.keys():
if file_map[key] == nice_file:
var bit_count = nice_file.count("/") + 2
var existing_nice_file = get_nice_file(key, bit_count)
nice_file = get_nice_file(file, bit_count)
while nice_file == existing_nice_file:
bit_count += 1
existing_nice_file = get_nice_file(key, bit_count)
nice_file = get_nice_file(file, bit_count)
file_map[key] = existing_nice_file
file_map[file] = nice_file
func get_nice_file(file_path: String, path_bit_count: int = 1) -> String:
var bits = file_path.replace("res://", "").replace(".dialogue", "").split("/")
bits = bits.slice(-path_bit_count)
return "/".join(bits)
func apply_filter() -> void:
list.clear()
for file in file_map.keys():
if filter == "" or filter.to_lower() in file.to_lower():
var nice_file = file_map[file]
if file in unsaved_files:
nice_file += MODIFIED_SUFFIX
var new_id := list.add_item(nice_file)
list.set_item_icon(new_id, icon)
select_file(current_file_path)
func apply_theme() -> void:
if is_instance_valid(filter_edit):
filter_edit.right_icon = get_theme_icon("Search", "EditorIcons")
if is_instance_valid(list):
list.add_theme_stylebox_override("panel", get_theme_stylebox("panel", "Panel"))
### Signals
func _on_theme_changed() -> void:
apply_theme()
func _on_filter_edit_text_changed(new_text: String) -> void:
self.filter = new_text
func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
var file = file_map.find_key(item_text)
if mouse_button_index == MOUSE_BUTTON_LEFT or mouse_button_index == MOUSE_BUTTON_RIGHT:
select_file(file)
file_selected.emit(file)
if mouse_button_index == MOUSE_BUTTON_RIGHT:
file_popup_menu_requested.emit(at_position)
if mouse_button_index == MOUSE_BUTTON_MIDDLE:
file_middle_clicked.emit(file)
func _on_list_item_activated(index: int) -> void:
var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "")
var file = file_map.find_key(item_text)
select_file(file)
file_double_clicked.emit(file)

View File

@@ -0,0 +1 @@
uid://dqa4a4wwoo0aa

View File

@@ -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"]

View File

@@ -0,0 +1,229 @@
@tool
extends Control
signal result_selected(path: String, cursor: Vector2, length: int)
const DialogueConstants = preload("../constants.gd")
@export var main_view: Control
@export var code_edit: CodeEdit
@onready var input: LineEdit = %Input
@onready var search_button: Button = %SearchButton
@onready var match_case_button: CheckBox = %MatchCaseButton
@onready var replace_toggle: CheckButton = %ReplaceToggle
@onready var replace_container: VBoxContainer = %ReplaceContainer
@onready var replace_input: LineEdit = %ReplaceInput
@onready var replace_selected_button: Button = %ReplaceSelectedButton
@onready var replace_all_button: Button = %ReplaceAllButton
@onready var results_container: VBoxContainer = %ResultsContainer
@onready var result_template: HBoxContainer = %ResultTemplate
var current_results: Dictionary = {}:
set(value):
current_results = value
update_results_view()
if current_results.size() == 0:
replace_selected_button.disabled = true
replace_all_button.disabled = true
else:
replace_selected_button.disabled = false
replace_all_button.disabled = false
get:
return current_results
var selections: PackedStringArray = []
func prepare() -> void:
input.grab_focus()
var template_label = result_template.get_node("Label")
template_label.get_theme_stylebox(&"focus").bg_color = code_edit.theme_overrides.current_line_color
template_label.add_theme_font_override(&"normal_font", code_edit.get_theme_font(&"font"))
replace_toggle.set_pressed_no_signal(false)
replace_container.hide()
$VBoxContainer/HBoxContainer/FindContainer/Label.text = DialogueConstants.translate(&"search.find")
input.placeholder_text = DialogueConstants.translate(&"search.placeholder")
input.text = ""
search_button.text = DialogueConstants.translate(&"search.find_all")
match_case_button.text = DialogueConstants.translate(&"search.match_case")
replace_toggle.text = DialogueConstants.translate(&"search.toggle_replace")
$VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceLabel.text = DialogueConstants.translate(&"search.replace_with")
replace_input.placeholder_text = DialogueConstants.translate(&"search.replace_placeholder")
replace_input.text = ""
replace_all_button.text = DialogueConstants.translate(&"search.replace_all")
replace_selected_button.text = DialogueConstants.translate(&"search.replace_selected")
selections.clear()
self.current_results = {}
#region helpers
func update_results_view() -> void:
for child in results_container.get_children():
child.queue_free()
for path in current_results.keys():
var path_label: Label = Label.new()
path_label.text = path
# Show open files
if main_view.open_buffers.has(path):
path_label.text += "(*)"
results_container.add_child(path_label)
for path_result in current_results.get(path):
var result_item: HBoxContainer = result_template.duplicate()
var checkbox: CheckBox = result_item.get_node("CheckBox") as CheckBox
var key: String = get_selection_key(path, path_result)
checkbox.toggled.connect(func(is_pressed):
if is_pressed:
if not selections.has(key):
selections.append(key)
else:
if selections.has(key):
selections.remove_at(selections.find(key))
)
checkbox.set_pressed_no_signal(selections.has(key))
checkbox.visible = replace_toggle.button_pressed
var result_label: RichTextLabel = result_item.get_node("Label") as RichTextLabel
var colors: Dictionary = code_edit.theme_overrides
var highlight: String = ""
if replace_toggle.button_pressed:
var matched_word: String = "[bgcolor=" + colors.critical_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + path_result.matched_text + "[/color][/bgcolor]"
highlight = "[s]" + matched_word + "[/s][bgcolor=" + colors.notice_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + replace_input.text + "[/color][/bgcolor]"
else:
highlight = "[bgcolor=" + colors.notice_color.to_html() + "][color=" + colors.text_color.to_html() + "]" + path_result.matched_text + "[/color][/bgcolor]"
var text: String = path_result.text.substr(0, path_result.index) + highlight + path_result.text.substr(path_result.index + path_result.query.length())
result_label.text = "%s: %s" % [str(path_result.line).lpad(4), text]
result_label.gui_input.connect(func(event):
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).double_click:
result_selected.emit(path, Vector2(path_result.index, path_result.line), path_result.query.length())
)
results_container.add_child(result_item)
func find_in_files() -> Dictionary:
var results: Dictionary = {}
var q: String = input.text
var cache = Engine.get_meta("DMCache")
var file: FileAccess
for path in cache.get_files():
var path_results: Array = []
var lines: PackedStringArray = []
if main_view.open_buffers.has(path):
lines = main_view.open_buffers.get(path).text.split("\n")
else:
file = FileAccess.open(path, FileAccess.READ)
lines = file.get_as_text().split("\n")
for i in range(0, lines.size()):
var index: int = find_in_line(lines[i], q)
while index > -1:
path_results.append({
line = i,
index = index,
text = lines[i],
matched_text = lines[i].substr(index, q.length()),
query = q
})
index = find_in_line(lines[i], q, index + q.length())
if file != null and file.is_open():
file.close()
if path_results.size() > 0:
results[path] = path_results
return results
func get_selection_key(path: String, path_result: Dictionary) -> String:
return "%s-%d-%d" % [path, path_result.line, path_result.index]
func find_in_line(line: String, query: String, from_index: int = 0) -> int:
if match_case_button.button_pressed:
return line.find(query, from_index)
else:
return line.findn(query, from_index)
func replace_results(only_selected: bool) -> void:
var file: FileAccess
var lines: PackedStringArray = []
for path in current_results:
if main_view.open_buffers.has(path):
lines = main_view.open_buffers.get(path).text.split("\n")
else:
file = FileAccess.open(path, FileAccess.READ_WRITE)
lines = file.get_as_text().split("\n")
# Read the results in reverse because we're going to be modifying them as we go
var path_results: Array = current_results.get(path).duplicate()
path_results.reverse()
for path_result in path_results:
var key: String = get_selection_key(path, path_result)
if not only_selected or (only_selected and selections.has(key)):
lines[path_result.line] = lines[path_result.line].substr(0, path_result.index) + replace_input.text + lines[path_result.line].substr(path_result.index + path_result.matched_text.length())
var replaced_text: String = "\n".join(lines)
if file != null and file.is_open():
file.seek(0)
file.store_string(replaced_text)
file.close()
else:
main_view.open_buffers.get(path).text = replaced_text
if main_view.current_file_path == path:
code_edit.text = replaced_text
current_results = find_in_files()
#endregion
#region signals
func _on_search_button_pressed() -> void:
selections.clear()
self.current_results = find_in_files()
func _on_input_text_submitted(new_text: String) -> void:
_on_search_button_pressed()
func _on_replace_toggle_toggled(toggled_on: bool) -> void:
replace_container.visible = toggled_on
if toggled_on:
replace_input.grab_focus()
update_results_view()
func _on_replace_input_text_changed(new_text: String) -> void:
update_results_view()
func _on_replace_selected_button_pressed() -> void:
replace_results(true)
func _on_replace_all_button_pressed() -> void:
replace_results(false)
func _on_match_case_button_toggled(toggled_on: bool) -> void:
_on_search_button_pressed()
#endregion

View File

@@ -0,0 +1 @@
uid://q368fmxxa8sd

View File

@@ -0,0 +1,139 @@
[gd_scene load_steps=3 format=3 uid="uid://0n7hwviyyly4"]
[ext_resource type="Script" uid="uid://q368fmxxa8sd" path="res://addons/dialogue_manager/components/find_in_files.gd" id="1_3xicy"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_owohg"]
bg_color = Color(0.266667, 0.278431, 0.352941, 0.243137)
corner_detail = 1
[node name="FindInFiles" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource("1_3xicy")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="FindContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer/FindContainer"]
layout_mode = 2
text = "Find:"
[node name="Input" type="LineEdit" parent="VBoxContainer/HBoxContainer/FindContainer"]
unique_name_in_owner = true
layout_mode = 2
clear_button_enabled = true
[node name="FindToolbar" type="HBoxContainer" parent="VBoxContainer/HBoxContainer/FindContainer"]
layout_mode = 2
[node name="SearchButton" type="Button" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Find all..."
[node name="MatchCaseButton" type="CheckBox" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Match case"
[node name="Control" type="Control" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ReplaceToggle" type="CheckButton" parent="VBoxContainer/HBoxContainer/FindContainer/FindToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Replace"
[node name="ReplaceContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="ReplaceLabel" type="Label" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
layout_mode = 2
text = "Replace with:"
[node name="ReplaceInput" type="LineEdit" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
clear_button_enabled = true
[node name="ReplaceToolbar" type="HBoxContainer" parent="VBoxContainer/HBoxContainer/ReplaceContainer"]
layout_mode = 2
[node name="ReplaceSelectedButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Replace selected"
[node name="ReplaceAllButton" type="Button" parent="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Replace all"
[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="ReplaceToolbar" type="HBoxContainer" parent="VBoxContainer/VBoxContainer"]
layout_mode = 2
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
follow_focus = true
[node name="ResultsContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 0
[node name="ResultTemplate" type="HBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_left = 155.0
offset_top = -74.0
offset_right = 838.0
offset_bottom = -51.0
[node name="CheckBox" type="CheckBox" parent="ResultTemplate"]
layout_mode = 2
[node name="Label" type="RichTextLabel" parent="ResultTemplate"]
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
theme_override_styles/focus = SubResource("StyleBoxFlat_owohg")
bbcode_enabled = true
text = "Result"
fit_content = true
scroll_active = false
[connection signal="text_submitted" from="VBoxContainer/HBoxContainer/FindContainer/Input" to="." method="_on_input_text_submitted"]
[connection signal="pressed" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/SearchButton" to="." method="_on_search_button_pressed"]
[connection signal="toggled" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/MatchCaseButton" to="." method="_on_match_case_button_toggled"]
[connection signal="toggled" from="VBoxContainer/HBoxContainer/FindContainer/FindToolbar/ReplaceToggle" to="." method="_on_replace_toggle_toggled"]
[connection signal="text_changed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceInput" to="." method="_on_replace_input_text_changed"]
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar/ReplaceSelectedButton" to="." method="_on_replace_selected_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ReplaceContainer/ReplaceToolbar/ReplaceAllButton" to="." method="_on_replace_all_button_pressed"]

View File

@@ -0,0 +1,218 @@
@tool
extends VBoxContainer
signal open_requested()
signal close_requested()
const DialogueConstants = preload("../constants.gd")
@onready var input: LineEdit = $Search/Input
@onready var result_label: Label = $Search/ResultLabel
@onready var previous_button: Button = $Search/PreviousButton
@onready var next_button: Button = $Search/NextButton
@onready var match_case_button: CheckBox = $Search/MatchCaseCheckBox
@onready var replace_check_button: CheckButton = $Search/ReplaceCheckButton
@onready var replace_panel: HBoxContainer = $Replace
@onready var replace_input: LineEdit = $Replace/Input
@onready var replace_button: Button = $Replace/ReplaceButton
@onready var replace_all_button: Button = $Replace/ReplaceAllButton
# The code edit we will be affecting (for some reason exporting this didn't work)
var code_edit: CodeEdit:
set(next_code_edit):
code_edit = next_code_edit
code_edit.gui_input.connect(_on_text_edit_gui_input)
code_edit.text_changed.connect(_on_text_edit_text_changed)
get:
return code_edit
var results: Array = []
var result_index: int = -1:
set(next_result_index):
result_index = next_result_index
if results.size() > 0:
var r = results[result_index]
code_edit.set_caret_line(r[0])
code_edit.select(r[0], r[1], r[0], r[1] + r[2])
else:
result_index = -1
if is_instance_valid(code_edit):
code_edit.deselect()
result_label.text = DialogueConstants.translate(&"n_of_n").format({ index = result_index + 1, total = results.size() })
get:
return result_index
func _ready() -> void:
apply_theme()
input.placeholder_text = DialogueConstants.translate(&"search.placeholder")
previous_button.tooltip_text = DialogueConstants.translate(&"search.previous")
next_button.tooltip_text = DialogueConstants.translate(&"search.next")
match_case_button.text = DialogueConstants.translate(&"search.match_case")
$Search/ReplaceCheckButton.text = DialogueConstants.translate(&"search.toggle_replace")
replace_button.text = DialogueConstants.translate(&"search.replace")
replace_all_button.text = DialogueConstants.translate(&"search.replace_all")
$Replace/ReplaceLabel.text = DialogueConstants.translate(&"search.replace_with")
self.result_index = -1
replace_panel.hide()
replace_button.disabled = true
replace_all_button.disabled = true
hide()
func focus_line_edit() -> void:
input.grab_focus()
input.select_all()
func apply_theme() -> void:
if is_instance_valid(previous_button):
previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons")
if is_instance_valid(next_button):
next_button.icon = get_theme_icon("ArrowRight", "EditorIcons")
# Find text in the code
func search(text: String = "", default_result_index: int = 0) -> void:
results.clear()
if text == "":
text = input.text
var lines = code_edit.text.split("\n")
for line_number in range(0, lines.size()):
var line = lines[line_number]
var column = find_in_line(line, text, 0)
while column > -1:
results.append([line_number, column, text.length()])
column = find_in_line(line, text, column + 1)
if results.size() > 0:
replace_button.disabled = false
replace_all_button.disabled = false
else:
replace_button.disabled = true
replace_all_button.disabled = true
self.result_index = clamp(default_result_index, 0, results.size() - 1)
# Find text in a string and match case if requested
func find_in_line(line: String, text: String, from_index: int = 0) -> int:
if match_case_button.button_pressed:
return line.find(text, from_index)
else:
return line.findn(text, from_index)
#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 = code_edit.get_selected_text()
if input.text == "" and selection != "":
input.text = selection
search(selection)
else:
search()
else:
input.text = ""
func _on_input_gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.is_pressed():
match event.as_text():
"Enter":
search(input.text)
"Escape":
emit_signal("close_requested")
func _on_replace_button_pressed() -> void:
if result_index == -1: return
# Replace the selection at result index
var r: Array = results[result_index]
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

View File

@@ -0,0 +1 @@
uid://cijsmjkq21cdq

View File

@@ -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"]

View File

@@ -0,0 +1,69 @@
@tool
extends VBoxContainer
signal title_selected(title: String)
const DialogueConstants = preload("../constants.gd")
@onready var filter_edit: LineEdit = $FilterEdit
@onready var list: ItemList = $List
var titles: PackedStringArray:
set(next_titles):
titles = next_titles
apply_filter()
get:
return titles
var filter: String:
set(next_filter):
filter = next_filter
apply_filter()
get:
return filter
func _ready() -> void:
apply_theme()
filter_edit.placeholder_text = DialogueConstants.translate(&"titles_list.filter")
func select_title(title: String) -> void:
list.deselect_all()
for i in range(0, list.get_item_count()):
if list.get_item_text(i) == title.strip_edges():
list.select(i)
func apply_filter() -> void:
list.clear()
for title in titles:
if filter == "" or filter.to_lower() in title.to_lower():
list.add_item(title.strip_edges())
func apply_theme() -> void:
if is_instance_valid(filter_edit):
filter_edit.right_icon = get_theme_icon("Search", "EditorIcons")
if is_instance_valid(list):
list.add_theme_stylebox_override("panel", get_theme_stylebox("panel", "Panel"))
### Signals
func _on_theme_changed() -> void:
apply_theme()
func _on_filter_edit_text_changed(new_text: String) -> void:
self.filter = new_text
func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
if mouse_button_index == MOUSE_BUTTON_LEFT:
var title = list.get_item_text(index)
title_selected.emit(title)

View File

@@ -0,0 +1 @@
uid://d0k2wndjj0ifm

View File

@@ -0,0 +1,27 @@
[gd_scene load_steps=2 format=3 uid="uid://ctns6ouwwd68i"]
[ext_resource type="Script" uid="uid://d0k2wndjj0ifm" path="res://addons/dialogue_manager/components/title_list.gd" id="1_5qqmd"]
[node name="TitleList" type="VBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource("1_5qqmd")
[node name="FilterEdit" type="LineEdit" parent="."]
layout_mode = 2
placeholder_text = "Filter titles"
clear_button_enabled = true
[node name="List" type="ItemList" parent="."]
layout_mode = 2
size_flags_vertical = 3
allow_reselect = true
[connection signal="theme_changed" from="." to="." method="_on_theme_changed"]
[connection signal="text_changed" from="FilterEdit" to="." method="_on_filter_edit_text_changed"]
[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"]

View File

@@ -0,0 +1,125 @@
@tool
extends Button
const DialogueConstants = preload("../constants.gd")
const DialogueSettings = preload("../settings.gd")
const REMOTE_RELEASES_URL = "https://api.github.com/repos/nathanhoad/godot_dialogue_manager/releases"
@onready var http_request: HTTPRequest = $HTTPRequest
@onready var download_dialog: AcceptDialog = $DownloadDialog
@onready var download_update_panel = $DownloadDialog/DownloadUpdatePanel
@onready var needs_reload_dialog: AcceptDialog = $NeedsReloadDialog
@onready var update_failed_dialog: AcceptDialog = $UpdateFailedDialog
@onready var timer: Timer = $Timer
var needs_reload: bool = false
# A lambda that gets called just before refreshing the plugin. Return false to stop the reload.
var on_before_refresh: Callable = func(): return true
func _ready() -> void:
hide()
apply_theme()
# Check for updates on GitHub
check_for_update()
# Check again every few hours
timer.start(60 * 60 * 12)
# Convert a version number to an actually comparable number
func version_to_number(version: String) -> int:
var bits = version.split(".")
return bits[0].to_int() * 1000000 + bits[1].to_int() * 1000 + bits[2].to_int()
func apply_theme() -> void:
var color: Color = get_theme_color("success_color", "Editor")
if needs_reload:
color = get_theme_color("error_color", "Editor")
icon = get_theme_icon("Reload", "EditorIcons")
add_theme_color_override("icon_normal_color", color)
add_theme_color_override("icon_focus_color", color)
add_theme_color_override("icon_hover_color", color)
add_theme_color_override("font_color", color)
add_theme_color_override("font_focus_color", color)
add_theme_color_override("font_hover_color", color)
func check_for_update() -> void:
if DialogueSettings.get_user_value("check_for_updates", true):
http_request.request(REMOTE_RELEASES_URL)
### Signals
func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS: return
var current_version: String = Engine.get_meta("DialogueManagerPlugin").get_version()
# Work out the next version from the releases information on GitHub
var response = JSON.parse_string(body.get_string_from_utf8())
if typeof(response) != TYPE_ARRAY: return
# GitHub releases are in order of creation, not order of version
var versions = (response as Array).filter(func(release):
var version: String = release.tag_name.substr(1)
var major_version: int = version.split(".")[0].to_int()
var current_major_version: int = current_version.split(".")[0].to_int()
return major_version == current_major_version and version_to_number(version) > version_to_number(current_version)
)
if versions.size() > 0:
download_update_panel.next_version_release = versions[0]
text = DialogueConstants.translate(&"update.available").format({ version = versions[0].tag_name.substr(1) })
show()
func _on_update_button_pressed() -> void:
if needs_reload:
var will_refresh = on_before_refresh.call()
if will_refresh:
EditorInterface.restart_editor(true)
else:
var scale: float = EditorInterface.get_editor_scale()
download_dialog.min_size = Vector2(300, 250) * scale
download_dialog.popup_centered()
func _on_download_dialog_close_requested() -> void:
download_dialog.hide()
func _on_download_update_panel_updated(updated_to_version: String) -> void:
download_dialog.hide()
needs_reload_dialog.dialog_text = DialogueConstants.translate(&"update.needs_reload")
needs_reload_dialog.ok_button_text = DialogueConstants.translate(&"update.reload_ok_button")
needs_reload_dialog.cancel_button_text = DialogueConstants.translate(&"update.reload_cancel_button")
needs_reload_dialog.popup_centered()
needs_reload = true
text = DialogueConstants.translate(&"update.reload_project")
apply_theme()
func _on_download_update_panel_failed() -> void:
download_dialog.hide()
update_failed_dialog.dialog_text = DialogueConstants.translate(&"update.failed")
update_failed_dialog.popup_centered()
func _on_needs_reload_dialog_confirmed() -> void:
EditorInterface.restart_editor(true)
func _on_timer_timeout() -> void:
if not needs_reload:
check_for_update()

View File

@@ -0,0 +1 @@
uid://cr1tt12dh5ecr

View File

@@ -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"]

231
addons/dialogue_manager/constants.gd vendored Normal file
View File

@@ -0,0 +1,231 @@
class_name DMConstants extends RefCounted
const USER_CONFIG_PATH = "user://dialogue_manager_user_config.json"
const CACHE_PATH = "user://dialogue_manager_cache.json"
enum MutationBehaviour {
Wait,
DoNotWait,
Skip
}
enum TranslationSource {
None,
Guess,
CSV,
PO
}
# Token types
const TOKEN_FUNCTION = &"function"
const TOKEN_DICTIONARY_REFERENCE = &"dictionary_reference"
const TOKEN_DICTIONARY_NESTED_REFERENCE = &"dictionary_nested_reference"
const TOKEN_GROUP = &"group"
const TOKEN_ARRAY = &"array"
const TOKEN_DICTIONARY = &"dictionary"
const TOKEN_PARENS_OPEN = &"parens_open"
const TOKEN_PARENS_CLOSE = &"parens_close"
const TOKEN_BRACKET_OPEN = &"bracket_open"
const TOKEN_BRACKET_CLOSE = &"bracket_close"
const TOKEN_BRACE_OPEN = &"brace_open"
const TOKEN_BRACE_CLOSE = &"brace_close"
const TOKEN_COLON = &"colon"
const TOKEN_COMPARISON = &"comparison"
const TOKEN_ASSIGNMENT = &"assignment"
const TOKEN_OPERATOR = &"operator"
const TOKEN_COMMA = &"comma"
const TOKEN_NULL_COALESCE = &"null_coalesce"
const TOKEN_DOT = &"dot"
const TOKEN_CONDITION = &"condition"
const TOKEN_BOOL = &"bool"
const TOKEN_NOT = &"not"
const TOKEN_AND_OR = &"and_or"
const TOKEN_STRING = &"string"
const TOKEN_NUMBER = &"number"
const TOKEN_VARIABLE = &"variable"
const TOKEN_COMMENT = &"comment"
const TOKEN_VALUE = &"value"
const TOKEN_ERROR = &"error"
# Line types
const TYPE_UNKNOWN = &""
const TYPE_IMPORT = &"import"
const TYPE_USING = &"using"
const TYPE_COMMENT = &"comment"
const TYPE_RESPONSE = &"response"
const TYPE_TITLE = &"title"
const TYPE_CONDITION = &"condition"
const TYPE_WHILE = &"while"
const TYPE_MATCH = &"match"
const TYPE_WHEN = &"when"
const TYPE_MUTATION = &"mutation"
const TYPE_GOTO = &"goto"
const TYPE_DIALOGUE = &"dialogue"
const TYPE_RANDOM = &"random"
const TYPE_ERROR = &"error"
# Line IDs
const ID_NULL = &""
const ID_ERROR = &"error"
const ID_ERROR_INVALID_TITLE = &"invalid title"
const ID_ERROR_TITLE_HAS_NO_BODY = &"title has no body"
const ID_END = &"end"
const ID_END_CONVERSATION = &"end!"
# Errors
const ERR_ERRORS_IN_IMPORTED_FILE = 100
const ERR_FILE_ALREADY_IMPORTED = 101
const ERR_DUPLICATE_IMPORT_NAME = 102
const ERR_EMPTY_TITLE = 103
const ERR_DUPLICATE_TITLE = 104
const ERR_TITLE_INVALID_CHARACTERS = 106
const ERR_UNKNOWN_TITLE = 107
const ERR_INVALID_TITLE_REFERENCE = 108
const ERR_TITLE_REFERENCE_HAS_NO_CONTENT = 109
const ERR_INVALID_EXPRESSION = 110
const ERR_UNEXPECTED_CONDITION = 111
const ERR_DUPLICATE_ID = 112
const ERR_MISSING_ID = 113
const ERR_INVALID_INDENTATION = 114
const ERR_INVALID_CONDITION_INDENTATION = 115
const ERR_INCOMPLETE_EXPRESSION = 116
const ERR_INVALID_EXPRESSION_FOR_VALUE = 117
const ERR_UNKNOWN_LINE_SYNTAX = 118
const ERR_TITLE_BEGINS_WITH_NUMBER = 119
const ERR_UNEXPECTED_END_OF_EXPRESSION = 120
const ERR_UNEXPECTED_FUNCTION = 121
const ERR_UNEXPECTED_BRACKET = 122
const ERR_UNEXPECTED_CLOSING_BRACKET = 123
const ERR_MISSING_CLOSING_BRACKET = 124
const ERR_UNEXPECTED_OPERATOR = 125
const ERR_UNEXPECTED_COMMA = 126
const ERR_UNEXPECTED_COLON = 127
const ERR_UNEXPECTED_DOT = 128
const ERR_UNEXPECTED_BOOLEAN = 129
const ERR_UNEXPECTED_STRING = 130
const ERR_UNEXPECTED_NUMBER = 131
const ERR_UNEXPECTED_VARIABLE = 132
const ERR_INVALID_INDEX = 133
const ERR_UNEXPECTED_ASSIGNMENT = 134
const ERR_UNKNOWN_USING = 135
const ERR_EXPECTED_WHEN_OR_ELSE = 136
const ERR_ONLY_ONE_ELSE_ALLOWED = 137
const ERR_WHEN_MUST_BELONG_TO_MATCH = 138
const ERR_CONCURRENT_LINE_WITHOUT_ORIGIN = 139
const ERR_GOTO_NOT_ALLOWED_ON_CONCURRECT_LINES = 140
const ERR_UNEXPECTED_SYNTAX_ON_NESTED_DIALOGUE_LINE = 141
const ERR_NESTED_DIALOGUE_INVALID_JUMP = 142
static var _current_locale: String = ""
static var _current_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_TITLE:
return translate(&"errors.empty_title")
ERR_DUPLICATE_TITLE:
return translate(&"errors.duplicate_title")
ERR_TITLE_INVALID_CHARACTERS:
return translate(&"errors.invalid_title_string")
ERR_TITLE_BEGINS_WITH_NUMBER:
return translate(&"errors.invalid_title_number")
ERR_UNKNOWN_TITLE:
return translate(&"errors.unknown_title")
ERR_INVALID_TITLE_REFERENCE:
return translate(&"errors.jump_to_invalid_title")
ERR_TITLE_REFERENCE_HAS_NO_CONTENT:
return translate(&"errors.title_has_no_content")
ERR_INVALID_EXPRESSION:
return translate(&"errors.invalid_expression")
ERR_UNEXPECTED_CONDITION:
return translate(&"errors.unexpected_condition")
ERR_DUPLICATE_ID:
return translate(&"errors.duplicate_id")
ERR_MISSING_ID:
return translate(&"errors.missing_id")
ERR_INVALID_INDENTATION:
return translate(&"errors.invalid_indentation")
ERR_INVALID_CONDITION_INDENTATION:
return translate(&"errors.condition_has_no_content")
ERR_INCOMPLETE_EXPRESSION:
return translate(&"errors.incomplete_expression")
ERR_INVALID_EXPRESSION_FOR_VALUE:
return translate(&"errors.invalid_expression_for_value")
ERR_FILE_NOT_FOUND:
return translate(&"errors.file_not_found")
ERR_UNEXPECTED_END_OF_EXPRESSION:
return translate(&"errors.unexpected_end_of_expression")
ERR_UNEXPECTED_FUNCTION:
return translate(&"errors.unexpected_function")
ERR_UNEXPECTED_BRACKET:
return translate(&"errors.unexpected_bracket")
ERR_UNEXPECTED_CLOSING_BRACKET:
return translate(&"errors.unexpected_closing_bracket")
ERR_MISSING_CLOSING_BRACKET:
return translate(&"errors.missing_closing_bracket")
ERR_UNEXPECTED_OPERATOR:
return translate(&"errors.unexpected_operator")
ERR_UNEXPECTED_COMMA:
return translate(&"errors.unexpected_comma")
ERR_UNEXPECTED_COLON:
return translate(&"errors.unexpected_colon")
ERR_UNEXPECTED_DOT:
return translate(&"errors.unexpected_dot")
ERR_UNEXPECTED_BOOLEAN:
return translate(&"errors.unexpected_boolean")
ERR_UNEXPECTED_STRING:
return translate(&"errors.unexpected_string")
ERR_UNEXPECTED_NUMBER:
return translate(&"errors.unexpected_number")
ERR_UNEXPECTED_VARIABLE:
return translate(&"errors.unexpected_variable")
ERR_INVALID_INDEX:
return translate(&"errors.invalid_index")
ERR_UNEXPECTED_ASSIGNMENT:
return translate(&"errors.unexpected_assignment")
ERR_UNKNOWN_USING:
return translate(&"errors.unknown_using")
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")
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
return _current_translation.get_message(string)

View File

@@ -0,0 +1 @@
uid://b1oarbmjtyesf

View File

@@ -0,0 +1,232 @@
@icon("./assets/icon.svg")
@tool
## A RichTextLabel specifically for use with [b]Dialogue Manager[/b] dialogue.
class_name DialogueLabel extends RichTextLabel
## Emitted for each letter typed out.
signal spoke(letter: String, letter_index: int, speed: float)
## Emitted when typing paused for a `[wait]`
signal paused_typing(duration: float)
## Emitted when the player skips the typing of dialogue.
signal skipped_typing()
## Emitted when typing finishes.
signal finished_typing()
# The action to press to skip typing.
@export var skip_action: StringName = &"ui_cancel"
## The speed with which the text types out.
@export var seconds_per_step: float = 0.02
## Automatically have a brief pause when these characters are encountered.
@export var pause_at_characters: String = ".?!"
## Don't auto pause if the character after the pause is one of these.
@export var skip_pause_at_character_if_followed_by: String = ")\""
## Don't auto pause after these abbreviations (only if "." is in `pause_at_characters`).[br]
## Abbreviations are limitted to 5 characters in length [br]
## Does not support multi-period abbreviations (ex. "p.m.")
@export var skip_pause_at_abbreviations: PackedStringArray = ["Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex"]
## The amount of time to pause when exposing a character present in `pause_at_characters`.
@export var seconds_per_pause_step: float = 0.3
var _already_mutated_indices: PackedInt32Array = []
## The current line of dialogue.
var dialogue_line:
set(next_dialogue_line):
dialogue_line = next_dialogue_line
custom_minimum_size = Vector2.ZERO
text = ""
text = dialogue_line.text
get:
return dialogue_line
## Whether the label is currently typing itself out.
var is_typing: bool = false:
set(value):
var is_finished: bool = is_typing != value and value == false
is_typing = value
if is_finished:
finished_typing.emit()
get:
return is_typing
var _last_wait_index: int = -1
var _last_mutation_index: int = -1
var _waiting_seconds: float = 0
var _is_awaiting_mutation: bool = false
func _process(delta: float) -> void:
if self.is_typing:
# Type out text
if visible_ratio < 1:
# See if we are waiting
if _waiting_seconds > 0:
_waiting_seconds = _waiting_seconds - delta
# If we are no longer waiting then keep typing
if _waiting_seconds <= 0:
_type_next(delta, _waiting_seconds)
else:
# Make sure any mutations at the end of the line get run
_mutate_inline_mutations(get_total_character_count())
self.is_typing = false
func _unhandled_input(event: InputEvent) -> void:
# Note: this will no longer be reached if using Dialogue Manager > 2.32.2. To make skip handling
# simpler (so all of mouse/keyboard/joypad are together) it is now the responsibility of the
# dialogue balloon.
if self.is_typing and visible_ratio < 1 and InputMap.has_action(skip_action) and event.is_action_pressed(skip_action):
get_viewport().set_input_as_handled()
skip_typing()
## Start typing out the text
func type_out() -> void:
text = dialogue_line.text
visible_characters = 0
visible_ratio = 0
_waiting_seconds = 0
_last_wait_index = -1
_last_mutation_index = -1
_already_mutated_indices.clear()
self.is_typing = true
# Allow typing listeners a chance to connect
await get_tree().process_frame
if get_total_character_count() == 0:
self.is_typing = false
elif seconds_per_step == 0:
_mutate_remaining_mutations()
visible_characters = get_total_character_count()
self.is_typing = false
## Stop typing out the text and jump right to the end
func skip_typing() -> void:
_mutate_remaining_mutations()
visible_characters = get_total_character_count()
self.is_typing = false
skipped_typing.emit()
# Type out the next character(s)
func _type_next(delta: float, seconds_needed: float) -> void:
if _is_awaiting_mutation: return
if visible_characters == get_total_character_count():
return
if _last_mutation_index != visible_characters:
_last_mutation_index = visible_characters
_mutate_inline_mutations(visible_characters)
if _is_awaiting_mutation: return
var additional_waiting_seconds: float = _get_pause(visible_characters)
# Pause on characters like "."
if _should_auto_pause():
additional_waiting_seconds += seconds_per_pause_step
# Pause at literal [wait] directives
if _last_wait_index != visible_characters and additional_waiting_seconds > 0:
_last_wait_index = visible_characters
_waiting_seconds += additional_waiting_seconds
paused_typing.emit(_get_pause(visible_characters))
else:
visible_characters += 1
if visible_characters <= get_total_character_count():
spoke.emit(get_parsed_text()[visible_characters - 1], visible_characters - 1, _get_speed(visible_characters))
# See if there's time to type out some more in this frame
seconds_needed += seconds_per_step * (1.0 / _get_speed(visible_characters))
if seconds_needed > delta:
_waiting_seconds += seconds_needed
else:
_type_next(delta, seconds_needed)
# Get the pause for the current typing position if there is one
func _get_pause(at_index: int) -> float:
return dialogue_line.pauses.get(at_index, 0)
# Get the speed for the current typing position
func _get_speed(at_index: int) -> float:
var speed: float = 1
for index in dialogue_line.speeds:
if index > at_index:
return speed
speed = dialogue_line.speeds[index]
return speed
# Run any inline mutations that haven't been run yet
func _mutate_remaining_mutations() -> void:
for i in range(visible_characters, get_total_character_count() + 1):
_mutate_inline_mutations(i)
# Run any mutations at the current typing position
func _mutate_inline_mutations(index: int) -> void:
for inline_mutation in dialogue_line.inline_mutations:
# inline mutations are an array of arrays in the form of [character index, resolvable function]
if inline_mutation[0] > index:
return
if inline_mutation[0] == index and not _already_mutated_indices.has(index):
_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 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()

View File

@@ -0,0 +1 @@
uid://g32um0mltv5d

View File

@@ -0,0 +1,19 @@
[gd_scene load_steps=2 format=3 uid="uid://ckvgyvclnwggo"]
[ext_resource type="Script" uid="uid://g32um0mltv5d" path="res://addons/dialogue_manager/dialogue_label.gd" id="1_cital"]
[node name="DialogueLabel" type="RichTextLabel"]
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
mouse_filter = 1
bbcode_enabled = true
fit_content = true
scroll_active = false
shortcut_keys_enabled = false
meta_underlined = false
hint_underlined = false
deselect_on_focus_loss_enabled = false
visible_characters_behavior = 1
script = ExtResource("1_cital")
skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")

View File

@@ -0,0 +1,99 @@
## 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 translation_key: String = ""
## A map for when and for how long to pause while typing out the dialogue text.
var pauses: Dictionary = {}
## A map for speed changes when typing out the dialogue text.
var speeds: Dictionary = {}
## A map of any mutations to run while typing out the dialogue text.
var inline_mutations: Array[Array] = []
## A list of responses attached to this line of dialogue.
var responses: Array = []
## A list of 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), [code]"auto"[/code], or [code]null[/code].
var time: String = ""
## Any #tags that were included in the line
var tags: PackedStringArray = []
## The mutation details if this is a mutation line (where [code]type == TYPE_MUTATION[/code]).
var mutation: Dictionary = {}
## The conditions to check before including this line in the flow of dialogue. If failed the line will be skipped over.
var conditions: Dictionary = {}
func _init(data: Dictionary = {}) -> void:
if data.size() > 0:
id = data.id
next_id = data.next_id
type = data.type
extra_game_states = data.get("extra_game_states", [])
match type:
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])
translation_key = data.get("translation_key", data.text)
pauses = data.get("pauses", {})
speeds = data.get("speeds", {})
inline_mutations = data.get("inline_mutations", [] as Array[Array])
time = data.get("time", "")
tags = data.get("tags", [])
concurrent_lines = data.get("concurrent_lines", [] as Array[DialogueLine])
DMConstants.TYPE_MUTATION:
mutation = data.mutation
func _to_string() -> String:
match type:
DMConstants.TYPE_DIALOGUE:
return "<DialogueLine character=\"%s\" text=\"%s\">" % [character, text]
DMConstants.TYPE_MUTATION:
return "<DialogueLine mutation>"
return ""
func get_tag_value(tag_name: String) -> String:
var wrapped := "%s=" % tag_name
for t in tags:
if t.begins_with(wrapped):
return t.replace(wrapped, "").strip_edges()
return ""

View File

@@ -0,0 +1 @@
uid://rhuq0eyf8ar2

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://c3rodes2l3gxb

View File

@@ -0,0 +1,42 @@
@tool
@icon("./assets/icon.svg")
## A collection of dialogue lines for use with [code]DialogueManager[/code].
class_name DialogueResource extends Resource
const DialogueLine = preload("./dialogue_line.gd")
## A list of state shortcuts
@export var using_states: PackedStringArray = []
## A map of titles and the lines they point to.
@export var titles: Dictionary = {}
## A list of character names.
@export var character_names: PackedStringArray = []
## The first title in the file.
@export var first_title: String = ""
## A map of the encoded lines of dialogue.
@export var lines: Dictionary = {}
## raw version of the text
@export var raw_text: String
## Get the next printable line of dialogue, starting from a referenced line ([code]title[/code] can
## be a title string or a stringified line number). Runs any mutations along the way and then returns
## the first dialogue line encountered.
func get_next_dialogue_line(title: String = "", extra_game_states: Array = [], mutation_behaviour: DMConstants.MutationBehaviour = DMConstants.MutationBehaviour.Wait) -> DialogueLine:
return await Engine.get_singleton("DialogueManager").get_next_dialogue_line(self, title, extra_game_states, mutation_behaviour)
## Get the list of any titles found in the file.
func get_titles() -> PackedStringArray:
return titles.keys()
func _to_string() -> String:
return "<DialogueResource titles=\"%s\">" % [",".join(titles.keys())]

View File

@@ -0,0 +1 @@
uid://dbs4435dsf3ry

View File

@@ -0,0 +1,63 @@
## 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 translation_key: String = ""
func _init(data: Dictionary = {}) -> void:
if data.size() > 0:
id = data.id
type = data.type
next_id = data.next_id
is_allowed = data.is_allowed
character = data.character
character_replacements = data.character_replacements
text = data.text
text_replacements = data.text_replacements
tags = data.tags
translation_key = data.translation_key
condition_as_text = data.condition_as_text
func _to_string() -> String:
return "<DialogueResponse text=\"%s\">" % text
func get_tag_value(tag_name: String) -> String:
var wrapped := "%s=" % tag_name
for t in tags:
if t.begins_with(wrapped):
return t.replace(wrapped, "").strip_edges()
return ""

View File

@@ -0,0 +1 @@
uid://cm0xpfeywpqid

View File

@@ -0,0 +1,147 @@
@icon("./assets/responses_menu.svg")
## A [Container] for dialogue responses provided by [b]Dialogue Manager[/b].
class_name DialogueResponsesMenu extends Container
## Emitted when a response is selected.
signal response_selected(response)
## Optionally specify a control to duplicate for each response
@export var response_template: Control
## The action for accepting a response (is possibly overridden by parent dialogue balloon).
@export var next_action: StringName = &""
## 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 = []:
get:
return responses
set(value):
responses = value
# Remove any current items
for item in get_children():
if item == response_template: continue
remove_child(item)
item.queue_free()
# Add new items
if responses.size() > 0:
for response in responses:
if hide_failed_responses and not response.is_allowed: 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)
_configure_focus()
func _ready() -> void:
visibility_changed.connect(func():
if 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 the selectable items in the menu.
func get_menu_items() -> Array:
var items: Array = []
for child in get_children():
if not child.visible: continue
if "Disallowed" in child.name: continue
items.append(child)
return items
#region Internal
# Prepare the menu for keyboard and mouse navigation.
func _configure_focus() -> void:
var items = get_menu_items()
for i in items.size():
var item: Control = items[i]
item.focus_mode = Control.FOCUS_ALL
item.focus_neighbor_left = item.get_path()
item.focus_neighbor_right = item.get_path()
if i == 0:
item.focus_neighbor_top = item.get_path()
item.focus_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")))
items[0].grab_focus()
#endregion
#region Signals
func _on_response_mouse_entered(item: Control) -> void:
if "Disallowed" in item.name: return
item.grab_focus()
func _on_response_gui_input(event: InputEvent, item: Control, response) -> void:
if "Disallowed" in item.name: return
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

View File

@@ -0,0 +1 @@
uid://bb52rsfwhkxbn

View File

@@ -0,0 +1,53 @@
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 translation_key: String = line.get(&"translation_key", line.text)
if translation_key in known_keys: continue
known_keys.append(translation_key)
translated_lines.append(line)
if translation_key == line.text:
msgs.append(PackedStringArray([line.text.replace('"', '\"'), "", "", line.get("notes", "")]))
else:
msgs.append(PackedStringArray([line.text.replace('"', '\"'), line.translation_key.replace('"', '\"'), "", line.get("notes", "")]))
return msgs
func _get_recognized_extensions() -> PackedStringArray:
return ["dialogue"]

View File

@@ -0,0 +1 @@
uid://c6bya881h1egb

View File

@@ -0,0 +1,223 @@
using Godot;
using Godot.Collections;
namespace DialogueManagerRuntime
{
public partial class ExampleBalloon : CanvasLayer
{
[Export] public string NextAction = "ui_accept";
[Export] public string SkipAction = "ui_cancel";
Control balloon;
RichTextLabel characterLabel;
RichTextLabel dialogueLabel;
VBoxContainer responsesMenu;
Resource resource;
Array<Variant> temporaryGameStates = new Array<Variant>();
bool isWaitingForInput = false;
bool willHideBalloon = false;
DialogueLine dialogueLine;
DialogueLine DialogueLine
{
get => dialogueLine;
set
{
if (value == null)
{
QueueFree();
return;
}
dialogueLine = value;
ApplyDialogueLine();
}
}
Timer MutationCooldown = new Timer();
public override void _Ready()
{
balloon = GetNode<Control>("%Balloon");
characterLabel = GetNode<RichTextLabel>("%CharacterLabel");
dialogueLabel = GetNode<RichTextLabel>("%DialogueLabel");
responsesMenu = GetNode<VBoxContainer>("%ResponsesMenu");
balloon.Hide();
balloon.GuiInput += (@event) =>
{
if ((bool)dialogueLabel.Get("is_typing"))
{
bool mouseWasClicked = @event is InputEventMouseButton && (@event as InputEventMouseButton).ButtonIndex == MouseButton.Left && @event.IsPressed();
bool skipButtonWasPressed = @event.IsActionPressed(SkipAction);
if (mouseWasClicked || skipButtonWasPressed)
{
GetViewport().SetInputAsHandled();
dialogueLabel.Call("skip_typing");
return;
}
}
if (!isWaitingForInput) return;
if (dialogueLine.Responses.Count > 0) return;
GetViewport().SetInputAsHandled();
if (@event is InputEventMouseButton && @event.IsPressed() && (@event as InputEventMouseButton).ButtonIndex == MouseButton.Left)
{
Next(dialogueLine.NextId);
}
else if (@event.IsActionPressed(NextAction) && GetViewport().GuiGetFocusOwner() == balloon)
{
Next(dialogueLine.NextId);
}
};
if (string.IsNullOrEmpty((string)responsesMenu.Get("next_action")))
{
responsesMenu.Set("next_action", NextAction);
}
responsesMenu.Connect("response_selected", Callable.From((DialogueResponse response) =>
{
Next(response.NextId);
}));
// Hide the balloon when a mutation is running
MutationCooldown.Timeout += () =>
{
if (willHideBalloon)
{
willHideBalloon = false;
balloon.Hide();
}
};
AddChild(MutationCooldown);
DialogueManager.Mutated += OnMutated;
}
public override void _ExitTree()
{
DialogueManager.Mutated -= OnMutated;
}
public override void _UnhandledInput(InputEvent @event)
{
// Only the balloon is allowed to handle input while it's showing
GetViewport().SetInputAsHandled();
}
public override async void _Notification(int what)
{
// Detect a change of locale and update the current dialogue line to show the new language
if (what == NotificationTranslationChanged && IsInstanceValid(dialogueLabel))
{
float visibleRatio = dialogueLabel.VisibleRatio;
DialogueLine = await DialogueManager.GetNextDialogueLine(resource, DialogueLine.Id, temporaryGameStates);
if (visibleRatio < 1.0f)
{
dialogueLabel.Call("skip_typing");
}
}
}
public async void Start(Resource dialogueResource, string title, Array<Variant> extraGameStates = null)
{
temporaryGameStates = new Array<Variant> { this } + (extraGameStates ?? new Array<Variant>());
isWaitingForInput = false;
resource = dialogueResource;
DialogueLine = await DialogueManager.GetNextDialogueLine(resource, title, temporaryGameStates);
}
public async void Next(string nextId)
{
DialogueLine = await DialogueManager.GetNextDialogueLine(resource, nextId, temporaryGameStates);
}
#region Helpers
private async void 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.Set("dialogue_line", dialogueLine);
// Set up the responses
responsesMenu.Hide();
responsesMenu.Set("responses", dialogueLine.Responses);
// Type out the text
balloon.Show();
willHideBalloon = false;
dialogueLabel.Show();
if (!string.IsNullOrEmpty(dialogueLine.Text))
{
dialogueLabel.Call("type_out");
await ToSignal(dialogueLabel, "finished_typing");
}
// Wait for input
if (dialogueLine.Responses.Count > 0)
{
balloon.FocusMode = Control.FocusModeEnum.None;
responsesMenu.Show();
}
else if (!string.IsNullOrEmpty(dialogueLine.Time))
{
float time = 0f;
if (!float.TryParse(dialogueLine.Time, out time))
{
time = dialogueLine.Text.Length * 0.02f;
}
await ToSignal(GetTree().CreateTimer(time), "timeout");
Next(dialogueLine.NextId);
}
else
{
isWaitingForInput = true;
balloon.FocusMode = Control.FocusModeEnum.All;
balloon.GrabFocus();
}
}
#endregion
#region signals
private void OnMutated(Dictionary _mutation)
{
isWaitingForInput = false;
willHideBalloon = true;
MutationCooldown.Start(0.1f);
}
#endregion
}
}

View File

@@ -0,0 +1 @@
uid://5b3w40kwakl3

View File

@@ -0,0 +1,176 @@
class_name DialogueManagerExampleBalloon extends CanvasLayer
## A basic dialogue balloon for use with Dialogue Manager.
## 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"
## The dialogue resource
var resource: DialogueResource
## Temporary game states
var temporary_game_states: Array = []
## See if we are waiting for the player
var is_waiting_for_input: bool = false
## See if we are running a long mutation and should hide the balloon
var will_hide_balloon: bool = false
## 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
queue_free()
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
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)
func _unhandled_input(_event: InputEvent) -> void:
# Only the balloon is allowed to handle input while it's showing
get_viewport().set_input_as_handled()
func _notification(what: int) -> void:
## Detect a change of locale and update the current dialogue line to show the new language
if what == NOTIFICATION_TRANSLATION_CHANGED and _locale != TranslationServer.get_locale() and is_instance_valid(dialogue_label):
_locale = TranslationServer.get_locale()
var visible_ratio = dialogue_label.visible_ratio
self.dialogue_line = await resource.get_next_dialogue_line(dialogue_line.id)
if visible_ratio < 1:
dialogue_label.skip_typing()
## Start some dialogue
func start(dialogue_resource: DialogueResource, title: String, extra_game_states: Array = []) -> void:
temporary_game_states = [self] + extra_game_states
is_waiting_for_input = false
resource = dialogue_resource
self.dialogue_line = await resource.get_next_dialogue_line(title, temporary_game_states)
## Apply any changes to the balloon given a new [DialogueLine].
func apply_dialogue_line() -> void:
mutation_cooldown.stop()
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 input
if dialogue_line.responses.size() > 0:
balloon.focus_mode = Control.FOCUS_NONE
responses_menu.show()
elif dialogue_line.time != "":
var time = dialogue_line.text.length() * 0.02 if dialogue_line.time == "auto" else dialogue_line.time.to_float()
await get_tree().create_timer(time).timeout
next(dialogue_line.next_id)
else:
is_waiting_for_input = true
balloon.focus_mode = Control.FOCUS_ALL
balloon.grab_focus()
## Go to the next line
func next(next_id: String) -> void:
self.dialogue_line = await resource.get_next_dialogue_line(next_id, temporary_game_states)
#region Signals
func _on_mutation_cooldown_timeout() -> void:
if will_hide_balloon:
will_hide_balloon = false
balloon.hide()
func _on_mutated(_mutation: Dictionary) -> void:
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

View File

@@ -0,0 +1 @@
uid://d1wt4ma6055l8

View File

@@ -0,0 +1,142 @@
[gd_scene load_steps=9 format=3 uid="uid://73jm5qjy52vq"]
[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://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_a8ve6"]
[ext_resource type="Script" uid="uid://bb52rsfwhkxbn" path="res://addons/dialogue_manager/dialogue_responses_menu.gd" id="3_72ixx"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_spyqn"]
bg_color = Color(0, 0, 0, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.329412, 0.329412, 0.329412, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ri4m3"]
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(1, 1, 1, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_e0njw"]
bg_color = Color(0, 0, 0, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.6, 0.6, 0.6, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_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")
[node name="ExampleBalloon" type="CanvasLayer"]
layer = 100
script = ExtResource("1_36de5")
[node name="Balloon" type="Control" parent="."]
unique_name_in_owner = true
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = SubResource("Theme_qq3yp")
[node name="MarginContainer" type="MarginContainer" parent="Balloon"]
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"]
clip_children = 2
layout_mode = 2
mouse_filter = 1
[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer"]
layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer"]
layout_mode = 2
[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.501961)
layout_mode = 2
mouse_filter = 1
bbcode_enabled = true
text = "Character"
fit_content = true
scroll_active = false
[node name="DialogueLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/VBoxContainer" instance=ExtResource("2_a8ve6")]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
text = "Dialogue..."
[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon" node_paths=PackedStringArray("response_template")]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -290.5
offset_top = -35.0
offset_right = 290.5
offset_bottom = 35.0
grow_horizontal = 2
grow_vertical = 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/ResponsesMenu"]
layout_mode = 2
text = "Response example"
[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
[connection signal="response_selected" from="Balloon/ResponsesMenu" to="." method="_on_responses_menu_response_selected"]

View File

@@ -0,0 +1,166 @@
[gd_scene load_steps=10 format=3 uid="uid://13s5spsk34qu"]
[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://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_hfvdi"]
[ext_resource type="Script" uid="uid://bb52rsfwhkxbn" path="res://addons/dialogue_manager/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")
[node name="ExampleBalloon" type="CanvasLayer"]
layer = 100
script = ExtResource("1_s2gbs")
[node name="Balloon" type="Control" parent="."]
unique_name_in_owner = true
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = SubResource("Theme_qq3yp")
[node name="MarginContainer" type="MarginContainer" parent="Balloon"]
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"]
clip_children = 2
layout_mode = 2
mouse_filter = 1
[node name="MarginContainer" type="MarginContainer" parent="Balloon/MarginContainer/PanelContainer"]
layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/MarginContainer/PanelContainer/MarginContainer"]
layout_mode = 2
[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.501961)
layout_mode = 2
mouse_filter = 1
bbcode_enabled = true
text = "Character"
fit_content = true
scroll_active = false
[node name="DialogueLabel" parent="Balloon/MarginContainer/PanelContainer/MarginContainer/VBoxContainer" instance=ExtResource("2_hfvdi")]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
text = "Dialogue..."
[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -116.5
offset_top = -9.0
offset_right = 116.5
offset_bottom = 9.0
grow_horizontal = 2
grow_vertical = 2
size_flags_vertical = 8
theme_override_constants/separation = 2
script = ExtResource("3_1j1j0")
[node name="ResponseExample" type="Button" parent="Balloon/ResponsesMenu"]
layout_mode = 2
text = "Response Example"
[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"]
[connection signal="response_selected" from="Balloon/ResponsesMenu" to="." method="_on_responses_menu_response_selected"]

View File

@@ -0,0 +1,26 @@
class_name DMExportPlugin extends EditorExportPlugin
const IGNORED_PATHS = [
"/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 = Engine.get_meta("DialogueManagerPlugin").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()

View File

@@ -0,0 +1 @@
uid://sa55ra11ji2q

110
addons/dialogue_manager/import_plugin.gd vendored Normal file
View File

@@ -0,0 +1,110 @@
@tool
class_name DMImportPlugin extends EditorImportPlugin
signal compiled_resource(resource: Resource)
const COMPILER_VERSION = 15
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():
return "Resource"
func _get_recognized_extensions() -> PackedStringArray:
return PackedStringArray(["dialogue"])
func _get_save_extension():
return "tres"
func _get_preset_count() -> int:
return 0
func _get_preset_name(preset_index: int) -> String:
return "Unknown"
func _get_import_options(path: String, preset_index: int) -> Array:
# When the options array is empty there is a misleading error on export
# that actually means nothing so let's just have an invisible option.
return [{
name = "defaults",
default_value = true
}]
func _get_option_visibility(path: String, option_name: StringName, options: Dictionary) -> bool:
return false
func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> Error:
var cache = Engine.get_meta("DMCache")
# Get the raw file contents
if not FileAccess.file_exists(source_file): return ERR_FILE_NOT_FOUND
var file: FileAccess = FileAccess.open(source_file, FileAccess.READ)
var raw_text: String = file.get_as_text()
cache.file_content_changed.emit(source_file, raw_text)
# 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])
cache.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.titles = result.titles
resource.first_title = result.first_title
resource.character_names = result.character_names
resource.lines = result.lines
resource.raw_text = result.raw_text
# Clear errors and possibly trigger any cascade recompiles
cache.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 = cache.get_dependent_paths_for_reimport(source_file)
for path in dependent_paths:
append_import_external_resource(path)
return err

View File

@@ -0,0 +1 @@
uid://dhwpj6ed8soyq

View File

@@ -0,0 +1,21 @@
@tool
class_name DMInspectorPlugin extends EditorInspectorPlugin
const DialogueEditorProperty = preload("./components/editor_property/editor_property.gd")
func _can_handle(object) -> bool:
if object is GDScript: return false
if not object is Node: return false
if "name" in object and object.name == "Dialogue Manager": return false
return true
func _parse_property(object: Object, type, name: String, hint_type, hint_string: String, usage_flags: int, wide: bool) -> bool:
if hint_string == "DialogueResource" or ("dialogue" in name.to_lower() and hint_string == "Resource"):
var property_editor = DialogueEditorProperty.new()
add_property_editor(name, property_editor)
return true
return false

View File

@@ -0,0 +1 @@
uid://0x31sbqbikov

Some files were not shown because too many files have changed in this diff Show More