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) ![StackPlayer icon](assets/icons/stack_player_icon.png) + > 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) ![StateMachinePlayer icon](assets/icons/state_machine_player_icon.png) + > 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 icon](assets/icons/state_machine_icon.png) + > `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