nodes/card.py¶
Part of Balatro Feel.
1"""Card node: interactive root + smoothly-following visual.
2
3Two nodes, one card, split so that neither knows how the other works:
4
5 Card (Node2D)
6 holds the *target* (slot or drag) position and the interaction state.
7 Emits a Signal for every state change; it never touches the visual.
8
9 CardVisual (Node2D)
10 connects to those signals in its constructor and answers each one with
11 juice: spring-following lerps, tilt, punch, scale, draw-order.
12
13That split is the point of the example: the juice layer can be rewritten,
14duplicated, or dropped without editing a line of card logic.
15"""
16
17from __future__ import annotations
18
19import math
20
21from simvx.core import Node2D, Signal, Sprite2D
22from simvx.core.coroutines import wait
23from simvx.core.input.state import Input
24from simvx.core.math.types import Vec2
25
26from .card_textures import (
27 CARD_H,
28 CARD_W,
29 CardId,
30 get_card_face,
31 get_shadow,
32)
33
34# Tunables (named after the Unity SerializedFields so the mapping stays clear)
35FOLLOW_SPEED = 30.0
36ROTATION_AMOUNT = 0.045
37ROTATION_SPEED = 18.0
38ROT_CLAMP = math.radians(35)
39TILT_AUTO_AMOUNT = math.radians(2.5)
40TILT_MANUAL_AMOUNT = 0.0024
41TILT_SPEED = 12.0
42
43SCALE_HOVER = 1.10
44SCALE_SELECT = 1.18
45SCALE_TRANSITION = 0.18
46
47HOVER_PUNCH_ANGLE = math.radians(6)
48HOVER_PUNCH_DURATION = 0.32
49SELECT_PUNCH_AMPLITUDE = 28.0
50SELECT_PUNCH_DURATION = 0.45
51SWAP_PUNCH_ANGLE = math.radians(20)
52SWAP_PUNCH_DURATION = 0.28
53
54SHADOW_OFFSET_REST = Vec2(0, 22)
55SHADOW_OFFSET_PRESS = Vec2(0, 6)
56SHADOW_LERP = 14.0
57
58DRAG_SPEED_LIMIT = 5400.0 # px/sec, higher than Unity's 50 because we work in pixels
59SELECTION_LIFT = 70.0
60
61# Draw-order elevation (sibling z_index; higher = on top). Resting cards stay at
62# 0 so they keep slot order; an active card lifts above its neighbours.
63Z_REST = 0
64Z_HOVER = 100
65Z_SELECTED = 200
66Z_DRAGGING = 300
67
68
69def _decaying_sine(t: float, oscillations: float = 6.0) -> float:
70 """Damped sine in [0,1] -> oscillating value, fades to 0 at t=1."""
71 if t >= 1.0:
72 return 0.0
73 return math.sin(t * math.pi * 2 * oscillations) * (1.0 - t) ** 2
74
75
76class CardVisual(Node2D):
77 """Springs toward its Card each frame and answers the Card's signals with juice."""
78
79 def __init__(self, card: Card, index: int = 0) -> None:
80 super().__init__(name="CardVisual")
81 self._card = card
82 self._index = index
83 self._fan_rotation = 0.0 # set by HandHolder when slot index changes
84 # Spring state
85 self._spring_pos = Vec2(card.position.x, card.position.y)
86 self._move_delta = Vec2(0, 0)
87 self._rot_delta = 0.0
88 self._tilt = Vec2(0, 0)
89 self._scale_target = 1.0
90 self._scale_current = 1.0
91 # Punch overlays (additive on top of spring)
92 self._punch_pos = Vec2(0, 0)
93 self._punch_rot = 0.0
94 # Shadow offset (animates on press/release)
95 self._shadow_pos = Vec2(SHADOW_OFFSET_REST.x, SHADOW_OFFSET_REST.y)
96 self._shadow_target = Vec2(SHADOW_OFFSET_REST.x, SHADOW_OFFSET_REST.y)
97
98 # Every juice trigger arrives as a Card signal: the Card never calls into
99 # the visual, so the card logic runs identically with no visual attached.
100 card.pointer_enter.connect(self.hover_in)
101 card.pointer_exit.connect(self.hover_out)
102 card.pointer_down.connect(self.press)
103 card.pointer_up.connect(self.release)
104 card.select_changed.connect(self._on_select_changed)
105 card.drag_started.connect(self.begin_drag)
106 card.drag_ended.connect(self.end_drag)
107
108 def on_ready(self) -> None:
109 # Build sprites once tree is mounted so width/height auto-sync via Sprite2D.
110 self.shadow_sprite = self.add_child(
111 Sprite2D(
112 texture=get_shadow(),
113 width=CARD_W + 36,
114 height=CARD_H + 36,
115 colour=(0, 0, 0, 1.0),
116 name="Shadow",
117 )
118 )
119 self.face_sprite = self.add_child(
120 Sprite2D(
121 texture=get_card_face(self._card.card_id),
122 width=CARD_W,
123 height=CARD_H,
124 name="Face",
125 )
126 )
127
128 # ------------------------------------------------------------------
129 # Per-frame spring follow
130 # ------------------------------------------------------------------
131 def on_update(self, dt: float) -> None:
132 card = self._card
133 target = card.position + Vec2(0, -SELECTION_LIFT if card.selected else 0)
134
135 # Position spring
136 a = min(1.0, FOLLOW_SPEED * dt)
137 new_pos = Vec2(
138 self._spring_pos.x + (target.x - self._spring_pos.x) * a,
139 self._spring_pos.y + (target.y - self._spring_pos.y) * a,
140 )
141 movement = new_pos - self._spring_pos
142 self._spring_pos = new_pos
143
144 # Rotation follows lateral velocity (the "tilt-as-it-moves" feel)
145 b = min(1.0, 25.0 * dt)
146 self._move_delta = Vec2(
147 self._move_delta.x + (movement.x - self._move_delta.x) * b,
148 self._move_delta.y + (movement.y - self._move_delta.y) * b,
149 )
150 # Fan rotation: a baseline tilt per slot, modulated by movement.
151 # When the card is being dragged, the fan baseline drops out so the
152 # rotation reads as raw "I'm being moved by the user".
153 fan = 0.0 if card.is_dragging or card.selected else self._fan_rotation
154 movement_rot = (self._move_delta.x if card.is_dragging else movement.x) * ROTATION_AMOUNT
155 movement_rot = max(-ROT_CLAMP, min(ROT_CLAMP, movement_rot))
156 target_rot = fan + movement_rot
157 c = min(1.0, ROTATION_SPEED * dt)
158 self._rot_delta = self._rot_delta + (target_rot - self._rot_delta) * c
159
160 # Hover tilt: the "look-at-cursor" parallax
161 if card.is_hovering and not card.is_dragging:
162 mp = Input.mouse_position
163 offset = self._spring_pos - mp
164 tilt_target = Vec2(
165 -offset.y * TILT_MANUAL_AMOUNT,
166 offset.x * TILT_MANUAL_AMOUNT,
167 )
168 else:
169 tilt_target = Vec2(0, 0)
170 # Auto-wobble baseline (sine on x, cosine on y, dampened on hover)
171 t_now = card._time
172 wobble = 0.2 if card.is_hovering else 1.0
173 wobble_x = math.sin(t_now * 1.3 + self._index) * TILT_AUTO_AMOUNT * wobble
174 wobble_y = math.cos(t_now * 1.3 + self._index) * TILT_AUTO_AMOUNT * wobble
175 ts = min(1.0, TILT_SPEED * dt)
176 self._tilt = Vec2(
177 self._tilt.x + (tilt_target.x + wobble_x - self._tilt.x) * ts,
178 self._tilt.y + (tilt_target.y + wobble_y - self._tilt.y) * ts,
179 )
180
181 # Scale spring (toward _scale_target set by hover/select/drag)
182 ss = min(1.0, 16.0 * dt)
183 self._scale_current += (self._scale_target - self._scale_current) * ss
184
185 # Shadow offset spring
186 sl = min(1.0, SHADOW_LERP * dt)
187 self._shadow_pos = Vec2(
188 self._shadow_pos.x + (self._shadow_target.x - self._shadow_pos.x) * sl,
189 self._shadow_pos.y + (self._shadow_target.y - self._shadow_pos.y) * sl,
190 )
191
192 # Draw order: an active card lifts in front of its neighbours, resting
193 # cards keep slot order (z 0). Drag > select > hover > rest.
194 if card.is_dragging:
195 z = Z_DRAGGING
196 elif card.selected:
197 z = Z_SELECTED
198 elif card.is_hovering:
199 z = Z_HOVER
200 else:
201 z = Z_REST
202 if self.z_index != z:
203 self.z_index = z
204
205 # Apply to children
206 # Translate this CardVisual to the spring position; sprites centre on local 0.
207 # Rotation = follow rotation + tilt-z
208 self.position = self._spring_pos + self._punch_pos
209 self.rotation = self._rot_delta + self._tilt.x * 0.4 + self._punch_rot
210 self.scale = Vec2(self._scale_current, self._scale_current)
211 if hasattr(self, "shadow_sprite"):
212 self.shadow_sprite.position = self._shadow_pos
213 self.shadow_sprite.scale = Vec2(1.0, 1.0)
214 self.face_sprite.position = Vec2(0, 0)
215
216 # ------------------------------------------------------------------
217 # Juice triggers: each one is connected to the matching Card signal
218 # ------------------------------------------------------------------
219 def hover_in(self) -> None:
220 self._scale_target = SCALE_HOVER
221 self.start_coroutine(self._punch_rotation(HOVER_PUNCH_ANGLE, HOVER_PUNCH_DURATION))
222
223 def hover_out(self) -> None:
224 if not self._card.was_dragged:
225 self._scale_target = 1.0
226
227 def press(self) -> None:
228 self._scale_target = SCALE_SELECT
229 self._shadow_target = SHADOW_OFFSET_PRESS
230
231 def release(self, long_press: bool) -> None:
232 self._scale_target = SCALE_HOVER if long_press else SCALE_SELECT
233 self._shadow_target = SHADOW_OFFSET_REST
234
235 def begin_drag(self) -> None:
236 self._scale_target = SCALE_SELECT
237
238 def end_drag(self) -> None:
239 self._scale_target = 1.0
240 self._shadow_target = SHADOW_OFFSET_REST
241
242 def _on_select_changed(self, selected: bool) -> None:
243 self.select_punch(1.0 if selected else -1.0)
244
245 def select_punch(self, dir_sign: float) -> None:
246 self.start_coroutine(self._punch_position(Vec2(0, -SELECT_PUNCH_AMPLITUDE * dir_sign), SELECT_PUNCH_DURATION))
247 self.start_coroutine(self._punch_rotation(HOVER_PUNCH_ANGLE * 0.7 * dir_sign, HOVER_PUNCH_DURATION * 1.2))
248
249 def swap_punch(self, dir_sign: float) -> None:
250 self.start_coroutine(self._punch_rotation(SWAP_PUNCH_ANGLE * dir_sign, SWAP_PUNCH_DURATION))
251
252 # ------------------------------------------------------------------
253 # Damped-sine punch coroutines (DOPunch* equivalent)
254 # ------------------------------------------------------------------
255 def _punch_position(self, amplitude: Vec2, duration: float, oscillations: float = 4.0):
256 elapsed = 0.0
257 while elapsed < duration:
258 t = elapsed / duration
259 k = _decaying_sine(t, oscillations)
260 self._punch_pos = Vec2(amplitude.x * k, amplitude.y * k)
261 dt = yield
262 elapsed += dt or 0.0
263 self._punch_pos = Vec2(0, 0)
264
265 def _punch_rotation(self, amplitude: float, duration: float, oscillations: float = 5.0):
266 elapsed = 0.0
267 while elapsed < duration:
268 t = elapsed / duration
269 self._punch_rot = amplitude * _decaying_sine(t, oscillations)
270 dt = yield
271 elapsed += dt or 0.0
272 self._punch_rot = 0.0
273
274 # ------------------------------------------------------------------
275 # Score-juice play sequence: pulse + lift the card
276 # ------------------------------------------------------------------
277 def play_pulse(self):
278 # Punch up + sparkle scale
279 self.start_coroutine(self._punch_position(Vec2(0, -90), 0.55, oscillations=2.5))
280 self.start_coroutine(self._scale_pulse())
281
282 def _scale_pulse(self):
283 prev = self._scale_target
284 self._scale_target = SCALE_SELECT * 1.15
285 yield from wait(0.18)
286 self._scale_target = prev
287
288
289class Card(Node2D):
290 """Interactive card state. Tracks hover/drag/select and announces every change."""
291
292 pointer_enter = Signal()
293 pointer_exit = Signal()
294 pointer_down = Signal()
295 pointer_up = Signal(bool) # long_press
296 select_changed = Signal(bool) # selected
297 drag_started = Signal()
298 drag_ended = Signal()
299
300 def __init__(self, card_id: CardId, slot_index: int = 0) -> None:
301 super().__init__(name=f"Card({card_id})")
302 self.card_id = card_id
303 self.slot_index = slot_index
304 self.is_hovering = False
305 self.is_dragging = False
306 self.was_dragged = False
307 self.selected = False
308 self._press_time: float | None = None
309 self._press_pos = Vec2(0, 0) # mouse position at press, for drag-slop test
310 self._drag_offset = Vec2(0, 0)
311 self._target_position = Vec2(0, 0)
312 self._time = 0.0
313 # Hit rectangle, in the holder's (screen) space
314 self._aabb_half = Vec2(CARD_W * 0.5, CARD_H * 0.5)
315 # Set by the holder once it has built the matching CardVisual. The Card
316 # itself never calls into it: the visual listens to the signals below.
317 self.visual: CardVisual | None = None
318
319 @property
320 def target_position(self) -> Vec2:
321 return self._target_position
322
323 @target_position.setter
324 def target_position(self, pos: Vec2) -> None:
325 self._target_position = pos
326
327 def on_update(self, dt: float) -> None:
328 self._time += dt
329 # Mouse-driven drag follow (speed-limited, like Unity moveSpeedLimit)
330 if self.is_dragging:
331 mouse = Input.mouse_position
332 target = mouse - self._drag_offset
333 delta = target - self.position
334 d = max(1e-6, delta.length())
335 max_step = DRAG_SPEED_LIMIT * dt
336 step = min(d, max_step)
337 self.position = self.position + (delta / d) * step
338 else:
339 # Lerp toward target slot
340 lerp = min(1.0, 22.0 * dt)
341 new_pos = self.position + (self._target_position - self.position) * lerp
342 self.position = new_pos
343
344 def contains(self, pos: Vec2) -> bool:
345 """Hit-test: is `pos` over this card's visual rectangle right now?"""
346 hit_centre = self.position + Vec2(0, -SELECTION_LIFT if self.selected else 0)
347 return abs(pos.x - hit_centre.x) <= self._aabb_half.x and abs(pos.y - hit_centre.y) <= self._aabb_half.y
348
349 def set_hover(self, on: bool) -> None:
350 if on and not self.is_hovering:
351 self.is_hovering = True
352 self.pointer_enter()
353 elif not on and self.is_hovering:
354 self.is_hovering = False
355 self.pointer_exit()
356
357 # Routed by HandHolder: Card itself doesn't handle clicks directly so the
358 # holder can resolve "topmost card under cursor" first.
359 def handle_press(self, mouse_pos: Vec2) -> None:
360 self._press_time = self._time
361 self._press_pos = mouse_pos
362 self.pointer_down()
363
364 def handle_release(self) -> bool:
365 """Return True if this counted as a click (short press, no drag)."""
366 long_press = self._press_time is not None and (self._time - self._press_time) > 0.2
367 self.pointer_up(long_press)
368 click = (not long_press) and (not self.was_dragged)
369 if click:
370 self.toggle_select()
371 self._press_time = None
372 return click
373
374 def start_drag(self, mouse_pos: Vec2) -> None:
375 self.is_dragging = True
376 self.was_dragged = True
377 self._drag_offset = mouse_pos - self.position
378 self.drag_started()
379
380 def stop_drag(self) -> None:
381 self.is_dragging = False
382 self._press_time = None
383 self.drag_ended()
384 # Clear was_dragged on the *next* frame (so click suppression still fires)
385 self.start_coroutine(self._clear_was_dragged())
386
387 def _clear_was_dragged(self):
388 yield # next frame
389 self.was_dragged = False
390
391 def toggle_select(self) -> None:
392 self.selected = not self.selected
393 self.select_changed(self.selected)
394
395 def deselect(self) -> None:
396 if self.selected:
397 self.selected = False
398 self.select_changed(False)
399
400
401__all__ = ["Card", "CardVisual", "CardId"]