nodes/card_textures.py¶

Part of Klondike Solitaire.

  1"""Procedural card textures for Klondike Solitaire.
  2
  3Mirrors the Balatro-Feel port's `card_textures.py` (`examples/ports/balatro_feel`)
  4with two extensions:
  5
  6  - `get_card_back()` returns the face-down card texture.
  7  - `get_empty_slot()` returns a transparent rounded rect outline used for
  8    empty foundation/tableau/stock placeholders.
  9
 10Each face is an RGBA uint8 ndarray with rounded corners, suit pip, and rank.
 11Built once at startup and cached. Sprite2D consumes the array directly via the
 12engine's ndarray-as-texture path -- no PNG file IO required.
 13"""
 14
 15from __future__ import annotations
 16
 17from dataclasses import dataclass
 18from functools import cache
 19
 20import numpy as np
 21
 22from simvx.core.text.measure import LOGICAL_EM
 23from simvx.graphics.draw2d import Draw2D
 24
 25# Card visual size. Solitaire fits 7 columns + table padding into 1280px so we
 26# size the cards a touch smaller than Balatro's 220x308 to keep margins clean.
 27CARD_W = 130
 28CARD_H = 182
 29CORNER_R = 14
 30BORDER = 4
 31
 32SUIT_COLOURS = {
 33    "H": (210, 50, 60),
 34    "D": (210, 50, 60),
 35    "S": (30, 30, 36),
 36    "C": (30, 30, 36),
 37}
 38
 39RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
 40SUITS = ["S", "H", "D", "C"]  # spades, hearts, diamonds, clubs
 41
 42
 43@dataclass(frozen=True)
 44class CardId:
 45    rank: str  # "A", "2".."10", "J", "Q", "K"
 46    suit: str  # "H", "D", "S", "C"
 47
 48    def __str__(self) -> str:
 49        return f"{self.rank}{self.suit}"
 50
 51    @property
 52    def is_red(self) -> bool:
 53        return self.suit in ("H", "D")
 54
 55    @property
 56    def value(self) -> int:
 57        return RANKS.index(self.rank)
 58
 59
 60# ---------------------------------------------------------------------------
 61# Anti-aliased rounded-rect alpha (shared by card body and shadow)
 62# ---------------------------------------------------------------------------
 63
 64
 65def _aa_rounded_alpha(w: int, h: int, r: float) -> np.ndarray:
 66    ys = (np.arange(h) + 0.5)[:, None]
 67    xs = (np.arange(w) + 0.5)[None, :]
 68    cx = np.clip(xs, r, w - r)
 69    cy = np.clip(ys, r, h - r)
 70    dx = xs - cx
 71    dy = ys - cy
 72    dist = np.sqrt(dx * dx + dy * dy)
 73    return np.clip(r + 0.5 - dist, 0.0, 1.0).astype(np.float32)
 74
 75
 76# ---------------------------------------------------------------------------
 77# Rank text
 78# ---------------------------------------------------------------------------
 79
 80
 81@cache
 82def _text_ink(text, size):
 83    """``text`` in the engine's 2D font as a 0-1 coverage mask, ``size`` px to the em.
 84
 85    Drawn by the engine rather than a font library loaded here, so the face bakes
 86    the same on desktop and in the browser. Cached per rank: every suit shares
 87    one mask and only its tint differs.
 88    """
 89    return Draw2D.text_pixels(text, scale=size / LOGICAL_EM)[..., 3].astype(np.float32) / 255.0
 90
 91
 92def _stamp_text(target, text, x, baseline_y, size, colour):
 93    """Composite ``text`` into ``target`` with its pen at ``(x, baseline_y)``."""
 94    ink = _text_ink(text, size)
 95    # The engine's image keeps a 2 px margin, so its pen sits at (2, 2 + ascent).
 96    left = x - 2
 97    top = baseline_y - 2 - round(Draw2D.text_ascent(size / LOGICAL_EM))
 98    x0, y0 = max(left, 0), max(top, 0)
 99    x1 = min(left + ink.shape[1], target.shape[1])
