nodes/card.pyΒΆ

Part of Casual Crusade.

  1"""Card: interactive node that draws a card face procedurally.
  2
  3A card has a CardData (directions + optional gem). It sits in the hand, can be
  4dragged over the board, and snaps to the nearest legal Tile on drop. Drag-feel
  5mirrors the Balatro-feel port: spring follow + tilt + shadow lift.
  6"""
  7
  8from __future__ import annotations
  9
 10from simvx.core import Node2D
 11from simvx.core.math.types import Vec2
 12
 13from .card_data import CardData
 14from .constants import (
 15    CARD_BG,
 16    CARD_BORDER,
 17    CARD_BORDER_COL,
 18    CARD_GAP,
 19    CARD_HOVER,
 20    CARD_VISITED,
 21    DRAG_SPEED_LIMIT,
 22    GEM_COLOURS,
 23    HAND_LERP_SPEED,
 24    TILE_HEIGHT,
 25    TILE_WIDTH,
 26)
 27
 28
 29class Card(Node2D):
 30    """Interactive card. ``position`` is the **top-left** of the card's tile-rect."""
 31
 32    def __init__(self, data: CardData, x: float, y: float, *, locked: bool = False):
 33        super().__init__(name=f"Card({''.join(data.directions)}{data.gem or ''})")
 34        self.data = data
 35        self.position = Vec2(x, y)
 36        self.target_position = Vec2(x, y)  # hand slot / tile to lerp back to
 37        self.hovered = False
 38        self.dragging = False
 39        self.locked = locked  # placed cards lock; can't be re-picked
 40        self.visited = False  # path-step highlight
 41        self.tile = None  # Tile this card sits on (when placed)
 42        self.scale_pulse = 1.0  # juice (>=1)
 43        self._drag_offset = Vec2(0, 0)
 44        self._lift = 0.0  # current vertical hover lift
 45
 46    # --------------------------------------------------------------
 47    # Hit-test (in world coords)
 48    # --------------------------------------------------------------
 49    def contains(self, pos: Vec2) -> bool:
 50        # The card draws within (position, position + (TW, TH)); hit-test that.
 51        return (
 52            self.position.x <= pos.x <= self.position.x + TILE_WIDTH
 53            and self.position.y - self._lift <= pos.y <= self.position.y - self._lift + TILE_HEIGHT
 54        )
 55
 56    def world_centre(self) -> Vec2:
 57        return Vec2(self.position.x + TILE_WIDTH / 2, self.position.y + TILE_HEIGHT / 2 - self._lift)
 58
 59    # --------------------------------------------------------------
 60    # Per-frame anim
 61    # --------------------------------------------------------------
 62    def on_update(self, dt: float) -> None:
 63        # Lerp to target position when not dragging
 64        if not self.dragging:
 65            lerp = min(1.0, HAND_LERP_SPEED * dt)
 66            self.position = Vec2(
 67                self.position.x + (self.target_position.x - self.position.x) * lerp,
 68                self.position.y + (self.target_position.y - self.position.y) * lerp,
 69            )
 70        # Lift spring (hover lift on hand cards)
 71        lift_target = 14.0 if self.hovered and not self.locked else 0.0
 72        self._lift += (lift_target - self._lift) * min(1.0, 18.0 * dt)
 73        # Pulse decay
 74        if self.scale_pulse > 1.0:
 75            self.scale_pulse = max(1.0, self.scale_pulse - 1.5 * dt)
 76
 77    def drag_to(self, mouse: Vec2, dt: float) -> None:
 78        """Speed-limited follow toward mouse (less drag offset)."""
 79        target = Vec2(mouse.x - self._drag_offset.x, mouse.y - self._drag_offset.y)
 80        delta = target - self.position
 81        d = max(1e-6, delta.length())
 82        step = min(d, DRAG_SPEED_LIMIT * dt)
 83        self.position = self.position + (delta / d) * step
 84
 85    # --------------------------------------------------------------
 86    # Interaction helpers
 87    # --------------------------------------------------------------
 88    def begin_drag(self, mouse: Vec2) -> None:
 89        self.dragging = True
 90        self._drag_offset = Vec2(mouse.x - self.position.x, mouse.y - self.position.y)
 91
 92    def end_drag(self) -> None:
 93        self.dragging = False
 94        self._drag_offset = Vec2(0, 0)
 95
 96    def settle_to(self, target: Vec2) -> None:
 97        """Snap *target* into both ``position`` and ``target_position``."""
 98        self.target_position = target
 99        self.position = target
