Source code for simvx.core.coroutines
"""Coroutine helpers: parallel, wait, wait_until, wait_signal, next_frame.
Coroutines are driven by the scene tree with ``gen.send(dt)`` after the
first prime, so helpers that track time can read the per-tick delta via
``dt = yield`` and accumulate it. Helpers below follow that convention,
which means they correctly respect ``SceneTree.paused`` and time scale.
"""
from collections.abc import Callable
from typing import TYPE_CHECKING
from .descriptors import Coroutine
if TYPE_CHECKING:
from .signals import Signal
[docs]
def parallel(*coroutines: Coroutine) -> Coroutine:
"""Run multiple coroutines simultaneously, finish when all complete.
Forwards the per-tick ``dt`` received via ``yield`` to each child
coroutine, priming each on its first advance.
"""
primed: dict[int, bool] = {id(co): False for co in coroutines}
active = list(coroutines)
dt = 0.0
while active:
finished = []
for co in active:
try:
if primed[id(co)]:
co.send(dt)
else:
next(co)
primed[id(co)] = True
except StopIteration:
finished.append(co)
for co in finished:
active.remove(co)
if active:
dt = yield
[docs]
def wait(seconds: float) -> Coroutine:
"""Pause a coroutine for ``seconds`` of scene-tree time."""
elapsed = 0.0
while elapsed < seconds:
dt = yield
elapsed += dt or 0.0
[docs]
def wait_until(condition: Callable[[], bool]) -> Coroutine:
"""Yield until condition() returns True."""
while not condition():
yield
[docs]
def wait_signal(signal: Signal) -> Coroutine:
"""Yield until signal is emitted. Returns signal args."""
received = [None]
done = [False]
def _on_signal(*args):
received[0] = args
done[0] = True
signal.connect(_on_signal)
try:
while not done[0]:
yield
finally:
signal.disconnect(_on_signal)
return received[0]
[docs]
def next_frame() -> Coroutine:
"""Yield for exactly one frame."""
yield
# ============================================================================
# Damped-sine impulses ("juice") -- punch_position / punch_rotation
# ============================================================================
def _resolve_amplitude_vec2(amplitude):
"""Coerce a scalar/Vec2/tuple into a Vec2 amplitude."""
from .math.types import Vec2
if isinstance(amplitude, int | float):
return Vec2(float(amplitude), float(amplitude))
return Vec2(amplitude)
[docs]
def punch_position(
node,
amplitude,
duration: float,
*,
frequency: float = 12.0,
decay: float = 6.0,
attr: str = "position",
) -> Coroutine:
"""Damped-sine impulse on ``node.position``: arcade hit / screen-shake.
Maps to the classic ``A * sin(2pi * f * t) * exp(-d * t)`` formula used by
Balatro, Claustrowordia and Casual Crusade for card slams and tile hits.
The node's ``position`` is offset around its current value each tick and
restored exactly to the starting value on completion.
Args:
node: Node to shake. The named attribute must support both reads and
tuple/Vec2 writes; ``position`` is the common case.
amplitude: Peak displacement. Pass a scalar for a uniform shake
(`Vec2(amp, amp)`), or a ``Vec2`` / 2-tuple to bias the axis
(e.g. ``(0, 8)`` for vertical-only).
duration: Total duration in seconds. After this many seconds the
position is snapped back to the starting value.
frequency: Oscillations per second (default 12 Hz -- arcade-feel).
decay: Exponential decay rate per second (default 6.0).
attr: Position attribute name on ``node`` (default ``"position"``).
Yields:
Per-tick ``dt`` from the coroutine driver.
"""
import math as _math
from .math.types import Vec2
amp = _resolve_amplitude_vec2(amplitude)
duration = max(float(duration), 0.0)
base = Vec2(getattr(node, attr))
elapsed = 0.0
try:
while elapsed < duration:
envelope = _math.exp(-decay * elapsed)
wave = _math.sin(2.0 * _math.pi * frequency * elapsed)
setattr(node, attr, base + amp * (envelope * wave))
dt = yield
elapsed += dt or 0.0
finally:
setattr(node, attr, base)
[docs]
def punch_rotation(
node,
amplitude: float,
duration: float,
*,
frequency: float = 12.0,
decay: float = 6.0,
attr: str = "rotation",
) -> Coroutine:
"""Damped-sine impulse on ``node.rotation`` (radians).
Twin of :func:`punch_position` for angular hits -- the camera shake
rotation, the card-flip wobble, the tile-press lean. Pass ``amplitude``
in **radians**.
Args:
node: Node to shake. The attribute must support float read/write.
amplitude: Peak angular displacement in radians.
duration: Total duration in seconds.
frequency: Oscillations per second (default 12 Hz).
decay: Exponential decay rate per second (default 6.0).
attr: Rotation attribute name on ``node`` (default ``"rotation"``).
Yields:
Per-tick ``dt`` from the coroutine driver.
"""
import math as _math
amp = float(amplitude)
duration = max(float(duration), 0.0)
base = float(getattr(node, attr))
elapsed = 0.0
try:
while elapsed < duration:
envelope = _math.exp(-decay * elapsed)
wave = _math.sin(2.0 * _math.pi * frequency * elapsed)
setattr(node, attr, base + amp * envelope * wave)
dt = yield
elapsed += dt or 0.0
finally:
setattr(node, attr, base)