Source code for core.tween

"""Tween/easing system for declarative property animation.

Usage from a user script::

    class MyScript:
        def on_start(self):
            # Animate transform.x to 500 over 1 second with ease_out_cubic
            self.tween(self.entity, "transform.x", target=500, duration=1.0,
                       easing=ease_out_cubic)

            # Animate with explicit start value
            self.tween(self.entity, "transform.rotation", start=0, target=360,
                       duration=2.0, easing=ease_in_out_quad, loops=0)

The ``ScriptSystem`` ticks the tween manager each frame via
``TweenManager.tick(dt)``.
"""
from __future__ import annotations
import math
from core.logger import get_logger

_tw_logger = get_logger("tween")


# ---------------------------------------------------------------------------
# Easing functions  (t: 0..1 -> 0..1)
# ---------------------------------------------------------------------------

[docs] def ease_linear(t: float) -> float: return t
[docs] def ease_in_quad(t: float) -> float: return t * t
[docs] def ease_out_quad(t: float) -> float: return t * (2.0 - t)
[docs] def ease_in_out_quad(t: float) -> float: if t < 0.5: return 2.0 * t * t return -1.0 + (4.0 - 2.0 * t) * t
[docs] def ease_in_cubic(t: float) -> float: return t * t * t
[docs] def ease_out_cubic(t: float) -> float: t -= 1.0 return t * t * t + 1.0
[docs] def ease_in_out_cubic(t: float) -> float: if t < 0.5: return 4.0 * t * t * t t -= 1.0 return 1.0 + 4.0 * t * t * t
[docs] def ease_in_elastic(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5.0 * math.pi)
[docs] def ease_out_elastic(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5.0 * math.pi) + 1.0
[docs] def ease_in_out_elastic(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 if t < 0.5: return -0.5 * math.pow(2, 10 * (2 * t - 1)) * math.sin((2 * t - 1.1) * 5.0 * math.pi) return 0.5 * math.pow(2, -10 * (2 * t - 1)) * math.sin((2 * t - 1.1) * 5.0 * math.pi) + 1.0
[docs] def ease_in_bounce(t: float) -> float: return 1.0 - ease_out_bounce(1.0 - t)
[docs] def ease_out_bounce(t: float) -> float: if t < 1.0 / 2.75: return 7.5625 * t * t elif t < 2.0 / 2.75: t -= 1.5 / 2.75 return 7.5625 * t * t + 0.75 elif t < 2.5 / 2.75: t -= 2.25 / 2.75 return 7.5625 * t * t + 0.9375 else: t -= 2.625 / 2.75 return 7.5625 * t * t + 0.984375
[docs] def ease_in_out_bounce(t: float) -> float: if t < 0.5: return 0.5 * ease_in_bounce(2.0 * t) return 0.5 * ease_out_bounce(2.0 * t - 1.0) + 0.5
[docs] def ease_in_back(t: float) -> float: s = 1.70158 return t * t * ((s + 1.0) * t - s)
[docs] def ease_out_back(t: float) -> float: s = 1.70158 t -= 1.0 return t * t * ((s + 1.0) * t + s) + 1.0
[docs] def ease_in_out_back(t: float) -> float: s = 1.70158 * 1.525 t *= 2.0 if t < 1.0: return 0.5 * (t * t * ((s + 1.0) * t - s)) t -= 2.0 return 0.5 * (t * t * ((s + 1.0) * t + s) + 2.0)
# --------------------------------------------------------------------------- # Tween data # --------------------------------------------------------------------------- class _Tween: __slots__ = ("entity", "attr_path", "start_val", "end_val", "duration", "elapsed", "easing", "on_complete", "loops", "loop_count", "yoyo", "_forward") def __init__(self, entity, attr_path: str, start_val: float, end_val: float, duration: float, easing, on_complete, loops: int, yoyo: bool): self.entity = entity self.attr_path = attr_path self.start_val = start_val self.end_val = end_val self.duration = max(0.001, duration) self.elapsed = 0.0 self.easing = easing self.on_complete = on_complete self.loops = loops # -1 = infinite, 0 = play once, N = repeat N extra times self.loop_count = 0 self.yoyo = yoyo self._forward = True # --------------------------------------------------------------------------- # Tween manager # --------------------------------------------------------------------------- def _resolve_attr(obj, path: str): """Resolve a dotted attribute path like 'transform.x' on *obj*. Returns (parent_obj, final_attr_name) or (None, None) on failure.""" parts = path.split(".") current = obj for part in parts[:-1]: current = getattr(current, part, None) if current is None: # Try component lookup if hasattr(obj, "get_component"): from core.components import Transform _component_map = {"transform": Transform} comp_type = _component_map.get(part.lower()) if comp_type: current = obj.get_component(comp_type) if current is None: return None, None return current, parts[-1]
[docs] class TweenManager: """Manages a set of active tweens. Typically one per entity or global.""" def __init__(self): self._tweens: list[_Tween] = []
[docs] def tween(self, entity, attr_path: str, target: float, start: float | None = None, duration: float = 1.0, easing=None, on_complete=None, loops: int = 0, yoyo: bool = False): """Create and register a new tween. Args: entity: The entity whose property to animate. attr_path: Dotted path to the property, e.g. ``"transform.x"``. target: Target value. start: Start value. If ``None``, the current value is read. duration: Animation duration in seconds. easing: Easing function ``(t) -> t``. Defaults to ``ease_linear``. on_complete: Optional callback invoked when the tween finishes. loops: ``0`` = play once, ``-1`` = infinite, ``N`` = repeat N extra times. yoyo: If True, alternates direction on each loop. """ if easing is None: easing = ease_linear if start is None: parent, attr = _resolve_attr(entity, attr_path) if parent is not None: start = float(getattr(parent, attr, 0.0)) else: start = 0.0 tw = _Tween(entity, attr_path, float(start), float(target), duration, easing, on_complete, loops, yoyo) self._tweens.append(tw) return tw
[docs] def cancel_all(self, entity=None): """Cancel tweens. If *entity* is given, cancel only that entity's tweens.""" if entity is None: self._tweens.clear() else: self._tweens = [tw for tw in self._tweens if tw.entity is not entity]
@property def count(self) -> int: return len(self._tweens)
[docs] def tick(self, dt: float): """Advance all tweens by *dt* seconds. Call once per frame.""" still_alive: list[_Tween] = [] for tw in self._tweens: tw.elapsed += dt t = min(tw.elapsed / tw.duration, 1.0) eased = tw.easing(t) if tw.yoyo and not tw._forward: value = tw.end_val + (tw.start_val - tw.end_val) * eased else: value = tw.start_val + (tw.end_val - tw.start_val) * eased # Apply value parent, attr = _resolve_attr(tw.entity, tw.attr_path) if parent is not None: try: setattr(parent, attr, value) except Exception as e: _tw_logger.error("Tween set failed", path=tw.attr_path, error=str(e)) continue # drop this tween if t >= 1.0: # Loop handling if tw.loops == -1 or tw.loop_count < tw.loops: tw.loop_count += 1 tw.elapsed = 0.0 if tw.yoyo: tw._forward = not tw._forward still_alive.append(tw) else: # Finished if tw.on_complete: try: tw.on_complete() except Exception as e: _tw_logger.error("Tween on_complete error", error=str(e)) else: still_alive.append(tw) self._tweens = still_alive