vinylgauntlet/assets/process_sprites.py
type-two df25531893 Art pass: wall variants, floor decals, per-class sprites, 2 idle villains
Texture audit found the real weakness: one wall texture repeated up to 316x per
maze, so levels read as wallpaper. Generated 26 more images on MODELBEAST (free,
local) and wired what was already sitting unused.

- 4 wall variants per theme (24 total), picked by a stable hash of tile position:
  neighbours differ, but a given tile keeps its texture across rebuilds
- 8 floor decals scattered on open tiles (5% density, seeded per level, tinted
  back to 0.45 alpha so litter never competes with sprites)
- Classes now have distinct SPRITES instead of colour tints (the 3 player
  variants were generated in batch 3 and never wired)
- Scalper (hunts gold only, flees with it) joins club/festival spawn tables;
  Fire Marshal (2nd wall-phasing stalker) appears in procgen from level 7
- Readability fix: CLUB NIGHT's fine checker floor read as noise under sprites.
  Regenerated a coarse one + per-theme floorTint so busy floors get knocked back.
- process_sprites skips not-yet-generated files instead of crashing the batch

39 -> 73 game textures. All verified in-browser: 316-wall maze splits evenly
across 4 variants, deterministic across rebuilds, per-class sprites, scalper
ignores espresso for gold, 71/71 assets load clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:56:22 +10:00

