#!/usr/bin/env python3 """props_import.py — turn raw generated art into game-ready prop sprites. python3 tools/props_import.py [--no-quantise] [--keep-bg] Pipeline per docs/SPACES.md §4: crop to content, remove near-black/flat background to alpha, nearest-neighbour downscale to the kind's target size, optional 16-colour quantise (keeps the pixel-art register), write public/props/.png and add to public/props/manifest.json. `--keep-bg` skips the background-to-alpha pass. Backdrop and texture plates need it: they are meant to be fully opaque, and the near-black cut punches holes through exactly the parts that make a night street look like night. Kinds and target sizes mirror PROP_SIZES in src/scenes/floor/FloorView.ts — unknown kinds are refused so a typo cannot ship an orphan sprite. Sizes for the venue expansion come from tools/gen/assets.py, which is the manifest the generator itself walks, so the two cannot drift apart. """ import fcntl import json import sys from pathlib import Path from PIL import Image TARGETS = { "deck": (32, 32), "mixer": (32, 16), "monitor": (16, 16), "boothLamp": (16, 16), "speaker": (16, 32), "discoball": (32, 32), "barShelf": (496, 16), "taps": (48, 16), "till": (32, 16), "glassStack": (32, 16), "glassRack": (32, 16), "boothTable": (32, 16), "sinkRow": (96, 16), "buttBin": (16, 16), "crate": (16, 16), "poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16), "cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32), "highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32), "dishwasher": (32, 16), "urinal": (48, 16), "handDryer": (16, 16), "cubicleDoor": (16, 16), "mopBucket": (16, 16), "cueRack": (16, 16), "barFridge": (32, 32), "iceWell": (32, 16), "patioHeater": (16, 32), "truss": (48, 16), # the door-scene backdrop plate is special-cased: full size, no quantise "street:backdrop": (640, 176), # door-scene red carpet plate (front-on, not a floor prop) "carpet": (128, 32), } # The venue expansion's kinds live in the generator manifest so one edit feeds # both the farm and this importer. Absent (a bare checkout, an older tools dir), # the base table above still works exactly as it did. try: sys.path.insert(0, str(Path(__file__).resolve().parent / "gen")) import assets as _assets # type: ignore[import-not-found] TARGETS.update({a.kind: a.size for a in _assets.ALL}) except Exception: # pragma: no cover - optional dependency by design pass # The luminance band the props that READ sit in. Measured off the tileset that # was already working: arcade 45, poolTable 53, speaker 59, mixer 61, cdj 68, # discoball 70. The ones that failed were 150-190 — stainless bins, marble, a # concrete pillar, a white plinth: all objects that really ARE pale, rendered # honestly, and therefore wrong for this game. The venue is painted at luminance # ~35 and buried under a 50% black sheet, so a prop at 180 does not read as a # bright object, it reads as a self-illuminated blob. Props are lit by the room's # own LIGHTS (docs/SPACES.md §1) — they have to arrive dark enough to be lit. LUM_CEILING = 110 def dim_to_room(img: Image.Image) -> Image.Image: """Scale RGB down until the median opaque luminance sits under the ceiling. Only ever darkens, and only when needed, so a prop that already reads (the dark cabinets, the pool table) passes through untouched. """ px = [p for p in img.getdata() if p[3] > 8] if not px: return img lums = sorted((p[0] + p[1] + p[2]) // 3 for p in px) median = lums[len(lums) // 2] if median <= LUM_CEILING: return img factor = LUM_CEILING / median r, g, b, a = img.split() lut = [min(255, int(v * factor)) for v in range(256)] return Image.merge("RGBA", (r.point(lut), g.point(lut), b.point(lut), a)) def main() -> None: if len(sys.argv) < 3: sys.exit(__doc__) raw, kind = Path(sys.argv[1]), sys.argv[2] quantise = "--no-quantise" not in sys.argv keep_bg = "--keep-bg" in sys.argv if kind not in TARGETS: sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here") img = Image.open(raw).convert("RGBA") if not keep_bg: # near-black background -> transparent (generated props sit on dark bg by prompt) px = img.load() for y in range(img.height): for x in range(img.width): r, g, b, a = px[x, y] if r < 18 and g < 18 and b < 22: px[x, y] = (0, 0, 0, 0) bbox = img.getbbox() if bbox: img = img.crop(bbox) if "--dim" in sys.argv: img = dim_to_room(img) w, h = TARGETS[kind] img = img.resize((w, h), Image.NEAREST) if quantise and not kind.startswith("street:"): alpha = img.getchannel("A") img = img.convert("RGB").quantize(16).convert("RGBA") img.putalpha(alpha) out_name = kind.replace(":", "_") root = Path(__file__).resolve().parent.parent out = root / "public" / "props" / f"{out_name}.png" out.parent.mkdir(parents=True, exist_ok=True) img.save(out) # The manifest is a read-modify-write, and the batch generator runs several # imports at once — unlocked, two finishing together each read the same # list, and the second write silently drops the first one's entry. The prop # is then on disk but not in the manifest, so the game never loads it and # renders a placeholder over perfectly good art. An exclusive lock for the # few milliseconds this takes is the whole fix. man_path = root / "public" / "props" / "manifest.json" lock_path = man_path.with_suffix(".lock") with open(lock_path, "w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) try: man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []} if out_name not in man["props"]: man["props"].append(out_name) man["props"].sort() man_path.write_text(json.dumps(man, indent=2) + "\n") finally: fcntl.flock(lock, fcntl.LOCK_UN) print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)") if __name__ == "__main__": main()