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