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

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="BossScript" load_steps=5 format=3 uid="uid://1u4y6hvc318e"]
[gd_resource type="Resource" script_class="BossScript" format=3 uid="uid://1u4y6hvc318e"]
[ext_resource type="Script" uid="uid://cdd6q2h0t1hhq" path="res://Scripts/Resources/BossPhase.cs" id="1_k3wbt"]
[ext_resource type="Resource" uid="uid://cnsmcexyhppdo" path="res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres" id="2_ovn8y"]
@ -10,3 +10,36 @@ script = ExtResource("2_t8f0y")
BossName = &"Rumia"
Phases = Array[ExtResource("1_k3wbt")]([ExtResource("2_ovn8y"), ExtResource("3_mi6hp")])
metadata/_custom_type_script = "uid://inasa76li3ym"
metadata/_bullet_graph_layout_v1 = {
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres": Vector2(-2380, 340),
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres::Resource_lrsat": Vector2(1140, 490),
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres::Resource_mi6hp": Vector2(60, 710),
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres::Resource_ovn8y": Vector2(1500, 490),
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres::Resource_xfnue": Vector2(-660, -340),
"res://Resources/BossPhases/Rumia/Rumia_NS1_3D.tres::legacy_lane": Vector2(-1380, -160),
"res://Resources/BossPhases/Rumia/Rumia_NS_1_Chase.tres": Vector2(3380, -580),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres": Vector2(-1400, 900),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_38hmx": Vector2(60, 930),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_46ksb": Vector2(1500, 1150),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_487di": Vector2(420, 930),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_4hlom": Vector2(1140, 710),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_4rhw5": Vector2(780, 930),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_78gkf": Vector2(2820, 2080),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_ali57": Vector2(1500, 710),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_cprus": Vector2(1500, 930),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_ddofg": Vector2(420, 1150),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_x84ys": Vector2(1140, 1150),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::Resource_xmchp": Vector2(1140, 930),
"res://Resources/BossPhases/Rumia/Rumia_SP1_3D.tres::legacy_lane": Vector2(780, 710),
"res://Resources/BossPhases/Rumia_Boss_Script_3D.tres": Vector2(-3020, 220),
"res://Resources/Patterns/Rumia_NS_2_3D.tres": Vector2(1800, 1960),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_2yamt": Vector2(780, 270),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_8xphn": Vector2(-300, 1960),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_d0o1f": Vector2(60, 490),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_rdwk1": Vector2(420, 270),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_t7cg8": Vector2(780, 490),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_ye6gf": Vector2(1140, 270),
"res://Resources/Patterns/Rumia_NS_2_3D.tres::Resource_yrur2": Vector2(420, 490),
"res://Resources/Patterns/Rumia_SP1_Part2.tres": Vector2(60, 1150),
"res://Resources/Patterns/Rumia_SP1_Part2_Predicting.tres": Vector2(780, 1150)
}

View file

@ -15,8 +15,17 @@ burstInterval = 0.1
ShotsPerBurst = 4
BurstRate = 1.0
metadata/_custom_type_script = "uid://bxiprx5nwmpnu"
metadata/_bullet_graph_layout_v1 = {
"res://Resources/BulletScripts/Danmaku_Room_1.tres::Resource_bdx2k": Vector2(-340, -60)
}
[resource]
script = ExtResource("4_7mmfe")
Title = "Danmaku Room 1"
Description = "Script for the first danmaku room"
Patterns = Array[Object]([SubResource("Resource_bdx2k")])
metadata/_custom_type_script = "uid://w8hcpu68ssq"
metadata/_bullet_graph_layout_v1 = {
"res://Resources/BulletScripts/Danmaku_Room_1.tres": Vector2(-1280, 40),
"res://Resources/BulletScripts/Danmaku_Room_1.tres::Resource_bdx2k": Vector2(-880, 140)
}

View file

