diff --git a/.plugged/Asset-Drawer b/.plugged/Asset-Drawer
new file mode 160000
index 00000000..f29aac8f
--- /dev/null
+++ b/.plugged/Asset-Drawer
@@ -0,0 +1 @@
+Subproject commit f29aac8fb01cab02a76758aa49d355079ac99825
diff --git a/.plugged/UIDesignTool b/.plugged/UIDesignTool
new file mode 160000
index 00000000..0e3bdbbf
--- /dev/null
+++ b/.plugged/UIDesignTool
@@ -0,0 +1 @@
+Subproject commit 0e3bdbbfe966a4ff27ab9b711eff55a049968fd9
diff --git a/.plugged/gd-YAFSM b/.plugged/gd-YAFSM
new file mode 160000
index 00000000..5b220c7b
--- /dev/null
+++ b/.plugged/gd-YAFSM
@@ -0,0 +1 @@
+Subproject commit 5b220c7b5bb070f22ca8874198a553a198adab42
diff --git a/.plugged/gd-blender-3d-shortcuts b/.plugged/gd-blender-3d-shortcuts
new file mode 160000
index 00000000..ff9cf37f
--- /dev/null
+++ b/.plugged/gd-blender-3d-shortcuts
@@ -0,0 +1 @@
+Subproject commit ff9cf37f3d813745d871ba835be758874d2faac8
diff --git a/.plugged/index.cfg b/.plugged/index.cfg
index 6a781cd3..466d5b9c 100644
--- a/.plugged/index.cfg
+++ b/.plugged/index.cfg
@@ -1,3 +1,74 @@
[plugin]
-installed={}
+installed={
+"Asset-Drawer": {
+"branch": "",
+"commit": "",
+"dest_files": ["res://addons/Asset_Drawer/LICENSE", "res://addons/Asset_Drawer/plugin.cfg", "res://addons/Asset_Drawer/AssetDrawerShortcut.tres", "res://addons/Asset_Drawer/FileSystem.gd"],
+"dev": true,
+"exclude": [],
+"include": [],
+"install_root": "",
+"name": "Asset-Drawer",
+"on_updated": "",
+"plug_dir": "res://.plugged/Asset-Drawer",
+"tag": "",
+"url": "https://git::@github.com/newjoker6/Asset-Drawer.git"
+},
+"UIDesignTool": {
+"branch": "",
+"commit": "",
+"dest_files": ["res://addons/ui_design_tool/scripts/Utils.gd", "res://addons/ui_design_tool/scripts/FontManager.gd", "res://addons/ui_design_tool/plugin.cfg", "res://addons/ui_design_tool/scenes/OverlayTextEdit.gd", "res://addons/ui_design_tool/scenes/Toolbar.tscn", "res://addons/ui_design_tool/scenes/OverlayTextEdit.tscn", "res://addons/ui_design_tool/scenes/Toolbar.gd", "res://addons/ui_design_tool/plugin.gd", "res://addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format-color-text.png.import", "res://addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/refresh-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/marker.png", "res://addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format-color-text.png", "res://addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg.import", "res://addons/ui_design_tool/assets/icons/marker.png.import", "res://addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg", "res://addons/ui_design_tool/assets/icons/refresh-white-18dp.svg.import"],
+"dev": true,
+"exclude": [],
+"include": [],
+"install_root": "",
+"name": "UIDesignTool",
+"on_updated": "",
+"plug_dir": "res://.plugged/UIDesignTool",
+"tag": "",
+"url": "https://git::@github.com/imjp94/UIDesignTool.git"
+},
+"gd-YAFSM": {
+"branch": "",
+"commit": "",
+"dest_files": ["res://addons/imjp94.yafsm/YAFSM.gd", "res://addons/imjp94.yafsm/src/StackPlayer.gd", "res://addons/imjp94.yafsm/src/transitions/Transition.gd", "res://addons/imjp94.yafsm/src/StateDirectory.gd", "res://addons/imjp94.yafsm/src/states/State.gd", "res://addons/imjp94.yafsm/src/states/StateMachine.gd", "res://addons/imjp94.yafsm/src/StateMachinePlayer.gd", "res://addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd", "res://addons/imjp94.yafsm/src/debugger/StackItem.tscn", "res://addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.tscn", "res://addons/imjp94.yafsm/src/conditions/ValueCondition.gd", "res://addons/imjp94.yafsm/src/conditions/BooleanCondition.gd", "res://addons/imjp94.yafsm/src/conditions/StringCondition.gd", "res://addons/imjp94.yafsm/src/conditions/IntegerCondition.gd", "res://addons/imjp94.yafsm/src/conditions/Condition.gd", "res://addons/imjp94.yafsm/src/conditions/FloatCondition.gd", "res://addons/imjp94.yafsm/README.md", "res://addons/imjp94.yafsm/scripts/Utils.gd", "res://addons/imjp94.yafsm/plugin.cfg", "res://addons/imjp94.yafsm/scenes/StateMachineEditor.tscn", "res://addons/imjp94.yafsm/scenes/StateMachineEditorLayer.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.tscn", "res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd", "res://addons/imjp94.yafsm/scenes/ContextMenu.tscn", "res://addons/imjp94.yafsm/scenes/ParametersPanel.gd", "res://addons/imjp94.yafsm/scenes/PathViewer.gd", "res://addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.tscn", "res://addons/imjp94.yafsm/scenes/transition_editors/TransitionInspector.gd", "res://addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn", "res://addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd", "res://addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd", "res://addons/imjp94.yafsm/scenes/state_nodes/StateInspector.gd", "res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn", "res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd", "res://addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn", "res://addons/imjp94.yafsm/scenes/StateMachineEditor.gd", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.tscn", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartGrid.gd", "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd", "res://addons/imjp94.yafsm/plugin.gd", "res://addons/imjp94.yafsm/assets/fonts/sans_serif.tres", "res://addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png.import", "res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/stack_player_icon.png", "res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg", "res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg", "res://addons/imjp94.yafsm/assets/icons/state_machine_icon.png.import", "res://addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/state_machine_icon.png", "res://addons/imjp94.yafsm/assets/icons/stack_player_icon.png.import", "res://addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png", "res://addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg", "res://addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg", "res://addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg.import", "res://addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg", "res://addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg"],
+"dev": false,
+"exclude": [],
+"include": [],
+"install_root": "",
+"name": "gd-YAFSM",
+"on_updated": "",
+"plug_dir": "res://.plugged/gd-YAFSM",
+"tag": "",
+"url": "https://git::@github.com/imjp94/gd-YAFSM.git"
+},
+"gd-blender-3d-shortcuts": {
+"branch": "",
+"commit": "",
+"dest_files": ["res://addons/gd-blender-3d-shortcuts/Utils.gd", "res://addons/gd-blender-3d-shortcuts/plugin.cfg", "res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd", "res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn", "res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd", "res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn", "res://addons/gd-blender-3d-shortcuts/plugin.gd"],
+"dev": true,
+"exclude": [],
+"include": [],
+"install_root": "",
+"name": "gd-blender-3d-shortcuts",
+"on_updated": "",
+"plug_dir": "res://.plugged/gd-blender-3d-shortcuts",
+"tag": "",
+"url": "https://git::@github.com/imjp94/gd-blender-3d-shortcuts.git"
+},
+"loggie": {
+"branch": "",
+"commit": "",
+"dest_files": ["res://addons/loggie/tools/loggie_tools.gd", "res://addons/loggie/tools/loggie_system_specs.gd", "res://addons/loggie/tools/loggie_enums.gd", "res://addons/loggie/loggie_settings.gd", "res://addons/loggie/custom_settings.gd.example", "res://addons/loggie/plugin.cfg", "res://addons/loggie/plugin.gd", "res://addons/loggie/loggie_message.gd", "res://addons/loggie/loggie.gd", "res://addons/loggie/assets/icon.png", "res://addons/loggie/assets/icon.png.import", "res://addons/loggie/assets/logo.png", "res://addons/loggie/assets/logo.png.import"],
+"dev": false,
+"exclude": [],
+"include": ["addons/"],
+"install_root": "",
+"name": "loggie",
+"on_updated": "",
+"plug_dir": "res://.plugged/loggie",
+"tag": "",
+"url": "https://git::@github.com/Shiva-Shadowsong/loggie.git"
+}
+}
diff --git a/.plugged/loggie b/.plugged/loggie
new file mode 160000
index 00000000..290d4fbe
--- /dev/null
+++ b/.plugged/loggie
@@ -0,0 +1 @@
+Subproject commit 290d4fbe75b1103efab6fefab3dbf62b68022d84
diff --git a/addons/Asset_Drawer/AssetDrawerShortcut.tres b/addons/Asset_Drawer/AssetDrawerShortcut.tres
new file mode 100644
index 00000000..b05aa41e
--- /dev/null
+++ b/addons/Asset_Drawer/AssetDrawerShortcut.tres
@@ -0,0 +1,7 @@
+[gd_resource type="InputEventKey" format=3 uid="uid://bafyb8y38ahfh"]
+
+[resource]
+device = -1
+ctrl_pressed = true
+keycode = 32
+unicode = 32
diff --git a/addons/Asset_Drawer/FileSystem.gd b/addons/Asset_Drawer/FileSystem.gd
new file mode 100644
index 00000000..1900f3d8
--- /dev/null
+++ b/addons/Asset_Drawer/FileSystem.gd
@@ -0,0 +1,117 @@
+@tool
+extends EditorPlugin
+
+## The root scene
+const ROOT: StringName = &"root"
+## Padding from the bottom when popped out
+const PADDING: int = 20
+## Padding from the bottom when not popped out
+const BOTTOM_PADDING: int = 60
+## Minimum height of the dock
+const MIN_HEIGHT: int = 50
+
+## The file system
+var file_dock: FileSystemDock = null
+
+var file_split_container: SplitContainer = null
+var file_tree: Tree = null
+var file_container: VBoxContainer = null
+var asset_drawer_shortcut: InputEventKey = InputEventKey.new()
+
+## Toggle for when the file system is moved to bottom
+var files_bottom: bool = false
+var new_size: Vector2
+var initial_load: bool = false
+var showing: bool = false
+
+
+func _enter_tree() -> void:
+ # Add tool button to move shelf to editor bottom
+ add_tool_menu_item("Files to Bottom", files_to_bottom)
+
+ init_file_dock()
+
+ await get_tree().create_timer(0.1).timeout
+ files_to_bottom()
+
+ # Prevent file tree from being shrunk on load
+ await get_tree().create_timer(0.1).timeout
+ file_split_container.split_offset = 175
+
+ # Get shortcut
+ asset_drawer_shortcut = preload("res://addons/Asset_Drawer/AssetDrawerShortcut.tres") as InputEventKey
+
+func init_file_dock() -> void:
+ # Get our file system
+ file_dock = EditorInterface.get_file_system_dock()
+ file_split_container = file_dock.get_child(3) as SplitContainer
+ file_tree = file_split_container.get_child(0) as Tree
+ file_container = file_split_container.get_child(1) as VBoxContainer
+
+#region show hide filesystem
+func _input(event: InputEvent) -> void:
+ if not files_bottom:
+ return
+
+ if asset_drawer_shortcut.is_match(event) and event.is_pressed() and not event.is_echo():
+ if showing:
+ hide_bottom_panel()
+ else:
+ make_bottom_panel_item_visible(file_dock)
+
+ showing = not showing
+#endregion
+
+func _exit_tree() -> void:
+ remove_tool_menu_item("Files to Bottom")
+ files_to_bottom()
+
+
+func _process(_delta: float) -> void:
+ var window := file_dock.get_window()
+ new_size = window.size
+
+ # Keeps the file system from being unusable in size
+ if window.name == ROOT and not files_bottom:
+ file_tree.size.y = new_size.y - PADDING
+ file_container.size.y = new_size.y - PADDING
+ return
+
+ # Adjust the size of the file system based on how far up
+ # the drawer has been pulled
+ if window.name == ROOT and files_bottom:
+ var dock_container := file_dock.get_parent() as Control
+ new_size = dock_container.size
+ var editorsettings := EditorInterface.get_editor_settings()
+ var fontsize: int = editorsettings.get_setting("interface/editor/main_font_size")
+ var editorscale := EditorInterface.get_editor_scale()
+
+ file_tree.size.y = new_size.y - (fontsize * 2) - (BOTTOM_PADDING * editorscale)
+ file_container.size.y = new_size.y - (fontsize * 2) - (BOTTOM_PADDING * editorscale)
+ return
+
+ # Keeps our systems sized when popped out
+ if window.name != ROOT and not files_bottom:
+ window.min_size.y = MIN_HEIGHT
+ file_tree.size.y = new_size.y - PADDING
+ file_container.size.y = new_size.y - PADDING
+
+ # Centers window on first pop
+ if not initial_load:
+ initial_load = true
+ var screenSize: Vector2 = DisplayServer.screen_get_size()
+ window.position = screenSize / 2
+
+
+# Moves the files between the bottom panel and the original dock
+func files_to_bottom() -> void:
+ if files_bottom:
+ remove_control_from_bottom_panel(file_dock)
+ add_control_to_dock(EditorPlugin.DOCK_SLOT_LEFT_BR, file_dock)
+ files_bottom = false
+ return
+
+ init_file_dock()
+ remove_control_from_docks(file_dock)
+ add_control_to_bottom_panel(file_dock, "File System")
+ files_bottom = true
diff --git a/addons/Asset_Drawer/LICENSE b/addons/Asset_Drawer/LICENSE
new file mode 100644
index 00000000..cfbc27e5
--- /dev/null
+++ b/addons/Asset_Drawer/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Michael McGuire
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/addons/Asset_Drawer/plugin.cfg b/addons/Asset_Drawer/plugin.cfg
new file mode 100644
index 00000000..7ed9fe68
--- /dev/null
+++ b/addons/Asset_Drawer/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Asset Drawer"
+description="Converts the File dock to an Asset Drawer at the bottom of the editor."
+author="GlitchedCode"
+version=""
+script="FileSystem.gd"
diff --git a/addons/gd-blender-3d-shortcuts/Utils.gd b/addons/gd-blender-3d-shortcuts/Utils.gd
new file mode 100644
index 00000000..6a559e3d
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/Utils.gd
@@ -0,0 +1,188 @@
+static func apply_transform(nodes, transform, cache_global_transforms):
+ var i = 0
+ for node in nodes:
+ var cache_global_transform = cache_global_transforms[i]
+ node.global_transform.origin = cache_global_transform.origin
+ node.global_transform.origin += cache_global_transform.basis.get_rotation_quaternion() * transform.origin
+ node.global_transform.basis.x = cache_global_transform.basis * transform.basis.x
+ node.global_transform.basis.y = cache_global_transform.basis * transform.basis.y
+ node.global_transform.basis.z = cache_global_transform.basis * transform.basis.z
+ i += 1
+
+static func apply_global_transform(nodes, transform, cache_transforms):
+ var i = 0
+ for node in nodes:
+ node.global_transform = transform * cache_transforms[i]
+ i += 1
+
+static func revert_transform(nodes, cache_global_transforms):
+ var i = 0
+ for node in nodes:
+ node.global_transform = cache_global_transforms[i]
+ i += 1
+
+static func reset_translation(nodes):
+ for node in nodes:
+ node.transform.origin = Vector3.ZERO
+
+static func reset_rotation(nodes):
+ for node in nodes:
+ var scale = node.transform.basis.get_scale()
+ node.transform.basis = Basis().scaled(scale)
+
+static func reset_scale(nodes):
+ for node in nodes:
+ var quat = node.transform.basis.get_rotation_quaternion()
+ node.transform.basis = Basis(quat)
+
+static func hide_nodes(nodes, is_hide=true):
+ for node in nodes:
+ node.visible = !is_hide
+
+static func recursive_get_children(node):
+ var children = node.get_children()
+ if children.size() == 0:
+ return []
+ else:
+ for child in children:
+ children += recursive_get_children(child)
+ return children
+
+static func get_spatial_editor(base_control):
+ var children = recursive_get_children(base_control)
+ for child in children:
+ if child.get_class() == "Node3DEditor":
+ return child
+
+static func get_spatial_editor_viewport_container(spatial_editor):
+ var children = recursive_get_children(spatial_editor)
+ for child in children:
+ if child.get_class() == "Node3DEditorViewportContainer":
+ return child
+
+static func get_spatial_editor_viewports(spatial_editor_viewport):
+ var children = recursive_get_children(spatial_editor_viewport)
+ var spatial_editor_viewports = []
+ for child in children:
+ if child.get_class() == "Node3DEditorViewport":
+ spatial_editor_viewports.append(child)
+ return spatial_editor_viewports
+
+static func get_spatial_editor_viewport_viewport(spatial_editor_viewport):
+ var children = recursive_get_children(spatial_editor_viewport)
+ for child in children:
+ if child.get_class() == "SubViewport":
+ return child
+
+static func get_spatial_editor_viewport_control(spatial_editor_viewport):
+ var children = recursive_get_children(spatial_editor_viewport)
+ for child in children:
+ if child.get_class() == "Control":
+ return child
+
+static func get_focused_spatial_editor_viewport(spatial_editor_viewports):
+ for viewport in spatial_editor_viewports:
+ var viewport_control = get_spatial_editor_viewport_control(viewport)
+ if viewport_control.get_rect().has_point(viewport_control.get_local_mouse_position()):
+ return viewport
+
+static func get_snap_dialog(spatial_editor):
+ var children = recursive_get_children(spatial_editor)
+ for child in children:
+ if child.get_class() == "ConfirmationDialog":
+ if child.title == "Snap Settings":
+ return child
+
+static func get_snap_dialog_line_edits(snap_dialog):
+ var line_edits = []
+ for child in recursive_get_children(snap_dialog):
+ if child.get_class() == "LineEdit":
+ line_edits.append(child)
+ return line_edits
+
+static func get_spatial_editor_local_space_button(spatial_editor):
+ var children = recursive_get_children(spatial_editor)
+ for child in children:
+ if child.get_class() == "Button":
+ if child.shortcut:
+ if child.shortcut.get_as_text() == OS.get_keycode_string(KEY_T):# TODO: Check if user has custom shortcut
+ return child
+
+static func get_spatial_editor_snap_button(spatial_editor):
+ var children = recursive_get_children(spatial_editor)
+ for child in children:
+ if child.get_class() == "Button":
+ if child.shortcut:
+ if child.shortcut.get_as_text() == OS.get_keycode_string(KEY_Y):# TODO: Check if user has custom shortcut
+ return child
+
+static func project_on_plane(camera, screen_point, plane):
+ var from = camera.project_ray_origin(screen_point)
+ var dir = camera.project_ray_normal(screen_point)
+ var intersection = plane.intersects_ray(from, dir)
+ return intersection if intersection else Vector3.ZERO
+
+static func transform_to_plane(t):
+ var a = t.basis.x
+ var b = t.basis.z
+ var c = a + b
+ var o = t.origin
+ return Plane(a + o, b + o, c + o)
+
+# Return new position when out of bounds
+static func infinite_rect(rect, from, to):
+ # Clamp from position to rect first, so it won't hit current side
+ from = Vector2(clamp(from.x, rect.position.x + 2, rect.size.x - 2), clamp(from.y, rect.position.y + 2, rect.size.y - 2))
+ # Intersect with sides of rect
+ var intersection
+ # Top
+ intersection = Geometry2D.segment_intersects_segment(rect.position, Vector2(rect.size.x, rect.position.y), from, to)
+ if intersection:
+ return intersection
+ # Left
+ intersection = Geometry2D.segment_intersects_segment(rect.position, Vector2(rect.position.x, rect.size.y), from, to)
+ if intersection:
+ return intersection
+ # Right
+ intersection = Geometry2D.segment_intersects_segment(rect.size, Vector2(rect.size.x, rect.position.y), from, to)
+ if intersection:
+ return intersection
+ # Bottom
+ intersection = Geometry2D.segment_intersects_segment(rect.size, Vector2(rect.position.x, rect.size.y), from, to)
+ if intersection:
+ return intersection
+ return null
+
+static func draw_axis(im, origin, axis, length, color):
+ var from = origin + (-axis * length / 2)
+ var to = origin + (axis * length / 2)
+ im.surface_begin(Mesh.PRIMITIVE_LINES)
+ im.surface_set_color(color)
+ im.surface_add_vertex(from)
+ im.surface_add_vertex(to)
+ im.surface_end()
+
+static func draw_dashed_line(canvas_item, from, to, color, width, dash_length = 5, cap_end = false, antialiased = false):
+ # See https://github.com/juddrgledhill/godot-dashed-line/blob/master/line_harness.gd
+ var length = (to - from).length()
+ var normal = (to - from).normalized()
+ var dash_step = normal * dash_length
+
+ if length < dash_length: #not long enough to dash
+ canvas_item.draw_line(from, to, color, width, antialiased)
+ return
+
+ else:
+ var draw_flag = true
+ var segment_start = from
+ var steps = length/dash_length
+ for start_length in range(0, steps + 1):
+ var segment_end = segment_start + dash_step
+ if draw_flag:
+ canvas_item.draw_line(segment_start, segment_end, color, width, antialiased)
+
+ segment_start = segment_end
+ draw_flag = !draw_flag
+
+ if cap_end:
+ canvas_item.draw_line(segment_start, to, color, width, antialiased)
diff --git a/addons/gd-blender-3d-shortcuts/plugin.cfg b/addons/gd-blender-3d-shortcuts/plugin.cfg
new file mode 100644
index 00000000..07fb92a5
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Blender 3D Shortcuts"
+description="Blender's 3D transforming shortcuts in Godot"
+author="imjp94"
+version="0.3.2"
+script="plugin.gd"
diff --git a/addons/gd-blender-3d-shortcuts/plugin.gd b/addons/gd-blender-3d-shortcuts/plugin.gd
new file mode 100644
index 00000000..a6ea4dac
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/plugin.gd
@@ -0,0 +1,828 @@
+@tool
+extends EditorPlugin
+
+const Utils = preload("Utils.gd")
+const PieMenuScn = preload("scenes/pie_menu/PieMenu.tscn")
+const PieMenuGroupScn = preload("scenes/pie_menu/PieMenuGroup.tscn")
+
+const DEFAULT_LINE_COLOR = Color.WHITE
+# [name, value]
+const DEBUG_DRAW_OPTIONS = [
+ ["Normal", 0], ["Unshaded", 1], ["Lighting", 2], ["Overdraw", 3], ["Wireframe", 4],
+ [
+ "Advance",
+ [
+ ["Shadows",
+ [
+ ["Shadow Atlas", 9], ["Directional Shadow Atlas", 10], ["Directional Shadow Splits", 14]
+ ]
+ ],
+ ["Lights",
+ [
+ ["Omni Lights Cluster", 20], ["Spot Lights Cluster", 21]
+ ]
+ ],
+ ["VoxelGI",
+ [
+ ["VoxelGI Albedo", 6], ["VoxelGI Lighting", 7], ["VoxelGI Emission", 8]
+ ]
+ ],
+ ["SDFGI",
+ [
+ ["SDFGI", 16], ["SDFGI Probes", 17], ["GI Buffer", 18]
+ ]
+ ],
+ ["Environment",
+ [
+ ["SSAO", 12], ["SSIL", 13]
+ ]
+ ],
+ ["Decals",
+ [
+ ["Decal Atlas", 15], ["Decal Cluster", 22]
+ ]
+ ],
+ ["Others",
+ [
+ ["Normal Buffer", 5], ["Scene Luminance", 11], ["Disable LOD", 19], ["Cluster Reflection Probes", 23], ["Occluders", 24], ["Motion Vectors", 25]
+ ]
+ ],
+ ]
+ ],
+]
+
+enum SESSION {
+ TRANSLATE,
+ ROTATE,
+ SCALE,
+ NONE
+}
+
+var translate_snap_line_edit
+var rotate_snap_line_edit
+var scale_snap_line_edit
+var local_space_button
+var snap_button
+var overlay_control
+var spatial_editor_viewports
+var debug_draw_pie_menu
+var overlay_control_canvas_layer = CanvasLayer.new()
+
+var overlay_label = Label.new()
+var axis_mesh_inst
+var axis_im = ImmediateMesh.new()
+var axis_im_material = StandardMaterial3D.new()
+
+var current_session = SESSION.NONE
+var pivot_point = Vector3.ZERO
+var constraint_axis = Vector3.ONE
+var translate_snap = 1.0
+var rotate_snap = deg_to_rad(15.0)
+var scale_snap = 0.1
+var is_snapping = false
+var is_global = true
+var axis_length = 1000
+var precision_mode = false
+var precision_factor = 0.1
+
+var _is_editing = false
+var _camera
+var _editing_transform = Transform3D.IDENTITY
+var _applying_transform = Transform3D.IDENTITY
+var _last_world_pos = Vector3.ZERO
+var _init_angle = NAN
+var _last_angle = 0
+var _last_center_offset = 0
+var _cummulative_center_offset = 0
+var _max_x = 0
+var _min_x = 0
+var _cache_global_transforms = []
+var _cache_transforms = [] # Nodes' local transform relative to pivot_point
+var _input_string = ""
+var _is_global_on_session = false
+var _is_warping_mouse = false
+var _is_pressing_right_mouse_button = false
+
+
+func _init():
+ axis_im_material.flags_unshaded = true
+ axis_im_material.vertex_color_use_as_albedo = true
+ axis_im_material.flags_no_depth_test = true
+
+ overlay_label.set("custom_colors/font_color_shadow", Color.BLACK)
+
+func _ready():
+ var spatial_editor = Utils.get_spatial_editor(get_editor_interface().get_base_control())
+ var snap_dialog = Utils.get_snap_dialog(spatial_editor)
+ var snap_dialog_line_edits = Utils.get_snap_dialog_line_edits(snap_dialog)
+ translate_snap_line_edit = snap_dialog_line_edits[0]
+ rotate_snap_line_edit = snap_dialog_line_edits[1]
+ scale_snap_line_edit = snap_dialog_line_edits[2]
+ translate_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.TRANSLATE))
+ rotate_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.ROTATE))
+ scale_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.SCALE))
+ local_space_button = Utils.get_spatial_editor_local_space_button(spatial_editor)
+ local_space_button.connect("toggled", _on_local_space_button_toggled)
+ snap_button = Utils.get_spatial_editor_snap_button(spatial_editor)
+ snap_button.connect("toggled", _on_snap_button_toggled)
+ debug_draw_pie_menu = PieMenuGroupScn.instantiate()
+ debug_draw_pie_menu.populate_menu(DEBUG_DRAW_OPTIONS, PieMenuScn.instantiate())
+ debug_draw_pie_menu.theme_source_node = spatial_editor
+ debug_draw_pie_menu.connect("item_focused", _on_PieMenu_item_focused)
+ debug_draw_pie_menu.connect("item_selected", _on_PieMenu_item_selected)
+ var spatial_editor_viewport_container = Utils.get_spatial_editor_viewport_container(spatial_editor)
+ if spatial_editor_viewport_container:
+ spatial_editor_viewports = Utils.get_spatial_editor_viewports(spatial_editor_viewport_container)
+ sync_settings()
+
+func _input(event):
+ if event is InputEventKey:
+ if event.pressed and not event.echo:
+ match event.keycode:
+ KEY_Z:
+ var focus = find_focused_control(get_tree().root)
+
+ if focus != null:
+ if focus.get_parent_control() != null:
+ # This may be slightly fragile if this name changes or the control gets placed another level deeper internally
+ if "Node3DEditorViewport" in focus.get_parent_control().name:
+ if debug_draw_pie_menu.visible:
+ debug_draw_pie_menu.hide()
+ get_viewport().set_input_as_handled()
+ else:
+ if not (event.ctrl_pressed or event.alt_pressed or event.shift_pressed) and current_session == SESSION.NONE:
+ show_debug_draw_pie_menu()
+ get_viewport().set_input_as_handled()
+
+ # Hacky way to intercept default shortcut behavior when in session
+ if current_session != SESSION.NONE:
+ var event_text = event.as_text()
+ if event_text.begins_with("Kp"):
+ append_input_string(event_text.replace("Kp ", ""))
+ get_viewport().set_input_as_handled()
+ match event.keycode:
+ KEY_Y:
+ if event.shift_pressed:
+ toggle_constraint_axis(Vector3.RIGHT + Vector3.BACK)
+ else:
+ toggle_constraint_axis(Vector3.UP)
+ get_viewport().set_input_as_handled()
+
+ if event is InputEventMouseMotion:
+ if current_session != SESSION.NONE and overlay_control:
+ # Infinite mouse movement
+ var rect = overlay_control.get_rect()
+ var local_mouse_pos = overlay_control.get_local_mouse_position()
+ if not rect.has_point(local_mouse_pos):
+ var warp_pos = Utils.infinite_rect(rect, local_mouse_pos, -event.velocity.normalized() * rect.size.length())
+ if warp_pos:
+ Input.warp_mouse(overlay_control.global_position + warp_pos)
+ _is_warping_mouse = true
+
+func _on_snap_value_changed(text, session):
+ match session:
+ SESSION.TRANSLATE:
+ translate_snap = text.to_float()
+ SESSION.ROTATE:
+ rotate_snap = deg_to_rad(text.to_float())
+ SESSION.SCALE:
+ scale_snap = text.to_float() / 100.0
+
+func _on_PieMenu_item_focused(menu, index):
+ var value = menu.buttons[index].get_meta("value", 0)
+ if not (value is Array):
+ switch_display_mode(value)
+
+func _on_PieMenu_item_selected(menu, index):
+ var value = menu.buttons[index].get_meta("value", 0)
+ if not (value is Array):
+ switch_display_mode(value)
+
+func show_debug_draw_pie_menu():
+ var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+ overlay_control = Utils.get_spatial_editor_viewport_control(spatial_editor_viewport) if spatial_editor_viewport else null
+ if not overlay_control:
+ return false
+ if overlay_control_canvas_layer.get_parent() != overlay_control:
+ overlay_control.add_child(overlay_control_canvas_layer)
+ if debug_draw_pie_menu.get_parent() != overlay_control_canvas_layer:
+ overlay_control_canvas_layer.add_child(debug_draw_pie_menu)
+ var viewport = Utils.get_spatial_editor_viewport_viewport(spatial_editor_viewport)
+
+ debug_draw_pie_menu.popup(overlay_control.get_global_mouse_position())
+ return true
+
+func _on_local_space_button_toggled(pressed):
+ is_global = !pressed
+
+func _on_snap_button_toggled(pressed):
+ is_snapping = pressed
+
+func _handles(object):
+ if object is Node3D:
+ _is_editing = get_editor_interface().get_selection().get_selected_nodes().size()
+ return _is_editing
+ elif object.get_class() == "MultiNodeEdit": # Explicitly handle MultiNodeEdit, otherwise, it will active when selected Resource
+ _is_editing = get_editor_interface().get_selection().get_transformable_selected_nodes().size() > 0
+ return _is_editing
+ return false
+
+func _edit(object):
+ var scene_root = get_editor_interface().get_edited_scene_root()
+ if scene_root:
+ # Let editor free axis_mesh_inst as the scene closed,
+ # then create new instance whenever needed
+ if not is_instance_valid(axis_mesh_inst):
+ axis_mesh_inst = MeshInstance3D.new()
+ axis_mesh_inst.mesh = axis_im
+ axis_mesh_inst.material_override = axis_im_material
+ if axis_mesh_inst.get_parent() == null:
+ scene_root.get_parent().add_child(axis_mesh_inst)
+ else:
+ if axis_mesh_inst.get_parent() != scene_root:
+ axis_mesh_inst.get_parent().remove_child(axis_mesh_inst)
+ scene_root.get_parent().add_child(axis_mesh_inst)
+
+func find_focused_control(node):
+ if node is Control and node.has_focus():
+ return node
+
+ for child in node.get_children():
+ var result = find_focused_control(child)
+
+ if result:
+ return result
+
+ return null
+
+func _forward_3d_gui_input(camera, event):
+ var forward = false
+ if current_session == SESSION.NONE:
+ # solve conflict with free look
+ if event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_RIGHT:
+ _is_pressing_right_mouse_button = event.is_pressed()
+ if _is_editing:
+ if event is InputEventKey:
+ if event.pressed:
+ match event.keycode:
+ KEY_G:
+ start_session(SESSION.TRANSLATE, camera, event)
+ forward = true
+ KEY_R:
+ start_session(SESSION.ROTATE, camera, event)
+ forward = true
+ KEY_S:
+ if not event.ctrl_pressed:
+ # solve conflict with free look
+ if not _is_pressing_right_mouse_button:
+ start_session(SESSION.SCALE, camera, event)
+ forward = true
+ KEY_H:
+ commit_hide_nodes()
+ KEY_X:
+ if event.shift_pressed:
+ delete_selected_nodes()
+ else:
+ confirm_delete_selected_nodes()
+ else:
+ if event is InputEventKey:
+ # Not sure why event.pressed always return false for numpad keys
+ match event.keycode:
+ KEY_KP_SUBTRACT:
+ toggle_input_string_sign()
+ return true
+ KEY_KP_ENTER:
+ commit_session()
+ end_session()
+ return true
+
+ if event.keycode == KEY_SHIFT:
+ precision_mode = event.pressed
+ forward = true
+
+ if event.pressed:
+ var event_text = event.as_text()
+ if append_input_string(event_text):
+ return true
+ match event.keycode:
+ KEY_G:
+ if current_session != SESSION.TRANSLATE:
+ revert()
+ clear_session()
+ start_session(SESSION.TRANSLATE, camera, event)
+ return true
+ KEY_R:
+ if current_session != SESSION.ROTATE:
+ revert()
+ clear_session()
+ start_session(SESSION.ROTATE, camera, event)
+ return true
+ KEY_S:
+ if not event.ctrl_pressed:
+ if current_session != SESSION.SCALE:
+ revert()
+ clear_session()
+ start_session(SESSION.SCALE, camera, event)
+ return true
+ KEY_X:
+ if event.shift_pressed:
+ toggle_constraint_axis(Vector3.UP + Vector3.BACK)
+ else:
+ toggle_constraint_axis(Vector3.RIGHT)
+ return true
+ KEY_Y:
+ if event.shift_pressed:
+ toggle_constraint_axis(Vector3.RIGHT + Vector3.BACK)
+ else:
+ toggle_constraint_axis(Vector3.UP)
+ return true
+ KEY_Z:
+ if event.shift_pressed:
+ toggle_constraint_axis(Vector3.RIGHT + Vector3.UP)
+ else:
+ toggle_constraint_axis(Vector3.BACK)
+ return true
+ KEY_MINUS:
+ toggle_input_string_sign()
+ return true
+ KEY_BACKSPACE:
+ trim_input_string()
+ return true
+ KEY_ENTER:
+ commit_session()
+ end_session()
+ return true
+ KEY_ESCAPE:
+ revert()
+ end_session()
+ return true
+
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == 2:
+ revert()
+ end_session()
+ return true
+ else:
+ commit_session()
+ end_session()
+ forward = true
+
+ if event is InputEventMouseMotion:
+ match current_session:
+ SESSION.TRANSLATE, SESSION.ROTATE, SESSION.SCALE:
+ mouse_transform(event)
+ update_overlays()
+ forward = true
+
+ return forward
+
+func _forward_3d_draw_over_viewport(overlay):
+ if current_session == SESSION.NONE:
+ if overlay_label.get_parent() != null:
+ overlay_label.get_parent().remove_child(overlay_label)
+ return
+
+ var editor_settings = get_editor_interface().get_editor_settings()
+ var line_color = DEFAULT_LINE_COLOR
+ if editor_settings.has_setting("editors/3d/selection_box_color"):
+ line_color = editor_settings.get_setting("editors/3d/selection_box_color")
+ var snapped = "snapped" if is_snapping else ""
+ var global_or_local = "global" if is_global else "local"
+ var along_axis = ""
+ if not constraint_axis.is_equal_approx(Vector3.ONE):
+ if constraint_axis.x > 0:
+ along_axis = "X"
+ if constraint_axis.y > 0:
+ along_axis += ", Y" if along_axis.length() else "Y"
+ if constraint_axis.z > 0:
+ along_axis += ", Z" if along_axis.length() else "Z"
+ if along_axis.length():
+ along_axis = "along " + along_axis
+
+ if overlay_label.get_parent() == null:
+ overlay_control.add_child(overlay_label)
+ overlay_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
+ overlay_label.position += Vector2(8, -8)
+ match current_session:
+ SESSION.TRANSLATE:
+ var translation = _applying_transform.origin
+ overlay_label.text = ("Translate (%.3f, %.3f, %.3f) %s %s %s" % [translation.x, translation.y, translation.z, global_or_local, along_axis, snapped])
+ SESSION.ROTATE:
+ var rotation = _applying_transform.basis.get_euler()
+ overlay_label.text = ("Rotate (%.3f, %.3f, %.3f) %s %s %s" % [rad_to_deg(rotation.x), rad_to_deg(rotation.y), rad_to_deg(rotation.z), global_or_local, along_axis, snapped])
+ SESSION.SCALE:
+ var scale = _applying_transform.basis.get_scale()
+ overlay_label.text = ("Scale (%.3f, %.3f, %.3f) %s %s %s" % [scale.x, scale.y, scale.z, global_or_local, along_axis, snapped])
+ if not _input_string.is_empty():
+ overlay_label.text += "(%s)" % _input_string
+ var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+ var screen_origin = overlay.size / 2.0 if is_pivot_point_behind_camera else _camera.unproject_position(pivot_point)
+ Utils.draw_dashed_line(overlay, screen_origin, overlay.get_local_mouse_position(), line_color, 1, 5, true, true)
+
+func text_transform(text):
+ var input_value = text.to_float()
+ match current_session:
+ SESSION.TRANSLATE:
+ _applying_transform.origin = constraint_axis * input_value
+ SESSION.ROTATE:
+ _applying_transform.basis = Basis().rotated((-_camera.global_transform.basis.z * constraint_axis).normalized(), deg_to_rad(input_value))
+ SESSION.SCALE:
+ if constraint_axis.x:
+ _applying_transform.basis.x = Vector3.RIGHT * input_value
+ if constraint_axis.y:
+ _applying_transform.basis.y = Vector3.UP * input_value
+ if constraint_axis.z:
+ _applying_transform.basis.z = Vector3.BACK * input_value
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ var t = _applying_transform
+ if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+ t.origin += pivot_point
+ Utils.apply_global_transform(nodes, t, _cache_transforms)
+ else:
+ Utils.apply_transform(nodes, t, _cache_global_transforms)
+
+func mouse_transform(event):
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ var is_single_node = nodes.size() == 1
+ var node1 = nodes[0]
+ var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+ if is_nan(_init_angle):
+ var screen_origin = _camera.unproject_position(pivot_point)
+ _init_angle = event.position.angle_to_point(screen_origin)
+ # Translation offset
+ var plane_transform = _camera.global_transform
+ plane_transform.origin = pivot_point
+ plane_transform.basis = plane_transform.basis.rotated(plane_transform.basis * Vector3.LEFT, deg_to_rad(90))
+ if is_pivot_point_behind_camera:
+ plane_transform.origin = _camera.global_transform.origin + -_camera.global_transform.basis.z * 10.0
+ var plane = Utils.transform_to_plane(plane_transform)
+ var axis_count = get_constraint_axis_count()
+ if axis_count == 2:
+ var normal = (Vector3.ONE - constraint_axis).normalized()
+ if is_single_node and not is_global:
+ normal = node1.global_transform.basis * normal
+ var plane_dist = normal * plane_transform.origin
+ plane = Plane(normal, plane_dist.x + plane_dist.y + plane_dist.z)
+ var world_pos = Utils.project_on_plane(_camera, event.position, plane)
+ if not is_global and is_single_node and axis_count < 3:
+ var normalized_node1_basis = node1.global_transform.basis.scaled(Vector3.ONE / node1.global_transform.basis.get_scale())
+ world_pos = world_pos * normalized_node1_basis
+ if is_equal_approx(_last_world_pos.length(), 0):
+ _last_world_pos = world_pos
+ var offset = world_pos - _last_world_pos
+ offset *= constraint_axis
+ offset = offset.snapped(Vector3.ONE * 0.001)
+ if _is_warping_mouse:
+ offset = Vector3.ZERO
+ # Rotation offset
+ var screen_origin = _camera.unproject_position(pivot_point)
+ if is_pivot_point_behind_camera:
+ screen_origin = overlay_control.size / 2.0
+ var angle = event.position.angle_to_point(screen_origin) - _init_angle
+ var angle_offset = angle - _last_angle
+ angle_offset = snapped(angle_offset, 0.001)
+ # Scale offset
+ if _max_x == 0:
+ _max_x = event.position.x
+ _min_x = _max_x - (_max_x - screen_origin.x) * 2
+ var center_value = 2 * ((event.position.x - _min_x) / (_max_x - _min_x)) - 1
+ if _last_center_offset == 0:
+ _last_center_offset = center_value
+ var center_offset = center_value - _last_center_offset
+ center_offset = snapped(center_offset, 0.001)
+ if _is_warping_mouse:
+ center_offset = 0
+ _cummulative_center_offset += center_offset
+ if _input_string.is_empty():
+ match current_session:
+ SESSION.TRANSLATE:
+ _editing_transform = _editing_transform.translated(offset)
+ _applying_transform.origin = _editing_transform.origin
+ if is_snapping:
+ var snap = Vector3.ONE * (translate_snap if not precision_mode else translate_snap * precision_factor)
+ _applying_transform.origin = _applying_transform.origin.snapped(snap)
+ SESSION.ROTATE:
+ var rotation_axis = (-_camera.global_transform.basis.z * constraint_axis).normalized()
+ if not rotation_axis.is_equal_approx(Vector3.ZERO):
+ _editing_transform.basis = _editing_transform.basis.rotated(rotation_axis, angle_offset)
+ var quat = _editing_transform.basis.get_rotation_quaternion()
+ if is_snapping:
+ var snap = Vector3.ONE * (rotate_snap if not precision_mode else rotate_snap * precision_factor)
+ quat.from_euler(quat.get_euler().snapped(snap))
+ _applying_transform.basis = Basis(quat)
+ SESSION.SCALE:
+ if constraint_axis.x:
+ _editing_transform.basis.x = Vector3.RIGHT * (1 + _cummulative_center_offset)
+ if constraint_axis.y:
+ _editing_transform.basis.y = Vector3.UP * (1 + _cummulative_center_offset)
+ if constraint_axis.z:
+ _editing_transform.basis.z = Vector3.BACK * (1 + _cummulative_center_offset)
+ _applying_transform.basis = _editing_transform.basis
+ if is_snapping:
+ var snap = Vector3.ONE * (scale_snap if not precision_mode else scale_snap * precision_factor)
+ _applying_transform.basis.x = _applying_transform.basis.x.snapped(snap)
+ _applying_transform.basis.y = _applying_transform.basis.y.snapped(snap)
+ _applying_transform.basis.z = _applying_transform.basis.z.snapped(snap)
+
+ var t = _applying_transform
+ if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+ t.origin += pivot_point
+ Utils.apply_global_transform(nodes, t, _cache_transforms)
+ else:
+ Utils.apply_transform(nodes, t, _cache_global_transforms)
+ _last_world_pos = world_pos
+ _last_center_offset = center_value
+ _last_angle = angle
+ _is_warping_mouse = false
+
+func cache_selected_nodes_transforms():
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ var inversed_pivot_transform = Transform3D().translated(pivot_point).affine_inverse()
+ for i in nodes.size():
+ var node = nodes[i]
+ _cache_global_transforms.append(node.global_transform)
+ _cache_transforms.append(inversed_pivot_transform * node.global_transform)
+
+func update_pivot_point():
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ var aabb = AABB()
+ for i in nodes.size():
+ var node = nodes[i]
+ if i == 0:
+ aabb.position = node.global_transform.origin
+ aabb = aabb.expand(node.global_transform.origin)
+ pivot_point = aabb.position + aabb.size / 2.0
+
+func start_session(session, camera, event):
+ if get_editor_interface().get_selection().get_transformable_selected_nodes().size() == 0:
+ return
+ current_session = session
+ _camera = camera
+ _is_global_on_session = is_global
+ update_pivot_point()
+ cache_selected_nodes_transforms()
+
+ if event.alt_pressed:
+ commit_reset_transform()
+ end_session()
+ return
+
+ update_overlays()
+ var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+ overlay_control = Utils.get_spatial_editor_viewport_control(spatial_editor_viewport) if spatial_editor_viewport else null
+
+func end_session():
+ _is_editing = get_editor_interface().get_selection().get_transformable_selected_nodes().size() > 0
+ # Manually set is_global to avoid triggering revert()
+ if is_instance_valid(local_space_button):
+ local_space_button.button_pressed = !_is_global_on_session
+ is_global = _is_global_on_session
+ clear_session()
+ update_overlays()
+
+func commit_session():
+ var undo_redo = get_undo_redo()
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ Utils.revert_transform(nodes, _cache_global_transforms)
+ undo_redo.create_action(SESSION.keys()[current_session].to_lower().capitalize())
+ var t = _applying_transform
+ if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+ t.origin += pivot_point
+ undo_redo.add_do_method(Utils, "apply_global_transform", nodes, t, _cache_transforms)
+ else:
+ undo_redo.add_do_method(Utils, "apply_transform", nodes, t, _cache_global_transforms)
+ undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+ undo_redo.commit_action()
+
+func commit_reset_transform():
+ var undo_redo = get_undo_redo()
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ match current_session:
+ SESSION.TRANSLATE:
+ undo_redo.create_action("Reset Translation")
+ undo_redo.add_do_method(Utils, "reset_translation", nodes)
+ undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+ undo_redo.commit_action()
+ SESSION.ROTATE:
+ undo_redo.create_action("Reset Rotation")
+ undo_redo.add_do_method(Utils, "reset_rotation", nodes)
+ undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+ undo_redo.commit_action()
+ SESSION.SCALE:
+ undo_redo.create_action("Reset Scale")
+ undo_redo.add_do_method(Utils, "reset_scale", nodes)
+ undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+ undo_redo.commit_action()
+ current_session = SESSION.NONE
+
+func commit_hide_nodes():
+ var undo_redo = get_undo_redo()
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ undo_redo.create_action("Hide Nodes")
+ undo_redo.add_do_method(Utils, "hide_nodes", nodes, true)
+ undo_redo.add_undo_method(Utils, "hide_nodes", nodes, false)
+ undo_redo.commit_action()
+
+## Opens a popup dialog to confirm deletion of selected nodes.
+func confirm_delete_selected_nodes():
+ var selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
+ if selected_nodes.is_empty():
+ return
+
+ var editor_theme = get_editor_interface().get_base_control().theme
+ var popup = ConfirmationDialog.new()
+ popup.theme = editor_theme
+
+ # Setting dialog text dynamically depending on the selection to mimick Godot's normal behavior.
+ popup.dialog_text = "Delete "
+ var selection_size = selected_nodes.size()
+ if selection_size == 1:
+ popup.dialog_text += selected_nodes[0].get_name()
+ elif selection_size > 1:
+ popup.dialog_text += str(selection_size) + " nodes"
+ for node in selected_nodes:
+ if node.get_child_count() > 0:
+ popup.dialog_text += " and children"
+ break
+ popup.dialog_text += "?"
+
+ add_child(popup)
+ popup.popup_centered()
+ popup.canceled.connect(popup.queue_free)
+ popup.confirmed.connect(delete_selected_nodes)
+ popup.confirmed.connect(popup.queue_free)
+
+## Instantly deletes selected nodes and creates an undo history entry.
+func delete_selected_nodes():
+ var undo_redo = get_undo_redo()
+
+ var selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
+ # Avoid creating an unnecessary history entry if no nodes are selected.
+ if selected_nodes.is_empty():
+ return
+
+ undo_redo.create_action("Delete Nodes", UndoRedo.MERGE_DISABLE)
+ for node in selected_nodes:
+ # We can't free nodes, they must be kept in memory for undo to work.
+ # That's why we use remove_child instead and call UndoRedo.add_undo_reference() below.
+ undo_redo.add_do_method(node.get_parent(), "remove_child", node)
+ undo_redo.add_undo_method(node.get_parent(), "add_child", node, true)
+ undo_redo.add_undo_method(node.get_parent(), "move_child", node, node.get_index())
+ # Every node's owner must be set upon undoing, otherwise, it won't appear in the scene dock
+ # and it'll be lost upon saving.
+ undo_redo.add_undo_method(node, "set_owner", node.owner)
+ for child in Utils.recursive_get_children(node):
+ undo_redo.add_undo_method(child, "set_owner", node.owner)
+ undo_redo.add_undo_reference(node)
+ undo_redo.commit_action()
+
+func revert():
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ Utils.revert_transform(nodes, _cache_global_transforms)
+ _editing_transform = Transform3D.IDENTITY
+ _applying_transform = Transform3D.IDENTITY
+ _last_world_pos = Vector3.ZERO
+ axis_im.clear_surfaces()
+
+func clear_session():
+ current_session = SESSION.NONE
+ constraint_axis = Vector3.ONE
+ pivot_point = Vector3.ZERO
+ precision_mode = false
+ _editing_transform = Transform3D.IDENTITY
+ _applying_transform = Transform3D.IDENTITY
+ _last_world_pos = Vector3.ZERO
+ _init_angle = NAN
+ _last_angle = 0
+ _last_center_offset = 0
+ _cummulative_center_offset = 0
+ _max_x = 0
+ _min_x = 0
+ _cache_global_transforms = []
+ _cache_transforms = []
+ _input_string = ""
+ _is_warping_mouse = false
+ axis_im.clear_surfaces()
+
+func sync_settings():
+ if translate_snap_line_edit:
+ translate_snap = translate_snap_line_edit.text.to_float()
+ if rotate_snap_line_edit:
+ rotate_snap = deg_to_rad(rotate_snap_line_edit.text.to_float())
+ if scale_snap_line_edit:
+ scale_snap = scale_snap_line_edit.text.to_float() / 100.0
+ if local_space_button:
+ is_global = !local_space_button.button_pressed
+ if snap_button:
+ is_snapping = snap_button.button_pressed
+
+func switch_display_mode(debug_draw):
+ var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+ if spatial_editor_viewport:
+ var viewport = Utils.get_spatial_editor_viewport_viewport(spatial_editor_viewport)
+ viewport.debug_draw = debug_draw
+
+# Repeatedly applying same axis will results in toggling is_global, just like pressing xx, yy or zz in blender
+func toggle_constraint_axis(axis):
+ # Following order as below:
+ # 1) Apply constraint on current mode
+ # 2) Toggle mode
+ # 3) Toggle mode again, and remove constraint
+ if is_global == _is_global_on_session:
+ if not constraint_axis.is_equal_approx(axis):
+ # 1
+ set_constraint_axis(axis)
+ else:
+ # 2
+ set_is_global(!_is_global_on_session)
+ else:
+ if constraint_axis.is_equal_approx(axis):
+ # 3
+ set_is_global(_is_global_on_session)
+ set_constraint_axis(Vector3.ONE)
+ else:
+ # Others situation
+ set_constraint_axis(axis)
+
+func toggle_input_string_sign():
+ if _input_string.begins_with("-"):
+ _input_string = _input_string.trim_prefix("-")
+ else:
+ _input_string = "-" + _input_string
+ input_string_changed()
+
+func trim_input_string():
+ _input_string = _input_string.substr(0, _input_string.length() - 1)
+ input_string_changed()
+
+func append_input_string(text):
+ text = "." if text == "Period" else text
+ if text.is_valid_int() or text == ".":
+ _input_string += text
+ input_string_changed()
+ return true
+
+func input_string_changed():
+ if not _input_string.is_empty():
+ text_transform(_input_string)
+ else:
+ _applying_transform = Transform3D.IDENTITY
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ Utils.revert_transform(nodes, _cache_global_transforms)
+ update_overlays()
+
+func get_constraint_axis_count():
+ var axis_count = 3
+ if constraint_axis.x == 0:
+ axis_count -= 1
+ if constraint_axis.y == 0:
+ axis_count -= 1
+ if constraint_axis.z == 0:
+ axis_count -= 1
+ return axis_count
+
+func set_constraint_axis(v):
+ revert()
+ if constraint_axis != v:
+ constraint_axis = v
+ draw_axises()
+ else:
+ constraint_axis = Vector3.ONE
+ if not _input_string.is_empty():
+ text_transform(_input_string)
+ update_overlays()
+
+func set_is_global(v):
+ if is_global != v:
+ if is_instance_valid(local_space_button):
+ local_space_button.button_pressed = !v
+ revert()
+ is_global = v
+ draw_axises()
+ if not _input_string.is_empty():
+ text_transform(_input_string)
+ update_overlays()
+
+func draw_axises():
+ if not constraint_axis.is_equal_approx(Vector3.ONE):
+ var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+ var axis_lines = []
+ if constraint_axis.x > 0:
+ axis_lines.append({"axis": Vector3.RIGHT, "color": Color.RED})
+ if constraint_axis.y > 0:
+ axis_lines.append({"axis": Vector3.UP, "color": Color.GREEN})
+ if constraint_axis.z > 0:
+ axis_lines.append({"axis": Vector3.BACK, "color": Color.BLUE})
+
+ for axis_line in axis_lines:
+ var axis = axis_line.get("axis")
+ var color = axis_line.get("color")
+ if is_global:
+ var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+ var axis_origin = _camera.global_transform.origin + -_camera.global_transform.basis.z * 10.0 if is_pivot_point_behind_camera else pivot_point
+ Utils.draw_axis(axis_im, axis_origin, axis, axis_length, color)
+ else:
+ for node in nodes:
+ Utils.draw_axis(axis_im, node.global_transform.origin, node.global_transform.basis * axis, axis_length, color)
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd
new file mode 100644
index 00000000..de83552c
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd
@@ -0,0 +1,159 @@
+@tool
+extends Control
+
+signal item_selected(index)
+signal item_focused(index)
+signal item_cancelled()
+
+const button_margin = 6
+
+@export var items := [] : set = set_items
+@export var selected_index = -1 : set = set_selected_index
+@export var radius = 100.0 : set = set_radius
+
+var buttons = []
+var pie_menus = []
+
+var focused_index = -1
+var theme_source_node = self : set = set_theme_source_node
+var grow_with_max_button_width = false
+
+
+func _ready():
+ set_items(items)
+ set_selected_index(selected_index)
+ set_radius(radius)
+ hide()
+ connect("visibility_changed", _on_visiblity_changed)
+
+func _input(event):
+ if visible:
+ if event is InputEventKey:
+ if event.pressed:
+ match event.keycode:
+ KEY_ESCAPE:
+ cancel()
+ if event is InputEventMouseMotion:
+ focus_item()
+ get_viewport().set_input_as_handled()
+ if event is InputEventMouseButton:
+ if event.pressed:
+ match event.button_index:
+ MOUSE_BUTTON_LEFT:
+ select_item(focused_index)
+ get_viewport().set_input_as_handled()
+ MOUSE_BUTTON_RIGHT:
+ cancel()
+ get_viewport().set_input_as_handled()
+
+func _on_visiblity_changed():
+ if not visible:
+ if selected_index != focused_index: # Cancellation
+ focused_index = selected_index
+
+func cancel():
+ hide()
+ get_viewport().set_input_as_handled()
+ emit_signal("item_cancelled")
+
+func select_item(index):
+ set_button_style(selected_index, "normal", "normal")
+ selected_index = index
+ focused_index = selected_index
+ hide()
+ emit_signal("item_selected", selected_index)
+
+func focus_item():
+ queue_redraw()
+ var pos = get_global_mouse_position()
+ var count = max(buttons.size(), 1)
+ var angle_offset = 2 * PI / count
+ var angle = pos.angle_to_point(global_position) + PI / 2 # -90 deg initial offset
+ if angle < 0:
+ angle += 2 * PI
+
+ var index = (angle / angle_offset)
+ var decimal = index - floor(index)
+ index = floor(index)
+ if decimal >= 0.5:
+ index += 1
+ if index > buttons.size()-1:
+ index = 0
+
+ set_button_style(focused_index, "normal", "normal")
+ focused_index = index
+ set_button_style(focused_index, "normal", "hover")
+ set_button_style(selected_index, "normal", "focus")
+ emit_signal("item_focused", focused_index)
+
+func popup(pos):
+ global_position = pos
+ show()
+
+func populate_menu():
+ clear_menu()
+ buttons = []
+ for i in items.size():
+ var item = items[i]
+ var is_array = item is Array
+ var name = item if not is_array else item[0]
+ var value = null if not is_array else item[1]
+ var button = Button.new()
+ button.grow_horizontal = Control.GROW_DIRECTION_BOTH
+ button.text = name
+ if value != null:
+ button.set_meta("value", value)
+ buttons.append(button)
+ set_button_style(i, "hover", "hover")
+ set_button_style(i, "pressed", "pressed")
+ set_button_style(i, "focus", "focus")
+ set_button_style(i, "disabled", "disabled")
+ set_button_style(i, "normal", "normal")
+ add_child(button)
+ align()
+
+ set_button_style(selected_index, "normal", "focus")
+
+func align():
+ var final_radius = radius
+ if grow_with_max_button_width:
+ var max_button_width = 0.0
+ for button in buttons:
+ max_button_width = max(max_button_width, button.size.x)
+ final_radius = max(radius, max_button_width)
+ var count = max(buttons.size(), 1)
+ var angle_offset = 2 * PI / count
+ var angle = PI / 2 # 90 deg initial offset
+ for button in buttons:
+ button.position = Vector2(final_radius, 0.0).rotated(angle) - (button.size / 2.0)
+ angle += angle_offset
+
+func clear_menu():
+ for button in buttons:
+ button.queue_free()
+
+func set_button_style(index, name, source):
+ if index < 0 or index > buttons.size() - 1:
+ return
+
+ buttons[index].set("theme_override_styles/%s" % name, get_theme_stylebox(source, "Button"))
+
+func set_items(v):
+ items = v
+ if is_inside_tree():
+ populate_menu()
+
+func set_selected_index(v):
+ set_button_style(selected_index, "normal", "normal")
+ selected_index = v
+ set_button_style(selected_index, "normal", "focus")
+
+func set_radius(v):
+ radius = v
+ align()
+
+func set_theme_source_node(v):
+ theme_source_node = v
+ for pie_menu in pie_menus:
+ if pie_menu:
+ pie_menu.theme_source_node = theme_source_node
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn
new file mode 100644
index 00000000..e7d367c1
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn
@@ -0,0 +1,11 @@
+[gd_scene load_steps=2 format=3 uid="uid://bxummco35581e"]
+
+[ext_resource type="Script" path="res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd" id="1"]
+
+[node name="PieMenu" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+script = ExtResource("1")
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd
new file mode 100644
index 00000000..997035e1
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd
@@ -0,0 +1,113 @@
+@tool
+extends Control
+const PieMenuScn = preload("PieMenu.tscn")
+
+signal item_focused(menu, index)
+signal item_selected(menu, index)
+signal item_cancelled(menu)
+
+var root
+var page_index = [0]
+var theme_source_node = self : set = set_theme_source_node
+
+
+func _ready():
+ hide()
+
+func _on_item_cancelled(pie_menu):
+ back()
+ emit_signal("item_cancelled", pie_menu)
+
+func _on_item_focused(index, pie_menu):
+ var current_menu = get_current_menu()
+ if current_menu == pie_menu:
+ emit_signal("item_focused", current_menu, index)
+
+func _on_item_selected(index):
+ var last_menu = get_current_menu()
+ page_index.append(index)
+ var current_menu = get_current_menu()
+ if current_menu:
+ current_menu.selected_index = -1
+ if current_menu.pie_menus.size() > 0: # Has next page
+ current_menu.popup(global_position)
+ else:
+ # Final selection, revert page index
+ if page_index.size() > 1:
+ page_index.pop_back()
+ last_menu = get_current_menu()
+ page_index = [0]
+ hide()
+ emit_signal("item_selected", last_menu, index)
+
+func popup(pos):
+ global_position = pos
+ var pie_menu = get_current_menu()
+ pie_menu.popup(global_position)
+ show()
+
+func populate_menu(items, pie_menu):
+ add_child(pie_menu)
+ if not root:
+ root = pie_menu
+ root.connect("item_focused", _on_item_focused.bind(pie_menu))
+ root.connect("item_selected", _on_item_selected)
+ root.connect("item_cancelled", _on_item_cancelled.bind(pie_menu))
+
+ pie_menu.items = items
+
+ for i in items.size():
+ var item = items[i]
+ var is_array = item is Array
+ # var name = item if not is_array else item[0]
+ var value = null if not is_array else item[1]
+ if value is Array:
+ var new_pie_menu = PieMenuScn.instantiate()
+ new_pie_menu.connect("item_focused", _on_item_focused.bind(new_pie_menu))
+ new_pie_menu.connect("item_selected", _on_item_selected)
+ new_pie_menu.connect("item_cancelled", _on_item_cancelled.bind(new_pie_menu))
+
+ populate_menu(value, new_pie_menu)
+ pie_menu.pie_menus.append(new_pie_menu)
+ else:
+ pie_menu.pie_menus.append(null)
+ return pie_menu
+
+func clear_menu():
+ if root:
+ root.queue_free()
+
+func back():
+ var last_menu = get_current_menu()
+ last_menu.hide()
+ page_index.pop_back()
+ if page_index.size() == 0:
+ page_index = [0]
+ hide()
+ return
+ else:
+ var current_menu = get_current_menu()
+ if current_menu:
+ current_menu.popup(global_position)
+
+func get_menu(indexes=[0]):
+ var pie_menu = root
+ for i in indexes.size():
+ if i == 0:
+ continue # root
+
+ var page = indexes[i]
+ pie_menu = pie_menu.pie_menus[page]
+ return pie_menu
+
+func get_current_menu():
+ return get_menu(page_index)
+
+func set_theme_source_node(v):
+ theme_source_node = v
+ if not root:
+ return
+
+ for pie_menu in root.pie_menus:
+ if pie_menu:
+ pie_menu.theme_source_node = theme_source_node
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn
new file mode 100644
index 00000000..db382e5a
--- /dev/null
+++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=3 uid="uid://c4cfbaj52t05b"]
+
+[ext_resource type="Script" path="res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd" id="1"]
+
+[node name="PieMenuGroup" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1")
diff --git a/addons/imjp94.yafsm/README.md b/addons/imjp94.yafsm/README.md
new file mode 100644
index 00000000..f1b6db05
--- /dev/null
+++ b/addons/imjp94.yafsm/README.md
@@ -0,0 +1,81 @@
+# Documentation
+
+## Classes
+
+All of the class are located in `res://addons/imjp94.yafsm/src` but you can just preload `res://addons/imjp94.yafsm/YAFSM.gd` to import all class available:
+
+```gdscript
+const YAFSM = preload("res://addons/imjp94.yafsm/YAFSM.gd")
+const StackPlayer = YAFSM.StackPlayer
+const StateMachinePlayer = YAFSM.StateMachinePlayer
+const StateMachine = YAFSM.StateMachine
+const State = YAFSM.State
+```
+
+### Node
+
+- [StackPlayer](src/StackPlayer.gd) 
+ > Manage stack of item, use push/pop function to set current item on top of stack
+ - `current # Current item on top of stack`
+ - `stack`
+ - signals:
+ - `pushed(to) # When item pushed to stack`
+ - `popped(from) # When item popped from stack`
+- [StateMachinePlayer](src/StateMachinePlayer.gd)(extends StackPlayer) 
+ > Manage state based on `StateMachine` and parameters inputted
+ - `state_machine # StateMachine being played`
+ - `active # Activeness of player`
+ - `autostart # Automatically enter Entry state on ready if true`
+ - `process_mode # ProcessMode of player`
+ - signals:
+ - `transited(from, to) # Transition of state`
+ - `entered(to) # Entry of state machine(including nested), empty string equals to root`
+ - `exited(from) # Exit of state machine(including nested, empty string equals to root`
+ - `updated(state, delta) # Time to update(based on process_mode), up to user to handle any logic, for example, update movement of KinematicBody`
+
+### Control
+
+- [StackPlayerDebugger](src/debugger/StackPlayerDebugger.gd)
+ > Visualize stack of parent StackPlayer on screen
+
+### Reference
+
+- [StateDirectory](src/StateDirectory.gd)
+ > Convert state path to directory object for traversal, mainly used for nested state
+
+### Resource
+
+Relationship between all `Resource`s can be best represented as below:
+
+```gdscript
+var state_machine = state_machine_player.state_machine
+var state = state_machine.states[state_name] # keyed by state name
+var transition = state_machine.transitions[from][to] # keyed by state name transition from/to
+var condition = transition.conditions[condition_name] # keyed by condition name
+```
+
+> For normal usage, you really don't have to access any `Resource` during runtime as they only store static data that describe the state machine, accessing `StackPlayer`/`StateMachinePlayer` alone should be sufficient.
+
+- [State](src/states/State.gd)
+ > Resource that represent a state
+ - `name`
+- [StateMachine](src/states/StateMachine.gd)(`extends State`) 
+ > `StateMachine` is also a `State`, but mainly used as container of `State`s and `Transitions`s
+ - `states`
+ - `transitions`
+- [Transition](src/transitions/Transition.gd)
+ > Describing connection from one state to another, all conditions must be fulfilled to transit to next state
+ - `from`
+ - `to`
+ - `conditions`
+- [Condition](src/conditions/Condition.gd)
+ > Empty condition with just a name, treated as trigger
+ - `name`
+- [ValueCondition](src/conditions/ValueCondition.gd)(`extends Condition`)
+ > Condition with value, fulfilled by comparing values based on comparation
+ - `comparation`
+ - `value`
+- [BooleanCondition](src/conditions/BooleanCondition.gd)(`extends ValueCondition`)
+- [IntegerCondition](src/conditions/IntegerCondition.gd)(`extends ValueCondition`)
+- [FloatCondition](src/conditions/FloatCondition.gd)(`extends ValueCondition`)
+- [StringCondition](src/conditions/StringCondition.gd)(`extends ValueCondition`)
diff --git a/addons/imjp94.yafsm/YAFSM.gd b/addons/imjp94.yafsm/YAFSM.gd
new file mode 100644
index 00000000..0f6f881f
--- /dev/null
+++ b/addons/imjp94.yafsm/YAFSM.gd
@@ -0,0 +1,20 @@
+# Node
+const StackPlayer = preload("src/StackPlayer.gd")
+const StateMachinePlayer = preload("src/StateMachinePlayer.gd")
+
+# Reference
+const StateDirectory = preload("src/StateDirectory.gd")
+
+# Resources
+# States
+const State = preload("src/states/State.gd")
+const StateMachine = preload("src/states/StateMachine.gd")
+# Transitions
+const Transition = preload("src/transitions/Transition.gd")
+# Conditions
+const Condition = preload("src/conditions/Condition.gd")
+const ValueCondition = preload("src/conditions/ValueCondition.gd")
+const BooleanCondition = preload("src/conditions/BooleanCondition.gd")
+const IntegerCondition = preload("src/conditions/IntegerCondition.gd")
+const FloatCondition = preload("src/conditions/FloatCondition.gd")
+const StringCondition = preload("src/conditions/StringCondition.gd")
diff --git a/addons/imjp94.yafsm/assets/fonts/sans_serif.tres b/addons/imjp94.yafsm/assets/fonts/sans_serif.tres
new file mode 100644
index 00000000..bc16b705
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/fonts/sans_serif.tres
@@ -0,0 +1,5 @@
+[gd_resource type="SystemFont" format=3 uid="uid://dmcxm8gxsonbq"]
+
+[resource]
+font_names = PackedStringArray("Sans-Serif")
+multichannel_signed_distance_field = true
diff --git a/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg
new file mode 100644
index 00000000..6d8d74cf
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg.import
new file mode 100644
index 00000000..5577dc69
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/add-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dg8cmn5ubq6r5"
+path="res://.godot/imported/add-white-18dp.svg-06b50d941748dbfd6e0203dec68494ea.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg"
+dest_files=["res://.godot/imported/add-white-18dp.svg-06b50d941748dbfd6e0203dec68494ea.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=false
+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/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg
new file mode 100644
index 00000000..4b45194b
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg.import
new file mode 100644
index 00000000..840595e3
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://yw43hcwiudst"
+path="res://.godot/imported/arrow_right-white-18dp.svg-10d349447e9bd513637eade1f10225f0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg"
+dest_files=["res://.godot/imported/arrow_right-white-18dp.svg-10d349447e9bd513637eade1f10225f0.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=false
+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=4.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg
new file mode 100644
index 00000000..0ffae97a
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg.import
new file mode 100644
index 00000000..b847102a
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/close-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://l78bjwo7shm"
+path="res://.godot/imported/close-white-18dp.svg-3d0e2341eb99a6dc45a6aecef969301b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg"
+dest_files=["res://.godot/imported/close-white-18dp.svg-3d0e2341eb99a6dc45a6aecef969301b.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=false
+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/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg
new file mode 100644
index 00000000..d957b351
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg.import
new file mode 100644
index 00000000..e00525ac
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cnkaa2ky1f4jq"
+path="res://.godot/imported/compare_arrows-white-18dp.svg-7313ec3b54f05c948521b16e0efaaeed.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg"
+dest_files=["res://.godot/imported/compare_arrows-white-18dp.svg-7313ec3b54f05c948521b16e0efaaeed.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=false
+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/imjp94.yafsm/assets/icons/remove-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg
new file mode 100644
index 00000000..ca58eadc
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg.import
new file mode 100644
index 00000000..ec3c8b33
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://p2md5n42lcqj"
+path="res://.godot/imported/remove-white-18dp.svg-984af3406d3d64ea0f778da7f0f5a4c3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg"
+dest_files=["res://.godot/imported/remove-white-18dp.svg-984af3406d3d64ea0f778da7f0f5a4c3.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=false
+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/imjp94.yafsm/assets/icons/stack_player_icon.png b/addons/imjp94.yafsm/assets/icons/stack_player_icon.png
new file mode 100644
index 00000000..f60ad059
Binary files /dev/null and b/addons/imjp94.yafsm/assets/icons/stack_player_icon.png differ
diff --git a/addons/imjp94.yafsm/assets/icons/stack_player_icon.png.import b/addons/imjp94.yafsm/assets/icons/stack_player_icon.png.import
new file mode 100644
index 00000000..632585d0
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/stack_player_icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bcc8ni3mjf55j"
+path="res://.godot/imported/stack_player_icon.png-bf093c6193b73dc7a03c728b884edd0b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/stack_player_icon.png"
+dest_files=["res://.godot/imported/stack_player_icon.png-bf093c6193b73dc7a03c728b884edd0b.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=false
+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/addons/imjp94.yafsm/assets/icons/state_machine_icon.png b/addons/imjp94.yafsm/assets/icons/state_machine_icon.png
new file mode 100644
index 00000000..e6402c48
Binary files /dev/null and b/addons/imjp94.yafsm/assets/icons/state_machine_icon.png differ
diff --git a/addons/imjp94.yafsm/assets/icons/state_machine_icon.png.import b/addons/imjp94.yafsm/assets/icons/state_machine_icon.png.import
new file mode 100644
index 00000000..914451db
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/state_machine_icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://quofx2t3tj1b"
+path="res://.godot/imported/state_machine_icon.png-9917b22df6299aea6994b92cacbcef16.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/state_machine_icon.png"
+dest_files=["res://.godot/imported/state_machine_icon.png-9917b22df6299aea6994b92cacbcef16.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=false
+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/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png b/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png
new file mode 100644
index 00000000..a7eb6dfb
Binary files /dev/null and b/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png differ
diff --git a/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png.import b/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png.import
new file mode 100644
index 00000000..d16978a5
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://crcg0exl13kdd"
+path="res://.godot/imported/state_machine_player_icon.png-12d6c36cda302327e8c107292c578aa4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png"
+dest_files=["res://.godot/imported/state_machine_player_icon.png-12d6c36cda302327e8c107292c578aa4.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=false
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg b/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg
new file mode 100644
index 00000000..b73eec90
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg.import b/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg.import
new file mode 100644
index 00000000..322fb8b4
--- /dev/null
+++ b/addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b2coah58shtq1"
+path="res://.godot/imported/subdirectory_arrow_right-white-18dp.svg-09b2961410e6b2c0e48e0cf1138c3548.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg"
+dest_files=["res://.godot/imported/subdirectory_arrow_right-white-18dp.svg-09b2961410e6b2c0e48e0cf1138c3548.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=false
+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/imjp94.yafsm/plugin.cfg b/addons/imjp94.yafsm/plugin.cfg
new file mode 100644
index 00000000..6c0b0f2d
--- /dev/null
+++ b/addons/imjp94.yafsm/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="gd-YAFSM"
+description="Yet Another Finite State Machine"
+author="imjp94"
+version="0.6.2"
+script="plugin.gd"
diff --git a/addons/imjp94.yafsm/plugin.gd b/addons/imjp94.yafsm/plugin.gd
new file mode 100644
index 00000000..cb5e5110
--- /dev/null
+++ b/addons/imjp94.yafsm/plugin.gd
@@ -0,0 +1,151 @@
+@tool
+extends EditorPlugin
+
+const StateMachineEditor = preload("scenes/StateMachineEditor.tscn")
+const TransitionInspector = preload("scenes/transition_editors/TransitionInspector.gd")
+const StateInspector = preload("scenes/state_nodes/StateInspector.gd")
+
+const StackPlayerIcon = preload("assets/icons/stack_player_icon.png")
+const StateMachinePlayerIcon = preload("assets/icons/state_machine_player_icon.png")
+
+var state_machine_editor = StateMachineEditor.instantiate()
+var transition_inspector = TransitionInspector.new()
+var state_inspector = StateInspector.new()
+
+var focused_object: # Can be StateMachine/StateMachinePlayer
+ set = set_focused_object
+var editor_selection
+
+var _handled_and_ready_to_edit = false # forces _handles => _edit flow
+
+func _enter_tree():
+ editor_selection = get_editor_interface().get_selection()
+ editor_selection.selection_changed.connect(_on_EditorSelection_selection_changed)
+ var editor_base_control = get_editor_interface().get_base_control()
+ add_custom_type("StackPlayer", "Node", StackPlayer, StackPlayerIcon)
+ add_custom_type("StateMachinePlayer", "Node", StateMachinePlayer, StateMachinePlayerIcon)
+
+ state_machine_editor.undo_redo = get_undo_redo()
+ state_machine_editor.selection_stylebox.bg_color = editor_base_control.get_theme_color("box_selection_fill_color", "Editor")
+ state_machine_editor.selection_stylebox.border_color = editor_base_control.get_theme_color("box_selection_stroke_color", "Editor")
+ state_machine_editor.zoom_minus.icon = editor_base_control.get_theme_icon("ZoomLess", "EditorIcons")
+ state_machine_editor.zoom_reset.icon = editor_base_control.get_theme_icon("ZoomReset", "EditorIcons")
+ state_machine_editor.zoom_plus.icon = editor_base_control.get_theme_icon("ZoomMore", "EditorIcons")
+ state_machine_editor.snap_button.icon = editor_base_control.get_theme_icon("SnapGrid", "EditorIcons")
+ state_machine_editor.condition_visibility.texture_pressed = editor_base_control.get_theme_icon("GuiVisibilityVisible", "EditorIcons")
+ state_machine_editor.condition_visibility.texture_normal = editor_base_control.get_theme_icon("GuiVisibilityHidden", "EditorIcons")
+ state_machine_editor.editor_accent_color = editor_base_control.get_theme_color("accent_color", "Editor")
+ state_machine_editor.current_layer.editor_accent_color = state_machine_editor.editor_accent_color
+ state_machine_editor.transition_arrow_icon = editor_base_control.get_theme_icon("TransitionImmediateBig", "EditorIcons")
+ state_machine_editor.inspector_changed.connect(_on_inspector_changed)
+ state_machine_editor.node_selected.connect(_on_StateMachineEditor_node_selected)
+ state_machine_editor.node_deselected.connect(_on_StateMachineEditor_node_deselected)
+ state_machine_editor.debug_mode_changed.connect(_on_StateMachineEditor_debug_mode_changed)
+ # Force anti-alias for default font, so rotated text will looks smoother
+ var font = editor_base_control.get_theme_font("main", "EditorFonts")
+ # font.use_filter = true
+
+ transition_inspector.undo_redo = get_undo_redo()
+ transition_inspector.transition_icon = editor_base_control.get_theme_icon("ToolConnect", "EditorIcons")
+ add_inspector_plugin(transition_inspector)
+ add_inspector_plugin(state_inspector)
+
+func _exit_tree():
+ remove_custom_type("StackPlayer")
+ remove_custom_type("StateMachinePlayer")
+
+ remove_inspector_plugin(transition_inspector)
+ remove_inspector_plugin(state_inspector)
+
+ if state_machine_editor:
+ hide_state_machine_editor()
+ state_machine_editor.queue_free()
+
+func _handles(object):
+ if object is StateMachine:
+ _handled_and_ready_to_edit = true # this should not be necessary, but it seemingly is (Godot 4.0-rc1)
+ return true # when return true from _handles, _edit can proceed.
+ if object is StateMachinePlayer:
+ if object.get_class() == "EditorDebuggerRemoteObject":
+ set_focused_object(object)
+ state_machine_editor.debug_mode = true
+ return false
+ return false
+
+func _edit(object):
+ if _handled_and_ready_to_edit: # Forces _handles => _edit flow. This should not be necessary, but it seemingly is (Godot 4.0-rc1)
+ _handled_and_ready_to_edit = false
+ set_focused_object(object)
+
+func show_state_machine_editor():
+ if focused_object and state_machine_editor:
+ if not state_machine_editor.is_inside_tree():
+ add_control_to_bottom_panel(state_machine_editor, "StateMachine")
+ make_bottom_panel_item_visible(state_machine_editor)
+
+func hide_state_machine_editor():
+ if state_machine_editor.is_inside_tree():
+ state_machine_editor.state_machine = null
+ remove_control_from_bottom_panel(state_machine_editor)
+
+func _on_EditorSelection_selection_changed():
+ if editor_selection == null:
+ return
+
+ var selected_nodes = editor_selection.get_selected_nodes()
+ if selected_nodes.size() == 1:
+ var selected_node = selected_nodes[0]
+ if selected_node is StateMachinePlayer:
+ set_focused_object(selected_node)
+ return
+ set_focused_object(null)
+
+func _on_focused_object_changed(new_obj):
+ if new_obj:
+ # Must be shown first, otherwise StateMachineEditor can't execute ui action as it is not added to scene tree
+ show_state_machine_editor()
+ var state_machine
+ if focused_object is StateMachinePlayer:
+ if focused_object.get_class() == "EditorDebuggerRemoteObject":
+ state_machine = focused_object.get("Members/state_machine")
+ if state_machine == null:
+ state_machine = focused_object.get("Members/StateMachinePlayer.gd/state_machine")
+ else:
+ state_machine = focused_object.state_machine
+ state_machine_editor.state_machine_player = focused_object
+ elif focused_object is StateMachine:
+ state_machine = focused_object
+ state_machine_editor.state_machine_player = null
+ state_machine_editor.state_machine = state_machine
+ else:
+ hide_state_machine_editor()
+
+func _on_inspector_changed(property):
+ #get_editor_interface().get_inspector().refresh()
+ notify_property_list_changed()
+
+func _on_StateMachineEditor_node_selected(node):
+ var to_inspect
+ if "state" in node:
+ if node.state is StateMachine: # Ignore, inspect state machine will trigger edit()
+ return
+ to_inspect = node.state
+ elif "transition" in node:
+ to_inspect = node.transition
+ get_editor_interface().inspect_object(to_inspect)
+
+func _on_StateMachineEditor_node_deselected(node):
+ # editor_selection.remove_node(node)
+ get_editor_interface().inspect_object(state_machine_editor.state_machine)
+
+func _on_StateMachineEditor_debug_mode_changed(new_debug_mode):
+ if not new_debug_mode:
+ state_machine_editor.debug_mode = false
+ state_machine_editor.state_machine_player = null
+ set_focused_object(null)
+ hide_state_machine_editor()
+
+func set_focused_object(obj):
+ if focused_object != obj:
+ focused_object = obj
+ _on_focused_object_changed(obj)
diff --git a/addons/imjp94.yafsm/scenes/ContextMenu.tscn b/addons/imjp94.yafsm/scenes/ContextMenu.tscn
new file mode 100644
index 00000000..802bdbbb
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/ContextMenu.tscn
@@ -0,0 +1,12 @@
+[gd_scene format=3 uid="uid://cflltb00e10be"]
+
+[node name="ContextMenu" type="PopupMenu"]
+size = Vector2i(104, 100)
+visible = true
+item_count = 3
+item_0/text = "Add State"
+item_0/id = 0
+item_1/text = "Add Entry"
+item_1/id = 1
+item_2/text = "Add Exit"
+item_2/id = 2
diff --git a/addons/imjp94.yafsm/scenes/ParametersPanel.gd b/addons/imjp94.yafsm/scenes/ParametersPanel.gd
new file mode 100644
index 00000000..290c0ef0
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/ParametersPanel.gd
@@ -0,0 +1,63 @@
+@tool
+extends MarginContainer
+
+
+@onready var grid = $PanelContainer/MarginContainer/VBoxContainer/GridContainer
+@onready var button = $PanelContainer/MarginContainer/VBoxContainer/MarginContainer/Button
+
+
+func _ready():
+ button.pressed.connect(_on_button_pressed)
+
+func update_params(params, local_params):
+ # Remove erased parameters from param panel
+ for param in grid.get_children():
+ if not (param.name in params):
+ remove_param(param.name)
+ for param in params:
+ var value = params[param]
+ if value == null: # Ignore trigger
+ continue
+ set_param(param, str(value))
+
+ # Remove erased local parameters from param panel
+ for param in grid.get_children():
+ if not (param.name in local_params) and not (param.name in params):
+ remove_param(param.name)
+ for param in local_params:
+ var nested_params = local_params[param]
+ for nested_param in nested_params:
+ var value = nested_params[nested_param]
+ if value == null: # Ignore trigger
+ continue
+ set_param(str(param, "/", nested_param), str(value))
+
+func set_param(param, value):
+ var label = grid.get_node_or_null(NodePath(param))
+ if not label:
+ label = Label.new()
+ label.name = param
+ grid.add_child(label)
+
+ label.text = "%s = %s" % [param, value]
+
+func remove_param(param):
+ var label = grid.get_node_or_null(NodePath(param))
+ if label:
+ grid.remove_child(label)
+ label.queue_free()
+ set_anchors_preset(PRESET_BOTTOM_RIGHT)
+
+func clear_params():
+ for child in grid.get_children():
+ grid.remove_child(child)
+ child.queue_free()
+
+func _on_button_pressed():
+ grid.visible = !grid.visible
+ if grid.visible:
+ button.text = "Hide params"
+ else:
+ button.text = "Show params"
+
+ set_anchors_preset(PRESET_BOTTOM_RIGHT)
diff --git a/addons/imjp94.yafsm/scenes/PathViewer.gd b/addons/imjp94.yafsm/scenes/PathViewer.gd
new file mode 100644
index 00000000..39324cb7
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/PathViewer.gd
@@ -0,0 +1,64 @@
+@tool
+extends HBoxContainer
+
+signal dir_pressed(dir, index)
+
+
+func _init():
+ add_dir("root")
+
+# Select parent dir & return its path
+func back():
+ return select_dir(get_child(max(get_child_count()-1 - 1, 0)).name)
+
+# Select dir & return its path
+func select_dir(dir):
+ for i in get_child_count():
+ var child = get_child(i)
+ if child.name == dir:
+ remove_dir_until(i)
+ return get_dir_until(i)
+
+# Add directory button
+func add_dir(dir):
+ var button = Button.new()
+ button.name = dir
+ button.flat = true
+ button.text = dir
+ add_child(button)
+ button.pressed.connect(_on_button_pressed.bind(button))
+ return button
+
+# Remove directory until index(exclusive)
+func remove_dir_until(index):
+ var to_remove = []
+ for i in get_child_count():
+ if index == get_child_count()-1 - i:
+ break
+ var child = get_child(get_child_count()-1 - i)
+ to_remove.append(child)
+ for n in to_remove:
+ remove_child(n)
+ n.queue_free()
+
+# Return current working directory
+func get_cwd():
+ return get_dir_until(get_child_count()-1)
+
+# Return path until index(inclusive) of directory
+func get_dir_until(index):
+ var path = ""
+ for i in get_child_count():
+ if i > index:
+ break
+ var child = get_child(i)
+ if i == 0:
+ path = "root"
+ else:
+ path = str(path, "/", child.text)
+ return path
+
+func _on_button_pressed(button):
+ var index = button.get_index()
+ var dir = button.name
+ emit_signal("dir_pressed", dir, index)
diff --git a/addons/imjp94.yafsm/scenes/StateMachineEditor.gd b/addons/imjp94.yafsm/scenes/StateMachineEditor.gd
new file mode 100644
index 00000000..a7f50a97
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/StateMachineEditor.gd
@@ -0,0 +1,761 @@
+@tool
+extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd"
+
+const StateMachine = preload("../src/states/StateMachine.gd")
+const Transition = preload("../src/transitions/Transition.gd")
+const State = preload("../src/states/State.gd")
+const StateDirectory = preload("../src/StateDirectory.gd")
+const StateNode = preload("state_nodes/StateNode.tscn")
+const TransitionLine = preload("transition_editors/TransitionLine.tscn")
+const StateNodeScript = preload("state_nodes/StateNode.gd")
+const StateMachineEditorLayer = preload("StateMachineEditorLayer.gd")
+const PathViewer = preload("PathViewer.gd")
+
+signal inspector_changed(property) # Inform plugin to refresh inspector
+signal debug_mode_changed(new_debug_mode)
+
+const ENTRY_STATE_MISSING_MSG = {
+ "key": "entry_state_missing",
+ "text": "Entry State missing, it will never get started. Right-click -> \"Add Entry\"."
+}
+const EXIT_STATE_MISSING_MSG = {
+ "key": "exit_state_missing",
+ "text": "Exit State missing, it will never exit from nested state. Right-click -> \"Add Exit\"."
+}
+const DEBUG_MODE_MSG = {
+ "key": "debug_mode",
+ "text": "Debug Mode"
+}
+
+@onready var context_menu = $ContextMenu
+@onready var state_node_context_menu = $StateNodeContextMenu
+@onready var convert_to_state_confirmation = $ConvertToStateConfirmation
+@onready var save_dialog = $SaveDialog
+@onready var create_new_state_machine_container = $MarginContainer
+@onready var create_new_state_machine = $MarginContainer/CreateNewStateMachine
+@onready var param_panel = $ParametersPanel
+var path_viewer = HBoxContainer.new()
+var condition_visibility = TextureButton.new()
+var unsaved_indicator = Label.new()
+var message_box = VBoxContainer.new()
+
+var editor_accent_color = Color.WHITE
+var transition_arrow_icon
+
+var undo_redo: EditorUndoRedoManager
+
+var debug_mode: = false:
+ set = set_debug_mode
+var state_machine_player:
+ set = set_state_machine_player
+var state_machine:
+ set = set_state_machine
+var can_gui_name_edit = true
+var can_gui_context_menu = true
+
+var _reconnecting_connection
+var _last_index = 0
+var _last_path = ""
+var _message_box_dict = {}
+var _context_node
+var _current_state = ""
+var _last_stack = []
+
+
+func _init():
+ super._init()
+
+ path_viewer.mouse_filter = MOUSE_FILTER_IGNORE
+ path_viewer.set_script(PathViewer)
+ path_viewer.dir_pressed.connect(_on_path_viewer_dir_pressed)
+ top_bar.add_child(path_viewer)
+
+ condition_visibility.tooltip_text = "Hide/Show Conditions on Transition Line"
+ condition_visibility.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
+ condition_visibility.toggle_mode = true
+ condition_visibility.size_flags_vertical = SIZE_SHRINK_CENTER
+ condition_visibility.focus_mode = FOCUS_NONE
+ condition_visibility.pressed.connect(_on_condition_visibility_pressed)
+ condition_visibility.button_pressed = true
+ gadget.add_child(condition_visibility)
+
+ unsaved_indicator.size_flags_vertical = SIZE_SHRINK_CENTER
+ unsaved_indicator.focus_mode = FOCUS_NONE
+ gadget.add_child(unsaved_indicator)
+
+ message_box.set_anchors_and_offsets_preset(PRESET_BOTTOM_WIDE)
+ message_box.grow_vertical = GROW_DIRECTION_BEGIN
+ add_child(message_box)
+
+ content.get_child(0).name = "root"
+
+ set_process(false)
+
+func _ready():
+ create_new_state_machine_container.visible = false
+ create_new_state_machine.pressed.connect(_on_create_new_state_machine_pressed)
+ context_menu.index_pressed.connect(_on_context_menu_index_pressed)
+ state_node_context_menu.index_pressed.connect(_on_state_node_context_menu_index_pressed)
+ convert_to_state_confirmation.confirmed.connect(_on_convert_to_state_confirmation_confirmed)
+ save_dialog.confirmed.connect(_on_save_dialog_confirmed)
+
+func _process(delta):
+ if not debug_mode:
+ set_process(false)
+ return
+ if not is_instance_valid(state_machine_player):
+ set_process(false)
+ set_debug_mode(false)
+ return
+ var stack = state_machine_player.get("Members/StackPlayer.gd/stack")
+ if ((stack == []) or (stack==null)):
+ set_process(false)
+ set_debug_mode(false)
+ return
+
+ if stack.size() == 1:
+ set_current_state(state_machine_player.get("Members/StackPlayer.gd/current"))
+ else:
+ var stack_max_index = stack.size() - 1
+ var prev_index = stack.find(_current_state)
+ if prev_index == -1:
+ if _last_stack.size() < stack.size():
+ # Reproduce transition, for example:
+ # [Entry, Idle, Walk]
+ # [Entry, Idle, Jump, Fall]
+ # Walk -> Idle
+ # Idle -> Jump
+ # Jump -> Fall
+ var common_index = -1
+ for i in _last_stack.size():
+ if _last_stack[i] == stack[i]:
+ common_index = i
+ break
+ if common_index > -1:
+ var count_from_last_stack = _last_stack.size()-1 - common_index -1
+ _last_stack.reverse()
+ # Transit back to common state
+ for i in count_from_last_stack:
+ set_current_state(_last_stack[i + 1])
+ # Transit to all missing state in current stack
+ for i in range(common_index + 1, stack.size()):
+ set_current_state(stack[i])
+ else:
+ set_current_state(stack.back())
+ else:
+ set_current_state(stack.back())
+ else:
+ # Set every skipped state
+ var missing_count = stack_max_index - prev_index
+ for i in range(1, missing_count + 1):
+ set_current_state(stack[prev_index + i])
+ _last_stack = stack
+ var params = state_machine_player.get("Members/_parameters")
+ var local_params = state_machine_player.get("Members/_local_parameters")
+ if params == null:
+ params = state_machine_player.get("Members/StateMachinePlayer.gd/_parameters")
+ local_params = state_machine_player.get("Members/StateMachinePlayer.gd/_local_parameters")
+ param_panel.update_params(params, local_params)
+ get_focused_layer(_current_state).debug_update(_current_state, params, local_params)
+
+func _on_path_viewer_dir_pressed(dir, index):
+ var path = path_viewer.select_dir(dir)
+ select_layer(get_layer(path))
+
+ if _last_index > index:
+ # Going backward
+ var end_state_parent_path = StateMachinePlayer.path_backward(_last_path)
+ var end_state_name = StateMachinePlayer.path_end_dir(_last_path)
+ var layer = content.get_node_or_null(NodePath(end_state_parent_path))
+ if layer:
+ var node = layer.content_nodes.get_node_or_null(NodePath(end_state_name))
+ if node:
+ var cond_1 = (not ("states" in node.state)) or (node.state.states=={}) # states property not defined or empty
+ # Now check if, for some reason, there are an Entry and/or an Exit node inside this node
+ # not registered in the states variable above.
+ var nested_layer = content.get_node_or_null(NodePath(_last_path))
+ var cond_2 = (nested_layer.content_nodes.get_node_or_null(NodePath(State.ENTRY_STATE)) == null) # there is no entry state in the node
+ var cond_3 = (nested_layer.content_nodes.get_node_or_null(NodePath(State.EXIT_STATE)) == null) # there is no exit state in the node
+ if (cond_1 and cond_2 and cond_3):
+ # Convert state machine node back to state node
+ convert_to_state(layer, node)
+
+ _last_index = index
+ _last_path = path
+
+func _on_context_menu_index_pressed(index):
+ var new_node = StateNode.instantiate()
+ new_node.theme.get_stylebox("focus", "FlowChartNode").border_color = editor_accent_color
+ match index:
+ 0: # Add State
+ ## Handle state name duplication (4.x changed how duplicates are
+ ## automatically handled and gave a random index instead of
+ ## a progressive one)
+ var default_new_state_name = "State"
+ var state_dup_index = 0
+ var new_name = default_new_state_name
+ for state_name in current_layer.state_machine.states:
+ if (state_name == new_name):
+ state_dup_index += 1
+ new_name = "%s%s" % [default_new_state_name, state_dup_index]
+ new_node.name = new_name
+ 1: # Add Entry
+ if State.ENTRY_STATE in current_layer.state_machine.states:
+ push_warning("Entry node already exist")
+ return
+ new_node.name = State.ENTRY_STATE
+ 2: # Add Exit
+ if State.EXIT_STATE in current_layer.state_machine.states:
+ push_warning("Exit node already exist")
+ return
+ new_node.name = State.EXIT_STATE
+ new_node.position = content_position(get_local_mouse_position())
+ add_node(current_layer, new_node)
+
+func _on_state_node_context_menu_index_pressed(index):
+ if not _context_node:
+ return
+
+ match index:
+ 0: # Copy
+ _copying_nodes = [_context_node]
+ _context_node = null
+ 1: # Duplicate
+ duplicate_nodes(current_layer, [_context_node])
+ _context_node = null
+ 2: # Delete
+ remove_node(current_layer, _context_node.name)
+ for connection_pair in current_layer.get_connection_list():
+ if connection_pair.from == _context_node.name or connection_pair.to == _context_node.name:
+ disconnect_node(current_layer, connection_pair.from, connection_pair.to).queue_free()
+ _context_node = null
+ 3: # Separator
+ _context_node = null
+ 4: # Convert
+ convert_to_state_confirmation.popup_centered()
+
+func _on_convert_to_state_confirmation_confirmed():
+ convert_to_state(current_layer, _context_node)
+ _context_node.queue_redraw() # Update outlook of node
+ # Remove layer
+ var path = str(path_viewer.get_cwd(), "/", _context_node.name)
+ var layer = get_layer(path)
+ if layer:
+ layer.queue_free()
+ _context_node = null
+
+func _on_save_dialog_confirmed():
+ save()
+
+func _on_create_new_state_machine_pressed():
+ var new_state_machine = StateMachine.new()
+
+ undo_redo.create_action("Create New StateMachine")
+
+ undo_redo.add_do_reference(new_state_machine)
+ undo_redo.add_do_property(state_machine_player, "state_machine", new_state_machine)
+ undo_redo.add_do_method(self, "set_state_machine", new_state_machine)
+ undo_redo.add_do_property(create_new_state_machine_container, "visible", false)
+ undo_redo.add_do_method(self, "check_has_entry")
+ undo_redo.add_do_method(self, "emit_signal", "inspector_changed", "state_machine")
+
+ undo_redo.add_undo_property(state_machine_player, "state_machine", null)
+ undo_redo.add_undo_method(self, "set_state_machine", null)
+ undo_redo.add_undo_property(create_new_state_machine_container, "visible", true)
+ undo_redo.add_undo_method(self, "check_has_entry")
+ undo_redo.add_undo_method(self, "emit_signal", "inspector_changed", "state_machine")
+
+ undo_redo.commit_action()
+
+func _on_condition_visibility_pressed():
+ for line in current_layer.content_lines.get_children():
+ line.vbox.visible = condition_visibility.button_pressed
+
+func _on_debug_mode_changed(new_debug_mode):
+ if new_debug_mode:
+ param_panel.show()
+ add_message(DEBUG_MODE_MSG.key, DEBUG_MODE_MSG.text)
+ set_process(true)
+ # mouse_filter = MOUSE_FILTER_IGNORE
+ can_gui_select_node = false
+ can_gui_delete_node = false
+ can_gui_connect_node = false
+ can_gui_name_edit = false
+ can_gui_context_menu = false
+ else:
+ param_panel.clear_params()
+ param_panel.hide()
+ remove_message(DEBUG_MODE_MSG.key)
+ set_process(false)
+ can_gui_select_node = true
+ can_gui_delete_node = true
+ can_gui_connect_node = true
+ can_gui_name_edit = true
+ can_gui_context_menu = true
+
+func _on_state_machine_player_changed(new_state_machine_player):
+ if not state_machine_player:
+ return
+ if new_state_machine_player.get_class() == "EditorDebuggerRemoteObject":
+ return
+
+ if new_state_machine_player:
+ create_new_state_machine_container.visible = !new_state_machine_player.state_machine
+ else:
+ create_new_state_machine_container.visible = false
+
+func _on_state_machine_changed(new_state_machine):
+ var root_layer = get_layer("root")
+ path_viewer.select_dir("root") # Before select_layer, so path_viewer will be updated in _on_layer_selected
+ select_layer(root_layer)
+ clear_graph(root_layer)
+ # Reset layers & path viewer
+ for child in root_layer.get_children():
+ if child is FlowChartLayer:
+ root_layer.remove_child(child)
+ child.queue_free()
+ if new_state_machine:
+ root_layer.state_machine = state_machine
+ var validated = StateMachine.validate(new_state_machine)
+ if validated:
+ print_debug("gd-YAFSM: Corrupted StateMachine Resource fixed, save to apply the fix.")
+ draw_graph(root_layer)
+ check_has_entry()
+
+func _gui_input(event):
+ super._gui_input(event)
+
+ if event is InputEventMouseButton:
+ match event.button_index:
+ MOUSE_BUTTON_RIGHT:
+ if event.pressed and can_gui_context_menu:
+ context_menu.set_item_disabled(1, current_layer.state_machine.has_entry())
+ context_menu.set_item_disabled(2, current_layer.state_machine.has_exit())
+ context_menu.position = get_window().position + Vector2i(get_viewport().get_mouse_position())
+ context_menu.popup()
+
+func _input(event):
+ # Intercept save action
+ if visible:
+ if event is InputEventKey:
+ match event.keycode:
+ KEY_S:
+ if event.ctrl_pressed and event.pressed:
+ save_request()
+
+func create_layer(node):
+ # Create/Move to new layer
+ var new_state_machine = convert_to_state_machine(current_layer, node)
+ # Determine current layer path
+ var parent_path = path_viewer.get_cwd()
+ var path = str(parent_path, "/", node.name)
+ var layer = get_layer(path)
+ path_viewer.add_dir(node.state.name) # Before select_layer, so path_viewer will be updated in _on_layer_selected
+ if not layer:
+ # New layer to spawn
+ layer = add_layer_to(get_layer(parent_path))
+ layer.name = node.state.name
+ layer.state_machine = new_state_machine
+ draw_graph(layer)
+ _last_index = path_viewer.get_child_count()-1
+ _last_path = path
+ return layer
+
+func open_layer(path):
+ var dir = StateDirectory.new(path)
+ dir.goto(dir.get_end_index())
+ dir.back()
+ var next_layer = get_next_layer(dir, get_layer("root"))
+ select_layer(next_layer)
+ return next_layer
+
+# Recursively get next layer
+func get_next_layer(dir, base_layer):
+ var next_layer = base_layer
+ var np = dir.next()
+ if np:
+ next_layer = base_layer.get_node_or_null(NodePath(np))
+ if next_layer:
+ next_layer = get_next_layer(dir, next_layer)
+ else:
+ var to_dir = StateDirectory.new(dir.get_current())
+ to_dir.goto(to_dir.get_end_index())
+ to_dir.back()
+ var node = base_layer.content_nodes.get_node_or_null(NodePath(to_dir.get_current_end()))
+ next_layer = get_next_layer(dir, create_layer(node))
+ return next_layer
+
+func get_focused_layer(state):
+ var current_dir = StateDirectory.new(state)
+ current_dir.goto(current_dir.get_end_index())
+ current_dir.back()
+ return get_layer(str("root/", current_dir.get_current()))
+
+func _on_state_node_gui_input(event, node):
+ if node.state.is_entry() or node.state.is_exit():
+ return
+
+ if event is InputEventMouseButton:
+ match event.button_index:
+ MOUSE_BUTTON_LEFT:
+ if event.pressed:
+ if event.double_click:
+ if node.name_edit.get_rect().has_point(event.position) and can_gui_name_edit:
+ # Edit State name if within LineEdit
+ node.enable_name_edit(true)
+ accept_event()
+ else:
+ var layer = create_layer(node)
+ select_layer(layer)
+ accept_event()
+ MOUSE_BUTTON_RIGHT:
+ if event.pressed:
+ # State node context menu
+ _context_node = node
+ state_node_context_menu.position = get_window().position + Vector2i(get_viewport().get_mouse_position())
+ state_node_context_menu.popup()
+ state_node_context_menu.set_item_disabled(4, not (node.state is StateMachine))
+ accept_event()
+
+func convert_to_state_machine(layer, node):
+ # Convert State to StateMachine
+ var new_state_machine
+ if node.state is StateMachine:
+ new_state_machine = node.state
+ else:
+ new_state_machine = StateMachine.new()
+ new_state_machine.name = node.state.name
+ new_state_machine.graph_offset = node.state.graph_offset
+ layer.state_machine.remove_state(node.state.name)
+ layer.state_machine.add_state(new_state_machine)
+ node.state = new_state_machine
+ return new_state_machine
+
+func convert_to_state(layer, node):
+ # Convert StateMachine to State
+ var new_state
+ if node.state is StateMachine:
+ new_state = State.new()
+ new_state.name = node.state.name
+ new_state.graph_offset = node.state.graph_offset
+ layer.state_machine.remove_state(node.state.name)
+ layer.state_machine.add_state(new_state)
+ node.state = new_state
+ else:
+ new_state = node.state
+ return new_state
+
+func create_layer_instance():
+ var layer = Control.new()
+ layer.set_script(StateMachineEditorLayer)
+ layer.editor_accent_color = editor_accent_color
+ return layer
+
+func create_line_instance():
+ var line = TransitionLine.instantiate()
+ line.theme.get_stylebox("focus", "FlowChartLine").shadow_color = editor_accent_color
+ line.theme.set_icon("arrow", "FlowChartLine", transition_arrow_icon)
+ return line
+
+# Request to save current editing StateMachine
+func save_request():
+ if not can_save():
+ return
+
+ save_dialog.dialog_text = "Saving StateMachine to %s" % state_machine.resource_path
+ save_dialog.popup_centered()
+
+# Save current editing StateMachine
+func save():
+ if not can_save():
+ return
+
+ unsaved_indicator.text = ""
+ ResourceSaver.save(state_machine, state_machine.resource_path)
+
+# Clear editor
+func clear_graph(layer):
+ clear_connections()
+
+ for child in layer.content_nodes.get_children():
+ if child is StateNodeScript:
+ layer.content_nodes.remove_child(child)
+ child.queue_free()
+
+ queue_redraw()
+ unsaved_indicator.text = "" # Clear graph is not action by user
+
+# Intialize editor with current editing StateMachine
+func draw_graph(layer):
+ for state_key in layer.state_machine.states.keys():
+ var state = layer.state_machine.states[state_key]
+ var new_node = StateNode.instantiate()
+ new_node.theme.get_stylebox("focus", "FlowChartNode").border_color = editor_accent_color
+ new_node.name = state_key # Set before add_node to let engine handle duplicate name
+ add_node(layer, new_node)
+ # Set after add_node to make sure UIs are initialized
+ new_node.state = state
+ new_node.state.name = state_key
+ new_node.position = state.graph_offset
+ for state_key in layer.state_machine.states.keys():
+ var from_transitions = layer.state_machine.transitions.get(state_key)
+ if from_transitions:
+ for transition in from_transitions.values():
+ connect_node(layer, transition.from, transition.to)
+ layer._connections[transition.from][transition.to].line.transition = transition
+ queue_redraw()
+ unsaved_indicator.text = "" # Draw graph is not action by user
+
+# Add message to message_box(overlay text at bottom of editor)
+func add_message(key, text):
+ var label = Label.new()
+ label.text = text
+ _message_box_dict[key] = label
+ message_box.add_child(label)
+ return label
+
+# Remove message from message_box
+func remove_message(key):
+ var control = _message_box_dict.get(key)
+ if control:
+ _message_box_dict.erase(key)
+ message_box.remove_child(control)
+ # Weird behavior of VBoxContainer, only sort children properly after changing grow_direction
+ message_box.grow_vertical = GROW_DIRECTION_END
+ message_box.grow_vertical = GROW_DIRECTION_BEGIN
+ return true
+ return false
+
+# Check if current editing StateMachine has entry, warns user if entry state missing
+func check_has_entry():
+ if not current_layer.state_machine:
+ return
+ if not current_layer.state_machine.has_entry():
+ if not (ENTRY_STATE_MISSING_MSG.key in _message_box_dict):
+ add_message(ENTRY_STATE_MISSING_MSG.key, ENTRY_STATE_MISSING_MSG.text)
+ else:
+ if ENTRY_STATE_MISSING_MSG.key in _message_box_dict:
+ remove_message(ENTRY_STATE_MISSING_MSG.key)
+
+# Check if current editing StateMachine is nested and has exit, warns user if exit state missing
+func check_has_exit():
+ if not current_layer.state_machine:
+ return
+ if not path_viewer.get_cwd() == "root": # Nested state
+ if not current_layer.state_machine.has_exit():
+ if not (EXIT_STATE_MISSING_MSG.key in _message_box_dict):
+ add_message(EXIT_STATE_MISSING_MSG.key, EXIT_STATE_MISSING_MSG.text)
+ return
+ if EXIT_STATE_MISSING_MSG.key in _message_box_dict:
+ remove_message(EXIT_STATE_MISSING_MSG.key)
+
+func _on_layer_selected(layer):
+ if layer:
+ layer.show_content()
+ check_has_entry()
+ check_has_exit()
+
+func _on_layer_deselected(layer):
+ if layer:
+ layer.hide_content()
+
+func _on_node_dragged(layer, node, dragged):
+ node.state.graph_offset = node.position
+ _on_edited()
+
+func _on_node_added(layer, new_node):
+ # Godot 4 duplicates node with an internal @ name, which breaks everything
+ while String(new_node.name).begins_with("@"):
+ new_node.name = String(new_node.name).lstrip("@")
+
+ new_node.undo_redo = undo_redo
+ new_node.state.name = new_node.name
+ new_node.state.graph_offset = new_node.position
+ new_node.name_edit_entered.connect(_on_node_name_edit_entered.bind(new_node))
+ new_node.gui_input.connect(_on_state_node_gui_input.bind(new_node))
+ layer.state_machine.add_state(new_node.state)
+ check_has_entry()
+ check_has_exit()
+ _on_edited()
+
+func _on_node_removed(layer, node_name):
+ var path = str(path_viewer.get_cwd(), "/", node_name)
+ var layer_to_remove = get_layer(path)
+ if layer_to_remove:
+ layer_to_remove.get_parent().remove_child(layer_to_remove)
+ layer_to_remove.queue_free()
+ var result = layer.state_machine.remove_state(node_name)
+ check_has_entry()
+ check_has_exit()
+ _on_edited()
+ return result
+
+func _on_node_connected(layer, from, to):
+ if _reconnecting_connection:
+ # Reconnection will trigger _on_node_connected after _on_node_reconnect_end/_on_node_reconnect_failed
+ if is_instance_valid(_reconnecting_connection.from_node) and \
+ _reconnecting_connection.from_node.name == from and \
+ is_instance_valid(_reconnecting_connection.to_node) and \
+ _reconnecting_connection.to_node.name == to:
+ _reconnecting_connection = null
+ return
+ if layer.state_machine.transitions.has(from):
+ if layer.state_machine.transitions[from].has(to):
+ return # Already existed as it is loaded from file
+
+ var line = layer._connections[from][to].line
+ var new_transition = Transition.new(from, to)
+ line.transition = new_transition
+ layer.state_machine.add_transition(new_transition)
+ clear_selection()
+ select(line)
+ _on_edited()
+
+func _on_node_disconnected(layer, from, to):
+ layer.state_machine.remove_transition(from, to)
+ _on_edited()
+
+func _on_node_reconnect_begin(layer, from, to):
+ _reconnecting_connection = layer._connections[from][to]
+ layer.state_machine.remove_transition(from, to)
+
+func _on_node_reconnect_end(layer, from, to):
+ var transition = _reconnecting_connection.line.transition
+ transition.to = to
+ layer.state_machine.add_transition(transition)
+ clear_selection()
+ select(_reconnecting_connection.line)
+
+func _on_node_reconnect_failed(layer, from, to):
+ var transition = _reconnecting_connection.line.transition
+ layer.state_machine.add_transition(transition)
+ clear_selection()
+ select(_reconnecting_connection.line)
+
+func _request_connect_from(layer, from):
+ if from == State.EXIT_STATE:
+ return false
+ return true
+
+func _request_connect_to(layer, to):
+ if to == State.ENTRY_STATE:
+ return false
+ return true
+
+func _on_duplicated(layer, old_nodes, new_nodes):
+ # Duplicate condition as well
+ for i in old_nodes.size():
+ var from_node = old_nodes[i]
+ for connection_pair in get_connection_list():
+ if from_node.name == connection_pair.from:
+ for j in old_nodes.size():
+ var to_node = old_nodes[j]
+ if to_node.name == connection_pair.to:
+ var old_connection = layer._connections[connection_pair.from][connection_pair.to]
+ var new_connection = layer._connections[new_nodes[i].name][new_nodes[j].name]
+ for condition in old_connection.line.transition.conditions.values():
+ new_connection.line.transition.add_condition(condition.duplicate())
+ _on_edited()
+
+func _on_node_name_edit_entered(new_name, node):
+ var old = node.state.name
+ var new = new_name
+ if old == new:
+ return
+ if "/" in new or "\\" in new: # No back/forward-slash
+ push_warning("Illegal State Name: / and \\ are not allowed in State name(%s)" % new)
+ node.name_edit.text = old
+ return
+
+ if current_layer.state_machine.change_state_name(old, new):
+ rename_node(current_layer, node.name, new)
+ node.name = new
+ # Rename layer as well
+ var path = str(path_viewer.get_cwd(), "/", node.name)
+ var layer = get_layer(path)
+ if layer:
+ layer.name = new
+ for child in path_viewer.get_children():
+ if child.text == old:
+ child.text = new
+ break
+ _on_edited()
+ else:
+ node.name_edit.text = old
+
+func _on_edited():
+ unsaved_indicator.text = "*"
+
+func _on_remote_transited(from, to):
+ var from_dir = StateDirectory.new(from)
+ var to_dir = StateDirectory.new(to)
+ var focused_layer = get_focused_layer(from)
+ if from:
+ if focused_layer:
+ focused_layer.debug_transit_out(from, to)
+ if to:
+ if from_dir.is_nested() and from_dir.is_exit():
+ if focused_layer:
+ var path = path_viewer.back()
+ select_layer(get_layer(path))
+ elif to_dir.is_nested():
+ if to_dir.is_entry() and focused_layer:
+ # Open into next layer
+ to_dir.goto(to_dir.get_end_index())
+ to_dir.back()
+ var node = focused_layer.content_nodes.get_node_or_null(NodePath(to_dir.get_current_end()))
+ if node:
+ var layer = create_layer(node)
+ select_layer(layer)
+ # In case where, "from" state is nested yet not an exit state,
+ # while "to" state is on different level, then jump to destination layer directly.
+ # This happens when StateMachinePlayer transit to state that existing in the stack,
+ # which trigger StackPlayer.reset() and cause multiple states removed from stack within one frame
+ elif from_dir.is_nested() and not from_dir.is_exit():
+ if to_dir._dirs.size() != from_dir._dirs.size():
+ to_dir.goto(to_dir.get_end_index())
+ var n = to_dir.back()
+ if not n:
+ n = "root"
+ var layer = get_layer(n)
+ path_viewer.select_dir(layer.name)
+ select_layer(layer)
+
+ focused_layer = get_focused_layer(to)
+ if not focused_layer:
+ focused_layer = open_layer(to)
+ focused_layer.debug_transit_in(from, to)
+
+# Return if current editing StateMachine can be saved, ignore built-in resource
+func can_save():
+ if not state_machine:
+ return false
+ var resource_path = state_machine.resource_path
+ if resource_path.is_empty():
+ return false
+ if ".scn" in resource_path or ".tscn" in resource_path: # Built-in resource will be saved by scene
+ return false
+ return true
+
+func set_debug_mode(v):
+ if debug_mode != v:
+ debug_mode = v
+ _on_debug_mode_changed(v)
+ emit_signal("debug_mode_changed", debug_mode)
+
+func set_state_machine_player(smp):
+ if state_machine_player != smp:
+ state_machine_player = smp
+ _on_state_machine_player_changed(smp)
+
+func set_state_machine(sm):
+ if state_machine != sm:
+ state_machine = sm
+ _on_state_machine_changed(sm)
+
+func set_current_state(v):
+ if _current_state != v:
+ var from = _current_state
+ var to = v
+ _current_state = v
+ _on_remote_transited(from, to)
diff --git a/addons/imjp94.yafsm/scenes/StateMachineEditor.tscn b/addons/imjp94.yafsm/scenes/StateMachineEditor.tscn
new file mode 100644
index 00000000..0d124ba6
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/StateMachineEditor.tscn
@@ -0,0 +1,101 @@
+[gd_scene load_steps=5 format=3 uid="uid://bp2f3rs2sgn8g"]
+
+[ext_resource type="PackedScene" uid="uid://ccv81pntbud75" path="res://addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/StateMachineEditor.gd" id="2"]
+[ext_resource type="PackedScene" uid="uid://cflltb00e10be" path="res://addons/imjp94.yafsm/scenes/ContextMenu.tscn" id="3"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/ParametersPanel.gd" id="4"]
+
+[node name="StateMachineEditor" type="Control"]
+visible = false
+clip_contents = true
+custom_minimum_size = Vector2i(0, 200)
+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
+focus_mode = 2
+mouse_filter = 1
+script = ExtResource("2")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+visible = false
+layout_mode = 1
+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
+
+[node name="Panel" type="Panel" parent="MarginContainer"]
+layout_mode = 2
+offset_right = 1152.0
+offset_bottom = 648.0
+
+[node name="CreateNewStateMachine" type="Button" parent="MarginContainer"]
+layout_mode = 2
+offset_left = 473.0
+offset_top = 308.0
+offset_right = 679.0
+offset_bottom = 339.0
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_colors/font_color = Color(0.87451, 0.87451, 0.87451, 1)
+text = "Create new StateMachine"
+
+[node name="ContextMenu" parent="." instance=ExtResource("3")]
+visible = false
+
+[node name="StateNodeContextMenu" parent="." instance=ExtResource("1")]
+visible = false
+
+[node name="SaveDialog" type="ConfirmationDialog" parent="."]
+
+[node name="ConvertToStateConfirmation" type="ConfirmationDialog" parent="."]
+dialog_text = "All nested states beneath it will be lost, are you sure about that?"
+dialog_autowrap = true
+
+[node name="ParametersPanel" type="MarginContainer" parent="."]
+visible = false
+layout_mode = 1
+anchors_preset = 3
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 0
+grow_vertical = 0
+script = ExtResource("4")
+
+[node name="PanelContainer" type="PanelContainer" parent="ParametersPanel"]
+layout_mode = 2
+offset_right = 113.0
+offset_bottom = 31.0
+
+[node name="MarginContainer" type="MarginContainer" parent="ParametersPanel/PanelContainer"]
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="ParametersPanel/PanelContainer/MarginContainer"]
+layout_mode = 2
+offset_right = 113.0
+offset_bottom = 31.0
+
+[node name="MarginContainer" type="MarginContainer" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+
+[node name="Button" type="Button" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer/MarginContainer"]
+layout_mode = 2
+offset_right = 113.0
+offset_bottom = 31.0
+size_flags_horizontal = 10
+text = "Show Params"
+
+[node name="GridContainer" type="GridContainer" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer"]
+visible = false
+layout_mode = 2
+columns = 4
diff --git a/addons/imjp94.yafsm/scenes/StateMachineEditorLayer.gd b/addons/imjp94.yafsm/scenes/StateMachineEditorLayer.gd
new file mode 100644
index 00000000..62ae3490
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/StateMachineEditorLayer.gd
@@ -0,0 +1,149 @@
+@tool
+extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd"
+
+const Utils = preload("res://addons/imjp94.yafsm/scripts/Utils.gd")
+const StateNode = preload("res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn")
+const StateNodeScript = preload("res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd")
+const StateDirectory = preload("../src/StateDirectory.gd")
+
+var editor_accent_color: = Color.WHITE:
+ set = set_editor_accent_color
+var editor_complementary_color = Color.WHITE
+
+var state_machine
+var tween_lines
+var tween_labels
+var tween_nodes
+
+
+func debug_update(current_state, parameters, local_parameters):
+ _init_tweens()
+ if not state_machine:
+ return
+ var current_dir = StateDirectory.new(current_state)
+ var transitions = state_machine.transitions.get(current_state, {})
+ if current_dir.is_nested():
+ transitions = state_machine.transitions.get(current_dir.get_end(), {})
+ for transition in transitions.values():
+ # Check all possible transitions from current state, update labels, color them accordingly
+ var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
+ if line:
+ # Blinking alpha of TransitionLine
+ var color1 = Color.WHITE
+ color1.a = 0.1
+ var color2 = Color.WHITE
+ color2.a = 0.5
+ if line.self_modulate == color1:
+ tween_lines.tween_property(line, "self_modulate", color2, 0.5)
+ elif line.self_modulate == color2:
+ tween_lines.tween_property(line, "self_modulate", color1, 0.5)
+ elif line.self_modulate == Color.WHITE:
+ tween_lines.tween_property(line, "self_modulate", color2, 0.5)
+ # Update TransitionLine condition labels
+ for condition in transition.conditions.values():
+ if not ("value" in condition): # Ignore trigger
+ continue
+ var value = parameters.get(str(condition.name))
+ value = str(value) if value != null else "?"
+ var label = line.vbox.get_node_or_null(NodePath(str(condition.name)))
+ var override_template_var = line._template_var.get(str(condition.name))
+ if override_template_var == null:
+ override_template_var = {}
+ line._template_var[str(condition.name)] = override_template_var
+ override_template_var["value"] = str(value)
+ line.update_label()
+ # Condition label color based on comparation
+ var cond_1: bool = condition.compare(parameters.get(str(condition.name)))
+ var cond_2: bool = condition.compare(local_parameters.get(str(condition.name)))
+ if cond_1 or cond_2:
+ tween_labels.tween_property(label, "self_modulate", Color.GREEN.lightened(0.5), 0.01)
+ else:
+ tween_labels.tween_property(label, "self_modulate", Color.RED.lightened(0.5), 0.01)
+ _start_tweens()
+
+func debug_transit_out(from, to):
+ _init_tweens()
+ var from_dir = StateDirectory.new(from)
+ var to_dir = StateDirectory.new(to)
+ var from_node = content_nodes.get_node_or_null(NodePath(from_dir.get_end()))
+ if from_node != null:
+ tween_nodes.tween_property(from_node, "self_modulate", editor_complementary_color, 0.01)
+ tween_nodes.tween_property(from_node, "self_modulate", Color.WHITE, 1)
+ var transitions = state_machine.transitions.get(from, {})
+ if from_dir.is_nested():
+ transitions = state_machine.transitions.get(from_dir.get_end(), {})
+ # Fade out color of StateNode
+ for transition in transitions.values():
+ var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
+ if line:
+ line.template = "{condition_name} {condition_comparation} {condition_value}"
+ line.update_label()
+ if transition.to == to_dir.get_end():
+ tween_lines.tween_property(line, "self_modulate", editor_complementary_color, 0.01)
+ tween_lines.tween_property(line, "self_modulate", Color.WHITE, 1).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_IN)
+ # Highlight all the conditions of the transition that just happened
+ for condition in transition.conditions.values():
+ if not ("value" in condition): # Ignore trigger
+ continue
+ var label = line.vbox.get_node_or_null(NodePath(condition.name))
+ tween_labels.tween_property(label, "self_modulate", editor_complementary_color, 0.01)
+ tween_labels.tween_property(label, "self_modulate", Color.WHITE, 1)
+ else:
+ tween_lines.tween_property(line, "self_modulate", Color.WHITE, 0.1)
+ # Revert color of TransitionLine condition labels
+ for condition in transition.conditions.values():
+ if not ("value" in condition): # Ignore trigger
+ continue
+ var label = line.vbox.get_node_or_null(NodePath(condition.name))
+ if label.self_modulate != Color.WHITE:
+ tween_labels.tween_property(label, "self_modulate", Color.WHITE, 0.5)
+ if from_dir.is_nested() and from_dir.is_exit():
+ # Transition from nested state
+ transitions = state_machine.transitions.get(from_dir.get_base(), {})
+ tween_lines.set_parallel(true)
+ for transition in transitions.values():
+ var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
+ if line:
+ tween_lines.tween_property(line, "self_modulate", editor_complementary_color.lightened(0.5), 0.1)
+ for transition in transitions.values():
+ var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
+ if line:
+ tween_lines.tween_property(line, "self_modulate", Color.WHITE, 0.1)
+ _start_tweens()
+
+func debug_transit_in(from, to):
+ _init_tweens()
+ var to_dir = StateDirectory.new(to)
+ var to_node = content_nodes.get_node_or_null(NodePath(to_dir.get_end()))
+ if to_node:
+ tween_nodes.tween_property(to_node, "self_modulate", editor_complementary_color, 0.5)
+ var transitions = state_machine.transitions.get(to, {})
+ if to_dir.is_nested():
+ transitions = state_machine.transitions.get(to_dir.get_end(), {})
+ # Change string template for current TransitionLines
+ for transition in transitions.values():
+ var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
+ line.template = "{condition_name} {condition_comparation} {condition_value}({value})"
+ _start_tweens()
+
+func set_editor_accent_color(color):
+ editor_accent_color = color
+ editor_complementary_color = Utils.get_complementary_color(color)
+
+
+func _init_tweens():
+ tween_lines = get_tree().create_tween()
+ tween_lines.stop()
+ tween_labels = get_tree().create_tween()
+ tween_labels.stop()
+ tween_nodes = get_tree().create_tween()
+ tween_nodes.stop()
+
+
+func _start_tweens():
+ tween_lines.tween_interval(0.001)
+ tween_lines.play()
+ tween_labels.tween_interval(0.001)
+ tween_labels.play()
+ tween_nodes.tween_interval(0.001)
+ tween_nodes.play()
diff --git a/addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn b/addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn
new file mode 100644
index 00000000..ccfdc40a
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn
@@ -0,0 +1,17 @@
+[gd_scene format=3 uid="uid://ccv81pntbud75"]
+
+[node name="StateNodeContextMenu" type="PopupMenu"]
+size = Vector2i(154, 120)
+visible = true
+item_count = 5
+item_0/text = "Copy"
+item_0/id = 0
+item_1/text = "Duplicate"
+item_1/id = 1
+item_2/text = "Delete"
+item_2/id = 4
+item_3/text = ""
+item_3/id = 2
+item_3/separator = true
+item_4/text = "Convert to State"
+item_4/id = 3
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd
new file mode 100644
index 00000000..ec7eeb13
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd
@@ -0,0 +1,22 @@
+@tool
+extends "ValueConditionEditor.gd"
+
+@onready var boolean_value = $MarginContainer/BooleanValue
+
+func _ready():
+ super._ready()
+
+ boolean_value.pressed.connect(_on_boolean_value_pressed)
+
+
+func _on_value_changed(new_value):
+ if boolean_value.button_pressed != new_value:
+ boolean_value.button_pressed = new_value
+
+func _on_boolean_value_pressed():
+ change_value_action(condition.value, boolean_value.button_pressed)
+
+func _on_condition_changed(new_condition):
+ super._on_condition_changed(new_condition)
+ if new_condition:
+ boolean_value.button_pressed = new_condition.value
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.tscn
new file mode 100644
index 00000000..e12bbfd9
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.tscn
@@ -0,0 +1,35 @@
+[gd_scene load_steps=3 format=3 uid="uid://bmpwx6h3ckekr"]
+
+[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd" id="2"]
+
+[node name="BoolConditionEditor" instance=ExtResource("1")]
+script = ExtResource("2")
+
+[node name="Name" parent="." index="0"]
+layout_mode = 2
+
+[node name="Comparation" parent="." index="1"]
+layout_mode = 2
+
+[node name="PopupMenu" parent="Comparation" index="0"]
+item_count = 2
+
+[node name="MarginContainer" parent="." index="2"]
+layout_mode = 2
+offset_top = 3.0
+offset_right = 146.0
+offset_bottom = 27.0
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="BooleanValue" type="CheckButton" parent="MarginContainer" index="0"]
+layout_mode = 2
+offset_right = 44.0
+offset_bottom = 24.0
+size_flags_horizontal = 6
+
+[node name="Remove" parent="." index="3"]
+layout_mode = 2
+offset_left = 150.0
+offset_right = 176.0
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd
new file mode 100644
index 00000000..3ae6b6ff
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd
@@ -0,0 +1,72 @@
+@tool
+extends HBoxContainer
+
+@onready var name_edit = $Name
+@onready var remove = $Remove
+
+var undo_redo
+
+var condition:
+ set = set_condition
+
+
+func _ready():
+ name_edit.text_submitted.connect(_on_name_edit_text_submitted)
+ name_edit.focus_entered.connect(_on_name_edit_focus_entered)
+ name_edit.focus_exited.connect(_on_name_edit_focus_exited)
+ name_edit.text_changed.connect(_on_name_edit_text_changed)
+ set_process_input(false)
+
+func _input(event):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if get_viewport().gui_get_focus_owner() == name_edit:
+ var local_event = name_edit.make_input_local(event)
+ if not name_edit.get_rect().has_point(local_event.position):
+ name_edit.release_focus()
+
+func _on_name_edit_text_changed(new_text):
+ # name_edit.release_focus()
+ if condition.name == new_text: # Avoid infinite loop
+ return
+
+ rename_edit_action(new_text)
+
+func _on_name_edit_focus_entered():
+ set_process_input(true)
+
+func _on_name_edit_focus_exited():
+ set_process_input(false)
+ if condition.name == name_edit.text:
+ return
+
+ rename_edit_action(name_edit.text)
+
+func _on_name_edit_text_submitted(new_text):
+ name_edit.tooltip_text = new_text
+
+func change_name_edit(from, to):
+ var transition = get_parent().get_parent().get_parent().transition # TODO: Better way to get Transition object
+ if transition.change_condition_name(from, to):
+ if name_edit.text != to: # Manually update name_edit.text, in case called from undo_redo
+ name_edit.text = to
+ else:
+ name_edit.text = from
+ push_warning("Change Condition name_edit from (%s) to (%s) failed, name_edit existed" % [from, to])
+
+func rename_edit_action(new_name_edit):
+ var old_name_edit = condition.name
+ undo_redo.create_action("Rename_edit Condition")
+ undo_redo.add_do_method(self, "change_name_edit", old_name_edit, new_name_edit)
+ undo_redo.add_undo_method(self, "change_name_edit", new_name_edit, old_name_edit)
+ undo_redo.commit_action()
+
+func _on_condition_changed(new_condition):
+ if new_condition:
+ name_edit.text = new_condition.name
+ name_edit.tooltip_text = name_edit.text
+
+func set_condition(c):
+ if condition != c:
+ condition = c
+ _on_condition_changed(c)
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn
new file mode 100644
index 00000000..18050e7c
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn
@@ -0,0 +1,24 @@
+[gd_scene load_steps=3 format=3 uid="uid://cie8lb6ww58ck"]
+
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://l78bjwo7shm" path="res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg" id="2"]
+
+[node name="ConditionEditor" type="HBoxContainer"]
+script = ExtResource("1")
+
+[node name="Name" type="LineEdit" parent="."]
+layout_mode = 2
+offset_right = 67.0
+offset_bottom = 31.0
+size_flags_horizontal = 3
+size_flags_vertical = 4
+text = "Param"
+
+[node name="Remove" type="Button" parent="."]
+layout_mode = 2
+offset_left = 71.0
+offset_right = 97.0
+offset_bottom = 31.0
+size_flags_horizontal = 9
+icon = ExtResource("2")
+flat = true
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd
new file mode 100644
index 00000000..4621a9d8
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd
@@ -0,0 +1,44 @@
+@tool
+extends "ValueConditionEditor.gd"
+
+@onready var float_value = $MarginContainer/FloatValue
+
+var _old_value = 0.0
+
+func _ready():
+ super._ready()
+
+ float_value.text_submitted.connect(_on_float_value_text_submitted)
+ float_value.focus_entered.connect(_on_float_value_focus_entered)
+ float_value.focus_exited.connect(_on_float_value_focus_exited)
+ set_process_input(false)
+
+func _input(event):
+ super._input(event)
+
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if get_viewport().gui_get_focus_owner() == float_value:
+ var local_event = float_value.make_input_local(event)
+ if not float_value.get_rect().has_point(local_event.position):
+ float_value.release_focus()
+
+func _on_value_changed(new_value):
+ float_value.text = str(snapped(new_value, 0.01)).pad_decimals(2)
+
+func _on_float_value_text_submitted(new_text):
+ change_value_action(_old_value, float(new_text))
+ float_value.release_focus()
+
+func _on_float_value_focus_entered():
+ set_process_input(true)
+ _old_value = float(float_value.text)
+
+func _on_float_value_focus_exited():
+ set_process_input(false)
+ change_value_action(_old_value, float(float_value.text))
+
+func _on_condition_changed(new_condition):
+ super._on_condition_changed(new_condition)
+ if new_condition:
+ float_value.text = str(snapped(new_condition.value, 0.01)).pad_decimals(2)
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.tscn
new file mode 100644
index 00000000..4689f262
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://doq6lkdh20j15"]
+
+[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd" id="2"]
+
+[node name="ValueConditionEditor" instance=ExtResource("1")]
+script = ExtResource("2")
+
+[node name="Comparation" parent="." index="1"]
+layout_mode = 2
+
+[node name="MarginContainer" parent="." index="2"]
+layout_mode = 2
+offset_right = 169.0
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="FloatValue" type="LineEdit" parent="MarginContainer" index="0"]
+layout_mode = 2
+offset_right = 67.0
+offset_bottom = 31.0
+size_flags_horizontal = 3
+
+[node name="Remove" parent="." index="3"]
+offset_left = 173.0
+offset_right = 199.0
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd
new file mode 100644
index 00000000..b47f2c2a
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd
@@ -0,0 +1,45 @@
+@tool
+extends "ValueConditionEditor.gd"
+
+@onready var integer_value = $MarginContainer/IntegerValue
+
+var _old_value = 0
+
+
+func _ready():
+ super._ready()
+
+ integer_value.text_submitted.connect(_on_integer_value_text_submitted)
+ integer_value.focus_entered.connect(_on_integer_value_focus_entered)
+ integer_value.focus_exited.connect(_on_integer_value_focus_exited)
+ set_process_input(false)
+
+func _input(event):
+ super._input(event)
+
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if get_viewport().gui_get_focus_owner() == integer_value:
+ var local_event = integer_value.make_input_local(event)
+ if not integer_value.get_rect().has_point(local_event.position):
+ integer_value.release_focus()
+
+func _on_value_changed(new_value):
+ integer_value.text = str(new_value)
+
+func _on_integer_value_text_submitted(new_text):
+ change_value_action(_old_value, int(new_text))
+ integer_value.release_focus()
+
+func _on_integer_value_focus_entered():
+ set_process_input(true)
+ _old_value = int(integer_value.text)
+
+func _on_integer_value_focus_exited():
+ set_process_input(false)
+ change_value_action(_old_value, int(integer_value.text))
+
+func _on_condition_changed(new_condition):
+ super._on_condition_changed(new_condition)
+ if new_condition:
+ integer_value.text = str(new_condition.value)
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.tscn
new file mode 100644
index 00000000..bbf9b840
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=3 format=3 uid="uid://d1ib30424prpf"]
+
+[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd" id="2"]
+
+[node name="IntegerConditionEditor" instance=ExtResource("1")]
+script = ExtResource("2")
+
+[node name="Comparation" parent="." index="1"]
+layout_mode = 2
+
+[node name="MarginContainer" parent="." index="2"]
+layout_mode = 2
+offset_right = 169.0
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="IntegerValue" type="LineEdit" parent="MarginContainer" index="0"]
+layout_mode = 2
+offset_right = 67.0
+offset_bottom = 31.0
+size_flags_horizontal = 3
+
+[node name="Remove" parent="." index="3"]
+offset_left = 173.0
+offset_right = 199.0
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd
new file mode 100644
index 00000000..dae7c5da
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd
@@ -0,0 +1,46 @@
+@tool
+extends "res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd"
+
+
+@onready var string_value = $MarginContainer/StringValue
+
+var _old_value = 0
+
+
+func _ready():
+ super._ready()
+
+ string_value.text_submitted.connect(_on_string_value_text_submitted)
+ string_value.focus_entered.connect(_on_string_value_focus_entered)
+ string_value.focus_exited.connect(_on_string_value_focus_exited)
+ set_process_input(false)
+
+func _input(event):
+ super._input(event)
+
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if get_viewport().gui_get_focus_owner() == string_value:
+ var local_event = string_value.make_input_local(event)
+ if not string_value.get_rect().has_point(local_event.position):
+ string_value.release_focus()
+
+func _on_value_changed(new_value):
+ string_value.text = new_value
+
+func _on_string_value_text_submitted(new_text):
+ change_value_action(_old_value, new_text)
+ string_value.release_focus()
+
+func _on_string_value_focus_entered():
+ set_process_input(true)
+ _old_value = string_value.text
+
+func _on_string_value_focus_exited():
+ set_process_input(false)
+ change_value_action(_old_value, string_value.text)
+
+func _on_condition_changed(new_condition):
+ super._on_condition_changed(new_condition)
+ if new_condition:
+ string_value.text = new_condition.value
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.tscn
new file mode 100644
index 00000000..004cf038
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=3 format=3 uid="uid://qfw0snt5kss6"]
+
+[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd" id="2"]
+
+[node name="StringConditionEditor" instance=ExtResource("1")]
+script = ExtResource("2")
+
+[node name="Comparation" parent="." index="1"]
+layout_mode = 2
+
+[node name="PopupMenu" parent="Comparation" index="0"]
+item_count = 2
+
+[node name="MarginContainer" parent="." index="2"]
+layout_mode = 2
+offset_right = 169.0
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="StringValue" type="LineEdit" parent="MarginContainer" index="0"]
+layout_mode = 2
+offset_right = 67.0
+offset_bottom = 31.0
+size_flags_horizontal = 3
+
+[node name="Remove" parent="." index="3"]
+offset_left = 173.0
+offset_right = 199.0
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd b/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd
new file mode 100644
index 00000000..3f8ab8fe
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd
@@ -0,0 +1,57 @@
+@tool
+extends "ConditionEditor.gd"
+const Utils = preload("../../scripts/Utils.gd")
+const Comparation = preload("../../src/conditions/ValueCondition.gd").Comparation
+
+@onready var comparation_button = $Comparation
+@onready var comparation_popup_menu = $Comparation/PopupMenu
+
+
+func _ready():
+ super._ready()
+
+ comparation_button.pressed.connect(_on_comparation_button_pressed)
+ comparation_popup_menu.id_pressed.connect(_on_comparation_popup_menu_id_pressed)
+
+func _on_comparation_button_pressed():
+ Utils.popup_on_target(comparation_popup_menu, comparation_button)
+
+func _on_comparation_popup_menu_id_pressed(id):
+ change_comparation_action(id)
+
+func _on_condition_changed(new_condition):
+ super._on_condition_changed(new_condition)
+ if new_condition:
+ comparation_button.text = comparation_popup_menu.get_item_text(new_condition.comparation)
+
+func _on_value_changed(new_value):
+ pass
+
+func change_comparation(id):
+ if id > Comparation.size() - 1:
+ push_error("Unexpected id(%d) from PopupMenu" % id)
+ return
+ condition.comparation = id
+ comparation_button.text = comparation_popup_menu.get_item_text(id)
+
+func change_comparation_action(id):
+ var from = condition.comparation
+ var to = id
+
+ undo_redo.create_action("Change Condition Comparation")
+ undo_redo.add_do_method(self, "change_comparation", to)
+ undo_redo.add_undo_method(self, "change_comparation", from)
+ undo_redo.commit_action()
+
+func set_value(v):
+ if condition.value != v:
+ condition.value = v
+ _on_value_changed(v)
+
+func change_value_action(from, to):
+ if from == to:
+ return
+ undo_redo.create_action("Change Condition Value")
+ undo_redo.add_do_method(self, "set_value", to)
+ undo_redo.add_undo_method(self, "set_value", from)
+ undo_redo.commit_action()
diff --git a/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn b/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn
new file mode 100644
index 00000000..2cda6db3
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn
@@ -0,0 +1,42 @@
+[gd_scene load_steps=3 format=3 uid="uid://blnscdhcxvpmk"]
+
+[ext_resource type="PackedScene" uid="uid://cie8lb6ww58ck" path="res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd" id="2"]
+
+[node name="ValueConditionEditor" instance=ExtResource("1")]
+script = ExtResource("2")
+
+[node name="Comparation" type="Button" parent="." index="1"]
+layout_mode = 2
+offset_left = 71.0
+offset_right = 98.0
+offset_bottom = 31.0
+size_flags_horizontal = 5
+size_flags_vertical = 4
+text = "=="
+
+[node name="PopupMenu" type="PopupMenu" parent="Comparation" index="0"]
+item_count = 6
+item_0/text = "=="
+item_0/id = 0
+item_1/text = "!="
+item_1/id = 1
+item_2/text = ">"
+item_2/id = 2
+item_3/text = "<"
+item_3/id = 3
+item_4/text = "≥"
+item_4/id = 4
+item_5/text = "≤"
+item_5/id = 5
+
+[node name="MarginContainer" type="MarginContainer" parent="." index="2"]
+layout_mode = 2
+offset_left = 102.0
+offset_right = 102.0
+offset_bottom = 31.0
+
+[node name="Remove" parent="." index="3"]
+offset_left = 106.0
+offset_right = 132.0
+tooltip_text = "Remove Condition"
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd b/addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd
new file mode 100644
index 00000000..2f9b2c4c
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd
@@ -0,0 +1,681 @@
+@tool
+extends Control
+
+const Utils = preload("res://addons/imjp94.yafsm/scripts/Utils.gd")
+const CohenSutherland = Utils.CohenSutherland
+const FlowChartNode = preload("FlowChartNode.gd")
+const FlowChartNodeScene = preload("FlowChartNode.tscn")
+const FlowChartLine = preload("FlowChartLine.gd")
+const FlowChartLineScene = preload("FlowChartLine.tscn")
+const FlowChartLayer = preload("FlowChartLayer.gd")
+const FlowChartGrid = preload("FlowChartGrid.gd")
+const Connection = FlowChartLayer.Connection
+
+signal connection(from, to, line) # When a connection established
+signal disconnection(from, to, line) # When a connection broken
+signal node_selected(node) # When a node selected
+signal node_deselected(node) # When a node deselected
+signal dragged(node, distance) # When a node dragged
+
+# Margin of content from edge of FlowChart
+@export var scroll_margin: = 50
+# Offset between two line that interconnecting
+@export var interconnection_offset: = 10
+# Snap amount
+@export var snap: = 20
+# Zoom amount
+@export var zoom: = 1.0:
+ set = set_zoom
+@export var zoom_step: = 0.2
+@export var max_zoom: = 2.0
+@export var min_zoom: = 0.5
+
+var grid = FlowChartGrid.new() # Grid
+var content = Control.new() # Root node that hold anything drawn in the flowchart
+var current_layer
+var h_scroll = HScrollBar.new()
+var v_scroll = VScrollBar.new()
+var top_bar = VBoxContainer.new()
+var gadget = HBoxContainer.new() # Root node of top overlay controls
+var zoom_minus = Button.new()
+var zoom_reset = Button.new()
+var zoom_plus = Button.new()
+var snap_button = Button.new()
+var snap_amount = SpinBox.new()
+
+var is_snapping = true
+var can_gui_select_node = true
+var can_gui_delete_node = true
+var can_gui_connect_node = true
+
+var _is_connecting = false
+var _current_connection
+var _is_dragging = false
+var _is_dragging_node = false
+var _drag_start_pos = Vector2.ZERO
+var _drag_end_pos = Vector2.ZERO
+var _drag_origins = []
+var _selection = []
+var _copying_nodes = []
+
+var selection_stylebox = StyleBoxFlat.new()
+var grid_major_color = Color(1, 1, 1, 0.2)
+var grid_minor_color = Color(1, 1, 1, 0.05)
+
+
+func _init():
+
+ focus_mode = FOCUS_ALL
+ selection_stylebox.bg_color = Color(0, 0, 0, 0.3)
+ selection_stylebox.set_border_width_all(1)
+
+ self.z_index = 0
+
+ content.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(content)
+ content.z_index = 1
+
+ grid.mouse_filter = MOUSE_FILTER_IGNORE
+ content.add_child.call_deferred(grid)
+ grid.z_index = -1
+
+ add_child(h_scroll)
+ h_scroll.set_anchors_and_offsets_preset(PRESET_BOTTOM_WIDE)
+ h_scroll.value_changed.connect(_on_h_scroll_changed)
+ h_scroll.gui_input.connect(_on_h_scroll_gui_input)
+
+ add_child(v_scroll)
+ v_scroll.set_anchors_and_offsets_preset(PRESET_RIGHT_WIDE)
+ v_scroll.value_changed.connect(_on_v_scroll_changed)
+ v_scroll.gui_input.connect(_on_v_scroll_gui_input)
+
+ h_scroll.offset_right = -v_scroll.size.x
+ v_scroll.offset_bottom = -h_scroll.size.y
+
+ h_scroll.min_value = 0
+ v_scroll.max_value = 0
+
+ add_layer_to(content)
+ select_layer_at(0)
+
+ top_bar.set_anchors_and_offsets_preset(PRESET_TOP_WIDE)
+ top_bar.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(top_bar)
+
+ gadget.mouse_filter = MOUSE_FILTER_IGNORE
+ top_bar.add_child(gadget)
+
+ zoom_minus.flat = true
+ zoom_minus.tooltip_text = "Zoom Out"
+ zoom_minus.pressed.connect(_on_zoom_minus_pressed)
+ zoom_minus.focus_mode = FOCUS_NONE
+ gadget.add_child(zoom_minus)
+
+ zoom_reset.flat = true
+ zoom_reset.tooltip_text = "Zoom Reset"
+ zoom_reset.pressed.connect(_on_zoom_reset_pressed)
+ zoom_reset.focus_mode = FOCUS_NONE
+ gadget.add_child(zoom_reset)
+
+ zoom_plus.flat = true
+ zoom_plus.tooltip_text = "Zoom In"
+ zoom_plus.pressed.connect(_on_zoom_plus_pressed)
+ zoom_plus.focus_mode = FOCUS_NONE
+ gadget.add_child(zoom_plus)
+
+ snap_button.flat = true
+ snap_button.toggle_mode = true
+ snap_button.tooltip_text = "Enable snap and show grid"
+ snap_button.pressed.connect(_on_snap_button_pressed)
+ snap_button.button_pressed = true
+ snap_button.focus_mode = FOCUS_NONE
+ gadget.add_child(snap_button)
+
+ snap_amount.value = snap
+ snap_amount.value_changed.connect(_on_snap_amount_value_changed)
+ gadget.add_child(snap_amount)
+
+func _on_h_scroll_gui_input(event):
+ if event is InputEventMouseButton:
+ var v = (h_scroll.max_value - h_scroll.min_value) * 0.01 # Scroll at 0.1% step
+ match event.button_index:
+ MOUSE_BUTTON_WHEEL_UP:
+ h_scroll.value -= v
+ MOUSE_BUTTON_WHEEL_DOWN:
+ h_scroll.value += v
+
+func _on_v_scroll_gui_input(event):
+ if event is InputEventMouseButton:
+ var v = (v_scroll.max_value - v_scroll.min_value) * 0.01 # Scroll at 0.1% step
+ match event.button_index:
+ MOUSE_BUTTON_WHEEL_UP:
+ v_scroll.value -= v # scroll left
+ MOUSE_BUTTON_WHEEL_DOWN:
+ v_scroll.value += v # scroll right
+
+func _on_h_scroll_changed(value):
+ content.position.x = -value
+
+func _on_v_scroll_changed(value):
+ content.position.y = -value
+
+func set_zoom(v):
+ zoom = clampf(v, min_zoom, max_zoom)
+ content.scale = Vector2.ONE * zoom
+ queue_redraw()
+ grid.queue_redraw()
+
+func _on_zoom_minus_pressed():
+ set_zoom(zoom - zoom_step)
+ queue_redraw()
+
+func _on_zoom_reset_pressed():
+ set_zoom(1.0)
+ queue_redraw()
+
+func _on_zoom_plus_pressed():
+ set_zoom(zoom + zoom_step)
+ queue_redraw()
+
+func _on_snap_button_pressed():
+ is_snapping = snap_button.button_pressed
+ queue_redraw()
+
+func _on_snap_amount_value_changed(value):
+ snap = value
+ queue_redraw()
+
+func _draw():
+ # Update scrolls
+ var content_rect: Rect2 = get_scroll_rect(current_layer, 0)
+ content.pivot_offset = content_rect.size / 2.0 # Scale from center
+ var flowchart_rect: Rect2 = get_rect()
+ # ENCLOSE CONDITIONS
+ var is_content_enclosed = (flowchart_rect.size.x >= content_rect.size.x)
+ is_content_enclosed = is_content_enclosed and (flowchart_rect.size.y >= content_rect.size.y)
+ is_content_enclosed = is_content_enclosed and (flowchart_rect.position.x <= content_rect.position.x)
+ is_content_enclosed = is_content_enclosed and (flowchart_rect.position.y >= content_rect.position.y)
+ if not is_content_enclosed or (h_scroll.min_value==h_scroll.max_value) or (v_scroll.min_value==v_scroll.max_value):
+ var h_min = 0 # content_rect.position.x - scroll_margin/2 - content_rect.get_center().x/2
+ var h_max = content_rect.size.x - content_rect.position.x - size.x + scroll_margin + content_rect.get_center().x
+ var v_min = 0 # content_rect.position.y - scroll_margin/2 - content_rect.get_center().y/2
+ var v_max = content_rect.size.y - content_rect.position.y - size.y + scroll_margin + content_rect.get_center().y
+ if h_min == h_max: # Otherwise scroll bar will complain no ratio
+ h_min -= 0.1
+ h_max += 0.1
+ if v_min == v_max: # Otherwise scroll bar will complain no ratio
+ v_min -= 0.1
+ v_max += 0.1
+ h_scroll.min_value = h_min
+ h_scroll.max_value = h_max
+ h_scroll.page = content_rect.size.x / 100
+ v_scroll.min_value = v_min
+ v_scroll.max_value = v_max
+ v_scroll.page = content_rect.size.y / 100
+
+ # Draw selection box
+ if not _is_dragging_node and not _is_connecting:
+ var selection_box_rect = get_selection_box_rect()
+ draw_style_box(selection_stylebox, selection_box_rect)
+
+ if is_snapping:
+ grid.visible = true
+ grid.queue_redraw()
+ else:
+ grid.visible = false
+
+ # Debug draw
+ # for node in content_nodes.get_children():
+ # var rect = get_transform() * (content.get_transform() * (node.get_rect()))
+ # draw_style_box(selection_stylebox, rect)
+
+ # var connection_list = get_connection_list()
+ # for i in connection_list.size():
+ # var connection = _connections[connection_list[i].from][connection_list[i].to]
+ # # Line's offset along its down-vector
+ # var line_local_up_offset = connection.line.position - connection.line.get_transform() * (Vector2.UP * connection.offset)
+ # var from_pos = content.get_transform() * (connection.get_from_pos() + line_local_up_offset)
+ # var to_pos = content.get_transform() * (connection.get_to_pos() + line_local_up_offset)
+ # draw_line(from_pos, to_pos, Color.yellow)
+
+func _gui_input(event):
+
+ var OS_KEY_DELETE = KEY_BACKSPACE if ( ["macOS", "OSX"].has(OS.get_name()) ) else KEY_DELETE
+ if event is InputEventKey:
+ match event.keycode:
+ OS_KEY_DELETE:
+ if event.pressed and can_gui_delete_node:
+ # Delete nodes
+ for node in _selection.duplicate():
+ if node is FlowChartLine:
+ # TODO: More efficient way to get connection from Line node
+ for connections_from in current_layer._connections.duplicate().values():
+ for connection in connections_from.duplicate().values():
+ if connection.line == node:
+ disconnect_node(current_layer, connection.from_node.name, connection.to_node.name).queue_free()
+ elif node is FlowChartNode:
+ remove_node(current_layer, node.name)
+ for connection_pair in current_layer.get_connection_list():
+ if connection_pair.from == node.name or connection_pair.to == node.name:
+ disconnect_node(current_layer, connection_pair.from, connection_pair.to).queue_free()
+ accept_event()
+ KEY_C:
+ if event.pressed and event.ctrl_pressed:
+ # Copy node
+ _copying_nodes = _selection.duplicate()
+ accept_event()
+ KEY_D:
+ if event.pressed and event.ctrl_pressed:
+ # Duplicate node directly from selection
+ duplicate_nodes(current_layer, _selection.duplicate())
+ accept_event()
+ KEY_V:
+ if event.pressed and event.ctrl_pressed:
+ # Paste node from _copying_nodes
+ duplicate_nodes(current_layer, _copying_nodes)
+ accept_event()
+
+ if event is InputEventMouseMotion:
+ match event.button_mask:
+ MOUSE_BUTTON_MASK_MIDDLE:
+ # Panning
+ h_scroll.value -= event.relative.x
+ v_scroll.value -= event.relative.y
+ queue_redraw()
+ MOUSE_BUTTON_LEFT:
+ # Dragging
+ if _is_dragging:
+ if _is_connecting:
+ # Connecting
+ if _current_connection:
+ var pos = content_position(get_local_mouse_position())
+ var clip_rects = [_current_connection.from_node.get_rect()]
+
+ # Snapping connecting line
+ for i in current_layer.content_nodes.get_child_count():
+ var child = current_layer.content_nodes.get_child(current_layer.content_nodes.get_child_count()-1 - i) # Inverse order to check from top to bottom of canvas
+ if child is FlowChartNode and child.name != _current_connection.from_node.name:
+ if _request_connect_to(current_layer, child.name):
+ if child.get_rect().has_point(pos):
+ pos = child.position + child.size / 2
+ clip_rects.append(child.get_rect())
+ break
+ _current_connection.line.join(_current_connection.get_from_pos(), pos, Vector2.ZERO, clip_rects)
+ elif _is_dragging_node:
+ # Dragging nodes
+ var dragged = content_position(_drag_end_pos) - content_position(_drag_start_pos)
+ for i in _selection.size():
+ var selected = _selection[i]
+ if not (selected is FlowChartNode):
+ continue
+ selected.position = (_drag_origins[i] + selected.size / 2.0 + dragged)
+ selected.modulate.a = 0.3
+ if is_snapping:
+ selected.position = selected.position.snapped(Vector2.ONE * snap)
+ selected.position -= selected.size / 2.0
+ _on_node_dragged(current_layer, selected, dragged)
+ emit_signal("dragged", selected, dragged)
+ # Update connection pos
+ for from in current_layer._connections:
+ var connections_from = current_layer._connections[from]
+ for to in connections_from:
+ if from == selected.name or to == selected.name:
+ var connection = current_layer._connections[from][to]
+ connection.join()
+ _drag_end_pos = get_local_mouse_position()
+ queue_redraw()
+
+ if event is InputEventMouseButton:
+ match event.button_index:
+ MOUSE_BUTTON_MIDDLE:
+ # Reset zoom
+ if event.double_click:
+ set_zoom(1.0)
+ queue_redraw()
+ MOUSE_BUTTON_WHEEL_UP:
+ # Zoom in
+ set_zoom(zoom + zoom_step/10)
+ queue_redraw()
+ MOUSE_BUTTON_WHEEL_DOWN:
+ # Zoom out
+ set_zoom(zoom - zoom_step/10)
+ queue_redraw()
+ MOUSE_BUTTON_LEFT:
+ # Hit detection
+ var hit_node
+ for i in current_layer.content_nodes.get_child_count():
+ var child = current_layer.content_nodes.get_child(current_layer.content_nodes.get_child_count()-1 - i) # Inverse order to check from top to bottom of canvas
+ if child is FlowChartNode:
+ if child.get_rect().has_point(content_position(get_local_mouse_position())):
+ hit_node = child
+ break
+ if not hit_node:
+ # Test Line
+ # Refer https://github.com/godotengine/godot/blob/master/editor/plugins/animation_state_machine_editor.cpp#L187
+ var closest = -1
+ var closest_d = 1e20
+ var connection_list = get_connection_list()
+ for i in connection_list.size():
+ var connection = current_layer._connections[connection_list[i].from][connection_list[i].to]
+ # Line's offset along its down-vector
+ var line_local_up_offset = connection.line.position - connection.line.get_transform()*(Vector2.DOWN * connection.offset)
+ var from_pos = connection.get_from_pos() + line_local_up_offset
+ var to_pos = connection.get_to_pos() + line_local_up_offset
+ var cp = Geometry2D.get_closest_point_to_segment(content_position(event.position), from_pos, to_pos)
+ var d = cp.distance_to(content_position(event.position))
+ if d > connection.line.size.y * 2:
+ continue
+ if d < closest_d:
+ closest = i
+ closest_d = d
+ if closest >= 0:
+ hit_node = current_layer._connections[connection_list[closest].from][connection_list[closest].to].line
+
+ if event.pressed:
+ if not (hit_node in _selection) and not event.shift_pressed:
+ # Click on empty space
+ clear_selection()
+ if hit_node:
+ # Click on node(can be a line)
+ _is_dragging_node = true
+ if hit_node is FlowChartLine:
+ current_layer.content_lines.move_child(hit_node, current_layer.content_lines.get_child_count()-1) # Raise selected line to top
+ if event.shift_pressed and can_gui_connect_node:
+ # Reconnection Start
+ for from in current_layer._connections.keys():
+ var from_connections = current_layer._connections[from]
+ for to in from_connections.keys():
+ var connection = from_connections[to]
+ if connection.line == hit_node:
+ _is_connecting = true
+ _is_dragging_node = false
+ _current_connection = connection
+ _on_node_reconnect_begin(current_layer, from, to)
+ break
+ if hit_node is FlowChartNode:
+ current_layer.content_nodes.move_child(hit_node, current_layer.content_nodes.get_child_count()-1) # Raise selected node to top
+ if event.shift_pressed and can_gui_connect_node:
+ # Connection start
+ if _request_connect_from(current_layer, hit_node.name):
+ _is_connecting = true
+ _is_dragging_node = false
+ var line = create_line_instance()
+ var connection = Connection.new(line, hit_node, null)
+ current_layer._connect_node(connection)
+ _current_connection = connection
+ _current_connection.line.join(_current_connection.get_from_pos(), content_position(event.position))
+ accept_event()
+ if _is_connecting:
+ clear_selection()
+ else:
+ if can_gui_select_node:
+ select(hit_node)
+ if not _is_dragging:
+ # Drag start
+ _is_dragging = true
+ for i in _selection.size():
+ var selected = _selection[i]
+ _drag_origins[i] = selected.position
+ selected.modulate.a = 1.0
+ _drag_start_pos = event.position
+ _drag_end_pos = event.position
+ else:
+ var was_connecting = _is_connecting
+ var was_dragging_node = _is_dragging_node
+ if _current_connection:
+ # Connection end
+ var from = _current_connection.from_node.name
+ var to = hit_node.name if hit_node else null
+ if hit_node is FlowChartNode and _request_connect_to(current_layer, to) and from != to:
+ # Connection success
+ var line
+ if _current_connection.to_node:
+ # Reconnection
+ line = disconnect_node(current_layer, from, _current_connection.to_node.name)
+ _current_connection.to_node = hit_node
+ _on_node_reconnect_end(current_layer, from, to)
+ connect_node(current_layer, from, to, line)
+ else:
+ # New Connection
+ current_layer.content_lines.remove_child(_current_connection.line)
+ line = _current_connection.line
+ _current_connection.to_node = hit_node
+ connect_node(current_layer, from, to, line)
+ else:
+ # Connection failed
+ if _current_connection.to_node:
+ # Reconnection
+ _current_connection.join()
+ _on_node_reconnect_failed(current_layer, from, name)
+ else:
+ # New Connection
+ _current_connection.line.queue_free()
+ _on_node_connect_failed(current_layer, from)
+ _is_connecting = false
+ _current_connection = null
+ accept_event()
+
+ if _is_dragging:
+ # Drag end
+ _is_dragging = false
+ _is_dragging_node = false
+ if not (was_connecting or was_dragging_node) and can_gui_select_node:
+ var selection_box_rect = get_selection_box_rect()
+ # Select node
+ for node in current_layer.content_nodes.get_children():
+ var rect = get_transform() * (content.get_transform() * (node.get_rect()))
+ if selection_box_rect.intersects(rect):
+ if node is FlowChartNode:
+ select(node)
+ # Select line
+ var connection_list = get_connection_list()
+ for i in connection_list.size():
+ var connection = current_layer._connections[connection_list[i].from][connection_list[i].to]
+ # Line's offset along its down-vector
+ var line_local_up_offset = connection.line.position - connection.line.get_transform() * (Vector2.UP * connection.offset)
+ var from_pos = content.get_transform() * (connection.get_from_pos() + line_local_up_offset)
+ var to_pos = content.get_transform() * (connection.get_to_pos() + line_local_up_offset)
+ if CohenSutherland.line_intersect_rectangle(from_pos, to_pos, selection_box_rect):
+ select(connection.line)
+ if was_dragging_node:
+ # Update _drag_origins with new position after dragged
+ for i in _selection.size():
+ var selected = _selection[i]
+ _drag_origins[i] = selected.position
+ selected.modulate.a = 1.0
+ _drag_start_pos = _drag_end_pos
+ queue_redraw()
+
+# Get selection box rect
+func get_selection_box_rect():
+ var pos = Vector2(min(_drag_start_pos.x, _drag_end_pos.x), min(_drag_start_pos.y, _drag_end_pos.y))
+ var size = (_drag_end_pos - _drag_start_pos).abs()
+ return Rect2(pos, size)
+
+# Get required scroll rect base on content
+func get_scroll_rect(layer=current_layer, force_scroll_margin=null):
+ var _scroll_margin = scroll_margin
+ if force_scroll_margin!=null:
+ _scroll_margin = force_scroll_margin
+ return layer.get_scroll_rect(_scroll_margin)
+
+func add_layer_to(target):
+ var layer = create_layer_instance()
+ target.add_child(layer)
+ return layer
+
+func get_layer(np):
+ return content.get_node_or_null(NodePath(np))
+
+func select_layer_at(i):
+ select_layer(content.get_child(i))
+
+func select_layer(layer):
+ var prev_layer = current_layer
+ _on_layer_deselected(prev_layer)
+ current_layer = layer
+ _on_layer_selected(layer)
+
+# Add node
+func add_node(layer, node):
+ layer.add_node(node)
+ _on_node_added(layer, node)
+
+# Remove node
+func remove_node(layer, node_name):
+ var node = layer.content_nodes.get_node_or_null(NodePath(node_name))
+ if node:
+ deselect(node) # Must deselct before remove to make sure _drag_origins synced with _selections
+ layer.remove_node(node)
+ _on_node_removed(layer, node_name)
+
+# Called after connection established
+func _connect_node(line, from_pos, to_pos):
+ pass
+
+# Called after connection broken
+func _disconnect_node(line):
+ if line in _selection:
+ deselect(line)
+
+func create_layer_instance():
+ var layer = Control.new()
+ layer.set_script(FlowChartLayer)
+ return layer
+
+# Return new line instance to use, called when connecting node
+func create_line_instance():
+ return FlowChartLineScene.instantiate()
+
+# Rename node
+func rename_node(layer, old, new):
+ layer.rename_node(old, new)
+
+# Connect two nodes with a line
+func connect_node(layer, from, to, line=null):
+ if not line:
+ line = create_line_instance()
+ line.name = "%s>%s" % [from, to] # "From>To"
+ layer.connect_node(line, from, to, interconnection_offset)
+ _on_node_connected(layer, from, to)
+ emit_signal("connection", from, to, line)
+
+# Break a connection between two node
+func disconnect_node(layer, from, to):
+ var line = layer.disconnect_node(from, to)
+ deselect(line) # Since line is selectable as well
+ _on_node_disconnected(layer, from, to)
+ emit_signal("disconnection", from, to)
+ return line
+
+# Clear all connections
+func clear_connections(layer=current_layer):
+ layer.clear_connections()
+
+# Select a node(can be a line)
+func select(node):
+ if node in _selection:
+ return
+
+ _selection.append(node)
+ node.selected = true
+ _drag_origins.append(node.position)
+ emit_signal("node_selected", node)
+
+# Deselect a node
+func deselect(node):
+ _selection.erase(node)
+ if is_instance_valid(node):
+ node.selected = false
+ _drag_origins.pop_back()
+ emit_signal("node_deselected", node)
+
+# Clear all selection
+func clear_selection():
+ for node in _selection.duplicate(): # duplicate _selection array as deselect() edit array
+ if not node:
+ continue
+ deselect(node)
+ _selection.clear()
+
+# Duplicate given nodes in editor
+func duplicate_nodes(layer, nodes):
+ clear_selection()
+ var new_nodes = []
+ for i in nodes.size():
+ var node = nodes[i]
+ if not (node is FlowChartNode):
+ continue
+ var new_node = node.duplicate(DUPLICATE_SIGNALS + DUPLICATE_SCRIPTS)
+ var offset = content_position(get_local_mouse_position()) - content_position(_drag_end_pos)
+ new_node.position = new_node.position + offset
+ new_nodes.append(new_node)
+ add_node(layer, new_node)
+ select(new_node)
+ # Duplicate connection within selection
+ for i in nodes.size():
+ var from_node = nodes[i]
+ for connection_pair in get_connection_list():
+ if from_node.name == connection_pair.from:
+ for j in nodes.size():
+ var to_node = nodes[j]
+ if to_node.name == connection_pair.to:
+ connect_node(layer, new_nodes[i].name, new_nodes[j].name)
+ _on_duplicated(layer, nodes, new_nodes)
+
+# Called after layer selected(current_layer changed)
+func _on_layer_selected(layer):
+ pass
+
+func _on_layer_deselected(layer):
+ pass
+
+# Called after a node added
+func _on_node_added(layer, node):
+ pass
+
+# Called after a node removed
+func _on_node_removed(layer, node):
+ pass
+
+# Called when a node dragged
+func _on_node_dragged(layer, node, dragged):
+ pass
+
+# Called when connection established between two nodes
+func _on_node_connected(layer, from, to):
+ pass
+
+# Called when connection broken
+func _on_node_disconnected(layer, from, to):
+ pass
+
+func _on_node_connect_failed(layer, from):
+ pass
+
+func _on_node_reconnect_begin(layer, from, to):
+ pass
+
+func _on_node_reconnect_end(layer, from, to):
+ pass
+
+func _on_node_reconnect_failed(layer, from, to):
+ pass
+
+func _request_connect_from(layer, from):
+ return true
+
+func _request_connect_to(layer, to):
+ return true
+
+# Called when nodes duplicated
+func _on_duplicated(layer, old_nodes, new_nodes):
+ pass
+
+# Convert position in FlowChart space to content(takes translation/scale of content into account)
+func content_position(pos):
+ return (pos - content.position - content.pivot_offset * (Vector2.ONE - content.scale)) * 1.0/content.scale
+
+# Return array of dictionary of connection as such [{"from1": "to1"}, {"from2": "to2"}]
+func get_connection_list(layer=current_layer):
+ return layer.get_connection_list()
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartGrid.gd b/addons/imjp94.yafsm/scenes/flowchart/FlowChartGrid.gd
new file mode 100644
index 00000000..94363ecd
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartGrid.gd
@@ -0,0 +1,64 @@
+extends Control
+
+var flowchart
+
+func _ready():
+ flowchart = get_parent().get_parent()
+ queue_redraw()
+
+# Original Draw in FlowChart.gd inspired by:
+# https://github.com/godotengine/godot/blob/6019dab0b45e1291e556e6d9e01b625b5076cc3c/scene/gui/graph_edit.cpp#L442
+func _draw():
+
+ self.position = flowchart.position
+ # Extents of the grid.
+ self.size = flowchart.size*100 # good with min_zoom = 0.5 e max_zoom = 2.0
+
+ var zoom = flowchart.zoom
+ var snap = flowchart.snap
+
+ # Origin of the grid.
+ var offset = -Vector2(1, 1)*10000 # good with min_zoom = 0.5 e max_zoom = 2.0
+
+ var corrected_size = size/zoom
+
+ var from = (offset / snap).floor()
+ var l = (corrected_size / snap).floor() + Vector2(1, 1)
+
+ var grid_minor = flowchart.grid_minor_color
+ var grid_major = flowchart.grid_major_color
+
+ var multi_line_vector_array: PackedVector2Array = PackedVector2Array()
+ var multi_line_color_array: PackedColorArray = PackedColorArray ()
+
+ # for (int i = from.x; i < from.x + len.x; i++) {
+ for i in range(from.x, from.x + l.x):
+ var color
+
+ if (int(abs(i)) % 10 == 0):
+ color = grid_major
+ else:
+ color = grid_minor
+
+ var base_ofs = i * snap
+
+ multi_line_vector_array.append(Vector2(base_ofs, offset.y))
+ multi_line_vector_array.append(Vector2(base_ofs, corrected_size.y))
+ multi_line_color_array.append(color)
+
+ # for (int i = from.y; i < from.y + len.y; i++) {
+ for i in range(from.y, from.y + l.y):
+ var color
+
+ if (int(abs(i)) % 10 == 0):
+ color = grid_major
+ else:
+ color = grid_minor
+
+ var base_ofs = i * snap
+
+ multi_line_vector_array.append(Vector2(offset.x, base_ofs))
+ multi_line_vector_array.append(Vector2(corrected_size.x, base_ofs))
+ multi_line_color_array.append(color)
+
+ draw_multiline_colors(multi_line_vector_array, multi_line_color_array, -1)
\ No newline at end of file
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd
new file mode 100644
index 00000000..44a6fc1f
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd
@@ -0,0 +1,157 @@
+@tool
+extends Control
+const FlowChartNode = preload("res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd")
+
+var content_lines = Control.new() # Node that hold all flowchart lines
+var content_nodes = Control.new() # Node that hold all flowchart nodes
+
+var _connections = {}
+
+func _init():
+
+ name = "FlowChartLayer"
+ mouse_filter = MOUSE_FILTER_IGNORE
+
+ content_lines.name = "content_lines"
+ content_lines.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(content_lines)
+ move_child(content_lines, 0) # Make sure content_lines always behind nodes
+
+ content_nodes.name = "content_nodes"
+ content_nodes.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(content_nodes)
+
+func hide_content():
+ content_nodes.hide()
+ content_lines.hide()
+
+func show_content():
+ content_nodes.show()
+ content_lines.show()
+
+# Get required scroll rect base on content
+func get_scroll_rect(scroll_margin=0):
+ var rect = Rect2()
+ for child in content_nodes.get_children():
+ # Every child is a state/statemachine node
+ var child_rect = child.get_rect()
+ rect = rect.merge(child_rect)
+ return rect.grow(scroll_margin)
+
+# Add node
+func add_node(node):
+ content_nodes.add_child(node)
+
+# Remove node
+func remove_node(node):
+ if node:
+ content_nodes.remove_child(node)
+
+# Called after connection established
+func _connect_node(connection):
+ content_lines.add_child(connection.line)
+ connection.join()
+
+# Called after connection broken
+func _disconnect_node(connection):
+ content_lines.remove_child(connection.line)
+ return connection.line
+
+# Rename node
+func rename_node(old, new):
+ for from in _connections.keys():
+ if from == old: # Connection from
+ var from_connections = _connections[from]
+ _connections.erase(old)
+ _connections[new] = from_connections
+ else: # Connection to
+ for to in _connections[from].keys():
+ if to == old:
+ var from_connection = _connections[from]
+ var value = from_connection[old]
+ from_connection.erase(old)
+ from_connection[new] = value
+
+# Connect two nodes with a line
+func connect_node(line, from, to, interconnection_offset=0):
+ if from == to:
+ return # Connect to self
+ var connections_from = _connections.get(from)
+ if connections_from:
+ if to in connections_from:
+ return # Connection existed
+ var connection = Connection.new(line, content_nodes.get_node(NodePath(from)), content_nodes.get_node(NodePath(to)))
+ if connections_from == null:
+ connections_from = {}
+ _connections[from] = connections_from
+ connections_from[to] = connection
+ _connect_node(connection)
+
+ # Check if connection in both ways
+ connections_from = _connections.get(to)
+ if connections_from:
+ var inv_connection = connections_from.get(from)
+ if inv_connection:
+ connection.offset = interconnection_offset
+ inv_connection.offset = interconnection_offset
+ connection.join()
+ inv_connection.join()
+
+# Break a connection between two node
+func disconnect_node(from, to):
+ var connections_from = _connections.get(from)
+ var connection = connections_from.get(to)
+ if connection == null:
+ return
+
+ _disconnect_node(connection)
+ if connections_from.size() == 1:
+ _connections.erase(from)
+ else:
+ connections_from.erase(to)
+
+ connections_from = _connections.get(to)
+ if connections_from:
+ var inv_connection = connections_from.get(from)
+ if inv_connection:
+ inv_connection.offset = 0
+ inv_connection.join()
+ return connection.line
+
+# Clear all selection
+func clear_connections():
+ for connections_from in _connections.values():
+ for connection in connections_from.values():
+ connection.line.queue_free()
+ _connections.clear()
+
+# Return array of dictionary of connection as such [{"from1": "to1"}, {"from2": "to2"}]
+func get_connection_list():
+ var connection_list = []
+ for connections_from in _connections.values():
+ for connection in connections_from.values():
+ connection_list.append({"from": connection.from_node.name, "to": connection.to_node.name})
+ return connection_list
+
+class Connection:
+ var line # Control node that draw line
+ var from_node
+ var to_node
+ var offset = 0 # line's y offset to make space for two interconnecting lines
+
+ func _init(p_line, p_from_node, p_to_node):
+ line = p_line
+ from_node = p_from_node
+ to_node = p_to_node
+
+ # Update line position
+ func join():
+ line.join(get_from_pos(), get_to_pos(), offset, [from_node.get_rect() if from_node else Rect2(), to_node.get_rect() if to_node else Rect2()])
+
+ # Return start position of line
+ func get_from_pos():
+ return from_node.position + from_node.size / 2
+
+ # Return destination position of line
+ func get_to_pos():
+ return to_node.position + to_node.size / 2 if to_node else line.position
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd
new file mode 100644
index 00000000..7d77d8c3
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd
@@ -0,0 +1,91 @@
+@tool
+extends Container
+# Custom style normal, focus, arrow
+
+var selected: = false:
+ set = set_selected
+
+
+func _init():
+
+ focus_mode = FOCUS_CLICK
+ mouse_filter = MOUSE_FILTER_IGNORE
+
+func _draw():
+ pivot_at_line_start()
+ var from = Vector2.ZERO
+ from.y += size.y / 2.0
+ var to = size
+ to.y -= size.y / 2.0
+ var arrow = get_theme_icon("arrow", "FlowChartLine")
+ var tint = Color.WHITE
+ if selected:
+ tint = get_theme_stylebox("focus", "FlowChartLine").shadow_color
+ draw_style_box(get_theme_stylebox("focus", "FlowChartLine"), Rect2(Vector2.ZERO, size))
+ else:
+ draw_style_box(get_theme_stylebox("normal", "FlowChartLine"), Rect2(Vector2.ZERO, size))
+
+
+ draw_texture(arrow, Vector2.ZERO - arrow.get_size() / 2 + size / 2, tint)
+
+func _get_minimum_size():
+ return Vector2(0, 5)
+
+func pivot_at_line_start():
+ pivot_offset.x = 0
+ pivot_offset.y = size.y / 2.0
+
+func join(from, to, offset=Vector2.ZERO, clip_rects=[]):
+ # Offset along perpendicular direction
+ var perp_dir = from.direction_to(to).rotated(deg_to_rad(90.0)).normalized()
+ from -= perp_dir * offset
+ to -= perp_dir * offset
+
+ var dist = from.distance_to(to)
+ var dir = from.direction_to(to)
+ var center = from + dir * dist / 2
+
+ # Clip line with provided Rect2 array
+ var clipped = [[from, to]]
+ var line_from = from
+ var line_to = to
+ for clip_rect in clip_rects:
+ if clipped.size() == 0:
+ break
+
+ line_from = clipped[0][0]
+ line_to = clipped[0][1]
+ clipped = Geometry2D.clip_polyline_with_polygon(
+ [line_from, line_to],
+ [clip_rect.position, Vector2(clip_rect.position.x, clip_rect.end.y),
+ clip_rect.end, Vector2(clip_rect.end.x, clip_rect.position.y)]
+ )
+
+ if clipped.size() > 0:
+ from = clipped[0][0]
+ to = clipped[0][1]
+ else: # Line is totally overlapped
+ from = center
+ to = center + dir * 0.1
+
+ # Extends line by 2px to minimise ugly seam
+ from -= dir * 2.0
+ to += dir * 2.0
+
+ size.x = to.distance_to(from)
+ # size.y equals to the thickness of line
+ position = from
+ position.y -= size.y / 2.0
+ rotation = Vector2.RIGHT.angle_to(dir)
+ pivot_at_line_start()
+
+func set_selected(v):
+ if selected != v:
+ selected = v
+ queue_redraw()
+
+func get_from_pos():
+ return get_transform() * (position)
+
+func get_to_pos():
+ return get_transform() * (position + size)
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn
new file mode 100644
index 00000000..285fa143
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn
@@ -0,0 +1,44 @@
+[gd_scene load_steps=7 format=3 uid="uid://creoglbeckyhs"]
+
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd" id="1"]
+
+[sub_resource type="Image" id="Image_jnerc"]
+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, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 85, 85, 85, 6, 65, 65, 68, 94, 66, 66, 66, 93, 71, 71, 71, 18, 255, 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, 128, 128, 128, 2, 66, 64, 67, 193, 65, 64, 66, 255, 65, 64, 66, 255, 66, 65, 67, 243, 67, 64, 67, 99, 255, 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, 69, 64, 69, 44, 65, 64, 66, 255, 142, 141, 143, 255, 187, 187, 188, 255, 93, 92, 93, 254, 65, 64, 66, 255, 66, 65, 68, 184, 73, 64, 73, 28, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 220, 220, 220, 255, 144, 143, 144, 255, 67, 66, 68, 255, 66, 65, 67, 243, 67, 64, 67, 99, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 193, 193, 193, 255, 93, 92, 93, 254, 65, 64, 66, 255, 66, 65, 68, 184, 73, 64, 73, 28, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 220, 220, 220, 255, 144, 143, 144, 255, 67, 66, 68, 255, 66, 65, 67, 243, 67, 64, 67, 99, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 193, 193, 193, 255, 93, 92, 93, 254, 65, 64, 66, 255, 66, 65, 68, 184, 73, 64, 73, 28, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 220, 220, 220, 255, 144, 143, 144, 255, 67, 66, 68, 255, 66, 65, 67, 242, 65, 65, 70, 47, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 193, 193, 193, 255, 79, 78, 80, 253, 67, 66, 69, 189, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 220, 219, 220, 255, 95, 94, 96, 254, 68, 67, 69, 210, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 188, 188, 188, 255, 89, 88, 89, 253, 65, 64, 66, 255, 66, 66, 66, 93, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 220, 219, 220, 255, 137, 136, 138, 255, 67, 66, 68, 255, 67, 66, 68, 239, 66, 64, 66, 88, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 188, 188, 188, 255, 89, 88, 89, 253, 65, 64, 66, 255, 66, 64, 66, 174, 66, 66, 66, 23, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 220, 219, 220, 255, 137, 136, 138, 255, 67, 66, 68, 255, 67, 66, 68, 239, 66, 64, 66, 88, 255, 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, 68, 64, 68, 60, 65, 64, 66, 255, 172, 171, 172, 255, 224, 224, 224, 255, 224, 224, 224, 255, 188, 188, 188, 255, 89, 88, 89, 253, 65, 64, 66, 255, 66, 64, 66, 174, 66, 66, 66, 23, 255, 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, 69, 64, 69, 48, 65, 64, 66, 255, 165, 165, 166, 255, 220, 219, 220, 255, 137, 136, 138, 255, 67, 66, 68, 255, 67, 66, 68, 239, 66, 64, 66, 88, 255, 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, 128, 128, 128, 2, 67, 66, 68, 227, 71, 70, 72, 253, 77, 76, 78, 253, 65, 64, 66, 255, 66, 64, 66, 174, 66, 66, 66, 23, 255, 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, 66, 66, 66, 31, 65, 64, 67, 156, 65, 65, 66, 169, 68, 64, 68, 79, 255, 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, 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": 20,
+"mipmaps": false,
+"width": 20
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_wopk1"]
+image = SubResource("Image_jnerc")
+
+[sub_resource type="StyleBoxFlat" id="3"]
+bg_color = Color(1, 1, 1, 1)
+shadow_color = Color(0.44, 0.73, 0.98, 1)
+shadow_size = 2
+
+[sub_resource type="StyleBoxFlat" id="4"]
+bg_color = Color(1, 1, 1, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(0.2, 0.2, 0.2, 1)
+border_blend = true
+shadow_color = Color(0.2, 0.2, 0.2, 1)
+shadow_size = 1
+
+[sub_resource type="Theme" id="5"]
+FlowChartLine/icons/arrow = SubResource("ImageTexture_wopk1")
+FlowChartLine/styles/focus = SubResource("3")
+FlowChartLine/styles/normal = SubResource("4")
+
+[node name="FlowChartLine" type="Container"]
+offset_bottom = 5.0
+pivot_offset = Vector2(0, 2.5)
+focus_mode = 1
+mouse_filter = 2
+theme = SubResource("5")
+script = ExtResource("1")
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd b/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd
new file mode 100644
index 00000000..55c38b9a
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd
@@ -0,0 +1,33 @@
+@tool
+extends Container
+# Custom style normal, focus
+
+var selected: = false:
+ set = set_selected
+
+
+func _init():
+
+ focus_mode = FOCUS_NONE # Let FlowChart has the focus to handle gui_input
+ mouse_filter = MOUSE_FILTER_PASS
+
+func _draw():
+ if selected:
+ draw_style_box(get_theme_stylebox("focus", "FlowChartNode"), Rect2(Vector2.ZERO, size))
+ else:
+ draw_style_box(get_theme_stylebox("normal", "FlowChartNode"), Rect2(Vector2.ZERO, size))
+
+func _notification(what):
+ match what:
+ NOTIFICATION_SORT_CHILDREN:
+ for child in get_children():
+ if child is Control:
+ fit_child_in_rect(child, Rect2(Vector2.ZERO, size))
+
+func _get_minimum_size():
+ return Vector2(50, 50)
+
+func set_selected(v):
+ if selected != v:
+ selected = v
+ queue_redraw()
diff --git a/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.tscn b/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.tscn
new file mode 100644
index 00000000..052d97e8
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.tscn
@@ -0,0 +1,34 @@
+[gd_scene load_steps=5 format=3 uid="uid://bar1eob74t82f"]
+
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd" id="1"]
+
+[sub_resource type="StyleBoxFlat" id="1"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(0.901961, 0.756863, 0.243137, 1)
+corner_radius_top_left = 4
+corner_radius_top_right = 4
+corner_radius_bottom_right = 4
+corner_radius_bottom_left = 4
+
+[sub_resource type="StyleBoxFlat" id="2"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+corner_radius_top_left = 4
+corner_radius_top_right = 4
+corner_radius_bottom_right = 4
+corner_radius_bottom_left = 4
+
+[sub_resource type="Theme" id="3"]
+FlowChartNode/styles/focus = SubResource("1")
+FlowChartNode/styles/normal = SubResource("2")
+
+[node name="FlowChartNode" type="Container"]
+theme = SubResource("3")
+script = ExtResource("1")
diff --git a/addons/imjp94.yafsm/scenes/state_nodes/StateInspector.gd b/addons/imjp94.yafsm/scenes/state_nodes/StateInspector.gd
new file mode 100644
index 00000000..813128eb
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/state_nodes/StateInspector.gd
@@ -0,0 +1,11 @@
+extends EditorInspectorPlugin
+
+const State = preload("res://addons/imjp94.yafsm/src/states/State.gd")
+
+func _can_handle(object):
+ return object is State
+
+func _parse_property(object, type, path, hint, hint_text, usage, wide) -> bool:
+ return false
+ # Hide all property
+ return true
diff --git a/addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd b/addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd
new file mode 100644
index 00000000..ef5da194
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd
@@ -0,0 +1,82 @@
+@tool
+extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd"
+const State = preload("../../src/states/State.gd")
+const StateMachine = preload("../../src/states/StateMachine.gd")
+
+signal name_edit_entered(new_name) # Emits when focused exit or Enter pressed
+
+@onready var name_edit = $MarginContainer/NameEdit
+
+var undo_redo
+
+var state:
+ set = set_state
+
+
+func _init():
+ super._init()
+
+ set_state(State.new())
+
+func _ready():
+ name_edit.focus_exited.connect(_on_NameEdit_focus_exited)
+ name_edit.text_submitted.connect(_on_NameEdit_text_submitted)
+ set_process_input(false) # _input only required when name_edit enabled to check mouse click outside
+
+func _draw():
+ if state is StateMachine:
+ if selected:
+ draw_style_box(get_theme_stylebox("nested_focus", "StateNode"), Rect2(Vector2.ZERO, size))
+ else:
+ draw_style_box(get_theme_stylebox("nested_normal", "StateNode"), Rect2(Vector2.ZERO, size))
+ else:
+ super._draw()
+
+func _input(event):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ # Detect click outside rect
+ if get_viewport().gui_get_focus_owner() == name_edit:
+ var local_event = make_input_local(event)
+ if not name_edit.get_rect().has_point(local_event.position):
+ name_edit.release_focus()
+
+func enable_name_edit(v):
+ if v:
+ set_process_input(true)
+ name_edit.editable = true
+ name_edit.selecting_enabled = true
+ name_edit.mouse_filter = MOUSE_FILTER_PASS
+ mouse_default_cursor_shape = CURSOR_IBEAM
+ name_edit.grab_focus()
+ else:
+ set_process_input(false)
+ name_edit.editable = false
+ name_edit.selecting_enabled = false
+ name_edit.mouse_filter = MOUSE_FILTER_IGNORE
+ mouse_default_cursor_shape = CURSOR_ARROW
+ name_edit.release_focus()
+
+func _on_state_name_changed(new_name):
+ name_edit.text = new_name
+ size.x = 0 # Force reset horizontal size
+
+func _on_state_changed(new_state):
+ if state:
+ state.name_changed.connect(_on_state_name_changed)
+ if name_edit:
+ name_edit.text = state.name
+
+func _on_NameEdit_focus_exited():
+ enable_name_edit(false)
+ name_edit.deselect()
+ emit_signal("name_edit_entered", name_edit.text)
+
+func _on_NameEdit_text_submitted(new_text):
+ enable_name_edit(false)
+ emit_signal("name_edit_entered", new_text)
+
+func set_state(s):
+ if state != s:
+ state = s
+ _on_state_changed(s)
diff --git a/addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn b/addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn
new file mode 100644
index 00000000..7a05cb5b
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn
@@ -0,0 +1,79 @@
+[gd_scene load_steps=8 format=3 uid="uid://l3mqbqjwjkc3"]
+
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd" id="2"]
+[ext_resource type="SystemFont" uid="uid://dmcxm8gxsonbq" path="res://addons/imjp94.yafsm/assets/fonts/sans_serif.tres" id="2_352m3"]
+
+[sub_resource type="StyleBoxFlat" id="1"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(0.44, 0.73, 0.98, 1)
+corner_radius_top_left = 4
+corner_radius_top_right = 4
+corner_radius_bottom_right = 4
+corner_radius_bottom_left = 4
+
+[sub_resource type="StyleBoxFlat" id="2"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+corner_radius_top_left = 4
+corner_radius_top_right = 4
+corner_radius_bottom_right = 4
+corner_radius_bottom_left = 4
+corner_detail = 2
+
+[sub_resource type="StyleBoxFlat" id="3"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.960784, 0.772549, 0.333333, 1)
+shadow_size = 2
+
+[sub_resource type="StyleBoxFlat" id="4"]
+bg_color = Color(0.164706, 0.164706, 0.164706, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+shadow_size = 2
+
+[sub_resource type="Theme" id="5"]
+FlowChartNode/styles/focus = SubResource("1")
+FlowChartNode/styles/normal = SubResource("2")
+StateNode/styles/nested_focus = SubResource("3")
+StateNode/styles/nested_normal = SubResource("4")
+
+[node name="StateNode" type="HBoxContainer"]
+grow_horizontal = 2
+grow_vertical = 2
+theme = SubResource("5")
+script = ExtResource("2")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 5
+theme_override_constants/margin_top = 5
+theme_override_constants/margin_right = 5
+theme_override_constants/margin_bottom = 5
+
+[node name="NameEdit" type="LineEdit" parent="MarginContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+mouse_filter = 2
+mouse_default_cursor_shape = 0
+theme_override_fonts/font = ExtResource("2_352m3")
+text = "State"
+alignment = 1
+editable = false
+expand_to_text_length = true
+selecting_enabled = false
+caret_blink = true
diff --git a/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd b/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd
new file mode 100644
index 00000000..bef007e0
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd
@@ -0,0 +1,185 @@
+@tool
+extends VBoxContainer
+const Utils = preload("../../scripts/Utils.gd")
+const ConditionEditor = preload("../condition_editors/ConditionEditor.tscn")
+const BoolConditionEditor = preload("../condition_editors/BoolConditionEditor.tscn")
+const IntegerConditionEditor = preload("../condition_editors/IntegerConditionEditor.tscn")
+const FloatConditionEditor = preload("../condition_editors/FloatConditionEditor.tscn")
+const StringConditionEditor = preload("../condition_editors/StringConditionEditor.tscn")
+
+@onready var header = $HeaderContainer/Header
+@onready var title = $HeaderContainer/Header/Title
+@onready var title_icon = $HeaderContainer/Header/Title/Icon
+@onready var from = $HeaderContainer/Header/Title/From
+@onready var to = $HeaderContainer/Header/Title/To
+@onready var condition_count_icon = $HeaderContainer/Header/ConditionCount/Icon
+@onready var condition_count_label = $HeaderContainer/Header/ConditionCount/Label
+@onready var priority_icon = $HeaderContainer/Header/Priority/Icon
+@onready var priority_spinbox = $HeaderContainer/Header/Priority/SpinBox
+@onready var add = $HeaderContainer/Header/HBoxContainer/Add
+@onready var add_popup_menu = $HeaderContainer/Header/HBoxContainer/Add/PopupMenu
+@onready var content_container = $MarginContainer
+@onready var condition_list = $MarginContainer/Conditions
+
+var undo_redo
+
+var transition:
+ set = set_transition
+
+var _to_free
+
+
+func _init():
+ _to_free = []
+
+func _ready():
+ header.gui_input.connect(_on_header_gui_input)
+ priority_spinbox.value_changed.connect(_on_priority_spinbox_value_changed)
+ add.pressed.connect(_on_add_pressed)
+ add_popup_menu.index_pressed.connect(_on_add_popup_menu_index_pressed)
+
+ condition_count_icon.texture = get_theme_icon("MirrorX", "EditorIcons")
+ priority_icon.texture = get_theme_icon("AnimationTrackGroup", "EditorIcons")
+
+func _exit_tree():
+ free_node_from_undo_redo() # Managed by EditorInspector
+
+func _on_header_gui_input(event):
+ if event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
+ toggle_conditions()
+
+func _on_priority_spinbox_value_changed(val: int) -> void:
+ set_priority(val)
+
+func _on_add_pressed():
+ Utils.popup_on_target(add_popup_menu, add)
+
+func _on_add_popup_menu_index_pressed(index):
+ ## Handle condition name duplication (4.x changed how duplicates are
+ ## automatically handled and gave a random index instead of a progressive one)
+ var default_new_condition_name = "Param"
+ var condition_dup_index = 0
+ var new_name = default_new_condition_name
+ for condition_editor in condition_list.get_children():
+ var condition_name = condition_editor.condition.name
+ if (condition_name == new_name):
+ condition_dup_index += 1
+ new_name = "%s%s" % [default_new_condition_name, condition_dup_index]
+ var condition
+ match index:
+ 0: # Trigger
+ condition = Condition.new(new_name)
+ 1: # Boolean
+ condition = BooleanCondition.new(new_name)
+ 2: # Integer
+ condition = IntegerCondition.new(new_name)
+ 3: # Float
+ condition = FloatCondition.new(new_name)
+ 4: # String
+ condition = StringCondition.new(new_name)
+ _:
+ push_error("Unexpected index(%d) from PopupMenu" % index)
+ var editor = create_condition_editor(condition)
+ add_condition_editor_action(editor, condition)
+
+func _on_ConditionEditorRemove_pressed(editor):
+ remove_condition_editor_action(editor)
+
+func _on_transition_changed(new_transition):
+ if not new_transition:
+ return
+
+ for condition in transition.conditions.values():
+ var editor = create_condition_editor(condition)
+ add_condition_editor(editor, condition)
+ update_title()
+ update_condition_count()
+ update_priority_spinbox_value()
+
+func _on_condition_editor_added(editor):
+ editor.undo_redo = undo_redo
+ if not editor.remove.pressed.is_connected(_on_ConditionEditorRemove_pressed):
+ editor.remove.pressed.connect(_on_ConditionEditorRemove_pressed.bind(editor))
+ transition.add_condition(editor.condition)
+ update_condition_count()
+
+func add_condition_editor(editor, condition):
+ condition_list.add_child(editor)
+ editor.condition = condition # Must be assigned after enter tree, as assignment would trigger ui code
+ _on_condition_editor_added(editor)
+
+func remove_condition_editor(editor):
+ transition.remove_condition(editor.condition.name)
+ condition_list.remove_child(editor)
+ _to_free.append(editor) # Freeing immediately after removal will break undo/redo
+ update_condition_count()
+
+func update_title():
+ from.text = transition.from
+ to.text = transition.to
+
+func update_condition_count():
+ var count = transition.conditions.size()
+ condition_count_label.text = str(count)
+ if count == 0:
+ hide_conditions()
+ else:
+ show_conditions()
+
+func update_priority_spinbox_value():
+ priority_spinbox.value = transition.priority
+ priority_spinbox.apply()
+
+func set_priority(value):
+ transition.priority = value
+
+func show_conditions():
+ content_container.visible = true
+
+func hide_conditions():
+ content_container.visible = false
+
+func toggle_conditions():
+ content_container.visible = !content_container.visible
+
+func create_condition_editor(condition):
+ var editor
+ if condition is BooleanCondition:
+ editor = BoolConditionEditor.instantiate()
+ elif condition is IntegerCondition:
+ editor = IntegerConditionEditor.instantiate()
+ elif condition is FloatCondition:
+ editor = FloatConditionEditor.instantiate()
+ elif condition is StringCondition:
+ editor = StringConditionEditor.instantiate()
+ else:
+ editor = ConditionEditor.instantiate()
+ return editor
+
+func add_condition_editor_action(editor, condition):
+ undo_redo.create_action("Add Transition Condition")
+ undo_redo.add_do_method(self, "add_condition_editor", editor, condition)
+ undo_redo.add_undo_method(self, "remove_condition_editor", editor)
+ undo_redo.commit_action()
+
+func remove_condition_editor_action(editor):
+ undo_redo.create_action("Remove Transition Condition")
+ undo_redo.add_do_method(self, "remove_condition_editor", editor)
+ undo_redo.add_undo_method(self, "add_condition_editor", editor, editor.condition)
+ undo_redo.commit_action()
+
+func set_transition(t):
+ if transition != t:
+ transition = t
+ _on_transition_changed(t)
+
+# Free nodes cached in UndoRedo stack
+func free_node_from_undo_redo():
+ for node in _to_free:
+ if is_instance_valid(node):
+ var history_id = undo_redo.get_object_history_id(node)
+ undo_redo.get_history_undo_redo(history_id).clear_history(false) # TODO: Should be handled by plugin.gd (Temporary solution as only TransitionEditor support undo/redo)
+ node.queue_free()
+
+ _to_free.clear()
diff --git a/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn b/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn
new file mode 100644
index 00000000..e516cebc
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn
@@ -0,0 +1,133 @@
+[gd_scene load_steps=7 format=3 uid="uid://dw0ecw2wdeosi"]
+
+[ext_resource type="Texture2D" uid="uid://dg8cmn5ubq6r5" path="res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd" id="3"]
+
+[sub_resource type="Gradient" id="Gradient_hw7k8"]
+offsets = PackedFloat32Array(1)
+colors = PackedColorArray(1, 1, 1, 1)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_ipxab"]
+gradient = SubResource("Gradient_hw7k8")
+width = 18
+height = 18
+
+[sub_resource type="Image" id="Image_o35y7"]
+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_v636r"]
+image = SubResource("Image_o35y7")
+
+[node name="TransitionEditor" type="VBoxContainer"]
+script = ExtResource("3")
+
+[node name="HeaderContainer" type="MarginContainer" parent="."]
+layout_mode = 2
+
+[node name="Panel" type="Panel" parent="HeaderContainer"]
+layout_mode = 2
+
+[node name="Header" type="HBoxContainer" parent="HeaderContainer"]
+layout_mode = 2
+
+[node name="Title" type="HBoxContainer" parent="HeaderContainer/Header"]
+layout_mode = 2
+tooltip_text = "Next State"
+
+[node name="From" type="Label" parent="HeaderContainer/Header/Title"]
+layout_mode = 2
+size_flags_horizontal = 3
+text = "From"
+
+[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/Title"]
+texture_filter = 1
+layout_mode = 2
+texture = SubResource("GradientTexture2D_ipxab")
+expand_mode = 3
+stretch_mode = 3
+
+[node name="To" type="Label" parent="HeaderContainer/Header/Title"]
+layout_mode = 2
+size_flags_horizontal = 3
+text = "To"
+
+[node name="VSeparator" type="VSeparator" parent="HeaderContainer/Header"]
+layout_mode = 2
+
+[node name="ConditionCount" type="HBoxContainer" parent="HeaderContainer/Header"]
+layout_mode = 2
+tooltip_text = "Number of Conditions"
+
+[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/ConditionCount"]
+texture_filter = 1
+layout_mode = 2
+texture = SubResource("ImageTexture_v636r")
+expand_mode = 3
+stretch_mode = 3
+
+[node name="Label" type="Label" parent="HeaderContainer/Header/ConditionCount"]
+layout_mode = 2
+text = "No."
+
+[node name="VSeparator2" type="VSeparator" parent="HeaderContainer/Header"]
+layout_mode = 2
+
+[node name="Priority" type="HBoxContainer" parent="HeaderContainer/Header"]
+layout_mode = 2
+tooltip_text = "Priority"
+
+[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/Priority"]
+texture_filter = 1
+layout_mode = 2
+texture = SubResource("ImageTexture_v636r")
+expand_mode = 3
+stretch_mode = 3
+
+[node name="SpinBox" type="SpinBox" parent="HeaderContainer/Header/Priority"]
+layout_mode = 2
+max_value = 10.0
+rounded = true
+allow_greater = true
+
+[node name="VSeparator3" type="VSeparator" parent="HeaderContainer/Header"]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="HeaderContainer/Header"]
+layout_mode = 2
+size_flags_horizontal = 10
+
+[node name="Add" type="Button" parent="HeaderContainer/Header/HBoxContainer"]
+layout_mode = 2
+tooltip_text = "Add Condition"
+icon = ExtResource("1")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="HeaderContainer/Header/HBoxContainer/Add"]
+item_count = 5
+item_0/text = "Trigger"
+item_0/id = 0
+item_1/text = "Boolean"
+item_1/id = 1
+item_2/text = "Integer"
+item_2/id = 2
+item_3/text = "Float"
+item_3/id = 3
+item_4/text = "String"
+item_4/id = 4
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 2
+
+[node name="Panel" type="Panel" parent="MarginContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Conditions" type="VBoxContainer" parent="MarginContainer"]
+layout_mode = 2
diff --git a/addons/imjp94.yafsm/scenes/transition_editors/TransitionInspector.gd b/addons/imjp94.yafsm/scenes/transition_editors/TransitionInspector.gd
new file mode 100644
index 00000000..5f5fe7f9
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/transition_editors/TransitionInspector.gd
@@ -0,0 +1,33 @@
+@tool
+extends EditorInspectorPlugin
+const Transition = preload("res://addons/imjp94.yafsm/src/transitions/Transition.gd")
+
+const TransitionEditor = preload("res://addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn")
+
+var undo_redo
+
+var transition_icon
+
+func _can_handle(object):
+ return object is Transition
+
+func _parse_property(object, type, path, hint, hint_text, usage, wide) -> bool:
+ match path:
+ "from":
+ return true
+ "to":
+ return true
+ "conditions":
+ var transition_editor = TransitionEditor.instantiate() # Will be freed by editor
+ transition_editor.undo_redo = undo_redo
+ add_custom_control(transition_editor)
+ transition_editor.ready.connect(_on_transition_editor_tree_entered.bind(transition_editor, object))
+ return true
+ "priority":
+ return true
+ return false
+
+func _on_transition_editor_tree_entered(editor, transition):
+ editor.transition = transition
+ if transition_icon:
+ editor.title_icon.texture = transition_icon
diff --git a/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd b/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd
new file mode 100644
index 00000000..07bd8855
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd
@@ -0,0 +1,112 @@
+@tool
+extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd"
+const Transition = preload("../../src/transitions/Transition.gd")
+const ValueCondition = preload("../../src/conditions/ValueCondition.gd")
+
+const hi_res_font: Font = preload("res://addons/imjp94.yafsm/assets/fonts/sans_serif.tres")
+
+@export var upright_angle_range: = 5.0
+
+@onready var label_margin = $MarginContainer
+@onready var vbox = $MarginContainer/VBoxContainer
+
+var undo_redo
+
+var transition:
+ set = set_transition
+var template = "{condition_name} {condition_comparation} {condition_value}"
+
+var _template_var = {}
+
+func _init():
+ super._init()
+
+ set_transition(Transition.new())
+
+func _draw():
+ super._draw()
+
+ var abs_rotation = abs(rotation)
+ var is_flip = abs_rotation > deg_to_rad(90.0)
+ var is_upright = (abs_rotation > (deg_to_rad(90.0) - deg_to_rad(upright_angle_range))) and (abs_rotation < (deg_to_rad(90.0) + deg_to_rad(upright_angle_range)))
+
+ if is_upright:
+ var x_offset = label_margin.size.x / 2
+ var y_offset = -label_margin.size.y
+ label_margin.position = Vector2((size.x - x_offset) / 2, 0)
+ else:
+ var x_offset = label_margin.size.x
+ var y_offset = -label_margin.size.y
+ if is_flip:
+ label_margin.rotation = deg_to_rad(180)
+ label_margin.position = Vector2((size.x + x_offset) / 2, 0)
+ else:
+ label_margin.rotation = deg_to_rad(0)
+ label_margin.position = Vector2((size.x - x_offset) / 2, y_offset)
+
+# Update overlay text
+func update_label():
+ if transition:
+ var template_var = {"condition_name": "", "condition_comparation": "", "condition_value": null}
+ for label in vbox.get_children():
+ if not (str(label.name) in transition.conditions.keys()): # Names of nodes are now of type StringName, not simple strings!
+ vbox.remove_child(label)
+ label.queue_free()
+ for condition in transition.conditions.values():
+ var label = vbox.get_node_or_null(NodePath(condition.name))
+ if not label:
+ label = Label.new()
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ label.add_theme_font_override("font", hi_res_font)
+ label.name = condition.name
+ vbox.add_child(label)
+ if "value" in condition:
+ template_var["condition_name"] = condition.name
+ template_var["condition_comparation"] = ValueCondition.COMPARATION_SYMBOLS[condition.comparation]
+ template_var["condition_value"] = condition.get_value_string()
+ label.text = template.format(template_var)
+ var override_template_var = _template_var.get(condition.name)
+ if override_template_var:
+ label.text = label.text.format(override_template_var)
+ else:
+ label.text = condition.name
+ queue_redraw()
+
+func _on_transition_changed(new_transition):
+ if not is_inside_tree():
+ return
+
+ if new_transition:
+ new_transition.condition_added.connect(_on_transition_condition_added)
+ new_transition.condition_removed.connect(_on_transition_condition_removed)
+ for condition in new_transition.conditions.values():
+ condition.name_changed.connect(_on_condition_name_changed)
+ condition.display_string_changed.connect(_on_condition_display_string_changed)
+ update_label()
+
+func _on_transition_condition_added(condition):
+ condition.name_changed.connect(_on_condition_name_changed)
+ condition.display_string_changed.connect(_on_condition_display_string_changed)
+ update_label()
+
+func _on_transition_condition_removed(condition):
+ condition.name_changed.disconnect(_on_condition_name_changed)
+ condition.display_string_changed.disconnect(_on_condition_display_string_changed)
+ update_label()
+
+func _on_condition_name_changed(from, to):
+ var label = vbox.get_node_or_null(NodePath(from))
+ if label:
+ label.name = to
+ update_label()
+
+func _on_condition_display_string_changed(display_string):
+ update_label()
+
+func set_transition(t):
+ if transition != t:
+ if transition:
+ if transition.condition_added.is_connected(_on_transition_condition_added):
+ transition.condition_added.disconnect(_on_transition_condition_added)
+ transition = t
+ _on_transition_changed(transition)
diff --git a/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.tscn b/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.tscn
new file mode 100644
index 00000000..3ab3b2fb
--- /dev/null
+++ b/addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.tscn
@@ -0,0 +1,26 @@
+[gd_scene load_steps=4 format=3 uid="uid://cwb2nrjai7fao"]
+
+[ext_resource type="PackedScene" uid="uid://creoglbeckyhs" path="res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn" id="1"]
+[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd" id="2"]
+[ext_resource type="SystemFont" uid="uid://dmcxm8gxsonbq" path="res://addons/imjp94.yafsm/assets/fonts/sans_serif.tres" id="3_y6xyv"]
+
+[node name="TransitionLine" instance=ExtResource("1")]
+script = ExtResource("2")
+upright_angle_range = 0.0
+
+[node name="MarginContainer" type="MarginContainer" parent="." index="0"]
+layout_mode = 2
+mouse_filter = 2
+
+[node name="Label" type="Label" parent="MarginContainer" index="0"]
+visible = false
+layout_mode = 2
+size_flags_horizontal = 6
+size_flags_vertical = 6
+theme_override_fonts/font = ExtResource("3_y6xyv")
+text = "Transition"
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" index="1"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
diff --git a/addons/imjp94.yafsm/scripts/Utils.gd b/addons/imjp94.yafsm/scripts/Utils.gd
new file mode 100644
index 00000000..16c8ee3d
--- /dev/null
+++ b/addons/imjp94.yafsm/scripts/Utils.gd
@@ -0,0 +1,103 @@
+# Position Popup near to its target while within window, solution from ColorPickerButton source code(https://github.com/godotengine/godot/blob/6d8c14f849376905e1577f9fc3f9512bcffb1e3c/scene/gui/color_picker.cpp#L878)
+static func popup_on_target(popup: Popup, target: Control):
+ popup.reset_size()
+ var usable_rect = Rect2(Vector2.ZERO, DisplayServer.window_get_size_with_decorations())
+ var cp_rect = Rect2(Vector2.ZERO, popup.get_size())
+ for i in 4:
+ if i > 1:
+ cp_rect.position.y = target.global_position.y - cp_rect.size.y
+ else:
+ cp_rect.position.y = target.global_position.y + target.get_size().y
+
+ if i & 1:
+ cp_rect.position.x = target.global_position.x
+ else:
+ cp_rect.position.x = target.global_position.x - max(0, cp_rect.size.x - target.get_size().x)
+
+ if usable_rect.encloses(cp_rect):
+ break
+ var main_window_position = DisplayServer.window_get_position()
+ var popup_position = main_window_position + Vector2i(cp_rect.position) # make it work in multi-screen setups
+ popup.set_position(popup_position)
+ popup.popup()
+
+static func get_complementary_color(color):
+ var r = max(color.r, max(color.b, color.g)) + min(color.r, min(color.b, color.g)) - color.r
+ var g = max(color.r, max(color.b, color.g)) + min(color.r, min(color.b, color.g)) - color.g
+ var b = max(color.r, max(color.b, color.g)) + min(color.r, min(color.b, color.g)) - color.b
+ return Color(r, g, b)
+
+class CohenSutherland:
+ const INSIDE = 0 # 0000
+ const LEFT = 1 # 0001
+ const RIGHT = 2 # 0010
+ const BOTTOM = 4 # 0100
+ const TOP = 8 # 1000
+
+ # Compute bit code for a point(x, y) using the clip
+ static func compute_code(x, y, x_min, y_min, x_max, y_max):
+ var code = INSIDE # initialised as being inside of clip window
+ if x < x_min: # to the left of clip window
+ code |= LEFT
+ elif x > x_max: # to the right of clip window
+ code |= RIGHT
+
+ if y < y_min: # below the clip window
+ code |= BOTTOM
+ elif y > y_max: # above the clip window
+ code |= TOP
+
+ return code
+
+ # Cohen-Sutherland clipping algorithm clips a line from
+ # P0 = (x0, y0) to P1 = (x1, y1) against a rectangle with
+ # diagonal from start(x_min, y_min) to end(x_max, y_max)
+ static func line_intersect_rectangle(from, to, rect):
+ var x_min = rect.position.x
+ var y_min = rect.position.y
+ var x_max = rect.end.x
+ var y_max = rect.end.y
+
+ var code0 = compute_code(from.x, from.y, x_min, y_min, x_max, y_max)
+ var code1 = compute_code(to.x, to.y, x_min, y_min, x_max, y_max)
+
+ var i = 0
+ while true:
+ i += 1
+ if !(code0 | code1): # bitwise OR 0, both points inside window
+ return true
+ elif code0 & code1: # Bitwise AND not 0, both points share an outside zone
+ return false
+ else:
+ # Failed both test, so calculate line segment to clip
+ # from outside point to intersection with clip edge
+ var x
+ var y
+ var code_out = max(code0, code1) # Pick the one outside window
+
+ # Find intersection points
+ # slope = (y1 - y0) / (x1 - x0)
+ # x = x0 + (1 / slope) * (ym - y0), where ym is y_mix/y_max
+ # y = y0 + slope * (xm - x0), where xm is x_min/x_max
+ if code_out & TOP: # Point above clip window
+ x = from.x + (to.x - from.x) * (y_max - from.y) / (to.y - from.y)
+ y = y_max
+ elif code_out & BOTTOM: # Point below clip window
+ x = from.x + (to.x - from.x) * (y_min - from.y) / (to.y - from.y)
+ y = y_min
+ elif code_out & RIGHT: # Point is to the right of clip window
+ y = from.y + (to.y - from.y) * (x_max - from.x) / (to.x - from.x)
+ x = x_max
+ elif code_out & LEFT: # Point is to the left of clip window
+ y = from.y + (to.y - from.y) * (x_min - from.x) / (to.x - from.x)
+ x = x_min
+
+ # Now move outside point to intersection point to clip and ready for next pass
+ if code_out == code0:
+ from.x = x
+ from.y = y
+ code0 = compute_code(from.x, from.y, x_min, y_min, x_max, y_max)
+ else:
+ to.x = x
+ to.y = y
+ code1 = compute_code(to.x ,to.y, x_min, y_min, x_max, y_max)
diff --git a/addons/imjp94.yafsm/src/StackPlayer.gd b/addons/imjp94.yafsm/src/StackPlayer.gd
new file mode 100644
index 00000000..31bd9d48
--- /dev/null
+++ b/addons/imjp94.yafsm/src/StackPlayer.gd
@@ -0,0 +1,88 @@
+@tool
+class_name StackPlayer extends Node
+
+signal pushed(to) # When item pushed to stack
+signal popped(from) # When item popped from stack
+
+# Enum to specify how reseting state stack should trigger event(transit, push, pop etc.)
+enum ResetEventTrigger {
+ NONE = -1, # No event
+ ALL = 0, # All removed state will emit event
+ LAST_TO_DEST = 1 # Only last state and destination will emit event
+}
+
+var current: # Current item on top of stack
+ get = get_current
+var stack:
+ set = _set_stack,
+ get = _get_stack
+
+var _stack
+
+
+func _init():
+ _stack = []
+
+# Push an item to the top of stack
+func push(to):
+ var from = get_current()
+ _stack.push_back(to)
+ _on_pushed(from, to)
+ emit_signal("pushed", to)
+
+# Remove the current item on top of stack
+func pop():
+ var to = get_previous()
+ var from = _stack.pop_back()
+ _on_popped(from, to)
+ emit_signal("popped", from)
+
+# Called when item pushed
+func _on_pushed(from, to):
+ pass
+
+# Called when item popped
+func _on_popped(from, to):
+ pass
+
+# Reset stack to given index, -1 to clear all item by default
+# Use ResetEventTrigger to define how _on_popped should be called
+func reset(to=-1, event=ResetEventTrigger.ALL):
+ assert(to > -2 and to < _stack.size(), "Reset to index out of bounds")
+ var last_index = _stack.size() - 1
+ var first_state = ""
+ var num_to_pop = last_index - to
+
+ if num_to_pop > 0:
+ for i in range(num_to_pop):
+ first_state = get_current() if i == 0 else first_state
+ match event:
+ ResetEventTrigger.LAST_TO_DEST:
+ _stack.pop_back()
+ if i == num_to_pop - 1:
+ _stack.push_back(first_state)
+ pop()
+ ResetEventTrigger.ALL:
+ pop()
+ _:
+ _stack.pop_back()
+ elif num_to_pop == 0:
+ match event:
+ ResetEventTrigger.NONE:
+ _stack.pop_back()
+ _:
+ pop()
+
+func _set_stack(val):
+ push_warning("Attempting to edit read-only state stack directly. " \
+ + "Control state machine from setting parameters or call update() instead")
+
+# Get duplicate of the stack being played
+func _get_stack():
+ return _stack.duplicate()
+
+func get_current():
+ return _stack.back() if not _stack.is_empty() else null
+
+func get_previous():
+ return _stack[_stack.size() - 2] if _stack.size() > 1 else null
diff --git a/addons/imjp94.yafsm/src/StateDirectory.gd b/addons/imjp94.yafsm/src/StateDirectory.gd
new file mode 100644
index 00000000..2a829533
--- /dev/null
+++ b/addons/imjp94.yafsm/src/StateDirectory.gd
@@ -0,0 +1,94 @@
+@tool
+extends RefCounted
+
+const State = preload("states/State.gd")
+
+var path
+var current:
+ get = get_current
+var base:
+ get = get_base
+var end:
+ get = get_end
+
+var _current_index = 0
+var _dirs = [""] # Empty string equals to root
+
+
+func _init(p):
+ path = p
+ _dirs += Array(p.split("/"))
+
+# Move to next level and return state if exists, else null
+func next():
+ if has_next():
+ _current_index += 1
+ return get_current_end()
+
+ return null
+
+# Move to previous level and return state if exists, else null
+func back():
+ if has_back():
+ _current_index -= 1
+ return get_current_end()
+
+ return null
+
+# Move to specified index and return state
+func goto(index):
+ assert(index > -1 and index < _dirs.size())
+ _current_index = index
+ return get_current_end()
+
+# Check if directory has next level
+func has_next():
+ return _current_index < _dirs.size() - 1
+
+# Check if directory has previous level
+func has_back():
+ return _current_index > 0
+
+# Get current full path
+func get_current():
+ # In Godot 4.x the end parameter of Array.slice() is EXCLUSIVE!
+ # https://docs.godotengine.org/en/latest/classes/class_array.html#class-array-method-slice
+ var packed_string_array: PackedStringArray = PackedStringArray(_dirs.slice(get_base_index(), _current_index+1))
+ return "/".join(packed_string_array)
+
+# Get current end state name of path
+func get_current_end():
+ var current_path = get_current()
+ return current_path.right(current_path.length()-1 - current_path.rfind("/"))
+
+# Get index of base state
+func get_base_index():
+ return 1 # Root(empty string) at index 0, base at index 1
+
+# Get level index of end state
+func get_end_index():
+ return _dirs.size() - 1
+
+# Get base state name
+func get_base():
+ return _dirs[get_base_index()]
+
+# Get end state name
+func get_end():
+ return _dirs[get_end_index()]
+
+# Get arrays of directories
+func get_dirs():
+ return _dirs.duplicate()
+
+# Check if it is Entry state
+func is_entry():
+ return get_end() == State.ENTRY_STATE
+
+# Check if it is Exit state
+func is_exit():
+ return get_end() == State.EXIT_STATE
+
+# Check if it is nested. ("Base" is not nested, "Base/NextState" is nested)
+func is_nested():
+ return _dirs.size() > 2 # Root(empty string) & base taken 2 place
diff --git a/addons/imjp94.yafsm/src/StateMachinePlayer.gd b/addons/imjp94.yafsm/src/StateMachinePlayer.gd
new file mode 100644
index 00000000..a295052b
--- /dev/null
+++ b/addons/imjp94.yafsm/src/StateMachinePlayer.gd
@@ -0,0 +1,378 @@
+@tool
+class_name StateMachinePlayer extends StackPlayer
+
+
+signal transited(from, to) # Transition of state
+signal entered(to) # Entry of state machine(including nested), empty string equals to root
+signal exited(from) # Exit of state machine(including nested, empty string equals to root
+signal updated(state, delta) # Time to update(based on process_mode), up to user to handle any logic, for example, update movement of KinematicBody
+
+# Enum to define how state machine should be updated
+enum UpdateProcessMode {
+ PHYSICS,
+ IDLE,
+ MANUAL
+}
+
+@export var state_machine: StateMachine # StateMachine being played
+@export var active: = true: # Activeness of player
+ set = set_active
+@export var autostart: = true # Automatically enter Entry state on ready if true
+@export var update_process_mode: UpdateProcessMode = UpdateProcessMode.IDLE: # ProcessMode of player
+ set = set_update_process_mode
+
+var _is_started = false
+var _parameters # Parameters to be passed to condition
+var _local_parameters
+var _is_update_locked = true
+var _was_transited = false # If last transition was successful
+var _is_param_edited = false
+
+
+func _init():
+ super._init()
+
+ if Engine.is_editor_hint():
+ return
+
+ _parameters = {}
+ _local_parameters = {}
+ _was_transited = true # Trigger _transit on _ready
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var _errors: Array[String] = []
+
+ if state_machine:
+ if not state_machine.has_entry():
+ _errors.append("The StateMachine provided does not have an Entry node.\nPlease create one to it works properly.")
+ else:
+ _errors.append("StateMachinePlayer needs a StateMachine to run.\nPlease create a StateMachine resource to it.")
+
+ return PackedStringArray(_errors)
+
+func _ready():
+ if Engine.is_editor_hint():
+ return
+
+ set_process(false)
+ set_physics_process(false)
+ call_deferred("_initiate") # Make sure connection of signals can be done in _ready to receive all signal callback
+
+func _initiate():
+ if autostart:
+ start()
+ _on_active_changed()
+ _on_update_process_mode_changed()
+
+func _process(delta):
+ if Engine.is_editor_hint():
+ return
+
+ _update_start()
+ update(delta)
+ _update_end()
+
+func _physics_process(delta):
+ if Engine.is_editor_hint():
+ return
+
+ _update_start()
+ update(delta)
+ _update_end()
+
+# Only get called in 2 condition, _parameters edited or last transition was successful
+func _transit():
+ if not active:
+ return
+ # Attempt to transit if parameter edited or last transition was successful
+ if not _is_param_edited and not _was_transited:
+ return
+
+ var from = get_current()
+ var local_params = _local_parameters.get(path_backward(from), {})
+ var next_state = state_machine.transit(get_current(), _parameters, local_params)
+ if next_state:
+ if stack.has(next_state):
+ reset(stack.find(next_state))
+ else:
+ push(next_state)
+ var to = next_state
+ _was_transited = next_state != null and next_state != ""
+ _is_param_edited = false
+ _flush_trigger(_parameters)
+ _flush_trigger(_local_parameters, true)
+
+ if _was_transited:
+ _on_state_changed(from, to)
+
+func _on_state_changed(from, to):
+ match to:
+ State.ENTRY_STATE:
+ emit_signal("entered", "")
+ State.EXIT_STATE:
+ set_active(false) # Disable on exit
+ emit_signal("exited", "")
+
+ if to.ends_with(State.ENTRY_STATE) and to.length() > State.ENTRY_STATE.length():
+ # Nexted Entry state
+ var state = path_backward(get_current())
+ emit_signal("entered", state)
+ elif to.ends_with(State.EXIT_STATE) and to.length() > State.EXIT_STATE.length():
+ # Nested Exit state, clear "local" params
+ var state = path_backward(get_current())
+ clear_param(state, false) # Clearing params internally, do not update
+ emit_signal("exited", state)
+
+ emit_signal("transited", from, to)
+
+# Called internally if process_mode is PHYSICS/IDLE to unlock update()
+func _update_start():
+ _is_update_locked = false
+
+# Called internally if process_mode is PHYSICS/IDLE to lock update() from external call
+func _update_end():
+ _is_update_locked = true
+
+# Called after update() which is dependant on process_mode, override to process current state
+func _on_updated(state, delta):
+ pass
+
+func _on_update_process_mode_changed():
+ if not active:
+ return
+
+ match update_process_mode:
+ UpdateProcessMode.PHYSICS:
+ set_physics_process(true)
+ set_process(false)
+ UpdateProcessMode.IDLE:
+ set_physics_process(false)
+ set_process(true)
+ UpdateProcessMode.MANUAL:
+ set_physics_process(false)
+ set_process(false)
+
+func _on_active_changed():
+ if Engine.is_editor_hint():
+ return
+
+ if active:
+ _on_update_process_mode_changed()
+ _transit()
+ else:
+ set_physics_process(false)
+ set_process(false)
+
+# Remove all trigger(param with null value) from provided params, only get called after _transit
+# Trigger another call of _flush_trigger on first layer of dictionary if nested is true
+func _flush_trigger(params, nested=false):
+ for param_key in params.keys():
+ var value = params[param_key]
+ if nested and value is Dictionary:
+ _flush_trigger(value)
+ if value == null: # Param with null as value is treated as trigger
+ params.erase(param_key)
+
+func reset(to=-1, event=ResetEventTrigger.LAST_TO_DEST):
+ super.reset(to, event)
+ _was_transited = true # Make sure to call _transit on next update
+
+# Manually start the player, automatically called if autostart is true
+func start():
+ assert(state_machine != null, "A StateMachine resource is required to start this StateMachinePlayer.")
+ assert(state_machine.has_entry(), "The StateMachine provided does not have an Entry node.")
+ push(State.ENTRY_STATE)
+ emit_signal("entered", "")
+ _was_transited = true
+ _is_started = true
+
+# Restart player
+func restart(is_active=true, preserve_params=false):
+ reset()
+ set_active(is_active)
+ if not preserve_params:
+ clear_param("", false)
+ start()
+
+# Update player to, first initiate transition, then call _on_updated, finally emit "update" signal, delta will be given based on process_mode.
+# Can only be called manually if process_mode is MANUAL, otherwise, assertion error will be raised.
+# *delta provided will be reflected in signal updated(state, delta)
+func update(delta=get_physics_process_delta_time()):
+ if not active:
+ return
+ if update_process_mode != UpdateProcessMode.MANUAL:
+ assert(not _is_update_locked, "Attempting to update manually with ProcessMode %s" % UpdateProcessMode.keys()[update_process_mode])
+
+ _transit()
+ var current_state = get_current()
+ _on_updated(current_state, delta)
+ emit_signal("updated", current_state, delta)
+ if update_process_mode == UpdateProcessMode.MANUAL:
+ # Make sure to auto advance even in MANUAL mode
+ if _was_transited:
+ call_deferred("update")
+
+# Set trigger to be tested with condition, then trigger _transit on next update,
+# automatically call update() if process_mode set to MANUAL and auto_update true
+# Nested trigger can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func set_trigger(name, auto_update=true):
+ set_param(name, null, auto_update)
+
+func set_nested_trigger(path, name, auto_update=true):
+ set_nested_param(path, name, null, auto_update)
+
+# Set param(null value treated as trigger) to be tested with condition, then trigger _transit on next update,
+# automatically call update() if process_mode set to MANUAL and auto_update true
+# Nested param can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func set_param(name, value, auto_update=true):
+ var path = ""
+ if "/" in name:
+ path = path_backward(name)
+ name = path_end_dir(name)
+ set_nested_param(path, name, value, auto_update)
+
+func set_nested_param(path, name, value, auto_update=true):
+ if path.is_empty():
+ _parameters[name] = value
+ else:
+ var local_params = _local_parameters.get(path)
+ if local_params is Dictionary:
+ local_params[name] = value
+ else:
+ local_params = {}
+ local_params[name] = value
+ _local_parameters[path] = local_params
+ _on_param_edited(auto_update)
+
+# Remove param, then trigger _transit on next update,
+# automatically call update() if process_mode set to MANUAL and auto_update true
+# Nested param can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func erase_param(name, auto_update=true):
+ var path = ""
+ if "/" in name:
+ path = path_backward(name)
+ name = path_end_dir(name)
+ return erase_nested_param(path, name, auto_update)
+
+func erase_nested_param(path, name, auto_update=true):
+ var result = false
+ if path.is_empty():
+ result = _parameters.erase(name)
+ else:
+ result = _local_parameters.get(path, {}).erase(name)
+ _on_param_edited(auto_update)
+ return result
+
+# Clear params from specified path, empty string to clear all, then trigger _transit on next update,
+# automatically call update() if process_mode set to MANUAL and auto_update true
+# Nested param can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func clear_param(path="", auto_update=true):
+ if path.is_empty():
+ _parameters.clear()
+ else:
+ _local_parameters.get(path, {}).clear()
+ # Clear nested params
+ for param_key in _local_parameters.keys():
+ if param_key.begins_with(path):
+ _local_parameters.erase(param_key)
+
+# Called when param edited, automatically call update() if process_mode set to MANUAL and auto_update true
+func _on_param_edited(auto_update=true):
+ _is_param_edited = true
+ if update_process_mode == UpdateProcessMode.MANUAL and auto_update and _is_started:
+ update()
+
+# Get value of param
+# Nested param can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func get_param(name, default=null):
+ var path = ""
+ if "/" in name:
+ path = path_backward(name)
+ name = path_end_dir(name)
+ return get_nested_param(path, name, default)
+
+func get_nested_param(path, name, default=null):
+ if path.is_empty():
+ return _parameters.get(name, default)
+ else:
+ var local_params = _local_parameters.get(path, {})
+ return local_params.get(name, default)
+
+# Get duplicate of whole parameter dictionary
+func get_params():
+ return _parameters.duplicate()
+
+# Return true if param exists
+# Nested param can be accessed through path "path/to/param_name", for example, "App/Game/is_playing"
+func has_param(name):
+ var path = ""
+ if "/" in name:
+ path = path_backward(name)
+ name = path_end_dir(name)
+ return has_nested_param(path, name)
+
+func has_nested_param(path, name):
+ if path.is_empty():
+ return name in _parameters
+ else:
+ var local_params = _local_parameters.get(path, {})
+ return name in local_params
+
+# Return if player started
+func is_entered():
+ return State.ENTRY_STATE in stack
+
+# Return if player ended
+func is_exited():
+ return get_current() == State.EXIT_STATE
+
+func set_active(v):
+ if active != v:
+ if v:
+ if is_exited():
+ push_warning("Attempting to make exited StateMachinePlayer active, call reset() then set_active() instead")
+ return
+ active = v
+ _on_active_changed()
+
+func set_update_process_mode(mode):
+ if update_process_mode != mode:
+ update_process_mode = mode
+ _on_update_process_mode_changed()
+
+func get_current():
+ var v = super.get_current()
+ return v if v else ""
+
+func get_previous():
+ var v = super.get_previous()
+ return v if v else ""
+
+# Convert node path to state path that can be used to query state with StateMachine.get_state.
+# Node path, "root/path/to/state", equals to State path, "path/to/state"
+static func node_path_to_state_path(node_path):
+ var p = node_path.replace("root", "")
+ if p.begins_with("/"):
+ p = p.substr(1)
+ return p
+
+# Convert state path to node path that can be used for query node in scene tree.
+# State path, "path/to/state", equals to Node path, "root/path/to/state"
+static func state_path_to_node_path(state_path):
+ var path = state_path
+ if path.is_empty():
+ path = "root"
+ else:
+ path = str("root/", path)
+ return path
+
+# Return parent path, "path/to/state" return "path/to"
+static func path_backward(path):
+ return path.substr(0, path.rfind("/"))
+
+# Return end directory of path, "path/to/state" returns "state"
+static func path_end_dir(path):
+ # In Godot 4.x the old behaviour of String.right() can be achieved with
+ # a negative length. Check the docs:
+ # https://docs.godotengine.org/en/stable/classes/class_string.html#class-string-method-right
+ return path.right(path.length()-1 - path.rfind("/"))
diff --git a/addons/imjp94.yafsm/src/conditions/BooleanCondition.gd b/addons/imjp94.yafsm/src/conditions/BooleanCondition.gd
new file mode 100644
index 00000000..7c9786d3
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/BooleanCondition.gd
@@ -0,0 +1,22 @@
+@tool
+extends ValueCondition
+class_name BooleanCondition
+
+@export var value: bool:
+ set = set_value,
+ get = get_value
+
+
+func set_value(v):
+ if value != v:
+ value = v
+ emit_signal("value_changed", v)
+ emit_signal("display_string_changed", display_string())
+
+func get_value():
+ return value
+
+func compare(v):
+ if typeof(v) != TYPE_BOOL:
+ return false
+ return super.compare(v)
diff --git a/addons/imjp94.yafsm/src/conditions/Condition.gd b/addons/imjp94.yafsm/src/conditions/Condition.gd
new file mode 100644
index 00000000..6d895bfd
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/Condition.gd
@@ -0,0 +1,23 @@
+@tool
+extends Resource
+class_name Condition
+
+signal name_changed(old, new)
+signal display_string_changed(new)
+
+@export var name: = "": # Name of condition, unique to Transition
+ set = set_name
+
+
+func _init(p_name=""):
+ name = p_name
+
+func set_name(n):
+ if name != n:
+ var old = name
+ name = n
+ emit_signal("name_changed", old, n)
+ emit_signal("display_string_changed", display_string())
+
+func display_string():
+ return name
diff --git a/addons/imjp94.yafsm/src/conditions/FloatCondition.gd b/addons/imjp94.yafsm/src/conditions/FloatCondition.gd
new file mode 100644
index 00000000..989f8167
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/FloatCondition.gd
@@ -0,0 +1,25 @@
+@tool
+extends ValueCondition
+class_name FloatCondition
+
+@export var value: float:
+ set = set_value,
+ get = get_value
+
+
+func set_value(v):
+ if not is_equal_approx(value, v):
+ value = v
+ emit_signal("value_changed", v)
+ emit_signal("display_string_changed", display_string())
+
+func get_value():
+ return value
+
+func get_value_string():
+ return str(snapped(value, 0.01)).pad_decimals(2)
+
+func compare(v):
+ if typeof(v) != TYPE_FLOAT:
+ return false
+ return super.compare(v)
diff --git a/addons/imjp94.yafsm/src/conditions/IntegerCondition.gd b/addons/imjp94.yafsm/src/conditions/IntegerCondition.gd
new file mode 100644
index 00000000..2020d9b7
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/IntegerCondition.gd
@@ -0,0 +1,23 @@
+@tool
+extends ValueCondition
+class_name IntegerCondition
+
+
+@export var value: int:
+ set = set_value,
+ get = get_value
+
+
+func set_value(v):
+ if value != v:
+ value = v
+ emit_signal("value_changed", v)
+ emit_signal("display_string_changed", display_string())
+
+func get_value():
+ return value
+
+func compare(v):
+ if typeof(v) != TYPE_INT:
+ return false
+ return super.compare(v)
diff --git a/addons/imjp94.yafsm/src/conditions/StringCondition.gd b/addons/imjp94.yafsm/src/conditions/StringCondition.gd
new file mode 100644
index 00000000..4fd198fd
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/StringCondition.gd
@@ -0,0 +1,25 @@
+@tool
+extends ValueCondition
+class_name StringCondition
+
+@export var value: String:
+ set = set_value,
+ get = get_value
+
+
+func set_value(v):
+ if value != v:
+ value = v
+ emit_signal("value_changed", v)
+ emit_signal("display_string_changed", display_string())
+
+func get_value():
+ return value
+
+func get_value_string():
+ return "\"%s\"" % value
+
+func compare(v):
+ if typeof(v) != TYPE_STRING:
+ return false
+ return super.compare(v)
diff --git a/addons/imjp94.yafsm/src/conditions/ValueCondition.gd b/addons/imjp94.yafsm/src/conditions/ValueCondition.gd
new file mode 100644
index 00000000..8eb7dcbf
--- /dev/null
+++ b/addons/imjp94.yafsm/src/conditions/ValueCondition.gd
@@ -0,0 +1,73 @@
+@tool
+extends Condition
+class_name ValueCondition
+
+signal comparation_changed(new_comparation) # Comparation hanged
+signal value_changed(new_value) # Value changed
+
+# Enum to define how to compare value
+enum Comparation {
+ EQUAL,
+ INEQUAL,
+ GREATER,
+ LESSER,
+ GREATER_OR_EQUAL,
+ LESSER_OR_EQUAL
+}
+# Comparation symbols arranged in order as enum Comparation
+const COMPARATION_SYMBOLS = [
+ "==",
+ "!=",
+ ">",
+ "<",
+ "≥",
+ "≤"
+]
+
+@export var comparation: Comparation = Comparation.EQUAL:
+ set = set_comparation
+
+func _init(p_name="", p_comparation=Comparation.EQUAL):
+ super._init(p_name)
+ comparation = p_comparation
+
+func set_comparation(c):
+ if comparation != c:
+ comparation = c
+ emit_signal("comparation_changed", c)
+ emit_signal("display_string_changed", display_string())
+
+# To be overrided by child class and emit value_changed signal
+func set_value(v):
+ pass
+
+# To be overrided by child class, as it is impossible to export(Variant)
+func get_value():
+ pass
+
+# To be used in _to_string()
+func get_value_string():
+ return get_value()
+
+# Compare value against this condition, return true if succeeded
+func compare(v):
+ if v == null:
+ return false
+
+ match comparation:
+ Comparation.EQUAL:
+ return v == get_value()
+ Comparation.INEQUAL:
+ return v != get_value()
+ Comparation.GREATER:
+ return v > get_value()
+ Comparation.LESSER:
+ return v < get_value()
+ Comparation.GREATER_OR_EQUAL:
+ return v >= get_value()
+ Comparation.LESSER_OR_EQUAL:
+ return v <= get_value()
+
+# Return human readable display string, for example, "condition_name == True"
+func display_string():
+ return "%s %s %s" % [super.display_string(), COMPARATION_SYMBOLS[comparation], get_value_string()]
diff --git a/addons/imjp94.yafsm/src/debugger/StackItem.tscn b/addons/imjp94.yafsm/src/debugger/StackItem.tscn
new file mode 100644
index 00000000..61339f77
--- /dev/null
+++ b/addons/imjp94.yafsm/src/debugger/StackItem.tscn
@@ -0,0 +1,11 @@
+[gd_scene format=3 uid="uid://b3b5ivtjmka6b"]
+
+[node name="StackItem" type="PanelContainer"]
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Label" type="Label" parent="."]
+offset_right = 36.0
+offset_bottom = 26.0
+text = "Item"
diff --git a/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd b/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd
new file mode 100644
index 00000000..db2d5f3d
--- /dev/null
+++ b/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd
@@ -0,0 +1,50 @@
+@tool
+extends Control
+const StackPlayer = preload("../StackPlayer.gd")
+const StackItem = preload("StackItem.tscn")
+
+@onready var Stack = $MarginContainer/Stack
+
+
+func _get_configuration_warning():
+ if not (get_parent() is StackPlayer):
+ return "Debugger must be child of StackPlayer"
+ return ""
+
+func _ready():
+ if Engine.is_editor_hint():
+ return
+
+ get_parent().pushed.connect(_on_StackPlayer_pushed)
+ get_parent().popped.connect(_on_StackPlayer_popped)
+ sync_stack()
+
+# Override to handle custom object presentation
+func _on_set_label(label, obj):
+ label.text = obj
+
+func _on_StackPlayer_pushed(to):
+ var stack_item = StackItem.instantiate()
+ _on_set_label(stack_item.get_node("Label"), to)
+ Stack.add_child(stack_item)
+ Stack.move_child(stack_item, 0)
+
+func _on_StackPlayer_popped(from):
+ # Sync whole stack instead of just popping top item, as ResetEventTrigger passed to reset() may be varied
+ sync_stack()
+
+func sync_stack():
+ var diff = Stack.get_child_count() - get_parent().stack.size()
+ for i in abs(diff):
+ if diff < 0:
+ var stack_item = StackItem.instantiate()
+ Stack.add_child(stack_item)
+ else:
+ var child = Stack.get_child(0)
+ Stack.remove_child(child)
+ child.queue_free()
+ var stack = get_parent().stack
+ for i in stack.size():
+ var obj = stack[stack.size()-1 - i] # Descending order, to list from bottom to top in VBoxContainer
+ var child = Stack.get_child(i)
+ _on_set_label(child.get_node("Label"), obj)
diff --git a/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.tscn b/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.tscn
new file mode 100644
index 00000000..34600965
--- /dev/null
+++ b/addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.tscn
@@ -0,0 +1,27 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd" type="Script" id=1]
+
+[node name="StackPlayerDebugger" type="Control"]
+modulate = Color( 1, 1, 1, 0.498039 )
+anchor_right = 1.0
+anchor_bottom = 1.0
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Stack" type="VBoxContainer" parent="MarginContainer"]
+margin_top = 600.0
+margin_bottom = 600.0
+mouse_filter = 2
+size_flags_horizontal = 0
+size_flags_vertical = 8
diff --git a/addons/imjp94.yafsm/src/states/State.gd b/addons/imjp94.yafsm/src/states/State.gd
new file mode 100644
index 00000000..252fd2f1
--- /dev/null
+++ b/addons/imjp94.yafsm/src/states/State.gd
@@ -0,0 +1,39 @@
+@tool
+extends Resource
+class_name State
+
+signal name_changed(new_name)
+
+# Reserved state name for Entry/Exit
+const ENTRY_STATE = "Entry"
+const EXIT_STATE = "Exit"
+
+const META_GRAPH_OFFSET = "graph_offset" # Meta key for graph_offset
+
+@export var name: = "": # Name of state, unique within StateMachine
+ set = set_name
+
+var graph_offset: # Position in FlowChart stored as meta, for editor only
+ set = set_graph_offset,
+ get = get_graph_offset
+
+
+func _init(p_name=""):
+ name = p_name
+
+func is_entry():
+ return name == ENTRY_STATE
+
+func is_exit():
+ return name == EXIT_STATE
+
+func set_graph_offset(offset):
+ set_meta(META_GRAPH_OFFSET, offset)
+
+func get_graph_offset():
+ return get_meta(META_GRAPH_OFFSET) if has_meta(META_GRAPH_OFFSET) else Vector2.ZERO
+
+func set_name(n):
+ if name != n:
+ name = n
+ emit_signal("name_changed", name)
diff --git a/addons/imjp94.yafsm/src/states/StateMachine.gd b/addons/imjp94.yafsm/src/states/StateMachine.gd
new file mode 100644
index 00000000..d0a5f5dc
--- /dev/null
+++ b/addons/imjp94.yafsm/src/states/StateMachine.gd
@@ -0,0 +1,230 @@
+@tool
+@icon("../../assets/icons/state_machine_icon.png")
+extends State
+class_name StateMachine
+
+signal transition_added(transition) # Transition added
+signal transition_removed(to_state) # Transition removed
+
+@export var states: Dictionary: # States within this StateMachine, keyed by State.name
+ get = get_states,
+ set = set_states
+@export var transitions: Dictionary: # Transitions from this state, keyed by Transition.to
+ get = get_transitions,
+ set = set_transitions
+
+var _states
+var _transitions
+
+
+func _init(p_name="", p_transitions={}, p_states={}):
+ super._init(p_name)
+ _transitions = p_transitions
+ _states = p_states
+
+# Attempt to transit with global/local parameters, where local_params override params
+func transit(current_state, params={}, local_params={}):
+ var nested_states = current_state.split("/")
+ var is_nested = nested_states.size() > 1
+ var end_state_machine = self
+ var base_path = ""
+ for i in nested_states.size() - 1: # Ignore last one, to get its parent StateMachine
+ var state = nested_states[i]
+ # Construct absolute base path
+ base_path = join_path(base_path, [state])
+ if end_state_machine != self:
+ end_state_machine = end_state_machine.states[state]
+ else:
+ end_state_machine = _states[state] # First level state
+
+ # Nested StateMachine in Exit state
+ if is_nested:
+ var is_nested_exit = nested_states[nested_states.size()-1] == State.EXIT_STATE
+ if is_nested_exit:
+ # Normalize path to transit again with parent of end_state_machine
+ var end_state_machine_parent_path = ""
+ for i in nested_states.size() - 2: # Ignore last two state(which is end_state_machine/end_state)
+ end_state_machine_parent_path = join_path(end_state_machine_parent_path, [nested_states[i]])
+ var end_state_machine_parent = get_state(end_state_machine_parent_path)
+ var normalized_current_state = end_state_machine.name
+ var next_state = end_state_machine_parent.transit(normalized_current_state, params)
+ if next_state:
+ # Construct next state into absolute path
+ next_state = join_path(end_state_machine_parent_path, [next_state])
+ return next_state
+
+ # Transit with current running nested state machine
+ var from_transitions = end_state_machine.transitions.get(nested_states[nested_states.size()-1])
+ if from_transitions:
+ var from_transitions_array = from_transitions.values()
+ from_transitions_array.sort_custom(func(a, b): Transition.sort(a, b))
+
+ for transition in from_transitions_array:
+ var next_state = transition.transit(params, local_params)
+ if next_state:
+ if "states" in end_state_machine.states[next_state]:
+ # Next state is a StateMachine, return entry state of the state machine in absolute path
+ next_state = join_path(base_path, [next_state, State.ENTRY_STATE])
+ else:
+ # Construct next state into absolute path
+ next_state = join_path(base_path, [next_state])
+ return next_state
+ return null
+
+# Get state from absolute path, for exmaple, "path/to/state" (root == empty string)
+# *It is impossible to get parent state machine with path like "../sibling", as StateMachine is not structed as a Tree
+func get_state(path):
+ var state
+ if path.is_empty():
+ state = self
+ else:
+ var nested_states = path.split("/")
+ for i in nested_states.size():
+ var dir = nested_states[i]
+ if state:
+ state = state.states[dir]
+ else:
+ state = _states[dir] # First level state
+ return state
+
+# Add state, state name must be unique within this StateMachine, return state added if succeed else return null
+func add_state(state):
+ if not state:
+ return null
+ if state.name in _states:
+ return null
+
+ _states[state.name] = state
+ return state
+
+# Remove state by its name
+func remove_state(state):
+ return _states.erase(state)
+
+# Change existing state key in states(Dictionary), return true if success
+func change_state_name(from, to):
+ if not (from in _states) or to in _states:
+ return false
+
+ for state_key in _states.keys():
+ var state = _states[state_key]
+ var is_name_changing_state = state_key == from
+ if is_name_changing_state:
+ state.name = to
+ _states[to] = state
+ _states.erase(from)
+ for from_key in _transitions.keys():
+ var from_transitions = _transitions[from_key]
+ if from_key == from:
+ _transitions.erase(from)
+ _transitions[to] = from_transitions
+ for to_key in from_transitions.keys():
+ var transition = from_transitions[to_key]
+ if transition.from == from:
+ transition.from = to
+ elif transition.to == from:
+ transition.to = to
+ if not is_name_changing_state:
+ # Transitions to name changed state needs to be updated
+ from_transitions.erase(from)
+ from_transitions[to] = transition
+ return true
+
+# Add transition, Transition.from must be equal to this state's name and Transition.to not added yet
+func add_transition(transition):
+ if transition.from == "" or transition.to == "":
+ push_warning("Transition missing from/to (%s/%s)" % [transition.from, transition.to])
+ return
+
+ var from_transitions
+ if transition.from in _transitions:
+ from_transitions = _transitions[transition.from]
+ else:
+ from_transitions = {}
+ _transitions[transition.from] = from_transitions
+
+ from_transitions[transition.to] = transition
+ emit_signal("transition_added", transition)
+
+# Remove transition with Transition.to(name of state transiting to)
+func remove_transition(from_state, to_state):
+ var from_transitions = _transitions.get(from_state)
+ if from_transitions:
+ if to_state in from_transitions:
+ from_transitions.erase(to_state)
+ if from_transitions.is_empty():
+ _transitions.erase(from_state)
+ emit_signal("transition_removed", from_state, to_state)
+
+func get_entries():
+ return _transitions[State.ENTRY_STATE].values()
+
+func get_exits():
+ return _transitions[State.EXIT_STATE].values()
+
+func has_entry():
+ return State.ENTRY_STATE in _states
+
+func has_exit():
+ return State.EXIT_STATE in _states
+
+# Get duplicate of states dictionary
+func get_states():
+ return _states.duplicate()
+
+func set_states(val):
+ _states = val
+
+# Get duplicate of transitions dictionary
+func get_transitions():
+ return _transitions.duplicate()
+
+func set_transitions(val):
+ _transitions = val
+
+static func join_path(base, dirs):
+ var path = base
+ for dir in dirs:
+ if path.is_empty():
+ path = dir
+ else:
+ path = str(path, "/", dir)
+ return path
+
+# Validate state machine resource to identify and fix error
+static func validate(state_machine):
+ var validated = false
+ for from_key in state_machine.transitions.keys():
+ # Non-existing state found in StateMachine.transitions
+ # See https://github.com/imjp94/gd-YAFSM/issues/6
+ if not (from_key in state_machine.states):
+ validated = true
+ push_warning("gd-YAFSM ValidationError: Non-existing state(%s) found in transition" % from_key)
+ state_machine.transitions.erase(from_key)
+ continue
+
+ var from_transition = state_machine.transitions[from_key]
+ for to_key in from_transition.keys():
+ # Non-existing state found in StateMachine.transitions
+ # See https://github.com/imjp94/gd-YAFSM/issues/6
+ if not (to_key in state_machine.states):
+ validated = true
+ push_warning("gd-YAFSM ValidationError: Non-existing state(%s) found in transition(%s -> %s)" % [to_key, from_key, to_key])
+ from_transition.erase(to_key)
+ continue
+
+ # Mismatch of StateMachine.transitions with Transition.to
+ # See https://github.com/imjp94/gd-YAFSM/issues/6
+ var to_transition = from_transition[to_key]
+ if to_key != to_transition.to:
+ validated = true
+ push_warning("gd-YAFSM ValidationError: Mismatch of StateMachine.transitions key(%s) with Transition.to(%s)" % [to_key, to_transition.to])
+ to_transition.to = to_key
+
+ # Self connecting transition
+ # See https://github.com/imjp94/gd-YAFSM/issues/5
+ if to_transition.from == to_transition.to:
+ validated = true
+ push_warning("gd-YAFSM ValidationError: Self connecting transition(%s -> %s)" % [to_transition.from, to_transition.to])
+ from_transition.erase(to_key)
+ return validated
diff --git a/addons/imjp94.yafsm/src/transitions/Transition.gd b/addons/imjp94.yafsm/src/transitions/Transition.gd
new file mode 100644
index 00000000..22321c80
--- /dev/null
+++ b/addons/imjp94.yafsm/src/transitions/Transition.gd
@@ -0,0 +1,98 @@
+@tool
+extends Resource
+class_name Transition
+
+signal condition_added(condition)
+signal condition_removed(condition)
+
+@export var from: String # Name of state transiting from
+@export var to: String # Name of state transiting to
+@export var conditions: Dictionary: # Conditions to transit successfuly, keyed by Condition.name
+ set = set_conditions,
+ get = get_conditions
+@export var priority: = 0 # Higher the number, higher the priority
+
+var _conditions
+
+
+func _init(p_from="", p_to="", p_conditions={}):
+ from = p_from
+ to = p_to
+ _conditions = p_conditions
+
+# Attempt to transit with parameters given, return name of next state if succeeded else null
+func transit(params={}, local_params={}):
+ var can_transit = _conditions.size() > 0
+ for condition in _conditions.values():
+ var has_param = params.has(condition.name)
+ var has_local_param = local_params.has(condition.name)
+ if has_param or has_local_param:
+ # local_params > params
+ var value = local_params.get(condition.name) if has_local_param else params.get(condition.name)
+ if value == null: # null value is treated as trigger
+ can_transit = can_transit and true
+ else:
+ if "value" in condition:
+ can_transit = can_transit and condition.compare(value)
+ else:
+ can_transit = false
+ if can_transit or _conditions.size() == 0:
+ return to
+ return null
+
+# Add condition, return true if succeeded
+func add_condition(condition):
+ if condition.name in _conditions:
+ return false
+
+ _conditions[condition.name] = condition
+ emit_signal("condition_added", condition)
+ return true
+
+# Remove condition by name of condition
+func remove_condition(name):
+ var condition = _conditions.get(name)
+ if condition:
+ _conditions.erase(name)
+ emit_signal("condition_removed", condition)
+ return true
+ return false
+
+# Change condition name, return true if succeeded
+func change_condition_name(from, to):
+ if not (from in _conditions) or to in _conditions:
+ return false
+
+ var condition = _conditions[from]
+ condition.name = to
+ _conditions.erase(from)
+ _conditions[to] = condition
+ return true
+
+func get_unique_name(name):
+ var new_name = name
+ var i = 1
+ while new_name in _conditions:
+ new_name = name + str(i)
+ i += 1
+ return new_name
+
+func equals(obj):
+ if obj == null:
+ return false
+ if not ("from" in obj and "to" in obj):
+ return false
+
+ return from == obj.from and to == obj.to
+
+# Get duplicate of conditions dictionary
+func get_conditions():
+ return _conditions.duplicate()
+
+func set_conditions(val):
+ _conditions = val
+
+static func sort(a, b):
+ if a.priority > b.priority:
+ return true
+ return false
diff --git a/addons/loggie/assets/icon.png b/addons/loggie/assets/icon.png
new file mode 100644
index 00000000..59c10f99
Binary files /dev/null and b/addons/loggie/assets/icon.png differ
diff --git a/addons/loggie/assets/icon.png.import b/addons/loggie/assets/icon.png.import
new file mode 100644
index 00000000..c12b639f
--- /dev/null
+++ b/addons/loggie/assets/icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://2fr6et0qni2y"
+path="res://.godot/imported/icon.png-57313fc4d67f18c33a83a3388ad36531.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/loggie/assets/icon.png"
+dest_files=["res://.godot/imported/icon.png-57313fc4d67f18c33a83a3388ad36531.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/addons/loggie/assets/logo.png b/addons/loggie/assets/logo.png
new file mode 100644
index 00000000..c7d6cde3
Binary files /dev/null and b/addons/loggie/assets/logo.png differ
diff --git a/addons/loggie/assets/logo.png.import b/addons/loggie/assets/logo.png.import
new file mode 100644
index 00000000..b2cdd951
--- /dev/null
+++ b/addons/loggie/assets/logo.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://duuj0ibyreygv"
+path="res://.godot/imported/logo.png-2771abf7e361d7f10d5859133bc43562.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/loggie/assets/logo.png"
+dest_files=["res://.godot/imported/logo.png-2771abf7e361d7f10d5859133bc43562.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/addons/loggie/custom_settings.gd.example b/addons/loggie/custom_settings.gd.example
new file mode 100644
index 00000000..843ba1a7
--- /dev/null
+++ b/addons/loggie/custom_settings.gd.example
@@ -0,0 +1,53 @@
+class_name CustomLoggieSettings extends LoggieSettings
+
+func load():
+ # Omit settings from here to have them use their default value instead.
+ # Otherwise, directly set the value of the setting to your liking.
+ # Any variable in [LoggieSettings] is a valid target to alter.
+ # You could also have them loaded here in some custom way, for example, from a .json or .ini file.
+ # See the documentation of the [LoggieSettings] class to see all available options and their descriptions.
+
+ self.terminal_mode = LoggieEnums.TerminalMode.BBCODE
+ self.log_level = LoggieEnums.LogLevel.INFO
+ self.show_loggie_specs = LoggieEnums.ShowLoggieSpecsMode.ESSENTIAL
+ self.show_system_specs = true
+ self.enforce_optimal_settings_in_release_build = true
+
+ self.output_message_domain = true
+ self.print_errors_to_console = true
+ self.print_warnings_to_console = true
+ self.use_print_debug_for_debug_msg = true
+ self.derive_and_show_class_names = true
+ self.nameless_class_name_proxy = LoggieEnums.NamelessClassExtensionNameProxy.BASE_TYPE
+ self.output_timestamps = true
+ self.timestamps_use_utc = true
+
+ self.format_timestamp = "[{day}.{month}.{year} {hour}:{minute}:{second}]"
+ self.format_info_msg = "{msg}"
+ self.format_notice_msg = "[b][color=cyan][NOTICE]:[/color][/b] {msg}"
+ self.format_warning_msg = "[b][color=orange][WARN]:[/color][/b] {msg}"
+ self.format_error_msg = "[b][color=red][ERROR]:[/color][/b] {msg}"
+ self.format_debug_msg = "[b][color=pink][DEBUG]:[/color][/b] {msg}"
+ self.format_header = "[b][i]{msg}[/i][/b]"
+ self.format_domain_prefix = "[b]({domain})[/b] {msg}"
+
+ self.h_separator_symbol = "-"
+ self.box_characters_mode = LoggieEnums.BoxCharactersMode.COMPATIBLE
+
+ self.box_symbols_compatible = {
+ "top_left" : "-",
+ "top_right" : "-",
+ "bottom_left" : "-",
+ "bottom_right" : "-",
+ "h_line" : "-",
+ "v_line" : ":",
+ }
+
+ self.box_symbols_pretty = {
+ "top_left" : "┌",
+ "top_right" : "┐",
+ "bottom_left" : "└",
+ "bottom_right" : "┘",
+ "h_line" : "─",
+ "v_line" : "│",
+ }
diff --git a/addons/loggie/loggie.gd b/addons/loggie/loggie.gd
new file mode 100644
index 00000000..18f991aa
--- /dev/null
+++ b/addons/loggie/loggie.gd
@@ -0,0 +1,167 @@
+@tool
+
+## Loggie is a basic logging utility for those who need common minor improvements and helpers around the basic [method print], [method print_rich]
+## and other default Godot printing functions. Loggie creates instances of [LoggieMsg], which are a wrapper around a string that needs to manipulated,
+## then uses them to properly format, arrange and present them in the console and .log files. Loggie uses the default Godot logging mechanism under the hood.
+extends Node
+
+## Stores a string describing the current version of Loggie.
+const VERSION : String = "v1.5"
+
+## Emitted any time Loggie attempts to log a message.
+## Useful for capturing the messages that pass through Loggie.
+## [br][param msg] is the message Loggie attempted to log (before any preprocessing).
+## [br][param preprocessed_content] is what the string content of that message contained after the preprocessing step,
+## which is what ultimately gets logged.
+## [br][param result] describes the final result of the attempt to log that message.
+signal log_attempted(msg : LoggieMsg, preprocessed_content : String, result : LoggieEnums.LogAttemptResult)
+
+## A reference to the settings of this Loggie. Read more about [LoggieSettings].
+var settings : LoggieSettings
+
+## Holds a mapping between all registered domains (string keys) and bool values representing whether
+## those domains are currently enabled. Enable domains with [method set_domain_enabled].
+## You can then place [LoggieMsg] messages into a domain by calling [method LoggieMsg.domain].
+## Messages belonging to a disabled domain will never be outputted.
+var domains : Dictionary = {}
+
+## Holds a mapping between script paths and the names of the classes defined in those scripts.
+var class_names : Dictionary = {}
+
+func _init() -> void:
+ var uses_original_settings_file = true
+ var default_settings_path = get_script().get_path().get_base_dir().path_join("loggie_settings.gd")
+ var custom_settings_path = get_script().get_path().get_base_dir().path_join("custom_settings.gd")
+
+ if self.settings == null:
+ if custom_settings_path != null and custom_settings_path != "" and ResourceLoader.exists(custom_settings_path):
+ var loaded_successfully = load_settings_from_path(custom_settings_path)
+ if loaded_successfully:
+ uses_original_settings_file = false
+
+ if uses_original_settings_file:
+ var _settings = ResourceLoader.load(default_settings_path)
+ if _settings != null:
+ self.settings = _settings.new()
+ self.settings.load()
+ else:
+ push_error("Loggie loaded neither a custom nor a default settings file. This will break the plugin. Make sure that a valid loggie_settings.gd is in the same directory where loggie.gd is.")
+ return
+
+ if self.settings.enforce_optimal_settings_in_release_build == true and is_in_production():
+ self.settings.terminal_mode = LoggieEnums.TerminalMode.PLAIN
+ self.settings.box_characters_mode = LoggieEnums.BoxCharactersMode.COMPATIBLE
+
+ # Already cache the name of the singleton found at loggie's script path.
+ class_names[self.get_script().resource_path] = LoggieSettings.loggie_singleton_name
+
+ # Prepopulate class data from ProjectSettings to avoid needing to read files.
+ if self.settings.derive_and_show_class_names == true and OS.has_feature("debug"):
+ for class_data: Dictionary in ProjectSettings.get_global_class_list():
+ class_names[class_data.path] = class_data.class
+
+ for autoload_setting: String in ProjectSettings.get_property_list().map(func(prop): return prop.name).filter(func(prop): return prop.begins_with("autoload/") and ProjectSettings.has_setting(prop)):
+ var autoload_class: String = autoload_setting.trim_prefix("autoload/")
+ var class_path: String = ProjectSettings.get_setting(autoload_setting)
+ class_path = class_path.trim_prefix("*")
+ if not class_names.has(class_path):
+ class_names[class_path] = autoload_class
+
+ # Don't print Loggie boot messages if Loggie is running only from the editor.
+ if Engine.is_editor_hint():
+ return
+
+ if self.settings.show_loggie_specs != LoggieEnums.ShowLoggieSpecsMode.DISABLED:
+ msg("👀 Loggie {version} booted.".format({"version" : self.VERSION})).color(Color.ORANGE).header().nl().info()
+ var loggie_specs_msg = LoggieSystemSpecsMsg.new().use_logger(self)
+ loggie_specs_msg.add(msg("|\t Using Custom Settings File: ").bold(), !uses_original_settings_file).nl().add("|\t ").hseparator(35).nl()
+
+ match self.settings.show_loggie_specs:
+ LoggieEnums.ShowLoggieSpecsMode.ESSENTIAL:
+ loggie_specs_msg.embed_essential_logger_specs()
+ LoggieEnums.ShowLoggieSpecsMode.ADVANCED:
+ loggie_specs_msg.embed_advanced_logger_specs()
+
+ loggie_specs_msg.preprocessed(false).info()
+
+ if self.settings.show_system_specs:
+ var system_specs_msg = LoggieSystemSpecsMsg.new().use_logger(self)
+ system_specs_msg.embed_specs().preprocessed(false).info()
+
+## Attempts to instantiate a LoggieSettings object from the script at the given [param path].
+## Returns true if successful, otherwise false and prints an error.
+func load_settings_from_path(path : String) -> bool:
+ var settings_resource = ResourceLoader.load(path)
+ var settings_instance
+
+ if settings_resource != null:
+ settings_instance = settings_resource.new()
+
+ if (settings_instance is LoggieSettings):
+ self.settings = settings_instance
+ self.settings.load()
+ return true
+ else:
+ push_error("Unable to instantiate a LoggieSettings object from the script at path {path}. Check that loggie.gd -> custom_settings_path is pointing to a valid .gd script that contains the class definition of a class that either extends LoggieSettings, or is LoggieSettings.".format({"path": path}))
+ return false
+
+## Checks if Loggie is running in production (release) mode of the game.
+## While it is, every [LoggieMsg] will have plain output.
+## Uses a sensible default check for most projects, but
+## you can rewrite this function to your needs if necessary.
+func is_in_production() -> bool:
+ return OS.has_feature("release")
+
+## Sets whether the domain with the given name is enabled.
+func set_domain_enabled(domain_name : String, enabled : bool) -> void:
+ domains[domain_name] = enabled
+
+## Checks whether the domain with the given name is enabled.
+## The domain name "" (empty string) is the default one for all newly created messages,
+## and is designed to always be enabled.
+func is_domain_enabled(domain_name : String) -> bool:
+ if domain_name == "":
+ return true
+
+ if domains.has(domain_name) and domains[domain_name] == true:
+ return true
+
+ return false
+
+## Creates a new [LoggieMsg] out of the given [param msg] and extra arguments (by converting them to strings and concatenating them to the msg).
+## You may continue to modify the [LoggieMsg] with additional functions from that class, then when you are ready to output it, use methods like:
+## [method LoggieMsg.info], [method LoggieMsg.warn], etc.
+func msg(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ var loggieMsg = LoggieMsg.new(message, arg1, arg2, arg3, arg4, arg5)
+ loggieMsg.use_logger(self)
+ return loggieMsg
+
+## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the info level.
+## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
+## For customization, use [method msg] instead.
+func info(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ return msg(message, arg1, arg2, arg3, arg4, arg5).info()
+
+## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the warn level.
+## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
+## For customization, use [method msg] instead.
+func warn(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ return msg(message, arg1, arg2, arg3, arg4, arg5).warn()
+
+## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the error level.
+## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
+## For customization, use [method msg] instead.
+func error(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ return msg(message, arg1, arg2, arg3, arg4, arg5).error()
+
+## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the debug level.
+## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
+## For customization, use [method msg] instead.
+func debug(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ return msg(message, arg1, arg2, arg3, arg4, arg5).debug()
+
+## A shortcut method that instantly creates a [LoggieMsg] with the given arguments and outputs it at the notice level.
+## Can be used when you have no intention of customizing a LoggieMsg in any way using helper methods.
+## For customization, use [method msg] instead.
+func notice(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ return msg(message, arg1, arg2, arg3, arg4, arg5).notice()
diff --git a/addons/loggie/loggie_message.gd b/addons/loggie/loggie_message.gd
new file mode 100644
index 00000000..4334f7b7
--- /dev/null
+++ b/addons/loggie/loggie_message.gd
@@ -0,0 +1,344 @@
+@tool
+
+## LoggieMsg represents a mutable object that holds an array of strings ([member content]) [i](referred to as 'content segments')[/i], and
+## a bunch of helper methods that make it easy to manipulate these segments and chain together additions and changes to them.
+## [br][br]For example:
+## [codeblock]
+### Prints: "Hello world!" at the INFO debug level.
+##var msg = LoggieMsg.new("Hello world").color(Color.RED).suffix("!").info()
+##[/codeblock]
+## [br] You can also use [method Loggie.msg] to quickly construct a message.
+## [br] Example of usage:
+## [codeblock]Loggie.msg("Hello world").color(Color("#ffffff")).suffix("!").info()[/codeblock]
+class_name LoggieMsg extends RefCounted
+
+## The full content of this message. By calling various helper methods in this class, this content is further altered.
+## The content is an array of strings which represents segments of the message which are ultimately appended together
+## to form the final message. You can start a new segment by calling [method msg] on this class.
+## You can then output the whole message with methods like [method info], [method debug], etc.
+var content : Array = [""]
+
+## The segment of [member content] that is currently being edited.
+var current_segment_index : int = 0
+
+## The (key string) domain this message belongs to.
+## "" is the default domain which is always enabled.
+## If this message attempts to be outputted, but belongs to a disabled domain, it will not be outputted.
+## You can change which domains are enabled in Loggie at any time with [Loggie.set_domain_enabled].
+## This is useful for creating blocks of debugging output that you can simply turn off/on with a boolean when you actually need them.
+var domain_name : String = ""
+
+## Whether this message should be preprocessed and modified during [method output].
+var preprocess : bool = true
+
+## Stores a reference to the logger that generated this message, from which we need to read settings and other data.
+## This variable should be set with [method use_logger] before an attempt is made to log this message out.
+var _logger : Variant
+
+func _init(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> void:
+ self.content[current_segment_index] = LoggieTools.concatenate_msg_and_args(message, arg1, arg2, arg3, arg4, arg5)
+
+## Returns a reference to the logger object that created this message.
+func get_logger() -> Variant:
+ return self._logger
+
+## Sets this message to use the given [param logger] as the logger from which it will be reading
+## settings. The given logger should be of class [Loggie] or an extension of it.
+func use_logger(logger_to_use : Variant) -> LoggieMsg:
+ self._logger = logger_to_use
+ return self
+
+## Outputs the given string [param msg] at the given output [param level] to the standard output using either [method print_rich] or [method print].
+## The domain from which the message is considered to be coming can be provided via [param target_domain].
+## The classification of the message can be provided via [param msg_type], as certain types need extra handling and treatment.
+## It also does a number of changes to the given [param msg] based on various Loggie settings.
+## Designed to be called internally. You should consider using [method info], [method error], [method warn], [method notice], [method debug] instead.
+func output(level : LoggieEnums.LogLevel, message : String, target_domain : String = "", msg_type : LoggieEnums.MsgType = LoggieEnums.MsgType.STANDARD) -> void:
+ var loggie = get_logger()
+
+ if loggie == null:
+ push_error("Attempt to log output with an invalid _logger. Make sure to call LoggieMsg.use_logger to set the appropriate logger before working with the message.")
+ return
+
+ if loggie.settings == null:
+ push_error("Attempt to use a _logger with invalid settings.")
+ return
+
+ # We don't output the message if the settings dictate that messages of that level shouldn't be outputted.
+ if level > loggie.settings.log_level:
+ loggie.log_attempted.emit(self, message, LoggieEnums.LogAttemptResult.LOG_LEVEL_INSUFFICIENT)
+ return
+
+ # We don't output the message if the domain from which it comes is not enabled.
+ if not loggie.is_domain_enabled(target_domain):
+ loggie.log_attempted.emit(self, message, LoggieEnums.LogAttemptResult.DOMAIN_DISABLED)
+ return
+
+ # Apply the matching formatting to the message based on the log level.
+ match level:
+ LoggieEnums.LogLevel.ERROR:
+ message = loggie.settings.format_error_msg.format({"msg": message})
+ LoggieEnums.LogLevel.WARN:
+ message = loggie.settings.format_warning_msg.format({"msg": message})
+ LoggieEnums.LogLevel.NOTICE:
+ message = loggie.settings.format_notice_msg.format({"msg": message})
+ LoggieEnums.LogLevel.INFO:
+ message = loggie.settings.format_info_msg.format({"msg": message})
+ LoggieEnums.LogLevel.DEBUG:
+ message = loggie.settings.format_debug_msg.format({"msg": message})
+
+ # Enter the preprocess tep unless it is disabled.
+ if self.preprocess:
+ # We append the name of the domain if that setting is enabled.
+ if !target_domain.is_empty() and loggie.settings.output_message_domain == true:
+ message = loggie.settings.format_domain_prefix.format({"domain" : target_domain, "msg" : message})
+
+ # We prepend the name of the class that called the function which resulted in this output being generated
+ # (if Loggie settings are configured to do so).
+ if loggie.settings.derive_and_show_class_names == true and OS.has_feature("debug"):
+ var stack_frame : Dictionary = LoggieTools.get_current_stack_frame_data()
+ var _class_name : String
+
+ var scriptPath = stack_frame.source
+ if loggie.class_names.has(scriptPath):
+ _class_name = loggie.class_names[scriptPath]
+ else:
+ _class_name = LoggieTools.get_class_name_from_script(scriptPath, loggie.settings.nameless_class_name_proxy)
+ loggie.class_names[scriptPath] = _class_name
+
+ if _class_name != "":
+ message = "[b]({class_name})[/b] {msg}".format({
+ "class_name" : _class_name,
+ "msg" : message
+ })
+
+ # We prepend a timestamp to the message (if Loggie settings are configured to do so).
+ if loggie.settings.output_timestamps == true:
+ var format_dict : Dictionary = Time.get_datetime_dict_from_system(loggie.settings.timestamps_use_utc)
+ for field in ["month", "day", "hour", "minute", "second"]:
+ format_dict[field] = "%02d" % format_dict[field]
+ message = "{formatted_time} {msg}".format({
+ "formatted_time" : loggie.settings.format_timestamp.format(format_dict),
+ "msg" : message
+ })
+
+ # Prepare the preprocessed message to be output in the terminal mode of choice.
+ message = LoggieTools.get_terminal_ready_string(message, loggie.settings.terminal_mode)
+
+ # Output the preprocessed message.
+ match loggie.settings.terminal_mode:
+ LoggieEnums.TerminalMode.ANSI, LoggieEnums.TerminalMode.BBCODE:
+ print_rich(message)
+ LoggieEnums.TerminalMode.PLAIN, _:
+ print(message)
+
+ # Dump a non-preprocessed terminal-ready version of the message in additional ways if that has been configured.
+ if msg_type == LoggieEnums.MsgType.ERROR and loggie.settings.print_errors_to_console:
+ push_error(LoggieTools.get_terminal_ready_string(self.string(), LoggieEnums.TerminalMode.PLAIN))
+ if msg_type == LoggieEnums.MsgType.WARNING and loggie.settings.print_warnings_to_console:
+ push_warning(LoggieTools.get_terminal_ready_string(self.string(), LoggieEnums.TerminalMode.PLAIN))
+ if msg_type == LoggieEnums.MsgType.DEBUG and loggie.settings.use_print_debug_for_debug_msg:
+ print_debug(LoggieTools.get_terminal_ready_string(self.string(), loggie.settings.terminal_mode))
+
+ loggie.log_attempted.emit(self, message, LoggieEnums.LogAttemptResult.SUCCESS)
+
+## Outputs this message from Loggie as an Error type message.
+## The [Loggie.settings.log_level] must be equal to or higher to the ERROR level for this to work.
+func error() -> LoggieMsg:
+ output(LoggieEnums.LogLevel.ERROR, self.string(), self.domain_name, LoggieEnums.MsgType.ERROR)
+ return self
+
+## Outputs this message from Loggie as an Warning type message.
+## The [Loggie.settings.log_level] must be equal to or higher to the WARN level for this to work.
+func warn() -> LoggieMsg:
+ output(LoggieEnums.LogLevel.WARN, self.string(), self.domain_name, LoggieEnums.MsgType.WARNING)
+ return self
+
+## Outputs this message from Loggie as an Notice type message.
+## The [Loggie.settings.log_level] must be equal to or higher to the NOTICE level for this to work.
+func notice() -> LoggieMsg:
+ output(LoggieEnums.LogLevel.NOTICE, self.string(), self.domain_name)
+ return self
+
+## Outputs this message from Loggie as an Info type message.
+## The [Loggie.settings.log_level] must be equal to or higher to the INFO level for this to work.
+func info() -> LoggieMsg:
+ output(LoggieEnums.LogLevel.INFO, self.string(), self.domain_name)
+ return self
+
+## Outputs this message from Loggie as a Debug type message.
+## The [Loggie.settings.log_level] must be equal to or higher to the DEBUG level for this to work.
+func debug() -> LoggieMsg:
+ output(LoggieEnums.LogLevel.DEBUG, self.string(), self.domain_name, LoggieEnums.MsgType.DEBUG)
+ return self
+
+## Returns the string content of this message.
+## If [param segment] is provided, it should be an integer indicating which segment of the message to return.
+## If its value is -1, all segments are concatenated together and returned.
+func string(segment : int = -1) -> String:
+ if segment == -1:
+ return "".join(self.content)
+ else:
+ if segment < self.content.size():
+ return self.content[segment]
+ else:
+ push_error("Attempt to access a non-existent segment of a LoggieMsg. Make sure to use a valid segment index.")
+ return ""
+
+## Converts the current content of this message to an ANSI compatible form.
+func to_ANSI() -> LoggieMsg:
+ var new_content : Array = []
+ for segment in self.content:
+ new_content.append(LoggieTools.rich_to_ANSI(segment))
+ self.content = new_content
+ return self
+
+## Strips all the BBCode in the current content of this message.
+func strip_BBCode() -> LoggieMsg:
+ var new_content : Array = []
+ for segment in self.content:
+ new_content.append(LoggieTools.remove_BBCode(segment))
+ self.content = new_content
+ return self
+
+## Wraps the content of the current segment of this message in the given color.
+## The [param color] can be provided as a [Color], a recognized Godot color name (String, e.g. "red"), or a color hex code (String, e.g. "#ff0000").
+func color(_color : Variant) -> LoggieMsg:
+ if _color is Color:
+ _color = _color.to_html()
+
+ self.content[current_segment_index] = "[color={colorstr}]{msg}[/color]".format({
+ "colorstr": _color,
+ "msg": self.content[current_segment_index]
+ })
+
+ return self
+
+## Stylizes the current segment of this message to be bold.
+func bold() -> LoggieMsg:
+ self.content[current_segment_index] = "[b]{msg}[/b]".format({"msg": self.content[current_segment_index]})
+ return self
+
+## Stylizes the current segment of this message to be italic.
+func italic() -> LoggieMsg:
+ self.content[current_segment_index] = "[i]{msg}[/i]".format({"msg": self.content[current_segment_index]})
+ return self
+
+## Stylizes the current segment of this message as a header.
+func header() -> LoggieMsg:
+ var loggie = get_logger()
+ self.content[current_segment_index] = loggie.settings.format_header.format({"msg": self.content[current_segment_index]})
+ return self
+
+## Constructs a decorative box with the given horizontal padding around the current segment
+## of this message. Messages containing a box are not going to be preprocessed, so they are best
+## used only as a special header or decoration.
+func box(h_padding : int = 4):
+ var loggie = get_logger()
+ var stripped_content = LoggieTools.remove_BBCode(self.content[current_segment_index]).strip_edges(true, true)
+ var content_length = stripped_content.length()
+ var h_fill_length = content_length + (h_padding * 2)
+ var box_character_source = loggie.settings.box_symbols_compatible if loggie.settings.box_characters_mode == LoggieEnums.BoxCharactersMode.COMPATIBLE else loggie.settings.box_symbols_pretty
+
+ var top_row_design = "{top_left_corner}{h_fill}{top_right_corner}".format({
+ "top_left_corner" : box_character_source.top_left,
+ "h_fill" : box_character_source.h_line.repeat(h_fill_length),
+ "top_right_corner" : box_character_source.top_right
+ })
+
+ var middle_row_design = "{vert_line}{padding}{content}{space_fill}{padding}{vert_line}".format({
+ "vert_line" : box_character_source.v_line,
+ "content" : self.content[current_segment_index],
+ "padding" : " ".repeat(h_padding),
+ "space_fill" : " ".repeat(h_fill_length - stripped_content.length() - h_padding*2)
+ })
+
+ var bottom_row_design = "{bottom_left_corner}{h_fill}{bottom_right_corner}".format({
+ "bottom_left_corner" : box_character_source.bottom_left,
+ "h_fill" : box_character_source.h_line.repeat(h_fill_length),
+ "bottom_right_corner" : box_character_source.bottom_right
+ })
+
+ self.content[current_segment_index] = "{top_row}\n{middle_row}\n{bottom_row}\n".format({
+ "top_row" : top_row_design,
+ "middle_row" : middle_row_design,
+ "bottom_row" : bottom_row_design
+ })
+
+ self.preprocessed(false)
+
+ return self
+
+## Appends additional content to this message at the end of the current content and its stylings.
+## This does not create a new message segment, just appends to the current one.
+func add(message : Variant = null, arg1 : Variant = null, arg2 : Variant = null, arg3 : Variant = null, arg4 : Variant = null, arg5 : Variant = null) -> LoggieMsg:
+ self.content[current_segment_index] = self.content[current_segment_index] + LoggieTools.concatenate_msg_and_args(message, arg1, arg2, arg3, arg4, arg5)
+ return self
+
+## Adds a specified amount of newlines to the end of the current segment of this message.
+func nl(amount : int = 1) -> LoggieMsg:
+ self.content[current_segment_index] += "\n".repeat(amount)
+ return self
+
+## Adds a specified amount of spaces to the end of the current segment of this message.
+func space(amount : int = 1) -> LoggieMsg:
+ self.content[current_segment_index] += " ".repeat(amount)
+ return self
+
+## Adds a specified amount of tabs to the end of the current segment of this message.
+func tab(amount : int = 1) -> LoggieMsg:
+ self.content[current_segment_index] += "\t".repeat(amount)
+ return self
+
+## Sets this message to belong to the domain with the given name.
+## If it attempts to be outputted, but the domain is disabled, it won't be outputted.
+func domain(_domain_name : String) -> LoggieMsg:
+ self.domain_name = _domain_name
+ return self
+
+## Prepends the given prefix string to the start of the message (first segment) with the provided separator.
+func prefix(str_prefix : String, separator : String = "") -> LoggieMsg:
+ self.content[0] = "{prefix}{separator}{content}".format({
+ "prefix" : str_prefix,
+ "separator" : separator,
+ "content" : self.content[0]
+ })
+ return self
+
+## Appends the given suffix string to the end of the message (last segment) with the provided separator.
+func suffix(str_suffix : String, separator : String = "") -> LoggieMsg:
+ self.content[self.content.size() - 1] = "{content}{separator}{suffix}".format({
+ "suffix" : str_suffix,
+ "separator" : separator,
+ "content" : self.content[self.content.size() - 1]
+ })
+ return self
+
+## Appends a horizontal separator with the given length to the current segment of this message.
+## If [param alternative_symbol] is provided, it should be a String, and it will be used as the symbol for the separator instead of the default one.
+func hseparator(size : int = 16, alternative_symbol : Variant = null) -> LoggieMsg:
+ var loggie = get_logger()
+ var symbol = loggie.settings.h_separator_symbol if alternative_symbol == null else str(alternative_symbol)
+ self.content[current_segment_index] = self.content[current_segment_index] + (symbol.repeat(size))
+ return self
+
+## Ends the current segment of the message and starts a new one.
+func endseg() -> LoggieMsg:
+ self.content.push_back("")
+ self.current_segment_index = self.content.size() - 1
+ return self
+
+## Creates a new segment in this message and sets its content to the given message.
+## Acts as a shortcut for calling [method endseg] + [method add].
+func msg(message = "", arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null) -> LoggieMsg:
+ self.endseg()
+ self.content[current_segment_index] = LoggieTools.concatenate_msg_and_args(message, arg1, arg2, arg3, arg4, arg5)
+ return self
+
+## Sets whether this message should be preprocessed and potentially modified with prefixes and suffixes during [method output].
+## If turned off, while outputting this message, Loggie will skip the steps where it appends the messaage domain, class name, timestamp, etc.
+## Whether preprocess is set to true doesn't affect the final conversion from RICH to ANSI or PLAIN, which always happens
+## under some circumstances that are based on other settings.
+func preprocessed(shouldPreprocess : bool) -> LoggieMsg:
+ self.preprocess = shouldPreprocess
+ return self
diff --git a/addons/loggie/loggie_settings.gd b/addons/loggie/loggie_settings.gd
new file mode 100644
index 00000000..22c3ad9d
--- /dev/null
+++ b/addons/loggie/loggie_settings.gd
@@ -0,0 +1,404 @@
+@tool
+
+## Defines a set of variables through which all the relevant settings of Loggie can have their
+## values set, read and documented. An instance of this class is found in [member Loggie.settings], and that's where Loggie
+## ultimately reads from when it's asking for the value of a setting. For user convenience, settings are (by default) exported
+## as custom Godot project settings and are loaded from there into these variables during [method load], however,
+## you can extend or overwrite this class' [method load] method to define a different way of loading these settings if you prefer.
+## [i](e.g. loading from a config.ini file, or a .json file, etc.)[/i].[br][br]
+##
+## Loggie calls [method load] on this class during its [method _ready] function.
+class_name LoggieSettings extends Resource
+
+## The name that will be used for the singleton referring to Loggie.
+## [br][br][i][b]Note:[/b] You may change this to something you're more used to, such as "log" or "logger".[/i]
+## When doing so, make sure to either do it while the Plugin is enabled, then disable and re-enable the plugin,
+## or that you manually clear out the previously created autoload (should be called "Loggie") in Project Settings -> Autoloads.
+static var loggie_singleton_name = "Loggie"
+
+## The dictionary which is used to grab the defaults and other values associated with each setting
+## relevant to Loggie, particularly important for the default way of loading [LoggieSettings] and
+## setting up Godot Project Settings related to Loggie.
+const project_settings = {
+ "remove_settings_if_plugin_disabled" = {
+ "path": "loggie/general/remove_settings_if_plugin_disabled",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "Choose whether you want Loggie project settings to be wiped from ProjectSettings if the Loggie plugin is disabled.",
+ },
+ "terminal_mode" = {
+ "path": "loggie/general/terminal_mode",
+ "default_value" : LoggieEnums.TerminalMode.BBCODE,
+ "type" : TYPE_INT,
+ "hint" : PROPERTY_HINT_ENUM,
+ "hint_string" : "Plain:0,ANSI:1,BBCode:2",
+ "doc" : "Choose the terminal for which loggie should preprocess the output so that it displays as intended.[br][br]Use BBCode for Godot console.[br]Use ANSI for Powershell, Bash, etc.[br]Use PLAIN for log files.",
+ },
+ "log_level" = {
+ "path": "loggie/general/log_level",
+ "default_value" : LoggieEnums.LogLevel.INFO,
+ "type" : TYPE_INT,
+ "hint" : PROPERTY_HINT_ENUM,
+ "hint_string" : "Error:0,Warn:1,Notice:2,Info:3,Debug:4",
+ "doc" : "Choose the level of messages which should be displayed. Loggie displays all messages that are outputted at the currently set level (or any lower level).",
+ },
+ "show_system_specs" = {
+ "path": "loggie/general/show_system_specs",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "Should Loggie log the system and device specs of the user as soon as it is booted?",
+ },
+ "show_loggie_specs" = {
+ "path": "loggie/general/show_loggie_specs",
+ "default_value" : LoggieEnums.ShowLoggieSpecsMode.ESSENTIAL,
+ "type" : TYPE_INT,
+ "hint" : PROPERTY_HINT_ENUM,
+ "hint_string" : "Disabled:0,Essential:1,Advanced:2",
+ "doc" : "Defines which way Loggie should print its own specs when it is booted.",
+ },
+ "enforce_optimal_settings_in_release_build" = {
+ "path": "loggie/general/enforce_optimal_settings_in_release_build",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "Should Loggie enforce certain settings to automatically change to optimal values in production/release builds?",
+ },
+ "output_timestamps" = {
+ "path": "loggie/timestamps/output_timestamps",
+ "default_value" : false,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "Should Loggie output a timestamp prefix with each message, showing the exact moment when that log line was produced?",
+ },
+ "timestamps_use_utc" = {
+ "path": "loggie/timestamps/timestamps_use_utc",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If 'Output Timestamps' is true, should those timestamps use the UTC time. If not, local system time is used instead.",
+ },
+ "output_message_domain" = {
+ "path": "loggie/preprocessing/output_message_domain",
+ "default_value" : false,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If true, logged messages will have the domain they are coming from prepended to them.",
+ },
+ "output_errors_to_console" = {
+ "path": "loggie/preprocessing/output_errors_also_to_console",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If true, errors printed by Loggie will also be visible through an additional print in the main output.",
+ },
+ "output_warnings_to_console" = {
+ "path": "loggie/preprocessing/output_warnings_also_to_console",
+ "default_value" : true,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If true, warnings printed by Loggie will also be visible through an additional print in the main output.",
+ },
+ "use_print_debug_for_debug_msgs" = {
+ "path": "loggie/preprocessing/use_print_debug_for_debug_msgs",
+ "default_value" : false,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If true, 'debug' level messages outputted by Loggie will be printed using Godot's 'print_debug' function, which is more verbose.",
+ },
+ "derive_and_display_class_names_from_scripts" = {
+ "path": "loggie/preprocessing/derive_and_display_class_names_from_scripts",
+ "default_value" : false,
+ "type" : TYPE_BOOL,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "If true, Loggie will attempt to find out the name of the main class from which the log line is coming and append it in front of the message.",
+ },
+ "nameless_class_name_proxy" = {
+ "path": "loggie/preprocessing/nameless_class_name_proxy",
+ "default_value" : LoggieEnums.NamelessClassExtensionNameProxy.BASE_TYPE,
+ "type" : TYPE_INT,
+ "hint" : PROPERTY_HINT_ENUM,
+ "hint_string" : "Nothing:0,ScriptName:1,BaseType:2",
+ "doc" : "If 'Derive and Display Class Names From Scripts' is enabled, and a script doesn't have a 'class_name', which text should we use as a substitute?",
+ },
+ "format_timestamp" = {
+ "path": "loggie/formats/timestamp",
+ "default_value" : "[{day}.{month}.{year} {hour}:{minute}:{second}]",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for timestamps which are prepended to the message when output_timestamps is enabled.",
+ },
+ "format_debug_msg" = {
+ "path": "loggie/formats/debug_message",
+ "default_value" : "[b][color=pink][DEBUG]:[/color][/b] {msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for debug messages.",
+ },
+ "format_info_msg" = {
+ "path": "loggie/formats/info_message",
+ "default_value" : "{msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for info messages.",
+ },
+ "format_notice_msg" = {
+ "path": "loggie/formats/notice_message",
+ "default_value" : "[b][color=cyan][NOTICE]:[/color][/b] {msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for notice messages.",
+ },
+ "format_warning_msg" = {
+ "path": "loggie/formats/warning_message",
+ "default_value" : "[b][color=orange][WARN]:[/color][/b] {msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for warning messages.",
+ },
+ "format_error_msg" = {
+ "path": "loggie/formats/error_message",
+ "default_value" : "[b][color=red][ERROR]:[/color][/b] {msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for error messages.",
+ },
+ "format_domain_prefix" = {
+ "path": "loggie/formats/domain_prefix",
+ "default_value" : "[b]({domain})[/b] {msg}",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for domain prefixes.",
+ },
+ "format_header" = {
+ "path": "loggie/formats/header",
+ "default_value" : "[b][i]{msg}[/i][/b]",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The format used for headers.",
+ },
+ "h_separator_symbol" = {
+ "path": "loggie/formats/h_separator_symbol",
+ "default_value" : "-",
+ "type" : TYPE_STRING,
+ "hint" : PROPERTY_HINT_NONE,
+ "hint_string" : "",
+ "doc" : "The symbol used for the horizontal separator.",
+ },
+ "box_characters_mode" = {
+ "path": "loggie/formats/box_characters_mode",
+ "default_value" : LoggieEnums.BoxCharactersMode.COMPATIBLE,
+ "type" : TYPE_INT,
+ "hint" : PROPERTY_HINT_ENUM,
+ "hint_string" : "Compatible:0,Pretty:1",
+ "doc" : "There are two sets of box characters defined in LoggieSettings - one set contains prettier characters that produce a nicer looking box, but may not render correctly in the context of various terminals. The other set contains characters that produce a less pretty box, but are compatible with being shown in most terminals.",
+ },
+}
+
+## The current terminal mode of Loggie.
+## Terminal mode determines whether BBCode, ANSI or some other type of
+## formatting is used to convey text effects, such as bold, italic, colors, etc.
+## [br][br]BBCode is compatible with the Godot console.
+## [br]ANSI is compatible with consoles like Powershell and Windows CMD.
+## [br]PLAIN is used to strip any effects and use plain text instead, which is good for saving raw logs into log files.
+var terminal_mode : LoggieEnums.TerminalMode
+
+## The current log level of Loggie.
+## It determines which types of messages are allowed to be logged.
+## Set this using [method setLogLevel].
+var log_level : LoggieEnums.LogLevel
+
+## Whether or not Loggie should log the loggie specs on ready.
+var show_loggie_specs : LoggieEnums.ShowLoggieSpecsMode
+
+## Whether or not Loggie should log the system specs on ready.
+var show_system_specs : bool
+
+## If true, the domain from which the [LoggieMsg] is coming will be prepended to the output.
+## If the domain is default (empty), nothing will change.
+var output_message_domain : bool
+
+## Whether to, in addition to logging errors with [method push_error],
+## Loggie should also print the error as a message in the standard output.
+var print_errors_to_console : bool
+
+## Whether to, in addition to logging errors with [method push_warning],
+## Loggie should also print the error as a message in the standard output.
+var print_warnings_to_console : bool
+
+## If true, instead of [method print], [method print_debug] will be
+## used when printing messages with [method LoggieMsg.debug].
+var use_print_debug_for_debug_msg : bool
+
+## Whether Loggie should use the scripts from which it is being called to
+## figure out a class name for the class that called a loggie function,
+## and display it at the start of each output message.
+## This only works in debug builds because it uses [method @GDScript.get_stack].
+## See that method's documentation to see why that can't be used in release builds.
+var derive_and_show_class_names : bool
+
+## Defines which text will be used as a substitute for the 'class_name' of scripts that do not have a 'class_name'.
+## Relevant only if [member derive_and_show_class_names] is enabled.
+var nameless_class_name_proxy : LoggieEnums.NamelessClassExtensionNameProxy
+
+## Whether Loggie should prepend a timestamp to each output message.
+var output_timestamps : bool
+
+## Whether the outputted timestamps (if [member output_timestamps] is enabled) use UTC or local machine time.
+var timestamps_use_utc : bool
+
+## Whether Loggie should enforce optimal values for certain settings when in a Release/Production build.
+## [br]If true, Loggie will enforce:
+## [br] * [member terminal_mode] to [member LoggieEnums.TerminalMode.PLAIN]
+## [br] * [member box_characters_mode] to [member LoggieEnums.BoxCharactersMode.COMPATIBLE]
+var enforce_optimal_settings_in_release_build : bool
+
+# ----------------------------------------------- #
+#region Formats for prints
+# ----------------------------------------------- #
+# As per the `print_rich` documentation, supported colors are: black, red, green, yellow, blue, magenta, pink, purple, cyan, white, orange, gray.
+# Any other color will be displayed in the Godot console or an ANSI based console, but the color tag (in case of BBCode) won't be properly stripped
+# when written to the .log file, resulting in BBCode visible in .log files.
+
+## The format used to decorate a message as a header when using [method LoggieMsg.header].[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+var format_header = "[b][i]{msg}[/i][/b]"
+
+## The format used when appending a domain to a message.[br]
+## See: [member output_message_domain]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## The [param {domain}] is a variable that will be replaced with the domain key.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_domain_prefix = "[b]({domain})[/b] {msg}"
+
+## The format used when outputting error messages.[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_error_msg = "[b][color=red][ERROR]:[/color][/b] {msg}"
+
+## The format used when outputting warning messages.[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_warning_msg = "[b][color=orange][WARN]:[/color][/b] {msg}"
+
+## The format used when outputting notice messages.[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_notice_msg = "[b][color=cyan][NOTICE]:[/color][/b] {msg}"
+
+## The format used when outputting info messages.[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_info_msg = "{msg}"
+
+## The format used when outputting debug messages.[br]
+## The [param {msg}] is a variable that will be replaced with the contents of the message.[br]
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_debug_msg = "[b][color=pink][DEBUG]:[/color][/b] {msg}"
+
+## The format used for timestamps which are prepended to the message when [member output_timestamps] is enabled.[br]
+## The variables [param {day}], [param {month}], [param {year}], [param {hour}], [param {minute}], [param {second}], [param {weekday}], and [param {dst}] are supported.
+## You can customize this in your ProjectSettings, or custom_settings.gd (if using it).[br]
+var format_timestamp = "[{day}.{month}.{year} {hour}:{minute}:{second}]"
+
+## The symbol which will be used for the HSeparator.
+var h_separator_symbol = "-"
+
+## The mode used for drawing boxes.
+var box_characters_mode : LoggieEnums.BoxCharactersMode
+
+## The symbols which will be used to construct a box decoration that will properly
+## display on any kind of terminal or text reader.
+## For a prettier but potentially incompatible box, use [member box_symbols_pretty] instead.
+var box_symbols_compatible = {
+ # ANSI and .log compatible box characters:
+ "top_left" : "-",
+ "top_right" : "-",
+ "bottom_left" : "-",
+ "bottom_right" : "-",
+ "h_line" : "-",
+ "v_line" : ":",
+}
+
+## The symbols which will be used to construct pretty box decoration.
+## These may not be compatible with some terminals or text readers.
+## Use the [member box_symbols_compatible] instead as an alternative.
+var box_symbols_pretty = {
+ "top_left" : "┌",
+ "top_right" : "┐",
+ "bottom_left" : "└",
+ "bottom_right" : "┘",
+ "h_line" : "─",
+ "v_line" : "│",
+}
+
+#endregion
+# ----------------------------------------------- #
+
+## Loads the initial (default) values for all of the LoggieSettings variables.
+## (By default, loads them from ProjectSettings (if any modifications there exist),
+## or looks in [LoggieEditorPlugin..project_settings] for default values).
+## [br][br]Extend this class and override this function to write your own logic for
+## how loggie should obtain these settings if you have a need for a different approach.
+func load():
+ terminal_mode = ProjectSettings.get_setting(project_settings.terminal_mode.path, project_settings.terminal_mode.default_value)
+ log_level = ProjectSettings.get_setting(project_settings.log_level.path, project_settings.log_level.default_value)
+ show_loggie_specs = ProjectSettings.get_setting(project_settings.show_loggie_specs.path, project_settings.show_loggie_specs.default_value)
+ show_system_specs = ProjectSettings.get_setting(project_settings.show_system_specs.path, project_settings.show_system_specs.default_value)
+ output_timestamps = ProjectSettings.get_setting(project_settings.output_timestamps.path, project_settings.output_timestamps.default_value)
+ timestamps_use_utc = ProjectSettings.get_setting(project_settings.timestamps_use_utc.path, project_settings.timestamps_use_utc.default_value)
+ enforce_optimal_settings_in_release_build = ProjectSettings.get_setting(project_settings.enforce_optimal_settings_in_release_build.path, project_settings.enforce_optimal_settings_in_release_build.default_value)
+
+ print_errors_to_console = ProjectSettings.get_setting(project_settings.output_errors_to_console.path, project_settings.output_errors_to_console.default_value)
+ print_warnings_to_console = ProjectSettings.get_setting(project_settings.output_warnings_to_console.path, project_settings.output_warnings_to_console.default_value)
+ use_print_debug_for_debug_msg = ProjectSettings.get_setting(project_settings.use_print_debug_for_debug_msgs.path, project_settings.use_print_debug_for_debug_msgs.default_value)
+
+ output_message_domain = ProjectSettings.get_setting(project_settings.output_message_domain.path, project_settings.output_message_domain.default_value)
+ derive_and_show_class_names = ProjectSettings.get_setting(project_settings.derive_and_display_class_names_from_scripts.path, project_settings.derive_and_display_class_names_from_scripts.default_value)
+
+ nameless_class_name_proxy = ProjectSettings.get_setting(project_settings.nameless_class_name_proxy.path, project_settings.nameless_class_name_proxy.default_value)
+ box_characters_mode = ProjectSettings.get_setting(project_settings.box_characters_mode.path, project_settings.box_characters_mode.default_value)
+
+ format_timestamp = ProjectSettings.get_setting(project_settings.format_timestamp.path, project_settings.format_timestamp.default_value)
+ format_info_msg = ProjectSettings.get_setting(project_settings.format_info_msg.path, project_settings.format_info_msg.default_value)
+ format_notice_msg = ProjectSettings.get_setting(project_settings.format_notice_msg.path, project_settings.format_notice_msg.default_value)
+ format_warning_msg = ProjectSettings.get_setting(project_settings.format_warning_msg.path, project_settings.format_warning_msg.default_value)
+ format_error_msg = ProjectSettings.get_setting(project_settings.format_error_msg.path, project_settings.format_error_msg.default_value)
+ format_debug_msg = ProjectSettings.get_setting(project_settings.format_debug_msg.path, project_settings.format_debug_msg.default_value)
+ h_separator_symbol = ProjectSettings.get_setting(project_settings.h_separator_symbol.path, project_settings.h_separator_symbol.default_value)
+
+## Returns a dictionary where the indices are names of relevant variables in the LoggieSettings class,
+## and the values are their current values.
+func to_dict() -> Dictionary:
+ var dict = {}
+ var included = [
+ "terminal_mode", "log_level", "show_loggie_specs", "show_system_specs", "enforce_optimal_settings_in_release_build",
+ "output_message_domain", "print_errors_to_console", "print_warnings_to_console",
+ "use_print_debug_for_debug_msg", "derive_and_show_class_names", "nameless_class_name_proxy",
+ "output_timestamps", "timestamps_use_utc", "format_header", "format_domain_prefix", "format_error_msg",
+ "format_warning_msg", "format_notice_msg", "format_info_msg", "format_debug_msg", "format_timestamp",
+ "h_separator_symbol", "box_characters_mode", "box_symbols_compatible", "box_symbols_pretty",
+ ]
+
+ for var_name in included:
+ dict[var_name] = get(var_name)
+ return dict
diff --git a/addons/loggie/plugin.cfg b/addons/loggie/plugin.cfg
new file mode 100644
index 00000000..537456ea
--- /dev/null
+++ b/addons/loggie/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Loggie"
+description="Simple functional stylish logger for your basic logging needs."
+author="Shiva Shadowsong"
+version="1.5"
+script="plugin.gd"
diff --git a/addons/loggie/plugin.gd b/addons/loggie/plugin.gd
new file mode 100644
index 00000000..28889d80
--- /dev/null
+++ b/addons/loggie/plugin.gd
@@ -0,0 +1,47 @@
+@tool
+class_name LoggieEditorPlugin extends EditorPlugin
+
+func _enter_tree():
+ add_autoload_singleton(LoggieSettings.loggie_singleton_name, "res://addons/loggie/loggie.gd")
+ add_loggie_project_settings()
+
+func _enable_plugin() -> void:
+ add_loggie_project_settings()
+
+func _disable_plugin() -> void:
+ var wipe_setting_exists = ProjectSettings.has_setting(LoggieSettings.project_settings.remove_settings_if_plugin_disabled.path)
+ if (not wipe_setting_exists) or (wipe_setting_exists and ProjectSettings.get_setting(LoggieSettings.project_settings.remove_settings_if_plugin_disabled.path, true)):
+ push_warning("The Loggie plugin is being disabled, and all of its ProjectSettings are erased from Godot. If you wish to prevent this behavior, look for the 'Project Settings -> Loggie -> General -> Remove Settings if Plugin Disabled' option while the plugin is enabled.")
+ remove_loggie_project_setings()
+ else:
+ push_warning("The Loggie plugin is being disabled, but its ProjectSettings have been prevented from being removed from Godot. If you wish to allow that behavior, look for the 'Project Settings -> Loggie -> General -> Remove Settings if Plugin Disabled' option while the plugin is enabled.")
+ remove_autoload_singleton(LoggieSettings.loggie_singleton_name)
+
+## Adds new Loggie related ProjectSettings to Godot.
+func add_loggie_project_settings():
+ for setting in LoggieSettings.project_settings.values():
+ add_project_setting(setting["path"], setting["default_value"], setting["type"], setting["hint"], setting["hint_string"], setting["doc"])
+
+## Removes Loggie related ProjectSettings from Godot.
+func remove_loggie_project_setings():
+ for setting in LoggieSettings.project_settings.values():
+ ProjectSettings.set_setting(setting["path"], null)
+
+ var error: int = ProjectSettings.save()
+ if error != OK:
+ push_error("Loggie - Encountered error %d while saving project settings." % error)
+
+## Adds a new project setting to Godot.
+## TODO: Figure out how to also add the documentation to the ProjectSetting so that it shows up
+## in the Godot Editor tooltip when the setting is hovered over.
+func add_project_setting(setting_name: String, default_value : Variant, value_type: int, type_hint: int = PROPERTY_HINT_NONE, hint_string: String = "", documentation : String = ""):
+ if !ProjectSettings.has_setting(setting_name):
+ ProjectSettings.set_setting(setting_name, default_value)
+
+ ProjectSettings.set_initial_value(setting_name, default_value)
+ ProjectSettings.add_property_info({ "name": setting_name, "type": value_type, "hint": type_hint, "hint_string": hint_string})
+ ProjectSettings.set_as_basic(setting_name, true)
+
+ var error: int = ProjectSettings.save()
+ if error:
+ push_error("Loggie - Encountered error %d while saving project settings." % error)
diff --git a/addons/loggie/tools/loggie_enums.gd b/addons/loggie/tools/loggie_enums.gd
new file mode 100644
index 00000000..dcfeb160
--- /dev/null
+++ b/addons/loggie/tools/loggie_enums.gd
@@ -0,0 +1,53 @@
+@tool
+class_name LoggieEnums extends Node
+
+## Based on which log level is currently set to be used by the Loggie., attempting to log a message that's on
+## a higher-than-configured log level will result in nothing happening.
+enum LogLevel {
+ ERROR, ## Log level which includes only the logging of Error type messages.
+ WARN, ## Log level which includes the logging of Error and Warning type messages.
+ NOTICE, ## Log level which includes the logging of Error, Warning and Notice type messages.
+ INFO, ## Log level which includes the logging of Error, Warning, Notice and Info type messages.
+ DEBUG ## Log level which includes the logging of Error, Warning, Notice, Info and Debug type messages.
+}
+
+## The classification of message types that can be used to distinguish two identical strings in nature
+## of their origin. This is different from [enum LogLevel].
+enum MsgType {
+ STANDARD, ## A message that is considered a standard text that is not special in any way.
+ ERROR, ## A message that is considered to be an error message.
+ WARNING, ## A message that is considered to be a warning message.
+ DEBUG ## A message that is considered to be a message used for debugging.
+}
+
+enum TerminalMode {
+ PLAIN, ## Prints will be plain text.
+ ANSI, ## Prints will be styled using the ANSI standard. Compatible with Powershell, Win CMD, etc.
+ BBCODE ## Prints will be styled using the Godot BBCode rules. Compatible with the Godot console.
+}
+
+enum BoxCharactersMode {
+ COMPATIBLE, ## Boxes are drawn using characters that compatible with any kind of terminal or text reader.
+ PRETTY ## Boxes are drawn using special unicode characters that create a prettier looking box which may not display properly in some terminals or text readers.
+}
+
+## Defines a list of possible approaches that can be taken to derive some kind of a class name proxy from a script that doesn't have a 'class_name' clause.
+enum NamelessClassExtensionNameProxy {
+ NOTHING, ## If there is no class_name, nothing will be displayed.
+ SCRIPT_NAME, ## Use the name of the script whose class_name we tried to read. (e.g. "my_script.gd").
+ BASE_TYPE, ## Use the name of the base type which the script extends (e.g. 'Node2D', 'Control', etc.)
+}
+
+## Defines a list of possible behaviors for the 'show_loggie_specs' setting.
+enum ShowLoggieSpecsMode {
+ DISABLED, ## Loggie specs won't be shown.
+ ESSENTIAL, ## Show only the essentials.
+ ADVANCED ## Show all loggie specs.
+}
+
+## Defines a list of possible outcomes that can happen when attempting to log a message.
+enum LogAttemptResult {
+ SUCCESS, ## Message will be logged successfully.
+ LOG_LEVEL_INSUFFICIENT, ## Message won't be logged because it was output at a log level higher than what Loggie is currently set to.
+ DOMAIN_DISABLED, ## Message won't be logged because it was outputted from a disabled domain.
+}
diff --git a/addons/loggie/tools/loggie_system_specs.gd b/addons/loggie/tools/loggie_system_specs.gd
new file mode 100644
index 00000000..94d484ff
--- /dev/null
+++ b/addons/loggie/tools/loggie_system_specs.gd
@@ -0,0 +1,185 @@
+@tool
+
+## LoggieSystemSpecs is a helper class that defines various functions on how to access data about the local machine and its specs
+## and creates displayable strings out of them.
+class_name LoggieSystemSpecsMsg extends LoggieMsg
+
+## Embeds various system specs into the content of this message.
+func embed_specs() -> LoggieSystemSpecsMsg:
+ self.embed_system_specs()
+ self.embed_localization_specs()
+ self.embed_date_data().nl()
+ self.embed_hardware_specs().nl()
+ self.embed_video_specs().nl()
+ self.embed_display_specs().nl()
+ self.embed_audio_specs().nl()
+ self.embed_engine_specs().nl()
+ self.embed_input_specs()
+ return self
+
+## Embeds essential data about the logger into the content of this message.
+func embed_essential_logger_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ self.add(loggie.msg("|\t Is in Production:").bold(), loggie.is_in_production()).nl()
+ self.add(loggie.msg("|\t Terminal Mode:").bold(), LoggieEnums.TerminalMode.keys()[loggie.settings.terminal_mode]).nl()
+ self.add(loggie.msg("|\t Log Level:").bold(), LoggieEnums.LogLevel.keys()[loggie.settings.log_level]).nl()
+ return self
+
+## Embeds advanced data about the logger into the content of this message.
+func embed_advanced_logger_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+
+ self.add(loggie.msg("|\t Is in Production:").bold(), loggie.is_in_production()).nl()
+
+ var settings_dict = loggie.settings.to_dict()
+ for setting_var_name : String in settings_dict.keys():
+ var setting_value = settings_dict[setting_var_name]
+ var content_to_print = setting_value
+
+ match setting_var_name:
+ "terminal_mode":
+ content_to_print = LoggieEnums.TerminalMode.keys()[setting_value]
+ "log_level":
+ content_to_print = LoggieEnums.LogLevel.keys()[setting_value]
+ "box_characters_mode":
+ content_to_print = LoggieEnums.BoxCharactersMode.keys()[setting_value]
+
+ self.add(loggie.msg("|\t", setting_var_name.capitalize(), ":").bold(), content_to_print).nl()
+
+ return self
+
+## Adds data about the user's software to the content of this message.
+func embed_system_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Operating System: ").color(Color.ORANGE).add(OS.get_name()).box(4)
+ self.add(header)
+ return self
+
+## Adds data about localization to the content of this message.
+func embed_localization_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Localization: ").color(Color.ORANGE).add(OS.get_locale()).box(7)
+ self.add(header)
+ return self
+
+## Adds data about the current date/time to the content of this message.
+func embed_date_data() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Date").color(Color.ORANGE).box(15)
+ self.add(header)
+ self.add(loggie.msg("Date and time (local):").bold(), Time.get_datetime_string_from_system(false, true)).nl()
+ self.add(loggie.msg("Date and time (UTC):").bold(), Time.get_datetime_string_from_system(true, true)).nl()
+ self.add(loggie.msg("Date (local):").bold(), Time.get_date_string_from_system(false)).nl()
+ self.add(loggie.msg("Date (UTC):").bold(), Time.get_date_string_from_system(true)).nl()
+ self.add(loggie.msg("Time (local):").bold(), Time.get_time_string_from_system(false)).nl()
+ self.add(loggie.msg("Time (UTC):").bold(), Time.get_time_string_from_system(true)).nl()
+ self.add(loggie.msg("Timezone:").bold(), Time.get_time_zone_from_system()).nl()
+ self.add(loggie.msg("UNIX time:").bold(), Time.get_unix_time_from_system()).nl()
+ return self
+
+## Adds data about the user's hardware to the content of this message.
+func embed_hardware_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Hardware").color(Color.ORANGE).box(13)
+ self.add(header)
+ self.add(loggie.msg("Model name:").bold(), OS.get_model_name()).nl()
+ self.add(loggie.msg("Processor name:").bold(), OS.get_processor_name()).nl()
+ return self
+
+## Adds data about the video system to the content of this message.
+func embed_video_specs() -> LoggieSystemSpecsMsg:
+ const adapter_type_to_string = ["Other (Unknown)", "Integrated", "Discrete", "Virtual", "CPU"]
+ var adapter_type_string = adapter_type_to_string[RenderingServer.get_video_adapter_type()]
+ var video_adapter_driver_info = OS.get_video_adapter_driver_info()
+ var loggie = get_logger()
+
+ var header = loggie.msg("Video").color(Color.ORANGE).box(15)
+ self.add(header)
+ self.add(loggie.msg("Adapter name:").bold(), RenderingServer.get_video_adapter_name()).nl()
+ self.add(loggie.msg("Adapter vendor:").bold(), RenderingServer.get_video_adapter_vendor()).nl()
+ self.add(loggie.msg("Adapter type:").bold(), adapter_type_string).nl()
+ self.add(loggie.msg("Adapter graphics API version:").bold(), RenderingServer.get_video_adapter_api_version()).nl()
+
+ if video_adapter_driver_info.size() > 0:
+ self.add(loggie.msg("Adapter driver name:").bold(), video_adapter_driver_info[0]).nl()
+ if video_adapter_driver_info.size() > 1:
+ self.add(loggie.msg("Adapter driver version:").bold(), video_adapter_driver_info[1]).nl()
+
+ return self
+
+## Adds data about the display to the content of this message.
+func embed_display_specs() -> LoggieSystemSpecsMsg:
+ const screen_orientation_to_string = [
+ "Landscape",
+ "Portrait",
+ "Landscape (reverse)",
+ "Portrait (reverse)",
+ "Landscape (defined by sensor)",
+ "Portrait (defined by sensor)",
+ "Defined by sensor",
+ ]
+ var screen_orientation_string = screen_orientation_to_string[DisplayServer.screen_get_orientation()]
+ var loggie = get_logger()
+
+ var header = loggie.msg("Display").color(Color.ORANGE).box(13)
+ self.add(header)
+ self.add(loggie.msg("Screen count:").bold(), DisplayServer.get_screen_count()).nl()
+ self.add(loggie.msg("DPI:").bold(), DisplayServer.screen_get_dpi()).nl()
+ self.add(loggie.msg("Scale factor:").bold(), DisplayServer.screen_get_scale()).nl()
+ self.add(loggie.msg("Maximum scale factor:").bold(), DisplayServer.screen_get_max_scale()).nl()
+ self.add(loggie.msg("Startup screen position:").bold(), DisplayServer.screen_get_position()).nl()
+ self.add(loggie.msg("Startup screen size:").bold(), DisplayServer.screen_get_size()).nl()
+ self.add(loggie.msg("Startup screen refresh rate:").bold(), ("%f Hz" % DisplayServer.screen_get_refresh_rate()) if DisplayServer.screen_get_refresh_rate() > 0.0 else "").nl()
+ self.add(loggie.msg("Usable (safe) area rectangle:").bold(), DisplayServer.get_display_safe_area()).nl()
+ self.add(loggie.msg("Screen orientation:").bold(), screen_orientation_string).nl()
+ return self
+
+## Adds data about the audio system to the content of this message.
+func embed_audio_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Audio").color(Color.ORANGE).box(14)
+ self.add(header)
+ self.add(loggie.msg("Mix rate:").bold(), "%d Hz" % AudioServer.get_mix_rate()).nl()
+ self.add(loggie.msg("Output latency:").bold(), "%f ms" % (AudioServer.get_output_latency() * 1000)).nl()
+ self.add(loggie.msg("Output device list:").bold(), ", ".join(AudioServer.get_output_device_list())).nl()
+ self.add(loggie.msg("Capture device list:").bold(), ", ".join(AudioServer.get_input_device_list())).nl()
+ return self
+
+## Adds data about the godot engine to the content of this message.
+func embed_engine_specs() -> LoggieSystemSpecsMsg:
+ var loggie = get_logger()
+ var header = loggie.msg("Engine").color(Color.ORANGE).box(14)
+ self.add(header)
+ self.add(loggie.msg("Version:").bold(), Engine.get_version_info()["string"]).nl()
+ self.add(loggie.msg("Command-line arguments:").bold(), str(OS.get_cmdline_args())).nl()
+ self.add(loggie.msg("Is debug build:").bold(), OS.is_debug_build()).nl()
+ self.add(loggie.msg("Filesystem is persistent:").bold(), OS.is_userfs_persistent()).nl()
+ return self
+
+## Adds data about the input device to the content of this message.
+func embed_input_specs() -> LoggieSystemSpecsMsg:
+ var has_virtual_keyboard = DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD)
+ var loggie = get_logger()
+
+ var header = loggie.msg("Input").color(Color.ORANGE).box(14)
+ self.add(header)
+ self.add(loggie.msg("Device has touch screen:").bold(), DisplayServer.is_touchscreen_available()).nl()
+ self.add(loggie.msg("Device has virtual keyboard:").bold(), has_virtual_keyboard).nl()
+
+ if has_virtual_keyboard:
+ self.add(loggie.msg("Virtual keyboard height:").bold(), DisplayServer.virtual_keyboard_get_height())
+
+ return self
+
+## Prints out a bunch of useful data about a given script.
+## Useful for debugging.
+func embed_script_data(script : Script):
+ var loggie = get_logger()
+ self.add("Script Data for:", script.get_path()).color("pink")
+ self.add(":").nl()
+ self.add(loggie.msg("get_class(): ").color("slate_blue").bold()).add(script.get_class()).nl()
+ self.add(loggie.msg("get_global_name(): ").color("slate_blue").bold()).add(script.get_global_name()).nl()
+ self.add(loggie.msg("get_base_script(): ").color("slate_blue").bold()).add(script.get_base_script().resource_path if script.get_base_script() != null else "No base script.").nl()
+ self.add(loggie.msg("get_instance_base_type(): ").color("slate_blue").bold()).add(script.get_instance_base_type()).nl()
+ self.add(loggie.msg("get_script_property_list(): ").color("slate_blue").bold()).add(script.get_script_property_list()).nl()
+ return self
diff --git a/addons/loggie/tools/loggie_tools.gd b/addons/loggie/tools/loggie_tools.gd
new file mode 100644
index 00000000..9b94e58f
--- /dev/null
+++ b/addons/loggie/tools/loggie_tools.gd
@@ -0,0 +1,369 @@
+@tool
+class_name LoggieTools extends Node
+
+## Removes BBCode from the given text.
+static func remove_BBCode(text: String) -> String:
+ # The bbcode tags to remove.
+ var tags = ["b", "i", "u", "s", "indent", "code", "url", "center", "right", "color", "bgcolor", "fgcolor"]
+
+ var regex = RegEx.new()
+ var tags_pattern = "|".join(tags)
+ regex.compile("\\[/?(" + tags_pattern + ")(=[^\\]]*)?\\]")
+
+ var stripped_text = regex.sub(text, "", true)
+ return stripped_text
+
+## Concatenates all given args into one single string, in consecutive order starting with 'msg'.
+static func concatenate_msg_and_args(msg : Variant, arg1 : Variant = null, arg2 : Variant = null, arg3 : Variant = null, arg4 : Variant = null, arg5 : Variant = null, arg6 : Variant = null) -> String:
+ var final_msg = convert_to_string(msg)
+ var arguments = [arg1, arg2, arg3, arg4, arg5, arg6]
+ for arg in arguments:
+ if arg != null:
+ final_msg += (" " + convert_to_string(arg))
+ return final_msg
+
+## Converts [param something] into a string.
+## If [param something] is a Dictionary, uses a special way to convert it into a string.
+## You can add more exceptions and rules for how different things are converted to strings here.
+static func convert_to_string(something : Variant) -> String:
+ var result : String
+ if something is Dictionary:
+ result = JSON.new().stringify(something, " ", false, true)
+ elif something is LoggieMsg:
+ result = str(something.string())
+ else:
+ result = str(something)
+ return result
+
+## Takes the given [param str] and returns a terminal-ready version of it by converting its content
+## to the appropriate format required to display the string correctly in the provided [param mode]
+## terminal mode.
+static func get_terminal_ready_string(str : String, mode : LoggieEnums.TerminalMode) -> String:
+ match mode:
+ LoggieEnums.TerminalMode.ANSI:
+ # We put the message through the rich_to_ANSI converter which takes care of converting BBCode
+ # to appropriate ANSI. (Only if the TerminalMode is set to ANSI).
+ # Godot claims to be already preparing BBCode output for ANSI, but it only works with a small
+ # predefined set of colors, and I think it totally strips stuff like [b], [i], etc.
+ # It is possible to display those stylings in ANSI, but we have to do our own conversion here
+ # to support these features instead of having them stripped.
+ str = LoggieTools.rich_to_ANSI(str)
+ LoggieEnums.TerminalMode.BBCODE:
+ # No need to do anything for BBCODE mode, because we already expect all strings to
+ # start out with this format in mind.
+ pass
+ LoggieEnums.TerminalMode.PLAIN, _:
+ str = LoggieTools.remove_BBCode(str)
+ return str
+
+## Converts a given [Color] to an ANSI compatible representation of it in code.
+static func color_to_ANSI(color: Color) -> String:
+ var r = int(color.r * 255)
+ var g = int(color.g * 255)
+ var b = int(color.b * 255)
+ return "\u001b[38;2;%d;%d;%dm" % [r, g, b]
+
+## Strips the BBCode from the given text, and converts all [b], [i] and [color] tags to appropriate ANSI representable codes,
+## then returns the converted string. The result of this conversion becomes an ANSI compatible representation of the given [param text].
+static func rich_to_ANSI(text: String) -> String:
+ var regex_color = RegEx.new()
+ regex_color.compile("\\[color=(.*?)\\](.*?)\\[/color\\]")
+
+ # Process color tags first
+ while regex_color.search(text):
+ var match = regex_color.search(text)
+ var color_str = match.get_string(1).to_upper()
+ var color: Color
+ var color_code: String
+ var reset_code = "\u001b[0m"
+
+ # Try to parse the color string
+ if LoggieTools.NamedColors.has(color_str):
+ color = LoggieTools.NamedColors[color_str]
+ else:
+ color = Color(color_str)
+
+ if color:
+ color_code = color_to_ANSI(color)
+ else:
+ color_code = ""
+ reset_code = ""
+
+ var replacement = color_code + match.get_string(2) + reset_code
+ text = text.replace(match.get_string(0), replacement)
+
+ # Process bold and italic tags
+ var bold_on = "\u001b[1m"
+ var bold_off = "\u001b[22m"
+ var italic_on = "\u001b[3m"
+ var italic_off = "\u001b[23m"
+
+ text = text.replace("[b]", bold_on).replace("[/b]", bold_off)
+ text = text.replace("[i]", italic_on).replace("[/i]", italic_off)
+
+ # Remove any other BBCode tags but retain the text between them
+ var regex_bbcode = RegEx.new()
+ regex_bbcode.compile("\\[(b|/b|i|/i|color=[^\\]]+|/color)\\]")
+ text = regex_bbcode.sub(text, "", true)
+
+ return text
+
+## Returns a dictionary of call stack data related to the stack the call to this function is a part of.
+static func get_current_stack_frame_data() -> Dictionary:
+ var stack = get_stack()
+ const callerIndex = 3
+ var targetIndex = callerIndex if stack.size() >= callerIndex else stack.size() - 1
+
+ if stack.size() > 0:
+ return stack[targetIndex]
+ else:
+ return {
+ "source" : "UnknownStackFrameSource",
+ "line" : 0,
+ "function" : "UnknownFunction"
+ }
+
+## Returns the `class_name` of a script.
+## [br][param path_or_script] should be either an absolute path to the script
+## (String, e.g. "res://my_script.gd"), or a [Script] object.
+## [br][param proxy] defines which kind of text will be used as a replacement
+## for the class name if the script has no 'class_name'.
+static func get_class_name_from_script(path_or_script : Variant, proxy : LoggieEnums.NamelessClassExtensionNameProxy) -> String:
+ var script
+ var _class_name = ""
+
+ if path_or_script is String or path_or_script is StringName:
+ if !ResourceLoader.exists(path_or_script, "Script"):
+ return _class_name
+ script = load(path_or_script)
+ elif path_or_script is Script:
+ script = path_or_script
+
+ if not (script is Script):
+ push_error("Invalid 'path_or_script' param provided to get_class_name_from_script: {path}".format({"path" : path_or_script}))
+ else:
+ if not script.has_method("get_global_name"):
+ # User is using a pre-4.3 version of Godot that doesn't have Script.get_global_name.
+ # We must use a different method to achieve this then.
+ return extract_class_name_from_gd_script(path_or_script, proxy)
+
+ # Try to get the class name directly.
+ _class_name = script.get_global_name()
+
+ if _class_name != "":
+ return _class_name
+
+ # If that's empty, the script is either a base class, or a class without a name.
+ # Check if this script has a base script, and if so, use that one as the target whose name to obtain.
+ # If it doesn't have it, use what the [param proxy] demands.
+ var base_script = script.get_base_script()
+ if base_script != null:
+ return get_class_name_from_script(base_script, proxy)
+ else:
+ match proxy:
+ LoggieEnums.NamelessClassExtensionNameProxy.BASE_TYPE:
+ _class_name = script.get_instance_base_type()
+ LoggieEnums.NamelessClassExtensionNameProxy.SCRIPT_NAME:
+ _class_name = script.get_script_property_list().front()["name"]
+
+ return _class_name
+
+## Opens and reads a .gd script file to find out its 'class_name' or what it 'extends'.
+## [param path_or_script] should be either an absolute path to the script
+## (String, e.g. "res://my_script.gd"), or a [Script] object.
+## [br][param proxy] defines which kind of text will be used as a replacement
+## for the class name if the script has no 'class_name'.
+## [br][br][b]Note:[/b] This is a compatibility method that will be used on older versions of Godot which
+## don't support [method Script.get_global_name].
+static func extract_class_name_from_gd_script(path_or_script : Variant, proxy : LoggieEnums.NamelessClassExtensionNameProxy) -> String:
+ var path : String
+
+ if path_or_script is String:
+ path = path_or_script
+ elif path_or_script is Script:
+ path = path_or_script.resource_path
+ else:
+ push_error("Invalid 'path_or_script' param provided to extract_class_name_from_gd_script: {path}".format({"path" : path_or_script}))
+ return ""
+
+ var file = FileAccess.open(path, FileAccess.READ)
+ if not file:
+ return "File Open Error {filepath}".format({"filepath" : path})
+
+ var _class_name: String = ""
+
+ for line_num in 40: # Loop only up to 40 lines
+ if file.eof_reached():
+ break
+
+ var line = file.get_line().strip_edges()
+
+ if line.begins_with("class_name"):
+ _class_name = line.split(" ")[1]
+ break
+
+ if _class_name == "":
+ var script = load(path)
+ if script is Script:
+ match proxy:
+ LoggieEnums.NamelessClassExtensionNameProxy.BASE_TYPE:
+ _class_name = script.get_instance_base_type()
+ LoggieEnums.NamelessClassExtensionNameProxy.SCRIPT_NAME:
+ _class_name = script.get_script_property_list().front()["name"]
+
+ file.close()
+
+ return _class_name
+
+## A dictionary of named colors matching the constants from [Color] used to help with rich text coloring.
+## There may be a way to obtain these Color values without this dictionary if one can somehow check for the
+## existence and value of a constant on the Color class (since they're already there),
+## but I can't seem to find a way, so this will have to do for now.
+static var NamedColors = {
+ "ALICE_BLUE": Color(0.941176, 0.972549, 1, 1),
+ "ANTIQUE_WHITE": Color(0.980392, 0.921569, 0.843137, 1),
+ "AQUA": Color(0, 1, 1, 1),
+ "AQUAMARINE": Color(0.498039, 1, 0.831373, 1),
+ "AZURE": Color(0.941176, 1, 1, 1),
+ "BEIGE": Color(0.960784, 0.960784, 0.862745, 1),
+ "BISQUE": Color(1, 0.894118, 0.768627, 1),
+ "BLACK": Color(0, 0, 0, 1),
+ "BLANCHED_ALMOND": Color(1, 0.921569, 0.803922, 1),
+ "BLUE": Color(0, 0, 1, 1),
+ "BLUE_VIOLET": Color(0.541176, 0.168627, 0.886275, 1),
+ "BROWN": Color(0.647059, 0.164706, 0.164706, 1),
+ "BURLYWOOD": Color(0.870588, 0.721569, 0.529412, 1),
+ "CADET_BLUE": Color(0.372549, 0.619608, 0.627451, 1),
+ "CHARTREUSE": Color(0.498039, 1, 0, 1),
+ "CHOCOLATE": Color(0.823529, 0.411765, 0.117647, 1),
+ "CORAL": Color(1, 0.498039, 0.313726, 1),
+ "CORNFLOWER_BLUE": Color(0.392157, 0.584314, 0.929412, 1),
+ "CORNSILK": Color(1, 0.972549, 0.862745, 1),
+ "CRIMSON": Color(0.862745, 0.0784314, 0.235294, 1),
+ "CYAN": Color(0, 1, 1, 1),
+ "DARK_BLUE": Color(0, 0, 0.545098, 1),
+ "DARK_CYAN": Color(0, 0.545098, 0.545098, 1),
+ "DARK_GOLDENROD": Color(0.721569, 0.52549, 0.0431373, 1),
+ "DARK_GRAY": Color(0.662745, 0.662745, 0.662745, 1),
+ "DARK_GREEN": Color(0, 0.392157, 0, 1),
+ "DARK_KHAKI": Color(0.741176, 0.717647, 0.419608, 1),
+ "DARK_MAGENTA": Color(0.545098, 0, 0.545098, 1),
+ "DARK_OLIVE_GREEN": Color(0.333333, 0.419608, 0.184314, 1),
+ "DARK_ORANGE": Color(1, 0.54902, 0, 1),
+ "DARK_ORCHID": Color(0.6, 0.196078, 0.8, 1),
+ "DARK_RED": Color(0.545098, 0, 0, 1),
+ "DARK_SALMON": Color(0.913725, 0.588235, 0.478431, 1),
+ "DARK_SEA_GREEN": Color(0.560784, 0.737255, 0.560784, 1),
+ "DARK_SLATE_BLUE": Color(0.282353, 0.239216, 0.545098, 1),
+ "DARK_SLATE_GRAY": Color(0.184314, 0.309804, 0.309804, 1),
+ "DARK_TURQUOISE": Color(0, 0.807843, 0.819608, 1),
+ "DARK_VIOLET": Color(0.580392, 0, 0.827451, 1),
+ "DEEP_PINK": Color(1, 0.0784314, 0.576471, 1),
+ "DEEP_SKY_BLUE": Color(0, 0.74902, 1, 1),
+ "DIM_GRAY": Color(0.411765, 0.411765, 0.411765, 1),
+ "DODGER_BLUE": Color(0.117647, 0.564706, 1, 1),
+ "FIREBRICK": Color(0.698039, 0.133333, 0.133333, 1),
+ "FLORAL_WHITE": Color(1, 0.980392, 0.941176, 1),
+ "FOREST_GREEN": Color(0.133333, 0.545098, 0.133333, 1),
+ "FUCHSIA": Color(1, 0, 1, 1),
+ "GAINSBORO": Color(0.862745, 0.862745, 0.862745, 1),
+ "GHOST_WHITE": Color(0.972549, 0.972549, 1, 1),
+ "GOLD": Color(1, 0.843137, 0, 1),
+ "GOLDENROD": Color(0.854902, 0.647059, 0.12549, 1),
+ "GRAY": Color(0.745098, 0.745098, 0.745098, 1),
+ "GREEN": Color(0, 1, 0, 1),
+ "GREEN_YELLOW": Color(0.678431, 1, 0.184314, 1),
+ "HONEYDEW": Color(0.941176, 1, 0.941176, 1),
+ "HOT_PINK": Color(1, 0.411765, 0.705882, 1),
+ "INDIAN_RED": Color(0.803922, 0.360784, 0.360784, 1),
+ "INDIGO": Color(0.294118, 0, 804, 1),
+ "IVORY": Color(1, 1, 0.941176, 1),
+ "KHAKI": Color(0.941176, 0.901961, 0.54902, 1),
+ "LAVENDER": Color(0.901961, 0.901961, 0.980392, 1),
+ "LAVENDER_BLUSH": Color(1, 0.941176, 0.960784, 1),
+ "LAWN_GREEN": Color(0.486275, 0.988235, 0, 1),
+ "LEMON_CHIFFON": Color(1, 0.980392, 0.803922, 1),
+ "LIGHT_BLUE": Color(0.678431, 0.847059, 0.901961, 1),
+ "LIGHT_CORAL": Color(0.941176, 0.501961, 0.501961, 1),
+ "LIGHT_CYAN": Color(0.878431, 1, 1, 1),
+ "LIGHT_GOLDENROD": Color(0.980392, 0.980392, 0.823529, 1),
+ "LIGHT_GRAY": Color(0.827451, 0.827451, 0.827451, 1),
+ "LIGHT_GREEN": Color(0.564706, 0.933333, 0.564706, 1),
+ "LIGHT_PINK": Color(1, 0.713726, 0.756863, 1),
+ "LIGHT_SALMON": Color(1, 0.627451, 0.478431, 1),
+ "LIGHT_SEA_GREEN": Color(0.12549, 0.698039, 0.666667, 1),
+ "LIGHT_SKY_BLUE": Color(0.529412, 0.807843, 0.980392, 1),
+ "LIGHT_SLATE_GRAY": Color(0.466667, 0.533333, 0.6, 1),
+ "LIGHT_STEEL_BLUE": Color(0.690196, 0.768627, 0.870588, 1),
+ "LIGHT_YELLOW": Color(1, 1, 0.878431, 1),
+ "LIME": Color(0, 1, 0, 1),
+ "LIME_GREEN": Color(0.196078, 0.803922, 0.196078, 1),
+ "LINEN": Color(0.980392, 0.941176, 0.901961, 1),
+ "MAGENTA": Color(1, 0, 1, 1),
+ "MAROON": Color(0.690196, 0.188235, 0.376471, 1),
+ "MEDIUM_AQUAMARINE": Color(0.4, 0.803922, 0.666667, 1),
+ "MEDIUM_BLUE": Color(0, 0, 0.803922, 1),
+ "MEDIUM_ORCHID": Color(0.729412, 0.333333, 0.827451, 1),
+ "MEDIUM_PURPLE": Color(0.576471, 0.439216, 0.858824, 1),
+ "MEDIUM_SEA_GREEN": Color(0.235294, 0.701961, 0.443137, 1),
+ "MEDIUM_SLATE_BLUE": Color(0.482353, 0.407843, 0.933333, 1),
+ "MEDIUM_SPRING_GREEN": Color(0, 0.980392, 0.603922, 1),
+ "MEDIUM_TURQUOISE": Color(0.282353, 0.819608, 0.8, 1),
+ "MEDIUM_VIOLET_RED": Color(0.780392, 0.0823529, 0.521569, 1),
+ "MIDNIGHT_BLUE": Color(0.0980392, 0.0980392, 0.439216, 1),
+ "MINT_CREAM": Color(0.960784, 1, 0.980392, 1),
+ "MISTY_ROSE": Color(1, 0.894118, 0.882353, 1),
+ "MOCCASIN": Color(1, 0.894118, 0.709804, 1),
+ "NAVAJO_WHITE": Color(1, 0.870588, 0.678431, 1),
+ "NAVY_BLUE": Color(0, 0, 0.501961, 1),
+ "OLD_LACE": Color(0.992157, 0.960784, 0.901961, 1),
+ "OLIVE": Color(0.501961, 0.501961, 0, 1),
+ "OLIVE_DRAB": Color(0.419608, 0.556863, 0.137255, 1),
+ "ORANGE": Color(1, 0.647059, 0, 1),
+ "ORANGE_RED": Color(1, 0.270588, 0, 1),
+ "ORCHID": Color(0.854902, 0.439216, 0.839216, 1),
+ "PALE_GOLDENROD": Color(0.933333, 0.909804, 0.666667, 1),
+ "PALE_GREEN": Color(0.596078, 0.984314, 0.596078, 1),
+ "PALE_TURQUOISE": Color(0.686275, 0.933333, 0.933333, 1),
+ "PALE_VIOLET_RED": Color(0.858824, 0.439216, 0.576471, 1),
+ "PAPAYA_WHIP": Color(1, 0.937255, 0.835294, 1),
+ "PEACH_PUFF": Color(1, 0.854902, 0.72549, 1),
+ "PERU": Color(0.803922, 0.521569, 0.247059, 1),
+ "PINK": Color(1, 0.752941, 0.796078, 1),
+ "PLUM": Color(0.866667, 0.627451, 0.866667, 1),
+ "POWDER_BLUE": Color(0.690196, 0.878431, 0.901961, 1),
+ "PURPLE": Color(0.627451, 0.12549, 0.941176, 1),
+ "REBECCA_PURPLE": Color(0.4, 0.2, 0.6, 1),
+ "RED": Color(1, 0, 0, 1),
+ "ROSY_BROWN": Color(0.737255, 0.560784, 0.560784, 1),
+ "ROYAL_BLUE": Color(0.254902, 0.411765, 0.882353, 1),
+ "SADDLE_BROWN": Color(0.545098, 0.270588, 0.0745098, 1),
+ "SALMON": Color(0.980392, 0.501961, 0.447059, 1),
+ "SANDY_BROWN": Color(0.956863, 0.643137, 0.376471, 1),
+ "SEA_GREEN": Color(0.180392, 0.545098, 0.341176, 1),
+ "SEASHELL": Color(1, 0.960784, 0.933333, 1),
+ "SIENNA": Color(0.627451, 0.321569, 0.176471, 1),
+ "SILVER": Color(0.752941, 0.752941, 0.752941, 1),
+ "SKY_BLUE": Color(0.529412, 0.807843, 0.921569, 1),
+ "SLATE_BLUE": Color(0.415686, 0.352941, 0.803922, 1),
+ "SLATE_GRAY": Color(0.439216, 0.501961, 0.564706, 1),
+ "SNOW": Color(1, 0.980392, 0.980392, 1),
+ "SPRING_GREEN": Color(0, 1, 0.498039, 1),
+ "STEEL_BLUE": Color(0.27451, 0.509804, 0.705882, 1),
+ "TAN": Color(0.823529, 0.705882, 0.54902, 1),
+ "TEAL": Color(0, 0.501961, 0.501961, 1),
+ "THISTLE": Color(0.847059, 0.74902, 0.847059, 1),
+ "TOMATO": Color(1, 0.388235, 0.278431, 1),
+ "TRANSPARENT": Color(1, 1, 1, 0),
+ "TURQUOISE": Color(0.25098, 0.878431, 0.815686, 1),
+ "VIOLET": Color(0.933333, 0.509804, 0.933333, 1),
+ "WEB_GRAY": Color(0.501961, 0.501961, 0.501961, 1),
+ "WEB_GREEN": Color(0, 0.501961, 0, 1),
+ "WEB_MAROON": Color(0.501961, 0, 0, 1),
+ "WEB_PURPLE": Color(0.501961, 0, 0.501961, 1),
+ "WHEAT": Color(0.960784, 0.870588, 0.701961, 1),
+ "WHITE": Color(1, 1, 1, 1),
+ "WHITE_SMOKE": Color(0.960784, 0.960784, 0.960784, 1),
+ "YELLOW": Color(1, 1, 0, 1),
+ "YELLOW_GREEN": Color(0.603922, 0.803922, 0.196078, 1)
+}
diff --git a/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg b/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg
new file mode 100644
index 00000000..3fe1ebc8
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg.import
new file mode 100644
index 00000000..62a6bd46
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d1uver224k3px"
+path="res://.godot/imported/folder_open-white-18dp.svg-b9b09b2c311e4324f6ceb8d836d92307.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg"
+dest_files=["res://.godot/imported/folder_open-white-18dp.svg-b9b09b2c311e4324f6ceb8d836d92307.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/ui_design_tool/assets/icons/format-color-text.png b/addons/ui_design_tool/assets/icons/format-color-text.png
new file mode 100644
index 00000000..dc80ed32
Binary files /dev/null and b/addons/ui_design_tool/assets/icons/format-color-text.png differ
diff --git a/addons/ui_design_tool/assets/icons/format-color-text.png.import b/addons/ui_design_tool/assets/icons/format-color-text.png.import
new file mode 100644
index 00000000..8fe3e94e
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format-color-text.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b3tqua2bt1ix2"
+path="res://.godot/imported/format-color-text.png-cb1d0e154a77178073ac1079d1806720.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format-color-text.png"
+dest_files=["res://.godot/imported/format-color-text.png-cb1d0e154a77178073ac1079d1806720.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/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg
new file mode 100644
index 00000000..6ff29278
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg.import
new file mode 100644
index 00000000..db844ef0
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://i11r3de57bc3"
+path="res://.godot/imported/format_align_center-white-18dp.svg-223e2eb74ca8e39f9d4bf989291c7829.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg"
+dest_files=["res://.godot/imported/format_align_center-white-18dp.svg-223e2eb74ca8e39f9d4bf989291c7829.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/ui_design_tool/assets/icons/format_align_left-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg
new file mode 100644
index 00000000..fe4e62f7
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg.import
new file mode 100644
index 00000000..b50b80d8
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dtsld0omp3fy0"
+path="res://.godot/imported/format_align_left-white-18dp.svg-f4e62d6e31b71bc8605b920932857161.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg"
+dest_files=["res://.godot/imported/format_align_left-white-18dp.svg-f4e62d6e31b71bc8605b920932857161.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/ui_design_tool/assets/icons/format_align_right-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg
new file mode 100644
index 00000000..3a2cbfd7
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg.import
new file mode 100644
index 00000000..35abfa76
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d0t8qupuaoigg"
+path="res://.godot/imported/format_align_right-white-18dp.svg-639ae8d469d29b7a7afdff99480dfa70.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg"
+dest_files=["res://.godot/imported/format_align_right-white-18dp.svg-639ae8d469d29b7a7afdff99480dfa70.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/ui_design_tool/assets/icons/format_bold-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg
new file mode 100644
index 00000000..c207f685
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg.import
new file mode 100644
index 00000000..3041f157
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3xaf7s36xuqc"
+path="res://.godot/imported/format_bold-white-18dp.svg-dd70eba3f014196757627e0aea4304f1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg"
+dest_files=["res://.godot/imported/format_bold-white-18dp.svg-dd70eba3f014196757627e0aea4304f1.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/ui_design_tool/assets/icons/format_clear-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg
new file mode 100644
index 00000000..ba12136f
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg.import
new file mode 100644
index 00000000..751b8276
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://xnn5xt6piaat"
+path="res://.godot/imported/format_clear-white-18dp.svg-47d87e370b9f3dc70b33de4a26f02608.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg"
+dest_files=["res://.godot/imported/format_clear-white-18dp.svg-47d87e370b9f3dc70b33de4a26f02608.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/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg
new file mode 100644
index 00000000..d3383dbc
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg.import
new file mode 100644
index 00000000..7b1c409e
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8qawl7hrofkj"
+path="res://.godot/imported/format_color_reset-white-18dp.svg-e433d2e99c38830ed08d7fa1f97f9a11.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg"
+dest_files=["res://.godot/imported/format_color_reset-white-18dp.svg-e433d2e99c38830ed08d7fa1f97f9a11.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/ui_design_tool/assets/icons/format_italic-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg
new file mode 100644
index 00000000..56d7c78b
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg.import
new file mode 100644
index 00000000..946dc168
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ck4h5hqubttt7"
+path="res://.godot/imported/format_italic-white-18dp.svg-7e46946409e5ba47a73f9c86ddf1ce61.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg"
+dest_files=["res://.godot/imported/format_italic-white-18dp.svg-7e46946409e5ba47a73f9c86ddf1ce61.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/ui_design_tool/assets/icons/format_underlined-white-18dp.svg b/addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg
new file mode 100644
index 00000000..38285420
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg.import
new file mode 100644
index 00000000..3bf4f567
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b44il4qj7cem1"
+path="res://.godot/imported/format_underlined-white-18dp.svg-b2765a4e60c3b18727158aebc6b78640.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg"
+dest_files=["res://.godot/imported/format_underlined-white-18dp.svg-b2765a4e60c3b18727158aebc6b78640.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/ui_design_tool/assets/icons/marker.png b/addons/ui_design_tool/assets/icons/marker.png
new file mode 100644
index 00000000..f164b44c
Binary files /dev/null and b/addons/ui_design_tool/assets/icons/marker.png differ
diff --git a/addons/ui_design_tool/assets/icons/marker.png.import b/addons/ui_design_tool/assets/icons/marker.png.import
new file mode 100644
index 00000000..cc8a8594
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/marker.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d1rj7h72swjhn"
+path="res://.godot/imported/marker.png-3deee63f805205d2092032fd6772df3e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/marker.png"
+dest_files=["res://.godot/imported/marker.png-3deee63f805205d2092032fd6772df3e.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/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg b/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg
new file mode 100644
index 00000000..68bd1bf4
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg.import
new file mode 100644
index 00000000..3cd26556
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cm5d77b25dgjc"
+path="res://.godot/imported/more_horiz-white-18dp.svg-2292c39c5fef87774f0dcabbf9749663.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg"
+dest_files=["res://.godot/imported/more_horiz-white-18dp.svg-2292c39c5fef87774f0dcabbf9749663.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/ui_design_tool/assets/icons/more_vert-white-18dp.svg b/addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg
new file mode 100644
index 00000000..d6fdb4fa
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg.import
new file mode 100644
index 00000000..0e23a1bd
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://crte1qj0ftynh"
+path="res://.godot/imported/more_vert-white-18dp.svg-f9ce1c1392fbe43035b0f9c38f40fd8c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg"
+dest_files=["res://.godot/imported/more_vert-white-18dp.svg-f9ce1c1392fbe43035b0f9c38f40fd8c.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/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg b/addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg
new file mode 100644
index 00000000..14d13a89
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg.import
new file mode 100644
index 00000000..eda34a46
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cqv3uc8bew0am"
+path="res://.godot/imported/photo_size_select_small-white-18dp.svg-a132cc84485fb38b8289f82a1cfb4be4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg"
+dest_files=["res://.godot/imported/photo_size_select_small-white-18dp.svg-a132cc84485fb38b8289f82a1cfb4be4.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/ui_design_tool/assets/icons/refresh-white-18dp.svg b/addons/ui_design_tool/assets/icons/refresh-white-18dp.svg
new file mode 100644
index 00000000..b4e78cd0
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/refresh-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/refresh-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/refresh-white-18dp.svg.import
new file mode 100644
index 00000000..be95635b
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/refresh-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dn7q7grbfr7kh"
+path="res://.godot/imported/refresh-white-18dp.svg-8592ca638cd7e6c945a15796e8610b7c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/refresh-white-18dp.svg"
+dest_files=["res://.godot/imported/refresh-white-18dp.svg-8592ca638cd7e6c945a15796e8610b7c.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/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg b/addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg
new file mode 100644
index 00000000..74dc02cf
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg.import
new file mode 100644
index 00000000..80ea18aa
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://rpjhdv5qake3"
+path="res://.godot/imported/vertical_align_bottom-white-18dp.svg-d38142e787fc53732b40c7e09204caed.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg"
+dest_files=["res://.godot/imported/vertical_align_bottom-white-18dp.svg-d38142e787fc53732b40c7e09204caed.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/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg b/addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg
new file mode 100644
index 00000000..dd7d5439
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg.import
new file mode 100644
index 00000000..aa82fc88
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ckriw8d4yelu"
+path="res://.godot/imported/vertical_align_center-white-18dp.svg-ff9e4504ee166be50beb982105c87414.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg"
+dest_files=["res://.godot/imported/vertical_align_center-white-18dp.svg-ff9e4504ee166be50beb982105c87414.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/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg b/addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg
new file mode 100644
index 00000000..c9c6f0df
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg.import b/addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg.import
new file mode 100644
index 00000000..7790e831
--- /dev/null
+++ b/addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjan2dq5nvdvk"
+path="res://.godot/imported/vertical_align_top-white-18dp.svg-baa4704503a2c09de95348bc71c911d2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg"
+dest_files=["res://.godot/imported/vertical_align_top-white-18dp.svg-baa4704503a2c09de95348bc71c911d2.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/ui_design_tool/plugin.cfg b/addons/ui_design_tool/plugin.cfg
new file mode 100644
index 00000000..1bb9e475
--- /dev/null
+++ b/addons/ui_design_tool/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="UI Design Tool"
+description=""
+author="imjp94"
+version="0.2.2"
+script="plugin.gd"
diff --git a/addons/ui_design_tool/plugin.gd b/addons/ui_design_tool/plugin.gd
new file mode 100644
index 00000000..3940d5a8
--- /dev/null
+++ b/addons/ui_design_tool/plugin.gd
@@ -0,0 +1,85 @@
+@tool
+extends EditorPlugin
+const Toolbar = preload("scenes/Toolbar.tscn")
+const OverlayTextEdit = preload("scenes/OverlayTextEdit.tscn")
+
+var toolbar
+var overlay_text_edit
+
+var editor_inspector = get_editor_interface().get_inspector()
+var editor_selection = get_editor_interface().get_selection()
+
+
+func _enter_tree():
+ toolbar = Toolbar.instantiate()
+ toolbar.undo_redo = get_undo_redo()
+ toolbar.connect("property_edited", _on_Toolbar_property_edited)
+ overlay_text_edit = OverlayTextEdit.instantiate()
+ overlay_text_edit.undo_redo = get_undo_redo()
+ overlay_text_edit.connect("property_edited", _on_OverlayTextEdit_property_edited)
+
+ editor_inspector.connect("property_selected", _on_property_selected)
+ editor_selection.connect("selection_changed", _on_selection_changed)
+
+ add_control_to_container(EditorPlugin.CONTAINER_CANVAS_EDITOR_BOTTOM, toolbar)
+ add_control_to_container(EditorPlugin.CONTAINER_CANVAS_EDITOR_BOTTOM, overlay_text_edit)
+
+func _exit_tree():
+ if toolbar:
+ toolbar.queue_free()
+ if overlay_text_edit:
+ overlay_text_edit.queue_free()
+
+func _handles(object):
+ if object is Control:
+ _make_visible(true)
+ return true
+ _make_visible(false)
+ return false
+
+func _forward_canvas_gui_input(event):
+ if event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ if event.double_click: # Always false when selected multiple nodes
+ if toolbar.focused_objects:
+ overlay_text_edit.popup()
+ return true
+ return false
+
+func _make_visible(visible):
+ if toolbar:
+ toolbar.visible = visible
+ # overlay_text_edit only visible on double click
+
+func _on_property_selected(property):
+ toolbar.focused_property = property
+ toolbar.focused_inspector = editor_inspector.get_viewport().gui_get_focus_owner()
+
+func _on_selection_changed():
+ var selections = editor_selection.get_selected_nodes()
+ var is_visible = false
+ var focused_objects = []
+ if selections.size() == 1:
+ var selection = selections[0]
+ if selection is Control:
+ focused_objects = [selection]
+ is_visible = true
+ elif selections.size() > 1:
+ var has_non_control = false
+ for selection in selections:
+ if not (selection is Control):
+ has_non_control = true
+ break
+ if not has_non_control:
+ is_visible = true
+ focused_objects = selections
+
+ toolbar.visible = is_visible
+ toolbar.focused_objects = focused_objects
+ overlay_text_edit.focused_objects = focused_objects
+
+func _on_Toolbar_property_edited(property):
+ pass
+
+func _on_OverlayTextEdit_property_edited(property):
+ pass
diff --git a/addons/ui_design_tool/scenes/OverlayTextEdit.gd b/addons/ui_design_tool/scenes/OverlayTextEdit.gd
new file mode 100644
index 00000000..ed44c462
--- /dev/null
+++ b/addons/ui_design_tool/scenes/OverlayTextEdit.gd
@@ -0,0 +1,58 @@
+@tool
+extends TextEdit
+
+signal property_edited(property)
+
+var focused_objects
+var undo_redo
+
+var _object_orig_text = ""
+
+func _ready():
+ set_as_top_level(true)
+ connect("focus_exited", _on_focused_exited)
+ connect("text_changed", _on_text_changed)
+ hide()
+
+func _on_text_changed():
+ if focused_objects:
+ # TODO: Option to set bbcode_text if is RichTextLabel
+ focused_objects.back().set("text", text)
+
+func _on_focused_exited():
+ if get_menu().visible: # Support right-click context menu
+ return
+
+ hide()
+ # TODO: More efficient way to handle undo/redo of text, right now, whole chunks of string is cached everytime
+ change_text(focused_objects.back(), text)
+
+# Popup at mouse position
+func popup():
+ if focused_objects == null:
+ return
+
+ var focused_object = focused_objects.back()
+ if not ("text" in focused_object):
+ return
+
+ show()
+ global_position = get_viewport().get_mouse_position()
+ size = focused_object.size
+ text = focused_object.text
+ grab_focus()
+
+ _object_orig_text = focused_object.text
+
+# Change text with undo/redo
+func change_text(object, to):
+ var from = _object_orig_text
+ undo_redo.create_action("Change Text")
+ undo_redo.add_do_method(self, "set_object_text", object, to)
+ undo_redo.add_undo_method(self, "set_object_text", object, from)
+ undo_redo.commit_action()
+ _object_orig_text = ""
+
+func set_object_text(object, text):
+ object.set("text", text)
+ emit_signal("property_edited", "text")
diff --git a/addons/ui_design_tool/scenes/OverlayTextEdit.tscn b/addons/ui_design_tool/scenes/OverlayTextEdit.tscn
new file mode 100644
index 00000000..448db9c4
--- /dev/null
+++ b/addons/ui_design_tool/scenes/OverlayTextEdit.tscn
@@ -0,0 +1,31 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://addons/ui_design_tool/scenes/OverlayTextEdit.gd" type="Script" id=1]
+
+[sub_resource type="StyleBoxFlat" id=1]
+bg_color = Color( 1, 1, 1, 0 )
+
+[node name="OverlayTextEdit" type="TextEdit"]
+offset_right = 300.0
+offset_bottom = 200.0
+minimum_size = Vector2( 300, 200 )
+custom_styles/read_only = SubResource( 1 )
+custom_styles/focus = SubResource( 1 )
+custom_styles/normal = SubResource( 1 )
+custom_styles/completion = SubResource( 1 )
+fold_gutter = true
+caret_blink = true
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Panel" type="Panel" parent="."]
+self_modulate = Color( 1, 1, 1, 0.588235 )
+show_behind_parent = true
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+__meta__ = {
+"_edit_use_anchors_": false
+}
diff --git a/addons/ui_design_tool/scenes/Toolbar.gd b/addons/ui_design_tool/scenes/Toolbar.gd
new file mode 100644
index 00000000..647ce79c
--- /dev/null
+++ b/addons/ui_design_tool/scenes/Toolbar.gd
@@ -0,0 +1,899 @@
+@tool
+extends Control
+const Utils = preload("../scripts/Utils.gd")
+const FontManager = preload("../scripts/FontManager.gd")
+
+signal property_edited(name) # Emitted when property edited, mainly to notify inspector refresh
+
+# Config file to save user preference
+const CONFIG_DIR = "res://addons/ui_design_tool/user_pref.cfg" # Must be abosulte path
+const CONFIG_SECTION_META = "path"
+const CONFIG_KEY_FONTS_DIR = "fonts_dir" # Directory to fonts resource
+# Generic font properties
+const PROPERTY_FONT_COLOR = "theme_override_colors/font_color"
+const PROPERTY_FONT = "theme_override_fonts/font"
+const PROPERTY_FONT_SIZE = "theme_override_font_sizes/font_size"
+# RichTextLabel font properties
+const PROPERTY_FONT_NORMAL = "theme_override_fonts/normal_font"
+const PROPERTY_FONT_BOLD = "theme_override_fonts/bold_font"
+const PROPERTY_FONT_ITALIC = "theme_override_fonts/italics_font"
+const PROPERTY_FONT_BOLD_ITALIC = "theme_override_fonts/bold_italics_font"
+const PROPERTY_FONT_COLOR_DEFAULT = "theme_override_colors/default_color"
+# Others generic properties
+const PROPERTY_HIGHLIGHT = "theme_override_styles/normal"
+const PROPERTY_HIGHLIGHT_PANEL = "theme_override_styles/panel"
+const PROPERTY_HORIZONTAL_ALIGNMENT = "horizontal_alignment"
+const PROPERTY_VERTICAL_ALIGNMENT = "vertical_alignment"
+
+const DEFAULT_FONT_SIZE = 16
+const FONT_FAMILY_REFERENCE_STRING = "____________" # Reference text to calculate display size of FontFamily
+const FONT_FORMATTING_REFERENCE_STRING = "HEADING_1_" # Reference text to calculate display size of FontFormatting
+
+# Toolbar UI
+@onready var FontFamily = $FontFamily
+@onready var FontFamilyOptions = $FontFamilyOptions
+@onready var FontFamilyOptionsPopupMenu = $FontFamilyOptions/PopupMenu
+@onready var FontFamilyFileDialog = $FontFamilyFileDialog
+@onready var FontSize = $FontSize
+@onready var FontSizePreset = $FontSize/FontSizePreset
+@onready var Bold = $Bold
+@onready var BoldPopupMenu = $Bold/PopupMenu
+@onready var Italic = $Italic
+@onready var Underline = $Underline
+@onready var FontColor = $FontColor
+@onready var FontColorColorRect = $FontColor/ColorRect
+@onready var FontColorColorPicker = $FontColor/PopupPanel/ColorPicker
+@onready var FontColorPopupPanel = $FontColor/PopupPanel
+@onready var Highlight = $Highlight
+@onready var HighlightColorRect = $Highlight/ColorRect
+@onready var HighlightColorPicker = $Highlight/PopupPanel/ColorPicker
+@onready var HighlightPopupPanel = $Highlight/PopupPanel
+@onready var HorizontalAlign = $HorizontalAlign
+@onready var HorizontalAlignPopupMenu = $HorizontalAlign/PopupMenu
+@onready var VerticalAlign = $VerticalAlign
+@onready var VerticalAlignPopupMenu = $VerticalAlign/PopupMenu
+@onready var FontFormatting = $FontFormatting
+@onready var Tools = $Tools
+@onready var ToolsPopupMenu = $Tools/PopupMenu
+
+# Reference passed down from EditorPlugin
+var focused_objects = [] : # Editor editing object
+ set(objs): # focused_objects setter, mainly called from EditorPlugin
+ var has_changed = false
+
+ if not objs.is_empty():
+ if focused_objects.size() == 1 and objs.size() == 1:
+ # Single selection changed
+ has_changed = focused_objects.back() != objs.back()
+ else:
+ has_changed = true
+ else:
+ if not focused_objects.is_empty():
+ has_changed = true
+
+ if has_changed:
+ focused_objects = objs
+ _on_focused_object_changed(focused_objects)
+var focused_property : # Editor editing property
+ set(prop): # focused_property setter, mainly called from EditorPlugin
+ if focused_property != prop:
+ focused_property = prop
+ _on_focused_property_changed(focused_property)
+var focused_inspector : # Editor editing inspector
+ set(insp): # focused_inspector setter, mainly called from EditorPlugin
+ if focused_inspector != insp:
+ focused_inspector = insp
+ _on_focused_inspector_changed(focused_inspector)
+var undo_redo
+
+var selected_font_root_dir = "res://"
+var font_manager = FontManager.new() # Manager of loaded fonts from fonts_dir
+var config = ConfigFile.new() # Config file of user preference
+
+var _is_visible_yet = false # Always True after it has visible once, mainly used to auto load fonts
+var _object_orig_font_color = Color.WHITE # Font color of object when FontColor pressed
+var _object_orig_highlight # Highlight(StyleBoxFlat) when Highlight pressed
+var _object_orig_font_formatting # FontManager.FontFormatting object when FontFormatting item selected
+
+
+func _init():
+ var result = config.load(CONFIG_DIR)
+ if result:
+ match result:
+ ERR_FILE_NOT_FOUND:
+ pass
+ _:
+ push_warning("UI Design Tool: An error occurred when trying to access %s, ERROR: %d" % [CONFIG_DIR, result])
+
+func _ready():
+ hide()
+ connect("visibility_changed", _on_visibility_changed)
+ # FontFamily
+ FontFamily.clip_text = true
+ FontFamily.custom_minimum_size.x = Utils.get_option_button_display_size(FontFamily, FONT_FAMILY_REFERENCE_STRING).x
+ FontFamily.connect("item_selected", _on_FontFamily_item_selected)
+ FontFamilyOptions.connect("pressed", _on_FontFamilyOptions_pressed)
+ FontFamilyOptionsPopupMenu.connect("id_pressed", _on_FontFamilyOptionsPopupMenu_id_pressed)
+ FontFamilyFileDialog.connect("dir_selected", _on_FontFamilyFileDialog_dir_selected)
+ # FontSize
+ FontSizePreset.connect("item_selected", _on_FontSizePreset_item_selected)
+ FontSize.connect("text_submitted", _on_FontSize_text_entered)
+ # Bold
+ Bold.connect("pressed", _on_Bold_pressed)
+ BoldPopupMenu.connect("id_pressed", _on_BoldPopupMenu_id_pressed)
+ # Italic
+ Italic.connect("pressed", _on_Italic_pressed)
+ # FontColor
+ FontColor.connect("pressed", _on_FontColor_pressed)
+ FontColorColorPicker.connect("color_changed", _on_FontColor_ColorPicker_color_changed)
+ FontColorPopupPanel.connect("popup_hide", _on_FontColor_PopupPanel_popup_hide)
+ # Highlight
+ Highlight.connect("pressed", _on_Highlight_pressed)
+ HighlightColorPicker.connect("color_changed", _on_Highlight_ColorPicker_color_changed)
+ HighlightPopupPanel.connect("popup_hide", _on_Highlight_PopupPanel_popup_hide)
+ # HorizontalAlign
+ HorizontalAlign.connect("pressed", _on_HorizontalAlign_pressed)
+ HorizontalAlignPopupMenu.connect("id_pressed", _on_HorizontalAlignPopupMenu_id_pressed)
+ HorizontalAlignPopupMenu.set_item_metadata(0, HORIZONTAL_ALIGNMENT_LEFT)
+ HorizontalAlignPopupMenu.set_item_metadata(1, HORIZONTAL_ALIGNMENT_CENTER)
+ HorizontalAlignPopupMenu.set_item_metadata(2, HORIZONTAL_ALIGNMENT_RIGHT)
+ # VerticalAlign
+ VerticalAlign.connect("pressed", _on_VerticalAlign_pressed)
+ VerticalAlignPopupMenu.connect("id_pressed", _on_VerticalAlignPopupMenu_id_pressed)
+ VerticalAlignPopupMenu.set_item_metadata(0, VERTICAL_ALIGNMENT_TOP)
+ VerticalAlignPopupMenu.set_item_metadata(1, VERTICAL_ALIGNMENT_CENTER)
+ VerticalAlignPopupMenu.set_item_metadata(2, VERTICAL_ALIGNMENT_BOTTOM)
+ # FontFormatting
+ FontFormatting.clip_text = true
+ FontFormatting.custom_minimum_size.x = Utils.get_option_button_display_size(FontFormatting, FONT_FORMATTING_REFERENCE_STRING).x
+ FontFormatting.connect("item_selected", _on_FontFormatting_item_selected)
+ # Tools
+ Tools.connect("pressed", _on_Tools_pressed)
+ ToolsPopupMenu.connect("id_pressed", _on_ToolsPopupMenu_id_pressed)
+
+func _on_visibility_changed():
+ if not _is_visible_yet and visible:
+ var fonts_dir = config.get_value(CONFIG_SECTION_META, CONFIG_KEY_FONTS_DIR, "")
+ if not fonts_dir.is_empty():
+ FontFamilyFileDialog.current_path = fonts_dir
+ _on_FontFamilyFileDialog_dir_selected(fonts_dir)
+ _is_visible_yet = true
+
+# Change font object with undo/redo
+func change_font(object, to):
+ var from = object.get(PROPERTY_FONT)
+ undo_redo.create_action("Change Font")
+ undo_redo.add_do_method(self, "set_font", object, to if to else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_font", object, from if from else false)
+ undo_redo.commit_action()
+
+# Change font data of font object with undo/redo
+func change_font_data(object, to):
+ var from = object.get(PROPERTY_FONT).base_font
+ undo_redo.create_action("Change Font Data")
+ undo_redo.add_do_method(self, "set_font_data", object, to if to else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_font_data", object, from if from else false)
+ undo_redo.commit_action()
+
+# Change rich text fonts with undo/redo
+func change_rich_text_fonts(object, to):
+ var from = {}
+ from["regular"] = object.get(PROPERTY_FONT_NORMAL)
+ from["bold"] = object.get(PROPERTY_FONT_BOLD)
+ from["regular_italic"] = object.get(PROPERTY_FONT_ITALIC)
+ from["bold_italic"] = object.get(PROPERTY_FONT_BOLD_ITALIC)
+ undo_redo.create_action("Change Rich Text Fonts")
+ undo_redo.add_do_method(self, "set_rich_text_fonts", object, to if to else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_rich_text_fonts", object, from if from else false)
+ undo_redo.commit_action()
+
+# Change font size with undo/redo
+func change_font_size(object, to):
+ var from = object.get(PROPERTY_FONT_SIZE)
+ undo_redo.create_action("Change Font Size")
+ undo_redo.add_do_method(self, "set_font_size", object, to)
+ undo_redo.add_undo_method(self, "set_font_size", object, from)
+ undo_redo.commit_action()
+
+# Change font color with undo/redo
+func change_font_color(object, to):
+ var from = _object_orig_font_color
+ undo_redo.create_action("Change Font Color")
+ undo_redo.add_do_method(self, "set_font_color", object, to if to is Color else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_font_color", object, from if from is Color else false)
+ undo_redo.commit_action()
+
+# Change highlight(StyleBoxFlat) with undo/redo
+func change_highlight(object, to):
+ var from = _object_orig_highlight
+ undo_redo.create_action("Change Highlight")
+ undo_redo.add_do_method(self, "set_highlight", object, to if to else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_highlight", object, from if from else false)
+ undo_redo.commit_action()
+
+# Change horizontal alignment with undo/redo
+func change_horizontal_alignment(object, to):
+ var from = object.get(PROPERTY_HORIZONTAL_ALIGNMENT)
+ undo_redo.create_action("Change Horizontal Alignment")
+ undo_redo.add_do_method(self, "set_horizontal_alignment", object, to)
+ undo_redo.add_undo_method(self, "set_horizontal_alignment", object, from)
+ undo_redo.commit_action()
+
+# Change vertical alignment with undo/redo
+func change_vertical_alignment(object, to):
+ var from = object.get(PROPERTY_VERTICAL_ALIGNMENT)
+ undo_redo.create_action("Change Vertical Alignment")
+ undo_redo.add_do_method(self, "set_vertical_alignment", object, to)
+ undo_redo.add_undo_method(self, "set_vertical_alignment", object, from)
+ undo_redo.commit_action()
+
+# Change font style(FontManager.FontFormatting) with undo/redo
+func change_font_formatting(object, to):
+ var from = _object_orig_font_formatting
+ undo_redo.create_action("Change Font Style")
+ undo_redo.add_do_method(self, "set_font_formatting", object, to if to else false) # Godot bug, varargs ignore null
+ undo_redo.add_undo_method(self, "set_font_formatting", object, from if from else false)
+ undo_redo.commit_action()
+
+# Reflect font name of focused_objects to toolbar
+func reflect_font_family_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var font_variation = obj.get(PROPERTY_FONT) if obj else null
+ if font_variation:
+ if font_variation.base_font:
+ var font_face = font_manager.get_font_face(font_variation.base_font)
+ if font_face:
+ for i in FontFamily.get_item_count():
+ var font_family_name = FontFamily.get_item_text(i)
+ if font_family_name == font_face.font_family:
+ FontFamily.tooltip_text = font_family_name
+ FontFamily.selected = i
+ reflect_font_weight_control()
+ return
+
+ FontFamily.tooltip_text = "Font Family"
+ reset_font_family_control()
+
+# Reflect font weight of focused_objects to toolbar, always call reflect_font_family_control first
+func reflect_font_weight_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var font_variation = obj.get(PROPERTY_FONT) if obj else null
+ if font_variation:
+ if font_variation.base_font:
+ var font_face = font_manager.get_font_face(font_variation.base_font)
+ if font_face:
+ var font_weight = font_face.font_weight
+
+ for i in BoldPopupMenu.get_item_count():
+ if font_weight.replace("-", "_") == BoldPopupMenu.get_item_text(i).to_lower().replace("-", "_"):
+ Bold.tooltip_text = BoldPopupMenu.get_item_text(i)
+ return true
+ return false
+
+# Reflect font size of focused_objects to toolbar, always call reflect_font_family_control first
+func reflect_font_size_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var has_font_size = PROPERTY_FONT_SIZE in obj
+ FontSize.mouse_filter = Control.MOUSE_FILTER_IGNORE if not has_font_size else Control.MOUSE_FILTER_STOP
+ FontSizePreset.disabled = not has_font_size
+ var font_size_color = Color.WHITE
+ font_size_color.a = 0.5 if not has_font_size else 1
+ FontSize.set(PROPERTY_FONT_COLOR, font_size_color)
+ var font_size = obj.get(PROPERTY_FONT_SIZE) if obj else null
+ if has_font_size and font_size == null:
+ font_size = DEFAULT_FONT_SIZE
+ FontSize.text = str(font_size) if font_size else str(DEFAULT_FONT_SIZE)
+
+# Reflect bold/italic of focused_objects to toolbar, always call reflect_font_family_control first
+func reflect_bold_italic_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ if FontFamily.get_item_count():
+ var font_family_name = FontFamily.get_item_text(FontFamily.selected)
+ # TODO: Better way to get current item text from PopupMenu than tooltip_text
+ var font_weight = Bold.tooltip_text.to_lower().replace("-", "_")
+ var font_family = font_manager.get_font_family(font_family_name)
+
+ Bold.disabled = font_family == null
+ var font_variation = obj.get(PROPERTY_FONT) if obj else null
+ if font_variation:
+ var font_face = font_manager.get_font_face(font_variation.base_font)
+ if font_face:
+ var is_italic = font_face.font_style == FontManager.FONT_STYLE.ITALIC
+ Italic.button_pressed = is_italic
+ if not is_italic:
+ if font_family:
+ Italic.disabled = not ("italic" in font_family.get(font_weight))
+ else:
+ Italic.disabled = true
+ else:
+ Italic.disabled = false
+ else:
+ Italic.button_pressed = false
+ Italic.disabled = true
+
+ var is_none = font_family_name == "None"
+ var font_weights = FontManager.FONT_WEIGHT.keys()
+ for i in font_weights.size():
+ var font_face = font_family.get(font_weights[i]) if font_family else null
+ var font_data = font_face.normal if font_face else null
+ BoldPopupMenu.set_item_disabled(i, true if is_none else font_data == null)
+ else:
+ Bold.disabled = true
+ Italic.disabled = true
+ Bold.button_pressed = false
+ Italic.button_pressed = false
+
+# Reflect font color of focused_objects to toolbar
+func reflect_font_color_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var focused_object_font_color = obj.get(PROPERTY_FONT_COLOR) if obj else null
+ var font_color = Color.WHITE
+ if focused_object_font_color != null:
+ font_color = focused_object_font_color
+ FontColorColorRect.color = font_color
+ FontColorColorPicker.color = font_color
+
+# Reflect highlight color of focused_objects to toolbar
+func reflect_highlight_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var focused_object_highlight = obj.get(PROPERTY_HIGHLIGHT) if obj else null
+ if obj is Panel or obj is PanelContainer:
+ focused_object_highlight = obj.get(PROPERTY_HIGHLIGHT_PANEL) if obj else null
+
+ var highlight_color = Color.WHITE # default modulate color
+ if focused_object_highlight != null:
+ if focused_object_highlight is StyleBoxFlat:
+ highlight_color = focused_object_highlight.bg_color
+ HighlightColorRect.color = highlight_color
+ HighlightColorPicker.color = highlight_color
+
+# Reflect horizontal alignment of focused_objects to toolbar
+func reflect_horizontal_alignment_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var h_align = obj.get(PROPERTY_HORIZONTAL_ALIGNMENT) if obj else null
+ if h_align != null:
+ var icon
+ HorizontalAlign.disabled = false
+ match h_align:
+ HORIZONTAL_ALIGNMENT_LEFT:
+ icon = HorizontalAlignPopupMenu.get_item_icon(0)
+ HORIZONTAL_ALIGNMENT_CENTER:
+ icon = HorizontalAlignPopupMenu.get_item_icon(1)
+ HORIZONTAL_ALIGNMENT_RIGHT:
+ icon = HorizontalAlignPopupMenu.get_item_icon(2)
+ if icon:
+ HorizontalAlign.icon = icon
+ else:
+ HorizontalAlign.disabled = true
+
+func reflect_vertical_alignment_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ var v_align = obj.get(PROPERTY_VERTICAL_ALIGNMENT) if obj else null
+ if v_align != null:
+ var icon
+ VerticalAlign.disabled = false
+ match v_align:
+ VERTICAL_ALIGNMENT_TOP:
+ icon = VerticalAlignPopupMenu.get_item_icon(0)
+ VERTICAL_ALIGNMENT_CENTER:
+ icon = VerticalAlignPopupMenu.get_item_icon(1)
+ VERTICAL_ALIGNMENT_BOTTOM:
+ icon = VerticalAlignPopupMenu.get_item_icon(2)
+ if icon:
+ VerticalAlign.icon = icon
+ else:
+ VerticalAlign.disabled = true
+
+# Reflect font style of focused_objects to toolbar, it only check if focused_objects can applied with style
+func reflect_font_formatting_control():
+ var obj = focused_objects.back() if focused_objects else null
+ if not obj:
+ return
+
+ # Font Style is not required to be accurate
+ var font_variation = obj.get(PROPERTY_FONT) if obj else null
+ FontFormatting.disabled = font_variation == null
+
+# Reset font name on toolbar
+func reset_font_family_control():
+ if FontFamily.get_item_count():
+ FontFamily.selected = FontFamily.get_item_count() - 1
+
+func _on_FontFamily_item_selected(index):
+ if focused_objects == null:
+ return
+
+ var font_family_name = FontFamily.get_item_text(index)
+ if font_family_name == "None":
+ _on_FontClear_pressed()
+ return
+
+ var font_family = font_manager.get_font_family(font_family_name)
+ if not font_family:
+ return
+
+ for obj in focused_objects:
+ if obj is RichTextLabel:
+ var to = {}
+ to["regular"] = create_new_font_obj(font_family.regular.normal.data) if font_family.regular.get("normal") else null
+ to["bold"] = create_new_font_obj(font_family.bold.normal.data) if font_family.bold.get("normal") else null
+ to["regular_italic"] = create_new_font_obj(font_family.regular.italic.data) if font_family.regular.get("italic") else null
+ to["bold_italic"] = create_new_font_obj(font_family.bold.italic.data) if font_family.bold.get("italic") else null
+ change_rich_text_fonts(obj, to)
+ else:
+ var font_variation = obj.get(PROPERTY_FONT)
+ if not font_variation:
+ var font_size = FontSizePreset.get_item_text(FontSizePreset.selected).to_int()
+ font_variation = create_new_font_obj(font_family.regular.normal.data)
+ change_font(obj, font_variation)
+ else:
+ change_font_data(obj, font_family.regular.normal.data) # TODO: Get fallback weight if regular not found
+
+
+func _on_FontFamilyOptions_pressed():
+ if focused_objects:
+ Utils.popup_on_target(FontFamilyOptionsPopupMenu, FontFamilyOptions)
+
+func _on_FontFamilyOptionsPopupMenu_id_pressed(index):
+ match index:
+ 0:
+ FontFamilyFileDialog.popup_centered(Vector2(600, 400))
+ 1:
+ _on_FontFamilyFileDialog_dir_selected(selected_font_root_dir)
+
+func _on_FontFamilyFileDialog_dir_selected(dir):
+ selected_font_root_dir = dir
+ # Load fonts
+ if font_manager.load_root_dir(dir):
+ FontFamily.clear()
+ for font_family in font_manager.font_families.values():
+ FontFamily.add_item(font_family.name)
+ FontFamily.add_item("None")
+
+ reflect_font_family_control()
+ config.set_value(CONFIG_SECTION_META, CONFIG_KEY_FONTS_DIR, dir)
+ config.save(CONFIG_DIR)
+ else:
+ print("Failed to load fonts")
+
+func _on_FontSizePreset_item_selected(index):
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ var new_font_size_str = FontSizePreset.get_item_text(index)
+ change_font_size(obj, new_font_size_str.to_int())
+
+func _on_FontSize_text_entered(new_text):
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ change_font_size(obj, FontSize.text.to_int())
+
+func _on_Bold_pressed():
+ if focused_objects == null:
+ return
+
+ Utils.popup_on_target(BoldPopupMenu, Bold)
+
+func _on_BoldPopupMenu_id_pressed(index):
+ if focused_objects == null:
+ return
+
+ var font_weight_text = BoldPopupMenu.get_item_text(index)
+ if font_weight_text == Bold.tooltip_text:
+ return
+
+ Bold.tooltip_text = font_weight_text
+ var font_family_name = FontFamily.get_item_text(FontFamily.selected)
+ var font_weight = Bold.tooltip_text.to_lower().replace("-", "_")
+ var font_family = font_manager.get_font_family(font_family_name)
+
+ for obj in focused_objects:
+ if obj is RichTextLabel:
+ continue
+ var font_variation = obj.get(PROPERTY_FONT)
+ if font_variation:
+ var font_faces = font_family.get(font_weight)
+ var font_face = font_faces.normal
+ if Italic.button_pressed:
+ if font_faces.has("italic"):
+ font_face = font_faces.italic
+ var font_data = font_face.data
+ change_font_data(obj, font_data)
+
+func _on_Italic_pressed():
+ if focused_objects == null:
+ return
+
+ var font_family_name = FontFamily.get_item_text(FontFamily.selected)
+ var font_family = font_manager.get_font_family(font_family_name)
+ if not font_family:
+ return
+
+ var font_weight = Bold.tooltip_text.to_lower().replace("-", "_")
+ var font_faces = font_family.get(font_weight)
+ var font_face = font_faces.get("italic") if Italic.button_pressed else font_faces.normal
+
+ for obj in focused_objects:
+ change_font_data(obj, font_face.data)
+
+func _on_FontColor_pressed():
+ if focused_objects == null:
+ return
+
+ Utils.popup_on_target(FontColorPopupPanel, FontColor)
+ var obj = focused_objects.back()
+
+ if obj is RichTextLabel:
+ _object_orig_font_color = obj.get(PROPERTY_FONT_COLOR_DEFAULT)
+ else:
+ _object_orig_font_color = obj.get(PROPERTY_FONT_COLOR)
+
+func _on_FontColor_ColorPicker_color_changed(color):
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ # Preview only, doesn't stack undo/redo as this is called very frequently
+ if obj is RichTextLabel:
+ obj.set(PROPERTY_FONT_COLOR_DEFAULT, FontColorColorPicker.color)
+ else:
+ obj.set(PROPERTY_FONT_COLOR, FontColorColorPicker.color)
+ FontColorColorRect.color = FontColorColorPicker.color
+
+func _on_FontColor_PopupPanel_popup_hide():
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ var current_font_color = obj.get(PROPERTY_FONT_COLOR)
+ var font_color
+ if current_font_color is Color or _object_orig_font_color is Color:
+ font_color = FontColorColorPicker.color
+ # Color selected
+ change_font_color(obj, font_color)
+
+func _on_Highlight_pressed():
+ if focused_objects == null:
+ return
+
+ Utils.popup_on_target(HighlightPopupPanel, Highlight)
+
+ for obj in focused_objects:
+ var style_box_flat = obj.get(PROPERTY_HIGHLIGHT)
+ if obj is Panel or obj is PanelContainer:
+ style_box_flat = obj.get(PROPERTY_HIGHLIGHT_PANEL)
+ if style_box_flat:
+ _object_orig_highlight = StyleBoxFlat.new()
+ _object_orig_highlight.bg_color = style_box_flat.bg_color
+ else:
+ _object_orig_highlight = null
+
+func _on_Highlight_ColorPicker_color_changed(color):
+ if focused_objects == null:
+ return
+
+ # Preview only, doesn't stack undo/redo as this is called very frequently
+ HighlightColorRect.color = color
+ var style_box_flat = StyleBoxFlat.new()
+
+ style_box_flat.bg_color = HighlightColorPicker.color
+
+ for obj in focused_objects:
+ if obj is Panel or obj is PanelContainer:
+ obj.set(PROPERTY_HIGHLIGHT_PANEL, style_box_flat)
+ else:
+ obj.set(PROPERTY_HIGHLIGHT, style_box_flat)
+
+func _on_Highlight_PopupPanel_popup_hide():
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ var current_highlight
+ if obj is Panel or obj is PanelContainer:
+ current_highlight = obj.get(PROPERTY_HIGHLIGHT_PANEL)
+ else:
+ current_highlight = obj.get(PROPERTY_HIGHLIGHT)
+
+ # Color selected
+ var style_box_flat
+ if current_highlight or _object_orig_highlight:
+ style_box_flat = StyleBoxFlat.new()
+ style_box_flat.bg_color = HighlightColorPicker.color
+ change_highlight(obj, style_box_flat)
+
+func _on_HorizontalAlign_pressed():
+ if focused_objects:
+ Utils.popup_on_target(HorizontalAlignPopupMenu, HorizontalAlign)
+
+func _on_HorizontalAlignPopupMenu_id_pressed(index):
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ HorizontalAlign.icon = HorizontalAlignPopupMenu.get_item_icon(index)
+ var selected_align = HorizontalAlignPopupMenu.get_item_metadata(index)
+ var current_align = obj.get(PROPERTY_HORIZONTAL_ALIGNMENT)
+ if current_align != selected_align:
+ change_horizontal_alignment(obj, selected_align)
+
+func _on_VerticalAlign_pressed():
+ if focused_objects:
+ Utils.popup_on_target(VerticalAlignPopupMenu, VerticalAlign)
+
+func _on_VerticalAlignPopupMenu_id_pressed(index):
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ VerticalAlign.icon = VerticalAlignPopupMenu.get_item_icon(index)
+ var selected_v_align = VerticalAlignPopupMenu.get_item_metadata(index)
+ var current_v_align = obj.get(PROPERTY_VERTICAL_ALIGNMENT)
+ if current_v_align != selected_v_align:
+ change_vertical_alignment(obj, selected_v_align)
+
+func _on_FontFormatting_item_selected(index):
+ if focused_objects == null:
+ return
+
+ var font_variation = focused_objects.back().get(PROPERTY_FONT)
+ if not font_variation:
+ return
+
+ var font_formatting_name = FontFormatting.get_item_text(index)
+ var font_formatting = font_manager.FONT_FORMATTINGS[font_formatting_name]
+ FontFormatting.tooltip_text = font_formatting_name
+ # TODO: Better way to get current item text from PopupMenu than tooltip_text
+ _object_orig_font_formatting= FontManager.FontFormatting.new(
+ Bold.tooltip_text.to_lower().replace("-", "_"), DEFAULT_FONT_SIZE, font_variation.spacing_glyph)
+
+ for obj in focused_objects:
+ change_font_formatting(obj, font_formatting)
+
+func _on_Tools_pressed():
+ if focused_objects:
+ Utils.popup_on_target(ToolsPopupMenu, Tools)
+
+func _on_ToolsPopupMenu_id_pressed(index):
+ if focused_objects == null:
+ return
+
+ match index:
+ 0: # Font Clear
+ _on_FontClear_pressed()
+ 1: # Color Clear
+ _on_ColorClear_pressed()
+ 2: # Rect Size Refresh
+ _on_RectSizeRefresh_pressed()
+
+func _on_FontClear_pressed():
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ if obj is RichTextLabel:
+ var to = {
+ "regular": null,
+ "bold": null,
+ "regular_italic": null,
+ "bold_italic": null
+ }
+ change_rich_text_fonts(obj, to)
+ else:
+ change_font(obj, null)
+
+ _on_focused_object_changed(focused_objects) # Update ui default state
+
+func _on_ColorClear_pressed():
+ if focused_objects == null:
+ return
+
+ for obj in focused_objects:
+ if obj is RichTextLabel:
+ _object_orig_font_color = obj.get(PROPERTY_FONT_COLOR_DEFAULT)
+ else:
+ _object_orig_font_color = obj.get(PROPERTY_FONT_COLOR)
+
+ if obj is Panel or obj is PanelContainer:
+ _object_orig_highlight = obj.get(PROPERTY_HIGHLIGHT_PANEL)
+ else:
+ _object_orig_highlight = obj.get(PROPERTY_HIGHLIGHT)
+ change_font_color(obj, null)
+ change_highlight(obj, null)
+
+func _on_RectSizeRefresh_pressed():
+ if focused_objects:
+ for obj in focused_objects:
+ obj.set("size", Vector2.ZERO)
+
+# focused_objects changed when user select different object in editor
+func _on_focused_object_changed(new_focused_object):
+ reflect_font_family_control() # Font family must be reflected first
+ reflect_font_size_control()
+ reflect_font_color_control()
+ reflect_highlight_control()
+ reflect_bold_italic_control()
+ reflect_horizontal_alignment_control()
+ reflect_vertical_alignment_control()
+ reflect_font_formatting_control()
+
+# focused_property changed when user select different property in inspector
+func _on_focused_property_changed(new_property):
+ pass
+
+# focused_inspector changed when user select different inspector in editor
+func _on_focused_inspector_changed(new_inspector):
+ pass
+
+# Called from setter method, handle update of font name/font weight in toolbar
+func _on_font_data_changed(new_font_data):
+ var font_face = font_manager.get_font_face(new_font_data)
+ if font_face:
+ reflect_font_family_control()
+
+ reflect_bold_italic_control()
+ emit_signal("property_edited", PROPERTY_FONT)
+
+# Called from setter method, handle update of font name/font weight in toolbar
+func _on_font_changed(new_font):
+ var font_family_name = FontFamily.get_item_text(FontFamily.selected)
+ var font_family = font_manager.get_font_family(font_family_name)
+ if not new_font:
+ reset_font_family_control()
+ else:
+ var font_face = font_manager.get_font_face(new_font.base_font)
+ if font_face:
+ reflect_font_family_control()
+ reflect_font_weight_control()
+
+ reflect_font_size_control()
+ reflect_bold_italic_control()
+ reflect_font_formatting_control()
+ emit_signal("property_edited", PROPERTY_FONT)
+
+# Called from setter method, handle update of font name/font weight in toolbar
+func _on_rich_text_fonts_changed(fonts):
+ # TODO: Reflect font name of rich text font
+ emit_signal("property_edited", PROPERTY_FONT)
+
+# Called from setter method, handle update of font size in toolbar
+func _on_font_size_changed(new_font_size):
+ var new_font_size_str = str(new_font_size)
+ FontSize.text = new_font_size_str
+
+ emit_signal("property_edited", PROPERTY_FONT_SIZE)
+
+# Called from setter method, handle update of font color in toolbar
+func _on_font_color_changed(new_font_color):
+ reflect_font_color_control()
+
+ emit_signal("property_edited", PROPERTY_FONT_COLOR)
+
+# Called from setter method, handle update of highlight in toolbar
+func _on_highlight_changed(new_highlight):
+ reflect_highlight_control()
+
+ if focused_objects is Panel or focused_objects is PanelContainer:
+ emit_signal("property_edited", PROPERTY_HIGHLIGHT_PANEL)
+ else:
+ emit_signal("property_edited", PROPERTY_HIGHLIGHT)
+
+# Called from setter method, handle update of horizontal alignment in toolbar
+func _on_horizontal_alignment_changed(h_align):
+ reflect_horizontal_alignment_control()
+
+ emit_signal("property_edited", PROPERTY_HORIZONTAL_ALIGNMENT)
+
+# Called from setter method, handle update of vertical alignment in toolbar
+func _on_vertical_alignment_changed(v_align):
+ reflect_vertical_alignment_control()
+
+ emit_signal("property_edited", PROPERTY_VERTICAL_ALIGNMENT)
+
+# font data setter, toolbar gets updated after called
+func set_font_data(object, font_data):
+ font_data = font_data if font_data else null # font might be bool false, as Godot ignore null for varargs
+ object.get(PROPERTY_FONT).base_font = font_data
+ _on_font_data_changed(font_data)
+
+# font setter, toolbar gets updated after called
+func set_font(object, font):
+ font = font if font else null
+ object.set(PROPERTY_FONT, font)
+ _on_font_changed(font)
+
+# rich text fonts setter, toolbar gets updated after called
+func set_rich_text_fonts(object, fonts):
+ object.set(PROPERTY_FONT_NORMAL, fonts.regular)
+ object.set(PROPERTY_FONT_BOLD, fonts.bold)
+ object.set(PROPERTY_FONT_ITALIC, fonts.regular_italic)
+ object.set(PROPERTY_FONT_BOLD_ITALIC, fonts.bold_italic)
+ _on_rich_text_fonts_changed(fonts)
+
+# font size setter, toolbar gets updated after called
+func set_font_size(object, font_size):
+ object.set(PROPERTY_FONT_SIZE, font_size)
+ _on_font_size_changed(font_size)
+
+# font color setter, toolbar gets updated after called
+func set_font_color(object, font_color):
+ font_color = font_color if font_color is Color else null
+ if object is RichTextLabel:
+ object.set(PROPERTY_FONT_COLOR_DEFAULT, font_color)
+ else:
+ object.set(PROPERTY_FONT_COLOR, font_color)
+ _on_font_color_changed(font_color)
+
+# highlight setter, toolbar gets updated after called
+func set_highlight(object, highlight):
+ highlight = highlight if highlight else null
+ if object is Panel or object is PanelContainer:
+ object.set(PROPERTY_HIGHLIGHT_PANEL, highlight)
+ else:
+ object.set(PROPERTY_HIGHLIGHT, highlight)
+ _on_highlight_changed(highlight)
+
+# Horizontal alignment setter, toolbar gets updated after called
+func set_horizontal_alignment(object, h_align):
+ object.set(PROPERTY_HORIZONTAL_ALIGNMENT, h_align)
+ _on_horizontal_alignment_changed(h_align)
+
+# Vertical alignment setter, toolbar gets updated after called
+func set_vertical_alignment(object, v_align):
+ object.set(PROPERTY_VERTICAL_ALIGNMENT, v_align)
+ _on_vertical_alignment_changed(v_align)
+
+# font style setter, toolbar gets updated after called
+func set_font_formatting(object, font_formatting):
+ if not font_formatting:
+ return
+
+ var font_family = font_manager.get_font_family(FontFamily.get_item_text(FontFamily.selected))
+ var font_face = font_family.get(font_formatting.font_weight).get(FontManager.get_font_style_str(font_formatting.font_style))
+ var font_data
+ if font_face:
+ font_data = font_face.data
+ else:
+ # Use current weight if desired weight not found
+ font_data = object.get(PROPERTY_FONT).base_font
+ set_font_data(object, font_data)
+ set_font_size(object, font_formatting.size)
+ set_font_extra_spacing_char(object, font_formatting.letter_spacing)
+
+# font letter spacing setter, toolbar gets updated after called
+func set_font_extra_spacing_char(object, new_spacing):
+ object.get(PROPERTY_FONT).spacing_glyph = new_spacing
+ # TODO: Add gui for font extra spacing
+
+# Convenience method to create font object with some default settings
+func create_new_font_obj(font_data, size=null):
+ var font_variation = FontVariation.new()
+ font_variation.base_font = font_data
+ return font_variation
diff --git a/addons/ui_design_tool/scenes/Toolbar.tscn b/addons/ui_design_tool/scenes/Toolbar.tscn
new file mode 100644
index 00000000..157e2a06
--- /dev/null
+++ b/addons/ui_design_tool/scenes/Toolbar.tscn
@@ -0,0 +1,384 @@
+[gd_scene load_steps=20 format=3 uid="uid://nq7vlsvxhv2p"]
+
+[ext_resource type="Script" path="res://addons/ui_design_tool/scenes/Toolbar.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://d3xaf7s36xuqc" path="res://addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg" id="2"]
+[ext_resource type="Texture2D" uid="uid://ck4h5hqubttt7" path="res://addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg" id="3"]
+[ext_resource type="Texture2D" uid="uid://b44il4qj7cem1" path="res://addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://b3tqua2bt1ix2" path="res://addons/ui_design_tool/assets/icons/format-color-text.png" id="5"]
+[ext_resource type="Texture2D" uid="uid://8qawl7hrofkj" path="res://addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg" id="6"]
+[ext_resource type="Texture2D" uid="uid://cqv3uc8bew0am" path="res://addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg" id="7"]
+[ext_resource type="Texture2D" uid="uid://d1uver224k3px" path="res://addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg" id="8"]
+[ext_resource type="Texture2D" uid="uid://dn7q7grbfr7kh" path="res://addons/ui_design_tool/assets/icons/refresh-white-18dp.svg" id="9"]
+[ext_resource type="Texture2D" uid="uid://d1rj7h72swjhn" path="res://addons/ui_design_tool/assets/icons/marker.png" id="10"]
+[ext_resource type="Texture2D" uid="uid://xnn5xt6piaat" path="res://addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg" id="11"]
+[ext_resource type="Texture2D" uid="uid://d0t8qupuaoigg" path="res://addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg" id="12"]
+[ext_resource type="Texture2D" uid="uid://i11r3de57bc3" path="res://addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg" id="13"]
+[ext_resource type="Texture2D" uid="uid://dtsld0omp3fy0" path="res://addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg" id="14"]
+[ext_resource type="Texture2D" uid="uid://rpjhdv5qake3" path="res://addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg" id="15"]
+[ext_resource type="Texture2D" uid="uid://cjan2dq5nvdvk" path="res://addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg" id="16"]
+[ext_resource type="Texture2D" uid="uid://ckriw8d4yelu" path="res://addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg" id="17"]
+[ext_resource type="Texture2D" uid="uid://crte1qj0ftynh" path="res://addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg" id="18"]
+[ext_resource type="Texture2D" uid="uid://cm5d77b25dgjc" path="res://addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg" id="19"]
+
+[node name="Toolbar" type="HBoxContainer"]
+visible = false
+script = ExtResource("1")
+
+[node name="FontFamily" type="OptionButton" parent="."]
+custom_minimum_size = Vector2i(99, 0)
+layout_mode = 2
+offset_right = 99.0
+offset_bottom = 31.0
+size_flags_vertical = 4
+tooltip_text = "Font Family"
+clip_text = true
+item_count = 9
+selected = 0
+popup/item_0/text = "Alata"
+popup/item_0/id = 0
+popup/item_1/text = "Bungee"
+popup/item_1/id = 1
+popup/item_2/text = "Concert_One"
+popup/item_2/id = 2
+popup/item_3/text = "Fredoka_One"
+popup/item_3/id = 3
+popup/item_4/text = "Neuton"
+popup/item_4/id = 4
+popup/item_5/text = "Nunito"
+popup/item_5/id = 5
+popup/item_6/text = "Roboto"
+popup/item_6/id = 6
+popup/item_7/text = "Space_Mono"
+popup/item_7/id = 7
+popup/item_8/text = "None"
+popup/item_8/id = 8
+
+[node name="FontFamilyOptions" type="Button" parent="."]
+layout_mode = 2
+offset_left = 103.0
+offset_right = 129.0
+offset_bottom = 31.0
+tooltip_text = "Font Family Options"
+icon = ExtResource("18")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="FontFamilyOptions"]
+item_count = 2
+item_0/text = "Load Fonts"
+item_0/icon = ExtResource("8")
+item_0/id = 0
+item_1/text = "Refresh Fonts"
+item_1/icon = ExtResource("9")
+item_1/id = 1
+
+[node name="FontFamilyFileDialog" type="FileDialog" parent="."]
+title = "Open a Directory"
+size = Vector2i(400, 300)
+min_size = Vector2i(300, 200)
+ok_button_text = "Select This Folder"
+file_mode = 2
+
+[node name="VSeparator" type="VSeparator" parent="."]
+layout_mode = 2
+offset_left = 133.0
+offset_right = 137.0
+offset_bottom = 31.0
+
+[node name="FontSize" type="LineEdit" parent="."]
+layout_mode = 2
+offset_left = 141.0
+offset_right = 208.0
+offset_bottom = 31.0
+tooltip_text = "Font Size"
+
+[node name="FontSizePreset" type="OptionButton" parent="FontSize"]
+show_behind_parent = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 27.0
+offset_right = 14.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Font Size Presets"
+disabled = true
+item_count = 17
+popup/item_0/text = "8"
+popup/item_0/id = 0
+popup/item_1/text = "9"
+popup/item_1/id = 1
+popup/item_2/text = "10"
+popup/item_2/id = 2
+popup/item_3/text = "11"
+popup/item_3/id = 3
+popup/item_4/text = "12"
+popup/item_4/id = 4
+popup/item_5/text = "14"
+popup/item_5/id = 5
+popup/item_6/text = "16"
+popup/item_6/id = 6
+popup/item_7/text = "18"
+popup/item_7/id = 7
+popup/item_8/text = "24"
+popup/item_8/id = 8
+popup/item_9/text = "30"
+popup/item_9/id = 9
+popup/item_10/text = "36"
+popup/item_10/id = 10
+popup/item_11/text = "48"
+popup/item_11/id = 11
+popup/item_12/text = "60"
+popup/item_12/id = 12
+popup/item_13/text = "72"
+popup/item_13/id = 13
+popup/item_14/text = "96"
+popup/item_14/id = 14
+popup/item_15/text = "128"
+popup/item_15/id = 15
+popup/item_16/text = "256"
+popup/item_16/id = 16
+
+[node name="PanelContainer" type="PanelContainer" parent="."]
+self_modulate = Color(1, 1, 1, 0)
+layout_mode = 2
+offset_left = 212.0
+offset_right = 212.0
+offset_bottom = 31.0
+mouse_filter = 2
+
+[node name="Bold" type="Button" parent="."]
+layout_mode = 2
+offset_left = 216.0
+offset_right = 242.0
+offset_bottom = 31.0
+tooltip_text = "Bold"
+disabled = true
+icon = ExtResource("2")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="Bold"]
+item_count = 9
+item_0/text = "Thin"
+item_0/id = 0
+item_1/text = "Extra-Light"
+item_1/id = 1
+item_2/text = "Light"
+item_2/id = 2
+item_3/text = "Regular"
+item_3/id = 3
+item_4/text = "Medium"
+item_4/id = 4
+item_5/text = "Semi-Bold"
+item_5/id = 5
+item_6/text = "Bold"
+item_6/id = 6
+item_7/text = "Extra-Bold"
+item_7/id = 7
+item_8/text = "Black"
+item_8/id = 8
+
+[node name="Italic" type="Button" parent="."]
+layout_mode = 2
+offset_left = 246.0
+offset_right = 272.0
+offset_bottom = 31.0
+tooltip_text = "Italic"
+disabled = true
+toggle_mode = true
+icon = ExtResource("3")
+flat = true
+
+[node name="Underline" type="Button" parent="."]
+layout_mode = 2
+offset_left = 276.0
+offset_right = 302.0
+offset_bottom = 31.0
+tooltip_text = "Underline
+*Only supported in RichTextLabel"
+disabled = true
+toggle_mode = true
+icon = ExtResource("4")
+flat = true
+
+[node name="FontColor" type="Button" parent="."]
+layout_mode = 2
+offset_left = 306.0
+offset_right = 332.0
+offset_bottom = 31.0
+tooltip_text = "Font Color"
+icon = ExtResource("5")
+flat = true
+
+[node name="PopupPanel" type="PopupPanel" parent="FontColor"]
+size = Vector2i(116, 227)
+
+[node name="ColorPicker" type="ColorPicker" parent="FontColor/PopupPanel"]
+offset_left = 4.0
+offset_top = 4.0
+offset_right = 294.0
+offset_bottom = 511.0
+
+[node name="ColorRect" type="ColorRect" parent="FontColor"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -9.0
+offset_top = 8.0
+offset_right = 9.0
+offset_bottom = 11.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Highlight" type="Button" parent="."]
+layout_mode = 2
+offset_left = 336.0
+offset_right = 362.0
+offset_bottom = 31.0
+tooltip_text = "Highlight Color"
+icon = ExtResource("10")
+flat = true
+
+[node name="PopupPanel" type="PopupPanel" parent="Highlight"]
+size = Vector2i(116, 227)
+
+[node name="ColorPicker" type="ColorPicker" parent="Highlight/PopupPanel"]
+offset_left = 4.0
+offset_top = 4.0
+offset_right = 294.0
+offset_bottom = 511.0
+
+[node name="ColorRect" type="ColorRect" parent="Highlight"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -9.0
+offset_top = 8.0
+offset_right = 9.0
+offset_bottom = 11.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="VSeparator2" type="VSeparator" parent="."]
+layout_mode = 2
+offset_left = 366.0
+offset_right = 370.0
+offset_bottom = 31.0
+
+[node name="HorizontalAlign" type="Button" parent="."]
+layout_mode = 2
+offset_left = 374.0
+offset_right = 400.0
+offset_bottom = 31.0
+tooltip_text = "Horizontal Align"
+toggle_mode = true
+icon = ExtResource("14")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="HorizontalAlign"]
+item_count = 3
+item_0/text = ""
+item_0/icon = ExtResource("14")
+item_0/id = 0
+item_1/text = ""
+item_1/icon = ExtResource("13")
+item_1/id = 1
+item_2/text = ""
+item_2/icon = ExtResource("12")
+item_2/id = 2
+
+[node name="VerticalAlign" type="Button" parent="."]
+layout_mode = 2
+offset_left = 404.0
+offset_right = 430.0
+offset_bottom = 31.0
+tooltip_text = "Vertical Align"
+toggle_mode = true
+icon = ExtResource("16")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="VerticalAlign"]
+item_count = 3
+item_0/text = ""
+item_0/icon = ExtResource("16")
+item_0/id = 0
+item_1/text = ""
+item_1/icon = ExtResource("17")
+item_1/id = 1
+item_2/text = ""
+item_2/icon = ExtResource("15")
+item_2/id = 2
+
+[node name="VSeparator3" type="VSeparator" parent="."]
+layout_mode = 2
+offset_left = 434.0
+offset_right = 438.0
+offset_bottom = 31.0
+
+[node name="FontFormatting" type="OptionButton" parent="."]
+custom_minimum_size = Vector2i(112, 0)
+layout_mode = 2
+offset_left = 442.0
+offset_right = 554.0
+offset_bottom = 31.0
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Font Formatting"
+clip_text = true
+item_count = 13
+selected = -1
+popup/item_0/text = "Heading 1"
+popup/item_0/id = 0
+popup/item_1/text = "Heading 2"
+popup/item_1/id = 1
+popup/item_2/text = "Heading 3"
+popup/item_2/id = 2
+popup/item_3/text = "Heading 4"
+popup/item_3/id = 3
+popup/item_4/text = "Heading 5"
+popup/item_4/id = 4
+popup/item_5/text = "Heading 6"
+popup/item_5/id = 5
+popup/item_6/text = "Subtitle 1"
+popup/item_6/id = 6
+popup/item_7/text = "Subtitle 2"
+popup/item_7/id = 7
+popup/item_8/text = "Body 1"
+popup/item_8/id = 8
+popup/item_9/text = "Body 2"
+popup/item_9/id = 9
+popup/item_10/text = "Button"
+popup/item_10/id = 10
+popup/item_11/text = "Caption"
+popup/item_11/id = 11
+popup/item_12/text = "Overline"
+popup/item_12/id = 12
+
+[node name="Tools" type="Button" parent="."]
+layout_mode = 2
+offset_left = 558.0
+offset_right = 584.0
+offset_bottom = 31.0
+tooltip_text = "Tools"
+icon = ExtResource("19")
+flat = true
+
+[node name="PopupMenu" type="PopupMenu" parent="Tools"]
+item_count = 3
+item_0/text = "Font Clear"
+item_0/icon = ExtResource("11")
+item_0/id = 0
+item_1/text = "Color Clear"
+item_1/icon = ExtResource("6")
+item_1/id = 1
+item_2/text = "Rect Size Refresh"
+item_2/icon = ExtResource("7")
+item_2/id = 2
diff --git a/addons/ui_design_tool/scripts/FontManager.gd b/addons/ui_design_tool/scripts/FontManager.gd
new file mode 100644
index 00000000..8982e811
--- /dev/null
+++ b/addons/ui_design_tool/scripts/FontManager.gd
@@ -0,0 +1,240 @@
+extends Object
+
+const FONT_FILE_PATTERN = "\\.ttf$"
+const FONT_WEIGHT_PATTERNS = {
+ "thin": "(?i)(-|_)thin",
+ "extra_light": "(?i)(-|_)extralight",
+ "light": "(?i)(-|_)light",
+ "regular": "(?i)(-|_)regular",
+ "medium": "(?i)(-|_)medium",
+ "semi_bold": "(?i)(-|_)semibold",
+ "bold": "(?i)(-|_)bold",
+ "extra_bold": "(?i)(-|_)extrabold",
+ "black": "(?i)(-|_)black",
+ "extra_black": "(?i)(-|_)extrablack"
+}
+const FONT_ITALIC_PATTERN = "(?i)italic"
+const FONT_ITALIC_ONLY_PATTERN = "(?i)(-|_)italic"
+const FONT_VARIABLE_PATTERN = "(?i)(-|_)variable"
+var FONT_FORMATTINGS = {
+ "Heading 1": FontFormatting.new("light", 96, -3),
+ "Heading 2": FontFormatting.new("light", 60, -2),
+ "Heading 3": FontFormatting.new("regular", 48),
+ "Heading 4": FontFormatting.new("regular", 34, 1),
+ "Heading 5": FontFormatting.new("regular", 24),
+ "Heading 6": FontFormatting.new("medium", 20, 1),
+ "Subtitle 1": FontFormatting.new("regular", 16),
+ "Subtitle 2": FontFormatting.new("medium", 14, 1),
+ "Body 1": FontFormatting.new("regular", 16, 1),
+ "Body 2": FontFormatting.new("regular", 14, 1),
+ "Button": FontFormatting.new("medium", 14, 1),
+ "Caption": FontFormatting.new("regular", 12, 1),
+ "Overline": FontFormatting.new("regular", 10)
+} # Typography hierarchy presets, see https://material.io/design/typography/the-type-system.html#type-scale
+const DIR_FOLDER_PATTERN = "\\w+(?!.*\\w)"
+
+var font_families = {}
+
+var _font_file_regex = RegEx.new()
+var _font_weight_regexes = {
+ "thin": RegEx.new(),
+ "extra_light": RegEx.new(),
+ "light": RegEx.new(),
+ "regular": RegEx.new(),
+ "medium": RegEx.new(),
+ "semi_bold": RegEx.new(),
+ "bold": RegEx.new(),
+ "extra_bold": RegEx.new(),
+ "black": RegEx.new(),
+ "extra_black": RegEx.new()
+}
+var _font_italic_regex = RegEx.new()
+var _font_italic_only_regex = RegEx.new()
+var _font_variable_regex = RegEx.new()
+var _dir_folder_regex = RegEx.new()
+
+
+func _init():
+ if _font_file_regex.compile(FONT_FILE_PATTERN):
+ print("Failed to compile ", FONT_FILE_PATTERN)
+
+ for font_weight in _font_weight_regexes.keys():
+ if _font_weight_regexes[font_weight].compile(FONT_WEIGHT_PATTERNS[font_weight]):
+ print("Failed to compile ", FONT_WEIGHT_PATTERNS[font_weight])
+
+ if _font_italic_regex.compile(FONT_ITALIC_PATTERN):
+ print("Failed to compile ", FONT_ITALIC_PATTERN)
+
+ if _font_italic_only_regex.compile(FONT_ITALIC_ONLY_PATTERN):
+ print("Failed to compile ", FONT_ITALIC_ONLY_PATTERN)
+
+ if _font_variable_regex.compile(FONT_VARIABLE_PATTERN):
+ print("Failed to compile ", FONT_VARIABLE_PATTERN)
+
+ if _dir_folder_regex.compile(DIR_FOLDER_PATTERN):
+ print("Failed to compile ", DIR_FOLDER_PATTERN)
+
+# Load root dir of font resources, check Readme for directory structure
+func load_root_dir(root_dir):
+ var directory = DirAccess.open(root_dir)
+ var result = DirAccess.get_open_error()
+ if result == OK:
+ font_families.clear()
+ directory.list_dir_begin() # Skip . and .. directory and hidden# TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var dir = directory.get_next()
+ while dir != "":
+ if not directory.current_is_dir():
+ dir = directory.get_next()
+ continue
+
+ load_fonts(directory.get_current_dir() + "/" + dir)
+ dir = directory.get_next()
+ directory.list_dir_end()
+ else:
+ push_warning("UI Design Tool: An error occurred when trying to access %s, ERROR: %d" % [root_dir, result])
+ return false
+
+ return true
+
+# Load fonts data from directory, check Readme for filename pattern
+func load_fonts(dir):
+ var directory = DirAccess.open(dir)
+ var result = DirAccess.get_open_error()
+ if result == OK:
+ var font_family_name = _dir_folder_regex.search(dir).get_string()
+ var font_family = FontFamily.new(font_family_name)
+ directory.list_dir_begin()
+ var filename = directory.get_next()
+ while filename != "":
+ if directory.current_is_dir():
+ filename = directory.get_next()
+ continue
+
+ if _font_file_regex.search(filename):
+ for font_weight in _font_weight_regexes.keys():
+ if _font_variable_regex.search(filename): # Godot doesn't support variable font
+ continue
+
+ var abs_dir = directory.get_current_dir() + "/" + filename
+ if _font_weight_regexes[font_weight].search(filename):
+ var font_data = load(abs_dir)
+
+ if _font_italic_regex.search(filename):
+ font_family.set_font_face(FontFace.new(font_family.name, font_weight, font_data, FONT_STYLE.ITALIC))
+ else:
+ font_family.set_font_face(FontFace.new(font_family.name, font_weight, font_data))
+ break
+ else:
+ # Capture regular italic from {font-name}-italic.ttf
+ if _font_italic_only_regex.search(filename):
+ var font_data = load(abs_dir)
+ font_family.set_font_face(FontFace.new(font_family.name, "regular", font_data, FONT_STYLE.ITALIC))
+ break
+ filename = directory.get_next()
+ directory.list_dir_end()
+
+ if not font_family.is_empty():
+ font_families[font_family.name] = font_family
+ else:
+ push_warning("UI Design Tool: Unable to locate usable .ttf files from %s, check README.md for proper directory/filename structure" % dir)
+ else:
+ push_warning("UI Design Tool: An error occurred when trying to access %s, ERROR: %d" % [dir, result])
+ return false
+
+ return true
+
+func get_font_face(font_data):
+ for res in font_families.values():
+ for font_weight in FONT_WEIGHT.keys():
+ var font_faces = res.get(font_weight)
+ for font_face in font_faces.values():
+ if font_face.data and font_data:
+ if font_face.data.resource_path == font_data.resource_path:
+ return font_face
+ return null
+
+# Find font resource with font name
+func get_font_family(font_family_name):
+ return font_families.get(font_family_name)
+
+static func get_font_style_str(font_style):
+ return FONT_STYLE.keys()[font_style].to_lower()
+
+# Declaration of font type with font_faces
+class FontFamily:
+ var name = ""
+ var thin = {}
+ var extra_light = {}
+ var light = {}
+ var regular = {}
+ var medium = {}
+ var semi_bold = {}
+ var bold = {}
+ var extra_bold = {}
+ var black = {}
+ var extra_black = {}
+
+ func _init(n):
+ name = n
+
+ func set_font_face(font_face):
+ var font_faces = get(font_face.font_weight.replace('-', '_'))
+ font_faces[FONT_STYLE.keys()[font_face.font_style].to_lower()] = font_face
+
+ func is_empty():
+ for font_weight in FONT_WEIGHT.keys():
+ var font_faces = get(font_weight)
+ if not font_faces.values().is_empty():
+ return false
+ return true
+
+ func get_class():
+ return "FontFamily"
+
+# Font face data, see (https://developer.mozilla.org/my/docs/Web/CSS/@font-face)
+class FontFace:
+ var font_family = ""
+ var font_weight = ""
+ var font_style = FONT_STYLE.NORMAL
+ var data
+
+ func _init(ff, fw, d, fs=FONT_STYLE.NORMAL):
+ font_family = ff
+ font_weight = fw
+ font_style = fs
+ data = d
+
+ func get_class():
+ return "FontFace"
+
+# Declaration of font style TODO: Custom resource to define font style
+class FontFormatting:
+ var font_weight = "regular"
+ var font_style = 0 # FONT_STYLE.NORMAL
+ var size = 16
+ var letter_spacing = 0
+
+ func _init(fw, s, ls=0):
+ font_weight = fw
+ size = s
+ letter_spacing = ls
+
+# List of font style, see (https://developer.mozilla.org/my/docs/Web/CSS/font-style)
+enum FONT_STYLE {
+ NORMAL,
+ ITALIC,
+ OBLIQUE
+}
+
+# List of font weights, see (https://docs.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass)
+const FONT_WEIGHT = {
+ "thin": 100,
+ "extra_light": 200,
+ "light": 300,
+ "regular": 400,
+ "medium": 500,
+ "semi_bold": 600,
+ "bold": 700,
+ "extra_bold": 800,
+ "black": 900
+}
diff --git a/addons/ui_design_tool/scripts/Utils.gd b/addons/ui_design_tool/scripts/Utils.gd
new file mode 100644
index 00000000..0aae45e3
--- /dev/null
+++ b/addons/ui_design_tool/scripts/Utils.gd
@@ -0,0 +1,61 @@
+static func markup_text_edit_selection(text_edit, start_text, end_text):
+ if not text_edit.is_selection_active():
+ return
+
+ var selection_from_pos = Vector2(text_edit.get_selection_from_column(), text_edit.get_selection_from_line())
+ var selection_to_pos = Vector2(text_edit.get_selection_to_column(), text_edit.get_selection_to_line())
+ var one_line_selection = selection_from_pos.y == selection_to_pos.y
+
+ text_edit.deselect()
+ set_text_edit_cursor_pos(text_edit, selection_from_pos.x, selection_from_pos.y)
+ text_edit.insert_text_at_cursor(start_text)
+
+ if one_line_selection:
+ selection_to_pos.x += start_text.length()
+
+ set_text_edit_cursor_pos(text_edit, selection_to_pos.x, selection_to_pos.y)
+ text_edit.insert_text_at_cursor(end_text)
+
+ if one_line_selection:
+ selection_to_pos.x += end_text.length()
+
+ text_edit.select(selection_from_pos.y, selection_from_pos.x, selection_to_pos.y, selection_to_pos.x)
+
+static func get_text_edit_cursor_pos(text_edit):
+ return Vector2(text_edit.get_caret_column(), text_edit.get_caret_line())
+
+static func set_text_edit_cursor_pos(text_edit, column, line):
+ text_edit.set_caret_column(column)
+ text_edit.set_caret_line(line)
+
+# Position Popup near to its target while within window, solution from ColorPickerButton source code(https://github.com/godotengine/godot/blob/6d8c14f849376905e1577f9fc3f9512bcffb1e3c/scene/gui/color_picker.cpp#L878)
+static func popup_on_target(popup, target):
+ popup.size = popup.get_contents_minimum_size()
+ var usable_rect = Rect2(Vector2.ZERO, DisplayServer.window_get_size())
+ var cp_rect = Rect2(Vector2.ZERO, popup.get_size())
+ for i in 4:
+ if i > 1:
+ cp_rect.position.y = target.global_position.y - cp_rect.size.y
+ else:
+ cp_rect.position.y = target.global_position.y + target.get_size().y
+
+ if i & 1:
+ cp_rect.position.x = target.global_position.x
+ else:
+ cp_rect.position.x = target.global_position.x - max(0, cp_rect.size.x - target.get_size().x)
+
+ if usable_rect.encloses(cp_rect):
+ break
+ popup.set_position(cp_rect.position)
+ popup.popup()
+
+# Roughly calculate the display size of option button regarding to the display_text
+static func get_option_button_display_size(option_button, display_text):
+ # TODO: Improve accuracy
+ # Use default theme if not assingned
+ var theme = option_button.get_theme() if option_button.get_theme() else Theme.new()
+ var string_size = theme.get_font("font", "fonts").get_string_size(display_text)
+ var arrow_icon = theme.get_icon("arrow", "styles")
+ # Takes arrow icon size into account
+ string_size.x += arrow_icon.get_width()
+ return string_size
diff --git a/button.gd b/button.gd
new file mode 100644
index 00000000..87d4dc61
--- /dev/null
+++ b/button.gd
@@ -0,0 +1,5 @@
+extends Button
+
+
+func _on_pressed() -> void:
+ get_tree().get_root().transparent = !get_tree().get_root().transparent
diff --git a/map.tscn b/map.tscn
new file mode 100644
index 00000000..28675270
--- /dev/null
+++ b/map.tscn
@@ -0,0 +1,23 @@
+[gd_scene load_steps=4 format=3 uid="uid://cucljju2i5tft"]
+
+[ext_resource type="SpriteFrames" uid="uid://d0lb23n4ro6li" path="res://Resources/Pokemon/SpriteFrames/Front shiny/ALCREMIE.tres" id="1_t7y4g"]
+[ext_resource type="Script" path="res://button.gd" id="1_vnm28"]
+[ext_resource type="TileSet" uid="uid://dvnid5geee26s" path="res://Resources/world/emerald_objects.tres" id="3_smneb"]
+
+[node name="Node2D" type="Node2D"]
+
+[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
+position = Vector2(579, 255)
+sprite_frames = ExtResource("1_t7y4g")
+frame_progress = 0.542784
+
+[node name="Button" type="Button" parent="."]
+offset_right = 134.0
+offset_bottom = 48.0
+text = "click"
+script = ExtResource("1_vnm28")
+
+[node name="TileMapLayer" type="TileMapLayer" parent="."]
+tile_set = ExtResource("3_smneb")
+
+[connection signal="pressed" from="Button" to="Button" method="_on_pressed"]
diff --git a/plug.gd b/plug.gd
index 0128e216..3210bafd 100644
--- a/plug.gd
+++ b/plug.gd
@@ -6,4 +6,8 @@ func _plugging():
# plug("imjp94/gd-YAFSM") # By default, gd-plug will only install anything from "addons/" directory
# Or you can explicitly specify which file/directory to include
# plug("imjp94/gd-YAFSM", {"include": ["addons/"]}) # By default, gd-plug will only install anything from "addons/" directory
- pass
+ plug("imjp94/gd-blender-3d-shortcuts", {"dev": true})
+ plug("imjp94/UIDesignTool", {"dev": true})
+ plug("imjp94/gd-YAFSM")
+ plug("Shiva-Shadowsong/loggie", {"include": ["addons/"]})
+ plug("newjoker6/Asset-Drawer", {"dev": true})
diff --git a/project.godot b/project.godot
index da1e6897..279f154e 100644
--- a/project.godot
+++ b/project.godot
@@ -10,10 +10,23 @@ config_version=5
[application]
-config/name="Godot Template"
+config/name="Pokemon Base"
+run/main_scene="res://map.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
config/icon="res://icon.svg"
+[autoload]
+
+Loggie="*res://addons/loggie/loggie.gd"
+
+[display]
+
+window/per_pixel_transparency/allowed=true
+
[editor_plugins]
-enabled=PackedStringArray("res://addons/gd-plug-ui/plugin.cfg")
+enabled=PackedStringArray("res://addons/Asset_Drawer/plugin.cfg", "res://addons/gd-blender-3d-shortcuts/plugin.cfg", "res://addons/gd-plug-ui/plugin.cfg", "res://addons/imjp94.yafsm/plugin.cfg", "res://addons/loggie/plugin.cfg", "res://addons/ui_design_tool/plugin.cfg")
+
+[layer_names]
+
+2d_physics/layer_1="World"
diff --git a/tools/SpriteGenerator.tscn b/tools/SpriteGenerator.tscn
new file mode 100644
index 00000000..c763611e
--- /dev/null
+++ b/tools/SpriteGenerator.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://dg6vahyy38a5"]
+
+[ext_resource type="Script" path="res://tools/sprite_generator.gd" id="1_21r1b"]
+
+[node name="Generator" type="Node2D"]
+script = ExtResource("1_21r1b")
diff --git a/tools/sprite_generator.gd b/tools/sprite_generator.gd
new file mode 100644
index 00000000..75a6721a
--- /dev/null
+++ b/tools/sprite_generator.gd
@@ -0,0 +1,153 @@
+@tool
+extends Node2D
+
+const outDir = "res://Resources/Pokemon/SpriteFrames"
+
+# steps: Steps to load. 2 + nr of frames
+# uid: Id for this resouce (uid://bla)
+const resource_header_template = """[gd_resource type="SpriteFrames" load_steps={steps} format=3 uid="{uid}"]
+"""
+
+# uid: Uid of the spritesheet image
+# path: Path to the spritesheet image
+# img_intern: Id of the image used inside the resource
+const ext_resource_template = """
+[ext_resource type="Texture2D" uid="{uid}" path="{path}" id="{img_intern}"]
+"""
+
+# sub_id: random string? Identifies the sub resource inside the resource
+# img_intern: Resource-internal Id of the spritesheet image
+# offset: Offset in pixel from the left. Multiple of image height
+# width, height: Width and height of the sub-frame. Always equal the height of the full image
+const sub_resource_template = """
+[sub_resource type="AtlasTexture" id="AtlasTexture_{sub_id}"]
+atlas = ExtResource("{img_intern}")
+region = Rect2({offset}, 0, {width}, {height})
+"""
+
+const frames_header_template = """
+[resource]
+animations = [{
+"frames": ["""
+
+# sub_id: Id of the sub-resource used by this frame
+const frame_template = """{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_{sub_id}")
+}"""
+
+const frames_end_template = """],
+"loop": true,
+"name": &"default",
+"speed": 12.0
+}]"""
+
+@export var trigger: bool = false:
+ set(new):
+ generate_dir("res://Resources/Pokemon/Raw", outDir)
+
+@export var regen_charset: bool = false:
+ set(new):
+ charset = "0123456789abcdefghijklmnopqrstuvwxyz".split("")
+
+var outDirBase := DirAccess.open(outDir)
+var charset: Array = "0123456789abcdefghijklmnopqrstuvwxyz".split("")
+
+func _ready() -> void:
+ charset = "0123456789abcdefghijklmnopqrstuvwxyz".split("")
+
+func generate_dir(inDirString: String, outDirString: String) -> void:
+ print("Running generator from "+inDirString+" to "+outDirString)
+ var inDir := DirAccess.open(inDirString)
+ if not inDir:
+ print("Failed to access directory", inDirString)
+ return
+ var err := inDir.list_dir_begin()
+ if err:
+ print(err)
+ return
+ var inFileName := inDir.get_next()
+ while inFileName != "":
+ if inDir.current_is_dir():
+ generate_dir(inDirString+"/"+inFileName, outDirString+"/"+inFileName)
+ inFileName = inDir.get_next()
+ continue
+ if !inFileName.ends_with(".png"):
+ inFileName = inDir.get_next()
+ continue
+ generate_from_file(inDirString + "/" + inFileName, outDirString, inFileName.trim_suffix(".png"))
+ inFileName = inDir.get_next()
+ inDir.list_dir_end()
+
+func generate_from_file(start_file: String, endDirString: String, file_name: String) -> void:
+ var end_dir := DirAccess.open(endDirString)
+ var out_file_name := endDirString
+ if endDirString.ends_with("/"):
+ out_file_name = out_file_name+file_name+".tres"
+ else:
+ out_file_name = out_file_name+"/"+file_name+".tres"
+ if end_dir.file_exists(out_file_name):
+ print("file "+file_name+" already exists. Ignoring")
+ return
+
+
+ var file := FileAccess.open(out_file_name, FileAccess.WRITE)
+ print(file.get_path_absolute())
+
+ print("Loading %s" % start_file)
+ var image: CompressedTexture2D = load(start_file)
+ if not image:
+ print("Failed to load %s" % start_file)
+ return
+ var height := image.get_height()
+ var total_len := image.get_width()
+ var frames := (total_len / height)-1
+ # print("%s dimensions: %d, frames: %d" % [start_file, height, frames])
+ var new_resource_id := ResourceUID.create_id()
+ var content := build_file_string(ResourceLoader.get_resource_uid(image.resource_path), height, frames, new_resource_id)
+ file.store_string(content)
+ file.close()
+ ResourceUID.add_id(new_resource_id, out_file_name)
+
+func build_file_string(image_resource_id: int, image_height: int, frame_count: int, new_resource_id: int) -> String:
+ var build_steps := frame_count + 2
+ var resource_id := ResourceUID.id_to_text(new_resource_id)
+
+ var ext_resource_internal_id := "1_"+gen_rand_id()
+ var ext_resource_path := ResourceUID.get_id_path(image_resource_id)
+ var ext_resource_id_string := ResourceUID.id_to_text(image_resource_id)
+
+ var sub_resource_ids: Array[String] = []
+ for i in range(frame_count):
+ sub_resource_ids.push_back(gen_rand_id())
+ print(sub_resource_ids)
+
+ var out_string = resource_header_template.format({"steps":build_steps, "uid":resource_id})
+ out_string += ext_resource_template.format({"uid": ext_resource_id_string, "path": ext_resource_path, "img_intern": ext_resource_internal_id})
+
+ for i in range(len(sub_resource_ids)):
+ var sub_id := sub_resource_ids[i]
+ out_string += sub_resource_template.format({
+ "sub_id": sub_id,
+ "img_intern": ext_resource_internal_id,
+ "offset": i * image_height,
+ "width": image_height,
+ "height": image_height,
+ })
+
+ out_string += frames_header_template
+ for i in range(len(sub_resource_ids)):
+ var sub_id := sub_resource_ids[i]
+ print("sub_id: ", sub_id)
+ out_string += frame_template.format({"sub_id": sub_id})
+ if i != frame_count-1:
+ out_string += ", "
+ out_string += frames_end_template
+
+ return out_string
+
+func gen_rand_id(length: int = 5) -> String:
+ var out = ""
+ for i in range(length):
+ out += charset.pick_random()
+ return out