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 y = -self._lift
108 s = self.scale_pulse
109 cx = TILE_WIDTH / 2
110 cy = y + TILE_HEIGHT / 2
111 # apply scale pulse around centre
112 sx = TILE_WIDTH * s
113 sy = TILE_HEIGHT * s
114 ox = cx - sx / 2
115 oy = cy - sy / 2
116
117 # Drop-shadow behind dragged cards (offset toward screen centre)
118 if self.dragging:
119 shadow_off = (12, 24)
120 renderer.rect(
121 (ox + shadow_off[0] + CARD_GAP, oy + shadow_off[1] + CARD_GAP),
122 (sx - 2 * CARD_GAP, sy - 2 * CARD_GAP),
123 fill=(0.0, 0.0, 0.0, 0.18),
124 )
125
126 # Black backing
127 renderer.rect((ox + CARD_GAP, oy + CARD_GAP), (sx - 2 * CARD_GAP, sy - 2 * CARD_GAP), fill=CARD_BORDER_COL)
128 # White (or hover/visited) face
129 face = CARD_BG
130 if self.visited:
131 face = CARD_VISITED
132 elif self.hovered and not self.locked:
133 face = CARD_HOVER
134 renderer.rect(
135 (ox + CARD_BORDER + CARD_GAP, oy + CARD_BORDER + CARD_GAP),
136 (sx - 2 * (CARD_BORDER + CARD_GAP), sy - 2 * (CARD_BORDER + CARD_GAP)),
137 fill=face,
138 )
139
140 # Direction lines (centre to edges)
141 thickness_rect = 6.0
142 if self.data.directions:
143 for d in self.data.directions:
144 self._draw_dir(renderer, ox, oy, sx, sy, d, thickness_rect)
145 # Centre dot
146 renderer.circle((cx, cy), 9, fill=CARD_BORDER_COL, segments=20)
147
148 # Gem (centre overlay)
149 if self.data.gem:
150 gcol = GEM_COLOURS[self.data.gem]
151 renderer.circle((cx, cy), 13, fill=CARD_BORDER_COL, segments=20)
152 renderer.circle((cx, cy), 7, fill=gcol, segments=20)
153
154 def _draw_dir(self, renderer, ox, oy, sx, sy, d: str, thick: float) -> None:
155 cx = ox + sx / 2
156 cy = oy + sy / 2
157 if d == "u":
158 tx, ty = cx, oy + CARD_BORDER + CARD_GAP
159 elif d == "d":
160 tx, ty = cx, oy + sy - CARD_BORDER - CARD_GAP
161 elif d == "l":
162 tx, ty = ox + CARD_BORDER + CARD_GAP, cy
163 elif d == "r":
164 tx, ty = ox + sx - CARD_BORDER - CARD_GAP, cy
165 else:
166 return
167 # Thick line as a rectangle
168 if d in ("u", "d"):
169 x = cx - thick / 2
170 y = min(cy, ty)
171 h = abs(cy - ty)
172 renderer.rect((x, y), (thick, h), fill=CARD_BORDER_COL)
173 else:
174 y = cy - thick / 2
175 x = min(cx, tx)
176 w = abs(cx - tx)
177 renderer.rect((x, y), (w, thick), fill=CARD_BORDER_COL)
178
179
180__all__ = ["Card", "CardData"]