Code stuff

This commit is contained in:
Melody Becker 2025-01-26 20:23:56 +01:00
parent cf22890c16
commit e58093b5a5
No known key found for this signature in database
153 changed files with 11196 additions and 4 deletions

1
.plugged/Asset-Drawer Submodule

@ -0,0 +1 @@
Subproject commit f29aac8fb01cab02a76758aa49d355079ac99825

1
.plugged/UIDesignTool Submodule

@ -0,0 +1 @@
Subproject commit 0e3bdbbfe966a4ff27ab9b711eff55a049968fd9

1
.plugged/gd-YAFSM Submodule

@ -0,0 +1 @@
Subproject commit 5b220c7b5bb070f22ca8874198a553a198adab42

@ -0,0 +1 @@
Subproject commit ff9cf37f3d813745d871ba835be758874d2faac8

View file

@ -1,3 +1,74 @@
[plugin] [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"
}
}

1
.plugged/loggie Submodule

@ -0,0 +1 @@
Subproject commit 290d4fbe75b1103efab6fefab3dbf62b68022d84

View file

@ -0,0 +1,7 @@
[gd_resource type="InputEventKey" format=3 uid="uid://bafyb8y38ahfh"]
[resource]
device = -1
ctrl_pressed = true
keycode = 32
unicode = 32

View file

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

View file

@ -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.

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

@ -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`)

View file

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

View file

@ -0,0 +1,5 @@
[gd_resource type="SystemFont" format=3 uid="uid://dmcxm8gxsonbq"]
[resource]
font_names = PackedStringArray("Sans-Serif")
multichannel_signed_distance_field = true

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M10 17l5-5-5-5v10z"/><path d="M0 24V0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 176 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><g><rect fill="none" height="24" width="24" x="0"/></g><g><g><g><path d="M9.01,14H2v2h7.01v3L13,15l-3.99-4V14z M14.99,13v-3H22V8h-7.01V5L11,9L14.99,13z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 306 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 13H5v-2h14v2z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 15l-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9l6 6z"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View file

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

View file

@ -0,0 +1,7 @@
[plugin]
name="gd-YAFSM"
description="Yet Another Finite State Machine"
author="imjp94"
version="0.6.2"
script="plugin.gd"

View file

@ -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)

View file

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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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()

View file

@ -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)

View file

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

View file

@ -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)

File diff suppressed because one or more lines are too long

View file

@ -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()

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

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

View file

@ -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("/"))

View file

@ -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)

View file

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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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()]

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

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

View file

@ -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" : "│",
}

167
addons/loggie/loggie.gd Normal file
View file

@ -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()

View file

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

View file

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

7
addons/loggie/plugin.cfg Normal file
View file

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

47
addons/loggie/plugin.gd Normal file
View file

@ -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)

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