nodes/textures.py¶

Part of Q1K3.

  1"""Procedural textures for Q1K3 port.
  2
  3Upstream uses Tiny Texture Tumbler (TTT): a JS DSL that generates 64x64 RGBA
  4canvases by stacking primitives (rect, gradient, noise, dither). We replicate
  5the gist with numpy: each texture is a 64x64 RGBA uint8 ndarray.
  6
  7Texture indices intentionally match the upstream TEX_* numeric IDs used by the
  8hand-authored Map 1 in `maps.py`.
  9"""
 10
 11from __future__ import annotations
 12
 13import numpy as np
 14
 15# Texture indices (used by maps.py and entities)
 16TEX_FLOOR_STONE = 0
 17TEX_WALL_BRICK = 1
 18TEX_NAIL = 2
 19TEX_CEILING = 3
 20TEX_NAILGUN = 4
 21TEX_FLOOR_WOOD = 5
 22TEX_DOOR = 6
 23TEX_SHOTGUN = 7
 24TEX_GRENADE = 8
 25TEX_DIRT = 9
 26TEX_METAL = 10
 27TEX_TRIM = 11
 28TEX_PICKUP_NAILGUN = 12
 29TEX_BLOCK_DARK = 13
 30TEX_BLOCK_LIGHT = 14
 31TEX_FLOOR_TILE = 15
 32TEX_WALL_PLAIN = 16
 33TEX_GRUNT = 17
 34TEX_ZOMBIE = 18
 35TEX_ENFORCER = 19
 36TEX_OGRE = 20
 37TEX_PLASMA = 21
 38TEX_HOUND = 22
 39TEX_HEALTH = 23
 40TEX_NAILS = 24
 41TEX_GRENADES = 25
 42TEX_KEY_DOOR = 26
 43TEX_PIPE = 27
 44TEX_GIB = 28
 45TEX_PARTICLE = 29
 46TEX_TORCH = 30
 47TEX_BLOOD = 31
 48
 49_TEX_SIZE = 64
 50
 51
 52def _rng(seed: int) -> np.random.Generator:
 53    return np.random.default_rng(seed)
 54
 55
 56def _solid(rgb: tuple[int, int, int]) -> np.ndarray:
 57    img = np.zeros((_TEX_SIZE, _TEX_SIZE, 4), dtype=np.uint8)
 58    img[..., 0] = rgb[0]
 59    img[..., 1] = rgb[1]
 60    img[..., 2] = rgb[2]
 61    img[..., 3] = 255
 62    return img
 63
 64
 65def _noise_overlay(img: np.ndarray, seed: int, amount: int = 24) -> np.ndarray:
 66    rng = _rng(seed)
 67    n = rng.integers(-amount, amount, size=(_TEX_SIZE, _TEX_SIZE), dtype=np.int16)
 68    out = img.copy()
 69    rgb = out[..., :3].astype(np.int16) + n[..., None]
 70    out[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
 71    return out
 72
 73
 74def _brick(seed: int, base: tuple[int, int, int], mortar: tuple[int, int, int]) -> np.ndarray:
 75    img = _solid(base)
 76    img = _noise_overlay(img, seed, amount=18)
 77    # Horizontal mortar lines every 16 px, vertical lines staggered every 32 px
 78    for row in range(0, _TEX_SIZE, 16):
 79        img[row : row + 2, :] = (*mortar, 255)
 80    for row in range(0, _TEX_SIZE, 16):
 81        offset = 16 if (row // 16) % 2 == 0 else 0
 82        for col in range(offset, _TEX_SIZE, 32):
 83            img[row : row + 16, col : col + 2] = (*mortar, 255)
 84    return img
 85
 86
 87def _stone(seed: int, base: tuple[int, int, int]) -> np.ndarray:
 88    img = _solid(base)
 89    img = _noise_overlay(img, seed, amount=22)
 90    rng = _rng(seed + 1)
 91    # Sprinkle darker speckles
 92    n = rng.integers(0, _TEX_SIZE, size=(80, 2))
 93    for x, y in n:
 94        img[y, x, :3] = np.clip(img[y, x, :3].astype(int) - 40, 0, 255)
 95    return img
 96
 97
 98def _wood(seed: int) -> np.ndarray:
 99    img = _solid((100, 60, 30))
100    img = _noise_overlay(img, seed, amount=14)
101    # Vertical streaks
102    rng = _rng(seed + 2)
103    for col in range(0, _TEX_SIZE, 4):
104        delta = rng.integers(-12, 12)
105        img[:, col : col + 2, :3] = np.clip(img[:, col : col + 2, :3].astype(int) + delta, 0, 255).astype(np.uint8)
106    return img
107
108
109def _metal(seed: int) -> np.ndarray:
110    img = _solid((110, 110, 120))
111    img = _noise_overlay(img, seed, amount=10)
112    # Horizontal panel lines every 32 px
113    for row in (0, 32):
114        img[row : row + 1, :] = (60, 60, 70, 255)
115    return img
116
117
118def _tile(seed: int, c1: tuple[int, int, int], c2: tuple[int, int, int]) -> np.ndarray:
119    img = _solid(c1)
120    img = _noise_overlay(img, seed, amount=6)
121    for row in range(0, _TEX_SIZE, 16):
122        for col in range(0, _TEX_SIZE, 16):
123            if ((row // 16) + (col // 16)) % 2 == 0:
124                img[row : row + 16, col : col + 16, :3] = c2
125    img = _noise_overlay(img, seed + 3, amount=8)
126    # Grout
127    for row in range(0, _TEX_SIZE, 16):
128        img[row : row + 1, :] = (40, 40, 40, 255)
129    for col in range(0, _TEX_SIZE, 16):
130        img[:, col : col + 1] = (40, 40, 40, 255)
131    return img
132
133
134def _door(seed: int, c: tuple[int, int, int]) -> np.ndarray:
135    img = _solid(c)
136    img = _noise_overlay(img, seed, amount=12)
137    # Vertical centre line
138    img[:, _TEX_SIZE // 2 - 1 : _TEX_SIZE // 2 + 1, :3] = (30, 30, 30)
139    # Border
140    img[:2, :, :3] = (40, 40, 40)
141    img[-2:, :, :3] = (40, 40, 40)
142    img[:, :2, :3] = (40, 40, 40)
143    img[:, -2:, :3] = (40, 40, 40)
144    return img
145
146
147def _torch_frame(seed: int) -> np.ndarray:
148    img = _solid((40, 28, 18))
149    rng = _rng(seed)
150    # Yellow flame at top
151    for r in range(0, 28):
152        intensity = 1.0 - r / 28.0
153        for c in range(_TEX_SIZE):
154            if 22 < c < 42 and rng.random() < intensity:
155                yellow = int(180 + 70 * intensity)
156                img[r, c, :3] = (yellow, int(yellow * 0.7), 30)
157    return img
158
159
160def _gun(seed: int, c: tuple[int, int, int]) -> np.ndarray:
161    img = _solid(c)
162    img = _noise_overlay(img, seed, amount=14)
163    # Centre highlight stripe
164    img[_TEX_SIZE // 2 - 4 : _TEX_SIZE // 2 + 4, :, :3] = np.clip(
165        img[_TEX_SIZE // 2 - 4 : _TEX_SIZE // 2 + 4, :, :3].astype(int) + 30, 0, 255
166    ).astype(np.uint8)
167    return img
168
169
170def _enemy_skin(seed: int, c: tuple[int, int, int]) -> np.ndarray:
171    img = _solid(c)
172    img = _noise_overlay(img, seed, amount=24)
173    # Two dark eye-spots
174    img[24:30, 18:24, :3] = (10, 0, 0)
175    img[24:30, 40:46, :3] = (10, 0, 0)
176    return img
177
178
179_TEXTURES: dict[int, np.ndarray] = {}
180
181
182def get(idx: int) -> np.ndarray:
183    """Return the cached 64x64 RGBA texture for index *idx*."""
184    if idx not in _TEXTURES:
185        _TEXTURES[idx] = _build(idx)
186    return _TEXTURES[idx]
187
188
189def _build(idx: int) -> np.ndarray:
190    if idx == TEX_FLOOR_STONE:
191        return _stone(seed=10, base=(110, 105, 100))
192    if idx == TEX_WALL_BRICK:
193        return _brick(seed=11, base=(140, 80, 60), mortar=(60, 50, 45))
194    if idx == TEX_NAIL:
195        return _solid((180, 180, 200))
196    if idx == TEX_CEILING:
197        return _stone(seed=12, base=(70, 70, 80))
198    if idx == TEX_NAILGUN:
199        return _gun(seed=13, c=(80, 60, 40))
200    if idx == TEX_FLOOR_WOOD:
201        return _wood(seed=14)
202    if idx == TEX_DOOR:
203        return _door(seed=15, c=(120, 80, 50))
204    if idx == TEX_SHOTGUN:
205        return _gun(seed=16, c=(60, 50, 50))
206    if idx == TEX_GRENADE:
207        return _solid((40, 50, 40))
208    if idx == TEX_DIRT:
209        return _stone(seed=17, base=(80, 60, 40))
210    if idx == TEX_METAL:
211        return _metal(seed=18)
212    if idx == TEX_TRIM:
213        return _solid((100, 100, 110))
214    if idx == TEX_PICKUP_NAILGUN:
215        return _gun(seed=19, c=(140, 100, 70))
216    if idx == TEX_BLOCK_DARK:
217        return _stone(seed=20, base=(50, 50, 60))
218    if idx == TEX_BLOCK_LIGHT:
219        return _stone(seed=21, base=(170, 170, 180))
220    if idx == TEX_FLOOR_TILE:
221        return _tile(seed=22, c1=(180, 180, 180), c2=(140, 140, 140))
222    if idx == TEX_WALL_PLAIN:
223        return _solid((130, 130, 140))
224    if idx == TEX_GRUNT:
225        return _enemy_skin(seed=23, c=(120, 100, 70))
226    if idx == TEX_ZOMBIE:
227        return _enemy_skin(seed=24, c=(80, 130, 70))
228    if idx == TEX_ENFORCER:
229        return _enemy_skin(seed=25, c=(80, 80, 130))
230    if idx == TEX_OGRE:
231        return _enemy_skin(seed=26, c=(150, 100, 60))
232    if idx == TEX_PLASMA:
233        return _solid((255, 200, 80))
234    if idx == TEX_HOUND:
235        return _enemy_skin(seed=27, c=(120, 80, 50))
236    if idx == TEX_HEALTH:
237        img = _solid((220, 60, 60))
238        # White cross
239        img[28:36, 16:48, :3] = (240, 240, 240)
240        img[16:48, 28:36, :3] = (240, 240, 240)
241        return img
242    if idx == TEX_NAILS:
243        return _solid((200, 200, 220))
244    if idx == TEX_GRENADES:
245        return _solid((90, 110, 70))
246    if idx == TEX_KEY_DOOR:
247        return _solid((220, 200, 60))
248    if idx == TEX_PIPE:
249        return _metal(seed=28)
250    if idx == TEX_GIB:
251        return _solid((140, 30, 30))
252    if idx == TEX_PARTICLE:
253        return _solid((255, 200, 80))
254    if idx == TEX_TORCH:
255        return _torch_frame(seed=29)
256    if idx == TEX_BLOOD:
257        return _solid((180, 30, 30))
258    # Fallback
259    return _solid((200, 0, 200))
260
261
262def warm_all() -> None:
263    """Pre-build every texture used by the port (call once at boot)."""
264    for idx in range(32):
265        get(idx)