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