150 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Turn the raw FLUX assets into game-ready textures in public/sprites/.
Sprites (magenta #ff00ff background) get chroma-keyed to transparency, autocropped,
and centered on a square canvas. Tiles (full-bleed textures) are just resized square.
Output filename = the texture key GameScene.js loads.
/opt/homebrew/bin/uv run --with pillow --with numpy python assets/process_sprites.py
"""
from pathlib import Path
from PIL import Image, ImageFilter
import numpy as np
HERE = Path(__file__).resolve().parent
SRC = HERE / "generated"
OUT = HERE.parent / "public" / "sprites"
OUT.mkdir(parents=True, exist_ok=True)
# game texture key -> (source file, size px, is_tile)
TABLE = {
"floor": ("floor_parquet.png", 96, True),
"floor_carpet": ("floor_carpet.png", 96, True),
# coarse checker: the original was fine-grained and read as noise under sprites
"floor_checker": ("floor_club_b.png", 96, True),
"wall": ("wall_crate.png", 48, True),
"wall_shelf": ("wall_shelf.png", 48, True),
"wall_poster": ("wall_poster.png", 48, True),
"pirate": ("enemy_record_pirate.png", 48, False),
"ghoul": ("enemy_crate_ghoul.png", 48, False),
"gold": ("pickup_goldrecord.png", 38, False),
"floor_archive": ("floor_archive.png", 96, True),
"wall_archive": ("wall_archive.png", 48, True),
"mite": ("enemy_dust_mite.png", 26, False),
"queen": ("enemy_mite_queen.png", 96, False),
"lamp": ("prop_lamp.png", 40, False),
"player": ("player_digger.png", 48, False),
"poser": ("enemy_poser.png", 48, False),
"nerd": ("enemy_gearnerd.png", 48, False),
"soundguy": ("enemy_soundguy.png", 48, False),
"generator": ("gen_discount_bin.png", 48, False),
"exit": ("prop_djbooth.png", 48, False),
"door": ("prop_velvet_rope.png", 48, False),
"espresso": ("pickup_espresso.png", 38, False),
"lanyard": ("pickup_lanyard.png", 38, False),
"airhorn": ("pickup_airhorn.png", 38, False),
"vinyl": ("pickup_vinyl.png", 24, False),
"floor_warehouse": ("floor_warehouse.png", 96, True),
"wall_warehouse": ("wall_warehouse.png", 48, True),
"floor_festival": ("floor_festival.png", 96, True),
"wall_festival": ("wall_festival.png", 48, True),
"gatekeeper": ("enemy_gatekeeper.png", 52, False),
"shazam": ("enemy_shazam_zombie.png", 48, False),
"warped": ("enemy_warped_record.png", 44, False),
"conductor": ("boss_spectral_conductor.png", 96, False),
"rpm78": ("boss_78rpm.png", 120, False),
"port_digger": ("portrait_digger.png", 112, False),
"port_selector": ("portrait_selector.png", 112, False),
"port_promoter": ("portrait_promoter.png", 112, False),
"port_soundtech": ("portrait_soundtech.png", 112, False),
# per-class player sprites (were generated but never wired — classes were tint-only)
"player_selector": ("player_selector.png", 48, False),
"player_promoter": ("player_promoter.png", 48, False),
"player_soundtech": ("player_soundtech.png", 48, False),
# idle villains
"scalper": ("enemy_scalper.png", 48, False),
"firemarshal": ("enemy_fire_marshal.png", 48, False),
# props
"speaker": ("prop_speaker.png", 44, False),
"strobe": ("prop_strobe.png", 40, False),
"turntable": ("prop_turntable.png", 44, False),
# --- batch 4: wall variants (3 per theme) kill the one-texture repetition ---
"wall_b": ("wall_crate_b.png", 48, True),
"wall_c": ("wall_crate_c.png", 48, True),
"wall_d": ("wall_crate_d.png", 48, True),
"wall_shelf_b": ("wall_shelf_b.png", 48, True),
"wall_shelf_c": ("wall_shelf_c.png", 48, True),
"wall_shelf_d": ("wall_shelf_d.png", 48, True),
"wall_poster_b": ("wall_poster_b.png", 48, True),
"wall_poster_c": ("wall_poster_c.png", 48, True),
"wall_poster_d": ("wall_poster_d.png", 48, True),
"wall_warehouse_b": ("wall_warehouse_b.png", 48, True),
"wall_warehouse_c": ("wall_warehouse_c.png", 48, True),
"wall_warehouse_d": ("wall_warehouse_d.png", 48, True),
"wall_festival_b": ("wall_festival_b.png", 48, True),
"wall_festival_c": ("wall_festival_c.png", 48, True),
"wall_festival_d": ("wall_festival_d.png", 48, True),
"wall_archive_b": ("wall_archive_b.png", 48, True),
"wall_archive_c": ("wall_archive_c.png", 48, True),
"wall_archive_d": ("wall_archive_d.png", 48, True),
# floor decals (scattered, non-colliding)
"decal_cables": ("decal_cables.png", 40, False),
"decal_cups": ("decal_cups.png", 36, False),
"decal_flyers": ("decal_flyers.png", 40, False),
"decal_confetti": ("decal_confetti.png", 44, False),
"decal_tape": ("decal_tape.png", 36, False),
"decal_sleeve": ("decal_sleeve.png", 40, False),
"decal_dust": ("decal_dust.png", 36, False),
"decal_puddle": ("decal_puddle.png", 40, False),
}
# full-art images: downscale + copy, never chroma-keyed (they aren't sprites)
ART = {
"logo": ("logo_marquee_b.png", 800),
}
def chroma_key(im):
arr = np.array(im.convert("RGBA"))
r, g, b = arr[..., 0].astype(int), arr[..., 1].astype(int), arr[..., 2].astype(int)
# magenta: high red+blue, low green, red~=blue (catches the darker shadow halo too)
mask = (r > 135) & (b > 135) & (g < 100) & (np.abs(r - b) < 70)
arr[..., 3][mask] = 0
return Image.fromarray(arr, "RGBA")
missing = []
for key, (fname, size, is_tile) in TABLE.items():
if not (SRC / fname).exists():
missing.append(fname) # not generated yet — skip, don't crash the batch
continue
im = Image.open(SRC / fname).convert("RGBA")
if is_tile:
im = im.resize((size, size), Image.LANCZOS)
else:
im = chroma_key(im)
bbox = im.getbbox()
if bbox:
im = im.crop(bbox)
im.thumbnail((size, size), Image.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
canvas.paste(im, ((size - im.width) // 2, (size - im.height) // 2), im)
im = canvas
# crisp up the LANCZOS-softened downscale
im = im.filter(ImageFilter.UnsharpMask(radius=1.2, percent=150, threshold=1))
im.save(OUT / f"{key}.png")
print(f"{key:10s} <- {fname:24s} {size}px {'tile' if is_tile else 'keyed'}")
for key, (fname, width) in ART.items():
if not (SRC / fname).exists():
missing.append(fname)
continue
im = Image.open(SRC / fname).convert("RGB")
im.thumbnail((width, width * 2), Image.LANCZOS)
im.save(OUT / f"{key}.png")
print(f"{key:10s} <- {fname:24s} art {im.width}x{im.height}")
if missing:
print(f"\nskipped {len(missing)} not-yet-generated: {', '.join(sorted(missing))}")
print(f"\nwrote {len(TABLE) + len(ART) - len(missing)} textures to {OUT}")