nodes/card_textures.py¶
Part of Balatro Feel.
1"""Procedural card-face textures.
2
3Each face is an RGBA uint8 ndarray with rounded corners, suit pip, and rank.
4Built once at startup; Sprite2D consumes the array directly via the engine's
5ndarray-as-texture path.
6"""
7
8from __future__ import annotations
9
10from dataclasses import dataclass
11from functools import cache
12
13import numpy as np
14
15from simvx.core.text.measure import LOGICAL_EM
16from simvx.graphics.draw2d import Draw2D
17
18CARD_W = 220
19CARD_H = 308
20CORNER_R = 22
21BORDER = 6
22
23SUIT_COLOURS = {
24 "H": (210, 50, 60),
25 "D": (210, 50, 60),
26 "S": (30, 30, 36),
27 "C": (30, 30, 36),
28}
29
30
31@dataclass(frozen=True)
32class CardId:
33 rank: str # "A", "2".."10", "J", "Q", "K"
34 suit: str # "H", "D", "S", "C"
35
36 def __str__(self) -> str:
37 return f"{self.rank}{self.suit}"
38
39
40def _aa_rounded_alpha(w: int, h: int, r: float) -> np.ndarray:
41 """Soft-edge alpha mask for a rounded rect (float32 [0,1])."""
42 ys = (np.arange(h) + 0.5)[:, None]
43 xs = (np.arange(w) + 0.5)[None, :]
44 # Distance to nearest edge, treating corners as discs of radius r.
45 cx = np.clip(xs, r, w - r)
46 cy = np.clip(ys, r, h - r)
47 dx = xs - cx
48 dy = ys - cy
49 dist = np.sqrt(dx * dx + dy * dy)
50 # alpha: 1 inside (dist <= r-0.5), 0 outside (dist >= r+0.5), linear ramp between
51 alpha = np.clip(r + 0.5 - dist, 0.0, 1.0)
52 return alpha.astype(np.float32)
53
54
55@cache
56def _text_ink(text: str, size: float) -> np.ndarray:
57 """``text`` in the engine's 2D font as a 0-1 coverage mask, ``size`` px to the em.
58
59 Drawn by the engine rather than a font library loaded here, so the face bakes
60 the same on desktop and in the browser. Cached per rank: every suit shares
61 one mask and only its tint differs.
62 """
63 return Draw2D.text_pixels(text, scale=size / LOGICAL_EM)[..., 3].astype(np.float32) / 255.0
64
65
66def _stamp_text(
67 target: np.ndarray, text: str, x: int, baseline_y: int, size: float, colour: tuple[int, int, int]
68) -> None:
69 """Composite ``text`` into ``target`` with its pen at ``(x, baseline_y)``."""
70 ink = _text_ink(text, size)
71 # The engine's image keeps a 2 px margin, so its pen sits at (2, 2 + ascent).
72 left = x - 2
73 top = baseline_y - 2 - round(Draw2D.text_ascent(size / LOGICAL_EM))
74 x0, y0 = max(left, 0), max(top, 0)
75 x1 = min(left + ink.shape[1], target.shape[1])
76 y1 = min(top + ink.shape[0], target.shape[0])
77 if x1 <= x0 or y1 <= y0:
78 return
79 alpha = ink[y0 - top : y1 - top, x0 - left : x1 - left, None]
80 dst = target[y0:y1, x0:x1].astype(np.float32)
81 dst[..., :3] = dst[..., :3] * (1.0 - alpha) + np.asarray(colour, dtype=np.float32) * alpha
82 dst[..., 3:] += 255.0 * alpha * (1.0 - dst[..., 3:] / 255.0)
83 target[y0:y1, x0:x1] = np.clip(dst, 0, 255).astype(np.uint8)
84
85
86# ---------------------------------------------------------------------------
87# Vector suit pips (no font dependency; draws into a soft alpha mask)
88# ---------------------------------------------------------------------------
89
90
91def _aa_triangle(xs, ys, p0, p1, p2):
92 """Filled triangle alpha mask."""
93
94 # Half-plane test for each edge; inside if all signs match.
95 def edge(a, b, x, y):
96 return (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0])
97
98 s0 = edge(p0, p1, xs, ys)
99 s1 = edge(p1, p2, xs, ys)
100 s2 = edge(p2, p0, xs, ys)
101 inside = ((s0 >= 0) & (s1 >= 0) & (s2 >= 0)) | ((s0 <= 0) & (s1 <= 0) & (s2 <= 0))
102 return inside.astype(np.float32)
103
104
105def _heart_alpha(size: int) -> np.ndarray:
106 """Heart: two upper circles + downward-pointing triangle."""
107 ys = np.linspace(0.0, 1.0, size)[:, None]
108 xs = np.linspace(-0.5, 0.5, size)[None, :]
109 r = 0.25
110 # Two lobes at top
111 left = np.sqrt((xs + 0.25) ** 2 + (ys - 0.30) ** 2) <= r
112 right = np.sqrt((xs - 0.25) ** 2 + (ys - 0.30) ** 2) <= r
113 # Downward triangle from (-0.5, 0.30) to (0.5, 0.30) to (0.0, 0.95)
114 p0 = (-0.50, 0.30)
115 p1 = (0.50, 0.30)
116 p2 = (0.0, 0.95)
117 tri = _aa_triangle(xs, ys, p0, p1, p2)
118 a = left.astype(np.float32) + right.astype(np.float32) + tri
119 return np.clip(a, 0.0, 1.0).astype(np.float32)
120
121
122def _diamond_alpha(size: int) -> np.ndarray:
123 ys = np.linspace(0.0, 1.0, size)[:, None]
124 xs = np.linspace(-0.5, 0.5, size)[None, :]
125 d = np.abs(xs) / 0.40 + np.abs(ys - 0.5) / 0.50
126 alpha = np.where(d <= 1.0, 1.0, 0.0)
127 return alpha.astype(np.float32)
128
129
130def _spade_alpha(size: int) -> np.ndarray:
131 """Spade: inverted heart (point up) + stem at bottom."""
132 ys = np.linspace(0.0, 1.0, size)[:, None]
133 xs = np.linspace(-0.5, 0.5, size)[None, :]
134 r = 0.25
135 # Two lobes at *bottom*
136 left = np.sqrt((xs + 0.25) ** 2 + (ys - 0.65) ** 2) <= r
137 right = np.sqrt((xs - 0.25) ** 2 + (ys - 0.65) ** 2) <= r
138 # Upward triangle from (-0.5, 0.65) to (0.5, 0.65) to (0.0, 0.05)
139 tri = _aa_triangle(xs, ys, (-0.50, 0.65), (0.50, 0.65), (0.0, 0.05))
140 body = left.astype(np.float32) + right.astype(np.float32) + tri
141 body = np.clip(body, 0.0, 1.0)
142 # Stem: trapezoid from (−0.18, 0.85) → (0.18, 0.85) → (0.10, 0.98) → (−0.10, 0.98)
143 stem = _aa_triangle(xs, ys, (-0.18, 0.85), (0.18, 0.85), (0.10, 0.98)) + _aa_triangle(
144 xs, ys, (-0.18, 0.85), (0.10, 0.98), (-0.10, 0.98)
145 )
146 return np.clip(body + stem, 0.0, 1.0).astype(np.float32)
147
148
149def _club_alpha(size: int) -> np.ndarray:
150 """Club: three circles + stem."""
151 ys = np.linspace(0.0, 1.0, size)[:, None]
152 xs = np.linspace(-0.5, 0.5, size)[None, :]
153 r = 0.22
154 top = np.sqrt(xs**2 + (ys - 0.30) ** 2) <= r
155 left = np.sqrt((xs + 0.22) ** 2 + (ys - 0.55) ** 2) <= r
156 right = np.sqrt((xs - 0.22) ** 2 + (ys - 0.55) ** 2) <= r
157 body = top.astype(np.float32) + left.astype(np.float32) + right.astype(np.float32)
158 body = np.clip(body, 0.0, 1.0)
159 stem = _aa_triangle(xs, ys, (-0.18, 0.78), (0.18, 0.78), (0.10, 0.98)) + _aa_triangle(
160 xs, ys, (-0.18, 0.78), (0.10, 0.98), (-0.10, 0.98)
161 )
162 return np.clip(body + stem, 0.0, 1.0).astype(np.float32)
163
164
165_SUIT_ALPHA_FNS = {
166 "H": _heart_alpha,
167 "D": _diamond_alpha,
168 "S": _spade_alpha,
169 "C": _club_alpha,
170}
171
172
173def _stamp_suit(target: np.ndarray, suit: str, cx: int, cy: int, size: int, colour: tuple[int, int, int]) -> None:
174 """Stamp a centred suit pip at (cx, cy) with `size` pixel dimension."""
175 alpha = _SUIT_ALPHA_FNS[suit](size)
176 h, w = alpha.shape
177 x0 = cx - w // 2
178 y0 = cy - h // 2
179 th, tw = target.shape[:2]
180 sx0 = max(x0, 0)
181 sy0 = max(y0, 0)
182 sx1 = min(x0 + w, tw)
183 sy1 = min(y0 + h, th)
184 if sx1 <= sx0 or sy1 <= sy0:
185 return
186 src = alpha[sy0 - y0 : sy1 - y0, sx0 - x0 : sx1 - x0]
187 alpha_src = src[..., None]
188 dst = target[sy0:sy1, sx0:sx1].astype(np.float32)
189 rgb = np.array(colour, dtype=np.float32)
190 blended_rgb = dst[..., :3] * (1.0 - alpha_src) + rgb * alpha_src
191 blended_a = np.maximum(dst[..., 3:], (alpha_src * 255.0))
192 out = np.concatenate([blended_rgb, blended_a], axis=-1)
193 target[sy0:sy1, sx0:sx1] = np.clip(out, 0, 255).astype(np.uint8)
194
195
196def make_card_face(card: CardId) -> np.ndarray:
197 """Build an RGBA uint8 card-face texture."""
198 img = np.zeros((CARD_H, CARD_W, 4), dtype=np.uint8)
199
200 alpha = _aa_rounded_alpha(CARD_W, CARD_H, CORNER_R)
201 body = np.empty_like(img)
202 body[..., 0] = 252
203 body[..., 1] = 250
204 body[..., 2] = 245
205 body[..., 3] = (alpha * 255).astype(np.uint8)
206 img = body
207
208 # Inner border: a slightly inset rounded rect drawn as a darker line via alpha mask
209 inner = _aa_rounded_alpha(CARD_W - 2 * BORDER, CARD_H - 2 * BORDER, CORNER_R - 4)
210 inner_full = np.zeros((CARD_H, CARD_W), dtype=np.float32)
211 inner_full[BORDER : CARD_H - BORDER, BORDER : CARD_W - BORDER] = inner
212 border_band = np.clip(alpha - inner_full, 0.0, 1.0)
213 edge_colour = np.array([220, 215, 205], dtype=np.float32)
214 for c in range(3):
215 img[..., c] = np.clip(
216 img[..., c] * (1.0 - border_band * 0.5) + edge_colour[c] * border_band * 0.5,
217 0,
218 255,
219 ).astype(np.uint8)
220
221 suit_rgb = SUIT_COLOURS[card.suit]
222 rank = card.rank
223
224 # Corner stack: rank text above a small suit pip.
225 rank_size = 50
226 suit_small = 30
227 margin = 18
228 rank_baseline = margin + rank_size
229 pip_centre_y = rank_baseline + suit_small // 2 + 4
230 pip_centre_x = margin + suit_small // 2 + 2
231
232 _stamp_text(img, rank, margin, rank_baseline, rank_size, suit_rgb)
233 _stamp_suit(img, card.suit, pip_centre_x, pip_centre_y, suit_small, suit_rgb)
234
235 # Bottom-right corner stack: stamp into a copy and flip 180°.
236 bot = np.zeros_like(img)
237 _stamp_text(bot, rank, margin, rank_baseline, rank_size, suit_rgb)
238 _stamp_suit(bot, card.suit, pip_centre_x, pip_centre_y, suit_small, suit_rgb)
239 bot_flipped = bot[::-1, ::-1, :]
240 bot_a = bot_flipped[..., 3:].astype(np.float32) / 255.0
241 img_f = img.astype(np.float32)
242 img_f[..., :3] = img_f[..., :3] * (1.0 - bot_a) + bot_flipped[..., :3].astype(np.float32) * bot_a
243 img_f[..., 3:] = np.maximum(img_f[..., 3:], bot_flipped[..., 3:].astype(np.float32))
244 img = np.clip(img_f, 0, 255).astype(np.uint8)
245
246 # Centre suit pip: sized to fit comfortably between the two corner stacks.
247 centre_size = 96
248 cx = CARD_W // 2
249 cy = CARD_H // 2
250 _stamp_suit(img, card.suit, cx, cy, centre_size, suit_rgb)
251
252 # Re-mask alpha to the rounded rect (in case glyphs leaked)
253 img[..., 3] = np.minimum(img[..., 3], (alpha * 255).astype(np.uint8))
254 return img
255
256
257def make_card_shadow() -> np.ndarray:
258 """Soft drop-shadow texture, same outline as a card."""
259 pad = 18
260 w = CARD_W + 2 * pad
261 h = CARD_H + 2 * pad
262 img = np.zeros((h, w, 4), dtype=np.uint8)
263 # Distance-based soft alpha
264 ys = np.arange(h)[:, None]
265 xs = np.arange(w)[None, :]
266 cx_l = pad + CORNER_R
267 cx_r = w - pad - CORNER_R - 1
268 cy_t = pad + CORNER_R
269 cy_b = h - pad - CORNER_R - 1
270 cx = np.clip(xs, cx_l, cx_r)
271 cy = np.clip(ys, cy_t, cy_b)
272 dx = xs - cx
273 dy = ys - cy
274 dist = np.sqrt(dx * dx + dy * dy) - CORNER_R
275 alpha = np.clip(1.0 - dist / pad, 0.0, 1.0) ** 2
276 img[..., 3] = (alpha * 110).astype(np.uint8)
277 return img
278
279
280_CACHE: dict[str, np.ndarray] = {}
281
282
283def get_card_face(card: CardId) -> np.ndarray:
284 key = str(card)
285 if key not in _CACHE:
286 _CACHE[key] = make_card_face(card)
287 return _CACHE[key]
288
289
290def get_shadow() -> np.ndarray:
291 if "_shadow" not in _CACHE:
292 _CACHE["_shadow"] = make_card_shadow()
293 return _CACHE["_shadow"]
294
295
296def make_default_hand() -> list[CardId]:
297 """Seven mixed cards for the demo hand."""
298 return [
299 CardId("A", "S"),
300 CardId("K", "H"),
301 CardId("Q", "D"),
302 CardId("J", "C"),
303 CardId("10", "H"),
304 CardId("9", "S"),
305 CardId("7", "D"),
306 ]