100
101    # --------------------------------------------------------------
102    # Rendering
103    # --------------------------------------------------------------
104    def on_draw(self, renderer) -> None:
105        if not self.visible:
106            return
107        x = self.position.x
108        y = self.position.y - self._lift
109        s = self.scale_pulse
110        cx = x + TILE_WIDTH / 2
111        cy = y + TILE_HEIGHT / 2
112        # apply scale pulse around centre
113        sx = TILE_WIDTH * s
114        sy = TILE_HEIGHT * s
115        ox = cx - sx / 2
116        oy = cy - sy / 2
117
118        # Drop-shadow behind dragged cards (offset toward screen centre)
119        if self.dragging:
120            shadow_off = (12, 24)
121            renderer.draw_rect(
122                (ox + shadow_off[0] + CARD_GAP, oy + shadow_off[1] + CARD_GAP),
123                (sx - 2 * CARD_GAP, sy - 2 * CARD_GAP),
124                colour=(0.0, 0.0, 0.0, 0.18),
125                filled=True,
126            )
127
128        # Black backing
129        renderer.draw_rect(
130            (ox + CARD_GAP, oy + CARD_GAP),
131            (sx - 2 * CARD_GAP, sy - 2 * CARD_GAP),
132            colour=CARD_BORDER_COL,
133            filled=True,
134        )
135        # White (or hover/visited) face
136        face = CARD_BG
137        if self.visited:
138            face = CARD_VISITED
139        elif self.hovered and not self.locked:
140            face = CARD_HOVER
141        renderer.draw_rect(
142            (ox + CARD_BORDER + CARD_GAP, oy + CARD_BORDER + CARD_GAP),
143            (sx - 2 * (CARD_BORDER + CARD_GAP), sy - 2 * (CARD_BORDER + CARD_GAP)),
144            colour=face,
145            filled=True,
146        )
147
148        # Direction lines (centre to edges)
149        thickness_rect = 6.0
150        if self.data.directions:
151            for d in self.data.directions:
152                self._draw_dir(renderer, ox, oy, sx, sy, d, thickness_rect)
153            # Centre dot
154            renderer.draw_circle((cx, cy), 9, colour=CARD_BORDER_COL, filled=True, segments=20)
155
156        # Gem (centre overlay)
157        if self.data.gem:
158            gcol = GEM_COLOURS[self.data.gem]
159            renderer.draw_circle((cx, cy), 13, colour=CARD_BORDER_COL, filled=True, segments=20)
160            renderer.draw_circle((cx, cy), 7, colour=gcol, filled=True, segments=20)
161
162    def _draw_dir(self, renderer, ox, oy, sx, sy, d: str, thick: float) -> None:
163        cx = ox + sx / 2
164        cy = oy + sy / 2
165        if d == "u":
166            tx, ty = cx, oy + CARD_BORDER + CARD_GAP
167        elif d == "d":
168            tx, ty = cx, oy + sy - CARD_BORDER - CARD_GAP
169        elif d == "l":
170            tx, ty = ox + CARD_BORDER + CARD_GAP, cy
171        elif d == "r":
172            tx, ty = ox + sx - CARD_BORDER - CARD_GAP, cy
173        else:
174            return
175        # Thick line as a rectangle
176        if d in ("u", "d"):
177            x = cx - thick / 2
178            y = min(cy, ty)
179            h = abs(cy - ty)
180            renderer.draw_rect((x, y), (thick, h), colour=CARD_BORDER_COL, filled=True)
181        else:
182            y = cy - thick / 2
183            x = min(cx, tx)
184            w = abs(cx - tx)
185            renderer.draw_rect((x, y), (w, thick), colour=CARD_BORDER_COL, filled=True)
186
187
188__all__ = ["Card", "CardData"]