"""AnimationPlayer -- timeline-based animation playback node."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from ..descriptors import Signal
from ..node import Node
from ._interpolate import _blend_values
from .track import AnimationClip
if TYPE_CHECKING:
from .skeletal import SkeletalAnimationClip
[docs]
class AnimationPlayer(Node):
"""Plays timeline-based animation clips on a target node.
As a Node subclass, it participates in the scene tree and gets
``process(dt)`` called automatically. By default it animates its parent.
Supports crossfading between clips, firing track events, and skeletal
animation via ``SkeletalAnimationClip`` + ``Skeleton``.
Attributes:
target: The node whose properties are animated. Defaults to
``parent`` if not set explicitly.
clips: Dictionary of registered ``AnimationClip`` objects keyed by name.
skeletal_clips: Dictionary of ``SkeletalAnimationClip`` objects keyed by name.
skeleton: Optional ``Skeleton`` node for bone-track playback.
current_clip: Name of the currently playing clip, or ``None``.
current_time: Playback position within the current clip (seconds).
playing: Whether playback is active.
speed_scale: Playback speed multiplier (1.0 = normal).
loop: Whether the current clip should loop when finished.
animation_finished: Signal emitted when a non-looping clip ends.
Example::
player = AnimationPlayer()
player.add_clip(jump_clip)
player.add_clip(run_clip)
player.play("jump")
player.crossfade("run", duration=0.3)
# Skeletal animation:
player.skeleton = skeleton_node
player.add_skeletal_clip(walk_skeletal_clip)
player.play("walk", loop=True)
"""
def __init__(self, target=None, skeleton=None, **kwargs):
super().__init__(**kwargs)
self.target = target # Node to animate (defaults to parent)
self.skeleton = skeleton # Skeleton node for bone-track playback
self.clips: dict[str, AnimationClip] = {}
self.skeletal_clips: dict[str, SkeletalAnimationClip] = {}
self.current_clip: str | None = None
self.current_time = 0.0
self.playing = False
self.speed_scale = 1.0
self.loop = False
# Crossfade state
self._crossfade_from: str | None = None
self._crossfade_from_time: float = 0.0
self._crossfade_duration: float = 0.0
self._crossfade_elapsed: float = 0.0
self._crossfading: bool = False
# Signals
self.animation_finished = Signal()
# Callbacks (legacy compatibility)
self.on_animation_finished: Callable | None = None
def _resolve_target(self):
"""Resolve target: use explicit target, else fall back to parent."""
return self.target if self.target is not None else self.parent
def _resolve_skeleton(self):
"""Resolve skeleton: explicit, else try parent."""
if self.skeleton is not None:
return self.skeleton
from ..skeleton import Skeleton
if isinstance(self.parent, Skeleton):
return self.parent
return None
[docs]
def add_clip(self, clip: AnimationClip | SkeletalAnimationClip):
"""Register an animation clip (property-based or skeletal)."""
from .skeletal import SkeletalAnimationClip as _SAC
if isinstance(clip, _SAC):
self.skeletal_clips[clip.name] = clip
else:
self.clips[clip.name] = clip
[docs]
def add_skeletal_clip(self, clip: SkeletalAnimationClip):
"""Register a skeletal animation clip."""
self.skeletal_clips[clip.name] = clip
def _has_clip(self, name: str) -> bool:
"""Check if a clip (property or skeletal) is registered under *name*."""
return name in self.clips or name in self.skeletal_clips
def _clip_duration(self, name: str) -> float:
"""Return duration of a named clip (property or skeletal)."""
if name in self.clips:
return self.clips[name].duration
if name in self.skeletal_clips:
return self.skeletal_clips[name].duration
return 0.0
[docs]
def play(self, clip_name: str, loop: bool = False):
"""Play animation clip, cancelling any active crossfade."""
if not self._has_clip(clip_name):
return
self.current_clip = clip_name
self.current_time = 0.0
self.playing = True
self.loop = loop
self._crossfading = False
[docs]
def crossfade(self, clip_name: str, duration: float = 0.3):
"""Blend from the current clip to a new clip over *duration* seconds.
If no clip is playing or the target clip is unknown, falls back to ``play()``.
"""
if not self._has_clip(clip_name) or not self.current_clip or not self.playing:
self.play(clip_name)
return
self._crossfade_from = self.current_clip
self._crossfade_from_time = self.current_time
self._crossfade_duration = max(duration, 1e-6)
self._crossfade_elapsed = 0.0
self._crossfading = True
self.current_clip = clip_name
self.current_time = 0.0
self.playing = True
[docs]
def stop(self):
"""Stop playback."""
self.playing = False
self._crossfading = False
[docs]
def pause(self):
"""Pause playback (alias for stop)."""
self.stop()
[docs]
def resume(self):
"""Resume playback."""
self.playing = True
[docs]
def seek(self, time: float):
"""Jump to time in current clip."""
self.current_time = time
[docs]
def process(self, dt: float):
"""Advance animation playback each frame (called by SceneTree)."""
if not self.playing or not self.current_clip:
return
clip_name = self.current_clip
is_skeletal = clip_name in self.skeletal_clips
duration = self._clip_duration(clip_name)
prev_time = self.current_time
self.current_time += dt * self.speed_scale
# Property-clip crossfade (skeletal crossfade not yet supported)
if self._crossfading and self._crossfade_from and not is_skeletal:
target = self._resolve_target()
if not target:
return
clip = self.clips[clip_name]
self._apply_crossfade(dt, clip, prev_time, target)
return
# Loop or finish
if self.current_time >= duration:
if self.loop:
self.current_time = self.current_time % duration if duration > 0 else 0.0
else:
self.current_time = duration
self.playing = False
self.animation_finished()
if self.on_animation_finished:
self.on_animation_finished()
if is_skeletal:
self._apply_skeletal_clip(self.skeletal_clips[clip_name])
else:
target = self._resolve_target()
if not target:
return
clip = self.clips[clip_name]
self._fire_track_events(clip, prev_time)
self._apply_clip_values(clip, target)
def _apply_crossfade(self, dt: float, clip: AnimationClip, prev_time: float, target):
"""Blend between outgoing and incoming clips during crossfade."""
self._crossfade_elapsed += dt
blend_t = min(1.0, self._crossfade_elapsed / self._crossfade_duration)
from_clip = self.clips[self._crossfade_from]
self._crossfade_from_time += dt * self.speed_scale
from_values = from_clip.evaluate(self._crossfade_from_time)
to_values = clip.evaluate(self.current_time)
for prop in set(from_values.keys()) | set(to_values.keys()):
blended = _blend_values(from_values.get(prop), to_values.get(prop), blend_t)
if blended is not None and hasattr(target, prop):
setattr(target, prop, blended)
self._fire_track_events(clip, prev_time)
if blend_t >= 1.0:
self._crossfading = False
self._crossfade_from = None
def _fire_track_events(self, clip: AnimationClip, prev_time: float):
"""Fire any events on clip tracks between prev_time and current_time."""
for track in clip.tracks.values():
if track.events:
track.fire_events(prev_time, self.current_time)
def _apply_clip_values(self, clip: AnimationClip, target):
"""Evaluate clip at current_time and set properties on target."""
values = clip.evaluate(self.current_time)
for prop, value in values.items():
if hasattr(target, prop):
setattr(target, prop, value)
def _apply_skeletal_clip(self, clip: SkeletalAnimationClip):
"""Evaluate a skeletal clip and push bone transforms to the skeleton."""
skel = self._resolve_skeleton()
if skel is None:
return
bone_transforms = clip.evaluate(self.current_time)
for bone_idx, transform in bone_transforms.items():
skel.set_bone_pose(bone_idx, transform)
skel.compute_pose()
[docs]
def to_dict(self) -> dict:
"""Serialize player state."""
return {
"clips": {name: clip.to_dict() for name, clip in self.clips.items()},
"current_clip": self.current_clip,
"current_time": self.current_time,
"speed_scale": self.speed_scale,
"loop": self.loop,
}
[docs]
@classmethod
def from_dict(cls, data: dict, target=None):
"""Deserialize player."""
player = cls(target=target)
for clip_data in data.get("clips", {}).values():
player.add_clip(AnimationClip.from_dict(clip_data))
player.current_clip = data.get("current_clip")
player.current_time = data.get("current_time", 0.0)
player.speed_scale = data.get("speed_scale", 1.0)
player.loop = data.get("loop", False)
return player