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"]