Broken graph editor

This commit is contained in:
MaddoScientisto 2026-03-01 23:14:33 +01:00
commit f996513dca
16 changed files with 1033 additions and 12 deletions

View file

@ -0,0 +1,499 @@
@tool
extends PanelContainer
const GraphAdapterScript := preload("res://addons/bullet_script_graph_editor/model/graph_adapter.gd")
const META_LAYOUT_KEY := "_bullet_graph_layout_v1"
var _editor_interface: EditorInterface
var _graph_adapter: RefCounted
var _current_resource: Resource
var _path_edit: LineEdit
var _status_label: Label
var _graph_edit: GraphEdit
var _file_dialog: FileDialog
var _open_selected_button: Button
var _inspect_selected_button: Button
var _selected_node_resource: Resource
var _wait_checkbox: CheckBox
func setup(editor_interface: EditorInterface) -> void:
_editor_interface = editor_interface
func _ready() -> void:
_graph_adapter = GraphAdapterScript.new()
_build_ui()
func is_supported_resource(resource: Resource) -> bool:
if _graph_adapter == null:
return false
return _graph_adapter.is_supported_resource(resource)
func open_resource(resource: Resource) -> void:
if resource == null:
_set_status("No resource selected.", true)
return
if not is_supported_resource(resource):
_set_status("Unsupported resource: %s" % resource.resource_path, true)
return
# Build the graph first to detect whether any nodes will be generated (diagnostic)
var built_graph: Dictionary = _graph_adapter.build_graph(resource)
var built_nodes: Dictionary = built_graph.get("nodes", {})
_current_resource = resource
_path_edit.text = resource.resource_path
_render_graph()
if built_nodes.size() == 0:
_set_status("Opened: %s (no nodes detected)" % _display_path(resource), true)
else:
_set_status("Opened: %s" % _display_path(resource), false)
func _build_ui() -> void:
var root := VBoxContainer.new()
root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(root)
var toolbar := HBoxContainer.new()
root.add_child(toolbar)
var open_button := Button.new()
open_button.text = "Open Resource"
open_button.pressed.connect(_on_open_pressed)
toolbar.add_child(open_button)
var open_inspected_button := Button.new()
open_inspected_button.text = "Open Inspected"
open_inspected_button.pressed.connect(_on_open_inspected_pressed)
toolbar.add_child(open_inspected_button)
var save_layout_button := Button.new()
save_layout_button.text = "Save Layout"
save_layout_button.pressed.connect(_save_layout_metadata)
toolbar.add_child(save_layout_button)
_open_selected_button = Button.new()
_open_selected_button.text = "Open Selected Node"
_open_selected_button.disabled = true
_open_selected_button.pressed.connect(_on_open_selected_node_pressed)
toolbar.add_child(_open_selected_button)
_inspect_selected_button = Button.new()
_inspect_selected_button.text = "Inspect Selected Node"
_inspect_selected_button.disabled = true
_inspect_selected_button.pressed.connect(_on_inspect_selected_node_pressed)
toolbar.add_child(_inspect_selected_button)
_path_edit = LineEdit.new()
_path_edit.placeholder_text = "res://Resources/..."
_path_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_path_edit.text_submitted.connect(_on_path_submitted)
toolbar.add_child(_path_edit)
_status_label = Label.new()
_status_label.text = "Open a BossScript/BossPhase/BulletScript to render graph."
root.add_child(_status_label)
_wait_checkbox = CheckBox.new()
_wait_checkbox.text = "Wait For Completion (selected pattern)"
_wait_checkbox.visible = false
_wait_checkbox.toggled.connect(_on_wait_for_completion_toggled)
root.add_child(_wait_checkbox)
_graph_edit = GraphEdit.new()
_graph_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_graph_edit.size_flags_vertical = Control.SIZE_EXPAND_FILL
_graph_edit.right_disconnects = true
_graph_edit.end_node_move.connect(_save_layout_metadata)
_graph_edit.node_selected.connect(_on_graph_node_selected)
root.add_child(_graph_edit)
_file_dialog = FileDialog.new()
_file_dialog.access = FileDialog.ACCESS_RESOURCES
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.add_filter("*.tres; Godot Resource")
_file_dialog.add_filter("*.res; Godot Resource")
_file_dialog.file_selected.connect(_on_file_selected)
add_child(_file_dialog)
func _on_open_pressed() -> void:
_file_dialog.popup_centered_ratio(0.8)
func _on_open_inspected_pressed() -> void:
if _editor_interface == null:
_set_status("Editor interface is not available.", true)
return
var inspector := _editor_interface.get_inspector()
if inspector == null:
_set_status("Inspector is not available.", true)
return
var edited_object := inspector.get_edited_object()
if not (edited_object is Resource):
_set_status("Inspector object is not a supported resource.", true)
return
open_resource(edited_object)
func _on_file_selected(path: String) -> void:
var loaded = ResourceLoader.load(path)
if not (loaded is Resource):
_set_status("Failed to load resource: %s" % path, true)
return
open_resource(loaded)
func _on_path_submitted(path: String) -> void:
if path.strip_edges() == "":
return
_on_file_selected(path)
func _clear_graph() -> void:
for child in _graph_edit.get_children():
if child is GraphNode:
child.queue_free()
func _render_graph() -> void:
if _current_resource == null:
return
_clear_graph()
var graph: Dictionary = _graph_adapter.build_graph(_current_resource)
var nodes: Dictionary = graph.get("nodes", {})
var edges: Array = graph.get("edges", [])
var node_name_by_id := {}
_selected_node_resource = null
_open_selected_button.disabled = true
_inspect_selected_button.disabled = true
_wait_checkbox.visible = false
var layout := _load_layout_metadata()
var auto_index := 0
for node_id in nodes.keys():
var node_data: Dictionary = nodes[node_id]
var graph_node := GraphNode.new()
graph_node.name = _node_name_from_id(node_id)
graph_node.title = node_data.get("title", "Node")
graph_node.resizable = false
graph_node.custom_minimum_size = Vector2(240, 84)
graph_node.set_meta("graph_id", node_id)
var info := VBoxContainer.new()
var subtitle := Label.new()
subtitle.text = node_data.get("subtitle", "")
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
subtitle.clip_text = true
subtitle.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.add_child(subtitle)
# Collapsible details handled below when there are actual details to show
# Determine if there are details to show (description or exported properties or patterns add button)
var potential_details := []
var node_resource: Resource = node_data.get("resource", null)
if node_resource != null:
graph_node.set_meta("resource", node_resource)
if node_resource != null:
var desc_text := str(node_data.get("subtitle", ""))
if desc_text != "":
potential_details.append("desc")
# check for exported properties (editor usage)
for property_data in node_resource.get_property_list():
if property_data is Dictionary:
var pname := property_data.get("name", "")
if pname == "" or pname.begins_with("_") or pname == "Patterns":
continue
var usage := int(property_data.get("usage", 0))
if usage & PROPERTY_USAGE_EDITOR:
potential_details.append(pname)
# Add Pattern button is considered a detail for BulletScript3D
if node_data.get("kind", "") == "BulletScript3D":
potential_details.append("add_pattern")
var has_details := potential_details.size() > 0
var details: VBoxContainer = null
var toggle: Button = null
if has_details:
details = VBoxContainer.new()
details.name = "details"
details.visible = false
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.add_child(details)
# Toggle button to expand/collapse
toggle = Button.new()
toggle.text = "Details"
toggle.toggle_mode = true
# default is not pressed; use setter to avoid assigning to a constant
toggle.set_pressed(false)
# bind graph_node and details so we can resize the node when toggling
toggle.pressed.connect(Callable(self, "_on_toggle_details_pressed").bind(graph_node, details))
info.add_child(toggle)
graph_node.add_child(info)
# Add quick action buttons on each node for convenience
if node_resource != null:
var actions := HBoxContainer.new()
actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var btn_open := Button.new()
btn_open.text = "Open"
btn_open.pressed.connect(Callable(self, "_on_node_open_pressed").bind(graph_node))
actions.add_child(btn_open)
var btn_inspect := Button.new()
btn_inspect.text = "Inspect"
btn_inspect.pressed.connect(Callable(self, "_on_node_inspect_pressed").bind(graph_node))
actions.add_child(btn_inspect)
info.add_child(actions)
graph_node.set_slot(0, true, 0, Color(0.75, 0.75, 0.9), true, 0, Color(0.75, 0.9, 0.75))
if layout.has(node_id):
graph_node.position_offset = layout[node_id]
else:
graph_node.position_offset = Vector2(60 + (auto_index % 5) * 320, 36 + int(auto_index / 5) * 160)
auto_index += 1
_graph_edit.add_child(graph_node)
node_name_by_id[node_id] = graph_node.name
for edge_data in edges:
var from_id: String = edge_data.get("from", "")
var to_id: String = edge_data.get("to", "")
if not (node_name_by_id.has(from_id) and node_name_by_id.has(to_id)):
continue
var from_name: StringName = StringName(node_name_by_id[from_id])
var to_name: StringName = StringName(node_name_by_id[to_id])
if from_name == to_name:
continue
if _graph_edit.is_node_connected(from_name, 0, to_name, 0):
continue
_graph_edit.connect_node(from_name, 0, to_name, 0)
func _load_layout_metadata() -> Dictionary:
if _current_resource == null:
return {}
if not _current_resource.has_meta(META_LAYOUT_KEY):
return {}
var data = _current_resource.get_meta(META_LAYOUT_KEY, {})
return data if data is Dictionary else {}
func _save_layout_metadata() -> void:
if _current_resource == null:
return
var layout := {}
for child in _graph_edit.get_children():
if not (child is GraphNode):
continue
var graph_id: String = child.get_meta("graph_id", "")
if graph_id == "":
continue
layout[graph_id] = child.position_offset
_current_resource.set_meta(META_LAYOUT_KEY, layout)
if _current_resource.resource_path != "":
var err := ResourceSaver.save(_current_resource, _current_resource.resource_path)
if err != OK:
_set_status("Failed to save graph layout metadata.", true)
return
_set_status("Saved graph layout metadata.", false)
func _node_name_from_id(node_id: String) -> String:
return "n_%s" % str(abs(node_id.hash()))
func _display_path(resource: Resource) -> String:
if resource == null:
return ""
if resource.resource_path != "":
return resource.resource_path
return "<subresource>"
func _set_status(message: String, is_error: bool) -> void:
if _status_label == null:
return
_status_label.text = message
_status_label.modulate = Color(1, 0.55, 0.55) if is_error else Color(1, 1, 1)
func _on_graph_node_selected(node_arg) -> void:
# GraphEdit may send different types (StringName, NodePath, or the node object itself).
var graph_node: GraphNode = null
if typeof(node_arg) == TYPE_OBJECT and node_arg is GraphNode:
graph_node = node_arg
else:
# Try to resolve by path or name
var resolved := _graph_edit.get_node_or_null(node_arg)
if resolved is GraphNode:
graph_node = resolved
if not (graph_node is GraphNode):
_selected_node_resource = null
_open_selected_button.disabled = true
_inspect_selected_button.disabled = true
_wait_checkbox.visible = false
return
_selected_node_resource = _get_graph_node_resource(graph_node)
var enabled := _selected_node_resource != null
_open_selected_button.disabled = not enabled
_inspect_selected_button.disabled = not enabled
if enabled and _resource_has_property(_selected_node_resource, "WaitForCompletion"):
_wait_checkbox.visible = true
_wait_checkbox.set_pressed_no_signal(bool(_selected_node_resource.get("WaitForCompletion")))
else:
_wait_checkbox.visible = false
_set_status("Selected: %s" % _display_path(_selected_node_resource), false)
func _on_node_open_pressed(graph_node: GraphNode) -> void:
if not (graph_node is GraphNode):
return
var res := _get_graph_node_resource(graph_node)
if res == null:
_set_status("Node has no resource to open.", true)
return
open_resource(res)
func _on_toggle_details_pressed(graph_node: GraphNode, details: VBoxContainer) -> void:
if details == null or graph_node == null:
return
details.visible = not details.visible
if details.visible:
var extra := details.get_combined_minimum_size().y
graph_node.custom_minimum_size = Vector2(240, 84 + extra)
else:
graph_node.custom_minimum_size = Vector2(240, 84)
func _on_prop_text_changed(resource: Resource, prop_name: String, new_text: String) -> void:
if resource == null:
return
resource.set(prop_name, new_text)
if resource.resource_path != "":
ResourceSaver.save(resource, resource.resource_path)
_set_status("Updated %s" % prop_name, false)
func _on_prop_float_changed(resource: Resource, prop_name: String, value: float) -> void:
if resource == null:
return
resource.set(prop_name, value)
if resource.resource_path != "":
ResourceSaver.save(resource, resource.resource_path)
_set_status("Updated %s" % prop_name, false)
func _on_prop_bool_changed(resource: Resource, prop_name: String, pressed: bool) -> void:
if resource == null:
return
resource.set(prop_name, pressed)
if resource.resource_path != "":
ResourceSaver.save(resource, resource.resource_path)
_set_status("Updated %s" % prop_name, false)
func _on_add_shooting_pattern_pressed(resource: Resource) -> void:
if resource == null:
_set_status("No resource to add pattern to.", true)
return
# Try to instantiate a ShootingPattern3D (GlobalClass C# type)
var new_pattern = null
# attempt to instantiate ShootingPattern3D; fall back to generic Resource if not available
# Instantiating a C# AttackPattern from GDScript can be fragile; create a generic Resource for now.
new_pattern = Resource.new()
# Append to Patterns array if present
if _resource_has_property(resource, "Patterns"):
var arr := resource.get("Patterns")
if not (arr is Array):
arr = []
arr.append(new_pattern)
resource.set("Patterns", arr)
if resource.resource_path != "":
ResourceSaver.save(resource, resource.resource_path)
_set_status("Added new pattern.", false)
_render_graph()
else:
_set_status("Resource has no Patterns array.", true)
func _on_node_inspect_pressed(graph_node: GraphNode) -> void:
if not (graph_node is GraphNode):
return
var res := _get_graph_node_resource(graph_node)
if res == null:
_set_status("Node has no resource to inspect.", true)
return
if _editor_interface == null:
_set_status("Editor interface not available.", true)
return
# Defer opening the inspector to avoid any re-entrancy or script-parse conflicts
call_deferred("_deferred_edit_resource", res)
# Immediate status update; the actual inspector open happens shortly after
_set_status("Inspecting (deferred): %s" % _display_path(res), false)
func _deferred_edit_resource(res: Resource) -> void:
if _editor_interface == null or res == null:
return
_editor_interface.edit_resource(res)
_set_status("Inspecting: %s" % _display_path(res), false)
func _on_open_selected_node_pressed() -> void:
if _selected_node_resource == null:
_set_status("No node resource selected.", true)
return
open_resource(_selected_node_resource)
func _on_inspect_selected_node_pressed() -> void:
if _selected_node_resource == null:
_set_status("No node resource selected.", true)
return
if _editor_interface == null:
_set_status("Editor interface is not available.", true)
return
_editor_interface.edit_resource(_selected_node_resource)
_set_status("Inspected selected node resource.", false)
func _unique_strings(values: Array) -> PackedStringArray:
var seen := {}
var result := PackedStringArray()
for value in values:
var text := str(value)
if text == "" or seen.has(text):
continue
seen[text] = true
result.append(text)
if result.is_empty():
result.append("-")
return result
func _on_wait_for_completion_toggled(pressed: bool) -> void:
if _selected_node_resource == null:
return
if not _resource_has_property(_selected_node_resource, "WaitForCompletion"):
return
_selected_node_resource.set("WaitForCompletion", pressed)
if _selected_node_resource.resource_path != "":
var err := ResourceSaver.save(_selected_node_resource, _selected_node_resource.resource_path)
if err != OK:
_set_status("Failed to save WaitForCompletion.", true)
return
_set_status("Updated WaitForCompletion on selected pattern.", false)
_render_graph()
func _resource_has_property(resource: Object, property_name: String) -> bool:
for property_data in resource.get_property_list():
if property_data is Dictionary and property_data.get("name", "") == property_name:
return true
return false
func _get_graph_node_resource(graph_node: GraphNode) -> Resource:
if graph_node == null:
return null
if not graph_node.has_meta("resource"):
return null
var value = graph_node.get_meta("resource")
return value if value is Resource else null

View file

@ -0,0 +1 @@
uid://duc4xqfaiyjq5

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://addons/bullet_script_graph_editor/ui/graph_editor_dock.gd" id="1_5n6ty"]
[node name="GraphEditorDock" type="PanelContainer"]
script = ExtResource("1_5n6ty")