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