682 lines
26 KiB
GDScript
682 lines
26 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## animation.* command handlers — keyframe (add/remove) and get_keys on AnimationPlayer tracks.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Coerce = Modules.Coerce
|
|
const Untrusted = Modules.Untrusted
|
|
const Helpers = Modules.CommandHelpers
|
|
|
|
|
|
static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:
|
|
registry.add("animation.keyframe", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_animation_keyframe(server, parameters)
|
|
, MCPToolkitCommandOptions.new())
|
|
registry.add("animation.get_keys", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_animation_get_keys(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_read_only())
|
|
registry.add("animationtree.edit", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_animationtree_edit(server, parameters)
|
|
, MCPToolkitCommandOptions.new())
|
|
registry.add("animationtree.list", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_animationtree_list(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_read_only())
|
|
|
|
|
|
# -- Helpers ------------------------------------------------------------------
|
|
|
|
|
|
static func _resolve_scene_node(node_path: String) -> Variant:
|
|
return Helpers.resolve_scene_node(node_path)
|
|
|
|
|
|
static func _resolve_animation(
|
|
player_path: String, animation_name: String,
|
|
) -> Dictionary:
|
|
var root := Helpers.get_edited_root()
|
|
if root == null:
|
|
return {"code": "NO_SCENE", "error": "no edited scene"}
|
|
if player_path.is_empty():
|
|
return {"code": "INVALID_PARAMS", "error": "missing player_path"}
|
|
var node = _resolve_scene_node(player_path)
|
|
if node == null:
|
|
return {"code": "NOT_FOUND",
|
|
"error": "no node at player_path %s" % player_path}
|
|
if not (node is AnimationPlayer):
|
|
return {"code": "INVALID_CLASS",
|
|
"error": "node at %s is not an AnimationPlayer (got %s)" % [
|
|
player_path, node.get_class()]}
|
|
var player := node as AnimationPlayer
|
|
if animation_name.is_empty():
|
|
return {"code": "INVALID_PARAMS", "error": "missing animation_name"}
|
|
if not player.has_animation(animation_name):
|
|
# Auto-create: if "library/anim" format and the library exists, create the Animation.
|
|
var slash_pos := animation_name.find("/")
|
|
if slash_pos != -1:
|
|
var lib_name := animation_name.substr(0, slash_pos)
|
|
var anim_key := animation_name.substr(slash_pos + 1)
|
|
if player.has_animation_library(lib_name):
|
|
var lib := player.get_animation_library(lib_name)
|
|
var new_anim := Animation.new()
|
|
lib.add_animation(anim_key, new_anim)
|
|
return {"player": player, "anim": new_anim, "auto_created": true}
|
|
var available: Array = []
|
|
for name_entry in player.get_animation_list():
|
|
available.append(str(name_entry))
|
|
if available.size() >= 10:
|
|
available.append("…")
|
|
break
|
|
return {"code": "NOT_FOUND",
|
|
"error": "no animation '%s' on player %s; available: %s" % [
|
|
animation_name, player_path, ", ".join(available)]}
|
|
return {"player": player, "anim": player.get_animation(animation_name)}
|
|
|
|
|
|
static func _track_type_name(track_type: int) -> String:
|
|
match track_type:
|
|
Animation.TYPE_VALUE: return "value"
|
|
Animation.TYPE_POSITION_3D: return "position_3d"
|
|
Animation.TYPE_ROTATION_3D: return "rotation_3d"
|
|
Animation.TYPE_SCALE_3D: return "scale_3d"
|
|
Animation.TYPE_BLEND_SHAPE: return "blend_shape"
|
|
Animation.TYPE_METHOD: return "method"
|
|
Animation.TYPE_BEZIER: return "bezier"
|
|
Animation.TYPE_AUDIO: return "audio"
|
|
Animation.TYPE_ANIMATION: return "animation"
|
|
_: return "unknown(%d)" % track_type
|
|
|
|
|
|
# -- Commands -----------------------------------------------------------------
|
|
|
|
|
|
static func _cmd_animation_keyframe(
|
|
server: Node, parameters: Dictionary,
|
|
) -> Dictionary:
|
|
var action := str(parameters.get("action", ""))
|
|
if not (action in ["add", "remove"]):
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"action must be 'add' or 'remove' (got '%s')" % action)
|
|
var player_path := str(parameters.get("player_path", ""))
|
|
player_path = Helpers.normalize_editor_path(player_path)
|
|
var animation_name := str(parameters.get("animation_name", ""))
|
|
var track_path := str(parameters.get("track_path", ""))
|
|
var time_raw = parameters.get("time", -1.0)
|
|
var time := float(time_raw) \
|
|
if (typeof(time_raw) == TYPE_FLOAT or typeof(time_raw) == TYPE_INT) else -1.0
|
|
if time < 0.0:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"time must be >= 0 (got %f)" % time)
|
|
if track_path.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing track_path")
|
|
if action == "add":
|
|
if not track_path.contains(":"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"track_path must include a property (e.g. 'Sprite2D:position')")
|
|
var track_type_param := str(parameters.get("track_type", ""))
|
|
if not track_type_param.is_empty() and track_type_param != "value":
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"track_type='%s' not supported (only 'value' / default)" % track_type_param)
|
|
if not parameters.has("value"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"missing value (required for action='add')")
|
|
var raw_value = parameters.get("value", null)
|
|
var resolved := _resolve_animation(player_path, animation_name)
|
|
if resolved.has("error"):
|
|
return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"]))
|
|
var animation: Animation = resolved["anim"]
|
|
var player: AnimationPlayer = resolved["player"]
|
|
var missing := Coerce.check_resource_paths(raw_value)
|
|
if missing != "":
|
|
return MCPToolkitError.fail("LOAD_FAILED",
|
|
"failed to load resource at %s" % missing)
|
|
var coerced = Coerce.coerce_value(raw_value)
|
|
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"]))
|
|
var track_index := -1
|
|
var track_path_node_path := NodePath(track_path)
|
|
for index in range(animation.get_track_count()):
|
|
if animation.track_get_path(index) == track_path_node_path:
|
|
track_index = index
|
|
break
|
|
if track_index == -1:
|
|
track_index = animation.add_track(Animation.TYPE_VALUE)
|
|
animation.track_set_path(track_index, track_path_node_path)
|
|
var existing_index := animation.track_find_key(
|
|
track_index, time, Animation.FIND_MODE_EXACT)
|
|
if existing_index != -1:
|
|
return MCPToolkitSuccess.ok({
|
|
"status": "returned",
|
|
"player_path": player_path,
|
|
"animation_name": animation_name,
|
|
"track_path": track_path,
|
|
"track_idx": track_index,
|
|
"time": time,
|
|
"key_idx": existing_index,
|
|
"value": Coerce.serialize_value(
|
|
animation.track_get_key_value(track_index, existing_index)),
|
|
})
|
|
animation.track_insert_key(track_index, time, coerced)
|
|
MCPToolkitUndoRedoAction.begin("animation.keyframe add %s @ %s" % [track_path, time], player) \
|
|
.do_method(animation.track_insert_key.bind(track_index, time, coerced)) \
|
|
.undo_method(server.undo_helpers._animation_remove_key_at.bind(animation, track_index, time)) \
|
|
.undo_reference(animation) \
|
|
.commit_recorded()
|
|
var new_index := animation.track_find_key(
|
|
track_index, time, Animation.FIND_MODE_EXACT)
|
|
# Persist external library to disk so runtime can find the animation.
|
|
var _slash := animation_name.find("/")
|
|
if _slash != -1:
|
|
var _lib_name := animation_name.substr(0, _slash)
|
|
var _lib := player.get_animation_library(_lib_name)
|
|
if _lib != null and not _lib.resource_path.is_empty():
|
|
ResourceSaver.save(_lib)
|
|
return MCPToolkitSuccess.ok({
|
|
"status": "created",
|
|
"player_path": player_path,
|
|
"animation_name": animation_name,
|
|
"track_path": track_path,
|
|
"track_idx": track_index,
|
|
"time": time,
|
|
"key_idx": new_index,
|
|
"value": Coerce.serialize_value(coerced),
|
|
})
|
|
else:
|
|
var resolved := _resolve_animation(player_path, animation_name)
|
|
if resolved.has("error"):
|
|
return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"]))
|
|
var animation: Animation = resolved["anim"]
|
|
var track_index := -1
|
|
var track_path_node_path := NodePath(track_path)
|
|
for index in range(animation.get_track_count()):
|
|
if animation.track_get_path(index) == track_path_node_path:
|
|
track_index = index
|
|
break
|
|
if track_index == -1:
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no track '%s' on animation '%s'" % [track_path, animation_name])
|
|
var key_index := animation.track_find_key(
|
|
track_index, time, Animation.FIND_MODE_EXACT)
|
|
if key_index == -1:
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no key at time=%f on track '%s'" % [time, track_path])
|
|
var captured_value = animation.track_get_key_value(track_index, key_index)
|
|
var serialised_value = Coerce.serialize_value(captured_value)
|
|
server.undo_helpers._animation_remove_key_at(animation, track_index, time)
|
|
MCPToolkitUndoRedoAction.begin("animation.keyframe remove %s @ %s" % [track_path, time], resolved["player"]) \
|
|
.do_method(server.undo_helpers._animation_remove_key_at.bind(animation, track_index, time)) \
|
|
.undo_method(server.undo_helpers._animation_insert_key_silent.bind(animation, track_index, time, captured_value)) \
|
|
.undo_reference(animation) \
|
|
.commit_recorded()
|
|
return MCPToolkitSuccess.ok({
|
|
"player_path": player_path,
|
|
"animation_name": animation_name,
|
|
"track_path": track_path,
|
|
"time": time,
|
|
"removed_value": serialised_value,
|
|
})
|
|
|
|
|
|
static func _cmd_animation_get_keys(parameters: Dictionary) -> Dictionary:
|
|
var player_path := str(parameters.get("player_path", ""))
|
|
player_path = Helpers.normalize_editor_path(player_path)
|
|
var animation_name := str(parameters.get("animation_name", ""))
|
|
var track_path := str(parameters.get("track_path", ""))
|
|
if track_path.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing track_path")
|
|
var resolved := _resolve_animation(player_path, animation_name)
|
|
if resolved.has("error"):
|
|
return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"]))
|
|
var animation: Animation = resolved["anim"]
|
|
var track_index := -1
|
|
var track_path_node_path := NodePath(track_path)
|
|
for index in range(animation.get_track_count()):
|
|
if animation.track_get_path(index) == track_path_node_path:
|
|
track_index = index
|
|
break
|
|
if track_index == -1:
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no track '%s' on animation '%s'" % [track_path, animation_name])
|
|
var keys: Array = []
|
|
for key_index in range(animation.track_get_key_count(track_index)):
|
|
keys.append({
|
|
"time": animation.track_get_key_time(track_index, key_index),
|
|
"value": Coerce.serialize_value(
|
|
animation.track_get_key_value(track_index, key_index)),
|
|
"transition": animation.track_get_key_transition(track_index, key_index),
|
|
})
|
|
return MCPToolkitSuccess.ok({
|
|
"player_path": player_path,
|
|
"animation_name": animation_name,
|
|
"track_path": track_path,
|
|
"track_idx": track_index,
|
|
"track_type": _track_type_name(animation.track_get_type(track_index)),
|
|
"length": animation.length,
|
|
"keys": Untrusted.wrap(
|
|
"animation", "%s/%s" % [player_path, animation_name],
|
|
JSON.stringify(keys)),
|
|
})
|
|
|
|
|
|
# -- AnimationTree editing ----------------------------------------------------
|
|
|
|
|
|
static func _resolve_tree(node_path: String) -> Variant:
|
|
var root := Helpers.get_edited_root()
|
|
if root == null:
|
|
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
|
|
if node_path.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path")
|
|
var node = _resolve_scene_node(node_path)
|
|
if node == null:
|
|
return MCPToolkitError.fail("NOT_FOUND", "no node at %s" % node_path)
|
|
if not (node is AnimationTree):
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"node at %s is not an AnimationTree (got %s)" % [node_path, node.get_class()])
|
|
return node
|
|
|
|
|
|
static func _resolve_state_machine(tree: AnimationTree) -> Variant:
|
|
var tree_root = tree.tree_root
|
|
if tree_root == null:
|
|
return MCPToolkitError.fail("INVALID_STATE", "AnimationTree has no tree_root set")
|
|
if not (tree_root is AnimationNodeStateMachine):
|
|
return MCPToolkitError.fail("INVALID_STATE",
|
|
"tree_root is %s, not AnimationNodeStateMachine" % tree_root.get_class())
|
|
return tree_root
|
|
|
|
|
|
static func _switch_mode_from_string(mode_str: String) -> int:
|
|
match mode_str:
|
|
"immediate": return 0
|
|
"sync": return 1
|
|
"at_end": return 2
|
|
_: return 0
|
|
|
|
|
|
static func _switch_mode_to_string(mode_int: int) -> String:
|
|
match mode_int:
|
|
0: return "immediate"
|
|
1: return "sync"
|
|
2: return "at_end"
|
|
_: return "immediate"
|
|
|
|
|
|
static func _advance_mode_from_string(mode_str: String) -> int:
|
|
match mode_str:
|
|
"disabled": return 0
|
|
"enabled": return 1
|
|
"auto": return 2
|
|
_: return 1
|
|
|
|
|
|
static func _advance_mode_to_string(mode_int: int) -> String:
|
|
match mode_int:
|
|
0: return "disabled"
|
|
1: return "enabled"
|
|
2: return "auto"
|
|
_: return "enabled"
|
|
|
|
|
|
static func _sm_summary(sm: AnimationNodeStateMachine) -> Dictionary:
|
|
# get_node_list() is a 4.5+ script API (engine: bound to get_node_list_as_typed_array
|
|
# in 4.5; absent on 4.2-4.4 → calling it errors and corrupts the return). Transitions
|
|
# are countable on every version (get_transition_count is 4.2+).
|
|
var summary := {"transitions_count": sm.get_transition_count()}
|
|
if sm.has_method(&"get_node_list"):
|
|
summary["nodes_count"] = sm.get_node_list().size()
|
|
else:
|
|
# On 4.2-4.4 node enumeration is unavailable, so omit nodes_count rather
|
|
# than report a fabricated 0 that would read as a failed add.
|
|
summary["note"] = "node enumeration unavailable on Godot 4.2-4.4 (get_node_list is 4.5+); nodes_count is omitted — verify the node via animationtree_list or the AnimationTree panel"
|
|
return summary
|
|
|
|
|
|
static func _cmd_animationtree_list(parameters: Dictionary) -> Dictionary:
|
|
var node_path := str(parameters.get("node_path", ""))
|
|
node_path = Helpers.normalize_editor_path(node_path)
|
|
var resolved = _resolve_tree(node_path)
|
|
if resolved is Dictionary:
|
|
return resolved
|
|
var tree: AnimationTree = resolved
|
|
return _at_list(tree)
|
|
|
|
|
|
static func _cmd_animationtree_edit(
|
|
server: Node, parameters: Dictionary,
|
|
) -> Dictionary:
|
|
var node_path := str(parameters.get("node_path", ""))
|
|
node_path = Helpers.normalize_editor_path(node_path)
|
|
var action := str(parameters.get("action", ""))
|
|
if action.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing action")
|
|
|
|
var resolved = _resolve_tree(node_path)
|
|
if resolved is Dictionary:
|
|
return resolved
|
|
var tree: AnimationTree = resolved
|
|
|
|
match action:
|
|
"set_root":
|
|
return _at_set_root(server, tree, node_path, parameters)
|
|
"add_node":
|
|
return _at_add_node(server, tree, node_path, parameters)
|
|
"remove_node":
|
|
return _at_remove_node(server, tree, node_path, parameters)
|
|
"add_transition":
|
|
return _at_add_transition(server, tree, node_path, parameters)
|
|
"remove_transition":
|
|
return _at_remove_transition(server, tree, node_path, parameters)
|
|
"set_property":
|
|
return _at_set_property(server, tree, node_path, parameters)
|
|
_:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"unknown action '%s'; expected set_root|add_node|remove_node|add_transition|remove_transition|set_property" % action)
|
|
|
|
|
|
static func _at_set_root(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var root_type := str(params.get("root_type", "AnimationNodeStateMachine"))
|
|
var new_root: AnimationNode = null
|
|
match root_type:
|
|
"AnimationNodeStateMachine":
|
|
new_root = AnimationNodeStateMachine.new()
|
|
"AnimationNodeBlendTree":
|
|
new_root = AnimationNodeBlendTree.new()
|
|
_:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"root_type must be 'AnimationNodeStateMachine' or 'AnimationNodeBlendTree' (got '%s')" % root_type)
|
|
|
|
var old_root = tree.tree_root
|
|
tree.tree_root = new_root
|
|
var action := MCPToolkitUndoRedoAction.begin("animationtree.edit set_root %s" % node_path, tree) \
|
|
.do_property(tree, &"tree_root", new_root) \
|
|
.undo_property(tree, &"tree_root", old_root) \
|
|
.do_reference(new_root)
|
|
if old_root != null:
|
|
action.undo_reference(old_root)
|
|
action.commit_recorded()
|
|
|
|
return MCPToolkitSuccess.ok({"root_type": root_type})
|
|
|
|
|
|
static func _at_add_node(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var sm_result = _resolve_state_machine(tree)
|
|
if sm_result is Dictionary:
|
|
return sm_result
|
|
var sm: AnimationNodeStateMachine = sm_result
|
|
|
|
var node_name := str(params.get("node_name", ""))
|
|
if node_name.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_name")
|
|
var node_type := str(params.get("node_type", "AnimationNodeAnimation"))
|
|
|
|
# Idempotent: if the node already exists, return it.
|
|
if sm.has_node(StringName(node_name)):
|
|
var existing = sm.get_node(StringName(node_name))
|
|
var summary := _sm_summary(sm)
|
|
summary["status"] = "returned"
|
|
summary["node_name"] = node_name
|
|
summary["node_type"] = existing.get_class()
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
# Instantiate the AnimationNode by class name.
|
|
if not ClassDB.class_exists(node_type):
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"class '%s' does not exist" % node_type)
|
|
if not ClassDB.is_parent_class(node_type, "AnimationNode"):
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"'%s' is not an AnimationNode subclass" % node_type)
|
|
var new_node: AnimationNode = ClassDB.instantiate(node_type) as AnimationNode
|
|
if new_node == null:
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"could not instantiate '%s'" % node_type)
|
|
|
|
# For AnimationNodeAnimation, set the animation property.
|
|
var anim_name := str(params.get("animation_name", ""))
|
|
if not anim_name.is_empty() and new_node is AnimationNodeAnimation:
|
|
(new_node as AnimationNodeAnimation).animation = StringName(anim_name)
|
|
|
|
var pos := Vector2.ZERO
|
|
var pos_dict = params.get("position", {})
|
|
if typeof(pos_dict) == TYPE_DICTIONARY:
|
|
pos = Vector2(float(pos_dict.get("x", 0)), float(pos_dict.get("y", 0)))
|
|
|
|
sm.add_node(StringName(node_name), new_node, pos)
|
|
MCPToolkitUndoRedoAction.begin("animationtree.edit add_node %s/%s" % [node_path, node_name], tree) \
|
|
.do_method(sm.add_node.bind(StringName(node_name), new_node, pos)) \
|
|
.undo_method(sm.remove_node.bind(StringName(node_name))) \
|
|
.do_reference(new_node) \
|
|
.commit_recorded()
|
|
|
|
var summary := _sm_summary(sm)
|
|
summary["status"] = "created"
|
|
summary["node_name"] = node_name
|
|
summary["node_type"] = node_type
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
|
|
static func _at_remove_node(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var sm_result = _resolve_state_machine(tree)
|
|
if sm_result is Dictionary:
|
|
return sm_result
|
|
var sm: AnimationNodeStateMachine = sm_result
|
|
|
|
var node_name := str(params.get("node_name", ""))
|
|
if node_name.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_name")
|
|
if not sm.has_node(StringName(node_name)):
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no node '%s' in state machine" % node_name)
|
|
|
|
var old_node: AnimationNode = sm.get_node(StringName(node_name))
|
|
var old_pos: Vector2 = sm.get_node_position(StringName(node_name))
|
|
sm.remove_node(StringName(node_name))
|
|
MCPToolkitUndoRedoAction.begin("animationtree.edit remove_node %s/%s" % [node_path, node_name], tree) \
|
|
.do_method(sm.remove_node.bind(StringName(node_name))) \
|
|
.undo_method(sm.add_node.bind(StringName(node_name), old_node, old_pos)) \
|
|
.undo_reference(old_node) \
|
|
.commit_recorded()
|
|
|
|
var summary := _sm_summary(sm)
|
|
summary["node_name"] = node_name
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
|
|
static func _at_add_transition(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var sm_result = _resolve_state_machine(tree)
|
|
if sm_result is Dictionary:
|
|
return sm_result
|
|
var sm: AnimationNodeStateMachine = sm_result
|
|
|
|
var from := str(params.get("from", ""))
|
|
var to := str(params.get("to", ""))
|
|
if from.is_empty() or to.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing from or to")
|
|
if not sm.has_node(StringName(from)):
|
|
return MCPToolkitError.fail("NOT_FOUND", "no node '%s' in state machine" % from)
|
|
if not sm.has_node(StringName(to)):
|
|
return MCPToolkitError.fail("NOT_FOUND", "no node '%s' in state machine" % to)
|
|
|
|
# Check for existing identical transition (idempotent).
|
|
for i in range(sm.get_transition_count()):
|
|
if str(sm.get_transition_from(i)) == from and str(sm.get_transition_to(i)) == to:
|
|
var summary := _sm_summary(sm)
|
|
summary["status"] = "returned"
|
|
summary["from"] = from
|
|
summary["to"] = to
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
var transition := AnimationNodeStateMachineTransition.new()
|
|
|
|
var switch_mode_str := str(params.get("switch_mode", ""))
|
|
if not switch_mode_str.is_empty():
|
|
transition.switch_mode = _switch_mode_from_string(switch_mode_str)
|
|
|
|
var advance_condition_str := str(params.get("advance_condition", ""))
|
|
if not advance_condition_str.is_empty():
|
|
transition.advance_condition = StringName(advance_condition_str)
|
|
|
|
var advance_mode_str := str(params.get("advance_mode", ""))
|
|
if not advance_mode_str.is_empty():
|
|
transition.advance_mode = _advance_mode_from_string(advance_mode_str)
|
|
|
|
sm.add_transition(StringName(from), StringName(to), transition)
|
|
MCPToolkitUndoRedoAction.begin("animationtree.edit add_transition %s %s->%s" % [node_path, from, to], tree) \
|
|
.do_method(sm.add_transition.bind(StringName(from), StringName(to), transition)) \
|
|
.undo_method(server.undo_helpers._sm_remove_transition_by_endpoints.bind(sm, from, to)) \
|
|
.do_reference(transition) \
|
|
.commit_recorded()
|
|
|
|
var summary := _sm_summary(sm)
|
|
summary["status"] = "created"
|
|
summary["from"] = from
|
|
summary["to"] = to
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
|
|
static func _at_remove_transition(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var sm_result = _resolve_state_machine(tree)
|
|
if sm_result is Dictionary:
|
|
return sm_result
|
|
var sm: AnimationNodeStateMachine = sm_result
|
|
|
|
var from := str(params.get("from", ""))
|
|
var to := str(params.get("to", ""))
|
|
if from.is_empty() or to.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing from or to")
|
|
|
|
var found_idx := -1
|
|
for i in range(sm.get_transition_count()):
|
|
if str(sm.get_transition_from(i)) == from and str(sm.get_transition_to(i)) == to:
|
|
found_idx = i
|
|
break
|
|
if found_idx == -1:
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no transition from '%s' to '%s'" % [from, to])
|
|
|
|
var old_transition: AnimationNodeStateMachineTransition = sm.get_transition(found_idx)
|
|
sm.remove_transition_by_index(found_idx)
|
|
MCPToolkitUndoRedoAction.begin("animationtree.edit remove_transition %s %s->%s" % [node_path, from, to], tree) \
|
|
.do_method(sm.remove_transition_by_index.bind(found_idx)) \
|
|
.undo_method(sm.add_transition.bind(StringName(from), StringName(to), old_transition)) \
|
|
.undo_reference(old_transition) \
|
|
.commit_recorded()
|
|
|
|
var summary := _sm_summary(sm)
|
|
summary["from"] = from
|
|
summary["to"] = to
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
|
|
static func _at_set_property(
|
|
server: Node, tree: AnimationTree, node_path: String,
|
|
params: Dictionary,
|
|
) -> Dictionary:
|
|
var sm_result = _resolve_state_machine(tree)
|
|
if sm_result is Dictionary:
|
|
return sm_result
|
|
var sm: AnimationNodeStateMachine = sm_result
|
|
|
|
var target_node := str(params.get("target_node", ""))
|
|
if target_node.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing target_node")
|
|
var property := str(params.get("property", ""))
|
|
if property.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing property")
|
|
if not params.has("value"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing value")
|
|
var value = params.get("value")
|
|
|
|
if not sm.has_node(StringName(target_node)):
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no node '%s' in state machine" % target_node)
|
|
var anim_node: AnimationNode = sm.get_node(StringName(target_node))
|
|
|
|
var old_value = anim_node.get(property)
|
|
var coerced = Coerce.coerce_value(value)
|
|
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"]))
|
|
|
|
anim_node.set(property, coerced)
|
|
MCPToolkitUndoRedoAction.begin("animationtree.edit set_property %s/%s.%s" % [node_path, target_node, property], tree) \
|
|
.do_property(anim_node, StringName(property), coerced) \
|
|
.undo_property(anim_node, StringName(property), old_value) \
|
|
.commit_recorded()
|
|
|
|
var summary := _sm_summary(sm)
|
|
summary["target_node"] = target_node
|
|
summary["property"] = property
|
|
return MCPToolkitSuccess.ok(summary)
|
|
|
|
|
|
static func _at_list(tree: AnimationTree) -> Dictionary:
|
|
var tree_root = tree.tree_root
|
|
if tree_root == null:
|
|
return MCPToolkitSuccess.ok({"root_type": "none", "nodes": [], "transitions": []})
|
|
|
|
var root_type := tree_root.get_class()
|
|
if not (tree_root is AnimationNodeStateMachine):
|
|
return MCPToolkitSuccess.ok({"root_type": root_type, "nodes": [], "transitions": []})
|
|
|
|
var sm: AnimationNodeStateMachine = tree_root as AnimationNodeStateMachine
|
|
var nodes: Array = []
|
|
# get_node_list() is a 4.5+ script API (absent on 4.2-4.4 → errors); guard it so list
|
|
# stays well-formed on older versions. Node enumeration is unavailable there (nodes:[]),
|
|
# but transitions below still enumerate (get_transition_* are 4.2+).
|
|
if sm.has_method(&"get_node_list"):
|
|
for sn_name in sm.get_node_list():
|
|
var anim_node: AnimationNode = sm.get_node(sn_name)
|
|
# Robustness: skip an unreadable node rather than dereferencing null.
|
|
if anim_node == null:
|
|
continue
|
|
var pos: Vector2 = sm.get_node_position(sn_name)
|
|
var entry := {
|
|
"name": str(sn_name),
|
|
"type": anim_node.get_class(),
|
|
"position": {"x": pos.x, "y": pos.y},
|
|
}
|
|
if anim_node is AnimationNodeAnimation:
|
|
entry["animation"] = str((anim_node as AnimationNodeAnimation).animation)
|
|
nodes.append(entry)
|
|
|
|
var transitions: Array = []
|
|
for i in range(sm.get_transition_count()):
|
|
var tr: AnimationNodeStateMachineTransition = sm.get_transition(i)
|
|
var t_entry := {
|
|
"from": str(sm.get_transition_from(i)),
|
|
"to": str(sm.get_transition_to(i)),
|
|
}
|
|
# Robustness: on some engine versions get_transition(i) can return null even
|
|
# for an in-range index (Godot 4.2 runtime divergence). Emit the
|
|
# endpoints we have and skip the transition-object fields rather than
|
|
# dereferencing null (which would yield a malformed return → INTERNAL).
|
|
if tr != null:
|
|
t_entry["switch_mode"] = _switch_mode_to_string(tr.switch_mode)
|
|
t_entry["advance_condition"] = str(tr.advance_condition)
|
|
t_entry["advance_mode"] = _advance_mode_to_string(tr.advance_mode)
|
|
transitions.append(t_entry)
|
|
|
|
return MCPToolkitSuccess.ok({
|
|
"root_type": root_type,
|
|
"nodes": Untrusted.wrap(
|
|
"animationtree", "nodes",
|
|
JSON.stringify(nodes)),
|
|
"transitions": Untrusted.wrap(
|
|
"animationtree", "transitions",
|
|
JSON.stringify(transitions)),
|
|
})
|