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    ]