nodes/hand_holder.pyΒΆ

Part of Balatro Feel.

  1"""HorizontalHandHolder: owns slots, dispatches input, animates the fan curve.
  2
  3Responsibilities:
  4- Lays out N slots horizontally with a fan curve (vertical offset + base rotation per index).
  5- Resolves the topmost card under the cursor each frame (Card itself doesn't grab clicks).
  6- Drives drag/swap: when the dragged card crosses a sibling, slots swap with a punch.
  7- Exposes the hand verbs (play, clear, discard, deal) so keys and buttons share one path.
  8"""
  9
 10from __future__ import annotations
 11
 12import math
 13
 14from simvx.core import Input, Node2D, Vec2
 15
 16from .card import Card, CardVisual
 17from .card_textures import CARD_W, CardId
 18
 19
 20class HorizontalHandHolder(Node2D):
 21    """A horizontal hand of cards with fan curve and drag-to-swap."""
 22
 23    SLOT_SPACING = CARD_W * 0.78
 24    FAN_VERTICAL_AMPL = 30.0  # max y-drop at hand edges
 25    FAN_ROTATION_AMPL = math.radians(12)  # max rotation at hand edges
 26
 27    def __init__(
 28        self,
 29        cards: list[CardId],
 30        centre: Vec2,
 31        interactive: bool = True,
 32        name: str = "HandHolder",
 33    ) -> None:
 34        # Holder sits at origin so slot positions are absolute screen coords.
 35        super().__init__(name=name)
 36        self._initial_cards = cards
 37        self._centre = centre
 38        # Screen y below which input belongs to the controls strip, not the table.
 39        self.input_clip_bottom = float("inf")
 40        # A decorative hand (the menu backdrop) still springs and wobbles but
 41        # never reads input.
 42        self.interactive = interactive
 43        self.cards: list[Card] = []
 44        self._dragging: Card | None = None
 45        # A hand dealt while the pointer is already down (the click on the menu's
 46        # Play button lands where the cards appear) must not read that press as a
 47        # card click: input arms on the first frame the pointer is up.
 48        self._armed = False
 49
 50    # ------------------------------------------------------------------
 51    # Setup
 52    # ------------------------------------------------------------------
 53    def on_ready(self) -> None:
 54        self.deal(self._initial_cards)
 55
 56    def deal(self, cards: list[CardId]) -> None:
 57        """Replace the hand with a fresh set of cards."""
 58        for card in self.cards:
 59            if card.visual is not None:
 60                self.remove_child(card.visual)
 61            self.remove_child(card)
 62        self.cards = []
 63        self._dragging = None
 64        self._armed = False
 65        for i, cid in enumerate(cards):
 66            card = Card(cid, slot_index=i)
 67            card.position = Vec2(self._centre.x, self._centre.y)
 68            self.cards.append(card)
 69            self.add_child(card)
 70            # The visual is parented to the holder, not to the Card, so its world
 71            # transform is not (holder * card * visual): no double translation.
 72            card.visual = self.add_child(CardVisual(card, index=i))
 73        self._refresh_targets()
 74
 75    # ------------------------------------------------------------------
 76    # Layout: sets each card's target position based on slot index
 77    # ------------------------------------------------------------------
 78    @property
 79    def centre(self) -> Vec2:
 80        """Screen-space centre of the fan. Assigning re-lays-out the hand."""
 81        return self._centre
 82
 83    @centre.setter
 84    def centre(self, value: Vec2) -> None:
 85        self._centre = value
 86        self._refresh_targets()
 87
 88    def _refresh_targets(self) -> None:
 89        n = len(self.cards)
 90        if n == 0:
 91            return
 92        cx, cy = self._centre.x, self._centre.y
 93        for card in self.cards:
 94            i = card.slot_index
 95            t = (i - (n - 1) / 2) / max(1, (n - 1) / 2) if n > 1 else 0.0
 96            x = cx + (i - (n - 1) / 2) * self.SLOT_SPACING
 97            fan_y = (t * t) * self.FAN_VERTICAL_AMPL if n >= 5 else 0.0
 98            y = cy + fan_y
 99            card.target_position = Vec2(x, y)
