From e58093b5a5b08b388da3dfe8d1b14e0dc5260b12 Mon Sep 17 00:00:00 2001 From: mStar Date: Sun, 26 Jan 2025 20:23:56 +0100 Subject: [PATCH] Code stuff --- .plugged/Asset-Drawer | 1 + .plugged/UIDesignTool | 1 + .plugged/gd-YAFSM | 1 + .plugged/gd-blender-3d-shortcuts | 1 + .plugged/index.cfg | 73 +- .plugged/loggie | 1 + addons/Asset_Drawer/AssetDrawerShortcut.tres | 7 + addons/Asset_Drawer/FileSystem.gd | 117 +++ addons/Asset_Drawer/LICENSE | 21 + addons/Asset_Drawer/plugin.cfg | 7 + addons/gd-blender-3d-shortcuts/Utils.gd | 188 ++++ addons/gd-blender-3d-shortcuts/plugin.cfg | 7 + addons/gd-blender-3d-shortcuts/plugin.gd | 828 ++++++++++++++++ .../scenes/pie_menu/PieMenu.gd | 159 ++++ .../scenes/pie_menu/PieMenu.tscn | 11 + .../scenes/pie_menu/PieMenuGroup.gd | 113 +++ .../scenes/pie_menu/PieMenuGroup.tscn | 13 + addons/imjp94.yafsm/README.md | 81 ++ addons/imjp94.yafsm/YAFSM.gd | 20 + .../imjp94.yafsm/assets/fonts/sans_serif.tres | 5 + .../assets/icons/add-white-18dp.svg | 1 + .../assets/icons/add-white-18dp.svg.import | 37 + .../assets/icons/arrow_right-white-18dp.svg | 1 + .../icons/arrow_right-white-18dp.svg.import | 37 + .../assets/icons/close-white-18dp.svg | 1 + .../assets/icons/close-white-18dp.svg.import | 37 + .../icons/compare_arrows-white-18dp.svg | 1 + .../compare_arrows-white-18dp.svg.import | 37 + .../assets/icons/remove-white-18dp.svg | 1 + .../assets/icons/remove-white-18dp.svg.import | 37 + .../assets/icons/stack_player_icon.png | Bin 0 -> 781 bytes .../assets/icons/stack_player_icon.png.import | 34 + .../assets/icons/state_machine_icon.png | Bin 0 -> 883 bytes .../icons/state_machine_icon.png.import | 34 + .../icons/state_machine_player_icon.png | Bin 0 -> 947 bytes .../state_machine_player_icon.png.import | 34 + .../subdirectory_arrow_right-white-18dp.svg | 1 + ...irectory_arrow_right-white-18dp.svg.import | 37 + addons/imjp94.yafsm/plugin.cfg | 7 + addons/imjp94.yafsm/plugin.gd | 151 +++ addons/imjp94.yafsm/scenes/ContextMenu.tscn | 12 + addons/imjp94.yafsm/scenes/ParametersPanel.gd | 63 ++ addons/imjp94.yafsm/scenes/PathViewer.gd | 64 ++ .../imjp94.yafsm/scenes/StateMachineEditor.gd | 761 +++++++++++++++ .../scenes/StateMachineEditor.tscn | 101 ++ .../scenes/StateMachineEditorLayer.gd | 149 +++ .../scenes/StateNodeContextMenu.tscn | 17 + .../condition_editors/BoolConditionEditor.gd | 22 + .../BoolConditionEditor.tscn | 35 + .../condition_editors/ConditionEditor.gd | 72 ++ .../condition_editors/ConditionEditor.tscn | 24 + .../condition_editors/FloatConditionEditor.gd | 44 + .../FloatConditionEditor.tscn | 26 + .../IntegerConditionEditor.gd | 45 + .../IntegerConditionEditor.tscn | 26 + .../StringConditionEditor.gd | 46 + .../StringConditionEditor.tscn | 29 + .../condition_editors/ValueConditionEditor.gd | 57 ++ .../ValueConditionEditor.tscn | 42 + .../scenes/flowchart/FlowChart.gd | 681 +++++++++++++ .../scenes/flowchart/FlowChartGrid.gd | 64 ++ .../scenes/flowchart/FlowChartLayer.gd | 157 +++ .../scenes/flowchart/FlowChartLine.gd | 91 ++ .../scenes/flowchart/FlowChartLine.tscn | 44 + .../scenes/flowchart/FlowChartNode.gd | 33 + .../scenes/flowchart/FlowChartNode.tscn | 34 + .../scenes/state_nodes/StateInspector.gd | 11 + .../scenes/state_nodes/StateNode.gd | 82 ++ .../scenes/state_nodes/StateNode.tscn | 79 ++ .../transition_editors/TransitionEditor.gd | 185 ++++ .../transition_editors/TransitionEditor.tscn | 133 +++ .../transition_editors/TransitionInspector.gd | 33 + .../transition_editors/TransitionLine.gd | 112 +++ .../transition_editors/TransitionLine.tscn | 26 + addons/imjp94.yafsm/scripts/Utils.gd | 103 ++ addons/imjp94.yafsm/src/StackPlayer.gd | 88 ++ addons/imjp94.yafsm/src/StateDirectory.gd | 94 ++ addons/imjp94.yafsm/src/StateMachinePlayer.gd | 378 ++++++++ .../src/conditions/BooleanCondition.gd | 22 + .../imjp94.yafsm/src/conditions/Condition.gd | 23 + .../src/conditions/FloatCondition.gd | 25 + .../src/conditions/IntegerCondition.gd | 23 + .../src/conditions/StringCondition.gd | 25 + .../src/conditions/ValueCondition.gd | 73 ++ .../imjp94.yafsm/src/debugger/StackItem.tscn | 11 + .../src/debugger/StackPlayerDebugger.gd | 50 + .../src/debugger/StackPlayerDebugger.tscn | 27 + addons/imjp94.yafsm/src/states/State.gd | 39 + .../imjp94.yafsm/src/states/StateMachine.gd | 230 +++++ .../src/transitions/Transition.gd | 98 ++ addons/loggie/assets/icon.png | Bin 0 -> 16039 bytes addons/loggie/assets/icon.png.import | 34 + addons/loggie/assets/logo.png | Bin 0 -> 59538 bytes addons/loggie/assets/logo.png.import | 34 + addons/loggie/custom_settings.gd.example | 53 ++ addons/loggie/loggie.gd | 167 ++++ addons/loggie/loggie_message.gd | 344 +++++++ addons/loggie/loggie_settings.gd | 404 ++++++++ addons/loggie/plugin.cfg | 7 + addons/loggie/plugin.gd | 47 + addons/loggie/tools/loggie_enums.gd | 53 ++ addons/loggie/tools/loggie_system_specs.gd | 185 ++++ addons/loggie/tools/loggie_tools.gd | 369 +++++++ .../assets/icons/folder_open-white-18dp.svg | 1 + .../icons/folder_open-white-18dp.svg.import | 37 + .../assets/icons/format-color-text.png | Bin 0 -> 480 bytes .../assets/icons/format-color-text.png.import | 34 + .../icons/format_align_center-white-18dp.svg | 1 + .../format_align_center-white-18dp.svg.import | 37 + .../icons/format_align_left-white-18dp.svg | 1 + .../format_align_left-white-18dp.svg.import | 37 + .../icons/format_align_right-white-18dp.svg | 1 + .../format_align_right-white-18dp.svg.import | 37 + .../assets/icons/format_bold-white-18dp.svg | 1 + .../icons/format_bold-white-18dp.svg.import | 37 + .../assets/icons/format_clear-white-18dp.svg | 1 + .../icons/format_clear-white-18dp.svg.import | 37 + .../icons/format_color_reset-white-18dp.svg | 1 + .../format_color_reset-white-18dp.svg.import | 37 + .../assets/icons/format_italic-white-18dp.svg | 1 + .../icons/format_italic-white-18dp.svg.import | 37 + .../icons/format_underlined-white-18dp.svg | 1 + .../format_underlined-white-18dp.svg.import | 37 + addons/ui_design_tool/assets/icons/marker.png | Bin 0 -> 483 bytes .../assets/icons/marker.png.import | 34 + .../assets/icons/more_horiz-white-18dp.svg | 1 + .../icons/more_horiz-white-18dp.svg.import | 37 + .../assets/icons/more_vert-white-18dp.svg | 1 + .../icons/more_vert-white-18dp.svg.import | 37 + .../photo_size_select_small-white-18dp.svg | 1 + ...to_size_select_small-white-18dp.svg.import | 37 + .../assets/icons/refresh-white-18dp.svg | 1 + .../icons/refresh-white-18dp.svg.import | 37 + .../vertical_align_bottom-white-18dp.svg | 1 + ...ertical_align_bottom-white-18dp.svg.import | 37 + .../vertical_align_center-white-18dp.svg | 1 + ...ertical_align_center-white-18dp.svg.import | 37 + .../icons/vertical_align_top-white-18dp.svg | 1 + .../vertical_align_top-white-18dp.svg.import | 37 + addons/ui_design_tool/plugin.cfg | 7 + addons/ui_design_tool/plugin.gd | 85 ++ .../ui_design_tool/scenes/OverlayTextEdit.gd | 58 ++ .../scenes/OverlayTextEdit.tscn | 31 + addons/ui_design_tool/scenes/Toolbar.gd | 899 ++++++++++++++++++ addons/ui_design_tool/scenes/Toolbar.tscn | 384 ++++++++ addons/ui_design_tool/scripts/FontManager.gd | 240 +++++ addons/ui_design_tool/scripts/Utils.gd | 61 ++ button.gd | 5 + map.tscn | 23 + plug.gd | 6 +- project.godot | 17 +- tools/SpriteGenerator.tscn | 6 + tools/sprite_generator.gd | 153 +++ 153 files changed, 11196 insertions(+), 4 deletions(-) create mode 160000 .plugged/Asset-Drawer create mode 160000 .plugged/UIDesignTool create mode 160000 .plugged/gd-YAFSM create mode 160000 .plugged/gd-blender-3d-shortcuts create mode 160000 .plugged/loggie create mode 100644 addons/Asset_Drawer/AssetDrawerShortcut.tres create mode 100644 addons/Asset_Drawer/FileSystem.gd create mode 100644 addons/Asset_Drawer/LICENSE create mode 100644 addons/Asset_Drawer/plugin.cfg create mode 100644 addons/gd-blender-3d-shortcuts/Utils.gd create mode 100644 addons/gd-blender-3d-shortcuts/plugin.cfg create mode 100644 addons/gd-blender-3d-shortcuts/plugin.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn create mode 100644 addons/imjp94.yafsm/README.md create mode 100644 addons/imjp94.yafsm/YAFSM.gd create mode 100644 addons/imjp94.yafsm/assets/fonts/sans_serif.tres create mode 100644 addons/imjp94.yafsm/assets/icons/add-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/add-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/assets/icons/close-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/close-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/assets/icons/stack_player_icon.png create mode 100644 addons/imjp94.yafsm/assets/icons/stack_player_icon.png.import create mode 100644 addons/imjp94.yafsm/assets/icons/state_machine_icon.png create mode 100644 addons/imjp94.yafsm/assets/icons/state_machine_icon.png.import create mode 100644 addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png create mode 100644 addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png.import create mode 100644 addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg create mode 100644 addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg.import create mode 100644 addons/imjp94.yafsm/plugin.cfg create mode 100644 addons/imjp94.yafsm/plugin.gd create mode 100644 addons/imjp94.yafsm/scenes/ContextMenu.tscn create mode 100644 addons/imjp94.yafsm/scenes/ParametersPanel.gd create mode 100644 addons/imjp94.yafsm/scenes/PathViewer.gd create mode 100644 addons/imjp94.yafsm/scenes/StateMachineEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/StateMachineEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/StateMachineEditorLayer.gd create mode 100644 addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartGrid.gd create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.gd create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartLine.tscn create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd create mode 100644 addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.tscn create mode 100644 addons/imjp94.yafsm/scenes/state_nodes/StateInspector.gd create mode 100644 addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd create mode 100644 addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn create mode 100644 addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd create mode 100644 addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.tscn create mode 100644 addons/imjp94.yafsm/scenes/transition_editors/TransitionInspector.gd create mode 100644 addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.gd create mode 100644 addons/imjp94.yafsm/scenes/transition_editors/TransitionLine.tscn create mode 100644 addons/imjp94.yafsm/scripts/Utils.gd create mode 100644 addons/imjp94.yafsm/src/StackPlayer.gd create mode 100644 addons/imjp94.yafsm/src/StateDirectory.gd create mode 100644 addons/imjp94.yafsm/src/StateMachinePlayer.gd create mode 100644 addons/imjp94.yafsm/src/conditions/BooleanCondition.gd create mode 100644 addons/imjp94.yafsm/src/conditions/Condition.gd create mode 100644 addons/imjp94.yafsm/src/conditions/FloatCondition.gd create mode 100644 addons/imjp94.yafsm/src/conditions/IntegerCondition.gd create mode 100644 addons/imjp94.yafsm/src/conditions/StringCondition.gd create mode 100644 addons/imjp94.yafsm/src/conditions/ValueCondition.gd create mode 100644 addons/imjp94.yafsm/src/debugger/StackItem.tscn create mode 100644 addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.gd create mode 100644 addons/imjp94.yafsm/src/debugger/StackPlayerDebugger.tscn create mode 100644 addons/imjp94.yafsm/src/states/State.gd create mode 100644 addons/imjp94.yafsm/src/states/StateMachine.gd create mode 100644 addons/imjp94.yafsm/src/transitions/Transition.gd create mode 100644 addons/loggie/assets/icon.png create mode 100644 addons/loggie/assets/icon.png.import create mode 100644 addons/loggie/assets/logo.png create mode 100644 addons/loggie/assets/logo.png.import create mode 100644 addons/loggie/custom_settings.gd.example create mode 100644 addons/loggie/loggie.gd create mode 100644 addons/loggie/loggie_message.gd create mode 100644 addons/loggie/loggie_settings.gd create mode 100644 addons/loggie/plugin.cfg create mode 100644 addons/loggie/plugin.gd create mode 100644 addons/loggie/tools/loggie_enums.gd create mode 100644 addons/loggie/tools/loggie_system_specs.gd create mode 100644 addons/loggie/tools/loggie_tools.gd create mode 100644 addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/folder_open-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format-color-text.png create mode 100644 addons/ui_design_tool/assets/icons/format-color-text.png.import create mode 100644 addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_align_center-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_align_left-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_align_right-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_bold-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_clear-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_color_reset-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_italic-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/format_underlined-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/marker.png create mode 100644 addons/ui_design_tool/assets/icons/marker.png.import create mode 100644 addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/more_horiz-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/more_vert-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/photo_size_select_small-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/refresh-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/refresh-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_bottom-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_center-white-18dp.svg.import create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg create mode 100644 addons/ui_design_tool/assets/icons/vertical_align_top-white-18dp.svg.import create mode 100644 addons/ui_design_tool/plugin.cfg create mode 100644 addons/ui_design_tool/plugin.gd create mode 100644 addons/ui_design_tool/scenes/OverlayTextEdit.gd create mode 100644 addons/ui_design_tool/scenes/OverlayTextEdit.tscn create mode 100644 addons/ui_design_tool/scenes/Toolbar.gd create mode 100644 addons/ui_design_tool/scenes/Toolbar.tscn create mode 100644 addons/ui_design_tool/scripts/FontManager.gd create mode 100644 addons/ui_design_tool/scripts/Utils.gd create mode 100644 button.gd create mode 100644 map.tscn create mode 100644 tools/SpriteGenerator.tscn create mode 100644 tools/sprite_generator.gd 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 0000000000000000000000000000000000000000..f60ad05955ae37d1444787911f10af36785f7108 GIT binary patch literal 781 zcmV+o1M>WdP);RvQ~;n$xc}l8}yv#sgcaN-E%+pd(S!DdoFn688vHF z_dqt6H)Erb;A~Y{5hG8CQu|Lv=#_xHKg{Sl~nulapA}A;rM+J2#E}aEer)>TKf{QrBMFgQbI5>#7 zI5?_;RvQ~;n$xc}l8}yv#sgcaN-E%+pd(S!DdoFn688vHF z_dqt6H)Erb;A~Y{5hG8CQu|Lv=#_xHKg{Sl~nuo;o0*W93{RJ8YIfb2AXnus9#n=SlsKIMQ5VeqiAPT+`FmXtu+=GSo z>JLy9ZLB7d_(WS@mEkJ67Ea`-C&69KT6^ua9}H_m3~6|+JQP{D3r8|HQ`P0O%nuYU zR=x66#(Qu$>t?IEd{o4c_Tf6FbE1pO*nxwYdx{4b!I;4K)n9_7x=;#lY_3-s?gW+s&68G>0E4yArz>~qUy;WWIS6fr=I3GhAZQ#)u(n6cI z(?+6?bq&1UuOt5xLv18xaVds$Cgby&`)`A}JjG;RvQ~;n$xc}l8}yv#sgcaN-E%+pd(S!DdoFn688vHF z_dqt6H)Erb;A~Y{5hG8CQu|Lv=#_xHKg{Sl~nu0uq>RmmL09TZoi(T|atnn9s$G{^Mn!q%05*P;vA-ofj*T7|9M!B27 zL>agaOvEmFrmy?}+Q36io&^>|2s0wmh+Xttxu3wb5^w_eS{UmKuobK*z&s*QT5 z>O^D%ST1nOz%jALx3t~53iH5H?4o-;hE4**%87CxOWNu>@EusueY=n0ZQvL1O*>vI z_y*Sa=KR>_r+pGhlWaUqvJvH8S>s1@tz6(XfFW!A;n+oMJvCzBbed$1TB~xdD3tO| z2;sPhoCQ{`@kjJEQF#;C2TZ3)_9RWR{r?pJnjwU3_2Q5<{+4 \ 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 0000000000000000000000000000000000000000..59c10f9939942a75fa72c6a7f2c7c738f127bb39 GIT binary patch literal 16039 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c7d6cde3ff71c1d3abc59db5d228f9008ac83e2c GIT binary patch literal 59538 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dc80ed32f8228065b00c44a6f140e40724cfc149 GIT binary patch literal 480 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweEpc6R~NK=9LfcRi5e zEbxddW?X?_wfUrhf_pt(978Nlznx^r=jbTV_Wz=m%j)_^3{ycptHiD02K#|Tpaw~h4Z-BuF?hQAxvX \ 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 0000000000000000000000000000000000000000..f164b44c8c369481791a86099f3dbdbae77f1c17 GIT binary patch literal 483 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweEpc6R~NK=9LfcRi5e zEbxddW?X?_wfUrhf(JZZ978NlpS@zM=Nc%&`k>T$uErk|rHh|b)*G-IGB^uZ z9Tx13((G|c=2<^QThPk!he_U^2fL0>?6^8#U%Pt#n \ 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