pokemon-base/addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd
2025-01-26 20:23:56 +01:00

681 lines
23 KiB
GDScript

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