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