529 lines
20 KiB
GDScript
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
|