112 lines
5.5 KiB
GDScript
112 lines
5.5 KiB
GDScript
@tool
|
||
extends RefCounted
|
||
## Validates a {source, signal, target, method} quartet against a scene tree whose
|
||
## root the caller resolves and passes in, returning the connect-ready pair
|
||
## (source/target/callable/echoed paths+names) or a {code, error} dict. This is the
|
||
## structural skeleton shared by the editor signal handlers (Mode A) and the runtime
|
||
## autoload (Mode B): both resolve a node path against a root, then guard
|
||
## has_signal / has_method identically — the ONLY thing that differs is where the
|
||
## root comes from (the editor's edited-scene root vs the live SceneTree root), which
|
||
## the caller resolves itself and passes in as a Node.
|
||
##
|
||
## Deliberately bare: it returns the leanest validated pair (the runtime's historic
|
||
## shape). The editor wraps it and layers its friendly hint enrichment (instanced-
|
||
## scene hint, script-source method-walk) on top of a has_signal/has_method failure
|
||
## — that enrichment is editor-only and stays in the editor file (see the 007 §D
|
||
## rule-of-three caveat); forcing it into this shared shape would either taint this
|
||
## clean child or bloat the runtime path with dead editor logic.
|
||
##
|
||
## Export-clean by contract: the Mode-B runtime autoload preloads this file, and
|
||
## GDScript resolves identifiers at parse time (before any is_editor_hint() guard),
|
||
## so naming ANY editor-only class — or preloading a script that does — would
|
||
## parse-fail the autoload in an export template (godotengine/godot#91713, unfixed
|
||
## 4.2–4.6). The entire identifier set here is core (Node/Object/Callable/Dictionary)
|
||
## and it preloads nothing. Keep it that way: a grep for "Editor" must return zero
|
||
## non-comment hits.
|
||
|
||
|
||
## Resolves a single node path against the supplied scene-tree [param root].
|
||
##
|
||
## The skeleton both servers shared: a null root yields null (no scene / no live
|
||
## tree); an empty or "." path resolves to the root itself (so a caller wanting a
|
||
## top-level signal need not type the full path); anything else goes through
|
||
## get_node_or_null. [param root] is the editor's edited-scene root or the runtime's
|
||
## live SceneTree root — each caller resolves its own root and passes the Node, never
|
||
## a root-resolver Callable: a bare static-method reference used as a Callable
|
||
## mis-binds to a NIL self on Godot 4.2 and silently aborts, so this shared static
|
||
## helper takes the resolved value, not a Callable to call. The return is Variant
|
||
## because a missing node is a valid null result the caller branches on.
|
||
static func resolve_node(path: String, root: Node) -> Variant:
|
||
if root == null:
|
||
return null
|
||
if path.is_empty() or path == ".":
|
||
return root
|
||
return root.get_node_or_null(path)
|
||
|
||
|
||
## Returns the [{name, args:[{name, type}]}] signal list of a node — the bare
|
||
## runtime shape (no connection walk; the editor's list adds connections itself).
|
||
static func list_signals_of(node: Object) -> Array:
|
||
var out: Array = []
|
||
for sig in node.get_signal_list():
|
||
var args: Array = []
|
||
for arg in sig.get("args", []):
|
||
args.append({
|
||
"name": str(arg.get("name", "")),
|
||
"type": int(arg.get("type", 0)),
|
||
})
|
||
out.append({
|
||
"name": str(sig.get("name", "")),
|
||
"args": args,
|
||
})
|
||
return out
|
||
|
||
|
||
## Validates the {source, signal, target, method} quartet and returns either a
|
||
## {code, error} dict (INVALID_PARAMS / NOT_FOUND) or the connect-ready pair:
|
||
## {source, target, source_path, target_path, signal_name, method_name, callable}.
|
||
##
|
||
## params accepts node_path OR source_path for the source. The caller is expected to
|
||
## have already normalized the paths it cares about (the editor normalizes /root/…
|
||
## paths before calling); this resolver treats the paths as given. [param root] is the
|
||
## scene-tree root both paths resolve against (see [method resolve_node]). The
|
||
## has_signal / has_method guards return the leanest message — the editor layers its
|
||
## richer hints on top of an INVALID_PARAMS failure by re-resolving the offending node
|
||
## itself.
|
||
static func resolve_pair(params: Variant, root: Node) -> Dictionary:
|
||
if typeof(params) != TYPE_DICTIONARY:
|
||
return {"code": "INVALID_PARAMS", "error": "params must be an object"}
|
||
var source_path := str(params.get("node_path", params.get("source_path", "")))
|
||
var signal_name := str(params.get("signal_name", ""))
|
||
var target_path := str(params.get("target_path", ""))
|
||
var method_name := str(params.get("method_name", ""))
|
||
if source_path.is_empty() or signal_name.is_empty() \
|
||
or target_path.is_empty() or method_name.is_empty():
|
||
return {"code": "INVALID_PARAMS",
|
||
"error": "node_path, signal_name, target_path, method_name are all required"}
|
||
var source = resolve_node(source_path, root)
|
||
if source == null:
|
||
return {"code": "NOT_FOUND", "error": "source node not found: %s" % source_path}
|
||
var target = resolve_node(target_path, root)
|
||
if target == null:
|
||
return {"code": "NOT_FOUND", "error": "target node not found: %s" % target_path}
|
||
# Explicit bool — `source`/`target` are Variant (resolve_node return), so the
|
||
# has_signal/has_method results need a typed receiver, not := inference.
|
||
var has_sig: bool = source.has_signal(signal_name)
|
||
if not has_sig:
|
||
return {"code": "INVALID_PARAMS",
|
||
"error": "signal %s not on %s" % [signal_name, source_path]}
|
||
var has_meth: bool = target.has_method(method_name)
|
||
if not has_meth:
|
||
return {"code": "INVALID_PARAMS",
|
||
"error": "method %s not on %s" % [method_name, target_path]}
|
||
return {
|
||
"source": source,
|
||
"target": target,
|
||
"source_path": source_path,
|
||
"target_path": target_path,
|
||
"signal_name": signal_name,
|
||
"method_name": method_name,
|
||
"callable": Callable(target, method_name),
|
||
}
|