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)