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