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()