Source code for simvx.core.animation.tween

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

from __future__ import annotations

import math
from collections.abc import Callable

from ..descriptors import Coroutine
from ._interpolate import _interpolate

# ============================================================================
# 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
# ============================================================================ # 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, fps: float = 60.0): """Add a wait step.""" self.steps.append(("wait", duration, fps)) return self
[docs] def on_complete(self, callback: Callable): """Set completion callback.""" self.complete_callback = callback return self
[docs] def build(self, fps: float = 60.0) -> Coroutine: """Build the coroutine chain. Args: fps: Frames per second for duration calculations. """ 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, fps=fps) elif step[0] == "wait": _, duration, wait_fps = step wait_frames = int(duration * wait_fps) for _ in range(wait_frames): yield 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, fps: float = 60.0, ) -> Coroutine: """Enhanced property tween generator with callbacks and repeating. Note: Uses frame-based timing (not real-time). Each yield represents one frame. Duration is converted to frame count based on fps parameter. 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 frame 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. fps: Frames per second for duration calculation (default: 60). 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!") ) """ 1.0 / fps # Delay phase (frame-based) if delay > 0: delay_frames = int(delay * fps) for _ in range(delay_frames): yield start = getattr(obj, prop) # Repeat loop for rep in range(repeat): total_frames = int(duration * fps) if total_frames <= 0: total_frames = 1 for frame in range(total_frames + 1): # +1 to ensure we hit t=1.0 t = min(1.0, frame / total_frames) eased_t = easing(t) setattr(obj, prop, _interpolate(start, target, eased_t)) if on_step: on_step(t) if t >= 1.0: break yield # Repeat callback if on_repeat and rep < repeat - 1: on_repeat(rep) # Completion callback if on_complete: on_complete()