diff --git a/Art/Background/0-0.test.png b/Art/Background/0-0.test.png new file mode 100644 index 00000000..2e008b08 Binary files /dev/null and b/Art/Background/0-0.test.png differ diff --git a/Art/Background/0-0.test.png.import b/Art/Background/0-0.test.png.import new file mode 100644 index 00000000..f78bd750 --- /dev/null +++ b/Art/Background/0-0.test.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4irj3je6odom" +path="res://.godot/imported/0-0.test.png-7d85cee674d3c368edee07176a9ad081.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Art/Background/0-0.test.png" +dest_files=["res://.godot/imported/0-0.test.png-7d85cee674d3c368edee07176a9ad081.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 diff --git a/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png b/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png new file mode 100644 index 00000000..3a6e6211 Binary files /dev/null and b/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png differ diff --git a/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png.import b/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png.import new file mode 100644 index 00000000..e255d494 --- /dev/null +++ b/Art/NPC/rescued_saplings_comfy_sleepy-1.test.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jdd721vyn4lp" +path="res://.godot/imported/rescued_saplings_comfy_sleepy-1.test.png-f63d28d050934f744e0f15ef547d215e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Art/NPC/rescued_saplings_comfy_sleepy-1.test.png" +dest_files=["res://.godot/imported/rescued_saplings_comfy_sleepy-1.test.png-f63d28d050934f744e0f15ef547d215e.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 diff --git a/Art/NPC/rescued_saplings_nerd-1.test.png b/Art/NPC/rescued_saplings_nerd-1.test.png new file mode 100644 index 00000000..21e86c9b Binary files /dev/null and b/Art/NPC/rescued_saplings_nerd-1.test.png differ diff --git a/Art/NPC/rescued_saplings_nerd-1.test.png.import b/Art/NPC/rescued_saplings_nerd-1.test.png.import new file mode 100644 index 00000000..c6cdcfe1 --- /dev/null +++ b/Art/NPC/rescued_saplings_nerd-1.test.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cnnelgrlm6grx" +path="res://.godot/imported/rescued_saplings_nerd-1.test.png-aed7cd7ec416325aa101246a37df79b7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Art/NPC/rescued_saplings_nerd-1.test.png" +dest_files=["res://.godot/imported/rescued_saplings_nerd-1.test.png-aed7cd7ec416325aa101246a37df79b7.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 diff --git a/Art/naming_convention.txt b/Art/naming_convention.txt index 18b7f538..c394f0a4 100644 --- a/Art/naming_convention.txt +++ b/Art/naming_convention.txt @@ -12,6 +12,7 @@ Background : - ; Card_art : ; Card_layout : ; Character : ; default, hurt, took damage, defeated, etc... +Event : ; Items : - ; type is either accessory or consumable Map : ; Menus : - ; diff --git a/Dialog/EventDialogueWindow.gd b/Dialog/EventDialogueWindow.gd new file mode 100644 index 00000000..b00797cf --- /dev/null +++ b/Dialog/EventDialogueWindow.gd @@ -0,0 +1,178 @@ +extends CanvasLayer + +# Note from TyTy: Most of this script has been autogenerated by DialougeManager addon. The comments will mention if it's added +# behavior added in to fit the needs for the game! + +## The action to use for advancing the dialogue +@export var next_action: StringName = &"ui_accept" + +## The action to use to skip typing the dialogue +@export var skip_action: StringName = &"ui_cancel" + +@onready var balloon: Control = %Balloon +@onready var character_label: RichTextLabel = %CharacterLabel +@onready var dialogue_label: DialogueLabel = %DialogueLabel +@onready var responses_menu: DialogueResponsesMenu = %ResponsesMenu +@onready var portrait: TextureRect = %Balloon/Panel/HBoxContainer/Control/Portrait + +# Custom variables for the flavor doodles/text on the dialog that can be customizable in the dialog script +@onready var flavor_image_1: TextureRect = %Balloon/Panel/FlavorNode/FlavorImage1 +@onready var flavor_image_2: TextureRect = %Balloon/Panel/FlavorNode/FlavorImage2 +@onready var flavor_image_3: TextureRect = %Balloon/Panel/FlavorNode/FlavorImage3 + +@onready var flavor_text_1: RichTextLabel = %Balloon/Panel/FlavorNode/FlavorText1 +@onready var flavor_text_2: RichTextLabel = %Balloon/Panel/FlavorNode/FlavorText2 +@onready var flavor_text_3: RichTextLabel = %Balloon/Panel/FlavorNode/FlavorText3 + +## 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 + +## The current line +var dialogue_line: DialogueLine: + set(next_dialogue_line): + is_waiting_for_input = false + balloon.focus_mode = Control.FOCUS_ALL + balloon.grab_focus() + + # The dialogue has finished so close the balloon + if not next_dialogue_line: + queue_free() + return + + # If the node isn't ready yet then none of the labels will be ready yet either + if not is_node_ready(): + await ready + + dialogue_line = next_dialogue_line + + character_label.visible = not dialogue_line.character.is_empty() + character_label.text = tr(dialogue_line.character, "dialogue") + + dialogue_label.hide() + dialogue_label.dialogue_line = dialogue_line + + responses_menu.hide() + responses_menu.set_responses(dialogue_line.responses) + + # Show our balloon + balloon.show() + will_hide_balloon = false + + dialogue_label.show() + if not dialogue_line.text.is_empty(): + dialogue_label.type_out() + await dialogue_label.finished_typing + + # Wait for input + if dialogue_line.responses.size() > 0: + balloon.focus_mode = Control.FOCUS_NONE + responses_menu.show() + elif dialogue_line.time != "": + var time: float = dialogue_line.text.length() * 0.02 if dialogue_line.time == "auto" else dialogue_line.time.to_float() + await get_tree().create_timer(time).timeout + next(dialogue_line.next_id) + else: + is_waiting_for_input = true + balloon.focus_mode = Control.FOCUS_ALL + balloon.grab_focus() + get: + return dialogue_line + + +func _ready() -> void: + balloon.hide() + Engine.get_singleton("DialogueManager").mutated.connect(_on_mutated) + + # If the responses menu doesn't have a next action set, use this one + if responses_menu.next_action.is_empty(): + responses_menu.next_action = next_action + + +func _unhandled_input(_event: InputEvent) -> void: + # Only the balloon is allowed to handle input while it's showing + get_viewport().set_input_as_handled() + + +## Start some dialogue +func start(dialogue_resource: DialogueResource, title: String, extra_game_states: Array = []) -> void: + temporary_game_states = [self] + extra_game_states + is_waiting_for_input = false + resource = dialogue_resource + self.dialogue_line = await resource.get_next_dialogue_line(title, temporary_game_states) + + +## Go to the next line +func next(next_id: String) -> void: + self.dialogue_line = await resource.get_next_dialogue_line(next_id, temporary_game_states) + + +### Signals + + +func _on_mutated(_mutation: Dictionary) -> void: + is_waiting_for_input = false + will_hide_balloon = true + get_tree().create_timer(0.1).timeout.connect(func() -> void: + if will_hide_balloon: + will_hide_balloon = false + balloon.hide() + ) + + +func _on_balloon_gui_input(event: InputEvent) -> void: + # See if we need to skip typing of the dialogue + if dialogue_label.is_typing: + var mouse_was_clicked: bool = event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed() + var skip_button_was_pressed: bool = event.is_action_pressed(skip_action) + if mouse_was_clicked or skip_button_was_pressed: + get_viewport().set_input_as_handled() + dialogue_label.skip_typing() + return + + if not is_waiting_for_input: return + if dialogue_line.responses.size() > 0: return + + # When there are no response options the balloon itself is the clickable thing + get_viewport().set_input_as_handled() + + if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT: + next(dialogue_line.next_id) + elif event.is_action_pressed(next_action) and get_viewport().gui_get_focus_owner() == balloon: + next(dialogue_line.next_id) + +# Custom function that is interacted via the .dialogue file to add a portrait to the scene +func add_portrait(art_name: String) -> void: + portrait.texture = load("res://Art/%s.png" % art_name) + +# Custom function that is interacted via the .dialogue file to add some flavor image based on the index passed in +func add_flavor_image(image_name: String, flavor_number: int) -> void: + var flavor_image: Texture2D = load("res://Art/%s.png" % image_name) + match flavor_number: + 1: + flavor_image_1.texture = flavor_image + 2: + flavor_image_2.texture = flavor_image + 3: + flavor_image_3.texture = flavor_image + +# Custom function that is interacted via the .dialogue file to add some flavor text based on the index passed in +func add_flavor_text(flavor_text: String, flavor_number: int) -> void: + match flavor_number: + 1: + flavor_text_1.add_text(flavor_text) + 2: + flavor_text_2.add_text(flavor_text) + 3: + flavor_text_3.add_text(flavor_text) + +func _on_responses_menu_response_selected(response: DialogueResponse) -> void: + next(response.next_id) diff --git a/Dialog/EventDialogueWindow.tscn b/Dialog/EventDialogueWindow.tscn new file mode 100644 index 00000000..865fb6b2 --- /dev/null +++ b/Dialog/EventDialogueWindow.tscn @@ -0,0 +1,233 @@ +[gd_scene load_steps=10 format=3 uid="uid://73jm5qjy52vq"] + +[ext_resource type="Script" path="res://Dialog/EventDialogueWindow.gd" id="1_rqkth"] +[ext_resource type="Texture2D" uid="uid://ube4dr03nn7e" path="res://Art/NPC/example-default.png" id="2_rsxwm"] +[ext_resource type="PackedScene" uid="uid://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="3_7xls4"] +[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_reponses_menu.gd" id="4_tijcl"] + +[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(1, 1, 1, 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="StyleBoxFlat" id="StyleBoxFlat_e0njw"] +bg_color = Color(1, 1, 1, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.513726, 0.513726, 0.513726, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uy0d5"] +bg_color = Color(0.945098, 0.929412, 0.898039, 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/colors/font_color = Color(0, 0, 0, 1) +Button/colors/font_focus_color = Color(0, 0, 0, 1) +Button/colors/font_hover_color = Color(0, 0, 0, 1) +Button/colors/font_hover_pressed_color = Color(0.360784, 0.360784, 0.360784, 1) +Button/styles/disabled = SubResource("StyleBoxFlat_spyqn") +Button/styles/focus = SubResource("StyleBoxFlat_ri4m3") +Button/styles/hover = SubResource("StyleBoxFlat_e0njw") +Button/styles/normal = SubResource("StyleBoxFlat_e0njw") +MarginContainer/constants/margin_bottom = 15 +MarginContainer/constants/margin_left = 30 +MarginContainer/constants/margin_right = 30 +MarginContainer/constants/margin_top = 15 +Panel/styles/panel = SubResource("StyleBoxFlat_uy0d5") + +[node name="EventDialog" type="CanvasLayer"] +layer = 100 +script = ExtResource("1_rqkth") + +[node name="Balloon" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = SubResource("Theme_qq3yp") + +[node name="Panel" type="Panel" parent="Balloon"] +clip_children = 2 +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 21.0 +offset_top = -643.0 +offset_right = -19.0 +offset_bottom = -43.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 1 + +[node name="HBoxContainer" type="HBoxContainer" parent="Balloon/Panel"] +custom_minimum_size = Vector2(620, 600) +layout_mode = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="Control" type="Control" parent="Balloon/Panel/HBoxContainer"] +custom_minimum_size = Vector2(620, 600) +layout_mode = 2 + +[node name="Portrait" type="TextureRect" parent="Balloon/Panel/HBoxContainer/Control"] +custom_minimum_size = Vector2(300, 210) +layout_mode = 0 +offset_left = 39.0 +offset_top = 74.0 +offset_right = 569.0 +offset_bottom = 474.0 +texture = ExtResource("2_rsxwm") +expand_mode = 1 + +[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel"] +layout_mode = 2 +offset_left = 600.0 +offset_right = 1220.0 +offset_bottom = 604.0 + +[node name="Dialogue" type="MarginContainer" parent="Balloon/Panel/VBoxContainer"] +custom_minimum_size = Vector2(0, 300) +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel/VBoxContainer/Dialogue"] +layout_mode = 2 + +[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/Panel/VBoxContainer/Dialogue/VBoxContainer"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.501961) +layout_mode = 2 +mouse_filter = 1 +theme_override_colors/default_color = Color(0, 0, 0, 1) +bbcode_enabled = true +text = "Character" +fit_content = true +scroll_active = false + +[node name="DialogueLabel" parent="Balloon/Panel/VBoxContainer/Dialogue/VBoxContainer" instance=ExtResource("3_7xls4")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_colors/default_color = Color(0, 0, 0, 1) +text = "Dialogue..." + +[node name="Responses" type="MarginContainer" parent="Balloon/Panel/VBoxContainer"] +custom_minimum_size = Vector2(0, 300) +layout_mode = 2 + +[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/Panel/VBoxContainer/Responses" node_paths=PackedStringArray("response_template")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/separation = 20 +script = ExtResource("4_tijcl") +response_template = NodePath("ResponseExample") + +[node name="ResponseExample" type="Button" parent="Balloon/Panel/VBoxContainer/Responses/ResponsesMenu"] +layout_mode = 2 +text = "Response example" + +[node name="FlavorNode" type="Control" parent="Balloon/Panel"] +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="FlavorImage1" type="TextureRect" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 1190.0 +offset_top = 521.0 +offset_right = 1230.0 +offset_bottom = 561.0 +rotation = 0.324631 +expand_mode = 1 + +[node name="FlavorImage2" type="TextureRect" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 515.0 +offset_top = 512.0 +offset_right = 555.0 +offset_bottom = 552.0 +rotation = -0.575959 +scale = Vector2(2, 2) +expand_mode = 1 + +[node name="FlavorImage3" type="TextureRect" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 23.0 +offset_top = -5.0 +offset_right = 63.0 +offset_bottom = 35.0 +rotation = 0.179769 +scale = Vector2(2, 2) +expand_mode = 1 + +[node name="FlavorText1" type="RichTextLabel" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 473.0 +offset_top = 10.0 +offset_right = 593.0 +offset_bottom = 50.0 +rotation = 0.324631 +theme_override_colors/default_color = Color(0, 0, 0, 1) +scroll_active = false + +[node name="FlavorText2" type="RichTextLabel" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 43.0 +offset_top = 504.0 +offset_right = 163.0 +offset_bottom = 604.0 +rotation = -0.179769 +theme_override_colors/default_color = Color(0, 0, 0, 1) +text = " +" + +[node name="FlavorText3" type="RichTextLabel" parent="Balloon/Panel/FlavorNode"] +layout_mode = 0 +offset_left = 626.0 +offset_top = 243.0 +offset_right = 746.0 +offset_bottom = 343.0 +rotation = -0.251327 +theme_override_colors/default_color = Color(0, 0, 0, 1) +fit_content = true +autowrap_mode = 2 + +[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"] +[connection signal="response_selected" from="Balloon/Panel/VBoxContainer/Responses/ResponsesMenu" to="." method="_on_responses_menu_response_selected"] diff --git a/Dialog/test.dialogue b/Dialog/test.dialogue new file mode 100644 index 00000000..490cf93d --- /dev/null +++ b/Dialog/test.dialogue @@ -0,0 +1,43 @@ +~ test +# Any line that starts with the "do" keyword will be used to call the function specified in balloon.gd +# If the function does not exist, it WILL throw an error. +# Adds a flavor image to the indexed flavor object in the dialog scene. All art assets are being read from res://Art/ and must be .png +do add_flavor_image("NPC/rescued_saplings_nerd-1.test", 1) +do add_flavor_image("NPC/rescued_saplings_comfy_sleepy-1.test", 2) +do add_flavor_image("NPC/rescued_saplings_nerd-1.test", 3) + +# Adds flavor text to the indexed flavor object in the dialog scene. +do add_flavor_text("Squeeb~!", 1) +do add_flavor_text("KonFauna!!", 2) +do add_flavor_text("Uuuuuu~!", 3) + +# Adds a portrait to the main portrait object. Calling add_portrait() again with a different asset will replace the current one. +# All art assets are being read from res://Art/ and must be .png +do add_portrait("Background/0-0.test") + +# Dialog Manager reads it line by line, do not put line breaks in your dialog if you want this all to be show in one text box +# If we want to have the text be on separate lines but on the same box of dialog, use a newline \n +# This example will add a new line while having it be part of one dialog entry. +# Ex. You run into a sapling!\nWhat do you do? +You run into a sapling! What do you do? +# Putting the - on a line will indicate to the system the player will need to make a choice. +# When resolving what will happen to a choice, indentation matters! +# Everything that's indented after each choice will display if the player selects that choice. +- Pat + do add_portrait("NPC/rescued_saplings_comfy_sleepy-1.test") + # We can surround text with square brackets [[]] to define random text to be selected. In this case you can either get + # "It smiles." or "It's sleepy" + You pat the sapling gentle on its' head. [[It smiles|It's sleepy]]. +# We can use the following => syntax to jump to another flow. We just need to ensure that the title exists within the .dialogue file. +- Punch => punch +- Slap + do add_portrait("NPC/rescued_saplings_comfy_sleepy-1.test") + With an open hand, you muster the strength and slap the sapling across the face. It... blushes? + +=> END + +# To start new flows, it must start with the ~ and end with => END. A title must also be provided so it can be referenced. +~ punch +do add_portrait("NPC/rescued_saplings_comfy_sleepy-1.test") +You wind up your fist and whack the sapling right in the face. It's just happy to be here. +=> END \ No newline at end of file diff --git a/Dialog/test.dialogue.import b/Dialog/test.dialogue.import new file mode 100644 index 00000000..38c46c86 --- /dev/null +++ b/Dialog/test.dialogue.import @@ -0,0 +1,15 @@ +[remap] + +importer="dialogue_manager_compiler_11" +type="Resource" +uid="uid://bcve0av2hiil5" +path="res://.godot/imported/test.dialogue-4650b644e4c5b470ea62d9c1230412ab.tres" + +[deps] + +source_file="res://Dialog/test.dialogue" +dest_files=["res://.godot/imported/test.dialogue-4650b644e4c5b470ea62d9c1230412ab.tres"] + +[params] + +defaults=true diff --git a/Dialog/test2.dialogue b/Dialog/test2.dialogue new file mode 100644 index 00000000..ab89e59d --- /dev/null +++ b/Dialog/test2.dialogue @@ -0,0 +1,16 @@ +~ test2 + +do add_flavor_text("Flavor Text~!", 1) +do add_flavor_image("Animation/banana", 2) +This is showcasing a second dialogue file to show\nThe versatility of the Dialogue Manager add-on. +We can simply call show_dialogue_balloon_scene(), pass in the EventDialogueWindow scene and whichever .dialogue file and then specify the starting point + +=> END + +~ test2Secret + +If you got here from the game, you probably modified the show_dialogue_balloon_scene() to get here. Well done. +do add_portrait("NPC/rescued_saplings_comfy_sleepy-1.test") +Have a sleepy sapling! + +=> END \ No newline at end of file diff --git a/Dialog/test2.dialogue.import b/Dialog/test2.dialogue.import new file mode 100644 index 00000000..36c1a2cc --- /dev/null +++ b/Dialog/test2.dialogue.import @@ -0,0 +1,15 @@ +[remap] + +importer="dialogue_manager_compiler_11" +type="Resource" +uid="uid://c5euen75fsb5w" +path="res://.godot/imported/test2.dialogue-cc0cf5721359ce8fd6cf77e21bf8219f.tres" + +[deps] + +source_file="res://Dialog/test2.dialogue" +dest_files=["res://.godot/imported/test2.dialogue-cc0cf5721359ce8fd6cf77e21bf8219f.tres"] + +[params] + +defaults=true diff --git a/Global/DEBUG_VAR.gd b/Global/DEBUG_VAR.gd index 4b6a33ac..d063e069 100644 --- a/Global/DEBUG_VAR.gd +++ b/Global/DEBUG_VAR.gd @@ -8,7 +8,10 @@ extends Node ## Player can move freely on the map without having to finish the current event or move in the range of its normal movement const DEBUG_FREE_MOVEMENT: bool = false +# Clicking on any room will show the test dialog script, check res://Dialog/test.dialogue +const DEBUG_TEST_DIALOGUE: bool = false + ## List of all debug variables [br] ## This is used to run the tests checking that every DEBUG is set to false ## ! If you add a new DEBUG variable, add it to this list -const LIST_OF_DEBUG: Array[bool] = [DEBUG_FREE_MOVEMENT] +const LIST_OF_DEBUG: Array[bool] = [DEBUG_FREE_MOVEMENT, DEBUG_TEST_DIALOGUE] diff --git a/UI/MapUI.gd b/UI/MapUI.gd index 48d5c656..827f4cb6 100644 --- a/UI/MapUI.gd +++ b/UI/MapUI.gd @@ -4,6 +4,14 @@ class_name MapUI var map_scene: PackedScene = preload("res://#Scenes/CardScrollUI.tscn") var room_ui: PackedScene = load("res://Map/RoomUI.tscn") +# DialogueManager add works essentially as a new packed scene with some extra functionality. +# Load up the dialog screen scene like we're switching to any normal scene. +# Then grab the dialog resource. +# We can pass in any .dialogue script here as long as it exists and it will work with the EventDialogueWindow. +# For testing, you replace test.dialogue or test2.dialogue and either one will work with the EventDialogueWindow scene. +var balloon_scene: PackedScene = load("res://Dialog/EventDialogueWindow.tscn") +var test_dialog: DialogueResource = load("res://Dialog/test.dialogue") + var _padding_offset: int = 20 var _MINIMUM_ROOM_WIDTH: int = 510 var _MINIMUM_ROOM_HEIGHT: int = 490 @@ -191,4 +199,9 @@ func _on_room_clicked(clicked_room: RoomUI) -> void: var player_adjacent_rooms: Array[RoomBase] = MapMovement.get_accessible_rooms_by_player() for room: RoomBase in player_adjacent_rooms: room.light_data.increase_light_by_player_movement() + + # If the debug flag to show the dialog screen is on, call the DialogueManager and show our dialogue. + # The "test" refers to the chunk of dialog script we want the dialog to start at. + if (DebugVar.DEBUG_TEST_DIALOGUE): + DialogueManager.show_dialogue_balloon_scene(balloon_scene, test_dialog, "test") queue_free() diff --git a/addons/dialogue_manager/DialogueManager.cs b/addons/dialogue_manager/DialogueManager.cs new file mode 100644 index 00000000..1fae5f9c --- /dev/null +++ b/addons/dialogue_manager/DialogueManager.cs @@ -0,0 +1,391 @@ +using Godot; +using Godot.Collections; +using System; +using System.Reflection; +using System.Threading.Tasks; + +#nullable enable + +namespace DialogueManagerRuntime +{ + public enum TranslationSource + { + None, + Guess, + CSV, + PO + } + + public partial class DialogueManager : Node + { + public delegate void PassedTitleEventHandler(string title); + public delegate void GotDialogueEventHandler(DialogueLine dialogueLine); + public delegate void MutatedEventHandler(Dictionary mutation); + public delegate void DialogueEndedEventHandler(Resource dialogueResource); + + public static PassedTitleEventHandler? PassedTitle; + public static GotDialogueEventHandler? GotDialogue; + public static MutatedEventHandler? Mutated; + public static DialogueEndedEventHandler? DialogueEnded; + + [Signal] public delegate void ResolvedEventHandler(Variant value); + + private static GodotObject? instance; + public static GodotObject Instance + { + get + { + if (instance == null) + { + instance = Engine.GetSingleton("DialogueManager"); + } + return instance; + } + } + + + public static Godot.Collections.Array GameStates + { + get => (Godot.Collections.Array)Instance.Get("game_states"); + set => Instance.Set("game_states", value); + } + + + public static bool IncludeSingletons + { + get => (bool)Instance.Get("include_singletons"); + set => Instance.Set("include_singletons", value); + } + + + public static bool IncludeClasses + { + get => (bool)Instance.Get("include_classes"); + set => Instance.Set("include_classes", value); + } + + + public static TranslationSource TranslationSource + { + get => (TranslationSource)(int)Instance.Get("translation_source"); + set => Instance.Set("translation_source", (int)value); + } + + + public static Func GetCurrentScene + { + set => Instance.Set("get_current_scene", Callable.From(value)); + } + + + public void Prepare() + { + Instance.Connect("passed_title", Callable.From((string title) => PassedTitle?.Invoke(title))); + Instance.Connect("got_dialogue", Callable.From((RefCounted line) => GotDialogue?.Invoke(new DialogueLine(line)))); + Instance.Connect("mutated", Callable.From((Dictionary mutation) => Mutated?.Invoke(mutation))); + Instance.Connect("dialogue_ended", Callable.From((Resource dialogueResource) => DialogueEnded?.Invoke(dialogueResource))); + } + + + public static async Task GetSingleton() + { + if (instance != null) return instance; + + var tree = Engine.GetMainLoop(); + int x = 0; + + // Try and find the singleton for a few seconds + while (!Engine.HasSingleton("DialogueManager") && x < 300) + { + await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame); + x++; + } + + // If it times out something is wrong + if (x >= 300) + { + throw new Exception("The DialogueManager singleton is missing."); + } + + instance = Engine.GetSingleton("DialogueManager"); + return instance; + } + + public static async Task GetNextDialogueLine(Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + Instance.Call("_bridge_get_next_dialogue_line", dialogueResource, key, extraGameStates ?? new Array()); + var result = await Instance.ToSignal(Instance, "bridge_get_next_dialogue_line_completed"); + + if ((RefCounted)result[0] == null) return null; + + return new DialogueLine((RefCounted)result[0]); + } + + + public static CanvasLayer ShowExampleDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + return (CanvasLayer)Instance.Call("show_example_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array()); + } + + + public static Node ShowDialogueBalloonScene(string balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array()); + } + + public static Node ShowDialogueBalloonScene(PackedScene balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array()); + } + + public static Node ShowDialogueBalloonScene(Node balloonScene, Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array()); + } + + + public static Node ShowDialogueBalloon(Resource dialogueResource, string key = "", Array? extraGameStates = null) + { + return (Node)Instance.Call("show_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array()); + } + + + public static async void Mutate(Dictionary mutation, Array? extraGameStates = null, bool isInlineMutation = false) + { + Instance.Call("_bridge_mutate", mutation, extraGameStates ?? new Array(), isInlineMutation); + await Instance.ToSignal(Instance, "bridge_mutated"); + } + + + public bool ThingHasMethod(GodotObject thing, string method) + { + MethodInfo? info = thing.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public); + return info != null; + } + + + public async void ResolveThingMethod(GodotObject thing, string method, Array args) + { + MethodInfo? info = thing.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public); + + if (info == null) return; + +#nullable disable + // Convert the method args to something reflection can handle + ParameterInfo[] argTypes = info.GetParameters(); + object[] _args = new object[argTypes.Length]; + for (int i = 0; i < argTypes.Length; i++) + { + if (i < args.Count && args[i].Obj != null) + { + _args[i] = Convert.ChangeType(args[i].Obj, argTypes[i].ParameterType); + } + else if (argTypes[i].DefaultValue != null) + { + _args[i] = argTypes[i].DefaultValue; + } + } + + if (info.ReturnType == typeof(Task)) + { + await (Task)info.Invoke(thing, _args); + EmitSignal(SignalName.Resolved, null); + } + else + { + var value = (Variant)info.Invoke(thing, _args); + EmitSignal(SignalName.Resolved, value); + } + } +#nullable enable + } + + + public partial class DialogueLine : RefCounted + { + private string type = "dialogue"; + public string Type + { + get => type; + set => type = value; + } + + private string next_id = ""; + public string NextId + { + get => next_id; + set => next_id = value; + } + + private string character = ""; + public string Character + { + get => character; + set => character = value; + } + + private string text = ""; + public string Text + { + get => text; + set => text = value; + } + + private string translation_key = ""; + public string TranslationKey + { + get => translation_key; + set => translation_key = value; + } + + private Array responses = new Array(); + public Array Responses + { + get => responses; + } + + private string? time = null; + public string? Time + { + get => time; + } + + private Dictionary pauses = new Dictionary(); + public Dictionary Pauses + { + get => pauses; + } + + private Dictionary speeds = new Dictionary(); + public Dictionary Speeds + { + get => speeds; + } + + private Array inline_mutations = new Array(); + public Array InlineMutations + { + get => inline_mutations; + } + + private Array extra_game_states = new Array(); + + private Array tags = new Array(); + public Array Tags + { + get => tags; + } + + public DialogueLine(RefCounted data) + { + type = (string)data.Get("type"); + next_id = (string)data.Get("next_id"); + character = (string)data.Get("character"); + text = (string)data.Get("text"); + translation_key = (string)data.Get("translation_key"); + pauses = (Dictionary)data.Get("pauses"); + speeds = (Dictionary)data.Get("speeds"); + inline_mutations = (Array)data.Get("inline_mutations"); + time = (string)data.Get("time"); + tags = (Array)data.Get("tags"); + + foreach (var response in (Array)data.Get("responses")) + { + responses.Add(new DialogueResponse(response)); + } + } + + + public string GetTagValue(string tagName) + { + string wrapped = $"{tagName}="; + foreach (var tag in tags) + { + if (tag.StartsWith(wrapped)) + { + return tag.Substring(wrapped.Length); + } + } + return ""; + } + + public override string ToString() + { + switch (type) + { + case "dialogue": + return $""; + case "mutation": + return ""; + default: + return ""; + } + } + } + + + public partial class DialogueResponse : RefCounted + { + private string next_id = ""; + public string NextId + { + get => next_id; + set => next_id = value; + } + + private bool is_allowed = true; + public bool IsAllowed + { + get => is_allowed; + set => is_allowed = value; + } + + private string text = ""; + public string Text + { + get => text; + set => text = value; + } + + private string translation_key = ""; + public string TranslationKey + { + get => translation_key; + set => translation_key = value; + } + + private Array tags = new Array(); + public Array Tags + { + get => tags; + } + + public DialogueResponse(RefCounted data) + { + next_id = (string)data.Get("next_id"); + is_allowed = (bool)data.Get("is_allowed"); + text = (string)data.Get("text"); + translation_key = (string)data.Get("translation_key"); + tags = (Array)data.Get("tags"); + } + + public string GetTagValue(string tagName) + { + string wrapped = $"{tagName}="; + foreach (var tag in tags) + { + if (tag.StartsWith(wrapped)) + { + return tag.Substring(wrapped.Length); + } + } + return ""; + } + + public override string ToString() + { + return $" + + + + + + + + + diff --git a/addons/dialogue_manager/assets/icon.svg.import b/addons/dialogue_manager/assets/icon.svg.import new file mode 100644 index 00000000..3b6fd5ec --- /dev/null +++ b/addons/dialogue_manager/assets/icon.svg.import @@ -0,0 +1,38 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3lr2uas6ax8v" +path="res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex" +metadata={ +"has_editor_variant": true, +"vram_texture": false +} + +[deps] + +source_file="res://addons/dialogue_manager/assets/icon.svg" +dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=true +editor/convert_colors_with_editor_theme=true diff --git a/addons/dialogue_manager/assets/responses_menu.svg b/addons/dialogue_manager/assets/responses_menu.svg new file mode 100644 index 00000000..4e4089d6 --- /dev/null +++ b/addons/dialogue_manager/assets/responses_menu.svg @@ -0,0 +1,52 @@ + + + + + + + + + + diff --git a/addons/dialogue_manager/assets/responses_menu.svg.import b/addons/dialogue_manager/assets/responses_menu.svg.import new file mode 100644 index 00000000..83355fc7 --- /dev/null +++ b/addons/dialogue_manager/assets/responses_menu.svg.import @@ -0,0 +1,38 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drjfciwitjm83" +path="res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex" +metadata={ +"has_editor_variant": true, +"vram_texture": false +} + +[deps] + +source_file="res://addons/dialogue_manager/assets/responses_menu.svg" +dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=true +editor/convert_colors_with_editor_theme=true diff --git a/addons/dialogue_manager/assets/update.svg b/addons/dialogue_manager/assets/update.svg new file mode 100644 index 00000000..a5b80ee7 --- /dev/null +++ b/addons/dialogue_manager/assets/update.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + diff --git a/addons/dialogue_manager/assets/update.svg.import b/addons/dialogue_manager/assets/update.svg.import new file mode 100644 index 00000000..2d8171a3 --- /dev/null +++ b/addons/dialogue_manager/assets/update.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3baj6rygkb3f" +path="res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/dialogue_manager/assets/update.svg" +dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/dialogue_manager/components/code_edit.gd b/addons/dialogue_manager/components/code_edit.gd new file mode 100644 index 00000000..e57c1af8 --- /dev/null +++ b/addons/dialogue_manager/components/code_edit.gd @@ -0,0 +1,425 @@ +@tool +extends CodeEdit + + +signal active_title_change(title: String) +signal error_clicked(line_number: int) +signal external_file_requested(path: String, title: String) + + +const DialogueSyntaxHighlighter = preload("./code_edit_syntax_highlighter.gd") + + +# A link back to the owner MainView +var main_view + +# Theme overrides for syntax highlighting, etc +var theme_overrides: Dictionary: + set(value): + theme_overrides = value + + syntax_highlighter = DialogueSyntaxHighlighter.new() + + # General UI + add_theme_color_override("font_color", theme_overrides.text_color) + add_theme_color_override("background_color", theme_overrides.background_color) + add_theme_color_override("current_line_color", theme_overrides.current_line_color) + add_theme_font_override("font", get_theme_font("source", "EditorFonts")) + add_theme_font_size_override("font_size", theme_overrides.font_size * theme_overrides.scale) + font_size = round(theme_overrides.font_size) + get: + return theme_overrides + +# Any parse errors +var errors: Array: + set(next_errors): + errors = next_errors + for i in range(0, get_line_count()): + var is_error: bool = false + for error in errors: + if error.line_number == i: + is_error = true + mark_line_as_error(i, is_error) + _on_code_edit_caret_changed() + get: + return errors + +# The last selection (if there was one) so we can remember it for refocusing +var last_selected_text: String + +var font_size: int: + set(value): + font_size = value + add_theme_font_size_override("font_size", font_size * theme_overrides.scale) + get: + return font_size + +var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s") + + +func _ready() -> void: + # Add error gutter + add_gutter(0) + set_gutter_type(0, TextEdit.GUTTER_TYPE_ICON) + + # Add comment delimiter + if not has_comment_delimiter("#"): + add_comment_delimiter("#", "", true) + + syntax_highlighter = DialogueSyntaxHighlighter.new() + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventKey and event.is_pressed(): + match event.as_text(): + "Ctrl+Equal", "Command+Equal": + self.font_size += 1 + get_viewport().set_input_as_handled() + "Ctrl+Minus", "Command+Minus": + self.font_size -= 1 + get_viewport().set_input_as_handled() + "Ctrl+0", "Command+0": + self.font_size = theme_overrides.font_size + get_viewport().set_input_as_handled() + "Ctrl+K", "Command+K": + toggle_comment() + get_viewport().set_input_as_handled() + "Alt+Up": + move_line(-1) + get_viewport().set_input_as_handled() + "Alt+Down": + move_line(1) + get_viewport().set_input_as_handled() + + elif event is InputEventMouse: + match event.as_text(): + "Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up": + self.font_size += 1 + get_viewport().set_input_as_handled() + "Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down": + self.font_size -= 1 + get_viewport().set_input_as_handled() + + +func _can_drop_data(at_position: Vector2, data) -> bool: + if typeof(data) != TYPE_DICTIONARY: return false + if data.type != "files": return false + + var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue") + return files.size() > 0 + + +func _drop_data(at_position: Vector2, data) -> void: + var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+") + + var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue") + for file in files: + # Don't import the file into itself + if file == main_view.current_file_path: continue + + var path = file.replace("res://", "").replace(".dialogue", "") + # Find the first non-import line in the file to add our import + var lines = text.split("\n") + for i in range(0, lines.size()): + if not lines[i].begins_with("import "): + insert_line_at(i, "import \"%s\" as %s\n" % [file, replace_regex.sub(path, "_", true)]) + set_caret_line(i) + break + + +func _request_code_completion(force: bool) -> void: + var cursor: Vector2 = get_cursor() + var current_line: String = get_line(cursor.y) + + if ("=> " in current_line or "=>< " in current_line) and (cursor.x > current_line.find("=>")): + var prompt: String = current_line.split("=>")[1] + if prompt.begins_with("< "): + prompt = prompt.substr(2) + else: + prompt = prompt.substr(1) + + if "=> " in current_line: + if matches_prompt(prompt, "end"): + add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons")) + if matches_prompt(prompt, "end!"): + add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons")) + + # Get all titles, including those in imports + var parser: DialogueManagerParser = DialogueManagerParser.new() + parser.prepare(text, main_view.current_file_path, false) + for title in parser.titles: + if "/" in title: + var bits = title.split("/") + if matches_prompt(prompt, bits[0]) or matches_prompt(prompt, bits[1]): + add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons")) + elif matches_prompt(prompt, title): + add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons")) + update_code_completion_options(true) + parser.free() + return + + var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "") + if name_so_far != "" and name_so_far[0].to_upper() == name_so_far[0]: + # Only show names starting with that character + var names: PackedStringArray = get_character_names(name_so_far) + if names.size() > 0: + for name in names: + add_code_completion_option(CodeEdit.KIND_CLASS, name + ": ", name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons")) + update_code_completion_options(true) + else: + cancel_code_completion() + + +func _filter_code_completion_candidates(candidates: Array) -> Array: + # Not sure why but if this method isn't overridden then all completions are wrapped in quotes. + return candidates + + +func _confirm_code_completion(replace: bool) -> void: + var completion = get_code_completion_option(get_code_completion_selected_index()) + begin_complex_operation() + # Delete any part of the text that we've already typed + for i in range(0, completion.display_text.length() - completion.insert_text.length()): + backspace() + # Insert the whole match + insert_text_at_caret(completion.display_text) + end_complex_operation() + + # Close the autocomplete menu on the next tick + call_deferred("cancel_code_completion") + + +### Helpers + + +# Get the current caret as a Vector2 +func get_cursor() -> Vector2: + return Vector2(get_caret_column(), get_caret_line()) + + +# Set the caret from a Vector2 +func set_cursor(from_cursor: Vector2) -> void: + set_caret_line(from_cursor.y) + set_caret_column(from_cursor.x) + + +# Check if a prompt is the start of a string without actually being that string +func matches_prompt(prompt: String, matcher: String) -> bool: + return prompt.length() < matcher.length() and matcher.to_lower().begins_with(prompt.to_lower()) + + +## Get a list of titles from the current text +func get_titles() -> PackedStringArray: + var titles = PackedStringArray([]) + var lines = text.split("\n") + for line in lines: + if line.begins_with("~ "): + titles.append(line.substr(2).strip_edges()) + return titles + + +## Work out what the next title above the current line is +func check_active_title() -> void: + var line_number = get_caret_line() + var lines = text.split("\n") + # Look at each line above this one to find the next title line + for i in range(line_number, -1, -1): + if lines[i].begins_with("~ "): + active_title_change.emit(lines[i].replace("~ ", "")) + return + + active_title_change.emit("") + + +# Move the caret line to match a given title +func go_to_title(title: String) -> void: + var lines = text.split("\n") + for i in range(0, lines.size()): + if lines[i].strip_edges() == "~ " + title: + set_caret_line(i) + center_viewport_to_caret() + + +func get_character_names(beginning_with: String) -> PackedStringArray: + var names: PackedStringArray = [] + var lines = text.split("\n") + for line in lines: + if ": " in line: + var name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "") + if not name in names and matches_prompt(beginning_with, name): + names.append(name) + return names + + +# Mark a line as an error or not +func mark_line_as_error(line_number: int, is_error: bool) -> void: + if is_error: + set_line_background_color(line_number, theme_overrides.error_line_color) + set_line_gutter_icon(line_number, 0, get_theme_icon("StatusError", "EditorIcons")) + else: + set_line_background_color(line_number, theme_overrides.background_color) + set_line_gutter_icon(line_number, 0, null) + + +# Insert or wrap some bbcode at the caret/selection +func insert_bbcode(open_tag: String, close_tag: String = "") -> void: + if close_tag == "": + insert_text_at_caret(open_tag) + grab_focus() + else: + var selected_text = get_selected_text() + insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag]) + grab_focus() + set_caret_column(get_caret_column() - close_tag.length()) + +# Insert text at current caret position +# Move Caret down 1 line if not => END +func insert_text(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() + + +# Move the selected lines up or down +func move_line(offset: int) -> void: + offset = clamp(offset, -1, 1) + + var cursor = get_cursor() + var reselect: bool = false + var from: int = cursor.y + var to: int = cursor.y + if has_selection(): + reselect = true + from = get_selection_from_line() + to = get_selection_to_line() + + var lines := text.split("\n") + + # We can't move the lines out of bounds + if from + offset < 0 or to + offset >= lines.size(): return + + var target_from_index = from - 1 if offset == -1 else to + 1 + var target_to_index = to if offset == -1 else from + var line_to_move = lines[target_from_index] + lines.remove_at(target_from_index) + lines.insert(target_to_index, line_to_move) + + text = "\n".join(lines) + + cursor.y += offset + from += offset + to += offset + if reselect: + select(from, 0, to, get_line_width(to)) + set_cursor(cursor) + text_changed.emit() + + +### Signals + + +func _on_code_edit_symbol_validate(symbol: String) -> void: + if symbol.begins_with("res://") and symbol.ends_with(".dialogue"): + set_symbol_lookup_word_as_valid(true) + return + + for title in get_titles(): + if symbol == title: + set_symbol_lookup_word_as_valid(true) + return + set_symbol_lookup_word_as_valid(false) + + +func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void: + if symbol.begins_with("res://") and symbol.ends_with(".dialogue"): + external_file_requested.emit(symbol, "") + else: + go_to_title(symbol) + + +func _on_code_edit_text_changed() -> void: + request_code_completion(true) + + +func _on_code_edit_text_set() -> void: + queue_redraw() + + +func _on_code_edit_caret_changed() -> void: + check_active_title() + last_selected_text = get_selected_text() + + +func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void: + var line_errors = errors.filter(func(error): return error.line_number == line) + if line_errors.size() > 0: + error_clicked.emit(line) diff --git a/addons/dialogue_manager/components/code_edit.tscn b/addons/dialogue_manager/components/code_edit.tscn new file mode 100644 index 00000000..a974ea34 --- /dev/null +++ b/addons/dialogue_manager/components/code_edit.tscn @@ -0,0 +1,56 @@ +[gd_scene load_steps=4 format=3 uid="uid://civ6shmka5e8u"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="1_58cfo"] +[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit.gd" id="1_g324i"] + +[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_cobxx"] +script = ExtResource("1_58cfo") + +[node name="CodeEdit" type="CodeEdit"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "~ title_thing + +if this = \"that\" or 'this' +Nathan: Something +- Then [if test.thing() == 2.0] => somewhere +- Other => END! + +~ somewhere + +set has_something = true +=> END" +highlight_all_occurrences = true +highlight_current_line = true +draw_tabs = true +syntax_highlighter = SubResource("SyntaxHighlighter_cobxx") +scroll_past_end_of_file = true +minimap_draw = true +symbol_lookup_on_click = true +line_folding = true +gutters_draw_line_numbers = true +gutters_draw_fold_gutter = true +delimiter_strings = Array[String](["\" \""]) +delimiter_comments = Array[String](["#"]) +code_completion_enabled = true +code_completion_prefixes = Array[String]([">", "<"]) +indent_automatic = true +auto_brace_completion_enabled = true +auto_brace_completion_highlight_matching = true +auto_brace_completion_pairs = { +"\"": "\"", +"(": ")", +"[": "]", +"{": "}" +} +script = ExtResource("1_g324i") + +[connection signal="caret_changed" from="." to="." method="_on_code_edit_caret_changed"] +[connection signal="gutter_clicked" from="." to="." method="_on_code_edit_gutter_clicked"] +[connection signal="symbol_lookup" from="." to="." method="_on_code_edit_symbol_lookup"] +[connection signal="symbol_validate" from="." to="." method="_on_code_edit_symbol_validate"] +[connection signal="text_changed" from="." to="." method="_on_code_edit_text_changed"] +[connection signal="text_set" from="." to="." method="_on_code_edit_text_set"] diff --git a/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd new file mode 100644 index 00000000..fe1b8bdd --- /dev/null +++ b/addons/dialogue_manager/components/code_edit_syntax_highlighter.gd @@ -0,0 +1,382 @@ +@tool +extends SyntaxHighlighter + + +enum ExpressionType {DO, SET, IF} + + +var dialogue_manager_parser: DialogueManagerParser = DialogueManagerParser.new() + +var regex_titles: RegEx = RegEx.create_from_string("^\\s*(?~\\s+[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+)") +var regex_comments: RegEx = RegEx.create_from_string("(?:(?>\"(?:\\\\\"|[^\"\\n])*\")[^\"\\n]*?\\s*(?<comment>#[^\\n]*)$|^[^\"#\\n]*?\\s*(?<comment2>#[^\\n]*))") +var regex_mutation: RegEx = RegEx.create_from_string("^\\s*(do|do!|set) (?<mutation>.*)") +var regex_condition: RegEx = RegEx.create_from_string("^\\s*(if|elif|while|else if) (?<condition>.*)") +var regex_wcondition: RegEx = RegEx.create_from_string("\\[if (?<condition>((?:[^\\[\\]]*)|(?:\\[(?1)\\]))*?)\\]") +var regex_wendif: RegEx = RegEx.create_from_string("\\[(\\/if|else)\\]") +var regex_rgroup: RegEx = RegEx.create_from_string("\\[\\[(?<options>.*?)\\]\\]") +var regex_endconditions: RegEx = RegEx.create_from_string("^\\s*(endif|else):?\\s*$") +var regex_tags: RegEx = RegEx.create_from_string("\\[(?<tag>(?!(?:ID:.*)|if)[a-zA-Z_][a-zA-Z0-9_]*!?)(?:[= ](?<val>[^\\[\\]]+))?\\](?:(?<text>(?!\\[\\/\\k<tag>\\]).*?)?(?<end>\\[\\/\\k<tag>\\]))?") +var regex_dialogue: RegEx = RegEx.create_from_string("^\\s*(?:(?<random>\\%[\\d.]* )|(?<response>- ))?(?:(?<character>[^#:]*): )?(?<dialogue>.*)$") +var regex_goto: RegEx = RegEx.create_from_string("=><? (?:(?<file>[^\\/]+)\\/)?(?<title>[^\\/]*)") +var regex_string: RegEx = RegEx.create_from_string("^(?<delimiter>[\"'])(?<content>(?:\\\\{2})*|(?:.*?[^\\\\](?:\\\\{2})*))\\1$") +var regex_escape: RegEx = RegEx.create_from_string("\\\\.") +var regex_number: RegEx = RegEx.create_from_string("^-?(?:(?:0x(?:[0-9A-Fa-f]{2})+)|(?:0b[01]+)|(?:\\d+(?:(?:[\\.]\\d*)?(?:e\\d+)?)|(?:_\\d+)+)?)$") +var regex_array: RegEx = RegEx.create_from_string("\\[((?>[^\\[\\]]+|(?R))*)\\]") +var regex_dict: RegEx = RegEx.create_from_string("^\\{((?>[^\\{\\}]+|(?R))*)\\}$") +var regex_kvdict: RegEx = RegEx.create_from_string("^\\s*(?<left>.*?)\\s*(?<colon>:|=)\\s*(?<right>[^\\/]+)$") +var regex_commas: RegEx = RegEx.create_from_string("([^,]+)(?:\\s*,\\s*)?") +var regex_assignment: RegEx = RegEx.create_from_string("^\\s*(?<var>[a-zA-Z_][a-zA-Z_0-9]*)(?:(?<attr>(?:\\.[a-zA-Z_][a-zA-Z_0-9]*)+)|(?:\\[(?<key>[^\\]]+)\\]))?\\s*(?<op>(?:\\/|\\*|-|\\+)?=)\\s*(?<val>.*)$") +var regex_varname: RegEx = RegEx.create_from_string("^\\s*(?!true|false|and|or|not|in|null)(?<var>[a-zA-Z_][a-zA-Z_0-9]*)(?:(?<attr>(?:\\.[a-zA-Z_][a-zA-Z_0-9]*)+)|(?:\\[(?<key>[^\\]]+)\\]))?\\s*$") +var regex_keyword: RegEx = RegEx.create_from_string("^\\s*(true|false|null)\\s*$") +var regex_function: RegEx = RegEx.create_from_string("^\\s*([a-zA-Z_][a-zA-Z_0-9]*\\s*)\\(") +var regex_comparison: RegEx = RegEx.create_from_string("^(?<left>.*?)\\s*(?<op>==|>=|<=|<|>|!=)\\s*(?<right>.*)$") +var regex_blogical: RegEx = RegEx.create_from_string("^(?<left>.*?)\\s+(?<op>and|or|in)\\s+(?<right>.*)$") +var regex_ulogical: RegEx = RegEx.create_from_string("^\\s*(?<op>not)\\s+(?<right>.*)$") +var regex_paren: RegEx = RegEx.create_from_string("\\((?<paren>((?:[^\\(\\)]*)|(?:\\((?1)\\)))*?)\\)") + +var cache: Dictionary = {} + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + dialogue_manager_parser.free() + + +func _clear_highlighting_cache() -> void: + cache = {} + + +## Returns the syntax coloring for a dialogue file line +func _get_line_syntax_highlighting(line: int) -> Dictionary: + var colors: Dictionary = {} + var text_edit: TextEdit = get_text_edit() + var text: String = text_edit.get_line(line) + + # Prevents an error from popping up while developing + if not is_instance_valid(text_edit) or text_edit.theme_overrides.is_empty(): + return colors + + # Disable this, as well as the line at the bottom of this function to remove the cache. + if text in cache: + return cache[text] + + # Comments, we have to remove them at this point so the rest of the processing is easier + # Counts both end-of-line and single-line comments + # Comments are not allowed within dialogue lines or response lines, so we ask the parser what it thinks the current line is + if not (dialogue_manager_parser.is_dialogue_line(text) or dialogue_manager_parser.is_response_line(text)) or dialogue_manager_parser.is_line_empty(text) or dialogue_manager_parser.is_import_line(text): + var comment_matches: Array[RegExMatch] = regex_comments.search_all(text) + for comment_match in comment_matches: + for i in ["comment", "comment2"]: + if i in comment_match.names: + colors[comment_match.get_start(i)] = {"color": text_edit.theme_overrides.comments_color} + text = text.substr(0, comment_match.get_start(i)) + + # Dialogues. + var dialogue_matches: Array[RegExMatch] = regex_dialogue.search_all(text) + for dialogue_match in dialogue_matches: + if "random" in dialogue_match.names: + colors[dialogue_match.get_start("random")] = {"color": text_edit.theme_overrides.symbols_color} + colors[dialogue_match.get_end("random")] = {"color": text_edit.theme_overrides.text_color} + if "response" in dialogue_match.names: + colors[dialogue_match.get_start("response")] = {"color": text_edit.theme_overrides.symbols_color} + colors[dialogue_match.get_end("response")] = {"color": text_edit.theme_overrides.text_color} + if "character" in dialogue_match.names: + colors[dialogue_match.get_start("character")] = {"color": text_edit.theme_overrides.members_color} + colors[dialogue_match.get_end("character")] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_dialogue_syntax_highlighting(dialogue_match.get_start("dialogue"), dialogue_match.get_string("dialogue")), true) + + # Title lines. + if dialogue_manager_parser.is_title_line(text): + var title_matches: Array[RegExMatch] = regex_titles.search_all(text) + for title_match in title_matches: + colors[title_match.get_start("title")] = {"color": text_edit.theme_overrides.titles_color} + + # Import lines. + var import_matches: Array[RegExMatch] = dialogue_manager_parser.IMPORT_REGEX.search_all(text) + for import_match in import_matches: + colors[import_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color} + colors[import_match.get_start("path") - 1] = {"color": text_edit.theme_overrides.strings_color} + colors[import_match.get_end("path") + 1] = {"color": text_edit.theme_overrides.conditions_color} + colors[import_match.get_start("prefix")] = {"color": text_edit.theme_overrides.members_color} + colors[import_match.get_end("prefix")] = {"color": text_edit.theme_overrides.conditions_color} + + # Using clauses + var using_matches: Array[RegExMatch] = dialogue_manager_parser.USING_REGEX.search_all(text) + for using_match in using_matches: + colors[using_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color} + colors[using_match.get_start("state") - 1] = {"color": text_edit.theme_overrides.text_color} + + # Condition keywords and expressions. + var condition_matches: Array[RegExMatch] = regex_condition.search_all(text) + for condition_match in condition_matches: + colors[condition_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color} + colors[condition_match.get_end(1)] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_expression_syntax_highlighting(condition_match.get_start("condition"), ExpressionType.IF, condition_match.get_string("condition")), true) + # endif/else + var endcondition_matches: Array[RegExMatch] = regex_endconditions.search_all(text) + for endcondition_match in endcondition_matches: + colors[endcondition_match.get_start(1)] = {"color": text_edit.theme_overrides.conditions_color} + colors[endcondition_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + + # Mutations. + var mutation_matches: Array[RegExMatch] = regex_mutation.search_all(text) + for mutation_match in mutation_matches: + colors[mutation_match.get_start(0)] = {"color": text_edit.theme_overrides.mutations_color} + colors.merge(_get_expression_syntax_highlighting(mutation_match.get_start("mutation"), ExpressionType.DO if mutation_match.strings[1] == "do" else ExpressionType.SET, mutation_match.get_string("mutation")), true) + + # CodeEdit seems to have issues if the Dictionary keys weren't added in order? + var new_colors: Dictionary = {} + var ordered_keys: Array = colors.keys() + ordered_keys.sort() + for index in ordered_keys: + new_colors[index] = colors[index] + + cache[text] = new_colors + return new_colors + + +## Returns the syntax highlighting for a dialogue line +func _get_dialogue_syntax_highlighting(start_index: int, text: String) -> Dictionary: + var text_edit: TextEdit = get_text_edit() + var colors: Dictionary = {} + + # #tag style tags + var hashtag_matches: Array[RegExMatch] = dialogue_manager_parser.TAGS_REGEX.search_all(text) + for hashtag_match in hashtag_matches: + colors[start_index + hashtag_match.get_start(0)] = { "color": text_edit.theme_overrides.comments_color } + colors[start_index + hashtag_match.get_end(0)] = { "color": text_edit.theme_overrides.text_color } + + # Global tags, like bbcode. + var tag_matches: Array[RegExMatch] = regex_tags.search_all(text) + for tag_match in tag_matches: + colors[start_index + tag_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + if "val" in tag_match.names: + colors.merge(_get_literal_syntax_highlighting(start_index + tag_match.get_start("val"), tag_match.get_string("val")), true) + colors[start_index + tag_match.get_end("val")] = {"color": text_edit.theme_overrides.symbols_color} + # Showing the text color straight in the editor for better ease-of-use + if tag_match.get_string("tag") == "color": + colors[start_index + tag_match.get_start("val")] = {"color": Color.from_string(tag_match.get_string("val"), text_edit.theme_overrides.text_color)} + if "text" in tag_match.names: + colors[start_index + tag_match.get_start("text")] = {"color": text_edit.theme_overrides.text_color} + # Text can still contain tags if several effects are applied ([center][b]Something[/b][/center], so recursing + colors.merge(_get_dialogue_syntax_highlighting(start_index + tag_match.get_start("text"), tag_match.get_string("text")), true) + colors[start_index + tag_match.get_end("text")] = {"color": text_edit.theme_overrides.symbols_color} + if "end" in tag_match.names: + colors[start_index + tag_match.get_start("end")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + tag_match.get_end("end")] = {"color": text_edit.theme_overrides.text_color} + colors[start_index + tag_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + # ID tag. + var translation_matches: Array[RegExMatch] = dialogue_manager_parser.TRANSLATION_REGEX.search_all(text) + for translation_match in translation_matches: + colors[start_index + translation_match.get_start(0)] = {"color": text_edit.theme_overrides.comments_color} + colors[start_index + translation_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + # Replacements. + var replacement_matches: Array[RegExMatch] = dialogue_manager_parser.REPLACEMENTS_REGEX.search_all(text) + for replacement_match in replacement_matches: + colors[start_index + replacement_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + replacement_match.get_start(1)] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + replacement_match.get_start(1), replacement_match.strings[1]), true) + colors[start_index + replacement_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + replacement_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + # Jump at the end of a response. + var goto_matches: Array[RegExMatch] = regex_goto.search_all(text) + for goto_match in goto_matches: + colors[start_index + goto_match.get_start(0)] = {"color": text_edit.theme_overrides.jumps_color} + if "file" in goto_match.names: + colors[start_index + goto_match.get_start("file")] = {"color": text_edit.theme_overrides.members_color} + colors[start_index + goto_match.get_end("file")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + goto_match.get_start("title")] = {"color": text_edit.theme_overrides.titles_color} + colors[start_index + goto_match.get_end("title")] = {"color": text_edit.theme_overrides.jumps_color} + colors[start_index + goto_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + # Wrapped condition. + var wcondition_matches: Array[RegExMatch] = regex_wcondition.search_all(text) + for wcondition_match in wcondition_matches: + colors[start_index + wcondition_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + wcondition_match.get_start(0) + 1] = {"color": text_edit.theme_overrides.conditions_color} + colors[start_index + wcondition_match.get_start(0) + 3] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + wcondition_match.get_start("condition"), wcondition_match.get_string("condition")), true) + colors[start_index + wcondition_match.get_end("condition")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + wcondition_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + # [/if] tag for color matching with the opening tag + var wendif_matches: Array[RegExMatch] = regex_wendif.search_all(text) + for wendif_match in wendif_matches: + colors[start_index + wendif_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + wendif_match.get_start(1)] = {"color": text_edit.theme_overrides.conditions_color} + colors[start_index + wendif_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + wendif_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + # Random groups + var rgroup_matches: Array[RegExMatch] = regex_rgroup.search_all(text) + for rgroup_match in rgroup_matches: + colors[start_index + rgroup_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + rgroup_match.get_start("options")] = {"color": text_edit.theme_overrides.text_color} + var separator_matches: Array[RegExMatch] = RegEx.create_from_string("\\|").search_all(rgroup_match.get_string("options")) + for separator_match in separator_matches: + colors[start_index + rgroup_match.get_start("options") + separator_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + rgroup_match.get_start("options") + separator_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + colors[start_index + rgroup_match.get_end("options")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + rgroup_match.get_end(0)] = {"color": text_edit.theme_overrides.text_color} + + return colors + + +## Returns the syntax highlighting for an expression (mutation set/do, or condition) +func _get_expression_syntax_highlighting(start_index: int, type: ExpressionType, text: String) -> Dictionary: + var text_edit: TextEdit = get_text_edit() + var colors: Dictionary = {} + + if type == ExpressionType.SET: + var assignment_matches: Array[RegExMatch] = regex_assignment.search_all(text) + for assignment_match in assignment_matches: + colors[start_index + assignment_match.get_start("var")] = {"color": text_edit.theme_overrides.text_color} + if "attr" in assignment_match.names: + colors[start_index + assignment_match.get_start("attr")] = {"color": text_edit.theme_overrides.members_color} + colors[start_index + assignment_match.get_end("attr")] = {"color": text_edit.theme_overrides.text_color} + if "key" in assignment_match.names: + # Braces are outside of the key, so coloring them symbols_color + colors[start_index + assignment_match.get_start("key") - 1] = {"color": text_edit.theme_overrides.symbols_color} + colors.merge(_get_literal_syntax_highlighting(start_index + assignment_match.get_start("key"), assignment_match.get_string("key")), true) + colors[start_index + assignment_match.get_end("key")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + assignment_match.get_end("key") + 1] = {"color": text_edit.theme_overrides.text_color} + + colors[start_index + assignment_match.get_start("op")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + assignment_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + assignment_match.get_start("val"), assignment_match.get_string("val")), true) + else: + colors.merge(_get_literal_syntax_highlighting(start_index, text), true) + + return colors + + +## Returns the syntax highlighting for a literal. +## For this purpose, "literal" refers to a regular code line that could be used to get a value out of: +## - function calls +## - real literals (bool, string, int, float, etc.) +## - logical operators (>, <, >=, or, and, not, etc.) +func _get_literal_syntax_highlighting(start_index: int, text: String) -> Dictionary: + var text_edit: TextEdit = get_text_edit() + var colors: Dictionary = {} + + # Removing spaces at start/end of the literal + var text_length: int = text.length() + text = text.lstrip(" ") + start_index += text_length - text.length() + text = text.rstrip(" ") + + # Parenthesis expression. + var paren_matches: Array[RegExMatch] = regex_paren.search_all(text) + for paren_match in paren_matches: + colors[start_index + paren_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + paren_match.get_start(0) + 1] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + paren_match.get_start("paren"), paren_match.get_string("paren")), true) + colors[start_index + paren_match.get_end(0) - 1] = {"color": text_edit.theme_overrides.symbols_color} + + # Strings. + var string_matches: Array[RegExMatch] = regex_string.search_all(text) + for string_match in string_matches: + colors[start_index + string_match.get_start(0)] = {"color": text_edit.theme_overrides.strings_color} + if "content" in string_match.names: + var escape_matches: Array[RegExMatch] = regex_escape.search_all(string_match.get_string("content")) + for escape_match in escape_matches: + colors[start_index + string_match.get_start("content") + escape_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + string_match.get_start("content") + escape_match.get_end(0)] = {"color": text_edit.theme_overrides.strings_color} + + # Numbers. + var number_matches: Array[RegExMatch] = regex_number.search_all(text) + for number_match in number_matches: + colors[start_index + number_match.get_start(0)] = {"color": text_edit.theme_overrides.numbers_color} + + # Arrays. + var array_matches: Array[RegExMatch] = regex_array.search_all(text) + for array_match in array_matches: + colors[start_index + array_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors.merge(_get_list_syntax_highlighting(start_index + array_match.get_start(1), array_match.strings[1]), true) + colors[start_index + array_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + + # Dictionaries. + var dict_matches: Array[RegExMatch] = regex_dict.search_all(text) + for dict_match in dict_matches: + colors[start_index + dict_match.get_start(0)] = {"color": text_edit.theme_overrides.symbols_color} + colors.merge(_get_list_syntax_highlighting(start_index + dict_match.get_start(1), dict_match.strings[1]), true) + colors[start_index + dict_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + + # Dictionary key: value pairs + var kvdict_matches: Array[RegExMatch] = regex_kvdict.search_all(text) + for kvdict_match in kvdict_matches: + colors.merge(_get_literal_syntax_highlighting(start_index + kvdict_match.get_start("left"), kvdict_match.get_string("left")), true) + colors[start_index + kvdict_match.get_start("colon")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + kvdict_match.get_end("colon")] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + kvdict_match.get_start("right"), kvdict_match.get_string("right")), true) + + # Booleans. + var bool_matches: Array[RegExMatch] = regex_keyword.search_all(text) + for bool_match in bool_matches: + colors[start_index + bool_match.get_start(0)] = {"color": text_edit.theme_overrides.conditions_color} + + # Functions. + var function_matches: Array[RegExMatch] = regex_function.search_all(text) + for function_match in function_matches: + var last_brace_index: int = text.rfind(")") + colors[start_index + function_match.get_start(1)] = {"color": text_edit.theme_overrides.mutations_color} + colors[start_index + function_match.get_end(1)] = {"color": text_edit.theme_overrides.symbols_color} + colors.merge(_get_list_syntax_highlighting(start_index + function_match.get_end(0), text.substr(function_match.get_end(0), last_brace_index - function_match.get_end(0))), true) + colors[start_index + last_brace_index] = {"color": text_edit.theme_overrides.symbols_color} + + # Variables. + var varname_matches: Array[RegExMatch] = regex_varname.search_all(text) + for varname_match in varname_matches: + colors[start_index + varname_match.get_start("var")] = {"color": text_edit.theme_overrides.text_color} + if "attr" in varname_match.names: + colors[start_index + varname_match.get_start("attr")] = {"color": text_edit.theme_overrides.members_color} + colors[start_index + varname_match.get_end("attr")] = {"color": text_edit.theme_overrides.text_color} + if "key" in varname_match.names: + # Braces are outside of the key, so coloring them symbols_color + colors[start_index + varname_match.get_start("key") - 1] = {"color": text_edit.theme_overrides.symbols_color} + colors.merge(_get_literal_syntax_highlighting(start_index + varname_match.get_start("key"), varname_match.get_string("key")), true) + colors[start_index + varname_match.get_end("key")] = {"color": text_edit.theme_overrides.symbols_color} + + # Comparison operators. + var comparison_matches: Array[RegExMatch] = regex_comparison.search_all(text) + for comparison_match in comparison_matches: + colors.merge(_get_literal_syntax_highlighting(start_index + comparison_match.get_start("left"), comparison_match.get_string("left")), true) + colors[start_index + comparison_match.get_start("op")] = {"color": text_edit.theme_overrides.symbols_color} + colors[start_index + comparison_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color} + var right = comparison_match.get_string("right") + if right.ends_with(":"): + right = right.substr(0, right.length() - 1) + colors.merge(_get_literal_syntax_highlighting(start_index + comparison_match.get_start("right"), right), true) + colors[start_index + comparison_match.get_start("right") + right.length()] = { "color": text_edit.theme_overrides.symbols_color } + + # Logical binary operators. + var blogical_matches: Array[RegExMatch] = regex_blogical.search_all(text) + for blogical_match in blogical_matches: + colors.merge(_get_literal_syntax_highlighting(start_index + blogical_match.get_start("left"), blogical_match.get_string("left")), true) + colors[start_index + blogical_match.get_start("op")] = {"color": text_edit.theme_overrides.conditions_color} + colors[start_index + blogical_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + blogical_match.get_start("right"), blogical_match.get_string("right")), true) + + # Logical unary operators. + var ulogical_matches: Array[RegExMatch] = regex_ulogical.search_all(text) + for ulogical_match in ulogical_matches: + colors[start_index + ulogical_match.get_start("op")] = {"color": text_edit.theme_overrides.conditions_color} + colors[start_index + ulogical_match.get_end("op")] = {"color": text_edit.theme_overrides.text_color} + colors.merge(_get_literal_syntax_highlighting(start_index + ulogical_match.get_start("right"), ulogical_match.get_string("right")), true) + + return colors + + +## Returns the syntax coloring for a list of literals separated by commas +func _get_list_syntax_highlighting(start_index: int, text: String) -> Dictionary: + var text_edit: TextEdit = get_text_edit() + var colors: Dictionary = {} + + # Comma-separated list of literals (for arrays and function arguments) + var element_matches: Array[RegExMatch] = regex_commas.search_all(text) + for element_match in element_matches: + colors.merge(_get_literal_syntax_highlighting(start_index + element_match.get_start(1), element_match.strings[1]), true) + + return colors diff --git a/addons/dialogue_manager/components/dialogue_cache.gd b/addons/dialogue_manager/components/dialogue_cache.gd new file mode 100644 index 00000000..5304d4bb --- /dev/null +++ b/addons/dialogue_manager/components/dialogue_cache.gd @@ -0,0 +1,160 @@ +extends Node + + +const DialogueConstants = preload("../constants.gd") +const DialogueSettings = preload("../settings.gd") +const DialogueManagerParseResult = preload("./parse_result.gd") + + +# Keeps track of errors and dependencies. +# { +# <dialogue file path> = { +# path = <dialogue file path>, +# dependencies = [<dialogue file path>, <dialogue file path>], +# errors = [<error>, <error>] +# } +# } +var _cache: Dictionary = {} + +var _update_dependency_timer: Timer = Timer.new() +var _update_dependency_paths: PackedStringArray = [] + + +func _ready() -> void: + add_child(_update_dependency_timer) + _update_dependency_timer.timeout.connect(_on_update_dependency_timeout) + + _build_cache() + + +func reimport_files(files: PackedStringArray = []) -> void: + if files.is_empty(): files = get_files() + + var file_system: EditorFileSystem = Engine.get_meta("DialogueManagerPlugin") \ + .get_editor_interface() \ + .get_resource_filesystem() + + # NOTE: Godot 4.2rc1 has an issue with reimporting more than one + # file at a time so we do them one by one + for file in files: + file_system.reimport_files([file]) + await get_tree().create_timer(0.2) + + +## Add a dialogue file to the cache. +func add_file(path: String, parse_results: DialogueManagerParseResult = null) -> void: + _cache[path] = { + path = path, + dependencies = [], + errors = [] + } + + if parse_results != null: + _cache[path].dependencies = Array(parse_results.imported_paths).filter(func(d): return d != path) + _cache[path].parsed_at = Time.get_ticks_msec() + + # If this is a fresh cache entry then we need to check for dependencies + if parse_results == null and not _update_dependency_paths.has(path): + queue_updating_dependencies(path) + + +## Get the file paths in the cache. +func get_files() -> PackedStringArray: + return _cache.keys() + + +## Remember any errors in a dialogue file. +func add_errors_to_file(path: String, errors: Array[Dictionary]) -> void: + if _cache.has(path): + _cache[path].errors = errors + else: + _cache[path] = { + path = path, + resource_path = "", + dependencies = [], + errors = errors + } + + +## Get a list of files that have errors in them. +func get_files_with_errors() -> Array[Dictionary]: + var files_with_errors: Array[Dictionary] = [] + for dialogue_file in _cache.values(): + if dialogue_file and dialogue_file.errors.size() > 0: + files_with_errors.append(dialogue_file) + return files_with_errors + + +## Queue a file to have it's dependencies checked +func queue_updating_dependencies(of_path: String) -> void: + _update_dependency_timer.stop() + if not _update_dependency_paths.has(of_path): + _update_dependency_paths.append(of_path) + _update_dependency_timer.start(0.5) + + +## Update any references to a file path that has moved +func move_file_path(from_path: String, to_path: String) -> void: + if not _cache.has(from_path): return + + if to_path != "": + _cache[to_path] = _cache[from_path].duplicate() + _cache.erase(from_path) + + +## Get any dialogue files that import a given path. +func get_files_with_dependency(imported_path: String) -> Array: + return _cache.values().filter(func(d): return d.dependencies.has(imported_path)) + + +## Get any paths that are dependent on a given path +func get_dependent_paths_for_reimport(on_path: String) -> PackedStringArray: + return get_files_with_dependency(on_path) \ + .filter(func(d): return Time.get_ticks_msec() - d.get("parsed_at", 0) > 3000) \ + .map(func(d): return d.path) + + +# Build the initial cache for dialogue files. +func _build_cache() -> void: + var current_files: PackedStringArray = _get_dialogue_files_in_filesystem() + for file in current_files: + add_file(file) + + +# Recursively find any dialogue files in a directory +func _get_dialogue_files_in_filesystem(path: String = "res://") -> PackedStringArray: + var files: PackedStringArray = [] + + if DirAccess.dir_exists_absolute(path): + var dir = DirAccess.open(path) + dir.list_dir_begin() + var file_name = dir.get_next() + while file_name != "": + var file_path: String = (path + "/" + file_name).simplify_path() + if dir.current_is_dir(): + if not file_name in [".godot", ".tmp"]: + files.append_array(_get_dialogue_files_in_filesystem(file_path)) + elif file_name.get_extension() == "dialogue": + files.append(file_path) + file_name = dir.get_next() + + return files + + +### Signals + + +func _on_update_dependency_timeout() -> void: + _update_dependency_timer.stop() + var import_regex: RegEx = RegEx.create_from_string("import \"(?<path>.*?)\"") + var file: FileAccess + var found_imports: Array[RegExMatch] + for path in _update_dependency_paths: + # Open the file and check for any "import" lines + file = FileAccess.open(path, FileAccess.READ) + found_imports = import_regex.search_all(file.get_as_text()) + var dependencies: PackedStringArray = [] + for found in found_imports: + dependencies.append(found.strings[found.names.path]) + _cache[path].dependencies = dependencies + _update_dependency_paths.clear() diff --git a/addons/dialogue_manager/components/download_update_panel.gd b/addons/dialogue_manager/components/download_update_panel.gd new file mode 100644 index 00000000..ddaa5347 --- /dev/null +++ b/addons/dialogue_manager/components/download_update_panel.gd @@ -0,0 +1,84 @@ +@tool +extends Control + + +signal failed() +signal updated(updated_to_version: String) + + +const DialogueConstants = preload("../constants.gd") + +const TEMP_FILE_NAME = "user://temp.zip" + + +@onready var logo: TextureRect = %Logo +@onready var label: Label = $VBox/Label +@onready var http_request: HTTPRequest = $HTTPRequest +@onready var download_button: Button = %DownloadButton + +var next_version_release: Dictionary: + set(value): + next_version_release = value + label.text = DialogueConstants.translate("update.is_available_for_download") % value.tag_name.substr(1) + get: + return next_version_release + + +func _ready() -> void: + $VBox/Center/DownloadButton.text = DialogueConstants.translate("update.download_update") + $VBox/Center2/NotesButton.text = DialogueConstants.translate("update.release_notes") + + +### Signals + + +func _on_download_button_pressed() -> void: + # Safeguard the actual dialogue manager repo from accidentally updating itself + if FileAccess.file_exists("res://examples/test_scenes/test_scene.gd"): + prints("You can't update the addon from within itself.") + failed.emit() + return + + http_request.request(next_version_release.zipball_url) + download_button.disabled = true + download_button.text = DialogueConstants.translate("update.downloading") + + +func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS: + failed.emit() + return + + # Save the downloaded zip + var zip_file: FileAccess = FileAccess.open(TEMP_FILE_NAME, FileAccess.WRITE) + zip_file.store_buffer(body) + zip_file.close() + + OS.move_to_trash(ProjectSettings.globalize_path("res://addons/dialogue_manager")) + + var zip_reader: ZIPReader = ZIPReader.new() + zip_reader.open(TEMP_FILE_NAME) + var files: PackedStringArray = zip_reader.get_files() + + var base_path = files[1] + # Remove archive folder + files.remove_at(0) + # Remove assets folder + files.remove_at(0) + + for path in files: + var new_file_path: String = path.replace(base_path, "") + if path.ends_with("/"): + DirAccess.make_dir_recursive_absolute("res://addons/%s" % new_file_path) + else: + var file: FileAccess = FileAccess.open("res://addons/%s" % new_file_path, FileAccess.WRITE) + file.store_buffer(zip_reader.read_file(path)) + + zip_reader.close() + DirAccess.remove_absolute(TEMP_FILE_NAME) + + updated.emit(next_version_release.tag_name.substr(1)) + + +func _on_notes_button_pressed() -> void: + OS.shell_open(next_version_release.html_url) diff --git a/addons/dialogue_manager/components/download_update_panel.tscn b/addons/dialogue_manager/components/download_update_panel.tscn new file mode 100644 index 00000000..92750f7a --- /dev/null +++ b/addons/dialogue_manager/components/download_update_panel.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=3 format=3 uid="uid://qdxrxv3c3hxk"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/download_update_panel.gd" id="1_4tm1k"] +[ext_resource type="Texture2D" uid="uid://d3baj6rygkb3f" path="res://addons/dialogue_manager/assets/update.svg" id="2_4o2m6"] + +[node name="DownloadUpdatePanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_4tm1k") + +[node name="HTTPRequest" type="HTTPRequest" parent="."] + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -1.0 +offset_top = 9.0 +offset_right = -1.0 +offset_bottom = 9.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="Logo" type="TextureRect" parent="VBox"] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(300, 80) +layout_mode = 2 +texture = ExtResource("2_4o2m6") +stretch_mode = 5 + +[node name="Label" type="Label" parent="VBox"] +layout_mode = 2 +text = "v1.2.3 is available for download." +horizontal_alignment = 1 + +[node name="Center" type="CenterContainer" parent="VBox"] +layout_mode = 2 + +[node name="DownloadButton" type="Button" parent="VBox/Center"] +unique_name_in_owner = true +layout_mode = 2 +text = "Download and install update" + +[node name="Center2" type="CenterContainer" parent="VBox"] +layout_mode = 2 + +[node name="NotesButton" type="LinkButton" parent="VBox/Center2"] +layout_mode = 2 +text = "Read release notes..." + +[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"] +[connection signal="pressed" from="VBox/Center/DownloadButton" to="." method="_on_download_button_pressed"] +[connection signal="pressed" from="VBox/Center2/NotesButton" to="." method="_on_notes_button_pressed"] diff --git a/addons/dialogue_manager/components/errors_panel.gd b/addons/dialogue_manager/components/errors_panel.gd new file mode 100644 index 00000000..fab0c11c --- /dev/null +++ b/addons/dialogue_manager/components/errors_panel.gd @@ -0,0 +1,85 @@ +@tool +extends HBoxContainer + + +signal error_pressed(line_number) + + +const DialogueConstants = preload("../constants.gd") + + +@onready var error_button: Button = $ErrorButton +@onready var next_button: Button = $NextButton +@onready var count_label: Label = $CountLabel +@onready var previous_button: Button = $PreviousButton + +## The index of the current error being shown +var error_index: int = 0: + set(next_error_index): + error_index = wrap(next_error_index, 0, errors.size()) + show_error() + get: + return error_index + +## The list of all errors +var errors: Array = []: + set(next_errors): + errors = next_errors + self.error_index = 0 + get: + return errors + + +func _ready() -> void: + apply_theme() + hide() + + +## Set up colors and icons +func apply_theme() -> void: + error_button.add_theme_color_override("font_color", get_theme_color("error_color", "Editor")) + error_button.add_theme_color_override("font_hover_color", get_theme_color("error_color", "Editor")) + error_button.icon = get_theme_icon("StatusError", "EditorIcons") + previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons") + next_button.icon = get_theme_icon("ArrowRight", "EditorIcons") + + +## Move the error index to match a given line +func show_error_for_line_number(line_number: int) -> void: + for i in range(0, errors.size()): + if errors[i].line_number == line_number: + self.error_index = i + + +## Show the current error +func show_error() -> void: + if errors.size() == 0: + hide() + else: + show() + count_label.text = DialogueConstants.translate("n_of_n").format({ index = error_index + 1, total = errors.size() }) + var error = errors[error_index] + error_button.text = DialogueConstants.translate("errors.line_and_message").format({ line = error.line_number + 1, column = error.column_number, message = DialogueConstants.get_error_message(error.error) }) + if error.has("external_error"): + error_button.text += " " + DialogueConstants.get_error_message(error.external_error) + + +### Signals + + +func _on_errors_panel_theme_changed() -> void: + apply_theme() + + +func _on_error_button_pressed() -> void: + emit_signal("error_pressed", errors[error_index].line_number, errors[error_index].column_number) + + +func _on_previous_button_pressed() -> void: + self.error_index -= 1 + _on_error_button_pressed() + + +func _on_next_button_pressed() -> void: + self.error_index += 1 + _on_error_button_pressed() diff --git a/addons/dialogue_manager/components/errors_panel.tscn b/addons/dialogue_manager/components/errors_panel.tscn new file mode 100644 index 00000000..956552b1 --- /dev/null +++ b/addons/dialogue_manager/components/errors_panel.tscn @@ -0,0 +1,56 @@ +[gd_scene load_steps=4 format=3 uid="uid://cs8pwrxr5vxix"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/errors_panel.gd" id="1_nfm3c"] + +[sub_resource type="Image" id="Image_wy5pj"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 93, 93, 55, 255, 97, 97, 58, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 98, 98, 47, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 94, 94, 46, 255, 93, 93, 236, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_s6fxl"] +image = SubResource("Image_wy5pj") + +[node name="ErrorsPanel" type="HBoxContainer"] +visible = false +offset_right = 1024.0 +offset_bottom = 600.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_nfm3c") +metadata/_edit_layout_mode = 1 + +[node name="ErrorButton" type="Button" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_colors/font_hover_color = Color(0, 0, 0, 1) +theme_override_constants/h_separation = 3 +icon = SubResource("ImageTexture_s6fxl") +flat = true +alignment = 0 +text_overrun_behavior = 4 + +[node name="Spacer" type="Control" parent="."] +custom_minimum_size = Vector2(40, 0) +layout_mode = 2 + +[node name="PreviousButton" type="Button" parent="."] +layout_mode = 2 +icon = SubResource("ImageTexture_s6fxl") +flat = true + +[node name="CountLabel" type="Label" parent="."] +layout_mode = 2 + +[node name="NextButton" type="Button" parent="."] +layout_mode = 2 +icon = SubResource("ImageTexture_s6fxl") +flat = true + +[connection signal="pressed" from="ErrorButton" to="." method="_on_error_button_pressed"] +[connection signal="pressed" from="PreviousButton" to="." method="_on_previous_button_pressed"] +[connection signal="pressed" from="NextButton" to="." method="_on_next_button_pressed"] diff --git a/addons/dialogue_manager/components/files_list.gd b/addons/dialogue_manager/components/files_list.gd new file mode 100644 index 00000000..f5d16b81 --- /dev/null +++ b/addons/dialogue_manager/components/files_list.gd @@ -0,0 +1,144 @@ +@tool +extends VBoxContainer + + +signal file_selected(file_path: String) +signal file_popup_menu_requested(at_position: Vector2) +signal file_double_clicked(file_path: String) +signal file_middle_clicked(file_path: String) + + +const DialogueConstants = preload("../constants.gd") + +const MODIFIED_SUFFIX = "(*)" + + +@export var icon: Texture2D + +@onready var filter_edit: LineEdit = $FilterEdit +@onready var list: ItemList = $List + +var file_map: Dictionary = {} + +var current_file_path: String = "" + +var files: PackedStringArray = []: + set(next_files): + files = next_files + files.sort() + update_file_map() + apply_filter() + get: + return files + +var unsaved_files: Array[String] = [] + +var filter: String: + set(next_filter): + filter = next_filter + apply_filter() + get: + return filter + + +func _ready() -> void: + apply_theme() + + filter_edit.placeholder_text = DialogueConstants.translate("files_list.filter") + + +func select_file(file: String) -> void: + list.deselect_all() + for i in range(0, list.get_item_count()): + var item_text = list.get_item_text(i).replace(MODIFIED_SUFFIX, "") + if item_text == get_nice_file(file, item_text.count("/") + 1): + list.select(i) + + +func mark_file_as_unsaved(file: String, is_unsaved: bool) -> void: + if not file in unsaved_files and is_unsaved: + unsaved_files.append(file) + elif file in unsaved_files and not is_unsaved: + unsaved_files.erase(file) + apply_filter() + + +func update_file_map() -> void: + file_map = {} + for file in files: + var nice_file: String = get_nice_file(file) + + # See if a value with just the file name is already in the map + for key in file_map.keys(): + if file_map[key] == nice_file: + var bit_count = nice_file.count("/") + 2 + + var existing_nice_file = get_nice_file(key, bit_count) + nice_file = get_nice_file(file, bit_count) + + while nice_file == existing_nice_file: + bit_count += 1 + existing_nice_file = get_nice_file(key, bit_count) + nice_file = get_nice_file(file, bit_count) + + file_map[key] = existing_nice_file + + file_map[file] = nice_file + + +func get_nice_file(file_path: String, path_bit_count: int = 1) -> String: + var bits = file_path.replace("res://", "").replace(".dialogue", "").split("/") + bits = bits.slice(-path_bit_count) + return "/".join(bits) + + +func apply_filter() -> void: + list.clear() + for file in file_map.keys(): + if filter == "" or filter.to_lower() in file.to_lower(): + var nice_file = file_map[file] + if file in unsaved_files: + nice_file += MODIFIED_SUFFIX + var new_id := list.add_item(nice_file) + list.set_item_icon(new_id, icon) + + select_file(current_file_path) + + +func apply_theme() -> void: + if is_instance_valid(filter_edit): + filter_edit.right_icon = get_theme_icon("Search", "EditorIcons") + + +### Signals + + +func _on_theme_changed() -> void: + apply_theme() + + +func _on_filter_edit_text_changed(new_text: String) -> void: + self.filter = new_text + + +func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void: + if mouse_button_index == MOUSE_BUTTON_LEFT: + var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "") + var file = file_map.find_key(item_text) + 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: + var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "") + var file = file_map.find_key(item_text) + file_middle_clicked.emit(file) + + +func _on_list_item_activated(index: int) -> void: + var item_text = list.get_item_text(index).replace(MODIFIED_SUFFIX, "") + var file = file_map.find_key(item_text) + select_file(file) + file_double_clicked.emit(file) diff --git a/addons/dialogue_manager/components/files_list.tscn b/addons/dialogue_manager/components/files_list.tscn new file mode 100644 index 00000000..12bee0b8 --- /dev/null +++ b/addons/dialogue_manager/components/files_list.tscn @@ -0,0 +1,40 @@ +[gd_scene load_steps=5 format=3 uid="uid://dnufpcdrreva3"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/files_list.gd" id="1_cytii"] +[ext_resource type="Texture2D" uid="uid://d3lr2uas6ax8v" path="res://addons/dialogue_manager/assets/icon.svg" id="2_3ijx1"] + +[sub_resource type="Image" id="Image_h3jns"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 44, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 94, 94, 234, 255, 95, 95, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_44sbr"] +image = SubResource("Image_h3jns") + +[node name="FilesList" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_cytii") +icon = ExtResource("2_3ijx1") + +[node name="FilterEdit" type="LineEdit" parent="."] +layout_mode = 2 +placeholder_text = "Filter files" +clear_button_enabled = true +right_icon = SubResource("ImageTexture_44sbr") + +[node name="List" type="ItemList" parent="."] +layout_mode = 2 +size_flags_vertical = 3 + +[connection signal="theme_changed" from="." to="." method="_on_theme_changed"] +[connection signal="text_changed" from="FilterEdit" to="." method="_on_filter_edit_text_changed"] +[connection signal="item_activated" from="List" to="." method="_on_list_item_activated"] +[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"] diff --git a/addons/dialogue_manager/components/parse_result.gd b/addons/dialogue_manager/components/parse_result.gd new file mode 100644 index 00000000..d467cb9a --- /dev/null +++ b/addons/dialogue_manager/components/parse_result.gd @@ -0,0 +1,10 @@ +class_name DialogueManagerParseResult extends RefCounted + +var imported_paths: PackedStringArray = [] +var using_states: PackedStringArray = [] +var titles: Dictionary = {} +var character_names: PackedStringArray = [] +var first_title: String = "" +var lines: Dictionary = {} +var errors: Array[Dictionary] = [] +var raw_text: String = "" diff --git a/addons/dialogue_manager/components/parser.gd b/addons/dialogue_manager/components/parser.gd new file mode 100644 index 00000000..ad6e3acf --- /dev/null +++ b/addons/dialogue_manager/components/parser.gd @@ -0,0 +1,1780 @@ +@tool + +class_name DialogueManagerParser extends Object + + +const DialogueConstants = preload("../constants.gd") +const DialogueSettings = preload("../settings.gd") +const ResolvedLineData = preload("./resolved_line_data.gd") +const ResolvedTagData = preload("./resolved_tag_data.gd") +const DialogueManagerParseResult = preload("./parse_result.gd") + + +var IMPORT_REGEX: RegEx = RegEx.create_from_string("import \"(?<path>[^\"]+)\" as (?<prefix>[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+)") +var USING_REGEX: RegEx = RegEx.create_from_string("using (?<state>.*)") +var VALID_TITLE_REGEX: RegEx = RegEx.create_from_string("^[^\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\-\\=\\+\\{\\}\\[\\]\\;\\:\\\"\\'\\,\\.\\<\\>\\?\\/\\s]+$") +var BEGINS_WITH_NUMBER_REGEX: RegEx = RegEx.create_from_string("^\\d") +var TRANSLATION_REGEX: RegEx = RegEx.create_from_string("\\[ID:(?<tr>.*?)\\]") +var TAGS_REGEX: RegEx = RegEx.create_from_string("\\[#(?<tags>.*?)\\]") +var MUTATION_REGEX: RegEx = RegEx.create_from_string("(?<keyword>do|do!|set) (?<mutation>.*)") +var CONDITION_REGEX: RegEx = RegEx.create_from_string("(if|elif|while|else if) (?<condition>.*)") +var WRAPPED_CONDITION_REGEX: RegEx = RegEx.create_from_string("\\[if (?<condition>.*)\\]") +var REPLACEMENTS_REGEX: RegEx = RegEx.create_from_string("{{(.*?)}}") +var GOTO_REGEX: RegEx = RegEx.create_from_string("=><? (?<jump_to_title>.*)") +var INDENT_REGEX: RegEx = RegEx.create_from_string("^\\t+") +var INLINE_RANDOM_REGEX: RegEx = RegEx.create_from_string("\\[\\[(?<options>.*?)\\]\\]") +var INLINE_CONDITIONALS_REGEX: RegEx = RegEx.create_from_string("\\[if (?<condition>.+?)\\](?<body>.*?)\\[\\/if\\]") + +var TOKEN_DEFINITIONS: Dictionary = { + DialogueConstants.TOKEN_FUNCTION: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*\\("), + DialogueConstants.TOKEN_DICTIONARY_REFERENCE: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*\\["), + DialogueConstants.TOKEN_PARENS_OPEN: RegEx.create_from_string("^\\("), + DialogueConstants.TOKEN_PARENS_CLOSE: RegEx.create_from_string("^\\)"), + DialogueConstants.TOKEN_BRACKET_OPEN: RegEx.create_from_string("^\\["), + DialogueConstants.TOKEN_BRACKET_CLOSE: RegEx.create_from_string("^\\]"), + DialogueConstants.TOKEN_BRACE_OPEN: RegEx.create_from_string("^\\{"), + DialogueConstants.TOKEN_BRACE_CLOSE: RegEx.create_from_string("^\\}"), + DialogueConstants.TOKEN_COLON: RegEx.create_from_string("^:"), + DialogueConstants.TOKEN_COMPARISON: RegEx.create_from_string("^(==|<=|>=|<|>|!=|in )"), + DialogueConstants.TOKEN_ASSIGNMENT: RegEx.create_from_string("^(\\+=|\\-=|\\*=|/=|=)"), + DialogueConstants.TOKEN_NUMBER: RegEx.create_from_string("^\\-?\\d+(\\.\\d+)?"), + DialogueConstants.TOKEN_OPERATOR: RegEx.create_from_string("^(\\+|\\-|\\*|/|%)"), + DialogueConstants.TOKEN_COMMA: RegEx.create_from_string("^,"), + DialogueConstants.TOKEN_DOT: RegEx.create_from_string("^\\."), + DialogueConstants.TOKEN_STRING: RegEx.create_from_string("^(\".*?\"|\'.*?\')"), + DialogueConstants.TOKEN_NOT: RegEx.create_from_string("^(not( |$)|!)"), + DialogueConstants.TOKEN_AND_OR: RegEx.create_from_string("^(and|or)( |$)"), + DialogueConstants.TOKEN_VARIABLE: RegEx.create_from_string("^[a-zA-Z_][a-zA-Z_0-9]*"), + DialogueConstants.TOKEN_COMMENT: RegEx.create_from_string("^#.*"), + DialogueConstants.TOKEN_CONDITION: RegEx.create_from_string("^(if|elif|else)"), + DialogueConstants.TOKEN_BOOL: RegEx.create_from_string("^(true|false)") +} + +var WEIGHTED_RANDOM_SIBLINGS_REGEX: RegEx = RegEx.create_from_string("^\\%(?<weight>[\\d.]+)? ") + +var raw_lines: PackedStringArray = [] +var parent_stack: Array[String] = [] + +var parsed_lines: Dictionary = {} +var imported_paths: PackedStringArray = [] +var using_states: PackedStringArray = [] +var titles: Dictionary = {} +var character_names: PackedStringArray = [] +var first_title: String = "" +var errors: Array[Dictionary] = [] +var raw_text: String = "" + +var _imported_line_map: Dictionary = {} +var _imported_line_count: int = 0 + +var while_loopbacks: Array[String] = [] + + +## Parse some raw dialogue text. Returns a dictionary containing parse results +static func parse_string(string: String, path: String) -> DialogueManagerParseResult: + var parser: DialogueManagerParser = DialogueManagerParser.new() + var error: Error = parser.parse(string, path) + var data: DialogueManagerParseResult = parser.get_data() + parser.free() + + if error == OK: + return data + else: + return null + + +## Extract bbcode and other markers from a string +static func extract_markers_from_string(string: String) -> ResolvedLineData: + var parser: DialogueManagerParser = DialogueManagerParser.new() + var markers: ResolvedLineData = parser.extract_markers(string) + parser.free() + + return markers + + +## Parse some raw dialogue text. Returns a dictionary containing parse results +func parse(text: String, path: String) -> Error: + prepare(text, path) + raw_text = text + + # Parse all of the content + var known_translations = {} + + # Get list of known autoloads + var autoload_names: PackedStringArray = get_autoload_names() + + # Keep track of the last doc comment + var doc_comments: Array[String] = [] + + # Then parse all lines + for id in range(0, raw_lines.size()): + var raw_line: String = raw_lines[id] + + var line: Dictionary = { + id = str(id), + next_id = DialogueConstants.ID_NULL + } + + # Work out if we are inside a conditional or option or if we just + # indented back out of one + var indent_size: int = get_indent(raw_line) + if indent_size < parent_stack.size() and not is_line_empty(raw_line): + for _tab in range(0, parent_stack.size() - indent_size): + parent_stack.pop_back() + + # If we are indented then this line should know about its parent + if parent_stack.size() > 0: + line["parent_id"] = parent_stack.back() + + # Trim any indentation (now that we've calculated it) so we can check + # the begining of each line for its type + raw_line = raw_line.strip_edges(true, false) + + # Grab translations + var translation_key: String = extract_translation(raw_line) + if translation_key != "": + line["translation_key"] = translation_key + raw_line = raw_line.replace("[ID:%s]" % translation_key, "") + + # Check for each kind of line + + # Start shortcuts + if raw_line.begins_with("using "): + var using_match: RegExMatch = USING_REGEX.search(raw_line) + if "state" in using_match.names: + var using_state: String = using_match.strings[using_match.names.state].strip_edges() + if not using_state in autoload_names: + add_error(id, 0, DialogueConstants.ERR_UNKNOWN_USING) + elif not using_state in using_states: + using_states.append(using_state) + continue + + # Response + elif is_response_line(raw_line): + # Add any doc notes + line["notes"] = "\n".join(doc_comments) + doc_comments = [] + + parent_stack.append(str(id)) + line["type"] = DialogueConstants.TYPE_RESPONSE + + # Extract any #tags + var tag_data: ResolvedTagData = extract_tags(raw_line) + line["tags"] = tag_data.tags + raw_line = tag_data.line_without_tags + + if " [if " in raw_line: + line["condition"] = extract_condition(raw_line, true, indent_size) + if " =>" in raw_line: + line["next_id"] = extract_goto(raw_line) + if " =><" in raw_line: + # Because of when the return point needs to be known at runtime we need to split + # this line into two (otherwise the return point would be dependent on the balloon) + var goto_line: Dictionary = { + type = DialogueConstants.TYPE_GOTO, + next_id = extract_goto(raw_line), + next_id_after = find_next_line_after_responses(id), + is_snippet = true + } + parsed_lines[str(id) + ".1"] = goto_line + line["next_id"] = str(id) + ".1" + + # Make sure the added goto line can actually go to somewhere + if goto_line.next_id in [DialogueConstants.ID_ERROR, DialogueConstants.ID_ERROR_INVALID_TITLE, DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY]: + line["next_id"] = goto_line.next_id + + line["character"] = "" + line["character_replacements"] = [] as Array[Dictionary] + line["text"] = extract_response_prompt(raw_line) + + var previous_response_id = find_previous_response_id(id) + if parsed_lines.has(previous_response_id): + var previous_response = parsed_lines[previous_response_id] + # Add this response to the list on the first response so that it is the + # authority on what is in the list of responses + previous_response["responses"] = previous_response["responses"] + PackedStringArray([str(id)]) + else: + # No previous response so this is the first in the list + line["responses"] = PackedStringArray([str(id)]) + + line["next_id_after"] = find_next_line_after_responses(id) + + # If this response has no body then the next id is the next id after + if not line.has("next_id") or line.next_id == DialogueConstants.ID_NULL: + var next_nonempty_line_id = get_next_nonempty_line_id(id) + if next_nonempty_line_id != DialogueConstants.ID_NULL: + if get_indent(raw_lines[next_nonempty_line_id.to_int()]) <= indent_size: + line["next_id"] = line.next_id_after + else: + line["next_id"] = next_nonempty_line_id + + line["text_replacements"] = extract_dialogue_replacements(line.get("text"), indent_size + 2) + for replacement in line.text_replacements: + if replacement.has("error"): + add_error(id, replacement.index, replacement.error) + + # If this response has a character name in it then it will automatically be + # injected as a line of dialogue if the player selects it + var response_text: String = line.text.replace("\\:", "!ESCAPED_COLON!") + if ": " in response_text: + if DialogueSettings.get_setting("create_lines_for_responses_with_characters", true): + var first_child: Dictionary = { + type = DialogueConstants.TYPE_DIALOGUE, + next_id = line.next_id, + next_id_after = line.next_id_after, + text_replacements = line.text_replacements, + tags = line.tags, + translation_key = line.get("translation_key") + } + parse_response_character_and_text(id, response_text, first_child, indent_size, parsed_lines) + line["character"] = first_child.character + line["character_replacements"] = first_child.character_replacements + line["text"] = first_child.text + line["translation_key"] = first_child.translation_key + parsed_lines[str(id) + ".2"] = first_child + line["next_id"] = str(id) + ".2" + else: + parse_response_character_and_text(id, response_text, line, indent_size, parsed_lines) + else: + line["text"] = response_text.replace("!ESCAPED_COLON!", ":") + + # Title + elif is_title_line(raw_line): + line["type"] = DialogueConstants.TYPE_TITLE + if not raw_lines[id].begins_with("~"): + add_error(id, indent_size + 2, DialogueConstants.ERR_NESTED_TITLE) + else: + line["text"] = extract_title(raw_line) + # Titles can't have numbers as the first letter (unless they are external titles which get replaced with hashes) + if id >= _imported_line_count and BEGINS_WITH_NUMBER_REGEX.search(line.text): + add_error(id, 2, DialogueConstants.ERR_TITLE_BEGINS_WITH_NUMBER) + # Only import titles are allowed to have "/" in them + var valid_title = VALID_TITLE_REGEX.search(raw_line.replace("/", "").substr(2).strip_edges()) + if not valid_title: + add_error(id, 2, DialogueConstants.ERR_TITLE_INVALID_CHARACTERS) + + # Condition + elif is_condition_line(raw_line, false): + parent_stack.append(str(id)) + line["type"] = DialogueConstants.TYPE_CONDITION + line["condition"] = extract_condition(raw_line, false, indent_size) + line["next_id_after"] = find_next_line_after_conditions(id) + var next_sibling_id = find_next_condition_sibling(id) + line["next_conditional_id"] = next_sibling_id if is_valid_id(next_sibling_id) else line.next_id_after + + elif is_condition_line(raw_line, true): + parent_stack.append(str(id)) + line["type"] = DialogueConstants.TYPE_CONDITION + line["next_id_after"] = find_next_line_after_conditions(id) + line["next_conditional_id"] = line["next_id_after"] + + elif is_while_condition_line(raw_line): + parent_stack.append(str(id)) + line["type"] = DialogueConstants.TYPE_CONDITION + line["condition"] = extract_condition(raw_line, false, indent_size) + line["next_id_after"] = find_next_line_after_conditions(id) + while_loopbacks.append(find_last_line_within_conditions(id)) + line["next_conditional_id"] = line["next_id_after"] + + # Mutation + elif is_mutation_line(raw_line): + line["type"] = DialogueConstants.TYPE_MUTATION + line["mutation"] = extract_mutation(raw_line) + + # Goto + elif is_goto_line(raw_line): + line["type"] = DialogueConstants.TYPE_GOTO + + if raw_line.begins_with("%"): + apply_weighted_random(id, raw_line, indent_size, line) + + line["next_id"] = extract_goto(raw_line) + if is_goto_snippet_line(raw_line): + line["is_snippet"] = true + line["next_id_after"] = get_line_after_line(id, indent_size, line) + else: + line["is_snippet"] = false + + # Nested dialogue + elif is_nested_dialogue_line(raw_line, parsed_lines, raw_lines, indent_size): + var parent_line: Dictionary = parsed_lines.values().back() + var parent_indent_size: int = get_indent(raw_lines[parent_line.id.to_int()]) + var should_update_translation_key: bool = parent_line.translation_key == parent_line.text + var suffix: String = raw_line.strip_edges(true, false) + if suffix == "": + suffix = " " + parent_line["text"] += "\n" + suffix + parent_line["text_replacements"] = extract_dialogue_replacements(parent_line.text, parent_line.character.length() + 2 + parent_indent_size) + for replacement in parent_line.text_replacements: + if replacement.has("error"): + add_error(id, replacement.index, replacement.error) + + if should_update_translation_key: + parent_line["translation_key"] = parent_line.text + + parent_line["next_id"] = get_line_after_line(id, parent_indent_size, parent_line) + + # Ignore this line when checking for indent errors + remove_error(parent_line.id.to_int(), DialogueConstants.ERR_INVALID_INDENTATION) + + var next_line = raw_lines[parent_line.next_id.to_int()] + if not is_dialogue_line(next_line) and get_indent(next_line) >= indent_size: + add_error(parent_line.next_id.to_int(), indent_size, DialogueConstants.ERR_INVALID_INDENTATION) + + continue + + elif raw_line.strip_edges().begins_with("##"): + doc_comments.append(raw_line.replace("##", "").strip_edges()) + continue + + elif is_line_empty(raw_line) or is_import_line(raw_line): + continue + + # Regular dialogue + else: + # Remove escape character + if raw_line.begins_with("\\using"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\if"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\elif"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\else"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\while"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\-"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\~"): raw_line = raw_line.substr(1) + if raw_line.begins_with("\\=>"): raw_line = raw_line.substr(1) + + # Add any doc notes + line["notes"] = "\n".join(doc_comments) + doc_comments = [] + + # Work out any weighted random siblings + if raw_line.begins_with("%"): + apply_weighted_random(id, raw_line, indent_size, line) + raw_line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line, "") + + line["type"] = DialogueConstants.TYPE_DIALOGUE + + # Extract any tags before we process the line + var tag_data: ResolvedTagData = extract_tags(raw_line) + line["tags"] = tag_data.tags + raw_line = tag_data.line_without_tags + + var l = raw_line.replace("\\:", "!ESCAPED_COLON!") + if ": " in l: + var bits = Array(l.strip_edges().split(": ")) + line["character"] = bits.pop_front().strip_edges() + if not line["character"] in character_names: + character_names.append(line["character"]) + # You can use variables in the character's name + line["character_replacements"] = extract_dialogue_replacements(line.character, indent_size) + for replacement in line.character_replacements: + if replacement.has("error"): + add_error(id, replacement.index, replacement.error) + line["text"] = ": ".join(bits).replace("!ESCAPED_COLON!", ":") + else: + line["character"] = "" + line["character_replacements"] = [] as Array[Dictionary] + line["text"] = l.replace("!ESCAPED_COLON!", ":") + + line["text_replacements"] = extract_dialogue_replacements(line.text, line.character.length() + 2 + indent_size) + for replacement in line.text_replacements: + if replacement.has("error"): + add_error(id, replacement.index, replacement.error) + + # Unescape any newlines + line["text"] = line.text.replace("\\n", "\n").strip_edges() + + # Work out where to go after this line + if line.next_id == DialogueConstants.ID_NULL: + line["next_id"] = get_line_after_line(id, indent_size, line) + + # Check for duplicate translation keys + if line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]: + if line.has("translation_key"): + if known_translations.has(line.translation_key) and known_translations.get(line.translation_key) != line.text: + add_error(id, indent_size, DialogueConstants.ERR_DUPLICATE_ID) + else: + known_translations[line.translation_key] = line.text + else: + # Default translations key + if DialogueSettings.get_setting("missing_translations_are_errors", false): + add_error(id, indent_size, DialogueConstants.ERR_MISSING_ID) + else: + line["translation_key"] = line.text + + ## Error checking + + # Can't find goto + var jump_index: int = raw_line.find("=>") + match line.next_id: + DialogueConstants.ID_ERROR: + add_error(id, jump_index, DialogueConstants.ERR_UNKNOWN_TITLE) + DialogueConstants.ID_ERROR_INVALID_TITLE: + add_error(id, jump_index, DialogueConstants.ERR_INVALID_TITLE_REFERENCE) + DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY: + add_error(id, jump_index, DialogueConstants.ERR_TITLE_REFERENCE_HAS_NO_CONTENT) + + # Line after condition isn't indented once to the right + if line.type == DialogueConstants.TYPE_CONDITION: + if is_valid_id(line.next_id): + var next_line: String = raw_lines[line.next_id.to_int()] + var next_indent: int = get_indent(next_line) + if next_indent != indent_size + 1: + add_error(line.next_id.to_int(), next_indent, DialogueConstants.ERR_INVALID_INDENTATION) + else: + add_error(id, indent_size, DialogueConstants.ERR_INVALID_CONDITION_INDENTATION) + + # Line after normal line is indented to the right + elif line.type in [ + DialogueConstants.TYPE_TITLE, + DialogueConstants.TYPE_DIALOGUE, + DialogueConstants.TYPE_MUTATION, + DialogueConstants.TYPE_GOTO + ] and is_valid_id(line.next_id): + var next_line = raw_lines[line.next_id.to_int()] + if next_line != null and get_indent(next_line) > indent_size: + add_error(id, indent_size, DialogueConstants.ERR_INVALID_INDENTATION) + + # Parsing condition failed + if line.has("condition") and line.condition.has("error"): + add_error(id, line.condition.index, line.condition.error) + + # Parsing mutation failed + elif line.has("mutation") and line.mutation.has("error"): + add_error(id, line.mutation.index, line.mutation.error) + + # Line failed to parse at all + if line.get("type") == DialogueConstants.TYPE_UNKNOWN: + add_error(id, 0, DialogueConstants.ERR_UNKNOWN_LINE_SYNTAX) + + # If there are no titles then use the first actual line + if first_title == "" and not is_import_line(raw_line): + first_title = str(id) + + # If this line is the last line of a while loop, edit the id of its next line + if str(id) in while_loopbacks: + if is_goto_snippet_line(raw_line): + line["next_id_after"] = line["parent_id"] + elif is_condition_line(raw_line, true) or is_while_condition_line(raw_line): + line["next_conditional_id"] = line["parent_id"] + line["next_id_after"] = line["parent_id"] + elif is_goto_line(raw_line) or is_title_line(raw_line): + pass + else: + line["next_id"] = line["parent_id"] + + # Done! + parsed_lines[str(id)] = line + + # Assume the last line ends the dialogue + var last_line: Dictionary = parsed_lines.values()[parsed_lines.values().size() - 1] + if last_line.next_id == "": + last_line.next_id = DialogueConstants.ID_END + + if errors.size() > 0: + return ERR_PARSE_ERROR + + return OK + + +func get_data() -> DialogueManagerParseResult: + var data: DialogueManagerParseResult = DialogueManagerParseResult.new() + data.imported_paths = imported_paths + data.using_states = using_states + data.titles = titles + data.character_names = character_names + data.first_title = first_title + data.lines = parsed_lines + data.errors = errors + data.raw_text = raw_text + return data + + +## Get the last parse errors +func get_errors() -> Array[Dictionary]: + return errors + + +## Prepare the parser by collecting all lines and titles +func prepare(text: String, path: String, include_imported_titles_hashes: bool = true) -> void: + using_states = [] + errors = [] + imported_paths = [] + _imported_line_map = {} + while_loopbacks = [] + titles = {} + character_names = [] + first_title = "" + raw_lines = text.split("\n") + + # Work out imports + var known_imports: Dictionary = {} + + # Include the base file path so that we can get around circular dependencies + known_imports[path.hash()] = "." + + var imported_titles: Dictionary = {} + for id in range(0, raw_lines.size()): + var line = raw_lines[id] + if is_import_line(line): + var import_data = extract_import_path_and_name(line) + var import_hash: int = import_data.path.hash() + if import_data.size() > 0: + # Keep track of titles so we can add imported ones later + if str(import_hash) in imported_titles.keys(): + add_error(id, 0, DialogueConstants.ERR_FILE_ALREADY_IMPORTED) + if import_data.prefix in imported_titles.values(): + add_error(id, 0, DialogueConstants.ERR_DUPLICATE_IMPORT_NAME) + imported_titles[str(import_hash)] = import_data.prefix + + # Import the file content + if not known_imports.has(import_hash): + var error: Error = import_content(import_data.path, import_data.prefix, _imported_line_map, known_imports) + if error != OK: + add_error(id, 0, error) + + # Make a map so we can refer compiled lines to where they were imported from + if not _imported_line_map.has(import_hash): + _imported_line_map[import_hash] = { + hash = import_hash, + imported_on_line_number = id, + from_line = 0, + to_line = 0 + } + + var imported_content: String = "" + var cummulative_line_number: int = 0 + for item in _imported_line_map.values(): + item["from_line"] = cummulative_line_number + if known_imports.has(item.hash): + cummulative_line_number += known_imports[item.hash].split("\n").size() + item["to_line"] = cummulative_line_number + if known_imports.has(item.hash): + imported_content += known_imports[item.hash] + "\n" + + _imported_line_count = cummulative_line_number + 1 + + # Join it with the actual content + raw_lines = (imported_content + "\n" + text).split("\n") + + # Find all titles first + for id in range(0, raw_lines.size()): + if raw_lines[id].begins_with("~ "): + var title: String = extract_title(raw_lines[id]) + if title == "": + add_error(id, 2, DialogueConstants.ERR_EMPTY_TITLE) + elif titles.has(title): + add_error(id, 2, DialogueConstants.ERR_DUPLICATE_TITLE) + else: + var next_nonempty_line_id: String = get_next_nonempty_line_id(id) + if next_nonempty_line_id != DialogueConstants.ID_NULL: + titles[title] = next_nonempty_line_id + if "/" in title: + if include_imported_titles_hashes == false: + titles.erase(title) + var bits: PackedStringArray = title.split("/") + if imported_titles.has(bits[0]): + title = imported_titles[bits[0]] + "/" + bits[1] + titles[title] = next_nonempty_line_id + elif first_title == "": + first_title = next_nonempty_line_id + else: + titles[title] = DialogueConstants.ID_ERROR_TITLE_HAS_NO_BODY + + +func add_error(line_number: int, column_number: int, error: int) -> void: + # See if the error was in an imported file + for item in _imported_line_map.values(): + if line_number < item.to_line: + errors.append({ + line_number = item.imported_on_line_number, + column_number = 0, + error = DialogueConstants.ERR_ERRORS_IN_IMPORTED_FILE, + external_error = error, + external_line_number = line_number + }) + return + + # Otherwise, it's in this file + errors.append({ + line_number = line_number - _imported_line_count, + column_number = column_number, + error = error + }) + + +func remove_error(line_number: int, error: int) -> void: + for i in range(errors.size() - 1, -1, -1): + var err = errors[i] + var is_native_error = err.line_number == line_number - _imported_line_count and err.error == error + var is_external_error = err.get("external_line_number") == line_number and err.get("external_error") == error + if is_native_error or is_external_error: + errors.remove_at(i) + return + + +func is_import_line(line: String) -> bool: + return line.begins_with("import ") and " as " in line + + +func is_title_line(line: String) -> bool: + return line.strip_edges(true, false).begins_with("~ ") + + +func is_condition_line(line: String, include_else: bool = true) -> bool: + line = line.strip_edges(true, false) + if line.begins_with("if ") or line.begins_with("elif ") or line.begins_with("else if"): return true + if include_else and line.begins_with("else"): return true + return false + +func is_while_condition_line(line: String) -> bool: + line = line.strip_edges(true, false) + if line.begins_with("while "): return true + return false + + +func is_mutation_line(line: String) -> bool: + line = line.strip_edges(true, false) + return line.begins_with("do ") or line.begins_with("do! ") or line.begins_with("set ") + + +func is_goto_line(line: String) -> bool: + line = line.strip_edges(true, false) + line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(line, "") + return line.begins_with("=> ") or line.begins_with("=>< ") + + +func is_goto_snippet_line(line: String) -> bool: + line = WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(line.strip_edges(), "") + return line.begins_with("=>< ") + + +func is_nested_dialogue_line(raw_line: String, parsed_lines: Dictionary, raw_lines: PackedStringArray, indent_size: int) -> bool: + if parsed_lines.values().is_empty(): return false + if raw_line.strip_edges().begins_with("#"): return false + + var parent_line: Dictionary = parsed_lines.values().back() + if parent_line.type != DialogueConstants.TYPE_DIALOGUE: return false + if get_indent(raw_lines[parent_line.id.to_int()]) >= indent_size: return false + return true + + +func is_dialogue_line(line: String) -> bool: + if line == null: return false + if is_response_line(line): return false + if is_title_line(line): return false + if is_condition_line(line, true): return false + if is_mutation_line(line): return false + if is_goto_line(line): return false + return true + + +func is_response_line(line: String) -> bool: + return line.strip_edges(true, false).begins_with("- ") + + +func is_valid_id(id: String) -> bool: + return false if id in [DialogueConstants.ID_NULL, DialogueConstants.ID_ERROR, DialogueConstants.ID_END_CONVERSATION] else true + + +func is_line_empty(line: String) -> bool: + line = line.strip_edges() + + if line == "": return true + if line == "endif": return true + if line.begins_with("#"): return true + + return false + + +func get_line_after_line(id: int, indent_size: int, line: Dictionary) -> String: + # Unless the next line is an outdent we can assume it comes next + var next_nonempty_line_id = get_next_nonempty_line_id(id) + if next_nonempty_line_id != DialogueConstants.ID_NULL \ + and indent_size <= get_indent(raw_lines[next_nonempty_line_id.to_int()]): + # The next line is a title so we need the next nonempty line after that + if is_title_line(raw_lines[next_nonempty_line_id.to_int()]): + return get_next_nonempty_line_id(next_nonempty_line_id.to_int()) + # Otherwise it's a normal line + else: + return next_nonempty_line_id + # Otherwise, we grab the ID from the parents next ID after children + elif line.has("parent_id") and parsed_lines.has(line.parent_id): + return parsed_lines[line.parent_id].next_id_after + + else: + return DialogueConstants.ID_NULL + + +func get_indent(line: String) -> int: + var tabs: RegExMatch = INDENT_REGEX.search(line) + if tabs: + return tabs.get_string().length() + else: + return 0 + + +func get_next_nonempty_line_id(line_number: int) -> String: + for i in range(line_number + 1, raw_lines.size()): + if not is_line_empty(raw_lines[i]): + return str(i) + return DialogueConstants.ID_NULL + + +func find_previous_response_id(line_number: int) -> String: + var line = raw_lines[line_number] + var indent_size = get_indent(line) + + # Look back up the list to find the previous response + var last_found_response_id: String = str(line_number) + + for i in range(line_number - 1, -1, -1): + line = raw_lines[i] + + if is_line_empty(line): continue + + # If its a response at the same indent level then its a match + elif get_indent(line) == indent_size: + if line.strip_edges().begins_with("- "): + last_found_response_id = str(i) + else: + return last_found_response_id + + # Return itself if nothing was found + return last_found_response_id + + +func apply_weighted_random(id: int, raw_line: String, indent_size: int, line: Dictionary) -> void: + var weight: float = 1 + var found = WEIGHTED_RANDOM_SIBLINGS_REGEX.search(raw_line) + if found and found.names.has("weight"): + weight = found.strings[found.names.weight].to_float() + + # Look back up the list to find the first weighted random line in this group + var original_random_line: Dictionary = {} + for i in range(id, 0, -1): + # Ignore doc comment lines + if raw_lines[i].strip_edges().begins_with("##"): + continue + # Lines that aren't prefixed with the random token are a dead end + if not raw_lines[i].strip_edges().begins_with("%") or get_indent(raw_lines[i]) != indent_size: + break + # Make sure we group random dialogue and random lines separately + elif WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line.strip_edges(), "").begins_with("=") != WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_lines[i].strip_edges(), "").begins_with("="): + break + # Otherwise we've found the origin + elif parsed_lines.has(str(i)) and parsed_lines[str(i)].has("siblings"): + original_random_line = parsed_lines[str(i)] + break + + # Attach it to the original random line and work out where to go after the line + if original_random_line.size() > 0: + original_random_line["siblings"] += [{ weight = weight, id = str(id) }] + if original_random_line.type != DialogueConstants.TYPE_GOTO: + # Update the next line for all siblings (not goto lines, though, they manager their + # own next ID) + original_random_line["next_id"] = get_line_after_line(id, indent_size, line) + for sibling in original_random_line["siblings"]: + if sibling.id in parsed_lines: + parsed_lines[sibling.id]["next_id"] = original_random_line["next_id"] + line["next_id"] = original_random_line.next_id + # Or set up this line as the original + else: + line["siblings"] = [{ weight = weight, id = str(id) }] + line["next_id"] = get_line_after_line(id, indent_size, line) + + if line.next_id == DialogueConstants.ID_NULL: + line["next_id"] = DialogueConstants.ID_END + + +func find_next_condition_sibling(line_number: int) -> String: + var line = raw_lines[line_number] + var expected_indent = get_indent(line) + + # Look down the list and find an elif or else at the same indent level + for i in range(line_number + 1, raw_lines.size()): + line = raw_lines[i] + if is_line_empty(line): continue + + var l = line.strip_edges() + if l.begins_with("~ "): + return DialogueConstants.ID_END_CONVERSATION + + elif get_indent(line) < expected_indent: + return DialogueConstants.ID_NULL + + elif get_indent(line) == expected_indent: + # Found an if, which begins a different block + if l.begins_with("if"): + return DialogueConstants.ID_NULL + + # Found what we're looking for + elif (l.begins_with("elif ") or l.begins_with("else")): + return str(i) + + return DialogueConstants.ID_NULL + + +func find_next_line_after_conditions(line_number: int) -> String: + var line = raw_lines[line_number] + var expected_indent = get_indent(line) + + # Look down the list for the first non condition line at the same or less indent level + for i in range(line_number + 1, raw_lines.size()): + line = raw_lines[i] + + if is_line_empty(line): continue + + var line_indent = get_indent(line) + line = line.strip_edges() + + if is_title_line(line): + return get_next_nonempty_line_id(i) + + elif line_indent > expected_indent: + continue + + elif line_indent == expected_indent: + if line.begins_with("elif ") or line.begins_with("else"): + continue + else: + return str(i) + + elif line_indent < expected_indent: + # We have to check the parent of this block + for p in range(line_number - 1, -1, -1): + line = raw_lines[p] + + if is_line_empty(line): continue + + line_indent = get_indent(line) + if line_indent < expected_indent: + return parsed_lines[str(p)].get("next_id_after", DialogueConstants.ID_NULL) + + return DialogueConstants.ID_END_CONVERSATION + +func find_last_line_within_conditions(line_number: int) -> String: + var line = raw_lines[line_number] + var expected_indent = get_indent(line) + + var candidate = DialogueConstants.ID_NULL + + # Look down the list for the last line that has an indent level 1 more than this line + # Ending the search when you find a line the same or less indent level + for i in range(line_number + 1, raw_lines.size()): + line = raw_lines[i] + + if is_line_empty(line): continue + + var line_indent = get_indent(line) + line = line.strip_edges() + + if line_indent > expected_indent + 1: + continue + elif line_indent == (expected_indent + 1): + candidate = i + else: + break + + return str(candidate) + +func find_next_line_after_responses(line_number: int) -> String: + var line = raw_lines[line_number] + var expected_indent = get_indent(line) + + # Find the first line after this one that has a smaller indent that isn't another option + # If we hit the eof then we give up + for i in range(line_number + 1, raw_lines.size()): + line = raw_lines[i] + + if is_line_empty(line): continue + + var indent = get_indent(line) + + line = line.strip_edges() + + # We hit a title so the next line is a new start + if is_title_line(line): + return get_next_nonempty_line_id(i) + + # Another option + elif line.begins_with("- "): + if indent == expected_indent: + # ...at the same level so we continue + continue + elif indent < expected_indent: + # ...outdented so check the previous parent + var previous_parent = parent_stack[parent_stack.size() - 2] + if parsed_lines.has(str(previous_parent)): + return parsed_lines[str(previous_parent)].next_id_after + else: + return DialogueConstants.ID_NULL + + # We're at the end of a conditional so jump back up to see what's after it + elif line.begins_with("elif ") or line.begins_with("else"): + for p in range(line_number - 1, -1, -1): + line = raw_lines[p] + + if is_line_empty(line): continue + + var line_indent = get_indent(line) + if line_indent < expected_indent: + return parsed_lines[str(p)].next_id_after + + # Otherwise check the indent for an outdent + else: + line_number = i + line = raw_lines[line_number] + if get_indent(line) <= expected_indent: + return str(line_number) + + # EOF so must be end of conversation + return DialogueConstants.ID_END_CONVERSATION + + +## Get the names of any autoloads in the project +func get_autoload_names() -> PackedStringArray: + var autoloads: PackedStringArray = [] + + var project = ConfigFile.new() + project.load("res://project.godot") + if project.has_section("autoload"): + return Array(project.get_section_keys("autoload")).filter(func(key): return key != "DialogueManager") + + return autoloads + + +## Import content from another dialogue file or return an ERR +func import_content(path: String, prefix: String, imported_line_map: Dictionary, known_imports: Dictionary) -> Error: + if FileAccess.file_exists(path): + var file = FileAccess.open(path, FileAccess.READ) + var content: PackedStringArray = file.get_as_text().split("\n") + + var imported_titles: Dictionary = {} + + for index in range(0, content.size()): + var line = content[index] + if is_import_line(line): + var import = extract_import_path_and_name(line) + if import.size() > 0: + if not known_imports.has(import.path.hash()): + # Add an empty record into the keys just so we don't end up with cyclic dependencies + known_imports[import.path.hash()] = "" + if import_content(import.path, import.prefix, imported_line_map, known_imports) != OK: + return ERR_LINK_FAILED + + if not imported_line_map.has(import.path.hash()): + # Make a map so we can refer compiled lines to where they were imported from + imported_line_map[import.path.hash()] = { + hash = import.path.hash(), + imported_on_line_number = index, + from_line = 0, + to_line = 0 + } + + imported_titles[import.prefix] = import.path.hash() + + var origin_hash: int = -1 + for hash_value in known_imports.keys(): + if known_imports[hash_value] == ".": + origin_hash = hash_value + + # Replace any titles or jump points with references to the files they point to (event if they point to their own file) + for i in range(0, content.size()): + var line = content[i] + if is_title_line(line): + var title = extract_title(line) + if "/" in line: + var bits = title.split("/") + content[i] = "~ %s/%s" % [imported_titles[bits[0]], bits[1]] + else: + content[i] = "~ %s/%s" % [str(path.hash()), title] + + elif "=>< " in line: + var jump: String = line.substr(line.find("=>< ") + "=>< ".length()).strip_edges() + if "/" in jump: + var bits: PackedStringArray = jump.split("/") + var title_hash: int = imported_titles[bits[0]] + if title_hash == origin_hash: + content[i] = "%s=>< %s" % [line.split("=>< ")[0], bits[1]] + else: + content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], title_hash, bits[1]] + + elif not jump in ["END", "END!"]: + content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], str(path.hash()), jump] + + elif "=> " in line: + var jump: String = line.substr(line.find("=> ") + "=> ".length()).strip_edges() + if "/" in jump: + var bits: PackedStringArray = jump.split("/") + var title_hash: int = imported_titles[bits[0]] + if title_hash == origin_hash: + content[i] = "%s=> %s" % [line.split("=> ")[0], bits[1]] + else: + content[i] = "%s=> %s/%s" % [line.split("=> ")[0], title_hash, bits[1]] + + elif not jump in ["END", "END!"]: + content[i] = "%s=> %s/%s" % [line.split("=> ")[0], str(path.hash()), jump] + + imported_paths.append(path) + known_imports[path.hash()] = "\n".join(content) + "\n=> END\n" + return OK + else: + return ERR_FILE_NOT_FOUND + + +func extract_import_path_and_name(line: String) -> Dictionary: + var found: RegExMatch = IMPORT_REGEX.search(line) + if found: + return { + path = found.strings[found.names.path], + prefix = found.strings[found.names.prefix] + } + else: + return {} + + +func extract_title(line: String) -> String: + return line.substr(2).strip_edges() + + +func extract_translation(line: String) -> String: + # Find a static translation key, eg. [ID:something] + var found: RegExMatch = TRANSLATION_REGEX.search(line) + if found: + return found.strings[found.names.tr] + else: + return "" + + +func extract_response_prompt(line: String) -> String: + # Find just the text prompt from a response, ignoring any conditions or gotos + line = line.substr(2) + if " [if " in line: + line = line.substr(0, line.find(" [if ")) + if " =>" in line: + line = line.substr(0, line.find(" =>")) + + # Without the translation key if there is one + var translation_key: String = extract_translation(line) + if translation_key: + line = line.replace("[ID:%s]" % translation_key, "") + + return line.replace("\\n", "\n").strip_edges() + + +func parse_response_character_and_text(id: int, text: String, line: Dictionary, indent_size: int, parsed_lines: Dictionary) -> void: + var bits = Array(text.strip_edges().split(": ")) + line["character"] = bits.pop_front().strip_edges() + line["character_replacements"] = extract_dialogue_replacements(line.character, line.character.length() + 2 + indent_size) + for replacement in line.character_replacements: + if replacement.has("error"): + add_error(id, replacement.index, replacement.error) + + if not line["character"] in character_names: + character_names.append(line["character"]) + + line["text"] = ": ".join(bits).replace("!ESCAPED_COLON!", ":").strip_edges() + + if line.get("translation_key", null) == null: + line["translation_key"] = line.text + + +func extract_mutation(line: String) -> Dictionary: + var found: RegExMatch = MUTATION_REGEX.search(line) + + if not found: + return { + index = 0, + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + + if found.names.has("mutation"): + var expression: Array = tokenise(found.strings[found.names.mutation], DialogueConstants.TYPE_MUTATION, found.get_start("mutation")) + if expression.size() == 0: + return { + index = found.get_start("mutation"), + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + elif expression[0].type == DialogueConstants.TYPE_ERROR: + return { + index = expression[0].index, + error = expression[0].value + } + else: + return { + expression = expression, + is_blocking = not "!" in found.strings[found.names.keyword] + } + + else: + return { + index = found.get_start(), + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + + +func extract_condition(raw_line: String, is_wrapped: bool, index: int) -> Dictionary: + var condition: Dictionary = {} + + var regex: RegEx = WRAPPED_CONDITION_REGEX if is_wrapped else CONDITION_REGEX + var found: RegExMatch = regex.search(raw_line) + + if found == null: + return { + index = 0, + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + + var raw_condition: String = found.strings[found.names.condition] + var expression: Array = tokenise(raw_condition, DialogueConstants.TYPE_CONDITION, index + found.get_start("condition")) + + if expression.size() == 0: + return { + index = index + found.get_start("condition"), + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + elif expression[0].type == DialogueConstants.TYPE_ERROR: + return { + index = expression[0].index, + error = expression[0].value + } + else: + return { + expression = expression + } + + +func extract_dialogue_replacements(text: String, index: int) -> Array[Dictionary]: + var founds: Array[RegExMatch] = REPLACEMENTS_REGEX.search_all(text) + + if founds == null or founds.size() == 0: + return [] + + var replacements: Array[Dictionary] = [] + for found in founds: + var replacement: Dictionary = {} + var value_in_text: String = found.strings[1] + var expression: Array = tokenise(value_in_text, DialogueConstants.TYPE_DIALOGUE, index + found.get_start(1)) + if expression.size() == 0: + replacement = { + index = index + found.get_start(1), + error = DialogueConstants.ERR_INCOMPLETE_EXPRESSION + } + elif expression[0].type == DialogueConstants.TYPE_ERROR: + replacement = { + index = expression[0].index, + error = expression[0].value + } + else: + replacement = { + value_in_text = "{{%s}}" % value_in_text, + expression = expression + } + replacements.append(replacement) + + return replacements + + +func extract_goto(line: String) -> String: + var found: RegExMatch = GOTO_REGEX.search(line) + + if found == null: return DialogueConstants.ID_ERROR + + var title: String = found.strings[found.names.jump_to_title].strip_edges() + + if " " in title or title == "": + return DialogueConstants.ID_ERROR_INVALID_TITLE + + # "=> END!" means end the conversation + if title == "END!": + return DialogueConstants.ID_END_CONVERSATION + # "=> END" means end the current title (and go back to the previous one if there is one + # in the stack) + elif title == "END": + return DialogueConstants.ID_END + + elif titles.has(title): + return titles.get(title) + else: + return DialogueConstants.ID_ERROR + + +func extract_tags(line: String) -> ResolvedTagData: + var resolved_tags: PackedStringArray = [] + var tag_matches: Array[RegExMatch] = TAGS_REGEX.search_all(line) + for tag_match in tag_matches: + line = line.replace(tag_match.get_string(), "") + var tags = tag_match.get_string().replace("[#", "").replace("]", "").replace(", ", ",").split(",") + for tag in tags: + tag = tag.replace("#", "") + if not tag in resolved_tags: + resolved_tags.append(tag) + + return ResolvedTagData.new({ + tags = resolved_tags, + line_without_tags = line + }) + + +func extract_markers(line: String) -> ResolvedLineData: + var text: String = line + var pauses: Dictionary = {} + var speeds: Dictionary = {} + var mutations: Array[Array] = [] + var bbcodes: Array = [] + var time: String = "" + + # Remove any escaped brackets (ie. "\[") + var escaped_open_brackets: PackedInt32Array = [] + var escaped_close_brackets: PackedInt32Array = [] + for i in range(0, text.length() - 1): + if text.substr(i, 2) == "\\[": + text = text.substr(0, i) + "!" + text.substr(i + 2) + escaped_open_brackets.append(i) + elif text.substr(i, 2) == "\\]": + text = text.substr(0, i) + "!" + text.substr(i + 2) + escaped_close_brackets.append(i) + + # Extract all of the BB codes so that we know the actual text (we could do this easier with + # a RichTextLabel but then we'd need to await idle_frame which is annoying) + var bbcode_positions = find_bbcode_positions_in_string(text) + var accumulaive_length_offset = 0 + for position in bbcode_positions: + # Ignore our own markers + if position.code in ["wait", "speed", "/speed", "do", "do!", "set", "next", "if", "else", "/if"]: + continue + + bbcodes.append({ + bbcode = position.bbcode, + start = position.start, + offset_start = position.start - accumulaive_length_offset + }) + accumulaive_length_offset += position.bbcode.length() + + for bb in bbcodes: + text = text.substr(0, bb.offset_start) + text.substr(bb.offset_start + bb.bbcode.length()) + + # Now find any dialogue markers + var next_bbcode_position = find_bbcode_positions_in_string(text, false) + var limit = 0 + while next_bbcode_position.size() > 0 and limit < 1000: + limit += 1 + + var bbcode = next_bbcode_position[0] + + var index = bbcode.start + var code = bbcode.code + var raw_args = bbcode.raw_args + var args = {} + if code in ["do", "do!", "set"]: + args["value"] = extract_mutation("%s %s" % [code, raw_args]) + else: + # Could be something like: + # "=1.0" + # " rate=20 level=10" + if raw_args and raw_args[0] == "=": + raw_args = "value" + raw_args + for pair in raw_args.strip_edges().split(" "): + if "=" in pair: + var bits = pair.split("=") + args[bits[0]] = bits[1] + + match code: + "wait": + if pauses.has(index): + pauses[index] += args.get("value").to_float() + else: + pauses[index] = args.get("value").to_float() + "speed": + speeds[index] = args.get("value").to_float() + "/speed": + speeds[index] = 1.0 + "do", "do!", "set": + mutations.append([index, args.get("value")]) + "next": + time = args.get("value") if args.has("value") else "0" + + # Find any BB codes that are after this index and remove the length from their start + var length = bbcode.bbcode.length() + for bb in bbcodes: + if bb.offset_start > bbcode.start: + bb.offset_start -= length + bb.start -= length + + # Find any escaped brackets after this that need moving + for i in range(0, escaped_open_brackets.size()): + if escaped_open_brackets[i] > bbcode.start: + escaped_open_brackets[i] -= length + for i in range(0, escaped_close_brackets.size()): + if escaped_close_brackets[i] > bbcode.start: + escaped_close_brackets[i] -= length + + text = text.substr(0, index) + text.substr(index + length) + next_bbcode_position = find_bbcode_positions_in_string(text, false) + + # Put the BB Codes back in + for bb in bbcodes: + text = text.insert(bb.start, bb.bbcode) + + # Put the escaped brackets back in + for index in escaped_open_brackets: + text = text.left(index) + "[" + text.right(text.length() - index - 1) + for index in escaped_close_brackets: + text = text.left(index) + "]" + text.right(text.length() - index - 1) + + return ResolvedLineData.new({ + text = text, + pauses = pauses, + speeds = speeds, + mutations = mutations, + time = time + }) + + +func find_bbcode_positions_in_string(string: String, find_all: bool = true) -> Array[Dictionary]: + if not "[" in string: return [] + + var positions: Array[Dictionary] = [] + + var open_brace_count: int = 0 + var start: int = 0 + var bbcode: String = "" + var code: String = "" + var is_finished_code: bool = false + for i in range(0, string.length()): + if string[i] == "[": + if open_brace_count == 0: + start = i + bbcode = "" + code = "" + is_finished_code = false + open_brace_count += 1 + + else: + if not is_finished_code and (string[i].to_upper() != string[i] or string[i] == "/" or string[i] == "!"): + code += string[i] + else: + is_finished_code = true + + if open_brace_count > 0: + bbcode += string[i] + + if string[i] == "]": + open_brace_count -= 1 + if open_brace_count == 0 and not code in ["if", "else", "/if"]: + positions.append({ + bbcode = bbcode, + code = code, + start = start, + raw_args = bbcode.substr(code.length() + 1, bbcode.length() - code.length() - 2).strip_edges() + }) + + if not find_all: + return positions + + return positions + + +func tokenise(text: String, line_type: String, index: int) -> Array: + var tokens: Array[Dictionary] = [] + var limit: int = 0 + while text.strip_edges() != "" and limit < 1000: + limit += 1 + var found = find_match(text) + if found.size() > 0: + tokens.append({ + index = index, + type = found.type, + value = found.value + }) + index += found.value.length() + text = found.remaining_text + elif text.begins_with(" "): + index += 1 + text = text.substr(1) + else: + return build_token_tree_error(DialogueConstants.ERR_INVALID_EXPRESSION, index) + + return build_token_tree(tokens, line_type, "")[0] + + +func build_token_tree_error(error: int, index: int) -> Array: + return [{ type = DialogueConstants.TOKEN_ERROR, value = error, index = index }] + + +func build_token_tree(tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Array: + var tree: Array[Dictionary] = [] + var limit = 0 + while tokens.size() > 0 and limit < 1000: + limit += 1 + var token = tokens.pop_front() + + var error = check_next_token(token, tokens, line_type, expected_close_token) + if error != OK: + return [build_token_tree_error(error, token.index), tokens] + + match token.type: + DialogueConstants.TOKEN_FUNCTION: + var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_PARENS_CLOSE) + + if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR: + return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens] + + tree.append({ + type = DialogueConstants.TOKEN_FUNCTION, + # Consume the trailing "(" + function = token.value.substr(0, token.value.length() - 1), + value = tokens_to_list(sub_tree[0]) + }) + tokens = sub_tree[1] + + DialogueConstants.TOKEN_DICTIONARY_REFERENCE: + var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACKET_CLOSE) + + if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR: + return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens] + + var args = tokens_to_list(sub_tree[0]) + if args.size() != 1: + return [build_token_tree_error(DialogueConstants.ERR_INVALID_INDEX, token.index), tokens] + + tree.append({ + type = DialogueConstants.TOKEN_DICTIONARY_REFERENCE, + # Consume the trailing "[" + variable = token.value.substr(0, token.value.length() - 1), + value = args[0] + }) + tokens = sub_tree[1] + + DialogueConstants.TOKEN_BRACE_OPEN: + var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACE_CLOSE) + + if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR: + return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens] + + var t = sub_tree[0] + for i in range(0, t.size() - 2): + # Convert Lua style dictionaries to string keys + if t[i].type == DialogueConstants.TOKEN_VARIABLE and t[i+1].type == DialogueConstants.TOKEN_ASSIGNMENT: + t[i].type = DialogueConstants.TOKEN_STRING + t[i+1].type = DialogueConstants.TOKEN_COLON + t[i+1].erase("value") + + tree.append({ + type = DialogueConstants.TOKEN_DICTIONARY, + value = tokens_to_dictionary(sub_tree[0]) + }) + + tokens = sub_tree[1] + + DialogueConstants.TOKEN_BRACKET_OPEN: + var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_BRACKET_CLOSE) + + if sub_tree[0].size() > 0 and sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR: + return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens] + + var type = DialogueConstants.TOKEN_ARRAY + var value = tokens_to_list(sub_tree[0]) + + # See if this is referencing a nested dictionary value + if tree.size() > 0: + var previous_token = tree[tree.size() - 1] + if previous_token.type in [DialogueConstants.TOKEN_DICTIONARY_REFERENCE, DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE]: + type = DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE + value = value[0] + + tree.append({ + type = type, + value = value + }) + tokens = sub_tree[1] + + DialogueConstants.TOKEN_PARENS_OPEN: + var sub_tree = build_token_tree(tokens, line_type, DialogueConstants.TOKEN_PARENS_CLOSE) + + if sub_tree[0][0].type == DialogueConstants.TOKEN_ERROR: + return [build_token_tree_error(sub_tree[0][0].value, token.index), tokens] + + tree.append({ + type = DialogueConstants.TOKEN_GROUP, + value = sub_tree[0] + }) + tokens = sub_tree[1] + + DialogueConstants.TOKEN_PARENS_CLOSE, \ + DialogueConstants.TOKEN_BRACE_CLOSE, \ + DialogueConstants.TOKEN_BRACKET_CLOSE: + if token.type != expected_close_token: + return [build_token_tree_error(DialogueConstants.ERR_UNEXPECTED_CLOSING_BRACKET, token.index), tokens] + + return [tree, tokens] + + DialogueConstants.TOKEN_NOT: + # Double nots negate each other + if tokens.size() > 0 and tokens.front().type == DialogueConstants.TOKEN_NOT: + tokens.pop_front() + else: + tree.append({ + type = token.type + }) + + DialogueConstants.TOKEN_COMMA, \ + DialogueConstants.TOKEN_COLON, \ + DialogueConstants.TOKEN_DOT: + tree.append({ + type = token.type + }) + + DialogueConstants.TOKEN_COMPARISON, \ + DialogueConstants.TOKEN_ASSIGNMENT, \ + DialogueConstants.TOKEN_OPERATOR, \ + DialogueConstants.TOKEN_AND_OR, \ + DialogueConstants.TOKEN_VARIABLE: \ + tree.append({ + type = token.type, + value = token.value.strip_edges() + }) + + DialogueConstants.TOKEN_STRING: + tree.append({ + type = token.type, + value = token.value.substr(1, token.value.length() - 2) + }) + + DialogueConstants.TOKEN_CONDITION: + return [build_token_tree_error(DialogueConstants.ERR_UNEXPECTED_CONDITION, token.index), token] + + DialogueConstants.TOKEN_BOOL: + tree.append({ + type = token.type, + value = token.value.to_lower() == "true" + }) + + DialogueConstants.TOKEN_NUMBER: + var value = token.value.to_float() if "." in token.value else token.value.to_int() + # If previous token is a number and this one is a negative number then + # inject a minus operator token in between them. + if tree.size() > 0 and token.value.begins_with("-") and tree[tree.size() - 1].type == DialogueConstants.TOKEN_NUMBER: + tree.append(({ + type = DialogueConstants.TOKEN_OPERATOR, + value = "-" + })) + tree.append({ + type = token.type, + value = -1 * value + }) + else: + tree.append({ + type = token.type, + value = value + }) + + if expected_close_token != "": + var index: int = tokens[0].index if tokens.size() > 0 else 0 + return [build_token_tree_error(DialogueConstants.ERR_MISSING_CLOSING_BRACKET, index), tokens] + + return [tree, tokens] + + +func check_next_token(token: Dictionary, next_tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Error: + var next_token: Dictionary = { type = null } + if next_tokens.size() > 0: + next_token = next_tokens.front() + + # Guard for assigning in a condition. If the assignment token isn't inside a Lua dictionary + # then it's an unexpected assignment in a condition line. + if token.type == DialogueConstants.TOKEN_ASSIGNMENT and line_type == DialogueConstants.TYPE_CONDITION and not next_tokens.any(func(t): return t.type == expected_close_token): + return DialogueConstants.ERR_UNEXPECTED_ASSIGNMENT + + # Special case for a negative number after this one + if token.type == DialogueConstants.TOKEN_NUMBER and next_token.type == DialogueConstants.TOKEN_NUMBER and next_token.value.begins_with("-"): + return OK + + var expected_token_types = [] + var unexpected_token_types = [] + match token.type: + DialogueConstants.TOKEN_FUNCTION, \ + DialogueConstants.TOKEN_PARENS_OPEN: + unexpected_token_types = [ + null, + DialogueConstants.TOKEN_COMMA, + DialogueConstants.TOKEN_COLON, + DialogueConstants.TOKEN_COMPARISON, + DialogueConstants.TOKEN_ASSIGNMENT, + DialogueConstants.TOKEN_OPERATOR, + DialogueConstants.TOKEN_AND_OR, + DialogueConstants.TOKEN_DOT + ] + + DialogueConstants.TOKEN_BRACKET_CLOSE: + unexpected_token_types = [ + DialogueConstants.TOKEN_NOT, + DialogueConstants.TOKEN_BOOL, + DialogueConstants.TOKEN_STRING, + DialogueConstants.TOKEN_NUMBER, + DialogueConstants.TOKEN_VARIABLE + ] + + DialogueConstants.TOKEN_BRACE_OPEN: + expected_token_types = [ + DialogueConstants.TOKEN_STRING, + DialogueConstants.TOKEN_VARIABLE, + DialogueConstants.TOKEN_NUMBER, + DialogueConstants.TOKEN_BRACE_CLOSE + ] + + DialogueConstants.TOKEN_PARENS_CLOSE, \ + DialogueConstants.TOKEN_BRACE_CLOSE: + unexpected_token_types = [ + DialogueConstants.TOKEN_NOT, + DialogueConstants.TOKEN_ASSIGNMENT, + DialogueConstants.TOKEN_BOOL, + DialogueConstants.TOKEN_STRING, + DialogueConstants.TOKEN_NUMBER, + DialogueConstants.TOKEN_VARIABLE + ] + + DialogueConstants.TOKEN_COMPARISON, \ + DialogueConstants.TOKEN_OPERATOR, \ + DialogueConstants.TOKEN_COMMA, \ + DialogueConstants.TOKEN_DOT, \ + DialogueConstants.TOKEN_NOT, \ + DialogueConstants.TOKEN_AND_OR, \ + DialogueConstants.TOKEN_DICTIONARY_REFERENCE: + unexpected_token_types = [ + null, + DialogueConstants.TOKEN_COMMA, + DialogueConstants.TOKEN_COLON, + DialogueConstants.TOKEN_COMPARISON, + DialogueConstants.TOKEN_ASSIGNMENT, + DialogueConstants.TOKEN_OPERATOR, + DialogueConstants.TOKEN_AND_OR, + DialogueConstants.TOKEN_PARENS_CLOSE, + DialogueConstants.TOKEN_BRACE_CLOSE, + DialogueConstants.TOKEN_BRACKET_CLOSE, + DialogueConstants.TOKEN_DOT + ] + + DialogueConstants.TOKEN_COLON: + unexpected_token_types = [ + DialogueConstants.TOKEN_COMMA, + DialogueConstants.TOKEN_COLON, + DialogueConstants.TOKEN_COMPARISON, + DialogueConstants.TOKEN_ASSIGNMENT, + DialogueConstants.TOKEN_OPERATOR, + DialogueConstants.TOKEN_AND_OR, + DialogueConstants.TOKEN_PARENS_CLOSE, + DialogueConstants.TOKEN_BRACE_CLOSE, + DialogueConstants.TOKEN_BRACKET_CLOSE, + DialogueConstants.TOKEN_DOT + ] + + DialogueConstants.TOKEN_BOOL, \ + DialogueConstants.TOKEN_STRING, \ + DialogueConstants.TOKEN_NUMBER: + unexpected_token_types = [ + DialogueConstants.TOKEN_NOT, + DialogueConstants.TOKEN_ASSIGNMENT, + DialogueConstants.TOKEN_BOOL, + DialogueConstants.TOKEN_STRING, + DialogueConstants.TOKEN_NUMBER, + DialogueConstants.TOKEN_VARIABLE, + DialogueConstants.TOKEN_FUNCTION, + DialogueConstants.TOKEN_PARENS_OPEN, + DialogueConstants.TOKEN_BRACE_OPEN, + DialogueConstants.TOKEN_BRACKET_OPEN + ] + + DialogueConstants.TOKEN_VARIABLE: + unexpected_token_types = [ + DialogueConstants.TOKEN_NOT, + DialogueConstants.TOKEN_BOOL, + DialogueConstants.TOKEN_STRING, + DialogueConstants.TOKEN_NUMBER, + DialogueConstants.TOKEN_VARIABLE, + DialogueConstants.TOKEN_FUNCTION, + DialogueConstants.TOKEN_PARENS_OPEN, + DialogueConstants.TOKEN_BRACE_OPEN, + DialogueConstants.TOKEN_BRACKET_OPEN + ] + + if (expected_token_types.size() > 0 and not next_token.type in expected_token_types or unexpected_token_types.size() > 0 and next_token.type in unexpected_token_types): + match next_token.type: + null: + return DialogueConstants.ERR_UNEXPECTED_END_OF_EXPRESSION + + DialogueConstants.TOKEN_FUNCTION: + return DialogueConstants.ERR_UNEXPECTED_FUNCTION + + DialogueConstants.TOKEN_PARENS_OPEN, \ + DialogueConstants.TOKEN_PARENS_CLOSE: + return DialogueConstants.ERR_UNEXPECTED_BRACKET + + DialogueConstants.TOKEN_COMPARISON, \ + DialogueConstants.TOKEN_ASSIGNMENT, \ + DialogueConstants.TOKEN_OPERATOR, \ + DialogueConstants.TOKEN_NOT, \ + DialogueConstants.TOKEN_AND_OR: + return DialogueConstants.ERR_UNEXPECTED_OPERATOR + + DialogueConstants.TOKEN_COMMA: + return DialogueConstants.ERR_UNEXPECTED_COMMA + DialogueConstants.TOKEN_COLON: + return DialogueConstants.ERR_UNEXPECTED_COLON + DialogueConstants.TOKEN_DOT: + return DialogueConstants.ERR_UNEXPECTED_DOT + + DialogueConstants.TOKEN_BOOL: + return DialogueConstants.ERR_UNEXPECTED_BOOLEAN + DialogueConstants.TOKEN_STRING: + return DialogueConstants.ERR_UNEXPECTED_STRING + DialogueConstants.TOKEN_NUMBER: + return DialogueConstants.ERR_UNEXPECTED_NUMBER + DialogueConstants.TOKEN_VARIABLE: + return DialogueConstants.ERR_UNEXPECTED_VARIABLE + + return DialogueConstants.ERR_INVALID_EXPRESSION + + return OK + + +func tokens_to_list(tokens: Array[Dictionary]) -> Array[Array]: + var list: Array[Array] = [] + var current_item: Array[Dictionary] = [] + for token in tokens: + if token.type == DialogueConstants.TOKEN_COMMA: + list.append(current_item) + current_item = [] + else: + current_item.append(token) + + if current_item.size() > 0: + list.append(current_item) + + return list + + +func tokens_to_dictionary(tokens: Array[Dictionary]) -> Dictionary: + var dictionary = {} + for i in range(0, tokens.size()): + if tokens[i].type == DialogueConstants.TOKEN_COLON: + if tokens.size() == i + 2: + dictionary[tokens[i-1]] = tokens[i+1] + else: + dictionary[tokens[i-1]] = { type = DialogueConstants.TOKEN_GROUP, value = tokens.slice(i+1) } + + return dictionary + + +func find_match(input: String) -> Dictionary: + for key in TOKEN_DEFINITIONS.keys(): + var regex = TOKEN_DEFINITIONS.get(key) + var found = regex.search(input) + if found: + return { + type = key, + remaining_text = input.substr(found.strings[0].length()), + value = found.strings[0] + } + + return {} diff --git a/addons/dialogue_manager/components/resolved_line_data.gd b/addons/dialogue_manager/components/resolved_line_data.gd new file mode 100644 index 00000000..10735862 --- /dev/null +++ b/addons/dialogue_manager/components/resolved_line_data.gd @@ -0,0 +1,15 @@ +extends RefCounted + +var text: String = "" +var pauses: Dictionary = {} +var speeds: Dictionary = {} +var mutations: Array[Array] = [] +var time: String = "" + + +func _init(data: Dictionary) -> void: + text = data.text + pauses = data.pauses + speeds = data.speeds + mutations = data.mutations + time = data.time diff --git a/addons/dialogue_manager/components/resolved_tag_data.gd b/addons/dialogue_manager/components/resolved_tag_data.gd new file mode 100644 index 00000000..728cc423 --- /dev/null +++ b/addons/dialogue_manager/components/resolved_tag_data.gd @@ -0,0 +1,10 @@ +extends RefCounted + + +var tags: PackedStringArray = [] +var line_without_tags: String = "" + + +func _init(data: Dictionary) -> void: + tags = data.tags + line_without_tags = data.line_without_tags diff --git a/addons/dialogue_manager/components/search_and_replace.gd b/addons/dialogue_manager/components/search_and_replace.gd new file mode 100644 index 00000000..e61e3caa --- /dev/null +++ b/addons/dialogue_manager/components/search_and_replace.gd @@ -0,0 +1,209 @@ +@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() + + previous_button.tooltip_text = DialogueConstants.translate("search.previous") + next_button.tooltip_text = DialogueConstants.translate("search.next") + match_case_button.text = DialogueConstants.translate("search.match_case") + $Search/ReplaceCheckButton.text = DialogueConstants.translate("search.toggle_replace") + replace_button.text = DialogueConstants.translate("search.replace") + replace_all_button.text = DialogueConstants.translate("search.replace_all") + $Replace/ReplaceLabel.text = DialogueConstants.translate("search.replace_with") + + self.result_index = -1 + + replace_panel.hide() + replace_button.disabled = true + replace_all_button.disabled = true + + hide() + + +func focus_line_edit() -> void: + input.grab_focus() + input.select_all() + + +func apply_theme() -> void: + if is_instance_valid(previous_button): + previous_button.icon = get_theme_icon("ArrowLeft", "EditorIcons") + if is_instance_valid(next_button): + next_button.icon = get_theme_icon("ArrowRight", "EditorIcons") + + +# Find text in the code +func search(text: String = "", default_result_index: int = 0) -> void: + results.clear() + + if text == "": + text = input.text + + var lines = code_edit.text.split("\n") + for line_number in range(0, lines.size()): + var line = lines[line_number] + + var column = find_in_line(line, text, 0) + while column > -1: + results.append([line_number, column, text.length()]) + column = find_in_line(line, text, column + 1) + + if results.size() > 0: + replace_button.disabled = false + replace_all_button.disabled = false + else: + replace_button.disabled = true + replace_all_button.disabled = true + + self.result_index = clamp(default_result_index, 0, results.size() - 1) + + +# Find text in a string and match case if requested +func find_in_line(line: String, text: String, from_index: int = 0) -> int: + if match_case_button.button_pressed: + return line.find(text, from_index) + else: + return line.findn(text, from_index) + + +### Signals + + +func _on_text_edit_gui_input(event: InputEvent) -> void: + if event is InputEventKey and event.is_pressed(): + match event.as_text(): + "Ctrl+F", "Command+F": + open_requested.emit() + "Ctrl+Shift+R", "Command+Shift+R": + replace_check_button.set_pressed(true) + open_requested.emit() + + +func _on_text_edit_text_changed() -> void: + results.clear() + + +func _on_search_and_replace_theme_changed() -> void: + apply_theme() + + +func _on_input_text_changed(new_text: String) -> void: + search(new_text) + + +func _on_previous_button_pressed() -> void: + self.result_index = wrapi(result_index - 1, 0, results.size()) + + +func _on_next_button_pressed() -> void: + self.result_index = wrapi(result_index + 1, 0, results.size()) + + +func _on_search_and_replace_visibility_changed() -> void: + if is_instance_valid(input): + if visible: + input.grab_focus() + var selection = code_edit.get_selected_text() + if input.text == "" and selection != "": + input.text = selection + search(selection) + else: + search() + else: + input.text = "" + + +func _on_input_gui_input(event: InputEvent) -> void: + if event is InputEventKey and event.is_pressed(): + match event.as_text(): + "Enter": + search(input.text) + "Escape": + emit_signal("close_requested") + + +func _on_replace_button_pressed() -> void: + if result_index == -1: return + + # Replace the selection at result index + var r: Array = results[result_index] + var lines: PackedStringArray = code_edit.text.split("\n") + var line: String = lines[r[0]] + line = line.substr(0, r[1]) + replace_input.text + line.substr(r[1] + r[2]) + lines[r[0]] = line + code_edit.text = "\n".join(lines) + search(input.text, result_index) + code_edit.text_changed.emit() + + +func _on_replace_all_button_pressed() -> void: + if match_case_button.button_pressed: + code_edit.text = code_edit.text.replace(input.text, replace_input.text) + else: + code_edit.text = code_edit.text.replacen(input.text, replace_input.text) + search() + code_edit.text_changed.emit() + + +func _on_replace_check_button_toggled(button_pressed: bool) -> void: + replace_panel.visible = button_pressed + if button_pressed: + replace_input.grab_focus() + + +func _on_input_focus_entered() -> void: + if results.size() == 0: + search() + else: + self.result_index = result_index + + +func _on_match_case_check_box_toggled(button_pressed: bool) -> void: + search() diff --git a/addons/dialogue_manager/components/search_and_replace.tscn b/addons/dialogue_manager/components/search_and_replace.tscn new file mode 100644 index 00000000..2e26c562 --- /dev/null +++ b/addons/dialogue_manager/components/search_and_replace.tscn @@ -0,0 +1,86 @@ +[gd_scene load_steps=2 format=3 uid="uid://gr8nakpbrhby"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/search_and_replace.gd" id="1_8oj1f"] + +[node name="SearchAndReplace" type="VBoxContainer"] +visible = false +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 31.0 +grow_horizontal = 2 +size_flags_horizontal = 3 +script = ExtResource("1_8oj1f") + +[node name="Search" type="HBoxContainer" parent="."] +layout_mode = 2 + +[node name="Input" type="LineEdit" parent="Search"] +layout_mode = 2 +size_flags_horizontal = 3 +metadata/_edit_use_custom_anchors = true + +[node name="MatchCaseCheckBox" type="CheckBox" parent="Search"] +layout_mode = 2 +text = "Match case" + +[node name="VSeparator" type="VSeparator" parent="Search"] +layout_mode = 2 + +[node name="PreviousButton" type="Button" parent="Search"] +layout_mode = 2 +tooltip_text = "Previous" +flat = true + +[node name="ResultLabel" type="Label" parent="Search"] +layout_mode = 2 +text = "0 of 0" + +[node name="NextButton" type="Button" parent="Search"] +layout_mode = 2 +tooltip_text = "Next" +flat = true + +[node name="VSeparator2" type="VSeparator" parent="Search"] +layout_mode = 2 + +[node name="ReplaceCheckButton" type="CheckButton" parent="Search"] +layout_mode = 2 +text = "Replace" + +[node name="Replace" type="HBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="ReplaceLabel" type="Label" parent="Replace"] +layout_mode = 2 +text = "Replace with:" + +[node name="Input" type="LineEdit" parent="Replace"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ReplaceButton" type="Button" parent="Replace"] +layout_mode = 2 +disabled = true +text = "Replace" +flat = true + +[node name="ReplaceAllButton" type="Button" parent="Replace"] +layout_mode = 2 +disabled = true +text = "Replace all" +flat = true + +[connection signal="theme_changed" from="." to="." method="_on_search_and_replace_theme_changed"] +[connection signal="visibility_changed" from="." to="." method="_on_search_and_replace_visibility_changed"] +[connection signal="focus_entered" from="Search/Input" to="." method="_on_input_focus_entered"] +[connection signal="gui_input" from="Search/Input" to="." method="_on_input_gui_input"] +[connection signal="text_changed" from="Search/Input" to="." method="_on_input_text_changed"] +[connection signal="toggled" from="Search/MatchCaseCheckBox" to="." method="_on_match_case_check_box_toggled"] +[connection signal="pressed" from="Search/PreviousButton" to="." method="_on_previous_button_pressed"] +[connection signal="pressed" from="Search/NextButton" to="." method="_on_next_button_pressed"] +[connection signal="toggled" from="Search/ReplaceCheckButton" to="." method="_on_replace_check_button_toggled"] +[connection signal="focus_entered" from="Replace/Input" to="." method="_on_input_focus_entered"] +[connection signal="gui_input" from="Replace/Input" to="." method="_on_input_gui_input"] +[connection signal="pressed" from="Replace/ReplaceButton" to="." method="_on_replace_button_pressed"] +[connection signal="pressed" from="Replace/ReplaceAllButton" to="." method="_on_replace_all_button_pressed"] diff --git a/addons/dialogue_manager/components/title_list.gd b/addons/dialogue_manager/components/title_list.gd new file mode 100644 index 00000000..673e460c --- /dev/null +++ b/addons/dialogue_manager/components/title_list.gd @@ -0,0 +1,67 @@ +@tool +extends VBoxContainer + +signal title_selected(title: String) + + +const DialogueConstants = preload("../constants.gd") + + +@onready var filter_edit: LineEdit = $FilterEdit +@onready var list: ItemList = $List + +var titles: PackedStringArray: + set(next_titles): + titles = next_titles + apply_filter() + get: + return titles + +var filter: String: + set(next_filter): + filter = next_filter + apply_filter() + get: + return filter + + +func _ready() -> void: + apply_theme() + + filter_edit.placeholder_text = DialogueConstants.translate("titles_list.filter") + + +func select_title(title: String) -> void: + list.deselect_all() + for i in range(0, list.get_item_count()): + if list.get_item_text(i) == title.strip_edges(): + list.select(i) + + +func apply_filter() -> void: + list.clear() + for title in titles: + if filter == "" or filter.to_lower() in title.to_lower(): + list.add_item(title.strip_edges()) + + +func apply_theme() -> void: + if is_instance_valid(filter_edit): + filter_edit.right_icon = get_theme_icon("Search", "EditorIcons") + + +### Signals + + +func _on_theme_changed() -> void: + apply_theme() + + +func _on_filter_edit_text_changed(new_text: String) -> void: + self.filter = new_text + + +func _on_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void: + if mouse_button_index == MOUSE_BUTTON_LEFT: + var title = list.get_item_text(index) + title_selected.emit(title) diff --git a/addons/dialogue_manager/components/title_list.tscn b/addons/dialogue_manager/components/title_list.tscn new file mode 100644 index 00000000..3e5c9a00 --- /dev/null +++ b/addons/dialogue_manager/components/title_list.tscn @@ -0,0 +1,45 @@ +[gd_scene load_steps=4 format=3 uid="uid://ctns6ouwwd68i"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/title_list.gd" id="1_5qqmd"] + +[sub_resource type="Image" id="Image_o5dqs"] +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_ekmpw"] +image = SubResource("Image_o5dqs") + +[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 +offset_right = 1152.0 +offset_bottom = 31.0 +placeholder_text = "Filter titles" +clear_button_enabled = true +right_icon = SubResource("ImageTexture_ekmpw") + +[node name="List" type="ItemList" parent="."] +layout_mode = 2 +offset_top = 35.0 +offset_right = 1152.0 +offset_bottom = 648.0 +size_flags_vertical = 3 +allow_reselect = true + +[connection signal="theme_changed" from="." to="." method="_on_theme_changed"] +[connection signal="text_changed" from="FilterEdit" to="." method="_on_filter_edit_text_changed"] +[connection signal="item_clicked" from="List" to="." method="_on_list_item_clicked"] diff --git a/addons/dialogue_manager/components/update_button.gd b/addons/dialogue_manager/components/update_button.gd new file mode 100644 index 00000000..86b0e013 --- /dev/null +++ b/addons/dialogue_manager/components/update_button.gd @@ -0,0 +1,126 @@ +@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 + +# The main editor plugin +var editor_plugin: EditorPlugin + +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 = editor_plugin.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) + return 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: + editor_plugin.get_editor_interface().restart_editor(true) + else: + var scale: float = editor_plugin.get_editor_interface().get_editor_scale() + download_dialog.min_size = Vector2(300, 250) * scale + download_dialog.popup_centered() + + +func _on_download_dialog_close_requested() -> void: + download_dialog.hide() + + +func _on_download_update_panel_updated(updated_to_version: String) -> void: + download_dialog.hide() + + needs_reload_dialog.dialog_text = DialogueConstants.translate("update.needs_reload") + needs_reload_dialog.ok_button_text = DialogueConstants.translate("update.reload_ok_button") + needs_reload_dialog.cancel_button_text = DialogueConstants.translate("update.reload_cancel_button") + needs_reload_dialog.popup_centered() + + needs_reload = true + text = DialogueConstants.translate("update.reload_project") + apply_theme() + + +func _on_download_update_panel_failed() -> void: + download_dialog.hide() + update_failed_dialog.dialog_text = DialogueConstants.translate("update.failed") + update_failed_dialog.popup_centered() + + +func _on_needs_reload_dialog_confirmed() -> void: + editor_plugin.get_editor_interface().restart_editor(true) + + +func _on_timer_timeout() -> void: + if not needs_reload: + check_for_update() diff --git a/addons/dialogue_manager/components/update_button.tscn b/addons/dialogue_manager/components/update_button.tscn new file mode 100644 index 00000000..533a94e1 --- /dev/null +++ b/addons/dialogue_manager/components/update_button.tscn @@ -0,0 +1,42 @@ +[gd_scene load_steps=3 format=3 uid="uid://co8yl23idiwbi"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/components/update_button.gd" id="1_d2tpb"] +[ext_resource type="PackedScene" uid="uid://qdxrxv3c3hxk" path="res://addons/dialogue_manager/components/download_update_panel.tscn" id="2_iwm7r"] + +[node name="UpdateButton" type="Button"] +visible = false +offset_right = 8.0 +offset_bottom = 8.0 +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_colors/font_hover_color = Color(0, 0, 0, 1) +theme_override_colors/font_focus_color = Color(0, 0, 0, 1) +text = "v2.9.0 available" +flat = true +script = ExtResource("1_d2tpb") + +[node name="HTTPRequest" type="HTTPRequest" parent="."] + +[node name="DownloadDialog" type="AcceptDialog" parent="."] +title = "Download update" +size = Vector2i(400, 300) +unresizable = true +min_size = Vector2i(300, 250) +ok_button_text = "Close" + +[node name="DownloadUpdatePanel" parent="DownloadDialog" instance=ExtResource("2_iwm7r")] + +[node name="UpdateFailedDialog" type="AcceptDialog" parent="."] +dialog_text = "You have been updated to version 2.4.3" + +[node name="NeedsReloadDialog" type="ConfirmationDialog" parent="."] + +[node name="Timer" type="Timer" parent="."] +wait_time = 14400.0 + +[connection signal="pressed" from="." to="." method="_on_update_button_pressed"] +[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"] +[connection signal="close_requested" from="DownloadDialog" to="." method="_on_download_dialog_close_requested"] +[connection signal="failed" from="DownloadDialog/DownloadUpdatePanel" to="." method="_on_download_update_panel_failed"] +[connection signal="updated" from="DownloadDialog/DownloadUpdatePanel" to="." method="_on_download_update_panel_updated"] +[connection signal="confirmed" from="NeedsReloadDialog" to="." method="_on_needs_reload_dialog_confirmed"] +[connection signal="timeout" from="Timer" to="." method="_on_timer_timeout"] diff --git a/addons/dialogue_manager/constants.gd b/addons/dialogue_manager/constants.gd new file mode 100644 index 00000000..0135c83d --- /dev/null +++ b/addons/dialogue_manager/constants.gd @@ -0,0 +1,187 @@ +extends Node + + +const USER_CONFIG_PATH = "user://dialogue_manager_user_config.json" +const CACHE_PATH = "user://dialogue_manager_cache.json" + +# Token types + +const TOKEN_FUNCTION = "function" +const TOKEN_DICTIONARY_REFERENCE = "dictionary_reference" +const TOKEN_DICTIONARY_NESTED_REFERENCE = "dictionary_nested_reference" +const TOKEN_GROUP = "group" +const TOKEN_ARRAY = "array" +const TOKEN_DICTIONARY = "dictionary" +const TOKEN_PARENS_OPEN = "parens_open" +const TOKEN_PARENS_CLOSE = "parens_close" +const TOKEN_BRACKET_OPEN = "bracket_open" +const TOKEN_BRACKET_CLOSE = "bracket_close" +const TOKEN_BRACE_OPEN = "brace_open" +const TOKEN_BRACE_CLOSE = "brace_close" +const TOKEN_COLON = "colon" +const TOKEN_COMPARISON = "comparison" +const TOKEN_ASSIGNMENT = "assignment" +const TOKEN_OPERATOR = "operator" +const TOKEN_COMMA = "comma" +const TOKEN_DOT = "dot" +const TOKEN_CONDITION = "condition" +const TOKEN_BOOL = "bool" +const TOKEN_NOT = "not" +const TOKEN_AND_OR = "and_or" +const TOKEN_STRING = "string" +const TOKEN_NUMBER = "number" +const TOKEN_VARIABLE = "variable" +const TOKEN_COMMENT = "comment" + +const TOKEN_ERROR = "error" + +# Line types + +const TYPE_UNKNOWN = "unknown" +const TYPE_RESPONSE = "response" +const TYPE_TITLE = "title" +const TYPE_CONDITION = "condition" +const TYPE_MUTATION = "mutation" +const TYPE_GOTO = "goto" +const TYPE_DIALOGUE = "dialogue" +const TYPE_ERROR = "error" + +const TYPE_ELSE = "else" + +# Line IDs + +const ID_NULL = "" +const ID_ERROR = "error" +const ID_ERROR_INVALID_TITLE = "invalid title" +const ID_ERROR_TITLE_HAS_NO_BODY = "title has no body" +const ID_END = "end" +const ID_END_CONVERSATION = "end!" + +# Errors + +const ERR_ERRORS_IN_IMPORTED_FILE = 100 +const ERR_FILE_ALREADY_IMPORTED = 101 +const ERR_DUPLICATE_IMPORT_NAME = 102 +const ERR_EMPTY_TITLE = 103 +const ERR_DUPLICATE_TITLE = 104 +const ERR_NESTED_TITLE = 105 +const ERR_TITLE_INVALID_CHARACTERS = 106 +const ERR_UNKNOWN_TITLE = 107 +const ERR_INVALID_TITLE_REFERENCE = 108 +const ERR_TITLE_REFERENCE_HAS_NO_CONTENT = 109 +const ERR_INVALID_EXPRESSION = 110 +const ERR_UNEXPECTED_CONDITION = 111 +const ERR_DUPLICATE_ID = 112 +const ERR_MISSING_ID = 113 +const ERR_INVALID_INDENTATION = 114 +const ERR_INVALID_CONDITION_INDENTATION = 115 +const ERR_INCOMPLETE_EXPRESSION = 116 +const ERR_INVALID_EXPRESSION_FOR_VALUE = 117 +const ERR_UNKNOWN_LINE_SYNTAX = 118 +const ERR_TITLE_BEGINS_WITH_NUMBER = 119 +const ERR_UNEXPECTED_END_OF_EXPRESSION = 120 +const ERR_UNEXPECTED_FUNCTION = 121 +const ERR_UNEXPECTED_BRACKET = 122 +const ERR_UNEXPECTED_CLOSING_BRACKET = 123 +const ERR_MISSING_CLOSING_BRACKET = 124 +const ERR_UNEXPECTED_OPERATOR = 125 +const ERR_UNEXPECTED_COMMA = 126 +const ERR_UNEXPECTED_COLON = 127 +const ERR_UNEXPECTED_DOT = 128 +const ERR_UNEXPECTED_BOOLEAN = 129 +const ERR_UNEXPECTED_STRING = 130 +const ERR_UNEXPECTED_NUMBER = 131 +const ERR_UNEXPECTED_VARIABLE = 132 +const ERR_INVALID_INDEX = 133 +const ERR_UNEXPECTED_ASSIGNMENT = 134 +const ERR_UNKNOWN_USING = 135 + + +## Get the error message +static func get_error_message(error: int) -> String: + match error: + ERR_ERRORS_IN_IMPORTED_FILE: + return translate("errors.import_errors") + ERR_FILE_ALREADY_IMPORTED: + return translate("errors.already_imported") + ERR_DUPLICATE_IMPORT_NAME: + return translate("errors.duplicate_import") + ERR_EMPTY_TITLE: + return translate("errors.empty_title") + ERR_DUPLICATE_TITLE: + return translate("errors.duplicate_title") + ERR_NESTED_TITLE: + return translate("errors.nested_title") + ERR_TITLE_INVALID_CHARACTERS: + return translate("errors.invalid_title_string") + ERR_TITLE_BEGINS_WITH_NUMBER: + return translate("errors.invalid_title_number") + ERR_UNKNOWN_TITLE: + return translate("errors.unknown_title") + ERR_INVALID_TITLE_REFERENCE: + return translate("errors.jump_to_invalid_title") + ERR_TITLE_REFERENCE_HAS_NO_CONTENT: + return translate("errors.title_has_no_content") + ERR_INVALID_EXPRESSION: + return translate("errors.invalid_expression") + ERR_UNEXPECTED_CONDITION: + return translate("errors.unexpected_condition") + ERR_DUPLICATE_ID: + return translate("errors.duplicate_id") + ERR_MISSING_ID: + return translate("errors.missing_id") + ERR_INVALID_INDENTATION: + return translate("errors.invalid_indentation") + ERR_INVALID_CONDITION_INDENTATION: + return translate("errors.condition_has_no_content") + ERR_INCOMPLETE_EXPRESSION: + return translate("errors.incomplete_expression") + ERR_INVALID_EXPRESSION_FOR_VALUE: + return translate("errors.invalid_expression_for_value") + ERR_FILE_NOT_FOUND: + return translate("errors.file_not_found") + ERR_UNEXPECTED_END_OF_EXPRESSION: + return translate("errors.unexpected_end_of_expression") + ERR_UNEXPECTED_FUNCTION: + return translate("errors.unexpected_function") + ERR_UNEXPECTED_BRACKET: + return translate("errors.unexpected_bracket") + ERR_UNEXPECTED_CLOSING_BRACKET: + return translate("errors.unexpected_closing_bracket") + ERR_MISSING_CLOSING_BRACKET: + return translate("errors.missing_closing_bracket") + ERR_UNEXPECTED_OPERATOR: + return translate("errors.unexpected_operator") + ERR_UNEXPECTED_COMMA: + return translate("errors.unexpected_comma") + ERR_UNEXPECTED_COLON: + return translate("errors.unexpected_colon") + ERR_UNEXPECTED_DOT: + return translate("errors.unexpected_dot") + ERR_UNEXPECTED_BOOLEAN: + return translate("errors.unexpected_boolean") + ERR_UNEXPECTED_STRING: + return translate("errors.unexpected_string") + ERR_UNEXPECTED_NUMBER: + return translate("errors.unexpected_number") + ERR_UNEXPECTED_VARIABLE: + return translate("errors.unexpected_variable") + ERR_INVALID_INDEX: + return translate("errors.invalid_index") + ERR_UNEXPECTED_ASSIGNMENT: + return translate("errors.unexpected_assignment") + ERR_UNKNOWN_USING: + return translate("errors.unknown_using") + + return translate("errors.unknown") + + +static func translate(string: String) -> String: + var base_path = new().get_script().resource_path.get_base_dir() + + var language: String = TranslationServer.get_tool_locale() + var translations_path: String = "%s/l10n/%s.po" % [base_path, language] + var fallback_translations_path: String = "%s/l10n/%s.po" % [base_path, TranslationServer.get_tool_locale().substr(0, 2)] + var en_translations_path: String = "%s/l10n/en.po" % base_path + var translations: Translation = load(translations_path if FileAccess.file_exists(translations_path) else (fallback_translations_path if FileAccess.file_exists(fallback_translations_path) else en_translations_path)) + return translations.get_message(string) diff --git a/addons/dialogue_manager/dialogue_label.gd b/addons/dialogue_manager/dialogue_label.gd new file mode 100644 index 00000000..24057bec --- /dev/null +++ b/addons/dialogue_manager/dialogue_label.gd @@ -0,0 +1,226 @@ +@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 charcter 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 + + +## The current line of dialogue. +var dialogue_line: + set(next_dialogue_line): + dialogue_line = next_dialogue_line + custom_minimum_size = Vector2.ZERO + text = dialogue_line.text + get: + return dialogue_line + +## Whether the label is currently typing itself out. +var is_typing: bool = false: + set(value): + var is_finished: bool = is_typing != value and value == false + is_typing = value + if is_finished: + finished_typing.emit() + get: + return is_typing + +var _last_wait_index: int = -1 +var _last_mutation_index: int = -1 +var _waiting_seconds: float = 0 +var _is_awaiting_mutation: bool = false + + +func _process(delta: float) -> void: + if self.is_typing: + # Type out text + if visible_ratio < 1: + # See if we are waiting + if _waiting_seconds > 0: + _waiting_seconds = _waiting_seconds - delta + # If we are no longer waiting then keep typing + if _waiting_seconds <= 0: + _type_next(delta, _waiting_seconds) + else: + # Make sure any mutations at the end of the line get run + _mutate_inline_mutations(get_total_character_count()) + self.is_typing = false + + +func _unhandled_input(event: InputEvent) -> void: + # Note: this will no longer be reached if using Dialogue Manager > 2.32.2. To make skip handling + # simpler (so all of mouse/keyboard/joypad are together) it is now the responsibility of the + # dialogue balloon. + if self.is_typing and visible_ratio < 1 and InputMap.has_action(skip_action) and event.is_action_pressed(skip_action): + get_viewport().set_input_as_handled() + skip_typing() + + +## Start typing out the text +func type_out() -> void: + text = dialogue_line.text + visible_characters = 0 + visible_ratio = 0 + _waiting_seconds = 0 + _last_wait_index = -1 + _last_mutation_index = -1 + + 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: + _is_awaiting_mutation = true + # The DialogueManager can't be referenced directly here so we need to get it by its path + await Engine.get_singleton("DialogueManager").mutate(inline_mutation[1], dialogue_line.extra_game_states, true) + _is_awaiting_mutation = false + + +# Determine if the current autopause character at the cursor should qualify to pause typing. +func _should_auto_pause() -> bool: + if visible_characters == 0: return false + + var parsed_text: String = get_parsed_text() + + # Avoid outofbounds when the label auto-translates and the text changes to one shorter while typing out + # Note: visible characters can be larger than parsed_text after a translation event + if visible_characters >= parsed_text.length(): return false + + # Ignore pause characters if they are next to a non-pause character + if parsed_text[visible_characters] in skip_pause_at_character_if_followed_by.split(): + return false + + # Ignore "." if it's between two numbers + if visible_characters > 3 and parsed_text[visible_characters - 1] == ".": + var possible_number: String = parsed_text.substr(visible_characters - 2, 3) + if str(float(possible_number)) == possible_number: + return false + + # Ignore "." if it's used in an abbreviation + # Note: does NOT support multi-period abbreviations (ex. p.m.) + if "." in pause_at_characters and parsed_text[visible_characters - 1] == ".": + for abbreviation in skip_pause_at_abbreviations: + if visible_characters >= abbreviation.length(): + var previous_characters: String = parsed_text.substr(visible_characters - abbreviation.length() - 1, abbreviation.length()) + if previous_characters == abbreviation: + return false + + # Ignore two non-"." characters next to each other + var other_pause_characters: PackedStringArray = pause_at_characters.replace(".", "").split() + if visible_characters > 1 and parsed_text[visible_characters - 1] in other_pause_characters and parsed_text[visible_characters] in other_pause_characters: + return false + + return parsed_text[visible_characters - 1] in pause_at_characters.split() diff --git a/addons/dialogue_manager/dialogue_label.tscn b/addons/dialogue_manager/dialogue_label.tscn new file mode 100644 index 00000000..df48b649 --- /dev/null +++ b/addons/dialogue_manager/dialogue_label.tscn @@ -0,0 +1,19 @@ +[gd_scene load_steps=2 format=3 uid="uid://ckvgyvclnwggo"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_label.gd" id="1_cital"] + +[node name="DialogueLabel" type="RichTextLabel"] +anchors_preset = 10 +anchor_right = 1.0 +grow_horizontal = 2 +mouse_filter = 1 +bbcode_enabled = true +fit_content = true +scroll_active = false +shortcut_keys_enabled = false +meta_underlined = false +hint_underlined = false +deselect_on_focus_loss_enabled = false +visible_characters_behavior = 1 +script = ExtResource("1_cital") +skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex") diff --git a/addons/dialogue_manager/dialogue_line.gd b/addons/dialogue_manager/dialogue_line.gd new file mode 100644 index 00000000..a9bf84a0 --- /dev/null +++ b/addons/dialogue_manager/dialogue_line.gd @@ -0,0 +1,98 @@ +## A line of dialogue returned from [code]DialogueManager[/code]. +class_name DialogueLine extends RefCounted + + +const _DialogueConstants = preload("./constants.gd") + + +## The ID of this line +var id: String + +## The internal type of this dialogue object. One of [code]TYPE_DIALOGUE[/code] or [code]TYPE_MUTATION[/code] +var type: String = _DialogueConstants.TYPE_DIALOGUE + +## The next line ID after this line. +var next_id: String = "" + +## The character name that is saying this line. +var character: String = "" + +## A dictionary of variable replacements fo the character name. Generally for internal use only. +var character_replacements: Array[Dictionary] = [] + +## The dialogue being spoken. +var text: String = "" + +## A dictionary of replacements for the text. Generally for internal use only. +var text_replacements: Array[Dictionary] = [] + +## The key to use for translating this line. +var translation_key: String = "" + +## A map for when and for how long to pause while typing out the dialogue text. +var pauses: Dictionary = {} + +## A map for speed changes when typing out the dialogue text. +var speeds: Dictionary = {} + +## A map of any mutations to run while typing out the dialogue text. +var inline_mutations: Array[Array] = [] + +## A list of responses attached to this line of dialogue. +var responses: Array[DialogueResponse] = [] + +## A list of any extra game states to check when resolving variables and mutations. +var extra_game_states: Array = [] + +## How long to show this line before advancing to the next. Either a float (of seconds), [code]"auto"[/code], or [code]null[/code]. +var time: String = "" + +## Any #tags that were included in the line +var tags: PackedStringArray = [] + +## The mutation details if this is a mutation line (where [code]type == TYPE_MUTATION[/code]). +var mutation: Dictionary = {} + +## The conditions to check before including this line in the flow of dialogue. If failed the line will be skipped over. +var conditions: Dictionary = {} + + +func _init(data: Dictionary = {}) -> void: + if data.size() > 0: + id = data.id + next_id = data.next_id + type = data.type + extra_game_states = data.get("extra_game_states", []) + + match type: + _DialogueConstants.TYPE_DIALOGUE: + character = data.character + character_replacements = data.get("character_replacements", [] as Array[Dictionary]) + text = data.text + text_replacements = data.get("text_replacements", [] as Array[Dictionary]) + translation_key = data.get("translation_key", data.text) + pauses = data.get("pauses", {}) + speeds = data.get("speeds", {}) + inline_mutations = data.get("inline_mutations", [] as Array[Array]) + time = data.get("time", "") + tags = data.get("tags", []) + + _DialogueConstants.TYPE_MUTATION: + mutation = data.mutation + + +func _to_string() -> String: + match type: + _DialogueConstants.TYPE_DIALOGUE: + return "<DialogueLine character=\"%s\" text=\"%s\">" % [character, text] + _DialogueConstants.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 "" diff --git a/addons/dialogue_manager/dialogue_manager.gd b/addons/dialogue_manager/dialogue_manager.gd new file mode 100644 index 00000000..1e2261e5 --- /dev/null +++ b/addons/dialogue_manager/dialogue_manager.gd @@ -0,0 +1,1235 @@ +extends Node + + +const DialogueConstants = preload("./constants.gd") +const Builtins = preload("./utilities/builtins.gd") +const DialogueSettings = preload("./settings.gd") +const DialogueResource = preload("./dialogue_resource.gd") +const DialogueLine = preload("./dialogue_line.gd") +const DialogueResponse = preload("./dialogue_response.gd") +const DialogueManagerParser = preload("./components/parser.gd") +const DialogueManagerParseResult = preload("./components/parse_result.gd") +const ResolvedLineData = preload("./components/resolved_line_data.gd") + + +## Emitted when a title is encountered while traversing dialogue, usually when jumping from a +## goto line +signal passed_title(title: String) + +## Emitted when a line of dialogue is encountered. +signal got_dialogue(line: DialogueLine) + +## Emitted when a mutation is encountered. +signal mutated(mutation: Dictionary) + +## Emitted when some dialogue has reached the end. +signal dialogue_ended(resource: DialogueResource) + +## Used internally. +signal bridge_get_next_dialogue_line_completed(line: DialogueLine) + +## Used inernally +signal bridge_mutated() + + +enum MutationBehaviour { + Wait, + DoNotWait, + Skip +} + +enum TranslationSource { + None, + Guess, + CSV, + PO +} + + +## The list of globals that dialogue can query +var game_states: Array = [] + +## Allow dialogue to call singletons +var include_singletons: bool = true + +## Allow dialogue to call static methods/properties on classes +var include_classes: bool = true + +## Manage translation behaviour +var translation_source: TranslationSource = TranslationSource.Guess + +## Used to resolve the current scene. Override if your game manages the current scene itself. +var get_current_scene: Callable = func(): + var current_scene: Node = get_tree().current_scene + if current_scene == null: + current_scene = get_tree().root.get_child(get_tree().root.get_child_count() - 1) + return current_scene + +var _has_loaded_autoloads: bool = false +var _autoloads: Dictionary = {} + + +var _node_properties: Array = [] + + +func _ready() -> void: + # Cache the known Node2D properties + _node_properties = ["Script Variables"] + var temp_node: Node2D = Node2D.new() + for property in temp_node.get_property_list(): + _node_properties.append(property.name) + temp_node.free() + + # Make the dialogue manager available as a singleton + if Engine.has_singleton("DialogueManager"): + Engine.unregister_singleton("DialogueManager") + Engine.register_singleton("DialogueManager", self) + + # Connect up the C# signals if need be + if DialogueSettings.has_dotnet_solution(): + _get_dotnet_dialogue_manager().Prepare() + + +## Step through lines and run any mutations until we either hit some dialogue or the end of the conversation +func get_next_dialogue_line(resource: DialogueResource, key: String = "", extra_game_states: Array = [], mutation_behaviour: MutationBehaviour = MutationBehaviour.Wait) -> DialogueLine: + # You have to provide a valid dialogue resource + if resource == null: + assert(false, DialogueConstants.translate("runtime.no_resource")) + if resource.lines.size() == 0: + assert(false, DialogueConstants.translate("runtime.no_content").format({ file_path = resource.resource_path })) + + # Inject any "using" states into the game_states + for state_name in resource.using_states: + var autoload = get_tree().root.get_node_or_null(state_name) + if autoload == null: + printerr(DialogueConstants.translate("runtime.unknown_autoload").format({ autoload = state_name })) + else: + extra_game_states = [autoload] + extra_game_states + + # Get the line data + var dialogue: DialogueLine = await get_line(resource, key, extra_game_states) + + # If our dialogue is nothing then we hit the end + if not is_valid(dialogue): + (func(): dialogue_ended.emit(resource)).call_deferred() + return null + + # Run the mutation if it is one + if dialogue.type == DialogueConstants.TYPE_MUTATION: + var actual_next_id: String = dialogue.next_id.split(",")[0] + match mutation_behaviour: + MutationBehaviour.Wait: + await mutate(dialogue.mutation, extra_game_states) + MutationBehaviour.DoNotWait: + mutate(dialogue.mutation, extra_game_states) + MutationBehaviour.Skip: + pass + if actual_next_id in [DialogueConstants.ID_END_CONVERSATION, DialogueConstants.ID_NULL, null]: + # End the conversation + (func(): dialogue_ended.emit(resource)).call_deferred() + return null + else: + return await get_next_dialogue_line(resource, dialogue.next_id, extra_game_states, mutation_behaviour) + else: + got_dialogue.emit(dialogue) + return dialogue + + +func get_resolved_line_data(data: Dictionary, extra_game_states: Array = []) -> ResolvedLineData: + var text: String = translate(data) + + # Resolve variables + for replacement in data.text_replacements: + var value = await resolve(replacement.expression.duplicate(true), extra_game_states) + var index: int = text.find(replacement.value_in_text) + text = text.substr(0, index) + str(value) + text.substr(index + replacement.value_in_text.length()) + + var parser: DialogueManagerParser = DialogueManagerParser.new() + + # Resolve random groups + for found in parser.INLINE_RANDOM_REGEX.search_all(text): + var options = found.get_string(&"options").split(&"|") + text = text.replace(&"[[%s]]" % found.get_string(&"options"), options[randi_range(0, options.size() - 1)]) + + # Do a pass on the markers to find any conditionals + var markers: ResolvedLineData = parser.extract_markers(text) + + # Resolve any conditionals and update marker positions as needed + if data.type == DialogueConstants.TYPE_DIALOGUE: + var resolved_text: String = markers.text + var conditionals: Array[RegExMatch] = parser.INLINE_CONDITIONALS_REGEX.search_all(resolved_text) + var replacements: Array = [] + for conditional in conditionals: + var condition_raw: String = conditional.strings[conditional.names.condition] + var body: String = conditional.strings[conditional.names.body] + var body_else: String = "" + if &"[else]" in body: + var bits = body.split(&"[else]") + body = bits[0] + body_else = bits[1] + var condition: Dictionary = parser.extract_condition("if " + condition_raw, false, 0) + # If the condition fails then use the else of "" + if not await check_condition({ condition = condition }, extra_game_states): + body = body_else + replacements.append({ + start = conditional.get_start(), + end = conditional.get_end(), + string = conditional.get_string(), + body = body + }) + + for i in range(replacements.size() -1, -1, -1): + var r: Dictionary = replacements[i] + resolved_text = resolved_text.substr(0, r.start) + r.body + resolved_text.substr(r.end, 9999) + # Move any other markers now that the text has changed + var offset: int = r.end - r.start - r.body.length() + for key in [&"pauses", &"speeds", &"time"]: + if markers.get(key) == null: continue + var marker = markers.get(key) + var next_marker: Dictionary = {} + for index in marker: + if index < r.start: + next_marker[index] = marker[index] + elif index > r.start: + next_marker[index - offset] = marker[index] + markers.set(key, next_marker) + var mutations: Array[Array] = markers.mutations + var next_mutations: Array[Array] = [] + for mutation in mutations: + var index = mutation[0] + if index < r.start: + next_mutations.append(mutation) + elif index > r.start: + next_mutations.append([index - offset, mutation[1]]) + markers.mutations = next_mutations + + markers.text = resolved_text + + parser.free() + + return markers + + +## Replace any variables, etc in the character name +func get_resolved_character(data: Dictionary, extra_game_states: Array = []) -> String: + var character: String = data.get(&"character", "") + + # Resolve variables + for replacement in data.get(&"character_replacements", []): + var value = await resolve(replacement.expression.duplicate(true), extra_game_states) + var index: int = character.find(replacement.value_in_text) + character = character.substr(0, index) + str(value) + character.substr(index + replacement.value_in_text.length()) + + # Resolve random groups + var random_regex: RegEx = RegEx.new() + random_regex.compile("\\[\\[(?<options>.*?)\\]\\]") + for found in random_regex.search_all(character): + var options = found.get_string(&"options").split("|") + character = character.replace("[[%s]]" % found.get_string(&"options"), options[randi_range(0, options.size() - 1)]) + + return character + + +## Generate a dialogue resource on the fly from some text +func create_resource_from_text(text: String) -> Resource: + var parser: DialogueManagerParser = DialogueManagerParser.new() + parser.parse(text, "") + var results: DialogueManagerParseResult = parser.get_data() + var errors: Array[Dictionary] = parser.get_errors() + parser.free() + + if errors.size() > 0: + printerr(DialogueConstants.translate("runtime.errors").format({ count = errors.size() })) + for error in errors: + printerr(DialogueConstants.translate("runtime.error_detail").format({ + line = error.line_number + 1, + message = DialogueConstants.get_error_message(error.error) + })) + assert(false, DialogueConstants.translate("runtime.errors_see_details").format({ count = errors.size() })) + + var resource: DialogueResource = DialogueResource.new() + resource.using_states = results.using_states + resource.titles = results.titles + resource.first_title = results.first_title + resource.character_names = results.character_names + resource.lines = results.lines + resource.raw_text = text + + return resource + + +## Show the example balloon +func show_example_dialogue_balloon(resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> CanvasLayer: + var balloon: Node = load(_get_example_balloon_path()).instantiate() + get_current_scene.call().add_child(balloon) + balloon.start(resource, title, extra_game_states) + + return balloon + + +## Show the configured dialogue balloon +func show_dialogue_balloon(resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> Node: + var balloon_path: String = DialogueSettings.get_setting(&"balloon_path", _get_example_balloon_path()) + if not ResourceLoader.exists(balloon_path): + balloon_path = _get_example_balloon_path() + return show_dialogue_balloon_scene(balloon_path, resource, title, extra_game_states) + + +## Show a given balloon scene +func show_dialogue_balloon_scene(balloon_scene, resource: DialogueResource, title: String = "", extra_game_states: Array = []) -> Node: + if balloon_scene is String: + balloon_scene = load(balloon_scene) + if balloon_scene is PackedScene: + balloon_scene = balloon_scene.instantiate() + + var balloon: Node = balloon_scene + get_current_scene.call().add_child(balloon) + if balloon.has_method(&"start"): + balloon.start(resource, title, extra_game_states) + elif balloon.has_method(&"Start"): + balloon.Start(resource, title, extra_game_states) + else: + assert(false, DialogueConstants.translate("runtime.dialogue_balloon_missing_start_method")) + return balloon + + +# Get the path to the example balloon +func _get_example_balloon_path() -> String: + var is_small_window: bool = ProjectSettings.get_setting("display/window/size/viewport_width") < 400 + var balloon_path: String = "/example_balloon/small_example_balloon.tscn" if is_small_window else "/example_balloon/example_balloon.tscn" + return get_script().resource_path.get_base_dir() + balloon_path + + +### Dotnet bridge + + +func _get_dotnet_dialogue_manager() -> Node: + return load(get_script().resource_path.get_base_dir() + "/DialogueManager.cs").new() + + +func _bridge_get_next_dialogue_line(resource: DialogueResource, key: String, extra_game_states: Array = []) -> void: + # dotnet needs at least one await tick of the signal gets called too quickly + await get_tree().process_frame + + var line = await get_next_dialogue_line(resource, key, extra_game_states) + bridge_get_next_dialogue_line_completed.emit(line) + + +func _bridge_mutate(mutation: Dictionary, extra_game_states: Array, is_inline_mutation: bool = false) -> void: + await mutate(mutation, extra_game_states, is_inline_mutation) + bridge_mutated.emit() + + +### Helpers + + +# Get a line by its ID +func get_line(resource: DialogueResource, key: String, extra_game_states: Array) -> DialogueLine: + key = key.strip_edges() + + # See if we were given a stack instead of just the one key + var stack: Array = key.split("|") + key = stack.pop_front() + var id_trail: String = "" if stack.size() == 0 else "|" + "|".join(stack) + + # Key is blank so just use the first title + if key == null or key == "": + key = resource.first_title + + # See if we just ended the conversation + if key in [DialogueConstants.ID_END, DialogueConstants.ID_NULL, null]: + if stack.size() > 0: + return await get_line(resource, "|".join(stack), extra_game_states) + else: + return null + elif key == DialogueConstants.ID_END_CONVERSATION: + return null + + # See if it is a title + if key.begins_with("~ "): + key = key.substr(2) + if resource.titles.has(key): + key = resource.titles.get(key) + + if key in resource.titles.values(): + passed_title.emit(resource.titles.find_key(key)) + + if not resource.lines.has(key): + assert(false, DialogueConstants.translate("errors.key_not_found").format({ key = key })) + + var data: Dictionary = resource.lines.get(key) + + # Check for weighted random lines + if data.has(&"siblings"): + var target_weight: float = randf_range(0, data.siblings.reduce(func(total, sibling): return total + sibling.weight, 0)) + var cummulative_weight: float = 0 + for sibling in data.siblings: + if target_weight < cummulative_weight + sibling.weight: + data = resource.lines.get(sibling.id) + break + else: + cummulative_weight += sibling.weight + + # Check condtiions + if data.type == DialogueConstants.TYPE_CONDITION: + # "else" will have no actual condition + if await check_condition(data, extra_game_states): + return await get_line(resource, data.next_id + id_trail, extra_game_states) + else: + return await get_line(resource, data.next_conditional_id + id_trail, extra_game_states) + + # Evaluate jumps + elif data.type == DialogueConstants.TYPE_GOTO: + if data.is_snippet: + id_trail = "|" + data.next_id_after + id_trail + return await get_line(resource, data.next_id + id_trail, extra_game_states) + + elif data.type == DialogueConstants.TYPE_DIALOGUE: + if not data.has(&"id"): + data.id = key + + # Set up a line object + var line: DialogueLine = await create_dialogue_line(data, extra_game_states) + + # If the jump point somehow has no content then just end + if not line: return null + + # If we are the first of a list of responses then get the other ones + if data.type == DialogueConstants.TYPE_RESPONSE: + # Note: For some reason C# has occasional issues with using the responses property directly + # so instead we use set and get here. + line.set(&"responses", await get_responses(data.get(&"responses", []), resource, id_trail, extra_game_states)) + return line + + # Inject the next node's responses if they have any + if resource.lines.has(line.next_id): + var next_line: Dictionary = resource.lines.get(line.next_id) + + # If the response line is marked as a title then make sure to emit the passed_title signal. + if line.next_id in resource.titles.values(): + passed_title.emit(resource.titles.find_key(line.next_id)) + + # If the next line is a title then check where it points to see if that is a set of responses. + if next_line.type == DialogueConstants.TYPE_GOTO and resource.lines.has(next_line.next_id): + next_line = resource.lines.get(next_line.next_id) + + if next_line != null and next_line.type == DialogueConstants.TYPE_RESPONSE: + # Note: For some reason C# has occasional issues with using the responses property directly + # so instead we use set and get here. + line.set(&"responses", await get_responses(next_line.get(&"responses", []), resource, id_trail, extra_game_states)) + + line.next_id = "|".join(stack) if line.next_id == DialogueConstants.ID_NULL else line.next_id + id_trail + return line + + +# Show a message or crash with error +func show_error_for_missing_state_value(message: String, will_show: bool = true) -> void: + if not will_show: return + + if DialogueSettings.get_setting(&"ignore_missing_state_values", false): + push_error(message) + elif will_show: + # If you're here then you're missing a method or property in your game state. The error + # message down in the debugger will give you some more information. + assert(false, message) + + +# Translate a string +func translate(data: Dictionary) -> String: + if translation_source == TranslationSource.None: + return data.text + + if data.translation_key == "" or data.translation_key == data.text: + return tr(data.text) + else: + # Line IDs work slightly differently depending on whether the translation came from a + # CSV or a PO file. CSVs use the line ID (or the line itself) as the translatable string + # whereas POs use the ID as context and the line itself as the translatable string. + match translation_source: + TranslationSource.PO: + return tr(data.text, StringName(data.translation_key)) + + TranslationSource.CSV: + return tr(data.translation_key) + + TranslationSource.Guess: + var translation_files: Array = ProjectSettings.get_setting(&"internationalization/locale/translations") + if translation_files.filter(func(f: String): return f.get_extension() == &"po").size() > 0: + # Assume PO + return tr(data.text, StringName(data.translation_key)) + else: + # Assume CSV + return tr(data.translation_key) + + return tr(data.translation_key) + + +# Create a line of dialogue +func create_dialogue_line(data: Dictionary, extra_game_states: Array) -> DialogueLine: + match data.type: + DialogueConstants.TYPE_DIALOGUE: + var resolved_data: ResolvedLineData = await get_resolved_line_data(data, extra_game_states) + return DialogueLine.new({ + id = data.get(&"id", ""), + type = DialogueConstants.TYPE_DIALOGUE, + next_id = data.next_id, + character = await get_resolved_character(data, extra_game_states), + character_replacements = data.character_replacements, + text = resolved_data.text, + text_replacements = data.text_replacements, + translation_key = data.translation_key, + pauses = resolved_data.pauses, + speeds = resolved_data.speeds, + inline_mutations = resolved_data.mutations, + time = resolved_data.time, + tags = data.get(&"tags", []), + extra_game_states = extra_game_states + }) + + DialogueConstants.TYPE_RESPONSE: + return DialogueLine.new({ + id = data.get(&"id", ""), + type = DialogueConstants.TYPE_RESPONSE, + next_id = data.next_id, + tags = data.get(&"tags", []), + extra_game_states = extra_game_states + }) + + DialogueConstants.TYPE_MUTATION: + return DialogueLine.new({ + id = data.get(&"id", ""), + type = DialogueConstants.TYPE_MUTATION, + next_id = data.next_id, + mutation = data.mutation, + extra_game_states = extra_game_states + }) + + return null + + +# Create a response +func create_response(data: Dictionary, extra_game_states: Array) -> DialogueResponse: + var resolved_data: ResolvedLineData = await get_resolved_line_data(data, extra_game_states) + return DialogueResponse.new({ + id = data.get(&"id", ""), + type = DialogueConstants.TYPE_RESPONSE, + next_id = data.next_id, + is_allowed = data.is_allowed, + character = await get_resolved_character(data, extra_game_states), + character_replacements = data.get(&"character_replacements", [] as Array[Dictionary]), + text = resolved_data.text, + text_replacements = data.text_replacements, + tags = data.get(&"tags", []), + translation_key = data.translation_key + }) + + +# Get the current game states +func get_game_states(extra_game_states: Array) -> Array: + if not _has_loaded_autoloads: + _has_loaded_autoloads = true + # Add any autoloads to a generic state so we can refer to them by name + for child in get_tree().root.get_children(): + # Ignore the dialogue manager + if child.name == &"DialogueManager": continue + # Ignore the current main scene + if get_tree().current_scene and child.name == get_tree().current_scene.name: continue + # Add the node to our known autoloads + _autoloads[child.name] = child + game_states = [_autoloads] + # Add any other state shortcuts from settings + for node_name in DialogueSettings.get_setting(&"states", []): + var state: Node = get_node_or_null("/root/" + node_name) + if state: + game_states.append(state) + + var current_scene: Node = get_current_scene.call() + var unique_states: Array = [] + for state in extra_game_states + [current_scene] + game_states: + if state != null and not unique_states.has(state): + unique_states.append(state) + return unique_states + + +# Check if a condition is met +func check_condition(data: Dictionary, extra_game_states: Array) -> bool: + if data.get(&"condition", null) == null: return true + if data.condition.size() == 0: return true + + return await resolve(data.condition.expression.duplicate(true), extra_game_states) + + +# Make a change to game state or run a method +func mutate(mutation: Dictionary, extra_game_states: Array, is_inline_mutation: bool = false) -> void: + var expression: Array[Dictionary] = mutation.expression + + # Handle built in mutations + if expression[0].type == DialogueConstants.TOKEN_FUNCTION and expression[0].function in [&"wait", &"debug"]: + var args: Array = await resolve_each(expression[0].value, extra_game_states) + match expression[0].function: + &"wait": + mutated.emit(mutation) + await get_tree().create_timer(float(args[0])).timeout + return + + &"debug": + prints("Debug:", args) + await get_tree().process_frame + + # Or pass through to the resolver + else: + if not mutation_contains_assignment(mutation.expression) and not is_inline_mutation: + mutated.emit(mutation) + + if mutation.get("is_blocking", true): + await resolve(mutation.expression.duplicate(true), extra_game_states) + return + else: + resolve(mutation.expression.duplicate(true), extra_game_states) + + # Wait one frame to give the dialogue handler a chance to yield + await get_tree().process_frame + + +func mutation_contains_assignment(mutation: Array) -> bool: + for token in mutation: + if token.type == DialogueConstants.TOKEN_ASSIGNMENT: + return true + return false + + +func resolve_each(array: Array, extra_game_states: Array) -> Array: + var results: Array = [] + for item in array: + results.append(await resolve(item.duplicate(true), extra_game_states)) + return results + + +# Replace an array of line IDs with their response prompts +func get_responses(ids: Array, resource: DialogueResource, id_trail: String, extra_game_states: Array) -> Array[DialogueResponse]: + var responses: Array[DialogueResponse] = [] + for id in ids: + var data: Dictionary = resource.lines.get(id).duplicate(true) + data.is_allowed = await check_condition(data, extra_game_states) + if DialogueSettings.get_setting(&"include_all_responses", false) or data.is_allowed: + var response: DialogueResponse = await create_response(data, extra_game_states) + response.next_id += id_trail + responses.append(response) + + return responses + + +# Get a value on the current scene or game state +func get_state_value(property: String, extra_game_states: Array): + # Special case for static primitive calls + if property == "Color": + return Color() + elif property == "Vector2": + return Vector2.ZERO + elif property == "Vector3": + return Vector3.ZERO + elif property == "Vector4": + return Vector4.ZERO + elif property == "Quaternian": + return Quaternion() + + var expression = Expression.new() + if expression.parse(property) != OK: + assert(false, DialogueConstants.translate("runtime.invalid_expression").format({ expression = property, error = expression.get_error_text() })) + + for state in get_game_states(extra_game_states): + if typeof(state) == TYPE_DICTIONARY: + if state.has(property): + return state.get(property) + else: + var result = expression.execute([], state, false) + if not expression.has_execute_failed(): + return result + + if include_singletons and Engine.has_singleton(property): + return Engine.get_singleton(property) + + if include_classes: + for class_data in ProjectSettings.get_global_class_list(): + if class_data.get(&"class") == property: + return load(class_data.path).new() + + show_error_for_missing_state_value(DialogueConstants.translate("runtime.property_not_found").format({ property = property, states = str(get_game_states(extra_game_states)) })) + + +# Set a value on the current scene or game state +func set_state_value(property: String, value, extra_game_states: Array) -> void: + for state in get_game_states(extra_game_states): + if typeof(state) == TYPE_DICTIONARY: + if state.has(property): + state[property] = value + return + elif thing_has_property(state, property): + state.set(property, value) + return + + if property.to_snake_case() != property: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.property_not_found_missing_export").format({ property = property, states = str(get_game_states(extra_game_states)) })) + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.property_not_found").format({ property = property, states = str(get_game_states(extra_game_states)) })) + + +# Collapse any expressions +func resolve(tokens: Array, extra_game_states: Array): + # Handle groups first + for token in tokens: + if token.type == DialogueConstants.TOKEN_GROUP: + token["type"] = "value" + token["value"] = await resolve(token.value, extra_game_states) + + # Then variables/methods + var i: int = 0 + var limit: int = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + + if token.type == DialogueConstants.TOKEN_FUNCTION: + var function_name: String = token.function + var args = await resolve_each(token.value, extra_game_states) + if tokens[i - 1].type == DialogueConstants.TOKEN_DOT: + # If we are calling a deeper function then we need to collapse the + # value into the thing we are calling the function on + var caller: Dictionary = tokens[i - 2] + if Builtins.is_supported(caller.value): + caller["type"] = "value" + caller["value"] = Builtins.resolve_method(caller.value, function_name, args) + tokens.remove_at(i) + tokens.remove_at(i-1) + i -= 2 + elif thing_has_method(caller.value, function_name, args): + caller["type"] = "value" + caller["value"] = await resolve_thing_method(caller.value, function_name, args) + tokens.remove_at(i) + tokens.remove_at(i-1) + i -= 2 + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.method_not_callable").format({ method = function_name, object = str(caller.value) })) + else: + var found: bool = false + match function_name: + &"str": + token["type"] = "value" + token["value"] = str(args[0]) + found = true + &"Vector2": + token["type"] = "value" + token["value"] = Vector2(args[0], args[1]) + found = true + &"Vector2i": + token["type"] = "value" + token["value"] = Vector2i(args[0], args[1]) + found = true + &"Vector3": + token["type"] = "value" + token["value"] = Vector3(args[0], args[1], args[2]) + found = true + &"Vector3i": + token["type"] = "value" + token["value"] = Vector3i(args[0], args[1], args[2]) + found = true + &"Vector4": + token["type"] = "value" + token["value"] = Vector4(args[0], args[1], args[2], args[3]) + found = true + &"Vector4i": + token["type"] = "value" + token["value"] = Vector4i(args[0], args[1], args[2], args[3]) + found = true + &"Quaternion": + token["type"] = "value" + token["value"] = Quaternion(args[0], args[1], args[2], args[3]) + found = true + &"Color": + token["type"] = "value" + match args.size(): + 0: + token["value"] = Color() + 1: + token["value"] = Color(args[0]) + 2: + token["value"] = Color(args[0], args[1]) + 3: + token["value"] = Color(args[0], args[1], args[2]) + 4: + token["value"] = Color(args[0], args[1], args[2], args[3]) + found = true + &"load": + token["type"] = "value" + token["value"] = load(args[0]) + found = true + &"emit": + token["type"] = "value" + token["value"] = resolve_signal(args, extra_game_states) + found = true + _: + for state in get_game_states(extra_game_states): + if thing_has_method(state, function_name, args): + token["type"] = "value" + token["value"] = await resolve_thing_method(state, function_name, args) + found = true + break + + show_error_for_missing_state_value(DialogueConstants.translate("runtime.method_not_found").format({ + method = args[0] if function_name in ["call", "call_deferred"] else function_name, + states = str(get_game_states(extra_game_states)) + }), not found) + + elif token.type == DialogueConstants.TOKEN_DICTIONARY_REFERENCE: + var value + if i > 0 and tokens[i - 1].type == DialogueConstants.TOKEN_DOT: + # If we are deep referencing then we need to get the parent object. + # `parent.value` is the actual object and `token.variable` is the name of + # the property within it. + value = tokens[i - 2].value[token.variable] + # Clean up the previous tokens + token.erase("variable") + tokens.remove_at(i - 1) + tokens.remove_at(i - 2) + i -= 2 + else: + # Otherwise we can just get this variable as a normal state reference + value = get_state_value(token.variable, extra_game_states) + + var index = await resolve(token.value, extra_game_states) + if typeof(value) == TYPE_DICTIONARY: + if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # If the next token is an assignment then we need to leave this as a reference + # so that it can be resolved once everything ahead of it has been resolved + token["type"] = "dictionary" + token["value"] = value + token["key"] = index + else: + if value.has(index): + token["type"] = "value" + token["value"] = value[index] + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.key_not_found").format({ key = str(index), dictionary = token.variable })) + elif typeof(value) == TYPE_ARRAY: + if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # If the next token is an assignment then we need to leave this as a reference + # so that it can be resolved once everything ahead of it has been resolved + token["type"] = "array" + token["value"] = value + token["key"] = index + else: + if index >= 0 and index < value.size(): + token["type"] = "value" + token["value"] = value[index] + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.array_index_out_of_bounds").format({ index = index, array = token.variable })) + + elif token.type == DialogueConstants.TOKEN_DICTIONARY_NESTED_REFERENCE: + var dictionary: Dictionary = tokens[i - 1] + var index = await resolve(token.value, extra_game_states) + var value = dictionary.value + if typeof(value) == TYPE_DICTIONARY: + if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # If the next token is an assignment then we need to leave this as a reference + # so that it can be resolved once everything ahead of it has been resolved + dictionary["type"] = "dictionary" + dictionary["key"] = index + dictionary["value"] = value + tokens.remove_at(i) + i -= 1 + else: + if dictionary.value.has(index): + dictionary["value"] = value.get(index) + tokens.remove_at(i) + i -= 1 + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.key_not_found").format({ key = str(index), dictionary = value })) + elif typeof(value) == TYPE_ARRAY: + if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # If the next token is an assignment then we need to leave this as a reference + # so that it can be resolved once everything ahead of it has been resolved + dictionary["type"] = "array" + dictionary["value"] = value + dictionary["key"] = index + tokens.remove_at(i) + i -= 1 + else: + if index >= 0 and index < value.size(): + dictionary["value"] = value[index] + tokens.remove_at(i) + i -= 1 + else: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.array_index_out_of_bounds").format({ index = index, array = value })) + + elif token.type == DialogueConstants.TOKEN_ARRAY: + token["type"] = "value" + token["value"] = await resolve_each(token.value, extra_game_states) + + elif token.type == DialogueConstants.TOKEN_DICTIONARY: + token["type"] = "value" + var dictionary = {} + for key in token.value.keys(): + var resolved_key = await resolve([key], extra_game_states) + var preresolved_value = token.value.get(key) + if typeof(preresolved_value) != TYPE_ARRAY: + preresolved_value = [preresolved_value] + var resolved_value = await resolve(preresolved_value, extra_game_states) + dictionary[resolved_key] = resolved_value + token["value"] = dictionary + + elif token.type == DialogueConstants.TOKEN_VARIABLE or token.type == DialogueConstants.TOKEN_NUMBER: + if str(token.value) == "null": + token["type"] = "value" + token["value"] = null + elif tokens[i - 1].type == DialogueConstants.TOKEN_DOT: + var caller: Dictionary = tokens[i - 2] + var property = token.value + if tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # If the next token is an assignment then we need to leave this as a reference + # so that it can be resolved once everything ahead of it has been resolved + caller["type"] = "property" + caller["property"] = property + else: + # If we are requesting a deeper property then we need to collapse the + # value into the thing we are referencing from + caller["type"] = "value" + if Builtins.is_supported(caller.value): + caller["value"] = Builtins.resolve_property(caller.value, property) + else: + caller["value"] = caller.value.get(property) + tokens.remove_at(i) + tokens.remove_at(i-1) + i -= 2 + elif tokens.size() > i + 1 and tokens[i + 1].type == DialogueConstants.TOKEN_ASSIGNMENT: + # It's a normal variable but we will be assigning to it so don't resolve + # it until everything after it has been resolved + token["type"] = "variable" + else: + token["type"] = "value" + token["value"] = get_state_value(str(token.value), extra_game_states) + + i += 1 + + # Then multiply and divide + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_OPERATOR and token.value in ["*", "/", "%"]: + token["type"] = "value" + token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value) + tokens.remove_at(i+1) + tokens.remove_at(i-1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + # Then addition and subtraction + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_OPERATOR and token.value in ["+", "-"]: + token["type"] = "value" + token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value) + tokens.remove_at(i+1) + tokens.remove_at(i-1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + # Then negations + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_NOT: + token["type"] = "value" + token["value"] = not tokens[i+1].value + tokens.remove_at(i+1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + # Then comparisons + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_COMPARISON: + token["type"] = "value" + token["value"] = compare(token.value, tokens[i-1].value, tokens[i+1].value) + tokens.remove_at(i+1) + tokens.remove_at(i-1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + # Then and/or + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_AND_OR: + token["type"] = "value" + token["value"] = apply_operation(token.value, tokens[i-1].value, tokens[i+1].value) + tokens.remove_at(i+1) + tokens.remove_at(i-1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + # Lastly, resolve any assignments + i = 0 + limit = 0 + while i < tokens.size() and limit < 1000: + limit += 1 + var token: Dictionary = tokens[i] + if token.type == DialogueConstants.TOKEN_ASSIGNMENT: + var lhs: Dictionary = tokens[i - 1] + var value + + match lhs.type: + &"variable": + value = apply_operation(token.value, get_state_value(lhs.value, extra_game_states), tokens[i+1].value) + set_state_value(lhs.value, value, extra_game_states) + &"property": + value = apply_operation(token.value, lhs.value.get(lhs.property), tokens[i+1].value) + if typeof(lhs.value) == TYPE_DICTIONARY: + lhs.value[lhs.property] = value + else: + lhs.value.set(lhs.property, value) + &"dictionary": + value = apply_operation(token.value, lhs.value.get(lhs.key, null), tokens[i+1].value) + lhs.value[lhs.key] = value + &"array": + show_error_for_missing_state_value( + DialogueConstants.translate("runtime.array_index_out_of_bounds").format({ index = lhs.key, array = lhs.value }), + lhs.key >= lhs.value.size() + ) + value = apply_operation(token.value, lhs.value[lhs.key], tokens[i+1].value) + lhs.value[lhs.key] = value + _: + show_error_for_missing_state_value(DialogueConstants.translate("runtime.left_hand_size_cannot_be_assigned_to")) + + token["type"] = "value" + token["value"] = value + tokens.remove_at(i+1) + tokens.remove_at(i-1) + i -= 1 + i += 1 + + if limit >= 1000: + assert(false, DialogueConstants.translate("runtime.something_went_wrong")) + + return tokens[0].value + + +func compare(operator: String, first_value, second_value) -> bool: + match operator: + &"in": + if first_value == null or second_value == null: + return false + else: + return first_value in second_value + &"<": + if first_value == null: + return true + elif second_value == null: + return false + else: + return first_value < second_value + &">": + if first_value == null: + return false + elif second_value == null: + return true + else: + return first_value > second_value + &"<=": + if first_value == null: + return true + elif second_value == null: + return false + else: + return first_value <= second_value + &">=": + if first_value == null: + return false + elif second_value == null: + return true + else: + return first_value >= second_value + &"==": + if first_value == null: + if typeof(second_value) == TYPE_BOOL: + return second_value == false + else: + return second_value == null + else: + return first_value == second_value + &"!=": + if first_value == null: + if typeof(second_value) == TYPE_BOOL: + return second_value == true + else: + return second_value != null + else: + return first_value != second_value + + return false + + +func apply_operation(operator: String, first_value, second_value): + match operator: + &"=": + return second_value + &"+", &"+=": + return first_value + second_value + &"-", &"-=": + return first_value - second_value + &"/", &"/=": + return first_value / second_value + &"*", &"*=": + return first_value * second_value + &"%": + return first_value % second_value + &"and": + return first_value and second_value + &"or": + return first_value or second_value + + assert(false, DialogueConstants.translate("runtime.unknown_operator")) + + +# Check if a dialogue line contains meaningful information +func is_valid(line: DialogueLine) -> bool: + if line == null: + return false + if line.type == DialogueConstants.TYPE_MUTATION and line.mutation == null: + return false + if line.type == DialogueConstants.TYPE_RESPONSE and line.get(&"responses").size() == 0: + return false + return true + + +func thing_has_method(thing, method: String, args: Array) -> bool: + if Builtins.is_supported(thing): + return thing != _autoloads + + if method in [&"call", &"call_deferred"]: + return thing.has_method(args[0]) + + if method == &"emit_signal": + return thing.has_signal(args[0]) + + if thing.has_method(method): + return true + + if method.to_snake_case() != method and DialogueSettings.has_dotnet_solution(): + # If we get this far then the method might be a C# method with a Task return type + return _get_dotnet_dialogue_manager().ThingHasMethod(thing, method) + + return false + + +# Check if a given property exists +func thing_has_property(thing: Object, property: String) -> bool: + if thing == null: + return false + + for p in thing.get_property_list(): + if _node_properties.has(p.name): + # Ignore any properties on the base Node + continue + if p.name == property: + return true + + return false + + +func resolve_signal(args: Array, extra_game_states: Array): + if args[0] is Signal: + args[0] = args[0].get_name() + + for state in get_game_states(extra_game_states): + if typeof(state) == TYPE_DICTIONARY: + continue + elif state.has_signal(args[0]): + match args.size(): + 1: + state.emit_signal(args[0]) + 2: + state.emit_signal(args[0], args[1]) + 3: + state.emit_signal(args[0], args[1], args[2]) + 4: + state.emit_signal(args[0], args[1], args[2], args[3]) + 5: + state.emit_signal(args[0], args[1], args[2], args[3], args[4]) + 6: + state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5]) + 7: + state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6]) + 8: + state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]) + return + + # The signal hasn't been found anywhere + show_error_for_missing_state_value(DialogueConstants.translate("runtime.signal_not_found").format({ signal_name = args[0], states = str(get_game_states(extra_game_states)) })) + + +func resolve_thing_method(thing, method: String, args: Array): + if Builtins.is_supported(thing): + var result = Builtins.resolve_method(thing, method, args) + if not Builtins.has_resolve_method_failed(): + return result + + if thing.has_method(method): + # Try to convert any literals to the right type + var method_info: Dictionary = thing.get_method_list().filter(func(m): return method == m.name)[0] + var method_args: Array = method_info.args + if method_info.flags & METHOD_FLAG_VARARG == 0 and method_args.size() < args.size(): + assert(false, DialogueConstants.translate("runtime.expected_n_got_n_args").format({ expected = method_args.size(), method = method, received = args.size()})) + for i in range(0, method_args.size()): + var m: Dictionary = method_args[i] + var to_type:int = typeof(args[i]) + if m.type == TYPE_ARRAY: + match m.hint_string: + &"String": + to_type = TYPE_PACKED_STRING_ARRAY + &"int": + to_type = TYPE_PACKED_INT64_ARRAY + &"float": + to_type = TYPE_PACKED_FLOAT64_ARRAY + &"Vector2": + to_type = TYPE_PACKED_VECTOR2_ARRAY + &"Vector3": + to_type = TYPE_PACKED_VECTOR3_ARRAY + _: + if m.hint_string != "": + assert(false, DialogueConstants.translate("runtime.unsupported_array_type").format({ type = m.hint_string})) + if typeof(args[i]) != to_type: + args[i] = convert(args[i], to_type) + + return await thing.callv(method, args) + + # If we get here then it's probably a C# method with a Task return type + var dotnet_dialogue_manager = _get_dotnet_dialogue_manager() + dotnet_dialogue_manager.ResolveThingMethod(thing, method, args) + return await dotnet_dialogue_manager.Resolved diff --git a/addons/dialogue_manager/dialogue_reponses_menu.gd b/addons/dialogue_manager/dialogue_reponses_menu.gd new file mode 100644 index 00000000..6da0e5c3 --- /dev/null +++ b/addons/dialogue_manager/dialogue_reponses_menu.gd @@ -0,0 +1,131 @@ +@icon("./assets/responses_menu.svg") + +## A VBoxContainer for dialogue responses provided by [b]Dialogue Manager[/b]. +class_name DialogueResponsesMenu extends VBoxContainer + + +## Emitted when a response is selected. +signal response_selected(response) + + +## Optionally specify a control to duplicate for each response +@export var response_template: Control + +## The action for accepting a response (is possibly overridden by parent dialogue balloon). +@export var next_action: StringName = &"" + +# The list of dialogue responses. +var responses: Array = []: + set(value): + responses = value + + # Remove any current items + for item in get_children(): + if item == response_template: continue + + remove_child(item) + item.queue_free() + + # Add new items + if responses.size() > 0: + for response in responses: + var item: Control + if is_instance_valid(response_template): + item = response_template.duplicate(DUPLICATE_GROUPS | DUPLICATE_SCRIPTS | DUPLICATE_SIGNALS) + item.show() + else: + item = Button.new() + item.name = "Response%d" % get_child_count() + if not response.is_allowed: + item.name = String(item.name) + "Disallowed" + item.disabled = true + + # If the item has a response property then use that + if "response" in item: + item.response = response + # Otherwise assume we can just set the text + else: + item.text = response.text + + item.set_meta("response", response) + + add_child(item) + + _configure_focus() + + +func _ready() -> void: + visibility_changed.connect(func(): + if visible and get_menu_items().size() > 0: + get_menu_items()[0].grab_focus() + ) + + if is_instance_valid(response_template): + response_template.hide() + + +# This is deprecated. +func set_responses(next_responses: Array) -> void: + self.responses = next_responses + + +# Prepare the menu for keyboard and mouse navigation. +func _configure_focus() -> void: + var items = get_menu_items() + for i in items.size(): + var item: Control = items[i] + + item.focus_mode = Control.FOCUS_ALL + + item.focus_neighbor_left = item.get_path() + item.focus_neighbor_right = item.get_path() + + if i == 0: + item.focus_neighbor_top = item.get_path() + item.focus_previous = item.get_path() + else: + item.focus_neighbor_top = items[i - 1].get_path() + item.focus_previous = items[i - 1].get_path() + + if i == items.size() - 1: + item.focus_neighbor_bottom = item.get_path() + item.focus_next = item.get_path() + else: + item.focus_neighbor_bottom = items[i + 1].get_path() + item.focus_next = items[i + 1].get_path() + + item.mouse_entered.connect(_on_response_mouse_entered.bind(item)) + item.gui_input.connect(_on_response_gui_input.bind(item, item.get_meta("response"))) + + items[0].grab_focus() + + +## 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 + + +### Signals + + +func _on_response_mouse_entered(item: Control) -> void: + if "Disallowed" in item.name: return + + item.grab_focus() + + +func _on_response_gui_input(event: InputEvent, item: Control, response) -> void: + if "Disallowed" in item.name: return + + get_viewport().set_input_as_handled() + + if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT: + response_selected.emit(response) + elif event.is_action_pressed(&"ui_accept" if next_action.is_empty() else next_action) and item in get_menu_items(): + response_selected.emit(response) diff --git a/addons/dialogue_manager/dialogue_resource.gd b/addons/dialogue_manager/dialogue_resource.gd new file mode 100644 index 00000000..a4065600 --- /dev/null +++ b/addons/dialogue_manager/dialogue_resource.gd @@ -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 _DialogueManager = preload("./dialogue_manager.gd") + +## A list of state shortcuts +@export var using_states: PackedStringArray = [] + +## A map of titles and the lines they point to. +@export var titles: Dictionary = {} + +## A list of character names. +@export var character_names: PackedStringArray = [] + +## The first title in the file. +@export var first_title: String = "" + +## A map of the encoded lines of dialogue. +@export var lines: Dictionary = {} + +## raw version of the text +@export var raw_text: String + + +## Get the next printable line of dialogue, starting from a referenced line ([code]title[/code] can +## be a title string or a stringified line number). Runs any mutations along the way and then returns +## the first dialogue line encountered. +func get_next_dialogue_line(title: String, extra_game_states: Array = [], mutation_behaviour: _DialogueManager.MutationBehaviour = _DialogueManager.MutationBehaviour.Wait) -> DialogueLine: + return await Engine.get_singleton("DialogueManager").get_next_dialogue_line(self, title, extra_game_states, mutation_behaviour) + + +## Get the list of any titles found in the file. +func get_titles() -> PackedStringArray: + return titles.keys() + + +func _to_string() -> String: + return "<DialogueResource titles=\"%s\">" % [",".join(titles.keys())] diff --git a/addons/dialogue_manager/dialogue_response.gd b/addons/dialogue_manager/dialogue_response.gd new file mode 100644 index 00000000..92cec241 --- /dev/null +++ b/addons/dialogue_manager/dialogue_response.gd @@ -0,0 +1,62 @@ +## A response to a line of dialogue, usualy attached to a [code]DialogueLine[/code]. +class_name DialogueResponse extends RefCounted + + +const _DialogueConstants = preload("./constants.gd") + + +## The ID of this response +var id: String + +## The internal type of this dialogue object, always set to [code]TYPE_RESPONSE[/code]. +var type: String = _DialogueConstants.TYPE_RESPONSE + +## The next line ID to use if this response is selected by the player. +var next_id: String = "" + +## [code]true[/code] if the condition of this line was met. +var is_allowed: bool = true + +## A character (depending on the "characters in responses" behaviour setting). +var character: String = "" + +## A dictionary of varialbe replaces for the character name. Generally for internal use only. +var character_replacements: Array[Dictionary] = [] + +## The prompt for this response. +var text: String = "" + +## A dictionary of variable replaces for the text. Generally for internal use only. +var text_replacements: Array[Dictionary] = [] + +## Any #tags +var tags: PackedStringArray = [] + +## The key to use for translating the text. +var translation_key: String = "" + + +func _init(data: Dictionary = {}) -> void: + if data.size() > 0: + id = data.id + type = data.type + next_id = data.next_id + is_allowed = data.is_allowed + character = data.character + character_replacements = data.character_replacements + text = data.text + text_replacements = data.text_replacements + tags = data.tags + translation_key = data.translation_key + + +func _to_string() -> String: + return "<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 "" diff --git a/addons/dialogue_manager/editor_translation_parser_plugin.gd b/addons/dialogue_manager/editor_translation_parser_plugin.gd new file mode 100644 index 00000000..9d404e2a --- /dev/null +++ b/addons/dialogue_manager/editor_translation_parser_plugin.gd @@ -0,0 +1,43 @@ +extends EditorTranslationParserPlugin + + +const DialogueConstants = preload("./constants.gd") +const DialogueSettings = preload("./settings.gd") +const DialogueManagerParseResult = preload("./components/parse_result.gd") + + +func _parse_file(path: String, msgids: Array, msgids_context_plural: Array) -> void: + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + var text: String = file.get_as_text() + + var data: DialogueManagerParseResult = DialogueManagerParser.parse_string(text, path) + var known_keys: PackedStringArray = PackedStringArray([]) + + # Add all character names if settings ask for it + if DialogueSettings.get_setting("export_characters_in_translation", true): + var character_names: PackedStringArray = data.character_names + for character_name in character_names: + if character_name in known_keys: continue + + known_keys.append(character_name) + + msgids_context_plural.append([character_name.replace('"', '\\"'), "dialogue", ""]) + + # Add all dialogue lines and responses + var dialogue: Dictionary = data.lines + for key in dialogue.keys(): + var line: Dictionary = dialogue.get(key) + + if not line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]: continue + if line.translation_key in known_keys: continue + + known_keys.append(line.translation_key) + + if line.translation_key == "" or line.translation_key == line.text: + msgids_context_plural.append([line.text.replace('"', '\\"'), "", ""]) + else: + msgids_context_plural.append([line.text.replace('"', '\\"'), line.translation_key.replace('"', '\\"'), ""]) + + +func _get_recognized_extensions() -> PackedStringArray: + return ["dialogue"] diff --git a/addons/dialogue_manager/example_balloon/ExampleBalloon.cs b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs new file mode 100644 index 00000000..a6574e00 --- /dev/null +++ b/addons/dialogue_manager/example_balloon/ExampleBalloon.cs @@ -0,0 +1,204 @@ +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 + { + isWaitingForInput = false; + balloon.FocusMode = Control.FocusModeEnum.All; + balloon.GrabFocus(); + + if (value == null) + { + QueueFree(); + return; + } + + dialogueLine = value; + UpdateDialogue(); + } + } + + + 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); + })); + + 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 async void Start(Resource dialogueResource, string title, Array<Variant> extraGameStates = null) + { + temporaryGameStates = 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 UpdateDialogue() + { + if (!IsNodeReady()) + { + await ToSignal(this, SignalName.Ready); + } + + // Set up the character name + characterLabel.Visible = !string.IsNullOrEmpty(dialogueLine.Character); + characterLabel.Text = Tr(dialogueLine.Character, "dialogue"); + + // Set up the dialogue + dialogueLabel.Hide(); + dialogueLabel.Set("dialogue_line", dialogueLine); + + // Set up the responses + responsesMenu.Hide(); + responsesMenu.Set("responses", dialogueLine.Responses); + + // Type out the text + balloon.Show(); + willHideBalloon = false; + dialogueLabel.Show(); + if (!string.IsNullOrEmpty(dialogueLine.Text)) + { + dialogueLabel.Call("type_out"); + await ToSignal(dialogueLabel, "finished_typing"); + } + + // Wait for input + if (dialogueLine.Responses.Count > 0) + { + balloon.FocusMode = Control.FocusModeEnum.None; + responsesMenu.Show(); + } + else if (!string.IsNullOrEmpty(dialogueLine.Time)) + { + float time = 0f; + if (!float.TryParse(dialogueLine.Time, out time)) + { + time = dialogueLine.Text.Length * 0.02f; + } + await ToSignal(GetTree().CreateTimer(time), "timeout"); + Next(dialogueLine.NextId); + } + else + { + isWaitingForInput = true; + balloon.FocusMode = Control.FocusModeEnum.All; + balloon.GrabFocus(); + } + } + + + #endregion + + + #region signals + + + private void OnMutated(Dictionary _mutation) + { + isWaitingForInput = false; + willHideBalloon = true; + GetTree().CreateTimer(0.1f).Timeout += () => + { + if (willHideBalloon) + { + willHideBalloon = false; + balloon.Hide(); + } + }; + } + + + #endregion + } +} \ No newline at end of file diff --git a/addons/dialogue_manager/example_balloon/example_balloon.gd b/addons/dialogue_manager/example_balloon/example_balloon.gd new file mode 100644 index 00000000..875e52fb --- /dev/null +++ b/addons/dialogue_manager/example_balloon/example_balloon.gd @@ -0,0 +1,141 @@ +extends CanvasLayer + +## The action to use for advancing the dialogue +@export var next_action: StringName = &"ui_accept" + +## The action to use to skip typing the dialogue +@export var skip_action: StringName = &"ui_cancel" + +@onready var balloon: Control = %Balloon +@onready var character_label: RichTextLabel = %CharacterLabel +@onready var dialogue_label: DialogueLabel = %DialogueLabel +@onready var responses_menu: DialogueResponsesMenu = %ResponsesMenu + +## The dialogue resource +var resource: DialogueResource + +## Temporary game states +var temporary_game_states: Array = [] + +## See if we are waiting for the player +var is_waiting_for_input: bool = false + +## See if we are running a long mutation and should hide the balloon +var will_hide_balloon: bool = false + +## The current line +var dialogue_line: DialogueLine: + set(next_dialogue_line): + is_waiting_for_input = false + balloon.focus_mode = Control.FOCUS_ALL + balloon.grab_focus() + + # The dialogue has finished so close the balloon + if not next_dialogue_line: + queue_free() + return + + # If the node isn't ready yet then none of the labels will be ready yet either + if not is_node_ready(): + await ready + + dialogue_line = next_dialogue_line + + character_label.visible = not dialogue_line.character.is_empty() + character_label.text = tr(dialogue_line.character, "dialogue") + + dialogue_label.hide() + dialogue_label.dialogue_line = dialogue_line + + responses_menu.hide() + responses_menu.set_responses(dialogue_line.responses) + + # Show our balloon + balloon.show() + will_hide_balloon = false + + dialogue_label.show() + if not dialogue_line.text.is_empty(): + dialogue_label.type_out() + await dialogue_label.finished_typing + + # Wait for input + if dialogue_line.responses.size() > 0: + balloon.focus_mode = Control.FOCUS_NONE + responses_menu.show() + elif dialogue_line.time != "": + var time = dialogue_line.text.length() * 0.02 if dialogue_line.time == "auto" else dialogue_line.time.to_float() + await get_tree().create_timer(time).timeout + next(dialogue_line.next_id) + else: + is_waiting_for_input = true + balloon.focus_mode = Control.FOCUS_ALL + balloon.grab_focus() + get: + return dialogue_line + + +func _ready() -> void: + balloon.hide() + Engine.get_singleton("DialogueManager").mutated.connect(_on_mutated) + + # If the responses menu doesn't have a next action set, use this one + if responses_menu.next_action.is_empty(): + responses_menu.next_action = next_action + + +func _unhandled_input(_event: InputEvent) -> void: + # Only the balloon is allowed to handle input while it's showing + get_viewport().set_input_as_handled() + + +## Start some dialogue +func start(dialogue_resource: DialogueResource, title: String, extra_game_states: Array = []) -> void: + temporary_game_states = [self] + extra_game_states + is_waiting_for_input = false + resource = dialogue_resource + self.dialogue_line = await resource.get_next_dialogue_line(title, temporary_game_states) + + +## Go to the next line +func next(next_id: String) -> void: + self.dialogue_line = await resource.get_next_dialogue_line(next_id, temporary_game_states) + + +### Signals + + +func _on_mutated(_mutation: Dictionary) -> void: + is_waiting_for_input = false + will_hide_balloon = true + get_tree().create_timer(0.1).timeout.connect(func(): + if will_hide_balloon: + will_hide_balloon = false + balloon.hide() + ) + + +func _on_balloon_gui_input(event: InputEvent) -> void: + # See if we need to skip typing of the dialogue + if dialogue_label.is_typing: + var mouse_was_clicked: bool = event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed() + var skip_button_was_pressed: bool = event.is_action_pressed(skip_action) + if mouse_was_clicked or skip_button_was_pressed: + get_viewport().set_input_as_handled() + dialogue_label.skip_typing() + return + + if not is_waiting_for_input: return + if dialogue_line.responses.size() > 0: return + + # When there are no response options the balloon itself is the clickable thing + get_viewport().set_input_as_handled() + + if event is InputEventMouseButton and event.is_pressed() and event.button_index == MOUSE_BUTTON_LEFT: + next(dialogue_line.next_id) + elif event.is_action_pressed(next_action) and get_viewport().gui_get_focus_owner() == balloon: + next(dialogue_line.next_id) + + +func _on_responses_menu_response_selected(response: DialogueResponse) -> void: + next(response.next_id) diff --git a/addons/dialogue_manager/example_balloon/example_balloon.tscn b/addons/dialogue_manager/example_balloon/example_balloon.tscn new file mode 100644 index 00000000..6facc260 --- /dev/null +++ b/addons/dialogue_manager/example_balloon/example_balloon.tscn @@ -0,0 +1,149 @@ +[gd_scene load_steps=9 format=3 uid="uid://73jm5qjy52vq"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/example_balloon/example_balloon.gd" id="1_36de5"] +[ext_resource type="PackedScene" uid="uid://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_a8ve6"] +[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_reponses_menu.gd" id="3_72ixx"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_spyqn"] +bg_color = Color(0, 0, 0, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.329412, 0.329412, 0.329412, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ri4m3"] +bg_color = Color(0.121569, 0.121569, 0.121569, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(1, 1, 1, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_e0njw"] +bg_color = Color(0, 0, 0, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.6, 0.6, 0.6, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uy0d5"] +bg_color = Color(0, 0, 0, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="Theme" id="Theme_qq3yp"] +default_font_size = 20 +Button/styles/disabled = SubResource("StyleBoxFlat_spyqn") +Button/styles/focus = SubResource("StyleBoxFlat_ri4m3") +Button/styles/hover = SubResource("StyleBoxFlat_e0njw") +Button/styles/normal = SubResource("StyleBoxFlat_e0njw") +MarginContainer/constants/margin_bottom = 15 +MarginContainer/constants/margin_left = 30 +MarginContainer/constants/margin_right = 30 +MarginContainer/constants/margin_top = 15 +Panel/styles/panel = SubResource("StyleBoxFlat_uy0d5") + +[node name="ExampleBalloon" type="CanvasLayer"] +layer = 100 +script = ExtResource("1_36de5") + +[node name="Balloon" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = SubResource("Theme_qq3yp") + +[node name="Panel" type="Panel" parent="Balloon"] +clip_children = 2 +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 21.0 +offset_top = -183.0 +offset_right = -19.0 +offset_bottom = -19.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 1 + +[node name="Dialogue" type="MarginContainer" parent="Balloon/Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel/Dialogue"] +layout_mode = 2 + +[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/Panel/Dialogue/VBoxContainer"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.501961) +layout_mode = 2 +mouse_filter = 1 +bbcode_enabled = true +text = "Character" +fit_content = true +scroll_active = false + +[node name="DialogueLabel" parent="Balloon/Panel/Dialogue/VBoxContainer" instance=ExtResource("2_a8ve6")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +text = "Dialogue..." + +[node name="Responses" type="MarginContainer" parent="Balloon"] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -147.0 +offset_top = -558.0 +offset_right = 494.0 +offset_bottom = -154.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/Responses" node_paths=PackedStringArray("response_template")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/separation = 2 +script = ExtResource("3_72ixx") +response_template = NodePath("ResponseExample") + +[node name="ResponseExample" type="Button" parent="Balloon/Responses/ResponsesMenu"] +layout_mode = 2 +text = "Response example" + +[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"] +[connection signal="response_selected" from="Balloon/Responses/ResponsesMenu" to="." method="_on_responses_menu_response_selected"] diff --git a/addons/dialogue_manager/example_balloon/small_example_balloon.tscn b/addons/dialogue_manager/example_balloon/small_example_balloon.tscn new file mode 100644 index 00000000..30118337 --- /dev/null +++ b/addons/dialogue_manager/example_balloon/small_example_balloon.tscn @@ -0,0 +1,173 @@ +[gd_scene load_steps=10 format=3 uid="uid://13s5spsk34qu"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/example_balloon/example_balloon.gd" id="1_s2gbs"] +[ext_resource type="PackedScene" uid="uid://ckvgyvclnwggo" path="res://addons/dialogue_manager/dialogue_label.tscn" id="2_hfvdi"] +[ext_resource type="Script" path="res://addons/dialogue_manager/dialogue_reponses_menu.gd" id="3_1j1j0"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_235ry"] +content_margin_left = 6.0 +content_margin_top = 3.0 +content_margin_right = 6.0 +content_margin_bottom = 3.0 +bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.345098, 0.345098, 0.345098, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ufjut"] +content_margin_left = 6.0 +content_margin_top = 3.0 +content_margin_right = 6.0 +content_margin_bottom = 3.0 +bg_color = Color(0.227451, 0.227451, 0.227451, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(1, 1, 1, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_fcbqo"] +content_margin_left = 6.0 +content_margin_top = 3.0 +content_margin_right = 6.0 +content_margin_bottom = 3.0 +bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_t6i7a"] +content_margin_left = 6.0 +content_margin_top = 3.0 +content_margin_right = 6.0 +content_margin_bottom = 3.0 +bg_color = Color(0.0666667, 0.0666667, 0.0666667, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uy0d5"] +bg_color = Color(0, 0, 0, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="Theme" id="Theme_qq3yp"] +default_font_size = 8 +Button/styles/disabled = SubResource("StyleBoxFlat_235ry") +Button/styles/focus = SubResource("StyleBoxFlat_ufjut") +Button/styles/hover = SubResource("StyleBoxFlat_fcbqo") +Button/styles/normal = SubResource("StyleBoxFlat_t6i7a") +MarginContainer/constants/margin_bottom = 4 +MarginContainer/constants/margin_left = 8 +MarginContainer/constants/margin_right = 8 +MarginContainer/constants/margin_top = 4 +Panel/styles/panel = SubResource("StyleBoxFlat_uy0d5") + +[node name="ExampleBalloon" type="CanvasLayer"] +layer = 100 +script = ExtResource("1_s2gbs") + +[node name="Balloon" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = SubResource("Theme_qq3yp") + +[node name="Panel" type="Panel" parent="Balloon"] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 3.0 +offset_top = -62.0 +offset_right = -4.0 +offset_bottom = -4.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="Dialogue" type="MarginContainer" parent="Balloon/Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Balloon/Panel/Dialogue"] +layout_mode = 2 + +[node name="CharacterLabel" type="RichTextLabel" parent="Balloon/Panel/Dialogue/VBoxContainer"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.501961) +layout_mode = 2 +mouse_filter = 1 +bbcode_enabled = true +text = "Character" +fit_content = true +scroll_active = false + +[node name="DialogueLabel" parent="Balloon/Panel/Dialogue/VBoxContainer" instance=ExtResource("2_hfvdi")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +text = "Dialogue..." +skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex") + +[node name="Responses" type="MarginContainer" parent="Balloon"] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -124.0 +offset_top = -218.0 +offset_right = 125.0 +offset_bottom = -50.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="ResponsesMenu" type="VBoxContainer" parent="Balloon/Responses"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/separation = 2 +script = ExtResource("3_1j1j0") + +[node name="ResponseExample" type="Button" parent="Balloon/Responses/ResponsesMenu"] +layout_mode = 2 +text = "Response Example" + +[connection signal="gui_input" from="Balloon" to="." method="_on_balloon_gui_input"] +[connection signal="response_selected" from="Balloon/Responses/ResponsesMenu" to="." method="_on_responses_menu_response_selected"] diff --git a/addons/dialogue_manager/import_plugin.gd b/addons/dialogue_manager/import_plugin.gd new file mode 100644 index 00000000..3f0af15e --- /dev/null +++ b/addons/dialogue_manager/import_plugin.gd @@ -0,0 +1,113 @@ +@tool +extends EditorImportPlugin + + +signal compiled_resource(resource: Resource) + + +const DialogueResource = preload("./dialogue_resource.gd") +const DialogueManagerParseResult = preload("./components/parse_result.gd") + +const compiler_version = 11 + + +func _get_importer_name() -> String: + # NOTE: A change to this forces a re-import of all dialogue + return "dialogue_manager_compiler_%s" % compiler_version + + +func _get_visible_name() -> String: + return "Dialogue" + + +func _get_import_order() -> int: + return -1000 + + +func _get_priority() -> float: + return 1000.0 + + +func _get_resource_type(): + return "Resource" + + +func _get_recognized_extensions() -> PackedStringArray: + return PackedStringArray(["dialogue"]) + + +func _get_save_extension(): + return "tres" + + +func _get_preset_count() -> int: + return 0 + + +func _get_preset_name(preset_index: int) -> String: + return "Unknown" + + +func _get_import_options(path: String, preset_index: int) -> Array: + # When the options array is empty there is a misleading error on export + # that actually means nothing so let's just have an invisible option. + return [{ + name = "defaults", + default_value = true + }] + + +func _get_option_visibility(path: String, option_name: StringName, options: Dictionary) -> bool: + return false + + +func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> Error: + var cache = Engine.get_meta("DialogueCache") + + # Get the raw file contents + if not FileAccess.file_exists(source_file): return ERR_FILE_NOT_FOUND + + var file: FileAccess = FileAccess.open(source_file, FileAccess.READ) + var raw_text: String = file.get_as_text() + + # Parse the text + var parser: DialogueManagerParser = DialogueManagerParser.new() + var err: Error = parser.parse(raw_text, source_file) + var data: DialogueManagerParseResult = parser.get_data() + var errors: Array[Dictionary] = parser.get_errors() + parser.free() + + if err != OK: + printerr("%d errors found in %s" % [errors.size(), source_file]) + cache.add_errors_to_file(source_file, errors) + return err + + # Get the current addon version + var config: ConfigFile = ConfigFile.new() + config.load("res://addons/dialogue_manager/plugin.cfg") + var version: String = config.get_value("plugin", "version") + + # Save the results to a resource + var resource: DialogueResource = DialogueResource.new() + resource.set_meta("dialogue_manager_version", version) + + resource.using_states = data.using_states + resource.titles = data.titles + resource.first_title = data.first_title + resource.character_names = data.character_names + resource.lines = data.lines + resource.raw_text = data.raw_text + + # Clear errors and possibly trigger any cascade recompiles + cache.add_file(source_file, data) + + err = ResourceSaver.save(resource, "%s.%s" % [save_path, _get_save_extension()]) + + compiled_resource.emit(resource) + + # Recompile any dependencies + var dependent_paths: PackedStringArray = cache.get_dependent_paths_for_reimport(source_file) + for path in dependent_paths: + append_import_external_resource(path) + + return err diff --git a/addons/dialogue_manager/l10n/en.mo b/addons/dialogue_manager/l10n/en.mo new file mode 100644 index 00000000..2ab4fdfd Binary files /dev/null and b/addons/dialogue_manager/l10n/en.mo differ diff --git a/addons/dialogue_manager/l10n/en.po b/addons/dialogue_manager/l10n/en.po new file mode 100644 index 00000000..d33f193a --- /dev/null +++ b/addons/dialogue_manager/l10n/en.po @@ -0,0 +1,463 @@ +msgid "" +msgstr "" +"Project-Id-Version: Dialogue Manager\n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.2.2\n" + +msgid "start_a_new_file" +msgstr "Start a new file" + +msgid "open_a_file" +msgstr "Open a file" + +msgid "open.open" +msgstr "Open..." + +msgid "open.no_recent_files" +msgstr "No recent files" + +msgid "open.clear_recent_files" +msgstr "Clear recent files" + +msgid "save_all_files" +msgstr "Save all files" + +msgid "test_dialogue" +msgstr "Test dialogue" + +msgid "search_for_text" +msgstr "Search for text" + +msgid "insert" +msgstr "Insert" + +msgid "translations" +msgstr "Translations" + +msgid "settings" +msgstr "Settings" + +msgid "sponsor" +msgstr "Sponsor" + +msgid "show_support" +msgstr "Support Dialogue Manager" + +msgid "docs" +msgstr "Docs" + +msgid "insert.wave_bbcode" +msgstr "Wave BBCode" + +msgid "insert.shake_bbcode" +msgstr "Shake BBCode" + +msgid "insert.typing_pause" +msgstr "Typing pause" + +msgid "insert.typing_speed_change" +msgstr "Typing speed change" + +msgid "insert.auto_advance" +msgstr "Auto advance" + +msgid "insert.templates" +msgstr "Templates" + +msgid "insert.title" +msgstr "Title" + +msgid "insert.dialogue" +msgstr "Dialogue" + +msgid "insert.response" +msgstr "Response" + +msgid "insert.random_lines" +msgstr "Random lines" + +msgid "insert.random_text" +msgstr "Random text" + +msgid "insert.actions" +msgstr "Actions" + +msgid "insert.jump" +msgstr "Jump to title" + +msgid "insert.end_dialogue" +msgstr "End dialogue" + +msgid "generate_line_ids" +msgstr "Generate line IDs" + +msgid "save_characters_to_csv" +msgstr "Save character names to CSV..." + +msgid "save_to_csv" +msgstr "Save lines to CSV..." + +msgid "import_from_csv" +msgstr "Import line changes from CSV..." + +msgid "confirm_close" +msgstr "Save changes to '{path}'?" + +msgid "confirm_close.save" +msgstr "Save changes" + +msgid "confirm_close.discard" +msgstr "Discard" + +msgid "buffer.save" +msgstr "Save" + +msgid "buffer.save_as" +msgstr "Save as..." + +msgid "buffer.close" +msgstr "Close" + +msgid "buffer.close_all" +msgstr "Close all" + +msgid "buffer.close_other_files" +msgstr "Close other files" + +msgid "buffer.copy_file_path" +msgstr "Copy file path" + +msgid "buffer.show_in_filesystem" +msgstr "Show in FileSystem" + +msgid "settings.invalid_test_scene" +msgstr "\"{path}\" does not extend BaseDialogueTestScene." + +msgid "settings.revert_to_default_test_scene" +msgstr "Revert to default test scene" + +msgid "settings.default_balloon_hint" +msgstr "Custom balloon to use when calling \"DialogueManager.show_balloon()\"" + +msgid "settings.revert_to_default_balloon" +msgstr "Revert to default balloon" + +msgid "settings.default_balloon_path" +msgstr "<example balloon>" + +msgid "settings.autoload" +msgstr "Autoload" + +msgid "settings.path" +msgstr "Path" + +msgid "settings.new_template" +msgstr "New dialogue files will start with template text" + +msgid "settings.missing_keys" +msgstr "Treat missing translation keys as errors" + +msgid "settings.missing_keys_hint" +msgstr "If you are using static translation keys then having this enabled will help you find any lines that you haven't added a key to yet." + +msgid "settings.characters_translations" +msgstr "Export character names in translation files" + +msgid "settings.wrap_long_lines" +msgstr "Wrap long lines" + +msgid "settings.include_failed_responses" +msgstr "Include responses with failed conditions" + +msgid "settings.ignore_missing_state_values" +msgstr "Skip over missing state value errors (not recommended)" + +msgid "settings.custom_test_scene" +msgstr "Custom test scene (must extend BaseDialogueTestScene)" + +msgid "settings.default_csv_locale" +msgstr "Default CSV Locale" + +msgid "settings.states_shortcuts" +msgstr "State Shortcuts" + +msgid "settings.states_message" +msgstr "If an autoload is enabled here you can refer to its properties and methods without having to use its name." + +msgid "settings.states_hint" +msgstr "ie. Instead of \"SomeState.some_property\" you could just use \"some_property\"" + +msgid "settings.recompile_warning" +msgstr "Changing these settings will force a recompile of all dialogue. Only change them if you know what you are doing." + +msgid "settings.create_lines_for_responses_with_characters" +msgstr "Create child dialogue line for responses with character names in them" + +msgid "settings.open_in_external_editor" +msgstr "Open dialogue files in external editor" + +msgid "settings.external_editor_warning" +msgstr "Note: Syntax highlighting and detailed error checking are not supported in external editors." + +msgid "settings.include_characters_in_translations" +msgstr "Include character names in translation exports" + +msgid "settings.include_notes_in_translations" +msgstr "Include notes (## comments) in translation exports" + +msgid "settings.check_for_updates" +msgstr "Check for updates" + +msgid "n_of_n" +msgstr "{index} of {total}" + +msgid "search.previous" +msgstr "Previous" + +msgid "search.next" +msgstr "Next" + +msgid "search.match_case" +msgstr "Match case" + +msgid "search.toggle_replace" +msgstr "Replace" + +msgid "search.replace_with" +msgstr "Replace with:" + +msgid "search.replace" +msgstr "Replace" + +msgid "search.replace_all" +msgstr "Replace all" + +msgid "files_list.filter" +msgstr "Filter files" + +msgid "titles_list.filter" +msgstr "Filter titles" + +msgid "errors.key_not_found" +msgstr "Key \"{key}\" not found." + +msgid "errors.line_and_message" +msgstr "Error at {line}, {column}: {message}" + +msgid "errors_in_script" +msgstr "You have errors in your script. Fix them and then try again." + +msgid "errors_with_build" +msgstr "You need to fix dialogue errors before you can run your game." + +msgid "errors.import_errors" +msgstr "There are errors in this imported file." + +msgid "errors.already_imported" +msgstr "File already imported." + +msgid "errors.duplicate_import" +msgstr "Duplicate import name." + +msgid "errors.unknown_using" +msgstr "Unknown autoload in using statement." + +msgid "errors.empty_title" +msgstr "Titles cannot be empty." + +msgid "errors.duplicate_title" +msgstr "There is already a title with that name." + +msgid "errors.nested_title" +msgstr "Titles cannot be indented." + +msgid "errors.invalid_title_string" +msgstr "Titles can only contain alphanumeric characters and numbers." + +msgid "errors.invalid_title_number" +msgstr "Titles cannot begin with a number." + +msgid "errors.unknown_title" +msgstr "Unknown title." + +msgid "errors.jump_to_invalid_title" +msgstr "This jump is pointing to an invalid title." + +msgid "errors.title_has_no_content" +msgstr "That title has no content. Maybe change this to a \"=> END\"." + +msgid "errors.invalid_expression" +msgstr "Expression is invalid." + +msgid "errors.unexpected_condition" +msgstr "Unexpected condition." + +msgid "errors.duplicate_id" +msgstr "This ID is already on another line." + +msgid "errors.missing_id" +msgstr "This line is missing an ID." + +msgid "errors.invalid_indentation" +msgstr "Invalid indentation." + +msgid "errors.condition_has_no_content" +msgstr "A condition line needs an indented line below it." + +msgid "errors.incomplete_expression" +msgstr "Incomplete expression." + +msgid "errors.invalid_expression_for_value" +msgstr "Invalid expression for value." + +msgid "errors.file_not_found" +msgstr "File not found." + +msgid "errors.unexpected_end_of_expression" +msgstr "Unexpected end of expression." + +msgid "errors.unexpected_function" +msgstr "Unexpected function." + +msgid "errors.unexpected_bracket" +msgstr "Unexpected bracket." + +msgid "errors.unexpected_closing_bracket" +msgstr "Unexpected closing bracket." + +msgid "errors.missing_closing_bracket" +msgstr "Missing closing bracket." + +msgid "errors.unexpected_operator" +msgstr "Unexpected operator." + +msgid "errors.unexpected_comma" +msgstr "Unexpected comma." + +msgid "errors.unexpected_colon" +msgstr "Unexpected colon." + +msgid "errors.unexpected_dot" +msgstr "Unexpected dot." + +msgid "errors.unexpected_boolean" +msgstr "Unexpected boolean." + +msgid "errors.unexpected_string" +msgstr "Unexpected string." + +msgid "errors.unexpected_number" +msgstr "Unexpected number." + +msgid "errors.unexpected_variable" +msgstr "Unexpected variable." + +msgid "errors.invalid_index" +msgstr "Invalid index." + +msgid "errors.unexpected_assignment" +msgstr "Unexpected assignment." + +msgid "errors.unknown" +msgstr "Unknown syntax." + +msgid "update.available" +msgstr "v{version} available" + +msgid "update.is_available_for_download" +msgstr "Version %s is available for download!" + +msgid "update.downloading" +msgstr "Downloading..." + +msgid "update.download_update" +msgstr "Download update" + +msgid "update.needs_reload" +msgstr "The project needs to be reloaded to install the update." + +msgid "update.reload_ok_button" +msgstr "Reload project" + +msgid "update.reload_cancel_button" +msgstr "Do it later" + +msgid "update.reload_project" +msgstr "Reload project" + +msgid "update.release_notes" +msgstr "Read release notes" + +msgid "update.success" +msgstr "Dialogue Manager is now v{version}." + +msgid "update.failed" +msgstr "There was a problem downloading the update." + +msgid "runtime.no_resource" +msgstr "No dialogue resource provided." + +msgid "runtime.no_content" +msgstr "\"{file_path}\" has no content." + +msgid "runtime.errors" +msgstr "You have {count} errors in your dialogue text." + +msgid "runtime.error_detail" +msgstr "Line {line}: {message}" + +msgid "runtime.errors_see_details" +msgstr "You have {count} errors in your dialogue text. See Output for details." + +msgid "runtime.invalid_expression" +msgstr "\"{expression}\" is not a valid expression: {error}" + +msgid "runtime.array_index_out_of_bounds" +msgstr "Index {index} out of bounds of array \"{array}\"." + +msgid "runtime.left_hand_size_cannot_be_assigned_to" +msgstr "Left hand side of expression cannot be assigned to." + +msgid "runtime.key_not_found" +msgstr "Key \"{key}\" not found in dictionary \"{dictionary}\"" + +msgid "runtime.property_not_found" +msgstr "\"{property}\" is not a property on any game states ({states})." + +msgid "runtime.property_not_found_missing_export" +msgstr "\"{property}\" is not a property on any game states ({states}). You might need to add an [Export] decorator." + +msgid "runtime.method_not_found" +msgstr "\"{method}\" is not a method on any game states ({states})" + +msgid "runtime.signal_not_found" +msgstr "\"{signal_name}\" is not a signal on any game states ({states})" + +msgid "runtime.method_not_callable" +msgstr "\"{method}\" is not a callable method on \"{object}\"" + +msgid "runtime.unknown_operator" +msgstr "Unknown operator." + +msgid "runtime.unknown_autoload" +msgstr "\"{autoload}\" doesn't appear to be a valid autoload." + +msgid "runtime.something_went_wrong" +msgstr "Something went wrong." + +msgid "runtime.expected_n_got_n_args" +msgstr "\"{method}\" was called with {received} arguments but it only has {expected}." + +msgid "runtime.unsupported_array_type" +msgstr "Array[{type}] isn't supported in mutations. Use Array as a type instead." + +msgid "runtime.dialogue_balloon_missing_start_method" +msgstr "Your dialogue balloon is missing a \"start\" or \"Start\" method." \ No newline at end of file diff --git a/addons/dialogue_manager/l10n/es.po b/addons/dialogue_manager/l10n/es.po new file mode 100644 index 00000000..3cbaa32e --- /dev/null +++ b/addons/dialogue_manager/l10n/es.po @@ -0,0 +1,457 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: Dialogue Manager\n" +"POT-Creation-Date: 2024-02-25 20:58\n" +"PO-Revision-Date: 2024-02-25 20:58\n" +"Last-Translator: you <you@example.com>\n" +"Language-Team: Spanish <yourteam@example.com>\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "start_a_new_file" +msgstr "Crear un nuevo archivo" + +msgid "open_a_file" +msgstr "Abrir un archivo" + +msgid "open.open" +msgstr "Abrir..." + +msgid "open.no_recent_files" +msgstr "No hay archivos recientes" + +msgid "open.clear_recent_files" +msgstr "Limpiar archivos recientes" + +msgid "save_all_files" +msgstr "Guardar todos los archivos" + +msgid "test_dialogue" +msgstr "Diálogo de prueba" + +msgid "search_for_text" +msgstr "Buscar texto" + +msgid "insert" +msgstr "Insertar" + +msgid "translations" +msgstr "Traducciones" + +msgid "settings" +msgstr "Ajustes" + +msgid "show_support" +msgstr "Contribuye con Dialogue Manager" + +msgid "docs" +msgstr "Docs" + +msgid "insert.wave_bbcode" +msgstr "BBCode ondulado" + +msgid "insert.shake_bbcode" +msgstr "BBCode agitado" + +msgid "insert.typing_pause" +msgstr "Pausa de escritura" + +msgid "insert.typing_speed_change" +msgstr "Cambiar la velocidad de escritura" + +msgid "insert.auto_advance" +msgstr "Avance automático" + +msgid "insert.templates" +msgstr "Plantillas" + +msgid "insert.title" +msgstr "Título" + +msgid "insert.dialogue" +msgstr "Diálogo" + +msgid "insert.response" +msgstr "Respuesta" + +msgid "insert.random_lines" +msgstr "Líneas aleatorias" + +msgid "insert.random_text" +msgstr "Texto aleatorio" + +msgid "insert.actions" +msgstr "Acciones" + +msgid "insert.jump" +msgstr "Ir al título" + +msgid "insert.end_dialogue" +msgstr "Finalizar diálogo" + +msgid "generate_line_ids" +msgstr "Generar IDs de línea" + +msgid "save_characters_to_csv" +msgstr "Guardar los nombres de los personajes en un archivo CSV..." + +msgid "save_to_csv" +msgstr "Guardar líneas en CSV..." + +msgid "import_from_csv" +msgstr "Importar cambios de línea desde CSV..." + +msgid "confirm_close" +msgstr "¿Guardar los cambios en '{path}'?" + +msgid "confirm_close.save" +msgstr "Guardar cambios" + +msgid "confirm_close.discard" +msgstr "Descartar" + +msgid "buffer.save" +msgstr "Guardar" + +msgid "buffer.save_as" +msgstr "Guardar como..." + +msgid "buffer.close" +msgstr "Cerrar" + +msgid "buffer.close_all" +msgstr "Cerrar todo" + +msgid "buffer.close_other_files" +msgstr "Cerrar otros archivos" + +msgid "buffer.copy_file_path" +msgstr "Copiar la ruta del archivo" + +msgid "buffer.show_in_filesystem" +msgstr "Mostrar en el sistema de archivos" + +msgid "settings.invalid_test_scene" +msgstr "\"{path}\" no extiende BaseDialogueTestScene." + +msgid "settings.revert_to_default_test_scene" +msgstr "Revertir a la escena de prueba por defecto" + +msgid "settings.default_balloon_hint" +msgstr "" +"Globo personalizado para usar al llamar a \"DialogueManager.show_balloon()\"" + +msgid "settings.revert_to_default_balloon" +msgstr "Volver al globo predeterminado" + +msgid "settings.default_balloon_path" +msgstr "<globo de ejemplo>" + +msgid "settings.autoload" +msgstr "Autocarga" + +msgid "settings.path" +msgstr "Ruta" + +msgid "settings.new_template" +msgstr "Los nuevos archivos de diálogo empezarán con una plantilla" + +msgid "settings.missing_keys" +msgstr "Tratar las claves de traducción faltantes como errores" + +msgid "settings.missing_keys_hint" +msgstr "Si estás utilizando claves de traducción estáticas, tener esta opción habilitada te ayudará a encontrar cualquier línea a la que aún no le hayas añadido una clave." + +msgid "settings.characters_translations" +msgstr "Exportar nombres de personajes en archivos de traducción" + +msgid "settings.wrap_long_lines" +msgstr "Romper líneas largas" + +msgid "settings.include_failed_responses" +msgstr "Incluir respuestas con condiciones fallidas" + +msgid "settings.ignore_missing_state_values" +msgstr "Omitir errores de valores de estado faltantes (no recomendado)" + +msgid "settings.custom_test_scene" +msgstr "Escena de prueba personalizada (debe extender BaseDialogueTestScene)" + +msgid "settings.default_csv_locale" +msgstr "Localización CSV por defecto" + +msgid "settings.states_shortcuts" +msgstr "Atajos de teclado" + +msgid "settings.states_message" +msgstr "Si un autoload está habilitado aquí, puedes referirte a sus propiedades y métodos sin tener que usar su nombre." + +msgid "settings.states_hint" +msgstr "ie. En lugar de \"SomeState.some_property\" podría simplemente usar \"some_property\"" + +msgid "settings.recompile_warning" +msgstr "Cambiar estos ajustes obligará a recompilar todo el diálogo. Hazlo solo si sabes lo que estás haciendo." + +msgid "settings.create_lines_for_responses_with_characters" +msgstr "Crear línea de diálogo para respuestas con nombres de personajes dentro." + +msgid "settings.open_in_external_editor" +msgstr "Abrir archivos de diálogo en el editor externo" + +msgid "settings.external_editor_warning" +msgstr "Nota: El resaltado de sintaxis y la verificación detallada de errores no están soportados en editores externos." + +msgid "settings.include_characters_in_translations" +msgstr "Incluir nombres de personajes en las exportaciones de traducción" + +msgid "settings.include_notes_in_translations" +msgstr "Incluir notas (## comentarios) en las exportaciones de traducción" + +msgid "n_of_n" +msgstr "{index} de {total}" + +msgid "search.previous" +msgstr "Anterior" + +msgid "search.next" +msgstr "Siguiente" + +msgid "search.match_case" +msgstr "Coincidir mayúsculas/minúsculas" + +msgid "search.toggle_replace" +msgstr "Reemplazar" + +msgid "search.replace_with" +msgstr "Reemplazar con:" + +msgid "search.replace" +msgstr "Reemplazar" + +msgid "search.replace_all" +msgstr "Reemplazar todo" + +msgid "files_list.filter" +msgstr "Filtrar archivos" + +msgid "titles_list.filter" +msgstr "Filtrar títulos" + +msgid "errors.key_not_found" +msgstr "La tecla \"{key}\" no se encuentra." + +msgid "errors.line_and_message" +msgstr "Error en {line}, {column}: {message}" + +msgid "errors_in_script" +msgstr "Tienes errores en tu guion. Corrígelos y luego inténtalo de nuevo." + +msgid "errors_with_build" +msgstr "Debes corregir los errores de diálogo antes de poder ejecutar tu juego." + +msgid "errors.import_errors" +msgstr "Hay errores en este archivo importado." + +msgid "errors.already_imported" +msgstr "Archivo ya importado." + +msgid "errors.duplicate_import" +msgstr "Nombre de importación duplicado." + +msgid "errors.unknown_using" +msgstr "Autoload desconocida en la declaración de uso." + +msgid "errors.empty_title" +msgstr "Los títulos no pueden estar vacíos." + +msgid "errors.duplicate_title" +msgstr "Ya hay un título con ese nombre." + +msgid "errors.nested_title" +msgstr "Los títulos no pueden tener sangría." + +msgid "errors.invalid_title_string" +msgstr "Los títulos solo pueden contener caracteres alfanuméricos y números." + +msgid "errors.invalid_title_number" +msgstr "Los títulos no pueden empezar con un número." + +msgid "errors.unknown_title" +msgstr "Título desconocido." + +msgid "errors.jump_to_invalid_title" +msgstr "Este salto está apuntando a un título inválido." + +msgid "errors.title_has_no_content" +msgstr "Ese título no tiene contenido. Quizá cambiarlo a \"=> FIN\"." + +msgid "errors.invalid_expression" +msgstr "La expresión es inválida." + +msgid "errors.unexpected_condition" +msgstr "Condición inesperada." + +msgid "errors.duplicate_id" +msgstr "Este ID ya está en otra línea." + +msgid "errors.missing_id" +msgstr "Esta línea está sin ID." + +msgid "errors.invalid_indentation" +msgstr "Sangría no válida." + +msgid "errors.condition_has_no_content" +msgstr "Una línea de condición necesita una línea sangrada debajo de ella." + +msgid "errors.incomplete_expression" +msgstr "Expresión incompleta." + +msgid "errors.invalid_expression_for_value" +msgstr "Expresión no válida para valor." + +msgid "errors.file_not_found" +msgstr "Archivo no encontrado." + +msgid "errors.unexpected_end_of_expression" +msgstr "Fin de expresión inesperado." + +msgid "errors.unexpected_function" +msgstr "Función inesperada." + +msgid "errors.unexpected_bracket" +msgstr "Corchete inesperado." + +msgid "errors.unexpected_closing_bracket" +msgstr "Bracket de cierre inesperado." + +msgid "errors.missing_closing_bracket" +msgstr "Falta cerrar corchete." + +msgid "errors.unexpected_operator" +msgstr "Operador inesperado." + +msgid "errors.unexpected_comma" +msgstr "Coma inesperada." + +msgid "errors.unexpected_colon" +msgstr "Dos puntos inesperados" + +msgid "errors.unexpected_dot" +msgstr "Punto inesperado." + +msgid "errors.unexpected_boolean" +msgstr "Booleano inesperado." + +msgid "errors.unexpected_string" +msgstr "String inesperado." + +msgid "errors.unexpected_number" +msgstr "Número inesperado." + +msgid "errors.unexpected_variable" +msgstr "Variable inesperada." + +msgid "errors.invalid_index" +msgstr "Índice no válido." + +msgid "errors.unexpected_assignment" +msgstr "Asignación inesperada." + +msgid "errors.unknown" +msgstr "Sintaxis desconocida." + +msgid "update.available" +msgstr "v{version} disponible" + +msgid "update.is_available_for_download" +msgstr "¡La versión %s ya está disponible para su descarga!" + +msgid "update.downloading" +msgstr "Descargando..." + +msgid "update.download_update" +msgstr "Descargar actualización" + +msgid "update.needs_reload" +msgstr "El proyecto debe ser recargado para instalar la actualización." + +msgid "update.reload_ok_button" +msgstr "Recargar proyecto" + +msgid "update.reload_cancel_button" +msgstr "Hazlo más tarde" + +msgid "update.reload_project" +msgstr "Recargar proyecto" + +msgid "update.release_notes" +msgstr "Leer las notas de la versión" + +msgid "update.success" +msgstr "El Gestor de Diálogo ahora es v{versión}." + +msgid "update.failed" +msgstr "Hubo un problema al descargar la actualización." + +msgid "runtime.no_resource" +msgstr "Recurso de diálogo no proporcionado." + +msgid "runtime.no_content" +msgstr "\"{file_path}\" no tiene contenido." + +msgid "runtime.errors" +msgstr "Tienes {count} errores en tu diálogo de texto." + +msgid "runtime.error_detail" +msgstr "Línea {line}: {message}" + +msgid "runtime.errors_see_details" +msgstr "Tienes {count} errores en tu texto de diálogo. Consulta la salida para más detalles." + +msgid "runtime.invalid_expression" +msgstr "\"{expression}\" no es una expresión válida: {error}" + +msgid "runtime.array_index_out_of_bounds" +msgstr "Índice {index} fuera de los límites del array \"{array}\"." + +msgid "runtime.left_hand_size_cannot_be_assigned_to" +msgstr "El lado izquierdo de la expresión no se puede asignar." + +msgid "runtime.key_not_found" +msgstr "Clave \"{key}\" no encontrada en el diccionario \"{dictionary}\"" + +msgid "runtime.property_not_found" +msgstr "\"{property}\" no es una propiedad en ningún estado del juego ({states})." + +msgid "runtime.property_not_found_missing_export" +msgstr "\"{property}\" no es una propiedad en ningún estado del juego ({states}). Es posible que necesites añadir un decorador [Export]." + +msgid "runtime.method_not_found" +msgstr "\"{method}\" no es un método en ningún estado del juego ({states})" + +msgid "runtime.signal_not_found" +msgstr "\"{signal_name}\" no es una señal en ningún estado del juego ({states})" + +msgid "runtime.method_not_callable" +msgstr "\"{method}\" no es un método llamable en \"{object}\"" + +msgid "runtime.unknown_operator" +msgstr "Operador desconocido." + +msgid "runtime.unknown_autoload" +msgstr "\"{autoload}\" parece no ser un autoload válido." + +msgid "runtime.something_went_wrong" +msgstr "Algo salió mal." + +msgid "runtime.expected_n_got_n_args" +msgstr "El método \"{method}\" se llamó con {received} argumentos, pero solo tiene {expected}." + +msgid "runtime.unsupported_array_type" +msgstr "Array[{type}] no está soportado en mutaciones. Utiliza Array como tipo en su lugar." + +msgid "runtime.dialogue_balloon_missing_start_method" +msgstr "Tu globo de diálogo no tiene un método \"start\" o \"Start\"." diff --git a/addons/dialogue_manager/l10n/translations.pot b/addons/dialogue_manager/l10n/translations.pot new file mode 100644 index 00000000..6881a5ba --- /dev/null +++ b/addons/dialogue_manager/l10n/translations.pot @@ -0,0 +1,453 @@ +msgid "" +msgstr "" +"Project-Id-Version: Dialogue Manager\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8-bit\n" + +msgid "start_a_new_file" +msgstr "" + +msgid "open_a_file" +msgstr "" + +msgid "open.open" +msgstr "" + +msgid "open.no_recent_files" +msgstr "" + +msgid "open.clear_recent_files" +msgstr "" + +msgid "save_all_files" +msgstr "" + +msgid "test_dialogue" +msgstr "" + +msgid "search_for_text" +msgstr "" + +msgid "insert" +msgstr "" + +msgid "translations" +msgstr "" + +msgid "settings" +msgstr "" + +msgid "sponsor" +msgstr "" + +msgid "show_support" +msgstr "" + +msgid "docs" +msgstr "" + +msgid "insert.wave_bbcode" +msgstr "" + +msgid "insert.shake_bbcode" +msgstr "" + +msgid "insert.typing_pause" +msgstr "" + +msgid "insert.typing_speed_change" +msgstr "" + +msgid "insert.auto_advance" +msgstr "" + +msgid "insert.templates" +msgstr "" + +msgid "insert.title" +msgstr "" + +msgid "insert.dialogue" +msgstr "" + +msgid "insert.response" +msgstr "" + +msgid "insert.random_lines" +msgstr "" + +msgid "insert.random_text" +msgstr "" + +msgid "insert.actions" +msgstr "" + +msgid "insert.jump" +msgstr "" + +msgid "insert.end_dialogue" +msgstr "" + +msgid "generate_line_ids" +msgstr "" + +msgid "save_to_csv" +msgstr "" + +msgid "import_from_csv" +msgstr "" + +msgid "confirm_close" +msgstr "" + +msgid "confirm_close.save" +msgstr "" + +msgid "confirm_close.discard" +msgstr "" + +msgid "buffer.save" +msgstr "" + +msgid "buffer.save_as" +msgstr "" + +msgid "buffer.close" +msgstr "" + +msgid "buffer.close_all" +msgstr "" + +msgid "buffer.close_other_files" +msgstr "" + +msgid "buffer.copy_file_path" +msgstr "" + +msgid "buffer.show_in_filesystem" +msgstr "" + +msgid "settings.invalid_test_scene" +msgstr "" + +msgid "settings.revert_to_default_test_scene" +msgstr "" + +msgid "settings.default_balloon_hint" +msgstr "" + +msgid "settings.revert_to_default_balloon" +msgstr "" + +msgid "settings.default_balloon_path" +msgstr "" + +msgid "settings.autoload" +msgstr "" + +msgid "settings.path" +msgstr "" + +msgid "settings.new_template" +msgstr "" + +msgid "settings.missing_keys" +msgstr "" + +msgid "settings.missing_keys_hint" +msgstr "" + +msgid "settings.characters_translations" +msgstr "" + +msgid "settings.wrap_long_lines" +msgstr "" + +msgid "settings.include_failed_responses" +msgstr "" + +msgid "settings.ignore_missing_state_values" +msgstr "" + +msgid "settings.custom_test_scene" +msgstr "" + +msgid "settings.default_csv_locale" +msgstr "" + +msgid "settings.states_shortcuts" +msgstr "" + +msgid "settings.states_message" +msgstr "" + +msgid "settings.states_hint" +msgstr "" + +msgid "settings.recompile_warning" +msgstr "" + +msgid "settings.create_lines_for_responses_with_characters" +msgstr "" + +msgid "settings.open_in_external_editor" +msgstr "" + +msgid "settings.external_editor_warning" +msgstr "" + +msgid "settings.include_characters_in_translations" +msgstr "" + +msgid "settings.include_notes_in_translations" +msgstr "" + +msgid "settings.check_for_updates" +msgstr "" + +msgid "n_of_n" +msgstr "" + +msgid "search.previous" +msgstr "" + +msgid "search.next" +msgstr "" + +msgid "search.match_case" +msgstr "" + +msgid "search.toggle_replace" +msgstr "" + +msgid "search.replace_with" +msgstr "" + +msgid "search.replace" +msgstr "" + +msgid "search.replace_all" +msgstr "" + +msgid "files_list.filter" +msgstr "" + +msgid "titles_list.filter" +msgstr "" + +msgid "errors.key_not_found" +msgstr "" + +msgid "errors.line_and_message" +msgstr "" + +msgid "errors_in_script" +msgstr "" + +msgid "errors_with_build" +msgstr "" + +msgid "errors.import_errors" +msgstr "" + +msgid "errors.already_imported" +msgstr "" + +msgid "errors.duplicate_import" +msgstr "" + +msgid "errors.unknown_using" +msgstr "" + +msgid "errors.empty_title" +msgstr "" + +msgid "errors.duplicate_title" +msgstr "" + +msgid "errors.nested_title" +msgstr "" + +msgid "errors.invalid_title_string" +msgstr "" + +msgid "errors.invalid_title_number" +msgstr "" + +msgid "errors.unknown_title" +msgstr "" + +msgid "errors.jump_to_invalid_title" +msgstr "" + +msgid "errors.title_has_no_content" +msgstr "" + +msgid "errors.invalid_expression" +msgstr "" + +msgid "errors.unexpected_condition" +msgstr "" + +msgid "errors.duplicate_id" +msgstr "" + +msgid "errors.missing_id" +msgstr "" + +msgid "errors.invalid_indentation" +msgstr "" + +msgid "errors.condition_has_no_content" +msgstr "" + +msgid "errors.incomplete_expression" +msgstr "" + +msgid "errors.invalid_expression_for_value" +msgstr "" + +msgid "errors.file_not_found" +msgstr "" + +msgid "errors.unexpected_end_of_expression" +msgstr "" + +msgid "errors.unexpected_function" +msgstr "" + +msgid "errors.unexpected_bracket" +msgstr "" + +msgid "errors.unexpected_closing_bracket" +msgstr "" + +msgid "errors.missing_closing_bracket" +msgstr "" + +msgid "errors.unexpected_operator" +msgstr "" + +msgid "errors.unexpected_comma" +msgstr "" + +msgid "errors.unexpected_colon" +msgstr "" + +msgid "errors.unexpected_dot" +msgstr "" + +msgid "errors.unexpected_boolean" +msgstr "" + +msgid "errors.unexpected_string" +msgstr "" + +msgid "errors.unexpected_number" +msgstr "" + +msgid "errors.unexpected_variable" +msgstr "" + +msgid "errors.invalid_index" +msgstr "" + +msgid "errors.unexpected_assignment" +msgstr "" + +msgid "errors.unknown" +msgstr "" + +msgid "update.available" +msgstr "" + +msgid "update.is_available_for_download" +msgstr "" + +msgid "update.downloading" +msgstr "" + +msgid "update.download_update" +msgstr "" + +msgid "update.needs_reload" +msgstr "" + +msgid "update.reload_ok_button" +msgstr "" + +msgid "update.reload_cancel_button" +msgstr "" + +msgid "update.reload_project" +msgstr "" + +msgid "update.release_notes" +msgstr "" + +msgid "update.success" +msgstr "" + +msgid "update.failed" +msgstr "" + +msgid "runtime.no_resource" +msgstr "" + +msgid "runtime.no_content" +msgstr "" + +msgid "runtime.errors" +msgstr "" + +msgid "runtime.error_detail" +msgstr "" + +msgid "runtime.errors_see_details" +msgstr "" + +msgid "runtime.invalid_expression" +msgstr "" + +msgid "runtime.array_index_out_of_bounds" +msgstr "" + +msgid "runtime.left_hand_size_cannot_be_assigned_to" +msgstr "" + +msgid "runtime.key_not_found" +msgstr "" + +msgid "runtime.property_not_found" +msgstr "" + +msgid "runtime.property_not_found_missing_export" +msgstr "" + +msgid "runtime.method_not_found" +msgstr "" + +msgid "runtime.signal_not_found" +msgstr "" + +msgid "runtime.method_not_callable" +msgstr "" + +msgid "runtime.unknown_operator" +msgstr "" + +msgid "runtime.unknown_autoload" +msgstr "" + +msgid "runtime.something_went_wrong" +msgstr "" + +msgid "runtime.expected_n_got_n_args" +msgstr "" + +msgid "runtime.unsupported_array_type" +msgstr "" + +msgid "runtime.dialogue_balloon_missing_start_method" +msgstr "" \ No newline at end of file diff --git a/addons/dialogue_manager/l10n/zh.po b/addons/dialogue_manager/l10n/zh.po new file mode 100644 index 00000000..b7f032f9 --- /dev/null +++ b/addons/dialogue_manager/l10n/zh.po @@ -0,0 +1,408 @@ +msgid "" +msgstr "" +"Project-Id-Version: Dialogue Manager\n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: penghao123456、憨憨羊の宇航鸽鸽\n" +"Language: zh\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.4\n" + +msgid "start_a_new_file" +msgstr "创建新文件" + +msgid "open_a_file" +msgstr "打开已有文件" + +msgid "open.open" +msgstr "打开……" + +msgid "open.no_recent_files" +msgstr "无历史记录" + +msgid "open.clear_recent_files" +msgstr "清空历史记录" + +msgid "save_all_files" +msgstr "保存所有文件" + +msgid "test_dialogue" +msgstr "测试对话" + +msgid "search_for_text" +msgstr "查找……" + +msgid "insert" +msgstr "插入" + +msgid "translations" +msgstr "翻译" + +msgid "settings" +msgstr "设置" + +msgid "show_support" +msgstr "支持 Dialogue Manager" + +msgid "docs" +msgstr "文档" + +msgid "insert.wave_bbcode" +msgstr "BBCode [lb]wave[rb]" + +msgid "insert.shake_bbcode" +msgstr "BBCode [lb]wave[rb]" + +msgid "insert.typing_pause" +msgstr "输入间隔" + +msgid "insert.typing_speed_change" +msgstr "输入速度变更" + +msgid "insert.auto_advance" +msgstr "自动切行" + +msgid "insert.templates" +msgstr "模板" + +msgid "insert.title" +msgstr "标题" + +msgid "insert.dialogue" +msgstr "对话" + +msgid "insert.response" +msgstr "回复选项" + +msgid "insert.random_lines" +msgstr "随机行" + +msgid "insert.random_text" +msgstr "随机文本" + +msgid "insert.actions" +msgstr "操作" + +msgid "insert.jump" +msgstr "标题间跳转" + +msgid "insert.end_dialogue" +msgstr "结束对话" + +msgid "generate_line_ids" +msgstr "生成行 ID" + +msgid "save_to_csv" +msgstr "生成 CSV" + +msgid "import_from_csv" +msgstr "从 CSV 导入" + +msgid "confirm_close" +msgstr "是否要保存到“{path}”?" + +msgid "confirm_close.save" +msgstr "保存" + +msgid "confirm_close.discard" +msgstr "不保存" + +msgid "buffer.save" +msgstr "保存" + +msgid "buffer.save_as" +msgstr "另存为……" + +msgid "buffer.close" +msgstr "关闭" + +msgid "buffer.close_all" +msgstr "全部关闭" + +msgid "buffer.close_other_files" +msgstr "关闭其他文件" + +msgid "buffer.copy_file_path" +msgstr "复制文件路径" + +msgid "buffer.show_in_filesystem" +msgstr "在 Godot 侧边栏中显示" + +msgid "settings.revert_to_default_test_scene" +msgstr "重置测试场景设定" + +msgid "settings.autoload" +msgstr "Autoload" + +msgid "settings.path" +msgstr "路径" + +msgid "settings.new_template" +msgstr "新建文件时自动插入模板" + +msgid "settings.missing_keys" +msgstr "将翻译键缺失视为错误" + +msgid "settings.missing_keys_hint" +msgstr "如果你使用静态键,这将会帮助你寻找未添加至翻译文件的键。" + +msgid "settings.characters_translations" +msgstr "在翻译文件中导出角色名。" + +msgid "settings.wrap_long_lines" +msgstr "自动折行" + +msgid "settings.include_failed_responses" +msgstr "在判断条件失败时仍显示回复选项" + +msgid "settings.ignore_missing_state_values" +msgstr "忽略全局变量缺失错误(不建议)" + +msgid "settings.custom_test_scene" +msgstr "自定义测试场景(必须继承自BaseDialogueTestScene)" + +msgid "settings.default_csv_locale" +msgstr "默认 CSV 区域格式" + +msgid "settings.states_shortcuts" +msgstr "全局变量映射" + +msgid "settings.states_message" +msgstr "当一个 Autoload 在这里被勾选,他的所有成员会被映射为全局变量。" + +msgid "settings.states_hint" +msgstr "比如,当你开启对于“Foo”的映射时,你可以将“Foo.bar”简写成“bar”。" + +msgid "n_of_n" +msgstr "第{index}个,共{total}个" + +msgid "search.previous" +msgstr "查找上一个" + +msgid "search.next" +msgstr "查找下一个" + +msgid "search.match_case" +msgstr "大小写敏感" + +msgid "search.toggle_replace" +msgstr "替换" + +msgid "search.replace_with" +msgstr "替换为" + +msgid "search.replace" +msgstr "替换" + +msgid "search.replace_all" +msgstr "全部替换" + +msgid "files_list.filter" +msgstr "查找文件" + +msgid "titles_list.filter" +msgstr "查找标题" + +msgid "errors.key_not_found" +msgstr "键“{key}”未找到" + +msgid "errors.line_and_message" +msgstr "第{line}行第{colume}列发生错误:{message}" + +msgid "errors_in_script" +msgstr "你的脚本中存在错误。请修复错误,然后重试。" + +msgid "errors_with_build" +msgstr "请先解决 Dialogue 中的错误。" + +msgid "errors.import_errors" +msgstr "被导入的文件存在问题。" + +msgid "errors.already_imported" +msgstr "文件已被导入。" + +msgid "errors.duplicate_import" +msgstr "导入名不能重复。" + +msgid "errors.empty_title" +msgstr "标题名不能为空。" + +msgid "errors.duplicate_title" +msgstr "标题名不能重复。" + +msgid "errors.nested_title" +msgstr "标题不能嵌套。" + +msgid "errors.invalid_title_string" +msgstr "标题名无效。" + +msgid "errors.invalid_title_number" +msgstr "标题不能以数字开始。" + +msgid "errors.unknown_title" +msgstr "标题未定义。" + +msgid "errors.jump_to_invalid_title" +msgstr "标题名无效。" + +msgid "errors.title_has_no_content" +msgstr "目标标题为空。请替换为“=> END”。" + +msgid "errors.invalid_expression" +msgstr "表达式无效。" + +msgid "errors.unexpected_condition" +msgstr "未知条件。" + +msgid "errors.duplicate_id" +msgstr "ID 重复。" + +msgid "errors.missing_id" +msgstr "ID 不存在。" + +msgid "errors.invalid_indentation" +msgstr "缩进无效。" + +msgid "errors.condition_has_no_content" +msgstr "条件下方不能为空。" + +msgid "errors.incomplete_expression" +msgstr "不完整的表达式。" + +msgid "errors.invalid_expression_for_value" +msgstr "无效的赋值表达式。" + +msgid "errors.file_not_found" +msgstr "文件不存在。" + +msgid "errors.unexpected_end_of_expression" +msgstr "表达式 end 不应存在。" + +msgid "errors.unexpected_function" +msgstr "函数不应存在。" + +msgid "errors.unexpected_bracket" +msgstr "方括号不应存在。" + +msgid "errors.unexpected_closing_bracket" +msgstr "方括号不应存在。" + +msgid "errors.missing_closing_bracket" +msgstr "闭方括号不存在。" + +msgid "errors.unexpected_operator" +msgstr "操作符不应存在。" + +msgid "errors.unexpected_comma" +msgstr "逗号不应存在。" + +msgid "errors.unexpected_colon" +msgstr "冒号不应存在。" + +msgid "errors.unexpected_dot" +msgstr "句号不应存在。" + +msgid "errors.unexpected_boolean" +msgstr "布尔值不应存在。" + +msgid "errors.unexpected_string" +msgstr "字符串不应存在。" + +msgid "errors.unexpected_number" +msgstr "数字不应存在。" + +msgid "errors.unexpected_variable" +msgstr "标识符不应存在。" + +msgid "errors.invalid_index" +msgstr "索引无效。" + +msgid "errors.unexpected_assignment" +msgstr "不应在条件判断中使用 = ,应使用 == 。" + +msgid "errors.unknown" +msgstr "语法错误。" + +msgid "update.available" +msgstr "v{version} 更新可用。" + +msgid "update.is_available_for_download" +msgstr "v%s 已经可以下载。" + +msgid "update.downloading" +msgstr "正在下载更新……" + +msgid "update.download_update" +msgstr "下载" + +msgid "update.needs_reload" +msgstr "需要重新加载项目以应用更新。" + +msgid "update.reload_ok_button" +msgstr "重新加载" + +msgid "update.reload_cancel_button" +msgstr "暂不重新加载" + +msgid "update.reload_project" +msgstr "重新加载" + +msgid "update.release_notes" +msgstr "查看发行注记" + +msgid "update.success" +msgstr "v{version} 已成功安装并应用。" + +msgid "update.failed" +msgstr "更新失败。" + +msgid "runtime.no_resource" +msgstr "找不到资源。" + +msgid "runtime.no_content" +msgstr "资源“{file_path}”为空。" + +msgid "runtime.errors" +msgstr "文件中存在{errrors}个错误。" + +msgid "runtime.error_detail" +msgstr "第{index}行:{message}" + +msgid "runtime.errors_see_details" +msgstr "文件中存在{errrors}个错误。请查看调试输出。" + +msgid "runtime.invalid_expression" +msgstr "表达式“{expression}”无效:{error}" + +msgid "runtime.array_index_out_of_bounds" +msgstr "数组索引“{index}”越界。(数组名:“{array}”)" + +msgid "runtime.left_hand_size_cannot_be_assigned_to" +msgstr "表达式左侧的变量无法被赋值。" + +msgid "runtime.key_not_found" +msgstr "键“{key}”在字典“{dictionary}”中不存在。" + +msgid "runtime.property_not_found" +msgstr "“{property}”不存在。(全局变量:{states})" + +msgid "runtime.property_not_found_missing_export" +msgstr "“{property}”不存在。(全局变量:{states})你可能需要添加一个修饰词 [Export]。" + +msgid "runtime.method_not_found" +msgstr "“{method}”不存在。(全局变量:{states})" + +msgid "runtime.signal_not_found" +msgstr "“{sighal_name}”不存在。(全局变量:{states})" + +msgid "runtime.method_not_callable" +msgstr "{method}不是对象“{object}”上的函数。" + +msgid "runtime.unknown_operator" +msgstr "未知操作符。" + +msgid "runtime.something_went_wrong" +msgstr "有什么出错了。" diff --git a/addons/dialogue_manager/l10n/zh_TW.po b/addons/dialogue_manager/l10n/zh_TW.po new file mode 100644 index 00000000..3bcf1532 --- /dev/null +++ b/addons/dialogue_manager/l10n/zh_TW.po @@ -0,0 +1,408 @@ +msgid "" +msgstr "" +"Project-Id-Version: Dialogue Manager\n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: 憨憨羊の宇航鴿鴿\n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.4\n" + +msgid "start_a_new_file" +msgstr "創建新檔案" + +msgid "open_a_file" +msgstr "開啟已有檔案" + +msgid "open.open" +msgstr "開啟……" + +msgid "open.no_recent_files" +msgstr "無歷史記錄" + +msgid "open.clear_recent_files" +msgstr "清空歷史記錄" + +msgid "save_all_files" +msgstr "儲存所有檔案" + +msgid "test_dialogue" +msgstr "測試對話" + +msgid "search_for_text" +msgstr "搜尋……" + +msgid "insert" +msgstr "插入" + +msgid "translations" +msgstr "翻譯" + +msgid "settings" +msgstr "設定" + +msgid "show_support" +msgstr "支援 Dialogue Manager" + +msgid "docs" +msgstr "文檔" + +msgid "insert.wave_bbcode" +msgstr "BBCode [lb]wave[rb]" + +msgid "insert.shake_bbcode" +msgstr "BBCode [lb]wave[rb]" + +msgid "insert.typing_pause" +msgstr "輸入間隔" + +msgid "insert.typing_speed_change" +msgstr "輸入速度變更" + +msgid "insert.auto_advance" +msgstr "自動切行" + +msgid "insert.templates" +msgstr "模板" + +msgid "insert.title" +msgstr "標題" + +msgid "insert.dialogue" +msgstr "對話" + +msgid "insert.response" +msgstr "回覆選項" + +msgid "insert.random_lines" +msgstr "隨機行" + +msgid "insert.random_text" +msgstr "隨機文本" + +msgid "insert.actions" +msgstr "操作" + +msgid "insert.jump" +msgstr "標題間跳轉" + +msgid "insert.end_dialogue" +msgstr "結束對話" + +msgid "generate_line_ids" +msgstr "生成行 ID" + +msgid "save_to_csv" +msgstr "生成 CSV" + +msgid "import_from_csv" +msgstr "從 CSV 匯入" + +msgid "confirm_close" +msgstr "是否要儲存到“{path}”?" + +msgid "confirm_close.save" +msgstr "儲存" + +msgid "confirm_close.discard" +msgstr "不儲存" + +msgid "buffer.save" +msgstr "儲存" + +msgid "buffer.save_as" +msgstr "儲存爲……" + +msgid "buffer.close" +msgstr "關閉" + +msgid "buffer.close_all" +msgstr "全部關閉" + +msgid "buffer.close_other_files" +msgstr "關閉其他檔案" + +msgid "buffer.copy_file_path" +msgstr "複製檔案位置" + +msgid "buffer.show_in_filesystem" +msgstr "在 Godot 側邊欄中顯示" + +msgid "settings.revert_to_default_test_scene" +msgstr "重置測試場景設定" + +msgid "settings.autoload" +msgstr "Autoload" + +msgid "settings.path" +msgstr "路徑" + +msgid "settings.new_template" +msgstr "新建檔案時自動插入模板" + +msgid "settings.missing_keys" +msgstr "將翻譯鍵缺失視爲錯誤" + +msgid "settings.missing_keys_hint" +msgstr "如果你使用靜態鍵,這將會幫助你尋找未添加至翻譯檔案的鍵。" + +msgid "settings.wrap_long_lines" +msgstr "自動折行" + +msgid "settings.characters_translations" +msgstr "在翻譯檔案中匯出角色名。" + +msgid "settings.include_failed_responses" +msgstr "在判斷條件失敗時仍顯示回復選項" + +msgid "settings.ignore_missing_state_values" +msgstr "忽略全局變量缺失錯誤(不建議)" + +msgid "settings.custom_test_scene" +msgstr "自訂測試場景(必須繼承自BaseDialogueTestScene)" + +msgid "settings.default_csv_locale" +msgstr "預設 CSV 區域格式" + +msgid "settings.states_shortcuts" +msgstr "全局變量映射" + +msgid "settings.states_message" +msgstr "當一個 Autoload 在這裏被勾選,他的所有成員會被映射爲全局變量。" + +msgid "settings.states_hint" +msgstr "比如,當你開啓對於“Foo”的映射時,你可以將“Foo.bar”簡寫成“bar”。" + +msgid "n_of_n" +msgstr "第{index}個,共{total}個" + +msgid "search.previous" +msgstr "搜尋上一個" + +msgid "search.next" +msgstr "搜尋下一個" + +msgid "search.match_case" +msgstr "大小寫敏感" + +msgid "search.toggle_replace" +msgstr "替換" + +msgid "search.replace_with" +msgstr "替換爲" + +msgid "search.replace" +msgstr "替換" + +msgid "search.replace_all" +msgstr "全部替換" + +msgid "files_list.filter" +msgstr "搜尋檔案" + +msgid "titles_list.filter" +msgstr "搜尋標題" + +msgid "errors.key_not_found" +msgstr "鍵“{key}”未找到" + +msgid "errors.line_and_message" +msgstr "第{line}行第{colume}列發生錯誤:{message}" + +msgid "errors_in_script" +msgstr "你的腳本中存在錯誤。請修復錯誤,然後重試。" + +msgid "errors_with_build" +msgstr "請先解決 Dialogue 中的錯誤。" + +msgid "errors.import_errors" +msgstr "被匯入的檔案存在問題。" + +msgid "errors.already_imported" +msgstr "檔案已被匯入。" + +msgid "errors.duplicate_import" +msgstr "匯入名不能重複。" + +msgid "errors.empty_title" +msgstr "標題名不能爲空。" + +msgid "errors.duplicate_title" +msgstr "標題名不能重複。" + +msgid "errors.nested_title" +msgstr "標題不能嵌套。" + +msgid "errors.invalid_title_string" +msgstr "標題名無效。" + +msgid "errors.invalid_title_number" +msgstr "標題不能以數字開始。" + +msgid "errors.unknown_title" +msgstr "標題未定義。" + +msgid "errors.jump_to_invalid_title" +msgstr "標題名無效。" + +msgid "errors.title_has_no_content" +msgstr "目標標題爲空。請替換爲“=> END”。" + +msgid "errors.invalid_expression" +msgstr "表達式無效。" + +msgid "errors.unexpected_condition" +msgstr "未知條件。" + +msgid "errors.duplicate_id" +msgstr "ID 重複。" + +msgid "errors.missing_id" +msgstr "ID 不存在。" + +msgid "errors.invalid_indentation" +msgstr "縮進無效。" + +msgid "errors.condition_has_no_content" +msgstr "條件下方不能爲空。" + +msgid "errors.incomplete_expression" +msgstr "不完整的表達式。" + +msgid "errors.invalid_expression_for_value" +msgstr "無效的賦值表達式。" + +msgid "errors.file_not_found" +msgstr "檔案不存在。" + +msgid "errors.unexpected_end_of_expression" +msgstr "表達式 end 不應存在。" + +msgid "errors.unexpected_function" +msgstr "函數不應存在。" + +msgid "errors.unexpected_bracket" +msgstr "方括號不應存在。" + +msgid "errors.unexpected_closing_bracket" +msgstr "方括號不應存在。" + +msgid "errors.missing_closing_bracket" +msgstr "閉方括號不存在。" + +msgid "errors.unexpected_operator" +msgstr "操作符不應存在。" + +msgid "errors.unexpected_comma" +msgstr "逗號不應存在。" + +msgid "errors.unexpected_colon" +msgstr "冒號不應存在。" + +msgid "errors.unexpected_dot" +msgstr "句號不應存在。" + +msgid "errors.unexpected_boolean" +msgstr "布爾值不應存在。" + +msgid "errors.unexpected_string" +msgstr "字符串不應存在。" + +msgid "errors.unexpected_number" +msgstr "數字不應存在。" + +msgid "errors.unexpected_variable" +msgstr "標識符不應存在。" + +msgid "errors.invalid_index" +msgstr "索引無效。" + +msgid "errors.unexpected_assignment" +msgstr "不應在條件判斷中使用 = ,應使用 == 。" + +msgid "errors.unknown" +msgstr "語法錯誤。" + +msgid "update.available" +msgstr "v{version} 更新可用。" + +msgid "update.is_available_for_download" +msgstr "v%s 已經可以下載。" + +msgid "update.downloading" +msgstr "正在下載更新……" + +msgid "update.download_update" +msgstr "下載" + +msgid "update.needs_reload" +msgstr "需要重新加載項目以套用更新。" + +msgid "update.reload_ok_button" +msgstr "重新加載" + +msgid "update.reload_cancel_button" +msgstr "暫不重新加載" + +msgid "update.reload_project" +msgstr "重新加載" + +msgid "update.release_notes" +msgstr "查看發行註記" + +msgid "update.success" +msgstr "v{version} 已成功安裝並套用。" + +msgid "update.failed" +msgstr "更新失敗。" + +msgid "runtime.no_resource" +msgstr "找不到資源。" + +msgid "runtime.no_content" +msgstr "資源“{file_path}”爲空。" + +msgid "runtime.errors" +msgstr "檔案中存在{errrors}個錯誤。" + +msgid "runtime.error_detail" +msgstr "第{index}行:{message}" + +msgid "runtime.errors_see_details" +msgstr "檔案中存在{errrors}個錯誤。請查看調試輸出。" + +msgid "runtime.invalid_expression" +msgstr "表達式“{expression}”無效:{error}" + +msgid "runtime.array_index_out_of_bounds" +msgstr "數組索引“{index}”越界。(數組名:“{array}”)" + +msgid "runtime.left_hand_size_cannot_be_assigned_to" +msgstr "表達式左側的變量無法被賦值。" + +msgid "runtime.key_not_found" +msgstr "鍵“{key}”在字典“{dictionary}”中不存在。" + +msgid "runtime.property_not_found" +msgstr "“{property}”不存在。(全局變量:{states})" + +msgid "runtime.method_not_found" +msgstr "“{method}”不存在。(全局變量:{states})" + +msgid "runtime.signal_not_found" +msgstr "“{sighal_name}”不存在。(全局變量:{states})" + +msgid "runtime.property_not_found_missing_export" +msgstr "“{property}”不存在。(全局變量:{states})你可能需要添加一個修飾詞 [Export]。" + +msgid "runtime.method_not_callable" +msgstr "{method}不是對象“{object}”上的函數。" + +msgid "runtime.unknown_operator" +msgstr "未知操作符。" + +msgid "runtime.something_went_wrong" +msgstr "有什麼出錯了。" diff --git a/addons/dialogue_manager/plugin.cfg b/addons/dialogue_manager/plugin.cfg new file mode 100644 index 00000000..4a6ca637 --- /dev/null +++ b/addons/dialogue_manager/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Dialogue Manager" +description="A simple but powerful branching dialogue system" +author="Nathan Hoad" +version="2.37.1" +script="plugin.gd" diff --git a/addons/dialogue_manager/plugin.gd b/addons/dialogue_manager/plugin.gd new file mode 100644 index 00000000..063b8ebf --- /dev/null +++ b/addons/dialogue_manager/plugin.gd @@ -0,0 +1,263 @@ +@tool +extends EditorPlugin + + +const DialogueConstants = preload("./constants.gd") +const DialogueImportPlugin = preload("./import_plugin.gd") +const DialogueTranslationParserPlugin = preload("./editor_translation_parser_plugin.gd") +const DialogueSettings = preload("./settings.gd") +const DialogueCache = preload("./components/dialogue_cache.gd") +const MainView = preload("./views/main_view.tscn") + + +var import_plugin: DialogueImportPlugin +var translation_parser_plugin: DialogueTranslationParserPlugin +var main_view +var dialogue_cache: DialogueCache + + +func _enter_tree() -> void: + add_autoload_singleton("DialogueManager", get_plugin_path() + "/dialogue_manager.gd") + + if Engine.is_editor_hint(): + Engine.set_meta("DialogueManagerPlugin", self) + + DialogueSettings.prepare() + + import_plugin = DialogueImportPlugin.new() + add_import_plugin(import_plugin) + + translation_parser_plugin = DialogueTranslationParserPlugin.new() + add_translation_parser_plugin(translation_parser_plugin) + + main_view = MainView.instantiate() + main_view.editor_plugin = self + get_editor_interface().get_editor_main_screen().add_child(main_view) + _make_visible(false) + + dialogue_cache = DialogueCache.new() + main_view.add_child(dialogue_cache) + Engine.set_meta("DialogueCache", dialogue_cache) + + _update_localization() + + get_editor_interface().get_file_system_dock().files_moved.connect(_on_files_moved) + get_editor_interface().get_file_system_dock().file_removed.connect(_on_file_removed) + + add_tool_menu_item("Create copy of dialogue example balloon...", _copy_dialogue_balloon) + + +func _exit_tree() -> void: + remove_autoload_singleton("DialogueManager") + + remove_import_plugin(import_plugin) + import_plugin = null + + remove_translation_parser_plugin(translation_parser_plugin) + translation_parser_plugin = null + + if is_instance_valid(main_view): + main_view.queue_free() + + Engine.remove_meta("DialogueManagerPlugin") + Engine.remove_meta("DialogueCache") + + get_editor_interface().get_file_system_dock().files_moved.disconnect(_on_files_moved) + get_editor_interface().get_file_system_dock().file_removed.disconnect(_on_file_removed) + + remove_tool_menu_item("Create copy of dialogue example balloon...") + + +func _has_main_screen() -> bool: + return true + + +func _make_visible(next_visible: bool) -> void: + if is_instance_valid(main_view): + main_view.visible = next_visible + + +func _get_plugin_name() -> String: + return "Dialogue" + + +func _get_plugin_icon() -> Texture2D: + return load(get_plugin_path() + "/assets/icon.svg") + + +func _handles(object) -> bool: + var editor_settings: EditorSettings = get_editor_interface().get_editor_settings() + var external_editor: String = editor_settings.get_setting("text_editor/external/exec_path") + var use_external_editor: bool = editor_settings.get_setting("text_editor/external/use_external_editor") and external_editor != "" + if object is DialogueResource and use_external_editor and DialogueSettings.get_user_value("open_in_external_editor", false): + var project_path: String = ProjectSettings.globalize_path("res://") + var file_path: String = ProjectSettings.globalize_path(object.resource_path) + OS.create_process(external_editor, [project_path, file_path]) + return false + + return object is DialogueResource + + +func _edit(object) -> void: + if is_instance_valid(main_view) and is_instance_valid(object): + main_view.open_resource(object) + + +func _apply_changes() -> void: + if is_instance_valid(main_view): + main_view.apply_changes() + _update_localization() + + +func _build() -> bool: + # If this is the dotnet Godot then we need to check if the solution file exists + if ProjectSettings.has_setting("dotnet/project/solution_directory"): + var directory: String = ProjectSettings.get("dotnet/project/solution_directory") + var file_name: String = ProjectSettings.get("dotnet/project/assembly_name") + var has_dotnet_solution: bool = FileAccess.file_exists("res://%s/%s.sln" % [directory, file_name]) + DialogueSettings.set_user_value("has_dotnet_solution", has_dotnet_solution) + + # Ignore errors in other files if we are just running the test scene + if DialogueSettings.get_user_value("is_running_test_scene", true): return true + + if dialogue_cache != null: + var files_with_errors = dialogue_cache.get_files_with_errors() + if files_with_errors.size() > 0: + for dialogue_file in files_with_errors: + push_error("You have %d error(s) in %s" % [dialogue_file.errors.size(), dialogue_file.path]) + get_editor_interface().edit_resource(load(files_with_errors[0].path)) + main_view.show_build_error_dialog() + return false + + return true + + +## Get the current version +func get_version() -> String: + var config: ConfigFile = ConfigFile.new() + config.load(get_plugin_path() + "/plugin.cfg") + return config.get_value("plugin", "version") + + +## Get the current path of the plugin +func get_plugin_path() -> String: + return get_script().resource_path.get_base_dir() + + +## Update references to a moved file +func update_import_paths(from_path: String, to_path: String) -> void: + dialogue_cache.move_file_path(from_path, to_path) + + # Reopen the file if it's already open + if main_view.current_file_path == from_path: + if to_path == "": + main_view.close_file(from_path) + else: + main_view.current_file_path = "" + main_view.open_file(to_path) + + # Update any other files that import the moved file + var dependents = dialogue_cache.get_files_with_dependency(from_path) + for dependent in dependents: + dependent.dependencies.remove_at(dependent.dependencies.find(from_path)) + dependent.dependencies.append(to_path) + + # Update the live buffer + if main_view.current_file_path == dependent.path: + main_view.code_edit.text = main_view.code_edit.text.replace(from_path, to_path) + main_view.pristine_text = main_view.code_edit.text + + # Open the file and update the path + var file: FileAccess = FileAccess.open(dependent.path, FileAccess.READ) + var text = file.get_as_text().replace(from_path, to_path) + file.close() + + file = FileAccess.open(dependent.path, FileAccess.WRITE) + file.store_string(text) + file.close() + + +func _update_localization() -> void: + var dialogue_files = dialogue_cache.get_files() + + # Add any new files to POT generation + var files_for_pot: PackedStringArray = ProjectSettings.get_setting("internationalization/locale/translations_pot_files", []) + var files_for_pot_changed: bool = false + for path in dialogue_files: + if not files_for_pot.has(path): + files_for_pot.append(path) + files_for_pot_changed = true + + # Remove any POT references that don't exist any more + for i in range(files_for_pot.size() - 1, -1, -1): + var file_for_pot: String = files_for_pot[i] + if file_for_pot.get_extension() == "dialogue" and not dialogue_files.has(file_for_pot): + files_for_pot.remove_at(i) + files_for_pot_changed = true + + # Update project settings if POT changed + if files_for_pot_changed: + ProjectSettings.set_setting("internationalization/locale/translations_pot_files", files_for_pot) + ProjectSettings.save() + + +### Callbacks + + +func _copy_dialogue_balloon() -> void: + var scale: float = get_editor_interface().get_editor_scale() + var directory_dialog: FileDialog = FileDialog.new() + var label: Label = Label.new() + label.text = "Dialogue balloon files will be copied into chosen directory." + directory_dialog.get_vbox().add_child(label) + directory_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + directory_dialog.min_size = Vector2(600, 500) * scale + directory_dialog.dir_selected.connect(func(path): + var plugin_path: String = get_plugin_path() + + var is_dotnet: bool = DialogueSettings.has_dotnet_solution() + var balloon_path: String = path + ("/Balloon.tscn" if is_dotnet else "/balloon.tscn") + var balloon_script_path: String = path + ("/DialogueBalloon.cs" if is_dotnet else "/balloon.gd") + + # Copy the balloon scene file and change the script reference + var is_small_window: bool = ProjectSettings.get_setting("display/window/size/viewport_width") < 400 + var example_balloon_file_name: String = "small_example_balloon.tscn" if is_small_window else "example_balloon.tscn" + var example_balloon_script_file_name: String = "ExampleBalloon.cs" if is_dotnet else "example_balloon.gd" + var file: FileAccess = FileAccess.open(plugin_path + "/example_balloon/" + example_balloon_file_name, FileAccess.READ) + var file_contents: String = file.get_as_text().replace(plugin_path + "/example_balloon/example_balloon.gd", balloon_script_path) + file = FileAccess.open(balloon_path, FileAccess.WRITE) + file.store_string(file_contents) + file.close() + + # Copy the script file + file = FileAccess.open(plugin_path + "/example_balloon/" + example_balloon_script_file_name, FileAccess.READ) + file_contents = file.get_as_text() + if is_dotnet: + file_contents = file_contents.replace("class ExampleBalloon", "class DialogueBalloon") + file = FileAccess.open(balloon_script_path, FileAccess.WRITE) + file.store_string(file_contents) + file.close() + + get_editor_interface().get_resource_filesystem().scan() + get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", balloon_path) + + DialogueSettings.set_setting("balloon_path", balloon_path) + + directory_dialog.queue_free() + ) + get_editor_interface().get_base_control().add_child(directory_dialog) + directory_dialog.popup_centered() + + +### Signals + + +func _on_files_moved(old_file: String, new_file: String) -> void: + update_import_paths(old_file, new_file) + DialogueSettings.move_recent_file(old_file, new_file) + + +func _on_file_removed(file: String) -> void: + update_import_paths(file, "") + if is_instance_valid(main_view): + main_view.close_file(file) diff --git a/addons/dialogue_manager/settings.gd b/addons/dialogue_manager/settings.gd new file mode 100644 index 00000000..b968a847 --- /dev/null +++ b/addons/dialogue_manager/settings.gd @@ -0,0 +1,184 @@ +@tool +extends Node + + +const DialogueConstants = preload("./constants.gd") + + +### Editor config + +const DEFAULT_SETTINGS = { + states = [], + missing_translations_are_errors = false, + export_characters_in_translation = true, + wrap_lines = false, + new_with_template = true, + include_all_responses = false, + ignore_missing_state_values = false, + custom_test_scene_path = preload("./test_scene.tscn").resource_path, + default_csv_locale = "en", + balloon_path = "", + create_lines_for_responses_with_characters = true, + include_character_in_translation_exports = false, + include_notes_in_translation_exports = false +} + + +static func prepare() -> void: + # Migrate previous keys + for key in [ + "states", + "missing_translations_are_errors", + "export_characters_in_translation", + "wrap_lines", + "new_with_template", + "include_all_responses", + "custom_test_scene_path" + ]: + if ProjectSettings.has_setting("dialogue_manager/%s" % key): + var value = ProjectSettings.get_setting("dialogue_manager/%s" % key) + ProjectSettings.set_setting("dialogue_manager/%s" % key, null) + set_setting(key, value) + + # Set up initial settings + for setting in DEFAULT_SETTINGS: + var setting_name: String = "dialogue_manager/general/%s" % setting + if not ProjectSettings.has_setting(setting_name): + set_setting(setting, DEFAULT_SETTINGS[setting]) + ProjectSettings.set_initial_value(setting_name, DEFAULT_SETTINGS[setting]) + if setting.ends_with("_path"): + ProjectSettings.add_property_info({ + "name": setting_name, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_FILE, + }) + + ProjectSettings.save() + + +static func set_setting(key: String, value) -> void: + ProjectSettings.set_setting("dialogue_manager/general/%s" % key, value) + ProjectSettings.set_initial_value("dialogue_manager/general/%s" % key, DEFAULT_SETTINGS[key]) + ProjectSettings.save() + + +static func get_setting(key: String, default): + if ProjectSettings.has_setting("dialogue_manager/general/%s" % key): + return ProjectSettings.get_setting("dialogue_manager/general/%s" % key) + else: + return default + + +static func get_settings(only_keys: PackedStringArray = []) -> Dictionary: + var settings: Dictionary = {} + for key in DEFAULT_SETTINGS.keys(): + if only_keys.is_empty() or key in only_keys: + settings[key] = get_setting(key, DEFAULT_SETTINGS[key]) + return settings + + +### User config + + +static func get_user_config() -> Dictionary: + var user_config: Dictionary = { + check_for_updates = true, + just_refreshed = null, + recent_files = [], + reopen_files = [], + most_recent_reopen_file = "", + carets = {}, + run_title = "", + run_resource_path = "", + is_running_test_scene = false, + has_dotnet_solution = false, + open_in_external_editor = false + } + + if FileAccess.file_exists(DialogueConstants.USER_CONFIG_PATH): + var file: FileAccess = FileAccess.open(DialogueConstants.USER_CONFIG_PATH, FileAccess.READ) + user_config.merge(JSON.parse_string(file.get_as_text()), true) + + return user_config + + +static func save_user_config(user_config: Dictionary) -> void: + var file: FileAccess = FileAccess.open(DialogueConstants.USER_CONFIG_PATH, FileAccess.WRITE) + file.store_string(JSON.stringify(user_config)) + + +static func set_user_value(key: String, value) -> void: + var user_config: Dictionary = get_user_config() + user_config[key] = value + save_user_config(user_config) + + +static func get_user_value(key: String, default = null): + return get_user_config().get(key, default) + + +static func add_recent_file(path: String) -> void: + var recent_files: Array = get_user_value("recent_files", []) + if path in recent_files: + recent_files.erase(path) + recent_files.insert(0, path) + set_user_value("recent_files", recent_files) + + +static func move_recent_file(from_path: String, to_path: String) -> void: + var recent_files: Array = get_user_value("recent_files", []) + for i in range(0, recent_files.size()): + if recent_files[i] == from_path: + recent_files[i] = to_path + set_user_value("recent_files", recent_files) + + +static func remove_recent_file(path: String) -> void: + var recent_files: Array = get_user_value("recent_files", []) + if path in recent_files: + recent_files.erase(path) + set_user_value("recent_files", recent_files) + + +static func get_recent_files() -> Array: + return get_user_value("recent_files", []) + + +static func clear_recent_files() -> void: + set_user_value("recent_files", []) + set_user_value("carets", {}) + + +static func set_caret(path: String, cursor: Vector2) -> void: + var carets: Dictionary = get_user_value("carets", {}) + carets[path] = { + x = cursor.x, + y = cursor.y + } + set_user_value("carets", carets) + + +static func get_caret(path: String) -> Vector2: + var carets = get_user_value("carets", {}) + if carets.has(path): + var caret = carets.get(path) + return Vector2(caret.x, caret.y) + else: + return Vector2.ZERO + + +static func has_dotnet_solution() -> bool: + if get_user_value("has_dotnet_solution", false): return true + + if ProjectSettings.has_setting("dotnet/project/solution_directory"): + var directory: String = ProjectSettings.get("dotnet/project/solution_directory") + var file_name: String = ProjectSettings.get("dotnet/project/assembly_name") + var has_dotnet_solution: bool = FileAccess.file_exists("res://%s/%s.sln" % [directory, file_name]) + set_user_value("has_dotnet_solution", has_dotnet_solution) + return has_dotnet_solution + else: + var plugin_path: String = new().get_script().resource_path.get_base_dir() + if not ResourceLoader.exists(plugin_path + "/DialogueManager.cs"): return false + if load(plugin_path + "/DialogueManager.cs") == null: return false + + return true diff --git a/addons/dialogue_manager/test_scene.gd b/addons/dialogue_manager/test_scene.gd new file mode 100644 index 00000000..5fc073eb --- /dev/null +++ b/addons/dialogue_manager/test_scene.gd @@ -0,0 +1,31 @@ +class_name BaseDialogueTestScene extends Node2D + + +const DialogueSettings = preload("./settings.gd") + + +@onready var title: String = DialogueSettings.get_user_value("run_title") +@onready var resource: DialogueResource = load(DialogueSettings.get_user_value("run_resource_path")) + + +func _ready(): + var screen_index: int = DisplayServer.get_primary_screen() + DisplayServer.window_set_position(Vector2(DisplayServer.screen_get_position(screen_index)) + (DisplayServer.screen_get_size(screen_index) - DisplayServer.window_get_size()) * 0.5) + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + + # Normally you can just call DialogueManager directly but doing so before the plugin has been + # enabled in settings will throw a compiler error here so I'm using get_singleton instead. + var dialogue_manager = Engine.get_singleton("DialogueManager") + dialogue_manager.dialogue_ended.connect(_on_dialogue_ended) + dialogue_manager.show_dialogue_balloon(resource, title) + + +func _enter_tree() -> void: + DialogueSettings.set_user_value("is_running_test_scene", false) + + +### Signals + + +func _on_dialogue_ended(_resource: DialogueResource): + get_tree().quit() diff --git a/addons/dialogue_manager/test_scene.tscn b/addons/dialogue_manager/test_scene.tscn new file mode 100644 index 00000000..f2bbd8d3 --- /dev/null +++ b/addons/dialogue_manager/test_scene.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://addons/dialogue_manager/test_scene.gd" id="1_yupoh"] + + +[node name="TestScene" type="Node2D"] +script = ExtResource("1_yupoh") diff --git a/addons/dialogue_manager/utilities/builtins.gd b/addons/dialogue_manager/utilities/builtins.gd new file mode 100644 index 00000000..f8442d4f --- /dev/null +++ b/addons/dialogue_manager/utilities/builtins.gd @@ -0,0 +1,468 @@ +extends Object + + +const DialogueConstants = preload("../constants.gd") + +const SUPPORTED_BUILTIN_TYPES = [ + TYPE_ARRAY, + TYPE_VECTOR2, + TYPE_VECTOR3, + TYPE_VECTOR4, + TYPE_DICTIONARY, + TYPE_QUATERNION, + TYPE_COLOR, + TYPE_SIGNAL +] + + +static var resolve_method_error: Error = OK + + +static func is_supported(thing) -> bool: + return typeof(thing) in SUPPORTED_BUILTIN_TYPES + + +static func resolve_property(builtin, property: String): + match typeof(builtin): + TYPE_ARRAY, TYPE_DICTIONARY, TYPE_QUATERNION: + return builtin[property] + + # Some types have constants that we need to manually resolve + + TYPE_VECTOR2: + return resolve_vector2_property(builtin, property) + TYPE_VECTOR3: + return resolve_vector3_property(builtin, property) + TYPE_VECTOR4: + return resolve_vector4_property(builtin, property) + TYPE_COLOR: + return resolve_color_property(builtin, property) + + +static func resolve_method(thing, method_name: String, args: Array): + resolve_method_error = OK + + # Resolve static methods manually + match typeof(thing): + TYPE_VECTOR2: + match method_name: + "from_angle": + return Vector2.from_angle(args[0]) + + TYPE_COLOR: + match method_name: + "from_hsv": + return Color.from_hsv(args[0], args[1], args[2]) if args.size() == 3 else Color.from_hsv(args[0], args[1], args[2], args[3]) + "from_ok_hsl": + return Color.from_ok_hsl(args[0], args[1], args[2]) if args.size() == 3 else Color.from_ok_hsl(args[0], args[1], args[2], args[3]) + "from_rgbe9995": + return Color.from_rgbe9995(args[0]) + "from_string": + return Color.from_string(args[0], args[1]) + + TYPE_QUATERNION: + match method_name: + "from_euler": + return Quaternion.from_euler(args[0]) + + # Anything else can be evaulatated automatically + var references: Array = ["thing"] + for i in range(0, args.size()): + references.append("arg%d" % i) + var expression = Expression.new() + if expression.parse("thing.%s(%s)" % [method_name, ",".join(references.slice(1))], references) != OK: + assert(false, expression.get_error_text()) + var result = expression.execute([thing] + args, null, false) + if expression.has_execute_failed(): + resolve_method_error = ERR_CANT_RESOLVE + return null + + return result + + +static func has_resolve_method_failed() -> bool: + return resolve_method_error != OK + + +static func resolve_color_property(color: Color, property: String): + match property: + "ALICE_BLUE": + return Color.ALICE_BLUE + "ANTIQUE_WHITE": + return Color.ANTIQUE_WHITE + "AQUA": + return Color.AQUA + "AQUAMARINE": + return Color.AQUAMARINE + "AZURE": + return Color.AZURE + "BEIGE": + return Color.BEIGE + "BISQUE": + return Color.BISQUE + "BLACK": + return Color.BLACK + "BLANCHED_ALMOND": + return Color.BLANCHED_ALMOND + "BLUE": + return Color.BLUE + "BLUE_VIOLET": + return Color.BLUE_VIOLET + "BROWN": + return Color.BROWN + "BURLYWOOD": + return Color.BURLYWOOD + "CADET_BLUE": + return Color.CADET_BLUE + "CHARTREUSE": + return Color.CHARTREUSE + "CHOCOLATE": + return Color.CHOCOLATE + "CORAL": + return Color.CORAL + "CORNFLOWER_BLUE": + return Color.CORNFLOWER_BLUE + "CORNSILK": + return Color.CORNSILK + "CRIMSON": + return Color.CRIMSON + "CYAN": + return Color.CYAN + "DARK_BLUE": + return Color.DARK_BLUE + "DARK_CYAN": + return Color.DARK_CYAN + "DARK_GOLDENROD": + return Color.DARK_GOLDENROD + "DARK_GRAY": + return Color.DARK_GRAY + "DARK_GREEN": + return Color.DARK_GREEN + "DARK_KHAKI": + return Color.DARK_KHAKI + "DARK_MAGENTA": + return Color.DARK_MAGENTA + "DARK_OLIVE_GREEN": + return Color.DARK_OLIVE_GREEN + "DARK_ORANGE": + return Color.DARK_ORANGE + "DARK_ORCHID": + return Color.DARK_ORCHID + "DARK_RED": + return Color.DARK_RED + "DARK_SALMON": + return Color.DARK_SALMON + "DARK_SEA_GREEN": + return Color.DARK_SEA_GREEN + "DARK_SLATE_BLUE": + return Color.DARK_SLATE_BLUE + "DARK_SLATE_GRAY": + return Color.DARK_SLATE_GRAY + "DARK_TURQUOISE": + return Color.DARK_TURQUOISE + "DARK_VIOLET": + return Color.DARK_VIOLET + "DEEP_PINK": + return Color.DEEP_PINK + "DEEP_SKY_BLUE": + return Color.DEEP_SKY_BLUE + "DIM_GRAY": + return Color.DIM_GRAY + "DODGER_BLUE": + return Color.DODGER_BLUE + "FIREBRICK": + return Color.FIREBRICK + "FLORAL_WHITE": + return Color.FLORAL_WHITE + "FOREST_GREEN": + return Color.FOREST_GREEN + "FUCHSIA": + return Color.FUCHSIA + "GAINSBORO": + return Color.GAINSBORO + "GHOST_WHITE": + return Color.GHOST_WHITE + "GOLD": + return Color.GOLD + "GOLDENROD": + return Color.GOLDENROD + "GRAY": + return Color.GRAY + "GREEN": + return Color.GREEN + "GREEN_YELLOW": + return Color.GREEN_YELLOW + "HONEYDEW": + return Color.HONEYDEW + "HOT_PINK": + return Color.HOT_PINK + "INDIAN_RED": + return Color.INDIAN_RED + "INDIGO": + return Color.INDIGO + "IVORY": + return Color.IVORY + "KHAKI": + return Color.KHAKI + "LAVENDER": + return Color.LAVENDER + "LAVENDER_BLUSH": + return Color.LAVENDER_BLUSH + "LAWN_GREEN": + return Color.LAWN_GREEN + "LEMON_CHIFFON": + return Color.LEMON_CHIFFON + "LIGHT_BLUE": + return Color.LIGHT_BLUE + "LIGHT_CORAL": + return Color.LIGHT_CORAL + "LIGHT_CYAN": + return Color.LIGHT_CYAN + "LIGHT_GOLDENROD": + return Color.LIGHT_GOLDENROD + "LIGHT_GRAY": + return Color.LIGHT_GRAY + "LIGHT_GREEN": + return Color.LIGHT_GREEN + "LIGHT_PINK": + return Color.LIGHT_PINK + "LIGHT_SALMON": + return Color.LIGHT_SALMON + "LIGHT_SEA_GREEN": + return Color.LIGHT_SEA_GREEN + "LIGHT_SKY_BLUE": + return Color.LIGHT_SKY_BLUE + "LIGHT_SLATE_GRAY": + return Color.LIGHT_SLATE_GRAY + "LIGHT_STEEL_BLUE": + return Color.LIGHT_STEEL_BLUE + "LIGHT_YELLOW": + return Color.LIGHT_YELLOW + "LIME": + return Color.LIME + "LIME_GREEN": + return Color.LIME_GREEN + "LINEN": + return Color.LINEN + "MAGENTA": + return Color.MAGENTA + "MAROON": + return Color.MAROON + "MEDIUM_AQUAMARINE": + return Color.MEDIUM_AQUAMARINE + "MEDIUM_BLUE": + return Color.MEDIUM_BLUE + "MEDIUM_ORCHID": + return Color.MEDIUM_ORCHID + "MEDIUM_PURPLE": + return Color.MEDIUM_PURPLE + "MEDIUM_SEA_GREEN": + return Color.MEDIUM_SEA_GREEN + "MEDIUM_SLATE_BLUE": + return Color.MEDIUM_SLATE_BLUE + "MEDIUM_SPRING_GREEN": + return Color.MEDIUM_SPRING_GREEN + "MEDIUM_TURQUOISE": + return Color.MEDIUM_TURQUOISE + "MEDIUM_VIOLET_RED": + return Color.MEDIUM_VIOLET_RED + "MIDNIGHT_BLUE": + return Color.MIDNIGHT_BLUE + "MINT_CREAM": + return Color.MINT_CREAM + "MISTY_ROSE": + return Color.MISTY_ROSE + "MOCCASIN": + return Color.MOCCASIN + "NAVAJO_WHITE": + return Color.NAVAJO_WHITE + "NAVY_BLUE": + return Color.NAVY_BLUE + "OLD_LACE": + return Color.OLD_LACE + "OLIVE": + return Color.OLIVE + "OLIVE_DRAB": + return Color.OLIVE_DRAB + "ORANGE": + return Color.ORANGE + "ORANGE_RED": + return Color.ORANGE_RED + "ORCHID": + return Color.ORCHID + "PALE_GOLDENROD": + return Color.PALE_GOLDENROD + "PALE_GREEN": + return Color.PALE_GREEN + "PALE_TURQUOISE": + return Color.PALE_TURQUOISE + "PALE_VIOLET_RED": + return Color.PALE_VIOLET_RED + "PAPAYA_WHIP": + return Color.PAPAYA_WHIP + "PEACH_PUFF": + return Color.PEACH_PUFF + "PERU": + return Color.PERU + "PINK": + return Color.PINK + "PLUM": + return Color.PLUM + "POWDER_BLUE": + return Color.POWDER_BLUE + "PURPLE": + return Color.PURPLE + "REBECCA_PURPLE": + return Color.REBECCA_PURPLE + "RED": + return Color.RED + "ROSY_BROWN": + return Color.ROSY_BROWN + "ROYAL_BLUE": + return Color.ROYAL_BLUE + "SADDLE_BROWN": + return Color.SADDLE_BROWN + "SALMON": + return Color.SALMON + "SANDY_BROWN": + return Color.SANDY_BROWN + "SEA_GREEN": + return Color.SEA_GREEN + "SEASHELL": + return Color.SEASHELL + "SIENNA": + return Color.SIENNA + "SILVER": + return Color.SILVER + "SKY_BLUE": + return Color.SKY_BLUE + "SLATE_BLUE": + return Color.SLATE_BLUE + "SLATE_GRAY": + return Color.SLATE_GRAY + "SNOW": + return Color.SNOW + "SPRING_GREEN": + return Color.SPRING_GREEN + "STEEL_BLUE": + return Color.STEEL_BLUE + "TAN": + return Color.TAN + "TEAL": + return Color.TEAL + "THISTLE": + return Color.THISTLE + "TOMATO": + return Color.TOMATO + "TRANSPARENT": + return Color.TRANSPARENT + "TURQUOISE": + return Color.TURQUOISE + "VIOLET": + return Color.VIOLET + "WEB_GRAY": + return Color.WEB_GRAY + "WEB_GREEN": + return Color.WEB_GREEN + "WEB_MAROON": + return Color.WEB_MAROON + "WEB_PURPLE": + return Color.WEB_PURPLE + "WHEAT": + return Color.WHEAT + "WHITE": + return Color.WHITE + "WHITE_SMOKE": + return Color.WHITE_SMOKE + "YELLOW": + return Color.YELLOW + "YELLOW_GREEN": + return Color.YELLOW_GREEN + + return color[property] + + +static func resolve_vector2_property(vector: Vector2, property: String): + match property: + "AXIS_X": + return Vector2.AXIS_X + "AXIS_Y": + return Vector2.AXIS_Y + "ZERO": + return Vector2.ZERO + "ONE": + return Vector2.ONE + "INF": + return Vector2.INF + "LEFT": + return Vector2.LEFT + "RIGHT": + return Vector2.RIGHT + "UP": + return Vector2.UP + "DOWN": + return Vector2.DOWN + + return vector[property] + + +static func resolve_vector3_property(vector: Vector3, property: String): + match property: + "AXIS_X": + return Vector3.AXIS_X + "AXIS_Y": + return Vector3.AXIS_Y + "AXIS_Z": + return Vector3.AXIS_Z + "ZERO": + return Vector3.ZERO + "ONE": + return Vector3.ONE + "INF": + return Vector3.INF + "LEFT": + return Vector3.LEFT + "RIGHT": + return Vector3.RIGHT + "UP": + return Vector3.UP + "DOWN": + return Vector3.DOWN + "FORWARD": + return Vector3.FORWARD + "BACK": + return Vector3.BACK + "MODEL_LEFT": + return Vector3(1, 0, 0) + "MODEL_RIGHT": + return Vector3(-1, 0, 0) + "MODEL_TOP": + return Vector3(0, 1, 0) + "MODEL_BOTTOM": + return Vector3(0, -1, 0) + "MODEL_FRONT": + return Vector3(0, 0, 1) + "MODEL_REAR": + return Vector3(0, 0, -1) + + return vector[property] + + +static func resolve_vector4_property(vector: Vector4, property: String): + match property: + "AXIS_X": + return Vector4.AXIS_X + "AXIS_Y": + return Vector4.AXIS_Y + "AXIS_Z": + return Vector4.AXIS_Z + "AXIS_W": + return Vector4.AXIS_W + "ZERO": + return Vector4.ZERO + "ONE": + return Vector4.ONE + "INF": + return Vector4.INF + + return vector[property] diff --git a/addons/dialogue_manager/views/main_view.gd b/addons/dialogue_manager/views/main_view.gd new file mode 100644 index 00000000..28290284 --- /dev/null +++ b/addons/dialogue_manager/views/main_view.gd @@ -0,0 +1,1077 @@ +@tool +extends Control + + +const DialogueConstants = preload("../constants.gd") +const DialogueSettings = preload("../settings.gd") + +const OPEN_OPEN = 100 +const OPEN_CLEAR = 101 + +const TRANSLATIONS_GENERATE_LINE_IDS = 100 +const TRANSLATIONS_SAVE_CHARACTERS_TO_CSV = 201 +const TRANSLATIONS_SAVE_TO_CSV = 202 +const TRANSLATIONS_IMPORT_FROM_CSV = 203 + +const ITEM_SAVE = 100 +const ITEM_SAVE_AS = 101 +const ITEM_CLOSE = 102 +const ITEM_CLOSE_ALL = 103 +const ITEM_CLOSE_OTHERS = 104 +const ITEM_COPY_PATH = 200 +const ITEM_SHOW_IN_FILESYSTEM = 201 + +enum TranslationSource { + CharacterNames, + Lines +} + + +@onready var parse_timer := $ParseTimer + +# Dialogs +@onready var new_dialog: FileDialog = $NewDialog +@onready var save_dialog: FileDialog = $SaveDialog +@onready var open_dialog: FileDialog = $OpenDialog +@onready var export_dialog: FileDialog = $ExportDialog +@onready var import_dialog: FileDialog = $ImportDialog +@onready var errors_dialog: AcceptDialog = $ErrorsDialog +@onready var settings_dialog: AcceptDialog = $SettingsDialog +@onready var settings_view := $SettingsDialog/SettingsView +@onready var build_error_dialog: AcceptDialog = $BuildErrorDialog +@onready var close_confirmation_dialog: ConfirmationDialog = $CloseConfirmationDialog +@onready var updated_dialog: AcceptDialog = $UpdatedDialog + +# Toolbar +@onready var new_button: Button = %NewButton +@onready var open_button: MenuButton = %OpenButton +@onready var save_all_button: Button = %SaveAllButton +@onready var test_button: Button = %TestButton +@onready var search_button: Button = %SearchButton +@onready var insert_button: MenuButton = %InsertButton +@onready var translations_button: MenuButton = %TranslationsButton +@onready var settings_button: Button = %SettingsButton +@onready var support_button: Button = %SupportButton +@onready var docs_button: Button = %DocsButton +@onready var version_label: Label = %VersionLabel +@onready var update_button: Button = %UpdateButton + +@onready var search_and_replace := %SearchAndReplace + +# Code editor +@onready var content: HSplitContainer = %Content +@onready var files_list := %FilesList +@onready var files_popup_menu: PopupMenu = %FilesPopupMenu +@onready var title_list := %TitleList +@onready var code_edit := %CodeEdit +@onready var errors_panel := %ErrorsPanel + +# The Dialogue Manager plugin +var editor_plugin: EditorPlugin + +# The currently open file +var current_file_path: String = "": + set(next_current_file_path): + current_file_path = next_current_file_path + files_list.current_file_path = current_file_path + if current_file_path == "": + save_all_button.disabled = true + test_button.disabled = true + search_button.disabled = true + insert_button.disabled = true + translations_button.disabled = true + content.dragger_visibility = SplitContainer.DRAGGER_HIDDEN + files_list.hide() + title_list.hide() + code_edit.hide() + errors_panel.hide() + else: + test_button.disabled = false + search_button.disabled = false + insert_button.disabled = false + translations_button.disabled = false + content.dragger_visibility = SplitContainer.DRAGGER_VISIBLE + files_list.show() + title_list.show() + code_edit.show() + + code_edit.text = open_buffers[current_file_path].text + code_edit.errors = [] + code_edit.clear_undo_history() + code_edit.set_cursor(DialogueSettings.get_caret(current_file_path)) + code_edit.grab_focus() + + _on_code_edit_text_changed() + + errors_panel.errors = [] + code_edit.errors = [] + get: + return current_file_path + +# A reference to the currently open files and their last saved text +var open_buffers: Dictionary = {} + +# Which thing are we exporting translations for? +var translation_source: TranslationSource = TranslationSource.Lines + + +func _ready() -> void: + apply_theme() + + # Start with nothing open + self.current_file_path = "" + + # Set up the update checker + version_label.text = "v%s" % editor_plugin.get_version() + update_button.editor_plugin = editor_plugin + update_button.on_before_refresh = func on_before_refresh(): + # Save everything + DialogueSettings.set_user_value("just_refreshed", { + current_file_path = current_file_path, + open_buffers = open_buffers + }) + return true + + # Did we just load from an addon version refresh? + var just_refreshed = DialogueSettings.get_user_value("just_refreshed", null) + if just_refreshed != null: + DialogueSettings.set_user_value("just_refreshed", null) + call_deferred("load_from_version_refresh", just_refreshed) + + # Hook up the search toolbar + search_and_replace.code_edit = code_edit + + # Connect menu buttons + insert_button.get_popup().id_pressed.connect(_on_insert_button_menu_id_pressed) + translations_button.get_popup().id_pressed.connect(_on_translations_button_menu_id_pressed) + + code_edit.main_view = self + code_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY if DialogueSettings.get_setting("wrap_lines", false) else TextEdit.LINE_WRAPPING_NONE + var editor_settings: EditorSettings = editor_plugin.get_editor_interface().get_editor_settings() + editor_settings.settings_changed.connect(_on_editor_settings_changed) + _on_editor_settings_changed() + + # Reopen any files that were open when Godot was closed + if editor_settings.get_setting("text_editor/behavior/files/restore_scripts_on_load"): + var reopen_files: Array = DialogueSettings.get_user_value("reopen_files", []) + for reopen_file in reopen_files: + open_file(reopen_file) + + self.current_file_path = DialogueSettings.get_user_value("most_recent_reopen_file", "") + + save_all_button.disabled = true + + close_confirmation_dialog.ok_button_text = DialogueConstants.translate("confirm_close.save") + close_confirmation_dialog.add_button(DialogueConstants.translate("confirm_close.discard"), true, "discard") + + settings_view.editor_plugin = editor_plugin + + errors_dialog.dialog_text = DialogueConstants.translate("errors_in_script") + + +func _exit_tree() -> void: + DialogueSettings.set_user_value("reopen_files", open_buffers.keys()) + DialogueSettings.set_user_value("most_recent_reopen_file", self.current_file_path) + + +func _unhandled_input(event: InputEvent) -> void: + if not visible: return + + if event is InputEventKey and event.is_pressed(): + match event.as_text(): + "Ctrl+Alt+S", "Command+Alt+S": + get_viewport().set_input_as_handled() + save_file(current_file_path) + "Ctrl+W", "Command+W": + get_viewport().set_input_as_handled() + close_file(current_file_path) + "Ctrl+F5", "Command+F5": + get_viewport().set_input_as_handled() + _on_test_button_pressed() + + +func apply_changes() -> void: + save_files() + + +# Load back to the previous buffer regardless of if it was actually saved +func load_from_version_refresh(just_refreshed: Dictionary) -> void: + if just_refreshed.has("current_file_content"): + # We just loaded from a version before multiple buffers + var file: FileAccess = FileAccess.open(just_refreshed.current_file_path, FileAccess.READ) + var file_text: String = file.get_as_text() + open_buffers[just_refreshed.current_file_path] = { + pristine_text = file_text, + text = just_refreshed.current_file_content + } + else: + open_buffers = just_refreshed.open_buffers + + if just_refreshed.current_file_path != "": + editor_plugin.get_editor_interface().edit_resource(load(just_refreshed.current_file_path)) + else: + editor_plugin.get_editor_interface().set_main_screen_editor("Dialogue") + + updated_dialog.dialog_text = DialogueConstants.translate("update.success").format({ version = update_button.get_version() }) + updated_dialog.popup_centered() + + +func new_file(path: String, content: String = "") -> void: + if open_buffers.has(path): + remove_file_from_open_buffers(path) + + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if content == "": + if DialogueSettings.get_setting("new_with_template", true): + file.store_string("\n".join([ + "~ this_is_a_node_title", + "", + "Nathan: [[Hi|Hello|Howdy]], this is some dialogue.", + "Nathan: Here are some choices.", + "- First one", + "\tNathan: You picked the first one.", + "- Second one", + "\tNathan: You picked the second one.", + "- Start again => this_is_a_node_title", + "- End the conversation => END", + "Nathan: For more information see the online documentation.", + "", + "=> END" + ])) + else: + file.store_string(content) + + editor_plugin.get_editor_interface().get_resource_filesystem().scan() + + +# Open a dialogue resource for editing +func open_resource(resource: DialogueResource) -> void: + open_file(resource.resource_path) + + +func open_file(path: String) -> void: + if not open_buffers.has(path): + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + var text = file.get_as_text() + + open_buffers[path] = { + cursor = Vector2.ZERO, + text = text, + pristine_text = text + } + + DialogueSettings.add_recent_file(path) + build_open_menu() + + files_list.files = open_buffers.keys() + files_list.select_file(path) + + self.current_file_path = path + + +func show_file_in_filesystem(path: String) -> void: + var file_system_dock: FileSystemDock = Engine.get_meta("DialogueManagerPlugin") \ + .get_editor_interface() \ + .get_file_system_dock() + + file_system_dock.navigate_to_path(path) + + +# Save any open files +func save_files() -> void: + save_all_button.disabled = true + + var saved_files: PackedStringArray = [] + for path in open_buffers: + if open_buffers[path].text != open_buffers[path].pristine_text: + saved_files.append(path) + save_file(path, false) + + if saved_files.size() > 0: + Engine.get_meta("DialogueCache").reimport_files(saved_files) + + +# Save a file +func save_file(path: String, rescan_file_system: bool = true) -> void: + var buffer = open_buffers[path] + + files_list.mark_file_as_unsaved(path, false) + save_all_button.disabled = files_list.unsaved_files.size() == 0 + + # Don't bother saving if there is nothing to save + if buffer.text == buffer.pristine_text: + return + + buffer.pristine_text = buffer.text + + # Save the current text + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + file.store_string(buffer.text) + file.close() + + if rescan_file_system: + Engine.get_meta("DialogueManagerPlugin") \ + .get_editor_interface() \ + .get_resource_filesystem()\ + .scan() + + +func close_file(file: String) -> void: + if not file in open_buffers.keys(): return + + var buffer = open_buffers[file] + + if buffer.text == buffer.pristine_text: + remove_file_from_open_buffers(file) + else: + close_confirmation_dialog.dialog_text = DialogueConstants.translate("confirm_close").format({ path = file.get_file() }) + close_confirmation_dialog.popup_centered() + + +func remove_file_from_open_buffers(file: String) -> void: + if not file in open_buffers.keys(): return + + var current_index = open_buffers.keys().find(file) + + open_buffers.erase(file) + if open_buffers.size() == 0: + self.current_file_path = "" + else: + current_index = clamp(current_index, 0, open_buffers.size() - 1) + self.current_file_path = open_buffers.keys()[current_index] + files_list.files = open_buffers.keys() + + +# Apply theme colors and icons to the UI +func apply_theme() -> void: + if is_instance_valid(editor_plugin) and is_instance_valid(code_edit): + var scale: float = editor_plugin.get_editor_interface().get_editor_scale() + var editor_settings = editor_plugin.get_editor_interface().get_editor_settings() + code_edit.theme_overrides = { + scale = scale, + + background_color = editor_settings.get_setting("text_editor/theme/highlighting/background_color"), + current_line_color = editor_settings.get_setting("text_editor/theme/highlighting/current_line_color"), + error_line_color = editor_settings.get_setting("text_editor/theme/highlighting/mark_color"), + + titles_color = editor_settings.get_setting("text_editor/theme/highlighting/control_flow_keyword_color"), + text_color = editor_settings.get_setting("text_editor/theme/highlighting/text_color"), + conditions_color = editor_settings.get_setting("text_editor/theme/highlighting/keyword_color"), + mutations_color = editor_settings.get_setting("text_editor/theme/highlighting/function_color"), + members_color = editor_settings.get_setting("text_editor/theme/highlighting/member_variable_color"), + strings_color = editor_settings.get_setting("text_editor/theme/highlighting/string_color"), + numbers_color = editor_settings.get_setting("text_editor/theme/highlighting/number_color"), + symbols_color = editor_settings.get_setting("text_editor/theme/highlighting/symbol_color"), + comments_color = editor_settings.get_setting("text_editor/theme/highlighting/comment_color"), + jumps_color = Color(editor_settings.get_setting("text_editor/theme/highlighting/control_flow_keyword_color"), 0.7), + + font_size = editor_settings.get_setting("interface/editor/code_font_size") + } + + new_button.icon = get_theme_icon("New", "EditorIcons") + new_button.tooltip_text = DialogueConstants.translate("start_a_new_file") + + open_button.icon = get_theme_icon("Load", "EditorIcons") + open_button.tooltip_text = DialogueConstants.translate("open_a_file") + + save_all_button.icon = get_theme_icon("Save", "EditorIcons") + save_all_button.tooltip_text = DialogueConstants.translate("start_all_files") + + test_button.icon = get_theme_icon("PlayScene", "EditorIcons") + test_button.tooltip_text = DialogueConstants.translate("test_dialogue") + + search_button.icon = get_theme_icon("Search", "EditorIcons") + search_button.tooltip_text = DialogueConstants.translate("search_for_text") + + insert_button.icon = get_theme_icon("RichTextEffect", "EditorIcons") + insert_button.text = DialogueConstants.translate("insert") + + translations_button.icon = get_theme_icon("Translation", "EditorIcons") + translations_button.text = DialogueConstants.translate("translations") + + settings_button.icon = get_theme_icon("Tools", "EditorIcons") + settings_button.tooltip_text = DialogueConstants.translate("settings") + + support_button.icon = get_theme_icon("Heart", "EditorIcons") + support_button.text = DialogueConstants.translate("sponsor") + support_button.tooltip_text = DialogueConstants.translate("show_support") + + docs_button.icon = get_theme_icon("Help", "EditorIcons") + docs_button.text = DialogueConstants.translate("docs") + + update_button.apply_theme() + + # Set up the effect menu + var popup: PopupMenu = insert_button.get_popup() + popup.clear() + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.wave_bbcode"), 0) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.shake_bbcode"), 1) + popup.add_separator() + popup.add_icon_item(get_theme_icon("Time", "EditorIcons"), DialogueConstants.translate("insert.typing_pause"), 3) + popup.add_icon_item(get_theme_icon("ViewportSpeed", "EditorIcons"), DialogueConstants.translate("insert.typing_speed_change"), 4) + popup.add_icon_item(get_theme_icon("DebugNext", "EditorIcons"), DialogueConstants.translate("insert.auto_advance"), 5) + popup.add_separator(DialogueConstants.translate("insert.templates")) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.title"), 6) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.dialogue"), 7) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.response"), 8) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.random_lines"), 9) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.random_text"), 10) + popup.add_separator(DialogueConstants.translate("insert.actions")) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.jump"), 11) + popup.add_icon_item(get_theme_icon("RichTextEffect", "EditorIcons"), DialogueConstants.translate("insert.end_dialogue"), 12) + + # Set up the translations menu + popup = translations_button.get_popup() + popup.clear() + popup.add_icon_item(get_theme_icon("Translation", "EditorIcons"), DialogueConstants.translate("generate_line_ids"), TRANSLATIONS_GENERATE_LINE_IDS) + popup.add_separator() + popup.add_icon_item(get_theme_icon("FileList", "EditorIcons"), DialogueConstants.translate("save_characters_to_csv"), TRANSLATIONS_SAVE_CHARACTERS_TO_CSV) + popup.add_icon_item(get_theme_icon("FileList", "EditorIcons"), DialogueConstants.translate("save_to_csv"), TRANSLATIONS_SAVE_TO_CSV) + popup.add_icon_item(get_theme_icon("AssetLib", "EditorIcons"), DialogueConstants.translate("import_from_csv"), TRANSLATIONS_IMPORT_FROM_CSV) + + # Dialog sizes + new_dialog.min_size = Vector2(600, 500) * scale + save_dialog.min_size = Vector2(600, 500) * scale + open_dialog.min_size = Vector2(600, 500) * scale + export_dialog.min_size = Vector2(600, 500) * scale + import_dialog.min_size = Vector2(600, 500) * scale + settings_dialog.min_size = Vector2(1000, 600) * scale + settings_dialog.max_size = Vector2(1000, 600) * scale + + +### Helpers + + +# Refresh the open menu with the latest files +func build_open_menu() -> void: + var menu = open_button.get_popup() + menu.clear() + menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), DialogueConstants.translate("open.open"), OPEN_OPEN) + menu.add_separator() + + var recent_files = DialogueSettings.get_recent_files() + if recent_files.size() == 0: + menu.add_item(DialogueConstants.translate("open.no_recent_files")) + menu.set_item_disabled(2, true) + else: + for path in recent_files: + if FileAccess.file_exists(path): + menu.add_icon_item(get_theme_icon("File", "EditorIcons"), path) + + menu.add_separator() + menu.add_item(DialogueConstants.translate("open.clear_recent_files"), OPEN_CLEAR) + if menu.id_pressed.is_connected(_on_open_menu_id_pressed): + menu.id_pressed.disconnect(_on_open_menu_id_pressed) + menu.id_pressed.connect(_on_open_menu_id_pressed) + + +# Get the last place a CSV, etc was exported +func get_last_export_path(extension: String) -> String: + var filename = current_file_path.get_file().replace(".dialogue", "." + extension) + return DialogueSettings.get_user_value("last_export_path", current_file_path.get_base_dir()) + "/" + filename + + +# Check the current text for errors +func parse() -> void: + # Skip if nothing to parse + if current_file_path == "": return + + var parser = DialogueManagerParser.new() + var errors: Array[Dictionary] = [] + if parser.parse(code_edit.text, current_file_path) != OK: + errors = parser.get_errors() + code_edit.errors = errors + errors_panel.errors = errors + parser.free() + + +func show_build_error_dialog() -> void: + build_error_dialog.dialog_text = DialogueConstants.translate("errors_with_build") + build_error_dialog.popup_centered() + + +# Generate translation line IDs for any line that doesn't already have one +func generate_translations_keys() -> void: + randomize() + seed(Time.get_unix_time_from_system()) + + var parser = DialogueManagerParser.new() + + var cursor: Vector2 = code_edit.get_cursor() + var lines: PackedStringArray = code_edit.text.split("\n") + + var key_regex = RegEx.new() + key_regex.compile("\\[ID:(?<key>.*?)\\]") + + # Make list of known keys + var known_keys = {} + for i in range(0, lines.size()): + var line = lines[i] + var found = key_regex.search(line) + if found: + var text = "" + var l = line.replace(found.strings[0], "").strip_edges().strip_edges() + if l.begins_with("- "): + text = parser.extract_response_prompt(l) + elif ":" in l: + text = l.split(":")[1] + else: + text = l + known_keys[found.strings[found.names.get("key")]] = text + + # Add in any that are missing + for i in lines.size(): + var line = lines[i] + var l = line.strip_edges() + + if parser.is_line_empty(l): continue + if parser.is_condition_line(l, true): continue + if parser.is_title_line(l): continue + if parser.is_mutation_line(l): continue + if parser.is_goto_line(l): continue + if parser.is_import_line(l): continue + + if "[ID:" in line: continue + + var key = "t" + str(randi() % 1000000).sha1_text().substr(0, 10) + while key in known_keys: + key = "t" + str(randi() % 1000000).sha1_text().substr(0, 10) + + var text = "" + if l.begins_with("- "): + text = parser.extract_response_prompt(l) + else: + text = l.substr(l.find(":") + 1) + + lines[i] = line.replace(text, text + " [ID:%s]" % key) + known_keys[key] = text + + code_edit.text = "\n".join(lines) + code_edit.set_cursor(cursor) + _on_code_edit_text_changed() + + parser.free() + + +# Add a translation file to the project settings +func add_path_to_project_translations(path: String) -> void: + var translations: PackedStringArray = ProjectSettings.get_setting("internationalization/locale/translations") + if not path in translations: + translations.append(path) + ProjectSettings.save() + + +# Export dialogue and responses to CSV +func export_translations_to_csv(path: String) -> void: + var default_locale: String = DialogueSettings.get_setting("default_csv_locale", "en") + + var file: FileAccess + + # If the file exists, open it first and work out which keys are already in it + var existing_csv: Dictionary = {} + var column_count: int = 2 + var default_locale_column: int = 1 + var character_column: int = -1 + var notes_column: int = -1 + if FileAccess.file_exists(path): + file = FileAccess.open(path, FileAccess.READ) + var is_first_line = true + var line: Array + while !file.eof_reached(): + line = file.get_csv_line() + if is_first_line: + is_first_line = false + column_count = line.size() + for i in range(1, line.size()): + if line[i] == default_locale: + default_locale_column = i + elif line[i] == "_character": + character_column = i + elif line[i] == "_notes": + notes_column = i + + # Make sure the line isn't empty before adding it + if line.size() > 0 and line[0].strip_edges() != "": + existing_csv[line[0]] = line + + # The character column wasn't found in the existing file but the setting is turned on + if character_column == -1 and DialogueSettings.get_setting("include_character_in_translation_exports", false): + character_column = column_count + column_count += 1 + existing_csv["keys"].append("_character") + + # The notes column wasn't found in the existing file but the setting is turned on + if notes_column == -1 and DialogueSettings.get_setting("include_notes_in_translation_exports", false): + notes_column = column_count + column_count += 1 + existing_csv["keys"].append("_notes") + + # Start a new file + file = FileAccess.open(path, FileAccess.WRITE) + + if not FileAccess.file_exists(path): + var headings: PackedStringArray = ["keys", default_locale] + if DialogueSettings.get_setting("include_character_in_translation_exports", false): + character_column = headings.size() + headings.append("_character") + if DialogueSettings.get_setting("include_notes_in_translation_exports", false): + notes_column = headings.size() + headings.append("_notes") + file.store_csv_line(headings) + column_count = headings.size() + + # Write our translations to file + var known_keys: PackedStringArray = [] + + var dialogue: Dictionary = DialogueManagerParser.parse_string(code_edit.text, current_file_path).lines + + # Make a list of stuff that needs to go into the file + var lines_to_save = [] + for key in dialogue.keys(): + var line: Dictionary = dialogue.get(key) + + if not line.type in [DialogueConstants.TYPE_DIALOGUE, DialogueConstants.TYPE_RESPONSE]: continue + if line.translation_key in known_keys: continue + + known_keys.append(line.translation_key) + + var line_to_save: PackedStringArray = [] + if existing_csv.has(line.translation_key): + line_to_save = existing_csv.get(line.translation_key) + line_to_save.resize(column_count) + existing_csv.erase(line.translation_key) + else: + line_to_save.resize(column_count) + line_to_save[0] = line.translation_key + + line_to_save[default_locale_column] = line.text + if character_column > -1: + line_to_save[character_column] = "(response)" if line.type == DialogueConstants.TYPE_RESPONSE else line.character + if notes_column > -1: + line_to_save[notes_column] = line.notes + + lines_to_save.append(line_to_save) + + # Store lines in the file, starting with anything that already exists that hasn't been touched + for line in existing_csv.values(): + file.store_csv_line(line) + for line in lines_to_save: + file.store_csv_line(line) + + file.close() + + editor_plugin.get_editor_interface().get_resource_filesystem().scan() + editor_plugin.get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", path) + + # Add it to the project l10n settings if it's not already there + var language_code: RegExMatch = RegEx.create_from_string("^[a-z]{2,3}").search(default_locale) + var translation_path: String = path.replace(".csv", ".%s.translation" % language_code.get_string()) + call_deferred("add_path_to_project_translations", translation_path) + + +func export_character_names_to_csv(path: String) -> void: + var file: FileAccess + + # If the file exists, open it first and work out which keys are already in it + var existing_csv = {} + var commas = [] + if FileAccess.file_exists(path): + file = FileAccess.open(path, FileAccess.READ) + var is_first_line = true + var line: Array + while !file.eof_reached(): + line = file.get_csv_line() + if is_first_line: + is_first_line = false + for i in range(2, line.size()): + commas.append("") + # Make sure the line isn't empty before adding it + if line.size() > 0 and line[0].strip_edges() != "": + existing_csv[line[0]] = line + + # Start a new file + file = FileAccess.open(path, FileAccess.WRITE) + + if not file.file_exists(path): + file.store_csv_line(["keys", DialogueSettings.get_setting("default_csv_locale", "en")]) + + # Write our translations to file + var known_keys: PackedStringArray = [] + + var character_names: PackedStringArray = DialogueManagerParser.parse_string(code_edit.text, current_file_path).character_names + + # Make a list of stuff that needs to go into the file + var lines_to_save = [] + for character_name in character_names: + if character_name in known_keys: continue + + known_keys.append(character_name) + + if existing_csv.has(character_name): + var existing_line = existing_csv.get(character_name) + existing_line[1] = character_name + lines_to_save.append(existing_line) + existing_csv.erase(character_name) + else: + lines_to_save.append(PackedStringArray([character_name, character_name] + commas)) + + # Store lines in the file, starting with anything that already exists that hasn't been touched + for line in existing_csv.values(): + file.store_csv_line(line) + for line in lines_to_save: + file.store_csv_line(line) + + file.close() + + editor_plugin.get_editor_interface().get_resource_filesystem().scan() + editor_plugin.get_editor_interface().get_file_system_dock().call_deferred("navigate_to_path", path) + + # Add it to the project l10n settings if it's not already there + var translation_path: String = path.replace(".csv", ".en.translation") + call_deferred("add_path_to_project_translations", translation_path) + + +# Import changes back from an exported CSV by matching translation keys +func import_translations_from_csv(path: String) -> void: + var cursor: Vector2 = code_edit.get_cursor() + + if not FileAccess.file_exists(path): return + + # Open the CSV file and build a dictionary of the known keys + var keys: Dictionary = {} + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + var csv_line: Array + while !file.eof_reached(): + csv_line = file.get_csv_line() + if csv_line.size() > 1: + keys[csv_line[0]] = csv_line[1] + + var parser: DialogueManagerParser = DialogueManagerParser.new() + + # Now look over each line in the dialogue and replace the content for matched keys + var lines: PackedStringArray = code_edit.text.split("\n") + var start_index: int = 0 + var end_index: int = 0 + for i in range(0, lines.size()): + var line: String = lines[i] + var translation_key: String = parser.extract_translation(line) + if keys.has(translation_key): + if parser.is_dialogue_line(line): + start_index = 0 + # See if we need to skip over a character name + line = line.replace("\\:", "!ESCAPED_COLON!") + if ": " in line: + start_index = line.find(": ") + 2 + lines[i] = (line.substr(0, start_index) + keys.get(translation_key) + " [ID:" + translation_key + "]").replace("!ESCAPED_COLON!", ":") + + elif parser.is_response_line(line): + start_index = line.find("- ") + 2 + # See if we need to skip over a character name + line = line.replace("\\:", "!ESCAPED_COLON!") + if ": " in line: + start_index = line.find(": ") + 2 + end_index = line.length() + if " =>" in line: + end_index = line.find(" =>") + if " [if " in line: + end_index = line.find(" [if ") + lines[i] = (line.substr(0, start_index) + keys.get(translation_key) + " [ID:" + translation_key + "]" + line.substr(end_index)).replace("!ESCAPED_COLON!", ":") + + code_edit.text = "\n".join(lines) + code_edit.set_cursor(cursor) + + parser.free() + + +func show_search_form(is_enabled: bool) -> void: + if code_edit.last_selected_text: + search_and_replace.input.text = code_edit.last_selected_text + + search_and_replace.visible = is_enabled + search_button.set_pressed_no_signal(is_enabled) + search_and_replace.focus_line_edit() + + +### Signals + + +func _on_editor_settings_changed() -> void: + var editor_settings: EditorSettings = editor_plugin.get_editor_interface().get_editor_settings() + code_edit.minimap_draw = editor_settings.get_setting("text_editor/appearance/minimap/show_minimap") + code_edit.minimap_width = editor_settings.get_setting("text_editor/appearance/minimap/minimap_width") + code_edit.scroll_smooth = editor_settings.get_setting("text_editor/behavior/navigation/smooth_scrolling") + + +func _on_open_menu_id_pressed(id: int) -> void: + match id: + OPEN_OPEN: + open_dialog.popup_centered() + OPEN_CLEAR: + DialogueSettings.clear_recent_files() + build_open_menu() + _: + var menu = open_button.get_popup() + var item = menu.get_item_text(menu.get_item_index(id)) + open_file(item) + + +func _on_files_list_file_selected(file_path: String) -> void: + self.current_file_path = file_path + + +func _on_insert_button_menu_id_pressed(id: int) -> void: + match id: + 0: + code_edit.insert_bbcode("[wave amp=25 freq=5]", "[/wave]") + 1: + code_edit.insert_bbcode("[shake rate=20 level=10]", "[/shake]") + 3: + code_edit.insert_bbcode("[wait=1]") + 4: + code_edit.insert_bbcode("[speed=0.2]") + 5: + code_edit.insert_bbcode("[next=auto]") + 6: + code_edit.insert_text("~ title") + 7: + code_edit.insert_text("Nathan: This is Some Dialogue") + 8: + code_edit.insert_text("Nathan: Choose a Response...\n- Option 1\n\tNathan: You chose option 1\n- Option 2\n\tNathan: You chose option 2") + 9: + code_edit.insert_text("% Nathan: This is random line 1.\n% Nathan: This is random line 2.\n%1 Nathan: This is weighted random line 3.") + 10: + code_edit.insert_text("Nathan: [[Hi|Hello|Howdy]]") + 11: + code_edit.insert_text("=> title") + 12: + code_edit.insert_text("=> END") + + +func _on_translations_button_menu_id_pressed(id: int) -> void: + match id: + TRANSLATIONS_GENERATE_LINE_IDS: + generate_translations_keys() + + TRANSLATIONS_SAVE_CHARACTERS_TO_CSV: + translation_source = TranslationSource.CharacterNames + export_dialog.filters = PackedStringArray(["*.csv ; Translation CSV"]) + export_dialog.current_path = get_last_export_path("csv") + export_dialog.popup_centered() + + TRANSLATIONS_SAVE_TO_CSV: + translation_source = TranslationSource.Lines + export_dialog.filters = PackedStringArray(["*.csv ; Translation CSV"]) + export_dialog.current_path = get_last_export_path("csv") + export_dialog.popup_centered() + + TRANSLATIONS_IMPORT_FROM_CSV: + import_dialog.current_path = get_last_export_path("csv") + import_dialog.popup_centered() + + +func _on_export_dialog_file_selected(path: String) -> void: + DialogueSettings.set_user_value("last_export_path", path.get_base_dir()) + match path.get_extension(): + "csv": + match translation_source: + TranslationSource.CharacterNames: + export_character_names_to_csv(path) + TranslationSource.Lines: + export_translations_to_csv(path) + + +func _on_import_dialog_file_selected(path: String) -> void: + DialogueSettings.set_user_value("last_export_path", path.get_base_dir()) + import_translations_from_csv(path) + + +func _on_main_view_theme_changed(): + apply_theme() + + +func _on_main_view_visibility_changed() -> void: + if visible and is_instance_valid(code_edit): + code_edit.grab_focus() + + +func _on_new_button_pressed() -> void: + new_dialog.current_file = "" + new_dialog.popup_centered() + + +func _on_new_dialog_file_selected(path: String) -> void: + new_file(path) + open_file(path) + + +func _on_save_dialog_file_selected(path: String) -> void: + new_file(path, code_edit.text) + open_file(path) + + +func _on_open_button_about_to_popup() -> void: + build_open_menu() + + +func _on_open_dialog_file_selected(path: String) -> void: + open_file(path) + + +func _on_save_all_button_pressed() -> void: + save_files() + + +func _on_code_edit_text_changed() -> void: + title_list.titles = code_edit.get_titles() + + var buffer = open_buffers[current_file_path] + buffer.text = code_edit.text + + files_list.mark_file_as_unsaved(current_file_path, buffer.text != buffer.pristine_text) + save_all_button.disabled = open_buffers.values().filter(func(d): return d.text != d.pristine_text).size() == 0 + + parse_timer.start(1) + + +func _on_code_edit_active_title_change(title: String) -> void: + title_list.select_title(title) + DialogueSettings.set_user_value("run_title", title) + + +func _on_code_edit_caret_changed() -> void: + DialogueSettings.set_caret(current_file_path, code_edit.get_cursor()) + + +func _on_code_edit_error_clicked(line_number: int) -> void: + errors_panel.show_error_for_line_number(line_number) + + +func _on_title_list_title_selected(title: String) -> void: + code_edit.go_to_title(title) + code_edit.grab_focus() + + +func _on_parse_timer_timeout() -> void: + parse_timer.stop() + parse() + + +func _on_errors_panel_error_pressed(line_number: int, column_number: int) -> void: + code_edit.set_caret_line(line_number) + code_edit.set_caret_column(column_number) + code_edit.grab_focus() + + +func _on_search_button_toggled(button_pressed: bool) -> void: + show_search_form(button_pressed) + + +func _on_search_and_replace_open_requested() -> void: + show_search_form(true) + + +func _on_search_and_replace_close_requested() -> void: + search_button.set_pressed_no_signal(false) + search_and_replace.visible = false + code_edit.grab_focus() + + +func _on_settings_button_pressed() -> void: + settings_view.prepare() + settings_dialog.popup_centered() + + +func _on_settings_view_script_button_pressed(path: String) -> void: + settings_dialog.hide() + editor_plugin.get_editor_interface().edit_resource(load(path)) + + +func _on_test_button_pressed() -> void: + save_file(current_file_path) + + if errors_panel.errors.size() > 0: + errors_dialog.popup_centered() + return + + DialogueSettings.set_user_value("is_running_test_scene", true) + DialogueSettings.set_user_value("run_resource_path", current_file_path) + var test_scene_path: String = DialogueSettings.get_setting("custom_test_scene_path", "res://addons/dialogue_manager/test_scene.tscn") + editor_plugin.get_editor_interface().play_custom_scene(test_scene_path) + + +func _on_settings_dialog_confirmed() -> void: + settings_view.apply_settings_changes() + parse() + code_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY if DialogueSettings.get_setting("wrap_lines", false) else TextEdit.LINE_WRAPPING_NONE + code_edit.grab_focus() + + +func _on_support_button_pressed() -> void: + OS.shell_open("https://patreon.com/nathanhoad") + + +func _on_docs_button_pressed() -> void: + OS.shell_open("https://github.com/nathanhoad/godot_dialogue_manager") + + +func _on_files_list_file_popup_menu_requested(at_position: Vector2) -> void: + files_popup_menu.position = Vector2(get_viewport().position) + files_list.global_position + at_position + files_popup_menu.popup() + + +func _on_files_list_file_middle_clicked(path: String): + close_file(path) + + +func _on_files_popup_menu_about_to_popup() -> void: + files_popup_menu.clear() + + files_popup_menu.add_item(DialogueConstants.translate("buffer.save"), ITEM_SAVE, KEY_MASK_CTRL | KEY_MASK_ALT | KEY_S) + files_popup_menu.add_item(DialogueConstants.translate("buffer.save_as"), ITEM_SAVE_AS) + files_popup_menu.add_item(DialogueConstants.translate("buffer.close"), ITEM_CLOSE, KEY_MASK_CTRL | KEY_W) + files_popup_menu.add_item(DialogueConstants.translate("buffer.close_all"), ITEM_CLOSE_ALL) + files_popup_menu.add_item(DialogueConstants.translate("buffer.close_other_files"), ITEM_CLOSE_OTHERS) + files_popup_menu.add_separator() + files_popup_menu.add_item(DialogueConstants.translate("buffer.copy_file_path"), ITEM_COPY_PATH) + files_popup_menu.add_item(DialogueConstants.translate("buffer.show_in_filesystem"), ITEM_SHOW_IN_FILESYSTEM) + + +func _on_files_popup_menu_id_pressed(id: int) -> void: + match id: + ITEM_SAVE: + save_file(current_file_path) + ITEM_SAVE_AS: + save_dialog.popup_centered() + ITEM_CLOSE: + close_file(current_file_path) + ITEM_CLOSE_ALL: + for path in open_buffers.keys(): + close_file(path) + ITEM_CLOSE_OTHERS: + for path in open_buffers.keys(): + if path != current_file_path: + close_file(path) + + ITEM_COPY_PATH: + DisplayServer.clipboard_set(current_file_path) + ITEM_SHOW_IN_FILESYSTEM: + show_file_in_filesystem(current_file_path) + + +func _on_code_edit_external_file_requested(path: String, title: String) -> void: + open_file(path) + if title != "": + code_edit.go_to_title(title) + else: + code_edit.set_caret_line(0) + + +func _on_close_confirmation_dialog_confirmed() -> void: + save_file(current_file_path) + remove_file_from_open_buffers(current_file_path) + + +func _on_close_confirmation_dialog_custom_action(action: StringName) -> void: + if action == "discard": + remove_file_from_open_buffers(current_file_path) + close_confirmation_dialog.hide() diff --git a/addons/dialogue_manager/views/main_view.tscn b/addons/dialogue_manager/views/main_view.tscn new file mode 100644 index 00000000..caa3e16f --- /dev/null +++ b/addons/dialogue_manager/views/main_view.tscn @@ -0,0 +1,313 @@ +[gd_scene load_steps=13 format=3 uid="uid://cbuf1q3xsse3q"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/views/main_view.gd" id="1_h6qfq"] +[ext_resource type="PackedScene" uid="uid://civ6shmka5e8u" path="res://addons/dialogue_manager/components/code_edit.tscn" id="2_f73fm"] +[ext_resource type="PackedScene" uid="uid://dnufpcdrreva3" path="res://addons/dialogue_manager/components/files_list.tscn" id="2_npj2k"] +[ext_resource type="PackedScene" uid="uid://ctns6ouwwd68i" path="res://addons/dialogue_manager/components/title_list.tscn" id="2_onb4i"] +[ext_resource type="PackedScene" uid="uid://co8yl23idiwbi" path="res://addons/dialogue_manager/components/update_button.tscn" id="2_ph3vs"] +[ext_resource type="PackedScene" uid="uid://gr8nakpbrhby" path="res://addons/dialogue_manager/components/search_and_replace.tscn" id="6_ylh0t"] +[ext_resource type="PackedScene" uid="uid://cs8pwrxr5vxix" path="res://addons/dialogue_manager/components/errors_panel.tscn" id="7_5cvl4"] +[ext_resource type="Script" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="7_necsa"] +[ext_resource type="PackedScene" uid="uid://cpg4lg1r3ff6m" path="res://addons/dialogue_manager/views/settings_view.tscn" id="9_8bf36"] + +[sub_resource type="Image" id="Image_gxr16"] +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_fguub"] +image = SubResource("Image_gxr16") + +[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_8u3np"] +script = ExtResource("7_necsa") + +[node name="MainView" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_h6qfq") + +[node name="ParseTimer" type="Timer" parent="."] + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 5 +theme_override_constants/margin_right = 5 +theme_override_constants/margin_bottom = 5 +metadata/_edit_layout_mode = 1 + +[node name="Content" type="HSplitContainer" parent="Margin"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +dragger_visibility = 1 + +[node name="SidePanel" type="VBoxContainer" parent="Margin/Content"] +custom_minimum_size = Vector2(150, 0) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/SidePanel"] +layout_mode = 2 + +[node name="NewButton" type="Button" parent="Margin/Content/SidePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Start a new file" +flat = true + +[node name="OpenButton" type="MenuButton" parent="Margin/Content/SidePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Open a file" + +[node name="SaveAllButton" type="Button" parent="Margin/Content/SidePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +disabled = true +flat = true + +[node name="Bookmarks" type="VSplitContainer" parent="Margin/Content/SidePanel"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="FilesList" parent="Margin/Content/SidePanel/Bookmarks" instance=ExtResource("2_npj2k")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="FilesPopupMenu" type="PopupMenu" parent="Margin/Content/SidePanel/Bookmarks/FilesList"] +unique_name_in_owner = true + +[node name="TitleList" parent="Margin/Content/SidePanel/Bookmarks" instance=ExtResource("2_onb4i")] +unique_name_in_owner = true +visible = false +layout_mode = 2 + +[node name="CodePanel" type="VBoxContainer" parent="Margin/Content"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 4.0 + +[node name="Toolbar" type="HBoxContainer" parent="Margin/Content/CodePanel"] +layout_mode = 2 + +[node name="InsertButton" type="MenuButton" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +disabled = true +text = "Insert" + +[node name="TranslationsButton" type="MenuButton" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +disabled = true +text = "Translations" +item_count = 6 +popup/item_0/text = "Generate line IDs" +popup/item_0/icon = SubResource("ImageTexture_fguub") +popup/item_0/id = 0 +popup/item_1/text = "" +popup/item_1/id = -1 +popup/item_1/separator = true +popup/item_2/text = "Save to CSV..." +popup/item_2/icon = SubResource("ImageTexture_fguub") +popup/item_2/id = 2 +popup/item_3/text = "Import changes from CSV..." +popup/item_3/icon = SubResource("ImageTexture_fguub") +popup/item_3/id = 3 +popup/item_4/text = "" +popup/item_4/id = -1 +popup/item_4/separator = true +popup/item_5/text = "Save to PO..." +popup/item_5/icon = SubResource("ImageTexture_fguub") +popup/item_5/id = 5 + +[node name="Separator" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"] +layout_mode = 2 + +[node name="SearchButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Search for text" +disabled = true +toggle_mode = true +flat = true + +[node name="TestButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Test dialogue" +disabled = true +flat = true + +[node name="Separator3" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"] +layout_mode = 2 + +[node name="SettingsButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Settings" +flat = true + +[node name="Spacer2" type="Control" parent="Margin/Content/CodePanel/Toolbar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="SupportButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +flat = true + +[node name="Separator4" type="VSeparator" parent="Margin/Content/CodePanel/Toolbar"] +layout_mode = 2 + +[node name="DocsButton" type="Button" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +text = "Docs" +flat = true + +[node name="VersionLabel" type="Label" parent="Margin/Content/CodePanel/Toolbar"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.490196) +layout_mode = 2 +text = "v2.19.0" +vertical_alignment = 1 + +[node name="UpdateButton" parent="Margin/Content/CodePanel/Toolbar" instance=ExtResource("2_ph3vs")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="SearchAndReplace" parent="Margin/Content/CodePanel" instance=ExtResource("6_ylh0t")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="CodeEdit" parent="Margin/Content/CodePanel" instance=ExtResource("2_f73fm")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_colors/bookmark_color = Color(1, 0.333333, 0.333333, 1) +text = "" +syntax_highlighter = SubResource("SyntaxHighlighter_8u3np") + +[node name="ErrorsPanel" parent="Margin/Content/CodePanel" instance=ExtResource("7_5cvl4")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="NewDialog" type="FileDialog" parent="."] +size = Vector2i(600, 500) +min_size = Vector2i(600, 500) +dialog_hide_on_ok = true +filters = PackedStringArray("*.dialogue ; Dialogue") + +[node name="SaveDialog" type="FileDialog" parent="."] +size = Vector2i(600, 500) +min_size = Vector2i(600, 500) +dialog_hide_on_ok = true +filters = PackedStringArray("*.dialogue ; Dialogue") + +[node name="OpenDialog" type="FileDialog" parent="."] +title = "Open a File" +size = Vector2i(600, 500) +min_size = Vector2i(600, 500) +ok_button_text = "Open" +dialog_hide_on_ok = true +file_mode = 0 +filters = PackedStringArray("*.dialogue ; Dialogue") + +[node name="ExportDialog" type="FileDialog" parent="."] +size = Vector2i(600, 500) +min_size = Vector2i(600, 500) + +[node name="ImportDialog" type="FileDialog" parent="."] +title = "Open a File" +size = Vector2i(600, 500) +min_size = Vector2i(600, 500) +ok_button_text = "Open" +file_mode = 0 +filters = PackedStringArray("*.csv ; Translation CSV") + +[node name="ErrorsDialog" type="AcceptDialog" parent="."] +title = "Error" +dialog_text = "You have errors in your script. Fix them and then try again." + +[node name="SettingsDialog" type="AcceptDialog" parent="."] +title = "Settings" +size = Vector2i(834, 600) +unresizable = true +min_size = Vector2i(600, 600) +ok_button_text = "Done" + +[node name="SettingsView" parent="SettingsDialog" instance=ExtResource("9_8bf36")] +offset_left = 8.0 +offset_top = 8.0 +offset_right = -8.0 +offset_bottom = -49.0 +current_tab = 0 + +[node name="BuildErrorDialog" type="AcceptDialog" parent="."] +title = "Errors" +dialog_text = "You need to fix dialogue errors before you can run your game." + +[node name="CloseConfirmationDialog" type="ConfirmationDialog" parent="."] +title = "Unsaved changes" +ok_button_text = "Save changes" + +[node name="UpdatedDialog" type="AcceptDialog" parent="."] +title = "Updated" +size = Vector2i(191, 100) +dialog_text = "You're now up to date!" + +[connection signal="theme_changed" from="." to="." method="_on_main_view_theme_changed"] +[connection signal="visibility_changed" from="." to="." method="_on_main_view_visibility_changed"] +[connection signal="timeout" from="ParseTimer" to="." method="_on_parse_timer_timeout"] +[connection signal="pressed" from="Margin/Content/SidePanel/Toolbar/NewButton" to="." method="_on_new_button_pressed"] +[connection signal="about_to_popup" from="Margin/Content/SidePanel/Toolbar/OpenButton" to="." method="_on_open_button_about_to_popup"] +[connection signal="pressed" from="Margin/Content/SidePanel/Toolbar/SaveAllButton" to="." method="_on_save_all_button_pressed"] +[connection signal="file_middle_clicked" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_middle_clicked"] +[connection signal="file_popup_menu_requested" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_popup_menu_requested"] +[connection signal="file_selected" from="Margin/Content/SidePanel/Bookmarks/FilesList" to="." method="_on_files_list_file_selected"] +[connection signal="about_to_popup" from="Margin/Content/SidePanel/Bookmarks/FilesList/FilesPopupMenu" to="." method="_on_files_popup_menu_about_to_popup"] +[connection signal="id_pressed" from="Margin/Content/SidePanel/Bookmarks/FilesList/FilesPopupMenu" to="." method="_on_files_popup_menu_id_pressed"] +[connection signal="title_selected" from="Margin/Content/SidePanel/Bookmarks/TitleList" to="." method="_on_title_list_title_selected"] +[connection signal="toggled" from="Margin/Content/CodePanel/Toolbar/SearchButton" to="." method="_on_search_button_toggled"] +[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/TestButton" to="." method="_on_test_button_pressed"] +[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/SettingsButton" to="." method="_on_settings_button_pressed"] +[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/SupportButton" to="." method="_on_support_button_pressed"] +[connection signal="pressed" from="Margin/Content/CodePanel/Toolbar/DocsButton" to="." method="_on_docs_button_pressed"] +[connection signal="close_requested" from="Margin/Content/CodePanel/SearchAndReplace" to="." method="_on_search_and_replace_close_requested"] +[connection signal="open_requested" from="Margin/Content/CodePanel/SearchAndReplace" to="." method="_on_search_and_replace_open_requested"] +[connection signal="active_title_change" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_active_title_change"] +[connection signal="caret_changed" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_caret_changed"] +[connection signal="error_clicked" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_error_clicked"] +[connection signal="external_file_requested" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_external_file_requested"] +[connection signal="text_changed" from="Margin/Content/CodePanel/CodeEdit" to="." method="_on_code_edit_text_changed"] +[connection signal="error_pressed" from="Margin/Content/CodePanel/ErrorsPanel" to="." method="_on_errors_panel_error_pressed"] +[connection signal="file_selected" from="NewDialog" to="." method="_on_new_dialog_file_selected"] +[connection signal="file_selected" from="SaveDialog" to="." method="_on_save_dialog_file_selected"] +[connection signal="file_selected" from="OpenDialog" to="." method="_on_open_dialog_file_selected"] +[connection signal="file_selected" from="ExportDialog" to="." method="_on_export_dialog_file_selected"] +[connection signal="file_selected" from="ImportDialog" to="." method="_on_import_dialog_file_selected"] +[connection signal="confirmed" from="SettingsDialog" to="." method="_on_settings_dialog_confirmed"] +[connection signal="script_button_pressed" from="SettingsDialog/SettingsView" to="." method="_on_settings_view_script_button_pressed"] +[connection signal="confirmed" from="CloseConfirmationDialog" to="." method="_on_close_confirmation_dialog_confirmed"] +[connection signal="custom_action" from="CloseConfirmationDialog" to="." method="_on_close_confirmation_dialog_custom_action"] diff --git a/addons/dialogue_manager/views/settings_view.gd b/addons/dialogue_manager/views/settings_view.gd new file mode 100644 index 00000000..986bd2f8 --- /dev/null +++ b/addons/dialogue_manager/views/settings_view.gd @@ -0,0 +1,280 @@ +@tool +extends TabContainer + + +signal script_button_pressed(path: String) + + +const DialogueConstants = preload("../constants.gd") +const DialogueSettings = preload("../settings.gd") + + +enum PathTarget { + CustomTestScene, + Balloon +} + + +# Editor +@onready var new_template_button: CheckBox = $Editor/NewTemplateButton +@onready var characters_translations_button: CheckBox = $Editor/CharactersTranslationsButton +@onready var wrap_lines_button: Button = $Editor/WrapLinesButton +@onready var default_csv_locale: LineEdit = $Editor/DefaultCSVLocale + +# Runtime +@onready var include_all_responses_button: CheckBox = $Runtime/IncludeAllResponsesButton +@onready var ignore_missing_state_values: CheckBox = $Runtime/IgnoreMissingStateValues +@onready var balloon_path_input: LineEdit = $Runtime/CustomBalloon/BalloonPath +@onready var revert_balloon_button: Button = $Runtime/CustomBalloon/RevertBalloonPath +@onready var load_balloon_button: Button = $Runtime/CustomBalloon/LoadBalloonPath +@onready var states_title: Label = $Runtime/StatesTitle +@onready var globals_list: Tree = $Runtime/GlobalsList + +# Advanced +@onready var check_for_updates: CheckBox = $Advanced/CheckForUpdates +@onready var include_characters_in_translations: CheckBox = $Advanced/IncludeCharactersInTranslations +@onready var include_notes_in_translations: CheckBox = $Advanced/IncludeNotesInTranslations +@onready var open_in_external_editor_button: CheckBox = $Advanced/OpenInExternalEditorButton +@onready var test_scene_path_input: LineEdit = $Advanced/CustomTestScene/TestScenePath +@onready var revert_test_scene_button: Button = $Advanced/CustomTestScene/RevertTestScene +@onready var load_test_scene_button: Button = $Advanced/CustomTestScene/LoadTestScene +@onready var custom_test_scene_file_dialog: FileDialog = $CustomTestSceneFileDialog +@onready var create_lines_for_response_characters: CheckBox = $Advanced/CreateLinesForResponseCharacters +@onready var missing_translations_button: CheckBox = $Advanced/MissingTranslationsButton + +var editor_plugin: EditorPlugin +var all_globals: Dictionary = {} +var enabled_globals: Array = [] +var path_target: PathTarget = PathTarget.CustomTestScene + +var _default_test_scene_path: String = preload("../test_scene.tscn").resource_path + +var _recompile_if_changed_settings: Dictionary + + +func _ready() -> void: + new_template_button.text = DialogueConstants.translate("settings.new_template") + $Editor/MissingTranslationsHint.text = DialogueConstants.translate("settings.missing_keys_hint") + characters_translations_button.text = DialogueConstants.translate("settings.characters_translations") + wrap_lines_button.text = DialogueConstants.translate("settings.wrap_long_lines") + $Editor/DefaultCSVLocaleLabel.text = DialogueConstants.translate("settings.default_csv_locale") + + include_all_responses_button.text = DialogueConstants.translate("settings.include_failed_responses") + ignore_missing_state_values.text = DialogueConstants.translate("settings.ignore_missing_state_values") + $Runtime/CustomBalloonLabel.text = DialogueConstants.translate("settings.default_balloon_hint") + states_title.text = DialogueConstants.translate("settings.states_shortcuts") + $Runtime/StatesMessage.text = DialogueConstants.translate("settings.states_message") + $Runtime/StatesHint.text = DialogueConstants.translate("settings.states_hint") + + check_for_updates.text = DialogueConstants.translate("settings.check_for_updates") + include_characters_in_translations.text = DialogueConstants.translate("settings.include_characters_in_translations") + include_notes_in_translations.text = DialogueConstants.translate("settings.include_notes_in_translations") + open_in_external_editor_button.text = DialogueConstants.translate("settings.open_in_external_editor") + $Advanced/ExternalWarning.text = DialogueConstants.translate("settings.external_editor_warning") + $Advanced/CustomTestSceneLabel.text = DialogueConstants.translate("settings.custom_test_scene") + $Advanced/RecompileWarning.text = DialogueConstants.translate("settings.recompile_warning") + missing_translations_button.text = DialogueConstants.translate("settings.missing_keys") + create_lines_for_response_characters.text = DialogueConstants.translate("settings.create_lines_for_responses_with_characters") + + current_tab = 0 + + +func prepare() -> void: + _recompile_if_changed_settings = _get_settings_that_require_recompilation() + + test_scene_path_input.placeholder_text = DialogueSettings.get_setting("custom_test_scene_path", _default_test_scene_path) + revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path + revert_test_scene_button.icon = get_theme_icon("RotateLeft", "EditorIcons") + revert_test_scene_button.tooltip_text = DialogueConstants.translate("settings.revert_to_default_test_scene") + load_test_scene_button.icon = get_theme_icon("Load", "EditorIcons") + + var balloon_path: String = DialogueSettings.get_setting("balloon_path", "") + if not FileAccess.file_exists(balloon_path): + DialogueSettings.set_setting("balloon_path", "") + balloon_path = "" + balloon_path_input.placeholder_text = balloon_path if balloon_path != "" else DialogueConstants.translate("settings.default_balloon_path") + revert_balloon_button.visible = balloon_path != "" + revert_balloon_button.icon = get_theme_icon("RotateLeft", "EditorIcons") + revert_balloon_button.tooltip_text = DialogueConstants.translate("settings.revert_to_default_balloon") + load_balloon_button.icon = get_theme_icon("Load", "EditorIcons") + + var scale: float = editor_plugin.get_editor_interface().get_editor_scale() + custom_test_scene_file_dialog.min_size = Vector2(600, 500) * scale + + states_title.add_theme_font_override("font", get_theme_font("bold", "EditorFonts")) + + check_for_updates.set_pressed_no_signal(DialogueSettings.get_user_value("check_for_updates", true)) + characters_translations_button.set_pressed_no_signal(DialogueSettings.get_setting("export_characters_in_translation", true)) + wrap_lines_button.set_pressed_no_signal(DialogueSettings.get_setting("wrap_lines", false)) + include_all_responses_button.set_pressed_no_signal(DialogueSettings.get_setting("include_all_responses", false)) + ignore_missing_state_values.set_pressed_no_signal(DialogueSettings.get_setting("ignore_missing_state_values", false)) + new_template_button.set_pressed_no_signal(DialogueSettings.get_setting("new_with_template", true)) + default_csv_locale.text = DialogueSettings.get_setting("default_csv_locale", "en") + + missing_translations_button.set_pressed_no_signal(DialogueSettings.get_setting("missing_translations_are_errors", false)) + create_lines_for_response_characters.set_pressed_no_signal(DialogueSettings.get_setting("create_lines_for_responses_with_characters", true)) + + include_characters_in_translations.set_pressed_no_signal(DialogueSettings.get_setting("include_character_in_translation_exports", false)) + include_notes_in_translations.set_pressed_no_signal(DialogueSettings.get_setting("include_notes_in_translation_exports", false)) + open_in_external_editor_button.set_pressed_no_signal(DialogueSettings.get_user_value("open_in_external_editor", false)) + + var editor_settings: EditorSettings = editor_plugin.get_editor_interface().get_editor_settings() + var external_editor: String = editor_settings.get_setting("text_editor/external/exec_path") + var use_external_editor: bool = editor_settings.get_setting("text_editor/external/use_external_editor") and external_editor != "" + if not use_external_editor: + open_in_external_editor_button.hide() + $Advanced/ExternalWarning.hide() + $Advanced/ExternalSeparator.hide() + + var project = ConfigFile.new() + var err = project.load("res://project.godot") + assert(err == OK, "Could not find the project file") + + all_globals.clear() + if project.has_section("autoload"): + for key in project.get_section_keys("autoload"): + if key != "DialogueManager": + all_globals[key] = project.get_value("autoload", key) + + enabled_globals = DialogueSettings.get_setting("states", []).duplicate() + globals_list.clear() + var root = globals_list.create_item() + for name in all_globals.keys(): + var item: TreeItem = globals_list.create_item(root) + item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) + item.set_checked(0, name in enabled_globals) + item.set_text(0, name) + item.add_button(1, get_theme_icon("Edit", "EditorIcons")) + item.set_text(2, all_globals.get(name, "").replace("*res://", "res://")) + + globals_list.set_column_expand(0, false) + globals_list.set_column_custom_minimum_width(0, 250) + globals_list.set_column_expand(1, false) + globals_list.set_column_custom_minimum_width(1, 40) + globals_list.set_column_titles_visible(true) + globals_list.set_column_title(0, DialogueConstants.translate("settings.autoload")) + globals_list.set_column_title(1, "") + globals_list.set_column_title(2, DialogueConstants.translate("settings.path")) + + +func apply_settings_changes() -> void: + if _recompile_if_changed_settings != _get_settings_that_require_recompilation(): + Engine.get_meta("DialogueCache").reimport_files() + + +func _get_settings_that_require_recompilation() -> Dictionary: + return DialogueSettings.get_settings([ + "missing_translations_are_errors", + "create_lines_for_responses_with_characters" + ]) + + +### Signals + + +func _on_missing_translations_button_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("missing_translations_are_errors", toggled_on) + + +func _on_characters_translations_button_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("export_characters_in_translation", toggled_on) + + +func _on_wrap_lines_button_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("wrap_lines", toggled_on) + + +func _on_include_all_responses_button_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("include_all_responses", toggled_on) + + +func _on_globals_list_item_selected() -> void: + var item = globals_list.get_selected() + var is_checked = not item.is_checked(0) + item.set_checked(0, is_checked) + + if is_checked: + enabled_globals.append(item.get_text(0)) + else: + enabled_globals.erase(item.get_text(0)) + + DialogueSettings.set_setting("states", enabled_globals) + + +func _on_globals_list_button_clicked(item: TreeItem, column: int, id: int, mouse_button_index: int) -> void: + emit_signal("script_button_pressed", item.get_text(2)) + + +func _on_sample_template_toggled(toggled_on): + DialogueSettings.set_setting("new_with_template", toggled_on) + + +func _on_revert_test_scene_pressed() -> void: + DialogueSettings.set_setting("custom_test_scene_path", _default_test_scene_path) + test_scene_path_input.placeholder_text = _default_test_scene_path + revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path + + +func _on_load_test_scene_pressed() -> void: + path_target = PathTarget.CustomTestScene + custom_test_scene_file_dialog.popup_centered() + + +func _on_custom_test_scene_file_dialog_file_selected(path: String) -> void: + match path_target: + PathTarget.CustomTestScene: + # Check that the test scene is a subclass of BaseDialogueTestScene + var test_scene: PackedScene = load(path) + if test_scene and test_scene.instantiate() is BaseDialogueTestScene: + DialogueSettings.set_setting("custom_test_scene_path", path) + test_scene_path_input.placeholder_text = path + revert_test_scene_button.visible = test_scene_path_input.placeholder_text != _default_test_scene_path + else: + var accept: AcceptDialog = AcceptDialog.new() + accept.dialog_text = DialogueConstants.translate("settings.invalid_test_scene").format({ path = path }) + add_child(accept) + accept.popup_centered.call_deferred() + + PathTarget.Balloon: + DialogueSettings.set_setting("balloon_path", path) + balloon_path_input.placeholder_text = path + revert_balloon_button.visible = balloon_path_input.placeholder_text != "" + + +func _on_ignore_missing_state_values_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("ignore_missing_state_values", toggled_on) + + +func _on_default_csv_locale_text_changed(new_text: String) -> void: + DialogueSettings.set_setting("default_csv_locale", new_text) + + +func _on_revert_balloon_path_pressed() -> void: + DialogueSettings.set_setting("balloon_path", "") + balloon_path_input.placeholder_text = DialogueConstants.translate("settings.default_balloon_path") + revert_balloon_button.visible = DialogueSettings.get_setting("balloon_path", "") != "" + + +func _on_load_balloon_path_pressed() -> void: + path_target = PathTarget.Balloon + custom_test_scene_file_dialog.popup_centered() + + +func _on_create_lines_for_response_characters_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("create_lines_for_responses_with_characters", toggled_on) + + +func _on_open_in_external_editor_button_toggled(toggled_on: bool) -> void: + DialogueSettings.set_user_value("open_in_external_editor", toggled_on) + + +func _on_include_characters_in_translations_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("include_character_in_translation_exports", toggled_on) + + +func _on_include_notes_in_translations_toggled(toggled_on: bool) -> void: + DialogueSettings.set_setting("include_notes_in_translation_exports", toggled_on) + + +func _on_keep_up_to_date_toggled(toggled_on: bool) -> void: + DialogueSettings.set_user_value("check_for_updates", toggled_on) diff --git a/addons/dialogue_manager/views/settings_view.tscn b/addons/dialogue_manager/views/settings_view.tscn new file mode 100644 index 00000000..09df0a27 --- /dev/null +++ b/addons/dialogue_manager/views/settings_view.tscn @@ -0,0 +1,221 @@ +[gd_scene load_steps=3 format=3 uid="uid://cpg4lg1r3ff6m"] + +[ext_resource type="Script" path="res://addons/dialogue_manager/views/settings_view.gd" id="1_06uxa"] + +[sub_resource type="Theme" id="Theme_3a8rc"] +HSeparator/constants/separation = 20 + +[node name="SettingsView" type="TabContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = -206.0 +offset_bottom = -345.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme = SubResource("Theme_3a8rc") +current_tab = 2 +script = ExtResource("1_06uxa") + +[node name="Editor" type="VBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="NewTemplateButton" type="CheckBox" parent="Editor"] +layout_mode = 2 +button_pressed = true +text = "New dialogue files will start with template text" + +[node name="MissingTranslationsHint" type="Label" parent="Editor"] +modulate = Color(1, 1, 1, 0.501961) +custom_minimum_size = Vector2(10, 0) +layout_mode = 2 +text = "If you are using static translation keys then having this enabled will help you find any lines that you haven't added a key to yet." +autowrap_mode = 3 + +[node name="CharactersTranslationsButton" type="CheckBox" parent="Editor"] +layout_mode = 2 +button_pressed = true +text = "Export character names in translation files" + +[node name="WrapLinesButton" type="CheckBox" parent="Editor"] +layout_mode = 2 +button_pressed = true +text = "Wrap long lines" + +[node name="HSeparator" type="HSeparator" parent="Editor"] +layout_mode = 2 + +[node name="DefaultCSVLocaleLabel" type="Label" parent="Editor"] +layout_mode = 2 +text = "Default CSV Locale" + +[node name="DefaultCSVLocale" type="LineEdit" parent="Editor"] +layout_mode = 2 + +[node name="Runtime" type="VBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="IncludeAllResponsesButton" type="CheckBox" parent="Runtime"] +layout_mode = 2 +text = "Include responses with failed conditions" + +[node name="IgnoreMissingStateValues" type="CheckBox" parent="Runtime"] +layout_mode = 2 +text = "Skip over missing state value errors (not recommended)" + +[node name="HSeparator" type="HSeparator" parent="Runtime"] +layout_mode = 2 + +[node name="CustomBalloonLabel" type="Label" parent="Runtime"] +layout_mode = 2 +text = "Custom balloon to use when calling \"DialogueManager.show_balloon()\"" + +[node name="CustomBalloon" type="HBoxContainer" parent="Runtime"] +layout_mode = 2 + +[node name="BalloonPath" type="LineEdit" parent="Runtime/CustomBalloon"] +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +editable = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false + +[node name="RevertBalloonPath" type="Button" parent="Runtime/CustomBalloon"] +visible = false +layout_mode = 2 +tooltip_text = "Revert to default test scene" +flat = true + +[node name="LoadBalloonPath" type="Button" parent="Runtime/CustomBalloon"] +layout_mode = 2 + +[node name="HSeparator2" type="HSeparator" parent="Runtime"] +layout_mode = 2 + +[node name="StatesTitle" type="Label" parent="Runtime"] +layout_mode = 2 +text = "State Shortcuts" + +[node name="StatesMessage" type="Label" parent="Runtime"] +layout_mode = 2 +text = "If an autoload is enabled here you can refer to its properties and methods without having to use its name." + +[node name="StatesHint" type="Label" parent="Runtime"] +modulate = Color(1, 1, 1, 0.501961) +custom_minimum_size = Vector2(10, 0) +layout_mode = 2 +text = "ie. Instead of \"SomeState.some_property\" you could just use \"some_property\"" +autowrap_mode = 3 + +[node name="GlobalsList" type="Tree" parent="Runtime"] +layout_mode = 2 +size_flags_vertical = 3 +columns = 3 +column_titles_visible = true +allow_reselect = true +hide_folding = true +hide_root = true +select_mode = 1 + +[node name="Advanced" type="VBoxContainer" parent="."] +layout_mode = 2 + +[node name="CheckForUpdates" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Check for updates" + +[node name="HSeparator" type="HSeparator" parent="Advanced"] +layout_mode = 2 + +[node name="IncludeCharactersInTranslations" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Include character names in translation exports" + +[node name="IncludeNotesInTranslations" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Include notes (## comments) in translation exports" + +[node name="ExternalSeparator" type="HSeparator" parent="Advanced"] +layout_mode = 2 + +[node name="OpenInExternalEditorButton" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Open dialogue files in external editor" + +[node name="ExternalWarning" type="Label" parent="Advanced"] +layout_mode = 2 +text = "Note: Syntax highlighting and detailed error checking are not supported in external editors." + +[node name="HSeparator3" type="HSeparator" parent="Advanced"] +layout_mode = 2 + +[node name="CustomTestSceneLabel" type="Label" parent="Advanced"] +layout_mode = 2 +text = "Custom test scene (must extend BaseDialogueTestScene)" + +[node name="CustomTestScene" type="HBoxContainer" parent="Advanced"] +layout_mode = 2 + +[node name="TestScenePath" type="LineEdit" parent="Advanced/CustomTestScene"] +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +placeholder_text = "res://addons/dialogue_manager/test_scene.tscn" +editable = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false + +[node name="RevertTestScene" type="Button" parent="Advanced/CustomTestScene"] +visible = false +layout_mode = 2 +tooltip_text = "Revert to default test scene" +flat = true + +[node name="LoadTestScene" type="Button" parent="Advanced/CustomTestScene"] +layout_mode = 2 + +[node name="HSeparator4" type="HSeparator" parent="Advanced"] +layout_mode = 2 + +[node name="RecompileWarning" type="Label" parent="Advanced"] +layout_mode = 2 +text = "Changing these settings will force a recompile of all dialogue. Only change them if you know what you are doing." + +[node name="MissingTranslationsButton" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Treat missing translation keys as errors" + +[node name="CreateLinesForResponseCharacters" type="CheckBox" parent="Advanced"] +layout_mode = 2 +text = "Create child dialogue line for responses with character names in them" + +[node name="CustomTestSceneFileDialog" type="FileDialog" parent="."] +title = "Open a File" +ok_button_text = "Open" +file_mode = 0 +filters = PackedStringArray("*.tscn ; Scene") + +[connection signal="toggled" from="Editor/NewTemplateButton" to="." method="_on_sample_template_toggled"] +[connection signal="toggled" from="Editor/CharactersTranslationsButton" to="." method="_on_characters_translations_button_toggled"] +[connection signal="toggled" from="Editor/WrapLinesButton" to="." method="_on_wrap_lines_button_toggled"] +[connection signal="text_changed" from="Editor/DefaultCSVLocale" to="." method="_on_default_csv_locale_text_changed"] +[connection signal="toggled" from="Runtime/IncludeAllResponsesButton" to="." method="_on_include_all_responses_button_toggled"] +[connection signal="toggled" from="Runtime/IgnoreMissingStateValues" to="." method="_on_ignore_missing_state_values_toggled"] +[connection signal="pressed" from="Runtime/CustomBalloon/RevertBalloonPath" to="." method="_on_revert_balloon_path_pressed"] +[connection signal="pressed" from="Runtime/CustomBalloon/LoadBalloonPath" to="." method="_on_load_balloon_path_pressed"] +[connection signal="button_clicked" from="Runtime/GlobalsList" to="." method="_on_globals_list_button_clicked"] +[connection signal="item_selected" from="Runtime/GlobalsList" to="." method="_on_globals_list_item_selected"] +[connection signal="toggled" from="Advanced/CheckForUpdates" to="." method="_on_keep_up_to_date_toggled"] +[connection signal="toggled" from="Advanced/IncludeCharactersInTranslations" to="." method="_on_include_characters_in_translations_toggled"] +[connection signal="toggled" from="Advanced/IncludeNotesInTranslations" to="." method="_on_include_notes_in_translations_toggled"] +[connection signal="toggled" from="Advanced/OpenInExternalEditorButton" to="." method="_on_open_in_external_editor_button_toggled"] +[connection signal="pressed" from="Advanced/CustomTestScene/RevertTestScene" to="." method="_on_revert_test_scene_pressed"] +[connection signal="pressed" from="Advanced/CustomTestScene/LoadTestScene" to="." method="_on_load_test_scene_pressed"] +[connection signal="toggled" from="Advanced/MissingTranslationsButton" to="." method="_on_missing_translations_button_toggled"] +[connection signal="toggled" from="Advanced/CreateLinesForResponseCharacters" to="." method="_on_create_lines_for_response_characters_toggled"] +[connection signal="file_selected" from="CustomTestSceneFileDialog" to="." method="_on_custom_test_scene_file_dialog_file_selected"] diff --git a/project.godot b/project.godot index f56291e1..84684ae0 100644 --- a/project.godot +++ b/project.godot @@ -29,6 +29,7 @@ GlobalVar="*res://Global/GLOBAL_VAR.gd" EnemyManager="*res://Managers/EnemyManager.gd" SceneController="*res://Core/SceneController.gd" MapManager="*res://Managers/MapManager.gd" +DialogueManager="*res://addons/dialogue_manager/dialogue_manager.gd" [debug] @@ -39,6 +40,10 @@ gdscript/warnings/unused_parameter=2 gdscript/warnings/untyped_declaration=2 gdscript/warnings/unsafe_cast=2 +[dialogue_manager] + +general/balloon_path="res://Dialog/EventDialogueWindow.tscn" + [display] window/size/viewport_width=1280 @@ -49,7 +54,7 @@ window/stretch/aspect="expand" [editor_plugins] -enabled=PackedStringArray("res://addons/SmoothScroll/plugin.cfg", "res://addons/gut/plugin.cfg") +enabled=PackedStringArray("res://addons/SmoothScroll/plugin.cfg", "res://addons/dialogue_manager/plugin.cfg", "res://addons/gut/plugin.cfg") [input] @@ -69,6 +74,10 @@ cancel_action={ ] } +[internationalization] + +locale/translations_pot_files=PackedStringArray("res://Dialog/test.dialogue", "res://Dialog/test2.dialogue") + [rendering] anti_aliasing/quality/msaa_2d=1