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