The venue ladder has always listed four venues in data/venues.ts, but they shared ONE floor map and differed only by difficulty knobs — so a promotion you survived a whole week for looked exactly like the room you had just left. - venueMap.ts grows a FloorLayout contract (map + props + lights + posts + probes + palette) and the newGrid() primitives every room is painted with. Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can import primitives from it without closing an import cycle. - Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar, pool room, pokies corner, trough + two cubicles, a beer garden that is plainly the nicest room in the pub, and a DJ "corner" that is a folding table because this pub never built a booth), Elevate (open-air rooftop — most of the grid is sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete warehouse, pillars you path around, central booth, loading-dock smoking area, twelve lights in the whole venue). - Nothing about a room is a module singleton any more. FloorView, sweep and FloorDemoScene take the layout; the door picks its street plate by venue id. - FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar shift inside The Royal's pool room, so each layout now names its own stations and the scene reads them from there. - tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds, every walkable tile reachable from the entry, anchors on walkable ground, posts not sealed (a post on a sealed tile freezes the player for the night — that has shipped twice), probes reachable, props on-grid. Proved against Voltage BEFORE the new rooms were authored against it. Dev route #floor:<venueId> boots the floor straight into one room, because otherwise seeing ROOM means surviving three weeks of the ladder. Gate: lint clean, build clean, 821 tests passing (was 784). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.8 KiB
Python
112 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""props_import.py — turn raw generated art into game-ready prop sprites.
|
|
|
|
python3 tools/props_import.py <raw-image> <kind> [--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/<kind>.png and add <kind> 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
|
|
|
|
|
|
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)
|
|
|
|
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()
|