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