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

529 lines
20 KiB
GDScript

@tool
extends RefCounted
## Curated motion presets for the AnimationPlayer surface.
##
## Each preset_* method:
## 1. Validates params + resolves the player (auto-creating its default lib).
## 2. Resolves the target node + classifies it as control / 2d / 3d.
## 3. Builds a single-track Animation with shape-appropriate keyframes.
## 4. Commits the add through the handler's shared `_commit_animation_add`
## so a single Ctrl-Z rolls back any auto-created library + the animation.
##
## Holds a WeakRef back to the AnimationHandler instance so the handler can
## continue to own this module strongly via `_presets` without forming a
## RefCounted cycle. Resolution / undo helpers live on the handler — keeping
## the `_undo_redo` member single-source there avoids drift.
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_preset_fade
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.5))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target: Node = target_resolved.node
var track_target: String = target_resolved.track_path_root
# Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
var has_modulate := false
for p in target.get_property_list():
if p.name == "modulate":
has_modulate = true
break
if not has_modulate:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D"
% [target_path, target.get_class()])
if anim_name.is_empty():
anim_name = "fade_%s" % mode
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var start_a: float = 0.0 if mode == "in" else 1.0
var end_a: float = 1.0 if mode == "in" else 0.0
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:modulate:a" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_a, "transition": "linear"},
{"time": duration, "value": end_a, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"mode": mode,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_slide
# ============================================================================
func preset_slide(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var direction: String = params.get("direction", "left")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if not ["left", "right", "up", "down"].has(direction):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction)
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
# Default distance picks 3D units vs screen pixels based on target kind.
var default_distance: float = 1.0 if kind == "3d" else 100.0
var distance: float = float(params.get("distance", default_distance))
if distance == 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero")
var offset: Variant = _direction_offset(kind, direction, distance)
var current_pos: Variant = target.position
var start_pos: Variant
var end_pos: Variant
if mode == "in":
start_pos = current_pos + offset
end_pos = current_pos
else:
start_pos = current_pos
end_pos = current_pos + offset
if anim_name.is_empty():
anim_name = "slide_%s_%s" % [mode, direction]
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_pos, "transition": "linear"},
{"time": duration, "value": end_pos, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"direction": direction,
"mode": mode,
"distance": distance,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_shake
# ============================================================================
func preset_shake(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var duration: float = float(params.get("duration", 0.3))
var frequency: float = float(params.get("frequency", 30.0))
var rng_seed: int = int(params.get("seed", 0))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if frequency <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
var default_intensity: float = 0.1 if kind == "3d" else 10.0
var intensity: float = float(params.get("intensity", default_intensity))
if intensity <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0")
if anim_name.is_empty():
anim_name = "shake"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var rng := RandomNumberGenerator.new()
if rng_seed != 0:
rng.seed = rng_seed
else:
rng.randomize()
# Samples between t=0 and t=duration (exclusive); bookended by at-rest keys.
var sample_count: int = int(ceil(frequency * duration))
if sample_count < 2:
sample_count = 2
var current_pos: Variant = target.position
var kfs: Array = []
kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"})
for i in range(1, sample_count):
var t: float = (float(i) / float(sample_count)) * duration
var jx: float = rng.randf_range(-intensity, intensity)
var jy: float = rng.randf_range(-intensity, intensity)
var jittered: Variant
if kind == "3d":
var jz: float = rng.randf_range(-intensity, intensity)
jittered = current_pos + Vector3(jx, jy, jz)
else:
jittered = current_pos + Vector2(jx, jy)
kfs.append({"time": t, "value": jittered, "transition": "linear"})
kfs.append({"time": duration, "value": current_pos, "transition": "linear"})
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", kfs)
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"length": duration,
"frequency": frequency,
"intensity": intensity,
"keyframe_count": kfs.size(),
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_pulse
# ============================================================================
func preset_pulse(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var from_scale: float = float(params.get("from_scale", 1.0))
var to_scale: float = float(params.get("to_scale", 1.1))
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if from_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0")
if to_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
if anim_name.is_empty():
anim_name = "pulse"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var from_vec: Variant
var to_vec: Variant
if kind == "3d":
from_vec = Vector3(from_scale, from_scale, from_scale)
to_vec = Vector3(to_scale, to_scale, to_scale)
else:
from_vec = Vector2(from_scale, from_scale)
to_vec = Vector2(to_scale, to_scale)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:scale" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": from_vec, "transition": "linear"},
{"time": duration * 0.5, "value": to_vec, "transition": "linear"},
{"time": duration, "value": from_vec, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"from_scale": from_scale,
"to_scale": to_scale,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Helpers — preset resolution
# ============================================================================
## Resolve a preset target node and classify its transform kind.
##
## Accepts two `target_path` shapes:
## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`,
## matching the convention used by every other scene-mutating tool. Targets
## outside the player's `root_node` subtree are converted to `..`-prefixed
## paths via `root_node.get_path_to(target)`, mirroring what the relative
## form accepts and how Godot stores track paths.
## * Relative — used as-is against the player's `root_node`, matching how
## animation tracks themselves are stored.
##
## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
## (relative to `root_node`) that callers should embed in the track path. For
## scene-absolute inputs this is the converted relative path; for relative
## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
##
## Mirrors the same root-node fallback that
## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how
## the track path will resolve at playback.
func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
var root_node := AnimationValues.player_root_node(player)
if root_node == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
var target: Node = null
var track_path_root: String = target_path
if target_path.begins_with("/"):
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
target = ScenePath.resolve(target_path, scene_root)
if target == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
ScenePath.format_node_error(target_path, scene_root))
# Convert to a root_node-relative path. For targets outside the
# subtree this yields a `..`-prefixed path, matching what the
# relative form already accepts (root_node.get_node_or_null
# resolves `..` segments) and what Godot's animation engine
# stores natively.
track_path_root = str(root_node.get_path_to(target))
else:
target = root_node.get_node_or_null(target_path)
if target == null:
# root_node.get_path() leaks the editor's SubViewport-wrapped
# path; use the clean scene-relative form so the hint is
# actionable.
var scene_root := EditorInterface.get_edited_scene_root()
var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
+ "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
% [target_path, root_hint, abs_example])
var kind: String
if target is Control:
kind = "control"
elif target is Node2D:
kind = "2d"
elif target is Node3D:
kind = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
return {"node": target, "kind": kind, "track_path_root": track_path_root}
## Build a directional offset for slide presets.
## Axis conventions:
## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y
## Node3D (world-up): left/right = ∓x, up = +y, down = -y
static func _direction_offset(kind: String, direction: String, distance: float) -> Variant:
if kind == "3d":
match direction:
"left": return Vector3(-distance, 0.0, 0.0)
"right": return Vector3(distance, 0.0, 0.0)
"up": return Vector3(0.0, distance, 0.0)
"down": return Vector3(0.0, -distance, 0.0)
else:
match direction:
"left": return Vector2(-distance, 0.0)
"right": return Vector2(distance, 0.0)
"up": return Vector2(0.0, -distance)
"down": return Vector2(0.0, distance)
return null