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"]