@ -1,5 +1,6 @@
using Cirno.Scripts.Actors;
using Cirno.Scripts.AttackPatterns;
using Cirno.Scripts.Resources.BulletScripts;
using Godot;
using Godot.Collections;
@ -12,23 +13,57 @@ public partial class BossPhase : Resource
[Export] public string PhaseName = string.Empty;
[Export] public int Threshold;
[Export] public bool PlayAnimation;
[Export] public BulletScript3D BulletScript3D;
// Legacy path kept for backwards compatibility with existing phase compositions.
// TODO: Migrate compositions to BulletScript3D-first workflows where possible.
[Export] public Array<AttackPattern> Patterns;
private int currentPatternIndex = 0;
private double patternTimer;
private bool _useBulletScript3D;
private IPatternMachine _patternMachine;
private BulletScript3D.BulletScriptMachine _bulletScriptMachine;
public void Start(Node boss)
{
_useBulletScript3D = BulletScript3D is not null;
patternTimer = 0;
if (_useBulletScript3D)
{
_bulletScriptMachine = BulletScript3D.Make(boss);
_bulletScriptMachine.Start();
_patternMachine = null;
return;
}
if (Patterns is null || Patterns.Count == 0)
{
GD.PushWarning($"BossPhase '{PhaseName}' has no legacy Patterns and no BulletScript3D assigned.");
_patternMachine = null;
return;
}
currentPatternIndex = 0;
_patternMachine = Patterns[currentPatternIndex].MakeMachine(boss);
_patternMachine.Start();
//Patterns[currentPatternIndex].Start(boss);
}
public void UpdatePhase(double delta)
{
if (_useBulletScript3D)
{
_bulletScriptMachine?.UpdatePhase(delta);
return;
}
if (_patternMachine is null || Patterns is null || Patterns.Count == 0)
{
return;
}
patternTimer += delta;
var currentPattern = Patterns[currentPatternIndex];
@ -40,7 +75,6 @@ public partial class BossPhase : Resource
var oldParent = _patternMachine.Parent;
_patternMachine = Patterns[currentPatternIndex].MakeMachine(oldParent);
_patternMachine.Start();
//_patternMachine.Start(currentPattern.Parent);
}
}
}

View file

