nodes/textures.py¶
Part of Mr. Rescue.
1"""Procedural NumPy textures.
2
3Generated once at module import. Returned as ``uint8`` RGBA arrays so
4``Sprite2D(texture=...)`` can adopt them directly. All colours come from
5:mod:`nodes.colours`.
6
7Sizes are kept in upstream units (16-px tiles, 16x22 player) so positioning
8matches the original gameplay. Sprite2D + ``filter="nearest"`` keeps them
9crisp at our 4× zoom.
10"""
11
12from __future__ import annotations
13
14import numpy as np
15
16from . import colours as C
17
18# ---------------------------------------------------------------- helpers
19
20
21def _rgba(c, w: int, h: int) -> np.ndarray:
22 """Solid colour rect as uint8 RGBA."""
23 a = np.zeros((h, w, 4), dtype=np.uint8)
24 r, g, b, al = c
25 a[..., 0] = int(r * 255)
26 a[..., 1] = int(g * 255)
27 a[..., 2] = int(b * 255)
28 a[..., 3] = int(al * 255)
29 return a
30
31
32def _fill_rect(img: np.ndarray, x: int, y: int, w: int, h: int, c) -> None:
33 r, g, b, a = c
34 img[y : y + h, x : x + w] = (
35 int(r * 255),
36 int(g * 255),
37 int(b * 255),
38 int(a * 255),
39 )
40
41
42# ---------------------------------------------------------------- tiles (16x16)
43
44
45def make_wall_tile() -> np.ndarray:
46 img = _rgba(C.WALL, 16, 16)
47 # Brick pattern: 2 rows of bricks per tile.
48 for row in (0, 8):
49 offset = 0 if row == 0 else 4
50 for x in range(0, 16, 8):
51 xs = (x + offset) % 16
52 _fill_rect(img, xs, row, 7, 7, C.WALL_LIGHT)
53 # Mortar gaps -> default WALL colour shows through.
54 return img
55
56
57def make_floor_tile() -> np.ndarray:
58 img = _rgba(C.FLOOR, 16, 16)
59 # Plank lines: horizontal seam mid-tile, vertical seams every 4 px.
60 rng = np.random.default_rng(0xF100)
61 for x in range(0, 16, 4):
62 img[:, x] = (90, 60, 36, 255)
63 img[7, :] = (140, 95, 60, 255)
64 img[8, :] = (60, 38, 22, 255)
65 # Speckles
66 for _ in range(8):
67 sx, sy = int(rng.integers(0, 16)), int(rng.integers(0, 16))
68 img[sy, sx, :3] = np.clip(img[sy, sx, :3].astype(int) - 18, 0, 255)
69 return img
70
71
72def make_ladder_tile() -> np.ndarray:
73 img = _rgba((0, 0, 0, 0), 16, 16)
74 # Two vertical rails + 3 rungs.
75 _fill_rect(img, 4, 0, 2, 16, C.LADDER)
76 _fill_rect(img, 10, 0, 2, 16, C.LADDER)
77 for y in (2, 7, 12):
78 _fill_rect(img, 4, y, 8, 2, C.LADDER)
79 return img
80
81
82def make_door_tile() -> np.ndarray:
83 img = _rgba(C.DOOR, 16, 16)
84 _fill_rect(img, 1, 1, 14, 14, (0.55, 0.36, 0.22, 1.0))
85 _fill_rect(img, 11, 7, 2, 2, C.HUD_FG) # knob
86 return img
87
88
89def make_interior_tile() -> np.ndarray:
90 return _rgba(C.INTERIOR, 16, 16)
91
92
93def make_sky_tile() -> np.ndarray:
94 img = _rgba(C.SKY, 16, 16)
95 # Tiny stars via deterministic RNG.
96 rng = np.random.default_rng(7)
97 for _ in range(2):
98 sx, sy = int(rng.integers(0, 16)), int(rng.integers(0, 16))
99 img[sy, sx] = (220, 220, 240, 255)
100 return img
101
102
103# ---------------------------------------------------------------- player (16x22)
104
105
106def make_player_frames(num: int = 4) -> list[np.ndarray]:
107 """Player run cycle: 4 frames, each 16x22 px."""
108 frames = []
109 for i in range(num):
110 img = _rgba((0, 0, 0, 0), 16, 22)
111 # Boots
112 bob = i % 2 # alternate left/right
113 _fill_rect(img, 3, 19, 4, 3, (0.10, 0.10, 0.12, 1.0)) # left foot
114 _fill_rect(img, 9, 19, 4, 3, (0.10, 0.10, 0.12, 1.0)) # right foot
115 if bob == 0:
116 img[19:22, 9:13] = img[19:22, 3:7] # mirror running pose
117 # Legs (yellow trousers)
118 _fill_rect(img, 4, 13, 8, 6, C.PLAYER_SUIT)
119 _fill_rect(img, 7, 13, 2, 6, (0.50, 0.40, 0.10, 1.0)) # crease
120 # Torso jacket
121 _fill_rect(img, 3, 7, 10, 7, C.PLAYER_SUIT)
122 _fill_rect(img, 3, 9, 10, 1, (0.40, 0.30, 0.05, 1.0)) # belt
123 # Head + helmet
124 _fill_rect(img, 5, 3, 6, 5, C.PLAYER_SKIN)
125 _fill_rect(img, 4, 0, 8, 4, C.PLAYER_HAT)
126 _fill_rect(img, 6, 4, 4, 1, (0.10, 0.10, 0.10, 1.0)) # visor shadow
127 frames.append(img)
128 return frames
129
130
131def make_player_carry_frames(num: int = 4) -> list[np.ndarray]:
132 """Player + civilian piggyback: 22x32."""
133 frames = []
134 for i in range(num):
135 img = _rgba((0, 0, 0, 0), 22, 32)
136 # Player at bottom
137 _fill_rect(img, 7, 22, 4, 4, (0.10, 0.10, 0.12, 1.0))
138 _fill_rect(img, 11, 22, 4, 4, (0.10, 0.10, 0.12, 1.0))
139 if i % 2 == 0:
140 img[22:26, 7:11] = img[22:26, 11:15]
141 _fill_rect(img, 6, 16, 10, 6, C.PLAYER_SUIT)
142 _fill_rect(img, 7, 12, 8, 5, C.PLAYER_SUIT)
143 _fill_rect(img, 7, 9, 8, 4, C.PLAYER_HAT)
144 # Civilian on top
145 _fill_rect(img, 7, 4, 8, 5, C.CIVILIAN_OUTFITS[0])
146 _fill_rect(img, 8, 0, 6, 5, C.CIVILIAN_SKIN)
147 frames.append(img)
148 return frames
149
150
151def make_player_dead() -> np.ndarray:
152 img = _rgba((0, 0, 0, 0), 16, 22)
153 _fill_rect(img, 2, 16, 12, 4, C.PLAYER_SUIT)
154 _fill_rect(img, 4, 12, 8, 5, C.PLAYER_SUIT)
155 _fill_rect(img, 4, 8, 8, 4, C.PLAYER_HAT)
156 return img
157
158
159def make_climb_frames(num: int = 4) -> list[np.ndarray]:
160 frames = []
161 for i in range(num):
162 img = _rgba((0, 0, 0, 0), 14, 23)
163 # Body facing camera, arms alternating
164 _fill_rect(img, 3, 15, 8, 7, C.PLAYER_SUIT)
165 _fill_rect(img, 4, 8, 6, 7, C.PLAYER_SUIT)
166 _fill_rect(img, 4, 4, 6, 5, C.PLAYER_SKIN)
167 _fill_rect(img, 3, 0, 8, 4, C.PLAYER_HAT)
168 # Arm reach
169 if i % 2 == 0:
170 _fill_rect(img, 0, 8, 3, 2, C.PLAYER_SUIT)
171 _fill_rect(img, 11, 12, 3, 2, C.PLAYER_SUIT)
172 else:
173 _fill_rect(img, 0, 12, 3, 2, C.PLAYER_SUIT)
174 _fill_rect(img, 11, 8, 3, 2, C.PLAYER_SUIT)
175 frames.append(img)
176 return frames
177
178
179# ---------------------------------------------------------------- water tip (12x9)
180
181
182def make_water_tip(hit: bool = False) -> np.ndarray:
183 img = _rgba((0, 0, 0, 0), 12, 9)
184 # Splash burst (3 bursts forming a "v" shape)
185 centres = [(2, 4), (6, 2), (6, 6), (10, 4)] if hit else [(3, 4), (8, 4)]
186 radius = 3 if hit else 2
187 for cx, cy in centres:
188 for dy in range(-radius, radius + 1):
189 for dx in range(-radius, radius + 1):
190 if dx * dx + dy * dy <= radius * radius:
191 x, y = cx + dx, cy + dy
192 if 0 <= x < 12 and 0 <= y < 9:
193 img[y, x] = (
194 int(C.WATER_TIP[0] * 255),
195 int(C.WATER_TIP[1] * 255),
196 int(C.WATER_TIP[2] * 255),
197 240,
198 )
199 return img
200
201
202def make_water_segment() -> np.ndarray:
203 """A 16x9 px tile of running water; loops by repeating horizontally."""
204 img = _rgba((0, 0, 0, 0), 16, 9)
205 rng = np.random.default_rng(0xC0FFEE)
206 base = (
207 int(C.WATER[0] * 255),
208 int(C.WATER[1] * 255),
209 int(C.WATER[2] * 255),
210 int(C.WATER[3] * 255),
211 )
212 for x in range(16):
213 for y in range(2, 7):
214 img[y, x] = base
215 # Speckle highlights
216 for _ in range(8):
217 sx = int(rng.integers(0, 16))
218 sy = int(rng.integers(2, 7))
219 img[sy, sx] = (
220 int(C.WATER_TIP[0] * 255),
221 int(C.WATER_TIP[1] * 255),
222 int(C.WATER_TIP[2] * 255),
223 255,
224 )
225 return img
226
227
228# ---------------------------------------------------------------- fire frames (16x16)
229
230
231def make_fire_frames(num: int = 5) -> list[np.ndarray]:
232 """Animated flame, drawn as soft palette bands."""
233 frames = []
234 rng = np.random.default_rng(123)
235 for f in range(num):
236 img = _rgba((0, 0, 0, 0), 16, 16)
237 # Phase-shifted vertical gradient (yellow at top, red at base).
238 for y in range(16):
239 # Wobble of base radius based on f.
240 radius = 5 + (1 if (y + f) % 3 == 0 else 0)
241 cx = 8 + (1 if (y + f) % 4 == 0 else 0)
242 if y < 5:
243 col = C.FIRE_YELLOW
244 elif y < 10:
245 col = C.FIRE_ORANGE
246 else:
247 col = C.FIRE_RED
248 for x in range(cx - radius, cx + radius):
249 if 0 <= x < 16:
250 al = 1.0 - abs(x - cx) / max(1, radius)
251 if al > 0.2:
252 img[y, x] = (
253 int(col[0] * 255),
254 int(col[1] * 255),
255 int(col[2] * 255),
256 int(255 * al),
257 )
258 # Sparkles
259 for _ in range(3):
260 sx = int(rng.integers(2, 14))
261 sy = int(rng.integers(0, 8))
262 img[sy, sx] = (255, 240, 200, 255)
263 frames.append(img)
264 return frames
265
266
267# ---------------------------------------------------------------- civilians (16x22)
268
269
270def make_civilian_frames(outfit_idx: int, num: int = 4) -> list[np.ndarray]:
271 out = C.CIVILIAN_OUTFITS[outfit_idx % len(C.CIVILIAN_OUTFITS)]
272 frames = []
273 for i in range(num):
274 img = _rgba((0, 0, 0, 0), 16, 22)
275 # Boots
276 _fill_rect(img, 3, 19, 4, 3, (0.20, 0.15, 0.10, 1.0))
277 _fill_rect(img, 9, 19, 4, 3, (0.20, 0.15, 0.10, 1.0))
278 if i % 2 == 0:
279 img[19:22, 9:13] = img[19:22, 3:7]
280 # Legs
281 _fill_rect(img, 4, 14, 8, 5, (0.20, 0.20, 0.30, 1.0))
282 # Torso
283 _fill_rect(img, 3, 8, 10, 6, out)
284 # Head + hair
285 _fill_rect(img, 5, 3, 6, 5, C.CIVILIAN_SKIN)
286 _fill_rect(img, 4, 0, 8, 4, (0.20, 0.10, 0.05, 1.0))
287 frames.append(img)
288 return frames
289
290
291def make_civilian_burn(outfit_idx: int) -> np.ndarray:
292 """Burning civilian: silhouette overlaid with hot edges."""
293 img = make_civilian_frames(outfit_idx, num=1)[0]
294 rng = np.random.default_rng(outfit_idx + 99)
295 h, w = img.shape[:2]
296 for y in range(h):
297 for x in range(w):
298 if img[y, x, 3] > 0 and rng.random() < 0.35:
299 img[y, x] = (
300 int(C.CIVILIAN_BURN[0] * 255),
301 int(C.CIVILIAN_BURN[1] * 255),
302 int(C.CIVILIAN_BURN[2] * 255),
303 255,
304 )
305 return img
306
307
308# ---------------------------------------------------------------- enemy (16x22)
309
310
311def make_enemy_frames(num: int = 4) -> list[np.ndarray]:
312 frames = []
313 for i in range(num):
314 img = _rgba((0, 0, 0, 0), 16, 22)
315 # Body
316 _fill_rect(img, 2, 8, 12, 12, C.ENEMY_BODY)
317 # Hot streaks
318 for stripe_y in (10, 14, 17):
319 _fill_rect(img, 2, stripe_y, 12, 1, C.ENEMY_HOT)
320 # Eyes
321 ex = (4, 10) if i % 2 == 0 else (5, 9)
322 for x in ex:
323 _fill_rect(img, x, 5, 2, 2, C.FIRE_YELLOW)
324 # Head bump
325 _fill_rect(img, 4, 2, 8, 4, C.ENEMY_BODY)
326 frames.append(img)
327 return frames
328
329
330# ---------------------------------------------------------------- gun (8x4)
331
332
333def make_gun_horiz() -> np.ndarray:
334 img = _rgba((0, 0, 0, 0), 8, 4)
335 _fill_rect(img, 0, 1, 8, 2, C.PLAYER_GUN)
336 _fill_rect(img, 5, 0, 3, 1, C.PLAYER_GUN) # nozzle bump
337 return img
338
339
340def make_gun_vert() -> np.ndarray:
341 img = _rgba((0, 0, 0, 0), 4, 8)
342 _fill_rect(img, 1, 0, 2, 8, C.PLAYER_GUN)
343 return img
344
345
346# ---------------------------------------------------------------- particles
347
348
349def make_particle_dot() -> np.ndarray:
350 img = _rgba((0, 0, 0, 0), 4, 4)
351 img[1:3, 1:3] = (255, 255, 255, 255)
352 return img
353
354
355# ---------------------------------------------------------------- decoration (16x16)
356#
357# Every decorative tile is fully opaque (fills the whole cell) so the interior
358# never shows the bare clear-colour void again. ``filter="nearest"`` keeps them
359# crisp at the 3x gameplay zoom.
360
361
362def make_wallpaper_tile(tint) -> np.ndarray:
363 """Opaque interior back-wall with a subtle vertical stripe + top shade."""
364 img = _rgba(tint, 16, 16)
365 r, g, b, _ = tint
366 # Faint vertical pinstripes every 4px (slightly lighter).
367 for x in range(2, 16, 4):
368 img[:, x, 0] = min(255, int(r * 255) + 14)
369 img[:, x, 1] = min(255, int(g * 255) + 14)
370 img[:, x, 2] = min(255, int(b * 255) + 14)
371 # Gentle top-down shading so floors don't look perfectly flat.
372 for y in range(16):
373 shade = int((y - 8) * 1.2)
374 img[y, :, :3] = np.clip(img[y, :, :3].astype(int) + shade, 0, 255)
375 return img
376
377
378def make_window_tile() -> np.ndarray:
379 """Lit window: dark frame, night-sky panes, a couple of warm lit panes."""
380 img = _rgba(C.WINDOW_FRAME, 16, 16)
381 _fill_rect(img, 2, 2, 12, 12, C.WINDOW_NIGHT)
382 # Four panes split by mullions.
383 lit = C.WINDOW_LIT
384 _fill_rect(img, 2, 2, 5, 5, lit)
385 _fill_rect(img, 9, 8, 5, 4, lit)
386 # Mullions (cross bars) in frame colour.
387 _fill_rect(img, 7, 2, 2, 12, C.WINDOW_FRAME)
388 _fill_rect(img, 2, 7, 12, 2, C.WINDOW_FRAME)
389 return img
390
391
392def make_wainscot_tile() -> np.ndarray:
393 """Baseboard skirting: dark lower panel with a bright trim cap."""
394 img = _rgba(C.WAINSCOT, 16, 16)
395 _fill_rect(img, 0, 0, 16, 2, C.WAINSCOT_TRIM) # trim cap
396 for x in range(0, 16, 6): # panel grooves
397 _fill_rect(img, x, 3, 1, 13, (0.18, 0.12, 0.09, 1.0))
398 return img
399
400
401def make_trim_tile() -> np.ndarray:
402 """A bright horizontal trim line (used to frame a storey)."""
403 img = _rgba((0, 0, 0, 0), 16, 16)
404 _fill_rect(img, 0, 6, 16, 4, C.WAINSCOT_TRIM)
405 _fill_rect(img, 0, 6, 16, 1, C.HUD_FG)
406 return img
407
408
409def make_shelf_tile() -> np.ndarray:
410 img = _rgba(C.INTERIOR, 16, 16) # opaque backing
411 _fill_rect(img, 1, 2, 14, 12, C.PROP_WOOD)
412 _fill_rect(img, 1, 7, 14, 1, C.PROP_WOOD_LIGHT) # shelf line
413 _fill_rect(img, 1, 13, 14, 1, C.PROP_WOOD_LIGHT)
414 # A couple of "books / boxes".
415 _fill_rect(img, 3, 3, 2, 4, (0.70, 0.30, 0.25, 1.0))
416 _fill_rect(img, 6, 3, 2, 4, (0.30, 0.50, 0.65, 1.0))
417 _fill_rect(img, 9, 9, 3, 4, (0.55, 0.55, 0.30, 1.0))
418 return img
419
420
421def make_crate_tile() -> np.ndarray:
422 img = _rgba(C.INTERIOR, 16, 16)
423 _fill_rect(img, 2, 4, 12, 10, C.PROP_WOOD)
424 _outline_rect(img, 2, 4, 12, 10, C.PROP_WOOD_LIGHT)
425 # Diagonal cross braces.
426 for i in range(10):
427 x = 2 + i
428 img[4 + i, x] = (
429 int(C.PROP_WOOD_LIGHT[0] * 255),
430 int(C.PROP_WOOD_LIGHT[1] * 255),
431 int(C.PROP_WOOD_LIGHT[2] * 255),
432 255,
433 )
434 img[13 - i, x] = img[4 + i, x]
435 return img
436
437
438def make_plant_tile() -> np.ndarray:
439 img = _rgba(C.INTERIOR, 16, 16)
440 _fill_rect(img, 5, 11, 6, 4, C.PROP_PLANT_POT) # pot
441 # Foliage blobs.
442 _fill_rect(img, 4, 4, 8, 7, C.PROP_PLANT)
443 _fill_rect(img, 6, 2, 4, 4, C.PROP_PLANT)
444 img[5, 7] = (int(0.40 * 255), int(0.70 * 255), int(0.42 * 255), 255)
445 return img
446
447
448def make_picture_tile() -> np.ndarray:
449 img = _rgba(C.INTERIOR, 16, 16)
450 _fill_rect(img, 4, 4, 8, 8, C.PROP_WOOD_LIGHT) # frame
451 _fill_rect(img, 5, 5, 6, 6, (0.30, 0.42, 0.55, 1.0)) # "painting"
452 _fill_rect(img, 6, 8, 4, 3, (0.45, 0.55, 0.35, 1.0))
453 return img
454
455
456def make_pipe_tile() -> np.ndarray:
457 img = _rgba(C.INTERIOR, 16, 16)
458 _fill_rect(img, 6, 0, 4, 16, C.PROP_METAL)
459 _fill_rect(img, 6, 0, 1, 16, (0.55, 0.58, 0.65, 1.0)) # highlight
460 _fill_rect(img, 4, 4, 8, 2, C.PROP_METAL) # joint band
461 return img
462
463
464def _outline_rect(img: np.ndarray, x: int, y: int, w: int, h: int, c) -> None:
465 r, g, b, a = c
466 rgba = (int(r * 255), int(g * 255), int(b * 255), int(a * 255))
467 img[y, x : x + w] = rgba
468 img[y + h - 1, x : x + w] = rgba
469 img[y : y + h, x] = rgba
470 img[y : y + h, x + w - 1] = rgba
471
472
473# ---------------------------------------------------------------- parallax backdrop
474
475
476def make_night_backdrop(w: int = 96, h: int = 160) -> np.ndarray:
477 """Vertical night gradient with a sprinkle of stars (stretched as a sprite)."""
478 img = np.zeros((h, w, 4), dtype=np.uint8)
479 top = np.array(C.NIGHT_TOP[:3])
480 bot = np.array(C.NIGHT_BOTTOM[:3])
481 for y in range(h):
482 t = y / max(1, h - 1)
483 col = (top + (bot - top) * t) * 255
484 img[y, :, :3] = col.astype(np.uint8)
485 img[y, :, 3] = 255
486 rng = np.random.default_rng(2026)
487 for _ in range(70):
488 sx, sy = int(rng.integers(0, w)), int(rng.integers(0, int(h * 0.6)))
489 b = int(rng.integers(150, 255))
490 img[sy, sx] = (b, b, min(255, b + 12), 255)
491 return img
492
493
494def make_skyline(w: int = 256, h: int = 96) -> np.ndarray:
495 """City silhouette strip with a few lit windows (transparent above roofs)."""
496 img = np.zeros((h, w, 4), dtype=np.uint8)
497 rng = np.random.default_rng(77)
498 near = (int(C.SKYLINE_NEAR[0] * 255), int(C.SKYLINE_NEAR[1] * 255), int(C.SKYLINE_NEAR[2] * 255), 255)
499 glow = (int(C.WINDOW_GLOW[0] * 255), int(C.WINDOW_GLOW[1] * 255), int(C.WINDOW_GLOW[2] * 255), 255)
500 x = 0
501 while x < w:
502 bw = int(rng.integers(14, 34))
503 bh = int(rng.integers(int(h * 0.35), h))
504 top = h - bh
505 img[top:h, x : min(w, x + bw), :3] = near[:3]
506 img[top:h, x : min(w, x + bw), 3] = 255
507 # Lit windows.
508 for wy in range(top + 3, h - 2, 6):
509 for wx in range(x + 3, min(w, x + bw) - 2, 6):
510 if rng.random() < 0.35:
511 img[wy : wy + 2, wx : wx + 2] = glow
512 x += bw + int(rng.integers(1, 5))
513 return img
514
515
516# ---------------------------------------------------------------- module-level cache
517
518# Single-shot: built lazily so test imports stay cheap.
519_CACHE: dict[str, np.ndarray | list[np.ndarray]] = {}
520
521
522def get(name: str):
523 if name in _CACHE:
524 return _CACHE[name]
525 if name == "wall":
526 v = make_wall_tile()
527 elif name == "floor":
528 v = make_floor_tile()
529 elif name == "ladder":
530 v = make_ladder_tile()
531 elif name == "door":
532 v = make_door_tile()
533 elif name == "interior":
534 v = make_interior_tile()
535 elif name == "sky":
536 v = make_sky_tile()
537 elif name.startswith("wallpaper_"):
538 idx = int(name.rsplit("_", 1)[-1])
539 v = make_wallpaper_tile(C.STOREY_TINTS[idx % len(C.STOREY_TINTS)])
540 elif name == "window":
541 v = make_window_tile()
542 elif name == "wainscot":
543 v = make_wainscot_tile()
544 elif name == "trim":
545 v = make_trim_tile()
546 elif name == "shelf":
547 v = make_shelf_tile()
548 elif name == "crate":
549 v = make_crate_tile()
550 elif name == "plant":
551 v = make_plant_tile()
552 elif name == "picture":
553 v = make_picture_tile()
554 elif name == "pipe":
555 v = make_pipe_tile()
556 elif name == "night_backdrop":
557 v = make_night_backdrop()
558 elif name == "skyline":
559 v = make_skyline()
560 elif name == "player_run":
561 v = make_player_frames()
562 elif name == "player_carry":
563 v = make_player_carry_frames()
564 elif name == "player_dead":
565 v = make_player_dead()
566 elif name == "player_climb":
567 v = make_climb_frames()
568 elif name == "water_segment":
569 v = make_water_segment()
570 elif name == "water_tip_open":
571 v = make_water_tip(False)
572 elif name == "water_tip_hit":
573 v = make_water_tip(True)
574 elif name == "fire":
575 v = make_fire_frames()
576 elif name == "enemy":
577 v = make_enemy_frames()
578 elif name == "particle_dot":
579 v = make_particle_dot()
580 elif name == "gun_horiz":
581 v = make_gun_horiz()
582 elif name == "gun_vert":
583 v = make_gun_vert()
584 elif name.startswith("civilian_run_"):
585 idx = int(name.rsplit("_", 1)[-1])
586 v = make_civilian_frames(idx)
587 elif name.startswith("civilian_burn_"):
588 idx = int(name.rsplit("_", 1)[-1])
589 v = make_civilian_burn(idx)
590 else:
591 raise KeyError(f"Unknown texture: {name}")
592 _CACHE[name] = v
593 return v