Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Handles signal listing, connecting, and disconnecting on scene nodes.
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
func list_signals(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
|
||||
|
||||
var _resolved := McpNodeValidator.resolve_or_error(path, "path")
|
||||
if _resolved.has("error"):
|
||||
return _resolved
|
||||
var node: Node = _resolved.node
|
||||
var scene_root: Node = _resolved.scene_root
|
||||
|
||||
## Default: hide editor-internal connections (SceneTreeEditor observers
|
||||
## live on every scene node and would otherwise dominate the response).
|
||||
## Pass include_editor=true to see them. See #213.
|
||||
var include_editor: bool = params.get("include_editor", false)
|
||||
|
||||
var signals: Array[Dictionary] = []
|
||||
for sig in node.get_signal_list():
|
||||
var args: Array[Dictionary] = []
|
||||
for arg in sig.get("args", []):
|
||||
args.append({"name": arg.get("name", ""), "type": type_string(arg.get("type", 0))})
|
||||
signals.append({
|
||||
"name": sig.get("name", ""),
|
||||
"args": args,
|
||||
})
|
||||
|
||||
var connections: Array[Dictionary] = []
|
||||
var editor_connection_count := 0
|
||||
for sig in signals:
|
||||
for conn in node.get_signal_connection_list(sig.name):
|
||||
var callable: Callable = conn.get("callable", Callable())
|
||||
var target := callable.get_object()
|
||||
if target == null:
|
||||
continue # skip connections to freed objects
|
||||
if not include_editor and _is_editor_internal_target(target, scene_root):
|
||||
editor_connection_count += 1
|
||||
continue
|
||||
connections.append({
|
||||
"signal": sig.name,
|
||||
"target": _format_target_path(target, scene_root),
|
||||
"method": callable.get_method(),
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": McpScenePath.from_node(node, scene_root),
|
||||
"signals": signals,
|
||||
"signal_count": signals.size(),
|
||||
"connections": connections,
|
||||
"connection_count": connections.size(),
|
||||
"editor_connection_count": editor_connection_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## A target is "editor-internal" when it's a Node sitting outside the edited
|
||||
## scene tree AND not anywhere under a declared autoload — typical case is
|
||||
## the SceneTreeEditor dock listening for visibility/script/state changes on
|
||||
## every scene node. Connections to autoloads (declared under ``autoload/*``
|
||||
## in ProjectSettings) are user-authored even though they live under
|
||||
## ``/root/<Name>`` rather than under the edited scene root, so the autoload
|
||||
## root *and* any descendant of it stay visible. Non-Node targets
|
||||
## (anonymous Callables, RefCounted listeners etc.) also stay visible — we
|
||||
## can't reliably classify them.
|
||||
func _is_editor_internal_target(target: Object, scene_root: Node) -> bool:
|
||||
if not (target is Node):
|
||||
return false
|
||||
var node_target: Node = target
|
||||
if node_target == scene_root:
|
||||
return false
|
||||
if scene_root.is_ancestor_of(node_target):
|
||||
return false
|
||||
if _is_under_autoload(node_target):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## True if `node` is a declared autoload root or sits anywhere under one.
|
||||
## When the node is in the SceneTree we read its absolute path
|
||||
## (``/root/<Name>/...``) and check the first segment after ``/root/``;
|
||||
## this covers connections to deep descendants of editor-instanced
|
||||
## autoloads (e.g. ``/root/MyAutoload/Foo/Bar``). When the node isn't in
|
||||
## the tree (test fixtures often construct nodes in isolation), we walk
|
||||
## the parent chain and match each ancestor's ``name`` against the
|
||||
## autoload key as a best-effort fallback.
|
||||
static func _is_under_autoload(node: Node) -> bool:
|
||||
if node.is_inside_tree():
|
||||
var path := str(node.get_path())
|
||||
if not path.begins_with("/root/"):
|
||||
return false
|
||||
var first_segment := path.substr(6).split("/", true, 1)[0]
|
||||
return ProjectSettings.has_setting("autoload/" + first_segment)
|
||||
var cursor: Node = node
|
||||
while cursor != null:
|
||||
if ProjectSettings.has_setting("autoload/" + str(cursor.name)):
|
||||
return true
|
||||
cursor = cursor.get_parent()
|
||||
return false
|
||||
|
||||
|
||||
## Serialize a connection's target path. Descendants of (or equal to) the
|
||||
## edited scene root render as the usual scene-relative form
|
||||
## (``/Main/Camera3D``). Non-descendants — autoload subtrees in particular
|
||||
## — render as their canonical absolute SceneTree path
|
||||
## (``/root/MyAutoload/Child``) instead of a scene-relative path full of
|
||||
## ``..`` segments, which agents can't navigate back to. Non-Node targets
|
||||
## (anonymous Callables, etc.) fall back to their string representation.
|
||||
static func _format_target_path(target: Object, scene_root: Node) -> String:
|
||||
if not (target is Node):
|
||||
return str(target)
|
||||
var node_target: Node = target
|
||||
if node_target == scene_root or scene_root.is_ancestor_of(node_target):
|
||||
return McpScenePath.from_node(node_target, scene_root)
|
||||
if node_target.is_inside_tree():
|
||||
return str(node_target.get_path())
|
||||
return McpScenePath.from_node(node_target, scene_root)
|
||||
|
||||
|
||||
func connect_signal(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_signal_params(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
|
||||
var source: Node = resolved.source
|
||||
var target: Node = resolved.target
|
||||
var signal_name: String = resolved.signal_name
|
||||
var method: String = resolved.method
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
if not source.has_signal(signal_name):
|
||||
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Signal '%s' not found on %s" % [signal_name, params.path])
|
||||
|
||||
if not target.has_method(method):
|
||||
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Method '%s' not found on %s" % [method, params.target])
|
||||
|
||||
var callable := Callable(target, method)
|
||||
if source.is_connected(signal_name, callable):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
||||
|
||||
|
||||
func disconnect_signal(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_signal_params(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
|
||||
var source: Node = resolved.source
|
||||
var target: Node = resolved.target
|
||||
var signal_name: String = resolved.signal_name
|
||||
var method: String = resolved.method
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var callable := Callable(target, method)
|
||||
if not source.is_connected(signal_name, callable):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
||||
|
||||
|
||||
func _resolve_signal_params(params: Dictionary) -> Dictionary:
|
||||
for key in ["path", "signal", "target", "method"]:
|
||||
## Type-check before calling .is_empty(): a non-string value (e.g. an
|
||||
## int or dict) has no is_empty() and would crash the handler, which
|
||||
## the dispatcher only reports as an opaque "malformed result" (#210).
|
||||
var value = params.get(key, "")
|
||||
var type_err = McpParamValidators.require_string(key, value)
|
||||
if type_err != null:
|
||||
return type_err
|
||||
if value.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key)
|
||||
|
||||
var _scene_check := McpNodeValidator.require_scene_or_error()
|
||||
if _scene_check.has("error"):
|
||||
return _scene_check
|
||||
var scene_root: Node = _scene_check.scene_root
|
||||
|
||||
var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source")
|
||||
if source_result.has("error"):
|
||||
return source_result
|
||||
var source: Node = source_result.node
|
||||
|
||||
var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target")
|
||||
if target_result.has("error"):
|
||||
return target_result
|
||||
var target: Node = target_result.node
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"target": target,
|
||||
"signal_name": params.signal,
|
||||
"method": params.method,
|
||||
"scene_root": scene_root,
|
||||
}
|
||||
|
||||
|
||||
## Resolve a path to a Node, with three distinct outcomes:
|
||||
## 1. Found in the edited scene tree → returns {node}
|
||||
## 2. Declared as an autoload AND instantiated at edit time → returns {node}
|
||||
## 3. Declared as an autoload but NOT instantiated at edit time → returns
|
||||
## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a
|
||||
## silent "not found" hides the real reason the connection can't be made.
|
||||
## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS.
|
||||
func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary:
|
||||
var node := McpScenePath.resolve(path, scene_root)
|
||||
if node != null:
|
||||
return {"node": node}
|
||||
|
||||
var name := path.trim_prefix("/")
|
||||
if ProjectSettings.has_setting("autoload/" + name):
|
||||
# Autoload is declared — see if the editor has it instanced.
|
||||
var tree := Engine.get_main_loop()
|
||||
if tree is SceneTree:
|
||||
var live := (tree as SceneTree).root.get_node_or_null(name)
|
||||
if live != null:
|
||||
return {"node": live}
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||
"%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] +
|
||||
"Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " +
|
||||
"Connect it from a script attached to the scene using @onready + connect(), " +
|
||||
"or enable editor-instancing for this autoload in Project Settings > Autoload.")
|
||||
|
||||
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
|
||||
"%s node not found: %s (not in scene tree or autoloads)" % [role, path])
|
||||
|
||||
|
||||
func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary:
|
||||
return {
|
||||
"source": McpScenePath.from_node(source, scene_root),
|
||||
"signal": signal_name,
|
||||
"target": McpScenePath.from_node(target, scene_root),
|
||||
"method": method,
|
||||
"undoable": true,
|
||||
}
|
||||
Reference in New Issue
Block a user