Source code for simvx.core.animation.state_machine

"""Animation state machine with parameter-based transitions."""

import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from ..descriptors import Property
from ..node import Node
from ._interpolate import _interpolate
from .track import AnimationClip

log = logging.getLogger(__name__)


[docs] @dataclass class AnimationState: """Single state in animation state machine.""" name: str clip: AnimationClip speed_scale: float = 1.0 loop: bool = True
[docs] @dataclass class Transition: """Transition between animation states.""" from_state: str to_state: str condition: Callable[[], bool] # Returns True when transition should happen blend_time: float = 0.2 # Time to blend between states
[docs] class AnimationTree(Node): """Animation state machine with parameter-based transitions. As a Node subclass, it participates in the scene tree and gets ``on_update(dt)`` called automatically. By default it animates its parent. Properties: target: The node whose properties are animated. Defaults to ``parent`` when it is left unset. Attributes: states: Registered ``AnimationState`` objects keyed by name. Each holds an ``AnimationClip``, which has no source form, so a saved scene does not carry the states and they are rebuilt by the code that added them. transitions: Registered ``Transition`` objects. Each holds a ``Callable`` condition, so these are not carried by a save either. parameters: The gameplay values transition conditions read, written through :meth:`set_parameter`. Runtime state. current_state, current_time, playing: Playback state. blending, blend_from, blend_to, blend_time, blend_duration: Transition state. ``blend_duration`` is the length of the crossfade now in flight, copied from the ``blend_time`` the transition declared, so it says nothing about a tree that is not mid-transition. Example: tree = AnimationTree(target=player) tree.add_state("idle", idle_clip, loop=True) tree.add_state("run", run_clip, loop=True) tree.add_state("jump", jump_clip, loop=False) tree.add_transition("idle", "run", lambda: tree.parameters["speed"] > 0.1) tree.add_transition("run", "idle", lambda: tree.parameters["speed"] < 0.1) tree.add_transition("idle", "jump", lambda: tree.parameters["jump_pressed"]) tree.set_parameter("speed", 0.0) tree.start("idle") """ target = Property(None, hint="Node whose properties are animated (defaults to the parent)") #: Playback and transition state, plus the gameplay parameters the #: conditions read. None of it is authoring state and no save carries it. __transient__ = frozenset( { "parameters", "current_state", "current_time", "playing", "blending", "blend_from", "blend_to", "blend_time", "blend_duration", } ) def __init__(self, **kwargs): super().__init__(**kwargs) self.parameters: dict[str, Any] = {} # User-defined parameters self.states: dict[str, AnimationState] = {} self.transitions: list[Transition] = [] self.current_state: str | None = None self.current_time = 0.0 self.playing = False # Blending state self.blending = False self.blend_from: str | None = None self.blend_to: str | None = None self.blend_time = 0.0 self.blend_duration = 0.2
[docs] def add_state(self, name: str, clip: AnimationClip, speed_scale: float = 1.0, loop: bool = True): """Add animation state.""" self.states[name] = AnimationState(name, clip, speed_scale, loop)
[docs] def add_transition(self, from_state: str, to_state: str, condition: Callable[[], bool], blend_time: float = 0.2): """Add transition with condition function.""" self.transitions.append(Transition(from_state, to_state, condition, blend_time))
[docs] def set_parameter(self, name: str, value: Any): """Update animation parameter.""" self.parameters[name] = value
[docs] def get_parameter(self, name: str, default: Any = None) -> Any: """Get animation parameter.""" return self.parameters.get(name, default)
[docs] def start(self, state_name: str): """Start animation tree with initial state.""" if state_name not in self.states: return self.current_state = state_name self.current_time = 0.0 self.playing = True self.blending = False
[docs] def stop(self): """Stop animation tree.""" self.playing = False
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 _check_transitions(self): """Check if any transitions should trigger.""" if not self.current_state or self.blending: return for trans in self.transitions: if trans.from_state == self.current_state: try: if trans.condition(): self._start_transition(trans.to_state, trans.blend_time) break except Exception as exc: log.warning("Transition condition %s->%s failed: %s", trans.from_state, trans.to_state, exc) def _start_transition(self, to_state: str, blend_time: float): """Start blending to new state.""" if to_state not in self.states: return self.blending = True self.blend_from = self.current_state self.blend_to = to_state self.blend_time = 0.0 self.blend_duration = blend_time
[docs] def on_update(self, dt: float): """Advance animation state machine each frame (called by SceneTree).""" target = self._resolve_target() if not self.playing or not target: return self._check_transitions() if self.blending: self._update_blend(dt, target) else: self._update_normal_playback(dt, target)
def _update_blend(self, dt: float, target): """Advance blend transition between two states.""" self.blend_time += dt blend_factor = min(1.0, self.blend_time / self.blend_duration) if self.blend_duration > 0 else 1.0 if blend_factor >= 1.0: self.current_state = self.blend_to self.current_time = 0.0 self.blending = False self.blend_from = None self.blend_to = None else: if self.blend_from and self.blend_to: from_state = self.states[self.blend_from] to_state = self.states[self.blend_to] from_values = from_state.clip.evaluate(self.current_time) to_values = to_state.clip.evaluate(0.0) for prop in set(from_values.keys()) | set(to_values.keys()): v0 = from_values.get(prop) v1 = to_values.get(prop) if v0 is not None and v1 is not None: blended = _interpolate(v0, v1, blend_factor) if hasattr(target, prop): setattr(target, prop, blended) if self.blend_from: from_state = self.states[self.blend_from] self.current_time += dt * from_state.speed_scale def _update_normal_playback(self, dt: float, target): """Advance normal (non-blending) playback for the current state.""" if not self.current_state: return state = self.states[self.current_state] self.current_time += dt * state.speed_scale if self.current_time >= state.clip.duration: if state.loop: self.current_time = self.current_time % state.clip.duration else: self.current_time = state.clip.duration values = state.clip.evaluate(self.current_time) for prop, value in values.items(): if hasattr(target, prop): setattr(target, prop, value)