Source code for simvx.core.animation.player

"""AnimationPlayer -- timeline-based animation playback node."""

from typing import TYPE_CHECKING

import numpy as np

from ..node import Node
from ..signals import Signal
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 ``on_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_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() 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
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 and reset to the start of the current clip. ``playing`` becomes ``False``, the playhead resets to ``current_time = 0``, and any crossfade in progress is cancelled. """ self.playing = False self.current_time = 0.0 self._crossfading = False
[docs] def pause(self): """Pause playback, preserving the playhead. ``playing`` becomes ``False`` but ``current_time`` is left untouched; ``resume()`` continues from where playback left off. """ self.playing = False
[docs] def resume(self): """Resume playback from the current playhead position.""" self.playing = True
[docs] def seek(self, time: float): """Jump to time in current clip.""" self.current_time = time
[docs] def on_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 # Crossfade in progress: blend outgoing -> incoming. Skeletal and # property crossfades take separate (TRS vs property) blend paths. if self._crossfading and self._crossfade_from: from_is_skeletal = self._crossfade_from in self.skeletal_clips if is_skeletal or from_is_skeletal: self._apply_skeletal_crossfade(dt, clip_name, from_is_skeletal, is_skeletal) return 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. Track whether playback wrapped this frame so events # in the (prev_time, duration] tail segment fire before the wrap. wrapped = False if self.current_time >= duration: if self.loop: if duration > 0: wrapped = True self.current_time = self.current_time % duration else: self.current_time = 0.0 else: self.current_time = duration self.playing = False self.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] if wrapped: # Fire events in two segments around the wrap so events in # (prev_time, duration] are not silently dropped when # self.current_time wraps back to a small value. self._fire_track_events_in_range(clip, prev_time, duration) self._fire_track_events_in_range(clip, 0.0, self.current_time) else: 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 _apply_skeletal_crossfade(self, dt: float, clip_name: str, from_is_skeletal: bool, to_is_skeletal: bool): """Blend per-bone TRS poses between the outgoing and incoming clips. At least one side is skeletal. Bone poses are sampled in TRS space from both clips, blended (SLERP rotation, LERP position/scale) by ``blend_t``, and written to the skeleton. On completion the player settles exactly onto the incoming clip. """ from .skeletal import blend_trs skel = self._resolve_skeleton() if skel is None: return self._crossfade_elapsed += dt blend_t = min(1.0, self._crossfade_elapsed / self._crossfade_duration) self._crossfade_from_time += dt * self.speed_scale from_trs = self._sample_pose_trs(self._crossfade_from, from_is_skeletal, self._crossfade_from_time) to_trs = self._sample_pose_trs(clip_name, to_is_skeletal, self.current_time) identity = ( np.zeros(3, dtype=np.float32), np.array([0, 0, 0, 1], dtype=np.float32), np.ones(3, dtype=np.float32), ) for bone_idx in set(from_trs.keys()) | set(to_trs.keys()): a = from_trs.get(bone_idx, identity) b = to_trs.get(bone_idx, identity) skel.set_bone_pose(bone_idx, blend_trs(a, b, blend_t)) skel.compute_pose() if blend_t >= 1.0: self._crossfading = False self._crossfade_from = None def _sample_pose_trs(self, clip_name: str, is_skeletal: bool, time: float): """Return {bone_index: (t, r, s)} for a clip at *time*. Property clips contribute no bone poses (empty dict) so a skeletal crossfade against a property clip blends the skeletal side toward rest. """ if is_skeletal: return self.skeletal_clips[clip_name].evaluate_trs(time) return {} 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 _fire_track_events_in_range(self, clip: AnimationClip, prev_time: float, cur_time: float): """Fire events on clip tracks in the half-open interval (prev_time, cur_time]. Used for wrap-aware looping where the playback interval spans a loop boundary and must be split into two segments. """ for track in clip.tracks.values(): if track.events: track.fire_events(prev_time, cur_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. Both property clips and skeletal clips are persisted (skeletal clips carry a ``"kind": "skeletal"`` tag so :meth:`from_dict` rebuilds the right type). Resumable playback state (``current_clip``, ``current_time``, ``speed_scale``, ``loop``, ``playing``) is included. A crossfade in progress is *not* persisted: on load the player settles onto the incoming clip (``current_clip``), which is the steady state the crossfade is converging toward anyway. """ return { "clips": {name: clip.to_dict() for name, clip in self.clips.items()}, "skeletal_clips": {name: clip.to_dict() for name, clip in self.skeletal_clips.items()}, "current_clip": self.current_clip, "current_time": self.current_time, "speed_scale": self.speed_scale, "loop": self.loop, "playing": self.playing, }
[docs] @classmethod def from_dict(cls, data: dict, target=None): """Deserialize player. Loads in a settled state (no active crossfade).""" from .skeletal import SkeletalAnimationClip player = cls(target=target) for clip_data in data.get("clips", {}).values(): player.add_clip(AnimationClip.from_dict(clip_data)) for clip_data in data.get("skeletal_clips", {}).values(): player.add_clip(SkeletalAnimationClip.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) player.playing = data.get("playing", False) return player