nodes/textures.pyΒΆ

Part of Claustrowordia.

  1"""Procedural tile textures.
  2
  3Each tile face is an RGBA uint8 ndarray: rounded square with a centred
  4letter glyph. Built once per (letter, palette) and cached.
  5"""
  6
  7from __future__ import annotations
  8
  9import numpy as np
 10
 11TILE_SIZE = 72
 12CORNER_R = 10
 13
 14# Palette: (body_rgb, edge_rgb, glyph_rgb)
 15PALETTE_NORMAL = ((242, 230, 200), (180, 150, 90), (60, 40, 20))  # warm parchment, dark glyph
 16PALETTE_MATCHED = ((110, 200, 130), (50, 130, 70), (255, 255, 240))  # green flash on score
 17PALETTE_LOCKED = ((228, 200, 165), (160, 110, 60), (70, 45, 20))  # warm tan (initial centre tiles)
 18
 19
 20def _aa_rounded_alpha(w: int, h: int, r: float) -> np.ndarray:
 21    ys = (np.arange(h) + 0.5)[:, None]
 22    xs = (np.arange(w) + 0.5)[None, :]
 23    cx = np.clip(xs, r, w - r)
 24    cy = np.clip(ys, r, h - r)
 25    dx = xs - cx
 26    dy = ys - cy
 27    dist = np.sqrt(dx * dx + dy * dy)
 28    return np.clip(r + 0.5 - dist, 0.0, 1.0).astype(np.float32)
 29
 30
 31def make_tile(letter: str, palette: tuple = PALETTE_NORMAL) -> np.ndarray:
 32    """Build an RGBA tile face (body only). The letter is drawn as a Text2D
 33    overlay so it renders via the MSDF path on both desktop and the freetype-less
 34    web runtime (freetype is stubbed out under Pyodide)."""
 35    body_rgb, edge_rgb, _glyph = palette
 36    img = np.zeros((TILE_SIZE, TILE_SIZE, 4), dtype=np.uint8)
 37    alpha = _aa_rounded_alpha(TILE_SIZE, TILE_SIZE, CORNER_R)
 38
 39    # Body fill
 40    body = np.array(body_rgb, dtype=np.float32)
 41    img[..., 0] = body[0]
 42    img[..., 1] = body[1]
 43    img[..., 2] = body[2]
 44    img[..., 3] = (alpha * 255).astype(np.uint8)
 45
 46    # Inset rounded rect for "edge highlight": a band around the rim
 47    inset = 4
 48    inner_alpha = np.zeros_like(alpha)
 49    if TILE_SIZE > 2 * inset:
 50        inner = _aa_rounded_alpha(TILE_SIZE - 2 * inset, TILE_SIZE - 2 * inset, CORNER_R - 3)
 51        inner_alpha[inset : TILE_SIZE - inset, inset : TILE_SIZE - inset] = inner
 52    band = np.clip(alpha - inner_alpha, 0.0, 1.0)
 53    edge = np.array(edge_rgb, dtype=np.float32)
 54    for c in range(3):
 55        img[..., c] = np.clip(
 56            img[..., c].astype(np.float32) * (1.0 - band * 0.55) + edge[c] * band * 0.55, 0, 255
 57        ).astype(np.uint8)
 58
 59    # Top highlight gradient: subtle sheen
 60    sheen = np.clip(1.0 - np.linspace(0, 1.5, TILE_SIZE), 0.0, 1.0)[:, None] * inner_alpha * 0.18
 61    for c in range(3):
 62        img[..., c] = np.clip(img[..., c].astype(np.float32) + 255.0 * sheen, 0, 255).astype(np.uint8)
 63
 64    return img
 65
 66
 67_PALETTES = {"normal": PALETTE_NORMAL, "matched": PALETTE_MATCHED, "locked": PALETTE_LOCKED}
 68
 69
 70def glyph_colour(palette_key: str = "normal") -> tuple[float, float, float, float]:
 71    """Letter colour for a palette as 0-1 floats (for the Text2D letter overlay)."""
 72    r, g, b = _PALETTES[palette_key][2]
 73    return (r / 255.0, g / 255.0, b / 255.0, 1.0)
 74
 75
 76def make_drop_preview() -> np.ndarray:
 77    """Translucent square preview shown under the held tile snap-to-cell."""
 78    img = np.zeros((TILE_SIZE, TILE_SIZE, 4), dtype=np.uint8)
 79    alpha = _aa_rounded_alpha(TILE_SIZE, TILE_SIZE, CORNER_R)
 80    img[..., 0] = 255
 81    img[..., 1] = 240
 82    img[..., 2] = 180
 83    img[..., 3] = (alpha * 90).astype(np.uint8)
 84    return img
 85
 86
 87def make_grid_cell() -> np.ndarray:
 88    """Empty grid cell: soft inset square."""
 89    img = np.zeros((TILE_SIZE, TILE_SIZE, 4), dtype=np.uint8)
 90    alpha = _aa_rounded_alpha(TILE_SIZE, TILE_SIZE, CORNER_R)
 91    inset = 6
 92    inner = _aa_rounded_alpha(TILE_SIZE - 2 * inset, TILE_SIZE - 2 * inset, CORNER_R - 3)
 93    inner_full = np.zeros_like(alpha)
 94    inner_full[inset : TILE_SIZE - inset, inset : TILE_SIZE - inset] = inner
 95    img[..., 0] = 60
 96    img[..., 1] = 55
 97    img[..., 2] = 50
 98    img[..., 3] = (alpha * 160 - inner_full * 110).clip(0, 255).astype(np.uint8)
 99    return img
100
101
102_CACHE: dict[str, np.ndarray] = {}
103
104
105def get_tile(letter: str, palette_key: str = "normal") -> np.ndarray:
106    key = f"{letter}|{palette_key}"
107    if key not in _CACHE:
108        palette = {
109            "normal": PALETTE_NORMAL,
110            "matched": PALETTE_MATCHED,
111            "locked": PALETTE_LOCKED,
112        }[palette_key]
113        _CACHE[key] = make_tile(letter, palette)
114    return _CACHE[key]
115
116
117def get_grid_cell() -> np.ndarray:
118    if "_grid_cell" not in _CACHE:
119        _CACHE["_grid_cell"] = make_grid_cell()
120    return _CACHE["_grid_cell"]
121
122
123def get_drop_preview() -> np.ndarray:
124    if "_drop_preview" not in _CACHE:
125        _CACHE["_drop_preview"] = make_drop_preview()
126    return _CACHE["_drop_preview"]
127
128
129__all__ = ["TILE_SIZE", "get_tile", "get_grid_cell", "get_drop_preview"]