diff --git a/Resources/BossPhases/Rumia_Boss_Script_3D.tres b/Resources/BossPhases/Rumia_Boss_Script_3D.tres index a8fba479..ca4ab464 100644 --- a/Resources/BossPhases/Rumia_Boss_Script_3D.tres +++ b/Resources/BossPhases/Rumia_Boss_Script_3D.tres @@ -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) +} diff --git a/Resources/BulletScripts/Danmaku_Room_1.tres b/Resources/BulletScripts/Danmaku_Room_1.tres index 1568031a..387fd563 100644 --- a/Resources/BulletScripts/Danmaku_Room_1.tres +++ b/Resources/BulletScripts/Danmaku_Room_1.tres @@ -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) +} diff --git a/Scripts/Resources/BossPhase.cs b/Scripts/Resources/BossPhase.cs index 15b4f013..6f363566 100644 --- a/Scripts/Resources/BossPhase.cs +++ b/Scripts/Resources/BossPhase.cs @@ -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 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); } } } \ No newline at end of file diff --git a/Scripts/Resources/BulletScripts/BulletScript3D.cs b/Scripts/Resources/BulletScripts/BulletScript3D.cs index c94fdc36..c2b43118 100644 --- a/Scripts/Resources/BulletScripts/BulletScript3D.cs +++ b/Scripts/Resources/BulletScripts/BulletScript3D.cs @@ -9,22 +9,27 @@ namespace Cirno.Scripts.Resources.BulletScripts; public partial class BulletScript3D : Resource { [Export] - public Array Patterns { get; private set; } - + public string Title { get; set; } = string.Empty; + [Export] + public string Description { get; set; } = string.Empty; + + [Export] + public Array Patterns { get; set; } = new Array(); + public BulletScriptMachine Make(Node parent) { return new BulletScriptMachine(parent, Patterns); } - + public class BulletScriptMachine(Node parent, Array patterns) { private int _currentPatternIndex = 0; //private double _patternTimer; - + private AttackPattern CurrentPattern => patterns[_currentPatternIndex]; private IPatternMachine _currentPatternMachine; - + public void Start() { if (patterns.Count == 0) return; @@ -33,11 +38,11 @@ public partial class BulletScript3D : Resource _currentPatternMachine = CurrentPattern.MakeMachine(parent); _currentPatternMachine.Start(); } - + public void UpdatePhase(double delta) { //_patternTimer += delta; - + _currentPatternMachine.UpdatePattern(delta); //CurrentPattern.UpdatePattern(delta); diff --git a/addons/bullet_script_graph_editor/inspector_plugin.gd b/addons/bullet_script_graph_editor/inspector_plugin.gd new file mode 100644 index 00000000..406e1d39 --- /dev/null +++ b/addons/bullet_script_graph_editor/inspector_plugin.gd @@ -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 diff --git a/addons/bullet_script_graph_editor/inspector_plugin.gd.uid b/addons/bullet_script_graph_editor/inspector_plugin.gd.uid new file mode 100644 index 00000000..33f0d735 --- /dev/null +++ b/addons/bullet_script_graph_editor/inspector_plugin.gd.uid @@ -0,0 +1 @@ +uid://bchuuu8pwh3k diff --git a/addons/bullet_script_graph_editor/model/graph_adapter.gd b/addons/bullet_script_graph_editor/model/graph_adapter.gd new file mode 100644 index 00000000..066f29fb --- /dev/null +++ b/addons/bullet_script_graph_editor/model/graph_adapter.gd @@ -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() diff --git a/addons/bullet_script_graph_editor/model/graph_adapter.gd.uid b/addons/bullet_script_graph_editor/model/graph_adapter.gd.uid new file mode 100644 index 00000000..3a7273fc --- /dev/null +++ b/addons/bullet_script_graph_editor/model/graph_adapter.gd.uid @@ -0,0 +1 @@ +uid://dkea0407rg2ar diff --git a/addons/bullet_script_graph_editor/plugin.cfg b/addons/bullet_script_graph_editor/plugin.cfg new file mode 100644 index 00000000..61493490 --- /dev/null +++ b/addons/bullet_script_graph_editor/plugin.cfg @@ -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" diff --git a/addons/bullet_script_graph_editor/plugin.gd b/addons/bullet_script_graph_editor/plugin.gd new file mode 100644 index 00000000..9b56dbb5 --- /dev/null +++ b/addons/bullet_script_graph_editor/plugin.gd @@ -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) diff --git a/addons/bullet_script_graph_editor/plugin.gd.uid b/addons/bullet_script_graph_editor/plugin.gd.uid new file mode 100644 index 00000000..fbe491c4 --- /dev/null +++ b/addons/bullet_script_graph_editor/plugin.gd.uid @@ -0,0 +1 @@ +uid://q2ctk5gbyvve diff --git a/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd b/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd new file mode 100644 index 00000000..534567d0 --- /dev/null +++ b/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd @@ -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 "" + +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 diff --git a/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd.uid b/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd.uid new file mode 100644 index 00000000..76b3944d --- /dev/null +++ b/addons/bullet_script_graph_editor/ui/graph_editor_dock.gd.uid @@ -0,0 +1 @@ +uid://duc4xqfaiyjq5 diff --git a/addons/bullet_script_graph_editor/ui/graph_editor_dock.tscn b/addons/bullet_script_graph_editor/ui/graph_editor_dock.tscn new file mode 100644 index 00000000..96185d89 --- /dev/null +++ b/addons/bullet_script_graph_editor/ui/graph_editor_dock.tscn @@ -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") diff --git a/docs/bullet-script-graph-editor-plan.md b/docs/bullet-script-graph-editor-plan.md new file mode 100644 index 00000000..7419ab6a --- /dev/null +++ b/docs/bullet-script-graph-editor-plan.md @@ -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`): `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. diff --git a/project.godot b/project.godot index 091f6bac..a99db19e 100644 --- a/project.godot +++ b/project.godot @@ -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]