100            if card.visual is not None:
101                # Fan rotation baseline: leftmost rotates left, rightmost rotates right.
102                card.visual._fan_rotation = t * self.FAN_ROTATION_AMPL
103                card.visual._index = i
104
105    # ------------------------------------------------------------------
106    # Input dispatch: routed centrally so we know which card is on top.
107    # ------------------------------------------------------------------
108    def _topmost_under(self, pos: Vec2) -> Card | None:
109        """Hit-test every card against `pos`; return the topmost (highest slot_index)."""
110        candidates = [c for c in self.cards if c.contains(pos)]
111        if not candidates:
112            return None
113        candidates.sort(key=lambda c: c.slot_index)
114        return candidates[-1]
115
116    def on_update(self, dt: float) -> None:
117        if not self.interactive:
118            return
119        if not self._armed:
120            # Swallow the click that dealt the hand: wait for a frame with no
121            # pointer activity at all before reading the pointer as a card click.
122            if Input.pressed("primary") or Input.just_pressed("primary") or Input.just_released("primary"):
123                return
124            self._armed = True
125        mp = Input.mouse_position
126        # Clicks that land on the controls strip belong to the buttons, not the table.
127        on_table = mp.y < self.input_clip_bottom
128
129        # Resolve hover (only the topmost)
130        topmost = self._topmost_under(mp) if on_table else None
131        for c in self.cards:
132            c.set_hover(c is topmost)
133
134        # Discrete edge events are polled rather than handled with @on_input: polling
135        # behaves identically under the platform loop and the scripted harness, and
136        # InputMap actions keep the bindings in one place (see main.py).
137        if on_table and Input.just_pressed("primary"):
138            self._handle_lmb_down(mp)
139        if Input.just_released("primary"):
140            self._handle_lmb_up()
141        if Input.just_pressed("clear_selection"):
142            self.clear_selection()
143        if Input.just_pressed("discard_card"):
144            self.discard()
145        if Input.just_pressed("play_hand"):
146            self.play_selected()
147
148        # Drag movement detection
149        if self._dragging is None and Input.pressed("primary"):
150            pressed = next((c for c in self.cards if c._press_time is not None), None)
151            if pressed is not None:
152                # Drag slop: only the mouse moving away from where it pressed
153                # counts as a drag. Measuring against the card centre instead
154                # would turn any off-centre click into an instant drag.
155                if (mp - pressed._press_pos).length() > 6.0:
156                    self._dragging = pressed
157                    pressed.start_drag(mp)
158
159        # Swap detection while dragging
160        if self._dragging is not None:
161            self._update_swaps()
162
163    def _update_swaps(self) -> None:
164        sel = self._dragging
165        if sel is None:
166            return
167        for card in self.cards:
168            if card is sel:
169                continue
170            # If dragging crosses a sibling
171            if sel.position.x > card.position.x and sel.slot_index < card.slot_index:
172                self._swap(sel, card)
173                break
174            if sel.position.x < card.position.x and sel.slot_index > card.slot_index:
175                self._swap(sel, card)
176                break
177
178    def _swap(self, a: Card, b: Card) -> None:
179        # Direction sign drives the punch direction
180        dir_sign = 1.0 if b.slot_index > a.slot_index else -1.0
181        a.slot_index, b.slot_index = b.slot_index, a.slot_index
182        if b.visual:
183            b.visual.swap_punch(-dir_sign)
184        self._refresh_targets()
185
186    def _handle_lmb_down(self, mp: Vec2) -> None:
187        topmost = self._topmost_under(mp)
188        if topmost is not None:
189            topmost.handle_press(mp)
190
191    def _handle_lmb_up(self) -> None:
192        if self._dragging is not None:
193            # Find the slot the dragged card now occupies and drop it there.
194            self._dragging.stop_drag()
195            self._dragging = None
196            self._refresh_targets()
197            return
198        for c in self.cards:
199            if c._press_time is not None:
200                c.handle_release()
201                return
202
203    # ------------------------------------------------------------------
204    # Hand verbs: shared by the keyboard shortcuts and the on-screen buttons
205    # ------------------------------------------------------------------
206    def clear_selection(self) -> None:
207        """Deselect every card."""
208        for c in self.cards:
209            c.deselect()
210
211    def discard(self) -> None:
212        """Remove the hovered card, or every selected card when nothing is hovered."""
213        doomed = [c for c in self.cards if c.is_hovering] or [c for c in self.cards if c.selected]
214        for card in doomed:
215            self._remove_card(card)
216        if doomed:
217            self._renumber_slots()
218
219    def _remove_card(self, card: Card) -> None:
220        if card is self._dragging:
221            self._dragging = None
222        if card.visual is not None:
223            self.remove_child(card.visual)
224        self.cards.remove(card)
225        self.remove_child(card)
226
227    def _renumber_slots(self) -> None:
228        for i, c in enumerate(sorted(self.cards, key=lambda c: c.slot_index)):
229            c.slot_index = i
230        self._refresh_targets()
231
232    def play_selected(self) -> None:
233        """Pulse the selected cards left to right, then deselect them."""
234        selected = sorted((c for c in self.cards if c.selected), key=lambda c: c.slot_index)
235        if not selected:
236            return
237        self.start_coroutine(self._play_chain(selected))
238
239    def _play_chain(self, cards: list[Card]):
240        from simvx.core.coroutines import wait
241
242        for c in cards:
243            if c.visual:
244                c.visual.play_pulse()
245            yield from wait(0.18)
246        yield from wait(0.4)
247        for c in cards:
248            c.deselect()