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