Source code for simvx.core.nodes_2d.spring_follow
"""SpringFollow2D -- critically-damped spring follow toward a target Vec2."""
import logging
from ..descriptors import Property
from ..math.types import Vec2
from ..properties import NodePath as NodePathProp
from .node2d import Node2D
log = logging.getLogger(__name__)
[docs]
class SpringFollow2D(Node2D):
"""Critically-damped spring follow toward a target Vec2.
Solitaire, Casual Crusade, Claustrowordia, and Balatro-Feel all
re-implemented "card slides toward its dealt slot" by hand; this node
is the lower-level primitive those games each want, sitting one step
below :class:`Pile` (which adds slot anchors, fans, hit zones, etc.).
The follower's ``position`` is integrated each frame toward
:attr:`target_position`. A critically-damped spring is used: no
oscillation, asymptotic approach, all controlled by a single
:attr:`half_life` parameter (seconds for the gap to halve).
Two ways to drive the target:
1. **Direct write** -- assign to :attr:`target_position` from outside.
2. **Track another node** -- set :attr:`target_path` to a node path
(e.g. ``"../HandSlot"``); the follower will read that node's
``position`` each frame.
Direct write wins when both are set (so animations can override the
tracked node temporarily). Set ``target_path=""`` to clear tracking.
Example::
slot = Node2D(name="HandSlot", position=(400, 300))
card = SpringFollow2D(name="Card3H", position=(0, 0), half_life=0.12)
card.target_path = "../HandSlot"
Implementation uses the critically-damped spring formulation::
omega = 1.67835 / half_life # gap halves every `half_life` seconds
a = (target - position) * omega**2 - velocity * 2 * omega
velocity += a * dt
position += velocity * dt
The constant ``1.67835`` is the numerical solution of
``(1 + omega*t) * exp(-omega*t) == 0.5`` -- the envelope of a
critically-damped spring -- so ``half_life`` is an honest "seconds
for the gap to halve" knob. Stable at typical frame rates without
sub-stepping.
"""
target_position = Property(
(0.0, 0.0),
hint="Target position the follower springs toward (write-through).",
)
target_path = NodePathProp(
"",
type_filter=Node2D,
hint="Optional path to a Node2D whose position becomes the target.",
)
half_life = Property(
0.12,
range=(0.001, 5.0),
hint="Seconds for the gap to halve (smaller = snappier).",
)
enabled = Property(True, hint="Disable the spring without removing the node.")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._velocity: Vec2 = Vec2(0.0, 0.0)
self._cached_target: Node2D | None = None
self._cached_path: str = ""
[docs]
@property
def velocity(self) -> Vec2:
"""Current per-second velocity of the follower (read-only)."""
return Vec2(self._velocity)
[docs]
def reset(self, position=None) -> None:
"""Snap to ``position`` (or the current target) with zero velocity."""
if position is None:
position = self._effective_target()
self.position = Vec2(position)
self._velocity = Vec2(0.0, 0.0)
def _resolve_target_node(self) -> Node2D | None:
path = self.target_path
if not path:
self._cached_target = None
self._cached_path = ""
return None
if path == self._cached_path and self._cached_target is not None:
if self._cached_target._tree is not None:
return self._cached_target
try:
node = self.get_node(path)
except (ValueError, KeyError, AttributeError):
if path != self._cached_path:
log.warning("SpringFollow2D '%s': invalid target_path '%s'", self.name, path)
self._cached_target = None
self._cached_path = path
return None
if not isinstance(node, Node2D):
if path != self._cached_path:
log.warning("SpringFollow2D '%s': target '%s' is not a Node2D", self.name, path)
self._cached_target = None
self._cached_path = path
return None
self._cached_target = node
self._cached_path = path
return node
def _effective_target(self) -> Vec2:
"""Pick the live target: tracked node beats target_position when set."""
tgt_node = self._resolve_target_node()
if tgt_node is not None:
return Vec2(tgt_node.position)
return Vec2(self.target_position)
[docs]
def on_process(self, dt: float) -> None:
if not self.enabled or dt <= 0.0:
return
target = self._effective_target()
half_life = max(float(self.half_life), 1e-4)
# Solution of (1 + omega*t) * exp(-omega*t) == 0.5 -- gives an honest
# "gap halves every half_life seconds" semantic for critical damping.
omega = 1.6783469900166612 / half_life
# Critically-damped spring: integrate acceleration toward target.
gap = target - self.position
accel = gap * (omega * omega) - self._velocity * (2.0 * omega)
self._velocity = self._velocity + accel * dt
self.position = self.position + self._velocity * dt