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