Source code for simvx.core.animation.player

"""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