466 lines
17 KiB
GDScript
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)
|