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 (
123                Input.is_action_pressed("primary")
124                or Input.is_action_just_pressed("primary")
125                or Input.is_action_just_released("primary")
126            ):
127                return
128            self._armed = True
129        mp = Input.mouse_position
130        # Clicks that land on the controls strip belong to the buttons, not the table.
131        on_table = mp.y < self.input_clip_bottom
132
133        # Resolve hover (only the topmost)
134        topmost = self._topmost_under(mp) if on_table else None
135        for c in self.cards:
136            c.set_hover(c is topmost)
137
138        # Discrete edge events are polled rather than handled with @on_input: polling
139        # behaves identically under the platform loop and the scripted harness, and
140        # InputMap actions keep the bindings in one place (see main.py).
141        if on_table and Input.is_action_just_pressed("primary"):
142            self._handle_lmb_down(mp)
143        if Input.is_action_just_released("primary"):
144            self._handle_lmb_up()
145        if Input.is_action_just_pressed("clear_selection"):
146            self.clear_selection()
147        if Input.is_action_just_pressed("discard_card"):
148            self.discard()
149        if Input.is_action_just_pressed("play_hand"):
150            self.play_selected()
151
152        # Drag movement detection
153        if self._dragging is None and Input.is_action_pressed("primary"):
154            pressed = next((c for c in self.cards if c._press_time is not None), None)
155            if pressed is not None:
156                # Drag slop: only the mouse moving away from where it pressed
157                # counts as a drag. Measuring against the card centre instead
158                # would turn any off-centre click into an instant drag.
159                if (mp - pressed._press_pos).length() > 6.0:
160                    self._dragging = pressed
161                    pressed.start_drag(mp)
162
163        # Swap detection while dragging
164        if self._dragging is not None:
165            self._update_swaps()
166
167    def _update_swaps(self) -> None:
168        sel = self._dragging
169        if sel is None:
170            return
171        for card in self.cards:
172            if card is sel:
173                continue
174            # If dragging crosses a sibling
175            if sel.position.x > card.position.x and sel.slot_index < card.slot_index:
176                self._swap(sel, card)
177                break
178            if sel.position.x < card.position.x and sel.slot_index > card.slot_index:
179                self._swap(sel, card)
180                break
181
182    def _swap(self, a: Card, b: Card) -> None:
183        # Direction sign drives the punch direction
184        dir_sign = 1.0 if b.slot_index > a.slot_index else -1.0
185        a.slot_index, b.slot_index = b.slot_index, a.slot_index
186        if b.visual:
187            b.visual.swap_punch(-dir_sign)
188        self._refresh_targets()
189
190    def _handle_lmb_down(self, mp: Vec2) -> None:
191        topmost = self._topmost_under(mp)
192        if topmost is not None:
193            topmost.handle_press(mp)
194
195    def _handle_lmb_up(self) -> None:
196        if self._dragging is not None:
197            # Find the slot the dragged card now occupies and drop it there.
198            self._dragging.stop_drag()
199            self._dragging = None
200            self._refresh_targets()
201            return
202        for c in self.cards:
203            if c._press_time is not None:
204                c.handle_release()
205                return
206
207    # ------------------------------------------------------------------
208    # Hand verbs: shared by the keyboard shortcuts and the on-screen buttons
209    # ------------------------------------------------------------------
210    def clear_selection(self) -> None:
211        """Deselect every card."""
212        for c in self.cards:
213            c.deselect()
214
215    def discard(self) -> None:
216        """Remove the hovered card, or every selected card when nothing is hovered."""
217        doomed = [c for c in self.cards if c.is_hovering] or [c for c in self.cards if c.selected]
218        for card in doomed:
219            self._remove_card(card)
220        if doomed:
221            self._renumber_slots()
222
223    def _remove_card(self, card: Card) -> None:
224        if card is self._dragging:
225            self._dragging = None
226        if card.visual is not None:
227            self.remove_child(card.visual)
228        self.cards.remove(card)
229        self.remove_child(card)
230
231    def _renumber_slots(self) -> None:
232        for i, c in enumerate(sorted(self.cards, key=lambda c: c.slot_index)):
233            c.slot_index = i
234        self._refresh_targets()
235
236    def play_selected(self) -> None:
237        """Pulse the selected cards left to right, then deselect them."""
238        selected = sorted((c for c in self.cards if c.selected), key=lambda c: c.slot_index)
239        if not selected:
240            return
241        self.start_coroutine(self._play_chain(selected))
242
243    def _play_chain(self, cards: list[Card]):
244        from simvx.core.coroutines import wait
245
246        for c in cards:
247            if c.visual:
248                c.visual.play_pulse()
249            yield from wait(0.18)
250        yield from wait(0.4)
251        for c in cards:
252            c.deselect()