nodes/card_node.pyΒΆ

Part of Klondike Solitaire.

  1"""CardNode -- visual representation of a single card.
  2
  3One node per physical card. The node is *always* a child of the table root --
  4its parent never changes when the card moves between piles. Instead the
  5``GameState`` is the source of truth, and each frame the layout pass tells each
  6``CardNode`` its target ``(x, y, z, face_up)``. The node springs toward that
  7target, applies a slight tilt during motion, and renders a drop shadow.
  8
  9Hit-testing uses a stateless AABB so the table can resolve "topmost card under
 10cursor" exactly like Balatro's HandHolder.
 11"""
 12
 13from __future__ import annotations
 14
 15import math
 16
 17from simvx.core import Node2D, Sprite2D
 18from simvx.core.math.types import Vec2
 19
 20from .card_textures import (
 21    CARD_H,
 22    CARD_W,
 23    CardId,
 24    get_card_back,
 25    get_card_face,
 26    get_shadow,
 27)
 28
 29# Spring tuning -- crisp but smooth.
 30FOLLOW_SPEED = 22.0
 31ROT_TILT_SCALE = 0.06  # radians per pixel of x-velocity (clamped)
 32ROT_DAMP = 14.0
 33ROT_CLAMP = math.radians(15)
 34SHADOW_REST = Vec2(2, 8)
 35SHADOW_DRAG = Vec2(4, 18)
 36SHADOW_SPEED = 12.0
 37
 38
 39class CardNode(Node2D):
 40    """A draggable, springy visual for one card.
 41
 42    The owning ``TableNode`` calls :meth:`set_target` each frame and
 43    :meth:`set_face` whenever the underlying ``CardState.face_up`` flips. The
 44    node never mutates game state itself.
 45    """
 46
 47    def __init__(self, card_id: CardId, name: str | None = None) -> None:
 48        super().__init__(name=name or f"Card({card_id})")
 49        self.card_id = card_id
 50        self._face_up = False
 51        self._target_pos = Vec2(0, 0)
 52        self._spring_pos = Vec2(0, 0)
 53        self._velocity = Vec2(0, 0)
 54        self._tilt = 0.0
 55        self._shadow_offset = Vec2(SHADOW_REST.x, SHADOW_REST.y)
 56        self._shadow_target = Vec2(SHADOW_REST.x, SHADOW_REST.y)
 57        # Rendering ordering -- higher draws on top. Table sets this per frame.
 58        self._depth = 0
 59        self._is_dragging = False
 60        self._aabb_half = Vec2(CARD_W * 0.5, CARD_H * 0.5)
 61
 62    # ------------------------------------------------------------ build
 63    def on_ready(self) -> None:
 64        self.shadow = self.add_child(
 65            Sprite2D(
 66                texture=get_shadow(),
 67                width=CARD_W + 24,
 68                height=CARD_H + 24,
 69                colour=(0, 0, 0, 1.0),
 70                name="Shadow",
 71            )
 72        )
 73        self.face = self.add_child(
 74            Sprite2D(
 75                texture=get_card_back(),
 76                width=CARD_W,
 77                height=CARD_H,
 78                name="Face",
 79            )
 80        )
 81        self.set_face(self._face_up)
 82
 83    # ------------------------------------------------------------ public API
 84    def set_target(self, pos: Vec2, depth: int = 0, snap: bool = False) -> None:
 85        self._target_pos = pos
 86        self._depth = depth
 87        # Drive engine z-order from logical depth so render matches game state.
 88        self.z_index = depth
 89        if snap:
 90            self._spring_pos = Vec2(pos.x, pos.y)
 91
 92    def set_face(self, face_up: bool) -> None:
 93        if face_up == self._face_up and hasattr(self, "face"):
 94            return
 95        self._face_up = face_up
 96        if hasattr(self, "face"):
 97            self.face.texture = get_card_face(self.card_id) if face_up else get_card_back()
 98
 99    def begin_drag(self) -> None:
100        self._is_dragging = True
101        self._shadow_target = Vec2(SHADOW_DRAG.x, SHADOW_DRAG.y)
102
103    def end_drag(self) -> None:
104        self._is_dragging = False
105        self._shadow_target = Vec2(SHADOW_REST.x, SHADOW_REST.y)
106
107    @property
108    def face_up(self) -> bool:
109        return self._face_up
110
111    @property
112    def depth(self) -> int:
113        return self._depth
114
115    def contains(self, pos: Vec2) -> bool:
116        return abs(pos.x - self.position.x) <= self._aabb_half.x and abs(pos.y - self.position.y) <= self._aabb_half.y
117
118    # ------------------------------------------------------------ per-frame
119    def on_update(self, dt: float) -> None:
120        a = min(1.0, FOLLOW_SPEED * dt)
121        new_pos = Vec2(
122            self._spring_pos.x + (self._target_pos.x - self._spring_pos.x) * a,
123            self._spring_pos.y + (self._target_pos.y - self._spring_pos.y) * a,
124        )
125        self._velocity = Vec2(
126            (new_pos.x - self._spring_pos.x) / max(dt, 1e-3),
127            (new_pos.y - self._spring_pos.y) / max(dt, 1e-3),
128        )
129        self._spring_pos = new_pos
130
131        # Movement-driven tilt
132        target_tilt = max(-ROT_CLAMP, min(ROT_CLAMP, self._velocity.x * ROT_TILT_SCALE * 0.01))
133        b = min(1.0, ROT_DAMP * dt)
134        self._tilt += (target_tilt - self._tilt) * b
135
136        # Shadow spring
137        c = min(1.0, SHADOW_SPEED * dt)
138        self._shadow_offset = Vec2(
139            self._shadow_offset.x + (self._shadow_target.x - self._shadow_offset.x) * c,
140            self._shadow_offset.y + (self._shadow_target.y - self._shadow_offset.y) * c,
141        )
142
143        self.position = self._spring_pos
144        self.rotation = self._tilt
145        if hasattr(self, "shadow"):
146            self.shadow.position = self._shadow_offset
147            self.face.position = Vec2(0, 0)
148
149
150__all__ = ["CardNode"]