"""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()