Source code for simvx.core.animation.tween

"""Tween system and easing functions for property animation.

Tweens are time-based: each ``yield`` returns the per-tick ``dt`` (in
seconds) sent by the coroutine driver, which the tween accumulates as
elapsed time. There is no notion of "frames" inside a tween: duration
is honoured regardless of frame rate.
"""

import logging
import math
from collections.abc import Callable

from ..descriptors import Coroutine
from ._interpolate import _interpolate

log = logging.getLogger(__name__)

# ============================================================================
# Easing Functions
# ============================================================================

[docs] def ease_linear(t: float) -> float: """Linear interpolation (no easing).""" return t
[docs] def ease_in_quad(t: float) -> float: """Quadratic ease-in.""" return t * t
[docs] def ease_out_quad(t: float) -> float: """Quadratic ease-out.""" return 1 - (1 - t) * (1 - t)
[docs] def ease_in_out_quad(t: float) -> float: """Quadratic ease-in-out.""" return 2 * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 2 / 2
[docs] def ease_in_cubic(t: float) -> float: """Cubic ease-in.""" return t * t * t
[docs] def ease_out_cubic(t: float) -> float: """Cubic ease-out.""" return 1 - (1 - t) ** 3
[docs] def ease_in_out_cubic(t: float) -> float: """Cubic ease-in-out.""" return 4 * t * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
[docs] def ease_in_quart(t: float) -> float: """Quartic ease-in.""" return t * t * t * t
[docs] def ease_out_quart(t: float) -> float: """Quartic ease-out.""" return 1 - (1 - t) ** 4
[docs] def ease_in_out_quart(t: float) -> float: """Quartic ease-in-out.""" return 8 * t * t * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 4 / 2
[docs] def ease_in_quint(t: float) -> float: """Quintic ease-in.""" return t * t * t * t * t
[docs] def ease_out_quint(t: float) -> float: """Quintic ease-out.""" return 1 - (1 - t) ** 5
[docs] def ease_in_out_quint(t: float) -> float: """Quintic ease-in-out.""" return 16 * t * t * t * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 5 / 2
[docs] def ease_in_sine(t: float) -> float: """Sine ease-in.""" return 1 - math.cos((t * math.pi) / 2)
[docs] def ease_out_sine(t: float) -> float: """Sine ease-out.""" return math.sin((t * math.pi) / 2)
[docs] def ease_in_out_sine(t: float) -> float: """Sine ease-in-out.""" return -(math.cos(math.pi * t) - 1) / 2
[docs] def ease_in_expo(t: float) -> float: """Exponential ease-in.""" return 0 if t == 0 else math.pow(2, 10 * t - 10)
[docs] def ease_out_expo(t: float) -> float: """Exponential ease-out.""" return 1 if t == 1 else 1 - math.pow(2, -10 * t)
[docs] def ease_in_out_expo(t: float) -> float: """Exponential ease-in-out.""" if t == 0: return 0 if t == 1: return 1 if t < 0.5: return math.pow(2, 20 * t - 10) / 2 return (2 - math.pow(2, -20 * t + 10)) / 2
[docs] def ease_in_back(t: float) -> float: """Back ease-in (overshoots).""" c1 = 1.70158 c3 = c1 + 1 return c3 * t * t * t - c1 * t * t
[docs] def ease_out_back(t: float) -> float: """Back ease-out (overshoots).""" c1 = 1.70158 c3 = c1 + 1 return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2)
[docs] def ease_in_out_back(t: float) -> float: """Back ease-in-out (overshoots).""" c1 = 1.70158 c2 = c1 * 1.525 if t < 0.5: return (math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 return (math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
[docs] def ease_in_elastic(t: float) -> float: """Elastic ease-in (spring effect).""" c4 = (2 * math.pi) / 3 if t == 0: return 0 if t == 1: return 1 return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4)
[docs] def ease_out_elastic(t: float) -> float: """Elastic ease-out (spring effect).""" c4 = (2 * math.pi) / 3 if t == 0: return 0 if t == 1: return 1 return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1
[docs] def ease_in_out_elastic(t: float) -> float: """Elastic ease-in-out (spring effect).""" c5 = (2 * math.pi) / 4.5 if t == 0: return 0 if t == 1: return 1 if t < 0.5: return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1
[docs] def ease_in_bounce(t: float) -> float: """Bounce ease-in.""" return 1 - ease_out_bounce(1 - t)
[docs] def ease_out_bounce(t: float) -> float: """Bounce ease-out.""" n1 = 7.5625 d1 = 2.75 if t < 1 / d1: return n1 * t * t elif t < 2 / d1: t -= 1.5 / d1 return n1 * t * t + 0.75 elif t < 2.5 / d1: t -= 2.25 / d1 return n1 * t * t + 0.9375 else: t -= 2.625 / d1 return n1 * t * t + 0.984375
[docs] def ease_in_out_bounce(t: float) -> float: """Bounce ease-in-out.""" if t < 0.5: return (1 - ease_out_bounce(1 - 2 * t)) / 2 return (1 + ease_out_bounce(2 * t - 1)) / 2
# ============================================================================ # Easing registry -- single source of truth for name <-> function lookup. # ============================================================================ #: Canonical mapping of easing name -> easing function. This is the single #: source of truth used for serialization (``easing_name``) and deserialization #: (``easing_by_name``). Names match the function identifiers. EASING_FUNCTIONS: dict[str, Callable[[float], float]] = { "ease_linear": ease_linear, "ease_in_quad": ease_in_quad, "ease_out_quad": ease_out_quad, "ease_in_out_quad": ease_in_out_quad, "ease_in_cubic": ease_in_cubic, "ease_out_cubic": ease_out_cubic, "ease_in_out_cubic": ease_in_out_cubic, "ease_in_quart": ease_in_quart, "ease_out_quart": ease_out_quart, "ease_in_out_quart": ease_in_out_quart, "ease_in_quint": ease_in_quint, "ease_out_quint": ease_out_quint, "ease_in_out_quint": ease_in_out_quint, "ease_in_sine": ease_in_sine, "ease_out_sine": ease_out_sine, "ease_in_out_sine": ease_in_out_sine, "ease_in_expo": ease_in_expo, "ease_out_expo": ease_out_expo, "ease_in_out_expo": ease_in_out_expo, "ease_in_back": ease_in_back, "ease_out_back": ease_out_back, "ease_in_out_back": ease_in_out_back, "ease_in_elastic": ease_in_elastic, "ease_out_elastic": ease_out_elastic, "ease_in_out_elastic": ease_in_out_elastic, "ease_in_bounce": ease_in_bounce, "ease_out_bounce": ease_out_bounce, "ease_in_out_bounce": ease_in_out_bounce, } #: Reverse lookup, function -> canonical name. Keyed by function identity. _EASING_NAMES: dict[Callable[[float], float], str] = {fn: name for name, fn in EASING_FUNCTIONS.items()}
[docs] def easing_by_name(name: str) -> Callable[[float], float]: """Resolve a registered easing function by name. Falls back to :func:`ease_linear` (with a warning) for unknown names, so a forward-compatible save written with a newer easing never fails to load. """ fn = EASING_FUNCTIONS.get(name) if fn is None: log.warning("Unknown easing %r; falling back to ease_linear", name) return ease_linear return fn
[docs] def easing_name(fn: Callable[[float], float]) -> str: """Return the canonical registered name for an easing function. Registered functions resolve directly. An unregistered callable (e.g. a user-supplied lambda) is matched by ``__name__`` if that name happens to be registered; otherwise it is recorded as ``"ease_linear"`` with a warning, since an anonymous function cannot be round-tripped by name. """ name = _EASING_NAMES.get(fn) if name is not None: return name by_attr = getattr(fn, "__name__", None) if by_attr in EASING_FUNCTIONS: return by_attr log.warning("Easing function %r is not registered; serializing as ease_linear", fn) return "ease_linear"
# ============================================================================ # TweenChain # ============================================================================
[docs] class TweenChain: """Chainable tween builder for sequential animations. Example: TweenChain(obj, 'position', Vec3(0, 0, 0)) \\ .to(Vec3(10, 0, 0), 1.0, ease_out_quad) \\ .wait(0.5) \\ .to(Vec3(0, 0, 0), 1.0, ease_in_quad) \\ .on_complete(lambda: print("Done!")) \\ .build() """ def __init__(self, obj, prop: str, start_value=None): self.obj = obj self.prop = prop self.start_value = start_value if start_value is not None else getattr(obj, prop) self.steps = [] self.complete_callback = None
[docs] def to(self, target, duration: float, easing=ease_linear): """Add a tween step.""" self.steps.append(("tween", target, duration, easing)) return self
[docs] def wait(self, duration: float): """Add a wait step (in seconds).""" self.steps.append(("wait", duration)) return self
[docs] def on_complete(self, callback: Callable): """Set completion callback.""" self.complete_callback = callback return self
[docs] def build(self) -> Coroutine: """Build the coroutine chain.""" def _execute(): for step in self.steps: if step[0] == "tween": _, target, duration, easing = step yield from tween(self.obj, self.prop, target, duration, easing) elif step[0] == "wait": _, duration = step elapsed = 0.0 while elapsed < duration: dt = yield elapsed += dt or 0.0 if self.complete_callback: self.complete_callback() return _execute()
# ============================================================================ # tween() # ============================================================================
[docs] def tween( obj, prop: str, target, duration: float, easing=ease_linear, delay: float = 0, repeat: int = 1, on_step: Callable[[float], None] | None = None, on_repeat: Callable[[int], None] | None = None, on_complete: Callable | None = None, ) -> Coroutine: """Time-based property tween generator with callbacks and repeating. The coroutine driver sends ``dt`` (per-tick delta time in seconds) on every resume; this generator accumulates it as elapsed time, so the animation runs in real time regardless of frame rate. Args: obj: Object to animate. prop: Property name to animate. target: Target value. duration: Animation duration in seconds. easing: Easing function (default: linear). delay: Delay before starting animation in seconds. repeat: Number of times to repeat (1 = play once, 2 = repeat once, etc). on_step: Called each tick with current progress (0.0 to 1.0). on_repeat: Called when a repeat cycle completes, with repeat index. on_complete: Called when all repeats complete. Repeat semantics ---------------- The ``start`` value is captured once before the first iteration. Every repeat replays the same ``start -> target`` interpolation, snapping back to ``start`` at the beginning of each cycle. Matches Godot's ``tween_property().set_loops()``, DOTween's ``LoopType.Restart`` default, and GreenSock's default ``repeat`` behavior. For ping-pong or incremental modes, see the ``repeat_mode`` enum TODO entry. Example:: yield from tween( player, 'position', Vec3(10, 0, 0), 2.0, easing=ease_out_quad, delay=0.5, repeat=2, on_complete=lambda: print("Done!"), ) """ # Delay phase if delay > 0: elapsed = 0.0 while elapsed < delay: dt = yield elapsed += dt or 0.0 start = getattr(obj, prop) duration = max(duration, 0.0) for rep in range(repeat): elapsed = 0.0 # Set the start frame so listeners see t=0 before any time advances. setattr(obj, prop, _interpolate(start, target, easing(0.0))) if on_step: on_step(0.0) while True: t = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration) setattr(obj, prop, _interpolate(start, target, easing(t))) if on_step: on_step(t) if t >= 1.0: break dt = yield elapsed += dt or 0.0 if on_repeat and rep < repeat - 1: on_repeat(rep) if on_complete: on_complete()
# ============================================================================ # Tween namespace -- short-hand helpers # ============================================================================
[docs] class Tween: """Namespace for one-liner tween shorthands. These wrap :func:`tween` for the common case where the developer is just fading or tinting a node. They never construct a node, attach to a scene tree, or require an ``AnimationPlayer`` -- yield from the returned coroutine inside a ``Node`` lifecycle method (``run``, ``on_process``, etc.). Example:: # Fade a UI overlay to fully transparent over half a second. yield from Tween.alpha(overlay, 0.0, 0.5) # Cross-fade the modulate tint to red. yield from Tween.colour(sprite, (1.0, 0.2, 0.2, 1.0), 0.3) """
[docs] @staticmethod def alpha( node, target: float, duration: float, *, attr: str = "modulate", easing: Callable[[float], float] = ease_linear, callback: Callable | None = None, ) -> Coroutine: """Tween only the alpha channel of an RGB(A) colour attribute. ``Sprite2D`` exposes the channel as ``colour``; ``Sprite3D``, ``NinePatch``, ``MeshInstance2D``, etc. expose it as ``modulate``. Pass ``attr=`` when the node uses a different name. For 3-component sources the function transparently widens to RGBA so the alpha component can be animated; this requires the attribute to accept a 4-tuple (Colour Properties built with ``has_alpha=False`` will reject the widening and raise -- that's correct, you can't animate alpha on an alpha-less colour). """ current = getattr(node, attr) if not isinstance(current, tuple) or len(current) not in (3, 4): raise TypeError( f"Tween.alpha: {type(node).__name__}.{attr} must be an RGB(A) tuple, " f"got {type(current).__name__}" ) if len(current) == 3: current = (*current, 1.0) setattr(node, attr, current) start_alpha = float(current[3]) target_alpha = float(target) duration = max(duration, 0.0) elapsed = 0.0 while True: t = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration) eased = easing(t) a = start_alpha + (target_alpha - start_alpha) * eased base = getattr(node, attr) setattr(node, attr, (base[0], base[1], base[2], a)) if t >= 1.0: break dt = yield elapsed += dt or 0.0 if callback is not None: callback()
[docs] @staticmethod def colour( node, target: tuple, duration: float, *, attr: str = "modulate", easing: Callable[[float], float] = ease_linear, callback: Callable | None = None, ) -> Coroutine: """Tween a full RGB(A) tuple on ``node`` over ``duration`` seconds. Source and target are auto-aligned: a 3-tuple target against a 4-tuple source is widened with alpha=1.0; the inverse truncates. """ start = getattr(node, attr) if not isinstance(start, tuple) or len(start) not in (3, 4): raise TypeError( f"Tween.colour: {type(node).__name__}.{attr} must be an RGB(A) tuple, " f"got {type(start).__name__}" ) if not isinstance(target, tuple | list) or len(target) not in (3, 4): raise TypeError(f"Tween.colour: target must be an RGB(A) tuple, got {type(target).__name__}") n = len(start) if len(target) != n: target = tuple(target) + (1.0,) if len(target) < n else tuple(target[:n]) target = tuple(float(v) for v in target) duration = max(duration, 0.0) elapsed = 0.0 while True: t = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration) eased = easing(t) blended = tuple(start[i] + (target[i] - start[i]) * eased for i in range(n)) setattr(node, attr, blended) if t >= 1.0: break dt = yield elapsed += dt or 0.0 if callback is not None: callback()