nodes/table.py¶
Part of Klondike Solitaire.
1"""TableNode -- the playing surface.
2
3Owns one ``CardNode`` per physical card in the deck. Mirrors ``GameState``
4each frame: every card's target position and depth come from the logical
5location of its ``CardState`` in the ``GameState``.
6
7Input is polled (``Input.is_mouse_button_pressed``) rather than event-driven
8because a drag is a per-frame quantity: every frame the held cards need the
9current cursor position, so the pointer state is read in the same pass that
10writes the card targets.
11
12Hit-testing routes through pile slot rectangles for empty piles (so an empty
13foundation accepts a click) and through ``CardNode.contains`` for cards.
14
15Layout is authored in a fixed 1280x720 design space and fitted to the real
16viewport by :meth:`TableNode.fit_viewport`, which scales and centres the whole
17table. Pointer coordinates are mapped back into design space once per frame,
18so every hit-test below works in the authored coordinates at any window size.
19
20Drag rules:
21 - Tableau: dragging a face-up card grabs all cards above it as a stack.
22 - Waste/foundation: only the top card can be dragged, single card.
23 - Stock: cannot be dragged. Click deals one or recycles waste.
24
25A drag that ends over an empty area (no valid drop) returns the cards to
26their original pile.
27"""
28
29from __future__ import annotations
30
31from simvx.core import Node2D, Signal, Sprite2D
32from simvx.core.input.enums import MouseButton
33from simvx.core.input.state import Input
34from simvx.core.math.types import Vec2
35
36from .card_node import CardNode
37from .card_textures import (
38 CARD_H,
39 CARD_W,
40 CardId,
41 get_empty_slot,
42 make_full_deck,
43)
44from .game_state import (
45 FOUNDATION,
46 STOCK,
47 TABLEAU,
48 WASTE,
49 CardState,
50 GameState,
51)
52
53# Design-space layout. fit_viewport() scales this box into the real viewport.
54DESIGN_W = 1280
55DESIGN_H = 720
56
57TOP_Y = 130
58TABLEAU_Y = 340
59COL_SPACING = 150
60LEFT_X = 130
61
62TABLEAU_FACEDOWN_OFFSET = 14
63TABLEAU_FACEUP_OFFSET = 28
64
65# Drag detection threshold (px before lift)
66DRAG_THRESHOLD = 5.0
67
68# z-depth bands so dragged cards always render on top
69DEPTH_BASE = 0
70DEPTH_DRAG = 1000
71
72
73class TableNode(Node2D):
74 """The table: stock, waste, four foundations, seven tableau columns."""
75
76 won = Signal()
77 state_changed = Signal() # fires after any state-mutating action
78
79 def __init__(self, state: GameState | None = None) -> None:
80 super().__init__(name="Table")
81 self.state = state or GameState.new_game()
82 self._cards: dict[tuple[str, str], CardNode] = {}
83 self._slot_sprites: dict[tuple[str, int], Sprite2D] = {}
84 self._dragging: list[CardNode] = []
85 self._drag_origin: tuple[str, int] | None = None
86 self._drag_start_mouse = Vec2(0, 0)
87 self._drag_offsets: list[Vec2] = []
88 self._press_pile: tuple[str, int, int] | None = None # (kind, idx, depth_in_pile)
89 self._press_was_drag = False
90 self._won_announced = False
91 self._initial_layout = True
92 self._view_scale = 1.0
93 # Set False while the title menu is up (or while the pointer is over the
94 # HUD strip) so a click never lands on the table underneath.
95 self.interactive = True
96
97 # ----------------------------------------------------------- viewport
98 def fit_viewport(self, size: Vec2 | tuple[float, float]) -> None:
99 """Scale and centre the 1280x720 design box inside ``size``."""
100 vw, vh = float(size[0]), float(size[1])
101 self._view_scale = min(vw / DESIGN_W, vh / DESIGN_H)
102 self.scale = Vec2(self._view_scale, self._view_scale)
103 self.position = Vec2(
104 (vw - DESIGN_W * self._view_scale) * 0.5,
105 (vh - DESIGN_H * self._view_scale) * 0.5,
106 )
107
108 def _to_design(self, screen_pos: Vec2) -> Vec2:
109 """Map a screen-space pointer position into design space."""
110 return Vec2(
111 (screen_pos.x - self.position.x) / self._view_scale,
112 (screen_pos.y - self.position.y) / self._view_scale,
113 )
114
115 # ----------------------------------------------------------- ready
116 def on_ready(self) -> None:
117 # Empty-slot placeholder sprites (one per pile location).
118 # Stock at (0,top), waste at (1,top), foundations at (3..6, top),
119 # tableau at (0..6, mid).
120 slot_tex = get_empty_slot()
121
122 for key in [(STOCK, 0), (WASTE, 0)]:
123 self._add_slot(slot_tex, *self._pile_origin(*key), key)
124 for i in range(4):
125 self._add_slot(slot_tex, *self._pile_origin(FOUNDATION, i), (FOUNDATION, i))
126 for i in range(7):
127 self._add_slot(slot_tex, *self._pile_origin(TABLEAU, i), (TABLEAU, i))
128
129 # One CardNode per (rank, suit) in the deck. Re-used for the entire
130 # session -- newgame() just reshuffles the underlying GameState and the
131 # nodes reflect the new piles automatically.
132 for cid in make_full_deck():
133 node = CardNode(cid)
134 self._cards[(cid.rank, cid.suit)] = node
135 self.add_child(node)
136 # Snap to off-screen until first layout
137 node.set_target(Vec2(-200, -200), snap=True)
138
139 def _add_slot(self, tex, x: float, y: float, key: tuple[str, int]) -> None:
140 spr = Sprite2D(
141 texture=tex,
142 width=CARD_W,
143 height=CARD_H,
144 position=Vec2(x, y),
145 name=f"Slot({key[0]}{key[1]})",
146 )
147 self.add_child(spr)
148 self._slot_sprites[key] = spr
149
150 # ----------------------------------------------------------- layout
151 def _pile_origin(self, kind: str, idx: int) -> tuple[float, float]:
152 """Top-left card slot anchor (centre of the *empty* placeholder)."""
153 if kind == STOCK:
154 return (LEFT_X, TOP_Y)
155 if kind == WASTE:
156 return (LEFT_X + COL_SPACING, TOP_Y)
157 if kind == FOUNDATION:
158 return (LEFT_X + (3 + idx) * COL_SPACING, TOP_Y)
159 if kind == TABLEAU:
160 return (LEFT_X + idx * COL_SPACING, TABLEAU_Y)
161 raise ValueError(f"Unknown pile {kind}")
162
163 def _layout(self) -> None:
164 """Walk the GameState and update each CardNode's target + depth.
165
166 Tableau columns stack with mixed face-down/face-up offsets. Other piles
167 place every card at the same slot anchor (only top card visible).
168 """
169 snap = self._initial_layout
170 self._initial_layout = False
171
172 for col_idx, col in enumerate(self.state.tableau):
173 ox, oy = self._pile_origin(TABLEAU, col_idx)
174 y = oy
175 for row, cs in enumerate(col):
176 node = self._node_for(cs.card)
177 node.set_face(cs.face_up)
178 target = Vec2(ox, y)
179 # Dragging cards override their target each frame -- skip them
180 if node not in self._dragging:
181 node.set_target(target, depth=DEPTH_BASE + row + col_idx * 100, snap=snap)
182 # Advance vertical offset based on whether the *next* card sits over this one
183 y += TABLEAU_FACEUP_OFFSET if cs.face_up else TABLEAU_FACEDOWN_OFFSET
184
185 for found_idx, pile in enumerate(self.state.foundations):
186 ox, oy = self._pile_origin(FOUNDATION, found_idx)
187 for row, cs in enumerate(pile):
188 node = self._node_for(cs.card)
189 node.set_face(cs.face_up)
190 if node not in self._dragging:
191 # Foundations don't fan -- all cards stack at the slot anchor
192 node.set_target(Vec2(ox, oy), depth=DEPTH_BASE + row + 800, snap=snap)
193
194 ox, oy = self._pile_origin(WASTE, 0)
195 for row, cs in enumerate(self.state.waste):
196 node = self._node_for(cs.card)
197 node.set_face(cs.face_up)
198 if node not in self._dragging:
199 node.set_target(Vec2(ox, oy), depth=DEPTH_BASE + row + 1500, snap=snap)
200
201 ox, oy = self._pile_origin(STOCK, 0)
202 for row, cs in enumerate(self.state.stock):
203 node = self._node_for(cs.card)
204 node.set_face(False) # stock is always face-down
205 if node not in self._dragging:
206 node.set_target(Vec2(ox, oy), depth=DEPTH_BASE + row + 2000, snap=snap)
207
208 def _node_for(self, cid: CardId) -> CardNode:
209 return self._cards[(cid.rank, cid.suit)]
210
211 # ----------------------------------------------------------- per-frame
212 def on_update(self, dt: float) -> None:
213 # Pointer edges, in design space. A drag is a per-frame quantity, so the
214 # pointer is read in the same pass that writes the card targets.
215 mp = self._to_design(Input.mouse_position)
216 if self.interactive and Input.is_mouse_button_just_pressed(MouseButton.LEFT):
217 self._on_press(mp)
218 if Input.is_mouse_button_pressed(MouseButton.LEFT):
219 self._on_drag(mp)
220 if Input.is_mouse_button_just_released(MouseButton.LEFT):
221 self._on_release(mp)
222
223 self._layout()
224
225 # Win detection
226 if self.state.is_won and not self._won_announced:
227 self._won_announced = True
228 self.won()
229
230 # ----------------------------------------------------------- input flow
231 def _on_press(self, mp: Vec2) -> None:
232 # Stock click -> deal one
233 slot_stock = self._slot_at(STOCK, 0)
234 if slot_stock and self._aabb_hit(slot_stock, mp):
235 self.state.deal_from_stock()
236 self._won_announced = False
237 self.state_changed()
238 return
239
240 # Otherwise: hit-test from top of every face-up pile.
241 hit = self._topmost_card_at(mp)
242 if hit is None:
243 return
244 kind, idx, depth = hit
245 cs = self._cs_at(kind, idx, depth)
246 if cs is None or not cs.face_up:
247 return
248
249 # Determine cards to grab (tableau supports multi-card stack)
250 if kind == TABLEAU:
251 stack = self.state.tableau[idx][depth:]
252 else:
253 # Only the top card is draggable from the waste or a foundation.
254 if depth != len(self._pile_list(kind, idx)) - 1:
255 return
256 stack = [cs]
257
258 # Stash press info so a tap (no drag) becomes auto-move
259 self._press_pile = (kind, idx, depth)
260 self._press_was_drag = False
261 self._dragging = [self._node_for(c.card) for c in stack]
262 self._drag_origin = (kind, idx)
263 self._drag_offsets = [Vec2(node.position.x - mp.x, node.position.y - mp.y) for node in self._dragging]
264 self._drag_start_mouse = mp
265
266 def _on_drag(self, mp: Vec2) -> None:
267 if not self._dragging:
268 return
269 # Begin lift after threshold
270 delta = mp - self._drag_start_mouse
271 if not self._press_was_drag and (delta.length() > DRAG_THRESHOLD):
272 self._press_was_drag = True
273 for n in self._dragging:
274 n.begin_drag()
275 if not self._press_was_drag:
276 return
277 # Stack offset: cards in a tableau drag preserve their visual gap
278 for i, node in enumerate(self._dragging):
279 base = mp + self._drag_offsets[i]
280 # Top dragged card mirrors mouse position; lower cards trail.
281 node.set_target(base, depth=DEPTH_DRAG + i)
282
283 def _on_release(self, mp: Vec2) -> None:
284 if not self._dragging:
285 return
286 nodes = self._dragging
287 origin = self._drag_origin
288 was_drag = self._press_was_drag
289 press_info = self._press_pile
290
291 # Reset drag state before potentially mutating GameState (layout uses _dragging)
292 self._dragging = []
293 self._drag_origin = None
294 self._press_was_drag = False
295 self._press_pile = None
296 for n in nodes:
297 n.end_drag()
298
299 if origin is None:
300 return
301 src_kind, src_idx = origin
302
303 if not was_drag:
304 # Click without drag -> auto-move to first legal foundation/tableau
305 if press_info is None:
306 return
307 kind, idx, depth = press_info
308 count = 1
309 if kind == TABLEAU:
310 count = len(self.state.tableau[idx]) - depth
311 target = self.state.find_destination(src_kind, src_idx, count)
312 if target is not None:
313 dst, dst_idx = target
314 if self.state.move_cards(src_kind, src_idx, dst, dst_idx, count):
315 self._won_announced = False
316 self.state_changed()
317 return
318
319 # Drag-drop: find a legal destination pile under the mouse
320 dest = self._destination_under(mp, src_kind, src_idx)
321 if dest is None:
322 # No-op: layout will return cards to origin via spring
323 return
324 dst_kind, dst_idx = dest
325 count = len(nodes)
326 if not self.state.move_cards(src_kind, src_idx, dst_kind, dst_idx, count):
327 return # Spring returns cards to origin
328 self._won_announced = False
329 self.state_changed()
330
331 # ----------------------------------------------------------- helpers
332 def _pile_list(self, kind: str, idx: int) -> list[CardState]:
333 if kind == TABLEAU:
334 return self.state.tableau[idx]
335 if kind == FOUNDATION:
336 return self.state.foundations[idx]
337 if kind == WASTE:
338 return self.state.waste
339 if kind == STOCK:
340 return self.state.stock
341 raise ValueError
342
343 def _cs_at(self, kind: str, idx: int, depth: int) -> CardState | None:
344 pile = self._pile_list(kind, idx)
345 return pile[depth] if 0 <= depth < len(pile) else None
346
347 def _slot_at(self, kind: str, idx: int) -> Sprite2D | None:
348 return self._slot_sprites.get((kind, idx))
349
350 def _aabb_hit(self, sprite: Sprite2D, mp: Vec2) -> bool:
351 size = sprite.draw_size
352 return abs(mp.x - sprite.position.x) <= size.x * 0.5 and abs(mp.y - sprite.position.y) <= size.y * 0.5
353
354 def _topmost_card_at(self, mp: Vec2) -> tuple[str, int, int] | None:
355 """Return (kind, idx, depth_in_pile) of the topmost face-up card under ``mp``,
356 or None. Tableau columns walk top-to-bottom of the visible stack."""
357 # Tableau: check top card first because it has the largest visible AABB
358 for col_idx in range(7):
359 col = self.state.tableau[col_idx]
360 for depth in range(len(col) - 1, -1, -1):
361 cs = col[depth]
362 if not cs.face_up:
363 break
364 node = self._node_for(cs.card)
365 # For non-top cards, the visible portion is just the top fold (28px)
366 if depth == len(col) - 1:
367 if node.contains(mp):
368 return (TABLEAU, col_idx, depth)
369 else:
370 # Hit-test only the visible fold rectangle
371 px = node.position.x
372 py = node.position.y
373 fold_top = py - CARD_H * 0.5
374 fold_bot = fold_top + TABLEAU_FACEUP_OFFSET
375 if abs(mp.x - px) <= CARD_W * 0.5 and fold_top <= mp.y <= fold_bot:
376 return (TABLEAU, col_idx, depth)
377 # Waste top
378 if self.state.waste:
379 top = self.state.waste[-1]
380 if self._node_for(top.card).contains(mp):
381 return (WASTE, 0, len(self.state.waste) - 1)
382 # Foundations
383 for f_idx in range(4):
384 pile = self.state.foundations[f_idx]
385 if pile and self._node_for(pile[-1].card).contains(mp):
386 return (FOUNDATION, f_idx, len(pile) - 1)
387 return None
388
389 def _destination_under(self, mp: Vec2, src_kind: str, src_idx: int) -> tuple[str, int] | None:
390 """Find the pile slot whose anchor is closest to mouse, prioritising
391 actual card hits over slot-only hits."""
392 # Foundations
393 for i in range(4):
394 ox, oy = self._pile_origin(FOUNDATION, i)
395 if abs(mp.x - ox) <= CARD_W * 0.6 and abs(mp.y - oy) <= CARD_H * 0.6:
396 return (FOUNDATION, i)
397 # Tableau columns -- compare to the column header (slot anchor) and
398 # extend the hit zone vertically.
399 for i in range(7):
400 ox, oy = self._pile_origin(TABLEAU, i)
401 col_height = max(CARD_H, len(self.state.tableau[i]) * TABLEAU_FACEUP_OFFSET + CARD_H)
402 if abs(mp.x - ox) <= CARD_W * 0.6 and oy - CARD_H * 0.5 <= mp.y <= oy + col_height:
403 return (TABLEAU, i)
404 return None
405
406 # ----------------------------------------------------------- actions
407 def action_undo(self) -> bool:
408 if self.state.undo():
409 self._won_announced = False
410 self.state_changed()
411 return True
412 return False
413
414 def action_new_game(self, seed: int | None = None) -> None:
415 self.load_state(GameState.new_game(seed))
416
417 def load_state(self, state: GameState) -> None:
418 """Adopt ``state`` wholesale: cancel any drag, snap the layout, announce.
419
420 Every entry point that swaps the logical state (new game, loading a
421 save, the scripted harness injecting an end-game) goes through here, so
422 the drag/win/layout flags can never be left describing the old game.
423 """
424 self.state = state
425 self._dragging = []
426 self._drag_origin = None
427 self._press_pile = None
428 self._press_was_drag = False
429 self._won_announced = False
430 self._initial_layout = True
431 self.state_changed()
432
433
434__all__ = ["TableNode"]