@ -9,7 +9,12 @@ namespace Cirno.Scripts.Resources.BulletScripts;
public partial class BulletScript3D : Resource
{
[Export]
public Array<AttackPattern> Patterns { get; private set; }
public string Title { get; set; } = string.Empty;
[Export]
public string Description { get; set; } = string.Empty;
[Export]
public Array<AttackPattern> Patterns { get; set; } = new Array<AttackPattern>();
public BulletScriptMachine Make(Node parent)
{

View file

@ -0,0 +1,46 @@
@tool
extends EditorInspectorPlugin
var _plugin: EditorPlugin
func setup(plugin: EditorPlugin) -> void:
_plugin = plugin
func _can_handle(object: Object) -> bool:
return object is Resource and _is_supported_resource(object)
func _parse_begin(object: Object) -> void:
if not (object is Resource):
return
var button := Button.new()
button.text = "Open In Bullet Graph Editor"
button.pressed.connect(_on_open_pressed.bind(object))
add_custom_control(button)
func _on_open_pressed(resource: Resource) -> void:
if _plugin == null:
return
_plugin.call("open_resource_in_graph", resource)
func _is_supported_resource(resource: Resource) -> bool:
var script: Script = resource.get_script()
if script == null:
return false
var path := script.resource_path
if path.contains("/Scripts/AttackPatterns/"):
return true
if path.contains("/Scripts/Resources/") and path.contains("Pattern"):
return true
if path.ends_with("BossScript.cs") \
or path.ends_with("BossPhase.cs") \
or path.ends_with("BulletScript3D.cs") \
or path.ends_with("BulletScript.cs") \
or path.ends_with("AttackPattern.cs") \
or path.ends_with("PatternGroup.cs") \
or path.ends_with("ParallelPatternGroup.cs"):
return true
return resource.get("WaitForCompletion") != null

View file

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

View file

@ -0,0 +1,281 @@
@tool
extends RefCounted
const SUPPORTED_SCRIPT_SUFFIXES := [
"BossScript.cs",
"BossPhase.cs",
"BulletScript3D.cs",
"BulletScript.cs",
"AttackPattern.cs",
"PatternGroup.cs",
"ParallelPatternGroup.cs",
"MovementPattern.cs"
]
func is_supported_resource(resource: Resource) -> bool:
if resource == null:
return false
var script: Script = resource.get_script()
var path := ""
if script != null:
path = script.resource_path
if path != "" and path.contains("/Scripts/AttackPatterns/"):
return true
if path != "" and path.contains("/Scripts/Resources/") and path.contains("Pattern"):
return true
for suffix in SUPPORTED_SCRIPT_SUFFIXES:
if path != "" and path.ends_with(suffix):
return true
# Heuristic for AttackPattern-derived resources exposed from C# exports.
if resource.get("WaitForCompletion") != null:
return true
return false
func build_graph(root: Resource) -> Dictionary:
var graph := {
"nodes": {},
"edges": [],
"edge_index": {},
"root_id": ""
}
if root == null:
return graph
var ctx := {
"graph": graph,
"visited": {}
}
var root_id := _visit_resource(root, "", "root", "root", ctx)
graph.erase("edge_index")
graph["root_id"] = root_id
return graph
func _visit_resource(resource: Resource, parent_id: String, edge_type: String, label: String, ctx: Dictionary) -> String:
var id := _make_node_id(resource)
var nodes: Dictionary = ctx["graph"]["nodes"]
if not nodes.has(id):
nodes[id] = {
"id": id,
"title": _make_title(resource, label),
"subtitle": _make_subtitle(resource),
"kind": _get_kind(resource),
"resource": resource,
"incoming": [],
"outgoing": []
}
if parent_id != "":
_add_edge(parent_id, id, edge_type, ctx)
var visited: Dictionary = ctx["visited"]
if visited.has(id):
return id
visited[id] = true
_process_children(resource, id, ctx)
return id
func _add_edge(from_id: String, to_id: String, edge_type: String, ctx: Dictionary) -> void:
var edge_key := "%s|%s|%s" % [from_id, to_id, edge_type]
var edge_index: Dictionary = ctx["graph"]["edge_index"]
if edge_index.has(edge_key):
return
edge_index[edge_key] = true
ctx["graph"]["edges"].append({
"from": from_id,
"to": to_id,
"type": edge_type
})
var nodes: Dictionary = ctx["graph"]["nodes"]
if nodes.has(from_id):
nodes[from_id]["outgoing"].append(edge_type)
if nodes.has(to_id):
nodes[to_id]["incoming"].append(edge_type)
func _ensure_virtual_node(id: String, title: String, subtitle: String, kind: String, ctx: Dictionary) -> void:
var nodes: Dictionary = ctx["graph"]["nodes"]
if nodes.has(id):
return
nodes[id] = {
"id": id,
"title": title,
"subtitle": subtitle,
"kind": kind,
"resource": null,
"incoming": [],
"outgoing": []
}
func _process_children(resource: Resource, id: String, ctx: Dictionary) -> void:
var kind := _get_kind(resource)
if kind == "BossScript":
_visit_ordered_array(resource, "Phases", id, "phase", "phase", ctx)
return
if kind == "BossPhase":
var bullet_lane_id := "%s::bullet_lane" % id
var legacy_lane_id := "%s::legacy_lane" % id
var bullet_script: Variant = _get_prop(resource, "BulletScript3D")
if bullet_script is Resource:
_ensure_virtual_node(bullet_lane_id, "BulletScript3D Lane", "Preferred execution path", "Lane", ctx)
_add_edge(id, bullet_lane_id, "bullet_script_lane", ctx)
_visit_resource(bullet_script, bullet_lane_id, "bullet_script", "BulletScript3D", ctx)
var legacy_values: Array = _get_array_prop(resource, "Patterns")
if legacy_values.size() > 0:
_ensure_virtual_node(legacy_lane_id, "Legacy Patterns Lane", "Compatibility path", "LegacyLane", ctx)
_add_edge(id, legacy_lane_id, "legacy_lane", ctx)
_visit_ordered_array(resource, "Patterns", legacy_lane_id, "legacy", "pattern", ctx)
return
if kind == "BulletScript3D" or kind == "BulletScript":
_visit_ordered_array(resource, "Patterns", id, "sequence", "pattern", ctx)
return
if kind == "PatternGroup":
_visit_ordered_array_fallback(resource, ["Patterns", "patterns"], id, "group", "child", ctx)
return
if kind == "ParallelPatternGroup":
_visit_parallel_array(resource, "Patterns", id, "parallel", "child", ctx)
return
if kind == "MovementPattern":
var child: Variant = _get_prop(resource, "shootingPattern")
if child is Resource:
_visit_resource(child, id, "embedded", "shootingPattern", ctx)
return
_process_common_links(resource, id, ctx)
func _process_common_links(resource: Resource, id: String, ctx: Dictionary) -> void:
for property_name in ["BulletScript3D", "BulletScript", "Script", "shootingPattern"]:
var linked: Variant = _get_prop(resource, property_name)
if linked is Resource:
_visit_resource(linked, id, "reference", property_name, ctx)
# Generic fallback for pattern arrays in custom pattern resources.
for array_name in ["Patterns", "patterns"]:
var values := _get_array_prop(resource, array_name)
if values.size() > 0:
_visit_ordered_array_fallback(resource, [array_name], id, "sequence", "pattern", ctx)
return
func _visit_ordered_array(owner: Resource, property_name: String, parent_id: String, first_edge_type: String, label_prefix: String, ctx: Dictionary) -> void:
var values: Array = _get_array_prop(owner, property_name)
var prev := parent_id
for i in values.size():
var item = values[i]
if not (item is Resource):
continue
var edge_type := first_edge_type if prev == parent_id else "sequence"
var label := "%s[%d]" % [label_prefix, i]
prev = _visit_resource(item, prev, edge_type, label, ctx)
func _visit_ordered_array_fallback(owner: Resource, property_names: Array, parent_id: String, first_edge_type: String, label_prefix: String, ctx: Dictionary) -> void:
for property_name in property_names:
var values: Array = _get_array_prop(owner, property_name)
if values.size() == 0:
continue
var prev := parent_id
for i in values.size():
var item = values[i]
if not (item is Resource):
continue
var edge_type := first_edge_type if prev == parent_id else "sequence"
var label := "%s[%d]" % [label_prefix, i]
prev = _visit_resource(item, prev, edge_type, label, ctx)
return
func _visit_parallel_array(owner: Resource, property_name: String, parent_id: String, edge_type: String, label_prefix: String, ctx: Dictionary) -> void:
var values: Array = _get_array_prop(owner, property_name)
for i in values.size():
var item = values[i]
if not (item is Resource):
continue
var label := "%s[%d]" % [label_prefix, i]
_visit_resource(item, parent_id, edge_type, label, ctx)
func _get_array_prop(resource: Resource, property_name: String) -> Array:
var value = _get_prop(resource, property_name)
return value if value is Array else []
func _get_prop(resource: Resource, property_name: String):
if resource == null:
return null
if not _has_property(resource, property_name):
return null
if resource.has_method("get"):
return resource.get(property_name)
return null
func _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 _make_node_id(resource: Resource) -> String:
if resource.resource_path != "":
return resource.resource_path
return "%s:%s" % [_get_kind(resource), str(resource.get_instance_id())]
func _make_title(resource: Resource, label: String) -> String:
var kind := _get_kind(resource)
# Prefer explicit Title property if present
var title_prop = _get_prop(resource, "Title")
if title_prop != null and str(title_prop) != "":
return "%s" % str(title_prop)
if kind == "BossScript":
var boss_name = _get_prop(resource, "BossName")
if boss_name != null and str(boss_name) != "":
return "BossScript: %s" % str(boss_name)
if kind == "BossPhase":
var phase_name = _get_prop(resource, "PhaseName")
if phase_name != null and str(phase_name) != "":
return "BossPhase: %s" % str(phase_name)
if label != "" and label != "root":
return "%s (%s)" % [kind, label]
return kind
func _make_subtitle(resource: Resource) -> String:
# Prefer Description property when available
var desc = _get_prop(resource, "Description")
if desc != null and str(desc) != "":
return str(desc)
if resource.resource_path != "":
return resource.resource_path
return "subresource"
func _get_kind(resource: Resource) -> String:
var script: Script = resource.get_script()
if script == null:
return resource.get_class()
var path := script.resource_path
if path.ends_with("BossScript.cs"):
return "BossScript"
if path.ends_with("BossPhase.cs"):
return "BossPhase"
if path.ends_with("BulletScript3D.cs"):
return "BulletScript3D"
if path.ends_with("BulletScript.cs"):
return "BulletScript"
if path.ends_with("PatternGroup.cs"):
return "PatternGroup"
if path.ends_with("ParallelPatternGroup.cs"):
return "ParallelPatternGroup"
if path.ends_with("MovementPattern.cs"):
return "MovementPattern"
if path.ends_with("AttackPattern.cs"):
return "AttackPattern"
if path.contains("/Scripts/AttackPatterns/"):
return path.get_file().trim_suffix(".cs")
var file_name := path.get_file().trim_suffix(".cs")
return file_name if file_name != "" else resource.get_class()

View file

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

View file

@ -0,0 +1,7 @@
[plugin]
name="Bullet Script Graph Editor"
description="Graph editor for BossScript and BulletScript resources."
author="GitHub Copilot"
version="0.1.0"
script="plugin.gd"

View file

@ -0,0 +1,32 @@
@tool
extends EditorPlugin
const GraphEditorDockScene := preload("res://addons/bullet_script_graph_editor/ui/graph_editor_dock.tscn")
const InspectorPluginScript := preload("res://addons/bullet_script_graph_editor/inspector_plugin.gd")
var _dock_instance: PanelContainer
var _inspector_plugin: EditorInspectorPlugin
func _enter_tree() -> void:
_dock_instance = GraphEditorDockScene.instantiate()
_dock_instance.setup(get_editor_interface())
add_control_to_bottom_panel(_dock_instance, "Bullet Graph")
_inspector_plugin = InspectorPluginScript.new()
_inspector_plugin.setup(self)
add_inspector_plugin(_inspector_plugin)
func _exit_tree() -> void:
if _inspector_plugin:
remove_inspector_plugin(_inspector_plugin)
_inspector_plugin = null
if _dock_instance:
remove_control_from_bottom_panel(_dock_instance)
_dock_instance.queue_free()
_dock_instance = null
func open_resource_in_graph(resource: Resource) -> void:
if _dock_instance == null:
return
_dock_instance.open_resource(resource)

View file

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

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

View file

@ -0,0 +1,65 @@
# Bullet Script Graph Editor Plan
## Goals
- Create an editor plugin in GDScript using `GraphEdit` and `GraphNode` for visual authoring of bullet scripts.
- Support opening from `BossScript`, `BossPhase`, `BulletScript3D`, or `BulletScript`.
- Visualize parent/child pattern relationships as linked graph nodes.
- Keep legacy `BossPhase.Patterns` intact to avoid breaking existing compositions.
- Introduce `BossPhase -> BulletScript3D` for flexible composition.
- Persist graph layout metadata so graph positions remain stable across sessions.
## Existing System Summary
- `BossScript` contains phases: `Scripts/Resources/ScriptableBullets/BossScript.cs`.
- `BossPhase` currently executes legacy `Patterns` (`Array<AttackPattern>`): `Scripts/Resources/BossPhase.cs`.
- `BulletScript3D` and `BulletScript` are standalone pattern containers:
- `Scripts/Resources/BulletScripts/BulletScript3D.cs`
- `Scripts/Resources/BulletScript.cs`
- Pattern composition already exists with:
- Sequential group: `Scripts/Resources/PatternGroup.cs`
- Parallel group: `Scripts/Resources/ScriptableBullets/ParallelPatternGroup.cs`
## Non-Breaking Runtime Changes
1. Add `[Export] public BulletScript3D BulletScript3D;` to `BossPhase`.
2. Keep `Patterns` unchanged and mark as legacy with a TODO comment.
3. In `BossPhase.Start` and `BossPhase.UpdatePhase`, prioritize `BulletScript3D` execution when assigned.
4. Fall back to legacy `Patterns` path when `BulletScript3D` is not assigned.
5. Add null/empty guards for robust editor/runtime behavior.
## Plugin Architecture
- New addon: `addons/bullet_script_graph_editor/`
- Main files:
- `plugin.cfg`
- `plugin.gd` (`EditorPlugin` entry)
- `inspector_plugin.gd` (open-in-graph button in inspector)
- `ui/graph_editor_dock.tscn`
- `ui/graph_editor_dock.gd`
- `model/graph_adapter.gd`
### Entry Points
- Bottom dock panel for direct resource picking/opening.
- Inspector button for fast open from selected resource.
### Graph Mapping Rules
- `BossScript` -> phase chain nodes.
- `BossPhase` ->
- legacy pattern chain lane from `Patterns`
- optional linked `BulletScript3D` lane.
- `BulletScript3D` / `BulletScript` -> ordered pattern chain.
- `PatternGroup` and `ParallelPatternGroup` -> composite nodes with child links.
### Persistence
- Store node graph position metadata on resources using editor metadata keys.
- Use stable node IDs to restore positions across reopen.
## Validation Checklist
1. Enable plugin and confirm dock is visible.
2. Open `Resources/BossPhases/Rumia_Boss_Script_3D.tres` and verify phase/pattern graph.
3. Open `Resources/BulletScripts/Danmaku_Room_1.tres` and verify pattern chain graph.
4. Save, reopen, and verify node positions persist.
5. Run build:
- `dotnet build k:/godot/cirno/Cirno.csproj -c Debug -v normal -p:GodotTargetPlatform=windows`
## Out-of-Scope for Initial Cut
- Removing legacy `Patterns`.
- Full simulation/debug playback inside the graph UI.
- Automatic bulk migration of all existing resources.

View file

@ -197,7 +197,7 @@ movie_writer/movie_file="D:/Maddo/Recordings/Capture.avi"
[editor_plugins]
enabled=PackedStringArray("res://addons/cyclops_level_builder/plugin.cfg", "res://addons/dialogic/plugin.cfg", "res://addons/func_godot/plugin.cfg", "res://addons/gdai-mcp-plugin-godot/plugin.cfg", "res://addons/godot_test_scene/plugin.cfg", "res://addons/resources_spreadsheet_view/plugin.cfg", "res://addons/scene_palette/plugin.cfg", "res://addons/smoothing/plugin.cfg", "res://addons/tattomoosa.vision_cone_3d/plugin.cfg", "res://addons/weapon_creator/plugin.cfg")
enabled=PackedStringArray("res://addons/bullet_script_graph_editor/plugin.cfg", "res://addons/cyclops_level_builder/plugin.cfg", "res://addons/dialogic/plugin.cfg", "res://addons/func_godot/plugin.cfg", "res://addons/gdai-mcp-plugin-godot/plugin.cfg", "res://addons/godot_test_scene/plugin.cfg", "res://addons/resources_spreadsheet_view/plugin.cfg", "res://addons/scene_palette/plugin.cfg", "res://addons/smoothing/plugin.cfg", "res://addons/tattomoosa.vision_cone_3d/plugin.cfg", "res://addons/weapon_creator/plugin.cfg")
[func_godot]