100    y1 = min(top + ink.shape[0], target.shape[0])
101    if x1 <= x0 or y1 <= y0:
102        return
103    alpha = ink[y0 - top : y1 - top, x0 - left : x1 - left, None]
104    dst = target[y0:y1, x0:x1].astype(np.float32)
105    dst[..., :3] = dst[..., :3] * (1.0 - alpha) + np.asarray(colour, dtype=np.float32) * alpha
106    dst[..., 3:] += 255.0 * alpha * (1.0 - dst[..., 3:] / 255.0)
107    target[y0:y1, x0:x1] = np.clip(dst, 0, 255).astype(np.uint8)
108
109
110# ---------------------------------------------------------------------------
111# Vector suit pips
112# ---------------------------------------------------------------------------
113
114
115def _aa_triangle(xs, ys, p0, p1, p2):
116    def edge(a, b, x, y):
117        return (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0])
118
119    s0 = edge(p0, p1, xs, ys)
120    s1 = edge(p1, p2, xs, ys)
121    s2 = edge(p2, p0, xs, ys)
122    inside = ((s0 >= 0) & (s1 >= 0) & (s2 >= 0)) | ((s0 <= 0) & (s1 <= 0) & (s2 <= 0))
123    return inside.astype(np.float32)
124
125
126def _heart_alpha(size):
127    ys = np.linspace(0.0, 1.0, size)[:, None]
128    xs = np.linspace(-0.5, 0.5, size)[None, :]
129    r = 0.25
130    left = np.sqrt((xs + 0.25) ** 2 + (ys - 0.30) ** 2) <= r
131    right = np.sqrt((xs - 0.25) ** 2 + (ys - 0.30) ** 2) <= r
132    tri = _aa_triangle(xs, ys, (-0.50, 0.30), (0.50, 0.30), (0.0, 0.95))
133    a = left.astype(np.float32) + right.astype(np.float32) + tri
134    return np.clip(a, 0.0, 1.0).astype(np.float32)
135
136
137def _diamond_alpha(size):
138    ys = np.linspace(0.0, 1.0, size)[:, None]
139    xs = np.linspace(-0.5, 0.5, size)[None, :]
140    d = np.abs(xs) / 0.40 + np.abs(ys - 0.5) / 0.50
141    return np.where(d <= 1.0, 1.0, 0.0).astype(np.float32)
142
143
144def _spade_alpha(size):
145    ys = np.linspace(0.0, 1.0, size)[:, None]
146    xs = np.linspace(-0.5, 0.5, size)[None, :]
147    r = 0.25
148    left = np.sqrt((xs + 0.25) ** 2 + (ys - 0.65) ** 2) <= r
149    right = np.sqrt((xs - 0.25) ** 2 + (ys - 0.65) ** 2) <= r
150    tri = _aa_triangle(xs, ys, (-0.50, 0.65), (0.50, 0.65), (0.0, 0.05))
151    body = np.clip(left.astype(np.float32) + right.astype(np.float32) + tri, 0.0, 1.0)
152    stem = _aa_triangle(xs, ys, (-0.18, 0.85), (0.18, 0.85), (0.10, 0.98)) + _aa_triangle(
153        xs, ys, (-0.18, 0.85), (0.10, 0.98), (-0.10, 0.98)
154    )
155    return np.clip(body + stem, 0.0, 1.0).astype(np.float32)
156
157
158def _club_alpha(size):
159    ys = np.linspace(0.0, 1.0, size)[:, None]
160    xs = np.linspace(-0.5, 0.5, size)[None, :]
161    r = 0.22
162    top = np.sqrt(xs**2 + (ys - 0.30) ** 2) <= r
163    left = np.sqrt((xs + 0.22) ** 2 + (ys - 0.55) ** 2) <= r
164    right = np.sqrt((xs - 0.22) ** 2 + (ys - 0.55) ** 2) <= r
165    body = np.clip(top.astype(np.float32) + left.astype(np.float32) + right.astype(np.float32), 0.0, 1.0)
166    stem = _aa_triangle(xs, ys, (-0.18, 0.78), (0.18, 0.78), (0.10, 0.98)) + _aa_triangle(
167        xs, ys, (-0.18, 0.78), (0.10, 0.98), (-0.10, 0.98)
168    )
169    return np.clip(body + stem, 0.0, 1.0).astype(np.float32)
170
171
172_SUIT_ALPHA_FNS = {"H": _heart_alpha, "D": _diamond_alpha, "S": _spade_alpha, "C": _club_alpha}
173
174
175def _stamp_suit(target, suit, cx, cy, size, colour):
176    alpha = _SUIT_ALPHA_FNS[suit](size)
177    h, w = alpha.shape
178    x0 = cx - w // 2
179    y0 = cy - h // 2
180    th, tw = target.shape[:2]
181    sx0 = max(x0, 0)
182    sy0 = max(y0, 0)
183    sx1 = min(x0 + w, tw)
184    sy1 = min(y0 + h, th)
185    if sx1 <= sx0 or sy1 <= sy0:
186        return
187    src = alpha[sy0 - y0 : sy1 - y0, sx0 - x0 : sx1 - x0]
188    alpha_src = src[..., None]
189    dst = target[sy0:sy1, sx0:sx1].astype(np.float32)
190    rgb = np.array(colour, dtype=np.float32)
191    blended_rgb = dst[..., :3] * (1.0 - alpha_src) + rgb * alpha_src
192    blended_a = np.maximum(dst[..., 3:], (alpha_src * 255.0))
193    out = np.concatenate([blended_rgb, blended_a], axis=-1)
194    target[sy0:sy1, sx0:sx1] = np.clip(out, 0, 255).astype(np.uint8)
195
196
197# ---------------------------------------------------------------------------
198# Card faces / back / empty slot
199# ---------------------------------------------------------------------------
200
201
202def make_card_face(card: CardId) -> np.ndarray:
203    img = np.zeros((CARD_H, CARD_W, 4), dtype=np.uint8)
204
205    alpha = _aa_rounded_alpha(CARD_W, CARD_H, CORNER_R)
206    img[..., 0] = 252
207    img[..., 1] = 250
208    img[..., 2] = 245
209    img[..., 3] = (alpha * 255).astype(np.uint8)
210
211    # Inner darker border
212    inner = _aa_rounded_alpha(CARD_W - 2 * BORDER, CARD_H - 2 * BORDER, CORNER_R - 3)
213    inner_full = np.zeros((CARD_H, CARD_W), dtype=np.float32)
214    inner_full[BORDER : CARD_H - BORDER, BORDER : CARD_W - BORDER] = inner
215    border_band = np.clip(alpha - inner_full, 0.0, 1.0)
216    edge_colour = np.array([220, 215, 205], dtype=np.float32)
217    for c in range(3):
218        img[..., c] = np.clip(
219            img[..., c] * (1.0 - border_band * 0.5) + edge_colour[c] * border_band * 0.5, 0, 255
220        ).astype(np.uint8)
221
222    suit_rgb = SUIT_COLOURS[card.suit]
223    rank = card.rank
224    rank_size = 32
225    suit_small = 18
226    margin = 10
227    rank_baseline = margin + rank_size
228    pip_centre_y = rank_baseline + suit_small // 2 + 2
229    pip_centre_x = margin + suit_small // 2
230
231    _stamp_text(img, rank, margin, rank_baseline, rank_size, suit_rgb)
232    _stamp_suit(img, card.suit, pip_centre_x, pip_centre_y, suit_small, suit_rgb)
233
234    # Bottom-right corner: stamp into copy and rotate 180 (flip both axes)
235    bot = np.zeros_like(img)
236    _stamp_text(bot, rank, margin, rank_baseline, rank_size, suit_rgb)
237    _stamp_suit(bot, card.suit, pip_centre_x, pip_centre_y, suit_small, suit_rgb)
238    bot_flipped = bot[::-1, ::-1, :]
239    bot_a = bot_flipped[..., 3:].astype(np.float32) / 255.0
240    img_f = img.astype(np.float32)
241    img_f[..., :3] = img_f[..., :3] * (1.0 - bot_a) + bot_flipped[..., :3].astype(np.float32) * bot_a
242    img_f[..., 3:] = np.maximum(img_f[..., 3:], bot_flipped[..., 3:].astype(np.float32))
243    img = np.clip(img_f, 0, 255).astype(np.uint8)
244
245    # Centre suit pip
246    centre_size = 60
247    cx = CARD_W // 2
248    cy = CARD_H // 2
249    _stamp_suit(img, card.suit, cx, cy, centre_size, suit_rgb)
250
251    # Re-mask alpha to the rounded rect
252    img[..., 3] = np.minimum(img[..., 3], (alpha * 255).astype(np.uint8))
253    return img
254
255
256def make_card_back() -> np.ndarray:
257    """Face-down card: deep blue with white diamond pattern + border."""
258    img = np.zeros((CARD_H, CARD_W, 4), dtype=np.uint8)
259    alpha = _aa_rounded_alpha(CARD_W, CARD_H, CORNER_R)
260
261    # Body: deep blue
262    img[..., 0] = 36
263    img[..., 1] = 60
264    img[..., 2] = 130
265    img[..., 3] = (alpha * 255).astype(np.uint8)
266
267    # Inner band
268    inner = _aa_rounded_alpha(CARD_W - 2 * BORDER, CARD_H - 2 * BORDER, CORNER_R - 3)
269    inner_full = np.zeros((CARD_H, CARD_W), dtype=np.float32)
270    inner_full[BORDER : CARD_H - BORDER, BORDER : CARD_W - BORDER] = inner
271    border_band = np.clip(alpha - inner_full, 0.0, 1.0)
272    light_edge = np.array([200, 215, 240], dtype=np.float32)
273    for c in range(3):
274        img[..., c] = np.clip(
275            img[..., c] * (1.0 - border_band * 0.7) + light_edge[c] * border_band * 0.7, 0, 255
276        ).astype(np.uint8)
277
278    # Crosshatch diamond pattern in the inner band
279    ys = np.arange(CARD_H)[:, None]
280    xs = np.arange(CARD_W)[None, :]
281    pattern_a = (xs + ys) % 14 < 2
282    pattern_b = (xs - ys) % 14 < 2
283    pattern = (pattern_a | pattern_b).astype(np.float32) * inner_full * 0.5
284    pattern_rgb = np.array([180, 200, 235], dtype=np.float32)
285    for c in range(3):
286        img[..., c] = np.clip(img[..., c] * (1.0 - pattern) + pattern_rgb[c] * pattern, 0, 255).astype(np.uint8)
287
288    img[..., 3] = np.minimum(img[..., 3], (alpha * 255).astype(np.uint8))
289    return img
290
291
292def make_empty_slot() -> np.ndarray:
293    """Empty pile placeholder: faint outline only, transparent fill."""
294    img = np.zeros((CARD_H, CARD_W, 4), dtype=np.uint8)
295    alpha = _aa_rounded_alpha(CARD_W, CARD_H, CORNER_R)
296    inner = _aa_rounded_alpha(CARD_W - 2 * BORDER, CARD_H - 2 * BORDER, CORNER_R - 3)
297    inner_full = np.zeros((CARD_H, CARD_W), dtype=np.float32)
298    inner_full[BORDER : CARD_H - BORDER, BORDER : CARD_W - BORDER] = inner
299    border_band = np.clip(alpha - inner_full, 0.0, 1.0)
300    img[..., 0] = 255
301    img[..., 1] = 255
302    img[..., 2] = 255
303    img[..., 3] = (border_band * 70).astype(np.uint8)
304    return img
305
306
307def make_card_shadow() -> np.ndarray:
308    pad = 12
309    w = CARD_W + 2 * pad
310    h = CARD_H + 2 * pad
311    img = np.zeros((h, w, 4), dtype=np.uint8)
312    ys = np.arange(h)[:, None]
313    xs = np.arange(w)[None, :]
314    cx_l = pad + CORNER_R
315    cx_r = w - pad - CORNER_R - 1
316    cy_t = pad + CORNER_R
317    cy_b = h - pad - CORNER_R - 1
318    cx = np.clip(xs, cx_l, cx_r)
319    cy = np.clip(ys, cy_t, cy_b)
320    dx = xs - cx
321    dy = ys - cy
322    dist = np.sqrt(dx * dx + dy * dy) - CORNER_R
323    a = np.clip(1.0 - dist / pad, 0.0, 1.0) ** 2
324    img[..., 3] = (a * 90).astype(np.uint8)
325    return img
326
327
328_CACHE: dict[str, np.ndarray] = {}
329
330
331def get_card_face(card: CardId) -> np.ndarray:
332    key = str(card)
333    if key not in _CACHE:
334        _CACHE[key] = make_card_face(card)
335    return _CACHE[key]
336
337
338def get_card_back() -> np.ndarray:
339    if "_back" not in _CACHE:
340        _CACHE["_back"] = make_card_back()
341    return _CACHE["_back"]
342
343
344def get_empty_slot() -> np.ndarray:
345    if "_slot" not in _CACHE:
346        _CACHE["_slot"] = make_empty_slot()
347    return _CACHE["_slot"]
348
349
350def get_shadow() -> np.ndarray:
351    if "_shadow" not in _CACHE:
352        _CACHE["_shadow"] = make_card_shadow()
353    return _CACHE["_shadow"]
354
355
356def make_full_deck() -> list[CardId]:
357    """Return all 52 cards in suit-major rank-minor order."""
358    return [CardId(rank, suit) for suit in SUITS for rank in RANKS]