Files
tekton/addons/godot_ai/handlers/animation_values.gd
T

466 lines
17 KiB
GDScript

@tool
extends RefCounted
## Read-only animation introspection + shared value-coercion / serialization.
##
## Holds:
## - Static helpers used by both the write handler (track building, simple
## composer) and the preset module (target/property resolution).
## - Instance methods that back the read MCP ops: animation_list,
## animation_get, animation_validate.
##
## The instance methods need the handler to resolve players / animations.
## To keep that without introducing a RefCounted cycle (the handler holds a
## strong ref to this module via `_values`), the back-pointer is a WeakRef.
## When the handler is freed during plugin teardown, _h() returns null and
## the (no-longer-routable) calls short-circuit to a generic editor-not-ready
## error — matches the dispatcher already being torn down at that point.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd")
const _NAMED_TRANSITIONS := {
"linear": 1.0,
"ease_in": 2.0,
"ease_out": 0.5,
"ease_in_out": -2.0,
}
## Component letters accepted on each aggregate base type, paired with the
## scalar Variant type the component resolves to. A subpath like `position:y`
## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT.
const _SUBPATH_COMPONENTS := {
TYPE_VECTOR2: ["xy", TYPE_FLOAT],
TYPE_VECTOR3: ["xyz", TYPE_FLOAT],
TYPE_VECTOR4: ["xyzw", TYPE_FLOAT],
TYPE_QUATERNION: ["xyzw", TYPE_FLOAT],
TYPE_COLOR: ["rgba", TYPE_FLOAT],
TYPE_VECTOR2I: ["xy", TYPE_INT],
TYPE_VECTOR3I: ["xyz", TYPE_INT],
TYPE_VECTOR4I: ["xyzw", TYPE_INT],
}
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_list (read)
# ============================================================================
func list_animations(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var animations: Array[Dictionary] = []
for lib_name in player.get_animation_library_list():
var lib: AnimationLibrary = player.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
var anim: Animation = lib.get_animation(anim_name)
var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name]
animations.append({
"name": display_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
})
return {
"data": {
"player_path": player_path,
"animations": animations,
"count": animations.size(),
}
}
# ============================================================================
# animation_get (read)
# ============================================================================
func get_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
var tracks: Array[Dictionary] = []
for i in anim.get_track_count():
var track_type := anim.track_get_type(i)
var type_name := track_type_to_string(track_type)
var keys: Array[Dictionary] = []
for k in anim.track_get_key_count(i):
var key_val = anim.track_get_key_value(i, k)
keys.append({
"time": anim.track_get_key_time(i, k),
"value": serialize_value(key_val),
"transition": anim.track_get_key_transition(i, k),
})
tracks.append({
"index": i,
"type": type_name,
"path": str(anim.track_get_path(i)),
"interpolation": interp_to_string(anim.track_get_interpolation_type(i)),
"key_count": keys.size(),
"keys": keys,
})
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
"tracks": tracks,
}
}
# ============================================================================
# animation_validate (read-only)
# ============================================================================
func validate_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var anim: Animation = player.get_animation(anim_name)
var root_node := player_root_node(player)
var broken_tracks: Array[Dictionary] = []
var valid_count := 0
for i in anim.get_track_count():
var track_path_str := str(anim.track_get_path(i))
# Split on the FIRST colon (node↔property boundary), not the last.
# Godot's get_node_or_null strips the ":property" tail natively, so
# the valid/broken classification is the same either way — but for
# BROKEN tracks the broken_tracks[].node_path field is what callers
# read to diagnose the missing node, and rfind would surface
# "MissingTarget:modulate" instead of "MissingTarget" for subpath
# tracks like the "Target:modulate:a" shape preset_fade emits.
var colon := track_path_str.find(":")
var node_part: String
if colon >= 0:
node_part = track_path_str.substr(0, colon)
else:
node_part = track_path_str
var target_node: Node = null
if root_node != null:
target_node = root_node.get_node_or_null(node_part)
if target_node == null:
broken_tracks.append({
"index": i,
"path": track_path_str,
"type": track_type_to_string(anim.track_get_type(i)),
"issue": "node_not_found",
"node_path": node_part,
})
else:
valid_count += 1
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_count": anim.get_track_count(),
"valid_count": valid_count,
"broken_count": broken_tracks.size(),
"broken_tracks": broken_tracks,
"valid": broken_tracks.is_empty(),
}
}
# ============================================================================
# Static helpers — shared with handler + presets
# ============================================================================
## Resolve the effective root node an AnimationPlayer animates against.
## Falls back to the player's parent when the explicit root_node NodePath is
## empty or unresolvable. Returns null when the player isn't in the tree.
##
## Mirrors the resolution Godot does at playback time so the validator,
## preset target resolver, and track-property coercer all see the same root.
static func player_root_node(player: AnimationPlayer) -> Node:
if not player.is_inside_tree():
return null
var rn := player.root_node
if rn != NodePath():
var n := player.get_node_or_null(rn)
if n != null:
return n
return player.get_parent()
## Coerce a JSON value to match the expected Godot type for the given
## track_path. Returns {"ok": value} or {"error": msg}.
## Passes the raw value through when the target node isn't in the scene
## yet (authoring-time path). Errors when the target exists but the
## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
## clearly fails — better to reject than silently store garbage.
## `override_root_node` lets callers supply the root to resolve target paths
## against when the player isn't in the tree yet (auto-create flow) — the
## player's future parent stands in for the root the AnimationPlayer will
## eventually use.
static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var ctx := resolve_track_prop_context(track_path, player, override_root_node)
if ctx.has("error"):
return {"error": ctx.error}
return coerce_with_context(value, ctx)
## Resolve a track_path's target property type once, so callers coercing many
## keyframes avoid walking `get_property_list()` on every one. Returns:
## {pass_through: true} — no resolution / authoring-time
## {pass_through: false, prop_type, prop_name} — coerce against this type
## {error: msg} — property not found on target
##
## Supports Godot's native NodePath subpath form `property:sub` (e.g.
## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property
## boundary), resolves the base property on the target, and for known
## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the
## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts.
static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var colon := track_path.find(":")
if colon < 0:
return {"pass_through": true}
var node_part := track_path.substr(0, colon)
var prop_full := track_path.substr(colon + 1)
# Property may include a subpath: "position:y", "modulate:a", etc.
var sub_colon := prop_full.find(":")
var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
var root_node: Node = override_root_node
if root_node == null:
root_node = player_root_node(player)
if root_node == null:
return {"pass_through": true}
var target: Node = root_node.get_node_or_null(node_part)
if target == null:
# Target node isn't in the scene yet — authoring-time path. Pass through.
return {"pass_through": true}
for p in target.get_property_list():
if p.name == prop_base:
var base_type: int = p.get("type", TYPE_NIL)
var coerce_type := base_type
if not prop_sub.is_empty():
var sub_type := subpath_component_type(base_type, prop_sub)
if sub_type == TYPE_NIL:
# Unknown subpath component — pass through so Godot's own
# NodePath resolution raises at playback if it's truly bogus,
# rather than fabricating a coerce error for a valid-but-
# uncommon form (e.g. Transform3D subpaths).
return {"pass_through": true}
coerce_type = sub_type
return {
"pass_through": false,
"prop_type": coerce_type,
"prop_name": prop_full,
}
# Target exists but the property doesn't. Reject loudly — silently storing
# the raw value here produces garbage keyframes at playback time.
return {"error":
"%s (target path: '%s')" %
[PropertyErrors.build_message(target, prop_base), node_part]}
## Map a `property:sub` subpath to its scalar component type. Returns
## TYPE_NIL when the base type / subkey pair isn't one we recognise —
## callers pass-through in that case rather than mis-coerce.
static func subpath_component_type(base_type: int, sub: String) -> int:
var entry = _SUBPATH_COMPONENTS.get(base_type)
if entry == null or sub.length() != 1:
return TYPE_NIL
return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL
static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
if ctx.get("pass_through", false):
return {"ok": value}
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
match prop_type:
TYPE_COLOR:
if value is Color:
return {"ok": value}
if value is String:
var s := value as String
var a := Color.from_string(s, Color(0, 0, 0, 0))
var b := Color.from_string(s, Color(1, 1, 1, 1))
if a == b:
return {"ok": a}
return {"error": "Cannot parse '%s' as Color for property '%s'" % [s, prop_name]}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {"ok": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))}
return {"error": "Cannot coerce value to Color for property '%s' (expected string, {r,g,b}, or Color)" % prop_name}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() >= 2:
return {"ok": Vector2(float(value[0]), float(value[1]))}
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
TYPE_VECTOR3:
if value is Vector3:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y") and value.has("z"):
return {"ok": Vector3(float(value.x), float(value.y), float(value.z))}
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z} or Vector3)" % prop_name}
TYPE_FLOAT:
if value is int or value is float:
return {"ok": float(value)}
TYPE_INT:
if value is float or value is int:
return {"ok": int(value)}
TYPE_BOOL:
if value is int or value is float or value is bool:
return {"ok": bool(value)}
return {"ok": value}
# ============================================================================
# Static helpers — parsing + serializing
# ============================================================================
## Parse a transition value: named string or raw float.
## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source.
static func parse_transition(v: Variant) -> float:
if v is float or v is int:
return float(v)
if v is String:
var key: String = (v as String).to_lower()
if _NAMED_TRANSITIONS.has(key):
return float(_NAMED_TRANSITIONS[key])
return 1.0
## Map an Animation.TrackType enum to a stable string. Unknown types report
## as "unknown" rather than being silently coerced to "method" — callers that
## only produce value/method tracks can ignore the others; clients that want
## to round-trip bezier/audio/etc. get an honest label to key off.
static func track_type_to_string(track_type: int) -> String:
match track_type:
Animation.TYPE_VALUE: return "value"
Animation.TYPE_METHOD: return "method"
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_BEZIER: return "bezier"
Animation.TYPE_AUDIO: return "audio"
Animation.TYPE_ANIMATION: return "animation"
_: return "unknown"
static func loop_mode_to_string(mode: int) -> String:
match mode:
Animation.LOOP_LINEAR: return "linear"
Animation.LOOP_PINGPONG: return "pingpong"
_: return "none"
static func interp_to_string(mode: int) -> String:
match mode:
Animation.INTERPOLATION_NEAREST: return "nearest"
Animation.INTERPOLATION_CUBIC: return "cubic"
_: return "linear"
## Convert a Godot Variant to a JSON-safe value.
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
return value
TYPE_STRING_NAME:
return str(value)
TYPE_VECTOR2:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_COLOR:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
TYPE_NODE_PATH:
return str(value)
TYPE_ARRAY:
var arr: Array = []
for item in value:
arr.append(serialize_value(item))
return arr
TYPE_DICTIONARY:
var out := {}
for k in value:
out[str(k)] = serialize_value(value[k])
return out
return str(value)