Skip to content

Commit 88e2ebe

Browse files
dsarnoclaude
andauthored
Accept scene-absolute target_path in animation presets (closes #328) (#337)
* Accept scene-absolute target_path in animation presets (closes #328) Animation preset tools (preset_fade/slide/shake/pulse) historically required target_path to be relative to the AnimationPlayer's root_node, while every other scene tool takes scene-absolute paths like /Main/Foo. Agents repeatedly tripped on the inconsistency and got an unhelpful "Target node not found" error that didn't mention either convention. _resolve_preset_target now accepts both shapes: scene-absolute paths (via McpScenePath.resolve) are converted to root_node-relative track paths via root_node.get_path_to(target). Targets outside the player's root_node subtree are rejected with a clear error explaining why animation tracks need that constraint. The relative-path error now names both supported conventions so callers can self-correct. Also extracts _player_root_node — the same is_inside_tree → root_node NodePath → get_parent walk was duplicated three times in the file (validate_animation, _resolve_track_prop_context, the new resolver). Coverage: four new GDScript tests (each preset's absolute-path branch, plus a target-equals-root_node edge case verifying "." track shape and an outside-subtree rejection). Augmented the existing missing-target test to lock the improved error message. Live-smoked: scene-absolute target_path now succeeds end-to-end, animation_manage validate reports broken_count=0 on the resulting track. https://claude.ai/code/session_01EduM6GCbywUfRhMQAL8M5N * Apply Copilot review feedback on preset target_path Three findings from the PR review: 1. Drop the `is_ancestor_of` strict check on scene-absolute targets. The relative form already accepts `..`-prefixed paths via `root_node.get_node_or_null`, and Godot's animation engine stores and resolves them natively. Rejecting the same node when callers spell it absolutely made the new shape strictly less capable than the relative form. `get_path_to` yields the right `..`-prefixed track path. 2. The hardcoded `/Main/World/Cube` example in the not-found error is misleading in any scene whose root isn't named Main. Use the actual `scene_root.name` so the hint always points at a valid path shape. 3. Add `test_preset_slide_accepts_scene_absolute_target` so slide gets the same positive abs-path coverage as fade/shake/pulse, and convert the old slide rejection test into `test_preset_slide_accepts_target_outside_root_node` to lock in the new permissive behavior (track stored as `../ForeignTarget:position`). Also updates `tools/animation.py` module docstring to match the new behavior. ruff check src/ tests/: clean. script/ci-check-gdscript: clean. pytest: 762 passed. https://claude.ai/code/session_01DwmN9ouQq88KnabiW42y2C * Fix slide preset test animation names The two new slide tests asserted on `slide_in` but `preset_slide` actually defaults `animation_name` to `slide_<mode>_<direction>` (see animation_handler.gd:923). Use `slide_in_left` to match. Caught by CI (Linux/macOS/Windows Godot test jobs). https://claude.ai/code/session_01DwmN9ouQq88KnabiW42y2C --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1aeb9a commit 88e2ebe

3 files changed

Lines changed: 294 additions & 35 deletions

File tree

plugin/addons/godot_ai/handlers/animation_handler.gd

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -588,13 +588,7 @@ func validate_animation(params: Dictionary) -> Dictionary:
588588

589589
var anim: Animation = player.get_animation(anim_name)
590590

591-
var root_node: Node = null
592-
if player.is_inside_tree():
593-
var rn := player.root_node
594-
if rn != NodePath():
595-
root_node = player.get_node_or_null(rn)
596-
if root_node == null:
597-
root_node = player.get_parent()
591+
var root_node := _player_root_node(player)
598592

599593
var broken_tracks: Array[Dictionary] = []
600594
var valid_count := 0
@@ -810,6 +804,7 @@ func preset_fade(params: Dictionary) -> Dictionary:
810804
if target_resolved.has("error"):
811805
return target_resolved
812806
var target: Node = target_resolved.node
807+
var track_target: String = target_resolved.track_path_root
813808

814809
# Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
815810
var has_modulate := false
@@ -839,7 +834,7 @@ func preset_fade(params: Dictionary) -> Dictionary:
839834
anim.length = duration
840835
anim.loop_mode = Animation.LOOP_NONE
841836

842-
var track_path := "%s:modulate:a" % target_path
837+
var track_path := "%s:modulate:a" % track_target
843838
_do_add_property_track(anim, track_path, "linear", [
844839
{"time": 0.0, "value": start_a, "transition": "linear"},
845840
{"time": duration, "value": end_a, "transition": "linear"},
@@ -905,6 +900,7 @@ func preset_slide(params: Dictionary) -> Dictionary:
905900
return target_resolved
906901
var target = target_resolved.node
907902
var kind: String = target_resolved.kind
903+
var track_target: String = target_resolved.track_path_root
908904

909905
# Default distance picks 3D units vs screen pixels based on target kind.
910906
var default_distance: float = 1.0 if kind == "3d" else 100.0
@@ -937,7 +933,7 @@ func preset_slide(params: Dictionary) -> Dictionary:
937933
anim.length = duration
938934
anim.loop_mode = Animation.LOOP_NONE
939935

940-
var track_path := "%s:position" % target_path
936+
var track_path := "%s:position" % track_target
941937
_do_add_property_track(anim, track_path, "linear", [
942938
{"time": 0.0, "value": start_pos, "transition": "linear"},
943939
{"time": duration, "value": end_pos, "transition": "linear"},
@@ -1001,6 +997,7 @@ func preset_shake(params: Dictionary) -> Dictionary:
1001997
return target_resolved
1002998
var target = target_resolved.node
1003999
var kind: String = target_resolved.kind
1000+
var track_target: String = target_resolved.track_path_root
10041001

10051002
var default_intensity: float = 0.1 if kind == "3d" else 10.0
10061003
var intensity: float = float(params.get("intensity", default_intensity))
@@ -1048,7 +1045,7 @@ func preset_shake(params: Dictionary) -> Dictionary:
10481045
anim.length = duration
10491046
anim.loop_mode = Animation.LOOP_NONE
10501047

1051-
var track_path := "%s:position" % target_path
1048+
var track_path := "%s:position" % track_target
10521049
_do_add_property_track(anim, track_path, "linear", kfs)
10531050

10541051
_commit_animation_add(
@@ -1110,6 +1107,7 @@ func preset_pulse(params: Dictionary) -> Dictionary:
11101107
if target_resolved.has("error"):
11111108
return target_resolved
11121109
var kind: String = target_resolved.kind
1110+
var track_target: String = target_resolved.track_path_root
11131111

11141112
if anim_name.is_empty():
11151113
anim_name = "pulse"
@@ -1134,7 +1132,7 @@ func preset_pulse(params: Dictionary) -> Dictionary:
11341132
anim.length = duration
11351133
anim.loop_mode = Animation.LOOP_NONE
11361134

1137-
var track_path := "%s:scale" % target_path
1135+
var track_path := "%s:scale" % track_target
11381136
_do_add_property_track(anim, track_path, "linear", [
11391137
{"time": 0.0, "value": from_vec, "transition": "linear"},
11401138
{"time": duration * 0.5, "value": to_vec, "transition": "linear"},
@@ -1165,26 +1163,77 @@ func preset_pulse(params: Dictionary) -> Dictionary:
11651163
# Helpers — preset resolution
11661164
# ============================================================================
11671165

1168-
## Resolve a preset target node relative to the player's animation root and
1169-
## classify its transform kind. Mirrors the same root-node fallback that
1170-
## `_resolve_track_prop_context` uses so tool inputs match how the track path
1171-
## will resolve at playback.
1172-
## Returns {node, kind} where kind ∈ {"control", "2d", "3d"}, or an error dict.
1166+
## Resolve the AnimationPlayer's effective `root_node` — the node animation
1167+
## tracks resolve their paths against at playback. Honors a non-default
1168+
## `root_node` NodePath, then falls back to `player.get_parent()` (Godot's
1169+
## documented default behavior for `root_node = ".."`). Returns null if the
1170+
## player isn't in the tree and has no resolvable parent.
1171+
static func _player_root_node(player: AnimationPlayer) -> Node:
1172+
if not player.is_inside_tree():
1173+
return null
1174+
var rn := player.root_node
1175+
if rn != NodePath():
1176+
var resolved := player.get_node_or_null(rn)
1177+
if resolved != null:
1178+
return resolved
1179+
return player.get_parent()
1180+
1181+
1182+
## Resolve a preset target node and classify its transform kind.
1183+
##
1184+
## Accepts two `target_path` shapes:
1185+
## * Scene-absolute (starts with "/") — resolved through `McpScenePath.resolve`,
1186+
## matching the convention used by every other scene-mutating tool. Targets
1187+
## outside the player's `root_node` subtree are converted to `..`-prefixed
1188+
## paths via `root_node.get_path_to(target)`, mirroring what the relative
1189+
## form accepts and how Godot stores track paths.
1190+
## * Relative — used as-is against the player's `root_node`, matching how
1191+
## animation tracks themselves are stored.
1192+
##
1193+
## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
1194+
## (relative to `root_node`) that callers should embed in the track path. For
1195+
## scene-absolute inputs this is the converted relative path; for relative
1196+
## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
1197+
##
1198+
## Mirrors the same root-node fallback that `_resolve_track_prop_context` uses
1199+
## so tool inputs match how the track path will resolve at playback.
11731200
func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
1174-
var root_node: Node = null
1175-
if player.is_inside_tree():
1176-
var rn := player.root_node
1177-
if rn != NodePath():
1178-
root_node = player.get_node_or_null(rn)
1179-
if root_node == null:
1180-
root_node = player.get_parent()
1201+
var root_node := _player_root_node(player)
11811202
if root_node == null:
11821203
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
11831204
"AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
1184-
var target: Node = root_node.get_node_or_null(target_path)
1185-
if target == null:
1186-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
1187-
"Target node not found at '%s' (resolved against player's root_node)" % target_path)
1205+
1206+
var target: Node = null
1207+
var track_path_root: String = target_path
1208+
if target_path.begins_with("/"):
1209+
var scene_root := EditorInterface.get_edited_scene_root()
1210+
if scene_root == null:
1211+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
1212+
"Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
1213+
target = McpScenePath.resolve(target_path, scene_root)
1214+
if target == null:
1215+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
1216+
McpScenePath.format_node_error(target_path, scene_root))
1217+
# Convert to a root_node-relative path. For targets outside the
1218+
# subtree this yields a `..`-prefixed path, matching what the
1219+
# relative form already accepts (root_node.get_node_or_null
1220+
# resolves `..` segments) and what Godot's animation engine
1221+
# stores natively.
1222+
track_path_root = str(root_node.get_path_to(target))
1223+
else:
1224+
target = root_node.get_node_or_null(target_path)
1225+
if target == null:
1226+
# root_node.get_path() leaks the editor's SubViewport-wrapped
1227+
# path; use the clean scene-relative form so the hint is
1228+
# actionable.
1229+
var scene_root := EditorInterface.get_edited_scene_root()
1230+
var root_hint := McpScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
1231+
var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
1232+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
1233+
("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
1234+
+ "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
1235+
% [target_path, root_hint, abs_example])
1236+
11881237
var kind: String
11891238
if target is Control:
11901239
kind = "control"
@@ -1195,7 +1244,7 @@ func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dic
11951244
else:
11961245
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
11971246
"Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
1198-
return {"node": target, "kind": kind}
1247+
return {"node": target, "kind": kind, "track_path_root": track_path_root}
11991248

12001249

12011250
## Build a directional offset for slide presets.
@@ -1481,13 +1530,7 @@ static func _resolve_track_prop_context(track_path: String, player: AnimationPla
14811530
var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
14821531
var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
14831532

1484-
var root_node: Node = override_root_node
1485-
if root_node == null and player.is_inside_tree():
1486-
var rn := player.root_node
1487-
if rn != NodePath():
1488-
root_node = player.get_node_or_null(rn)
1489-
if root_node == null:
1490-
root_node = player.get_parent()
1533+
var root_node: Node = override_root_node if override_root_node != null else _player_root_node(player)
14911534
if root_node == null:
14921535
return {"pass_through": true}
14931536

src/godot_ai/tools/animation.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@
5858
• preset_pulse(player_path, target_path, from_scale=1.0, to_scale=1.1,
5959
duration=0.4, animation_name="", overwrite=False)
6060
One-call pulse / hover-bounce (3-keyframe scale ping-pong).
61+
62+
Preset target_path: accepts either a scene-absolute path (e.g. "/Main/World/Cube",
63+
matching every other scene tool) or a path relative to the AnimationPlayer's
64+
root_node (e.g. "World/Cube", matching how Animation tracks store node paths).
65+
Scene-absolute targets outside the player's root_node subtree are converted to
66+
a `..`-prefixed track path via root_node.get_path_to(target), the same shape
67+
the relative form already accepts.
6168
"""
6269

6370

@@ -76,6 +83,10 @@ async def animation_create(
7683
7784
After creating the clip, add tracks via ``animation_manage`` ops
7885
``add_property_track`` / ``add_method_track`` / ``create_simple``.
86+
Track node paths are stored relative to the AnimationPlayer's
87+
``root_node`` (default: its parent), not to the scene root — see
88+
``animation_manage`` preset ops for a forgiving target_path that
89+
accepts either form.
7990
If ``player_path`` doesn't resolve, an AnimationPlayer is auto-created
8091
at that path (parent must exist).
8192

0 commit comments

Comments
 (0)