The hip-hop room has stock (7 racks, 47 bins, 2,865 records); the entry hall and back room are empty. Import all four anyway: the empty rooms are the connective tissue, and they are the only floor in the building big enough to get an office chair up to speed. A portal pair is one doorway seen from both sides, so the importer walks the graph and places each room by making its portal land exactly on its neighbour's. Every pair here sits on parallel walls with opposite facings, so each join is a pure translation — and when one isn't, it says so rather than quietly getting it wrong. It then recentres the union, emits the interior walls with a gap at each doorway, and puts the spawn in the entry hall, because that is the way in. hiphop-room <-> entry-hall <-> shop-floor <-> back-room 12.96 x 15.41 m - 36 racks - 325 bins - 302 real genre labels - 24,024 records Levels.record_store() now takes the walls and rooms from the import, hangs the street glazing on the entry hall's outer wall, and lays a light grid across whatever footprint the rooms came out as. probe_store.gd also checks the new facts: five doorways exist, and the spawn has nothing in it (excluding the player, who is supposed to be standing there — that false positive was the probe's bug, not the level's). All gates green: smoke clean, 7/7 sites 0.00 m/s, overlap CLEAN, all probes pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
393 lines
17 KiB
Python
393 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Turn the REAL Monster Robot Party shop into a Destroyulator level.
|
|
|
|
The live storefront (robotmonster.party/store/) is assembled procedurally from Postgres
|
|
rows — the wowplatter virtual_* schema, served by recordgod as GET /virtual/scene. The
|
|
game's sites are data too, so Site 7 is not hand-placed: this reads the shop and emits
|
|
game/assets/store/shop_floor.json, which Levels.record_store() loads.
|
|
|
|
Re-run it whenever the real shop is rearranged and the level follows.
|
|
|
|
python3 tools/import_store.py # fetch live
|
|
python3 tools/import_store.py --offline scene.json # from a saved dump
|
|
|
|
Why the shop maps onto the game so cleanly: virtual_crate.rack_id already records which
|
|
rack holds which bin, so the game's support cascade (smash the rack, its bins go over)
|
|
falls straight out of the data. Nothing about it is authored.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import pathlib
|
|
import sys
|
|
import urllib.request
|
|
|
|
URL = "https://robotmonster.party/virtual/scene"
|
|
OUT = pathlib.Path(__file__).resolve().parent.parent / "game/assets/store/shop_floor.json"
|
|
|
|
# --- archetype -> what the game builds -------------------------------------------------
|
|
# The shop has 21 rack archetypes; the game has a curated store-* GLB set. Map by shape
|
|
# and material, not one-to-one: two timber rack sizes, tubs, columns, and the fittings.
|
|
RACK_GLB = {
|
|
"wooden-rack-type-long": ("store-rack-long", "wood"),
|
|
"wooden-rack-type-wide": ("store-rack-wide", "wood"),
|
|
"big-4x4x4-01": ("store-rack-long", "wood"),
|
|
"white-tub-3x3": ("store-rack-wide", "wood"),
|
|
"white-tub-3x3-long": ("store-rack-long", "wood"),
|
|
"tubs-4x2-vertical": ("store-rack-wide", "wood"),
|
|
"tubs-4x2-wide": ("store-rack-wide", "wood"),
|
|
"3-top-4-low-tubs-on-shelf-wide": ("store-rack-wide", "wood"),
|
|
"3-top-4-low-tubs-on-shelf-long": ("store-rack-long", "wood"),
|
|
"chiu-short": ("store-rack-wide", "wood"),
|
|
"chiu-long": ("store-rack-long", "wood"),
|
|
"mos-japhop": ("store-rack-wide", "wood"),
|
|
"library trolley": ("store-trolley", "steel"),
|
|
"WHITE COLUMN": ("store-column-white", "steel"),
|
|
"GREY COLUMN": ("store-column-white", "steel"),
|
|
"AIR CON": ("store-aircon", "steel"),
|
|
"STATION 1": ("store-station", "wood"),
|
|
"STATION 2": ("store-station", "wood"),
|
|
"floor crate": ("store-crate-blue", "plastic"),
|
|
}
|
|
# Bins. Tubs and crates are moulded plastic; the timber bin is wood.
|
|
CRATE_GLB = {
|
|
"White Tub": ("store-tub-white", "plastic"),
|
|
"White Tub long": ("store-bin-long", "plastic"),
|
|
"Black Tub": ("store-tub-black", "plastic"),
|
|
"Black Tub long": ("store-bin-long", "plastic"),
|
|
"Blue Crate": ("store-crate-blue", "plastic"),
|
|
"Wooden Rack Bin": ("crate", "wood"),
|
|
}
|
|
CRATE_DEFAULT = ("store-crate-blue", "plastic")
|
|
# Archetypes that are not objects: flat benches and zero-depth wall shelves become slabs,
|
|
# and the roof beam is structure. Building them as smashable props would be wrong.
|
|
AS_SLAB = {"long guy", "short guy", "wall long", "wall big", "ROOF COLUMN", "Shelf Level",
|
|
"Shelf Level long"}
|
|
SKIP_CRATE_TYPES = {"Shelf Level", "Shelf Level long"} # shelves, not bins
|
|
|
|
|
|
def fetch(url, offline):
|
|
if offline:
|
|
return json.loads(pathlib.Path(offline).read_text())
|
|
with urllib.request.urlopen(url, timeout=30) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def rot_y(x, z, deg):
|
|
a = math.radians(deg or 0.0)
|
|
return x * math.cos(a) + z * math.sin(a), -x * math.sin(a) + z * math.cos(a)
|
|
|
|
|
|
def resolve_overlaps(pieces, iters=60, slop=0.012):
|
|
"""Push interpenetrating pieces apart in X/Z until nothing overlaps.
|
|
|
|
The web storefront is a RENDERER — nothing collides there, so a rack can sit a few
|
|
centimetres inside its neighbour and look perfectly fine for years. Godot's solver
|
|
disagrees violently (that's what the game's spawn guard exists to catch). This is the
|
|
seam where a physics-free layout becomes a physically valid one.
|
|
|
|
Each piece has a `w` weight: racks are heavy and barely move, bins are light and give
|
|
way, so aisles keep their shape and the furniture stays where the shop put it.
|
|
Heights are never touched — a ceiling beam must stay on the ceiling.
|
|
"""
|
|
moved = 0.0
|
|
for _ in range(iters):
|
|
worst = 0.0
|
|
for i in range(len(pieces)):
|
|
a = pieces[i]
|
|
for j in range(i + 1, len(pieces)):
|
|
b = pieces[j]
|
|
# vertical separation is real separation: a bin on a shelf above another
|
|
# rack is not an overlap
|
|
ay0, ay1 = a["y"], a["y"] + a["fit"][1]
|
|
by0, by1 = b["y"], b["y"] + b["fit"][1]
|
|
if ay1 <= by0 + slop or by1 <= ay0 + slop:
|
|
continue
|
|
dx = (a["x"] - b["x"])
|
|
dz = (a["z"] - b["z"])
|
|
ox = (a["fit"][0] + b["fit"][0]) * 0.5 - abs(dx)
|
|
oz = (a["fit"][2] + b["fit"][2]) * 0.5 - abs(dz)
|
|
if ox <= slop or oz <= slop:
|
|
continue
|
|
worst = max(worst, min(ox, oz))
|
|
# separate along the axis of LEAST penetration — the smaller correction
|
|
wa, wb = a["w"], b["w"]
|
|
tot = wa + wb
|
|
if ox < oz:
|
|
push = (ox + slop) * (1.0 if dx >= 0 else -1.0)
|
|
a["x"] += push * (wb / tot)
|
|
b["x"] -= push * (wa / tot)
|
|
else:
|
|
push = (oz + slop) * (1.0 if dz >= 0 else -1.0)
|
|
a["z"] += push * (wb / tot)
|
|
b["z"] -= push * (wa / tot)
|
|
moved += abs(push)
|
|
if worst <= slop:
|
|
break
|
|
return moved
|
|
|
|
|
|
def audit(pieces, slop=0.004):
|
|
"""What still interpenetrates after resolution — the same test the game's
|
|
dev/probe_overlap.gd runs, so the importer can't hand off a level it knows is bad."""
|
|
bad = []
|
|
for i in range(len(pieces)):
|
|
a = pieces[i]
|
|
for j in range(i + 1, len(pieces)):
|
|
b = pieces[j]
|
|
ay1, by1 = a["y"] + a["fit"][1], b["y"] + b["fit"][1]
|
|
if ay1 <= b["y"] + slop or by1 <= a["y"] + slop:
|
|
continue
|
|
ox = (a["fit"][0] + b["fit"][0]) * 0.5 - abs(a["x"] - b["x"])
|
|
oz = (a["fit"][2] + b["fit"][2]) * 0.5 - abs(a["z"] - b["z"])
|
|
if ox > slop and oz > slop:
|
|
bad.append((min(ox, oz), a.get("name", a["glb"]), b.get("name", b["glb"])))
|
|
return sorted(bad, reverse=True)
|
|
|
|
|
|
def stitch(rooms, portals, start):
|
|
"""Lay every room out in one world by walking the portal graph.
|
|
|
|
A portal pair is the same doorway seen from both sides, so placing room B against
|
|
room A means making B's portal land exactly on A's. In this shop every pair sits on
|
|
parallel walls with opposite facings, which makes each join a pure translation —
|
|
anything else would need a rotation, and we say so rather than quietly getting it
|
|
wrong.
|
|
"""
|
|
place = {start: (0.0, 0.0)}
|
|
order = [start]
|
|
warned = []
|
|
while order:
|
|
sid = order.pop(0)
|
|
ox, oz = place[sid]
|
|
for p in portals.values():
|
|
if p["space_id"] != sid or p["to_space"] in place:
|
|
continue
|
|
other = portals.get(p["links_to_id"])
|
|
if other is None or other["space_id"] != p["to_space"]:
|
|
continue
|
|
fa = (p.get("facing_deg") or 0) % 360
|
|
fb = (other.get("facing_deg") or 0) % 360
|
|
if (fa + 180) % 360 != fb:
|
|
warned.append((p["id"], other["id"], fa, fb))
|
|
# our portal in world space; theirs must land on it
|
|
wx, wz = p["x"] + ox, p["z"] + oz
|
|
place[p["to_space"]] = (wx - other["x"], wz - other["z"])
|
|
order.append(p["to_space"])
|
|
return place, warned
|
|
|
|
|
|
def room_walls(sid, room, off, portals, bounds, tol=0.08):
|
|
"""A room's four walls, with a gap wherever a portal pierces one.
|
|
|
|
Walls that sit on the level's outer boundary are skipped — Floorplan already builds
|
|
the shell from `bounds`, and a second wall in the same plane just z-fights it.
|
|
"""
|
|
ox, oz = off
|
|
x0, z0 = ox, oz
|
|
x1, z1 = ox + room["room_width"], oz + room["room_depth"]
|
|
bx0, bx1, bz0, bz1 = bounds
|
|
mine = [p for p in portals.values() if p["space_id"] == sid]
|
|
out = []
|
|
# (axis, at, from, to, which local edge this is)
|
|
edges = [("x", x0, z0, z1, ("x", room and 0.0)), ("x", x1, z0, z1, ("x", room["room_width"])),
|
|
("z", z0, x0, x1, ("z", 0.0)), ("z", z1, x0, x1, ("z", room["room_depth"]))]
|
|
for axis, at, a, b, (lax, lat) in edges:
|
|
if axis == "x" and (abs(at - bx0) < tol or abs(at - bx1) < tol):
|
|
continue
|
|
if axis == "z" and (abs(at - bz0) < tol or abs(at - bz1) < tol):
|
|
continue
|
|
w = {"a": axis, "at": round(at, 3), "from": round(a, 3), "to": round(b, 3)}
|
|
for p in mine:
|
|
# does this portal sit in this wall? compare in the room's local frame
|
|
local = p["x"] if lax == "x" else p["z"]
|
|
if abs(local - lat) < tol:
|
|
w["door"] = round((p["z"] + oz) if axis == "x" else (p["x"] + ox), 3)
|
|
break
|
|
out.append(w)
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--url", default=URL)
|
|
ap.add_argument("--offline", help="a directory of scene_<id>.json dumps")
|
|
ap.add_argument("--out", default=str(OUT))
|
|
ap.add_argument("--start", type=int, default=2, help="space id to anchor the layout on")
|
|
args = ap.parse_args()
|
|
|
|
# --- every room, and the portal graph that joins them ------------------------------
|
|
first = fetch(args.url, f"{args.offline}/scene_{args.start}.json" if args.offline else None)
|
|
ids = [s["id"] for s in first.get("spaces", [])] or [first["space"]["id"]]
|
|
scenes = {}
|
|
for sid in ids:
|
|
if sid == args.start:
|
|
scenes[sid] = first
|
|
elif args.offline:
|
|
scenes[sid] = fetch(None, f"{args.offline}/scene_{sid}.json")
|
|
else:
|
|
scenes[sid] = fetch(f"{args.url}?space={sid}", None)
|
|
rooms = {sid: sc["space"] for sid, sc in scenes.items()}
|
|
portals = {}
|
|
for sc in scenes.values():
|
|
for p in sc["portals"]:
|
|
portals[p["id"]] = p
|
|
|
|
place, warned = stitch(rooms, portals, args.start)
|
|
for a, b, fa, fb in warned:
|
|
print(f"[import_store] WARNING portals {a}/{b} face {fa}/{fb} — not opposite, "
|
|
f"the join may need a rotation this importer does not do")
|
|
for sid in ids:
|
|
if sid not in place:
|
|
print(f"[import_store] WARNING space {sid} is not reachable through any portal "
|
|
f"— left out of the level")
|
|
|
|
# --- the union footprint, recentred on the origin ------------------------------------
|
|
xs = [place[s][0] for s in place] + [place[s][0] + rooms[s]["room_width"] for s in place]
|
|
zs = [place[s][1] for s in place] + [place[s][1] + rooms[s]["room_depth"] for s in place]
|
|
ux0, ux1, uz0, uz1 = min(xs), max(xs), min(zs), max(zs)
|
|
cx, cz = (ux0 + ux1) * 0.5, (uz0 + uz1) * 0.5
|
|
for s in place:
|
|
place[s] = (place[s][0] - cx, place[s][1] - cz)
|
|
bounds = (ux0 - cx, ux1 - cx, uz0 - cz, uz1 - cz)
|
|
ceiling = max(r.get("ceiling_height") or 2.8 for r in rooms.values())
|
|
|
|
pieces, slabs, footprints, walls = [], [], [], []
|
|
n_crates = stock = 0
|
|
|
|
for sid in place:
|
|
sc = scenes[sid]
|
|
off = place[sid]
|
|
walls += room_walls(sid, rooms[sid], off, portals, bounds)
|
|
rack_types = {t["id"]: t for t in sc["rack_types"]}
|
|
crate_types = {c["id"]: c for c in sc["crate_types"]}
|
|
racks = [r for r in sc["racks"]
|
|
if r.get("visible") == "y" and r.get("space_id") == sid]
|
|
crates_by_rack = {}
|
|
for c in sc["crates"]:
|
|
if c.get("visible") == "y" and c.get("rack_id"):
|
|
crates_by_rack.setdefault(c["rack_id"], []).append(c)
|
|
|
|
for r in racks:
|
|
t = rack_types.get(r["rack_type_id"])
|
|
if not t:
|
|
continue
|
|
name = t.get("name", "")
|
|
x, z = r["pos_x"] + off[0], r["pos_z"] + off[1]
|
|
yaw = math.radians(r.get("rotation_y") or 0.0)
|
|
tw, th, td = t.get("width", 0.5), t.get("height", 0.5), t.get("depth", 0.5)
|
|
base = r.get("pos_y") or 0.0
|
|
if name in AS_SLAB:
|
|
slabs.append({"size": [max(tw, 0.05), max(th, 0.04), max(td, 0.05)],
|
|
"at": [round(x, 3), round(max(th, 0.04) / 2.0 + base, 3),
|
|
round(z, 3)],
|
|
"mat": "wood" if "guy" in name or "wall" in name else "trim"})
|
|
continue
|
|
glb, kind = RACK_GLB.get(name, ("store-rack-wide", "wood"))
|
|
pieces.append({"kind_of": "rack", "glb": glb, "kind": kind,
|
|
"id": f"r{r['id']}", "x": x, "z": z, "y": base, "yaw": yaw,
|
|
"w": 8.0, "room": sid,
|
|
"fit": [max(tw, 0.05), max(th, 0.05), max(td, 0.05)],
|
|
"name": r.get("name") or name,
|
|
"rack": r, "rot": r.get("rotation_y") or 0.0,
|
|
"crates": crates_by_rack.get(r["id"], []),
|
|
"ctypes": crate_types})
|
|
|
|
moved = resolve_overlaps(pieces, iters=400)
|
|
INSET = 0.03
|
|
for p in pieces:
|
|
if p["kind_of"] == "rack":
|
|
p["fit"][0] = max(p["fit"][0] - INSET, 0.05)
|
|
p["fit"][2] = max(p["fit"][2] - INSET, 0.05)
|
|
|
|
for p in [p for p in pieces if p["kind_of"] == "rack"]:
|
|
r = p["rack"]
|
|
for c in p["crates"]:
|
|
ct = p["ctypes"].get(c["crate_type_id"], {})
|
|
cname = ct.get("name", "")
|
|
if cname in SKIP_CRATE_TYPES:
|
|
continue
|
|
lx, lz = rot_y(c.get("pos_x") or 0.0, c.get("pos_z") or 0.0, p["rot"])
|
|
cglb, ckind = CRATE_GLB.get(cname, CRATE_DEFAULT)
|
|
e = {"kind_of": "crate", "glb": cglb, "kind": ckind,
|
|
"x": p["x"] + lx, "z": p["z"] + lz,
|
|
"y": (r.get("pos_y") or 0.0) + (c.get("pos_y") or 0.0),
|
|
"yaw": p["yaw"], "w": 1.0, "supported_by": p["id"], "room": p["room"],
|
|
"fit": [max(ct.get("width") or 0.3, 0.05),
|
|
max(ct.get("height") or 0.3, 0.05),
|
|
max(ct.get("depth") or 0.3, 0.05)]}
|
|
label = (c.get("label_text") or "").strip()
|
|
if label and label != "---":
|
|
e["label"] = label
|
|
pieces.append(e)
|
|
n_crates += 1
|
|
stock += c.get("n_items") or 0
|
|
|
|
left = audit([p for p in pieces if p["kind_of"] == "rack"])
|
|
for depth, na, nb in left[:6]:
|
|
print(f"[import_store] STILL OVERLAPPING {depth * 100:.0f} cm: {na} / {nb}")
|
|
|
|
smashables = []
|
|
for p in pieces:
|
|
e = {"glb": p["glb"], "kind": p["kind"], "at": [round(p["x"], 3), round(p["z"], 3)],
|
|
"sit_on": round(p["y"], 3), "yaw": round(p["yaw"], 4),
|
|
"fit": [round(v, 3) for v in p["fit"]]}
|
|
if p["kind_of"] == "rack":
|
|
e["id"] = p["id"]
|
|
e["name"] = p["name"]
|
|
if p["y"] < 1.2:
|
|
footprints.append((p["x"], p["z"],
|
|
max(p["fit"][0], p["fit"][2]) * 0.5 + 0.35))
|
|
else:
|
|
e["frozen"] = True
|
|
e["supported_by"] = p["supported_by"]
|
|
if p.get("label"):
|
|
e["label"] = p["label"]
|
|
smashables.append(e)
|
|
|
|
# --- spawn: the entry hall is where you actually walk in ----------------------------
|
|
hub = None
|
|
for sid in place:
|
|
if rooms[sid]["name"].startswith("entry"):
|
|
hub = sid
|
|
if hub is None:
|
|
hub = args.start
|
|
hx, hz = place[hub]
|
|
best = (hx + rooms[hub]["room_width"] * 0.5, hz + rooms[hub]["room_depth"] * 0.5)
|
|
best_clear = min((((best[0] - fx) ** 2 + (best[1] - fz) ** 2) ** 0.5 - fr)
|
|
for fx, fz, fr in footprints) if footprints else 9.9
|
|
|
|
out = {
|
|
"_source": args.offline or args.url,
|
|
"_note": "GENERATED by tools/import_store.py from the live shop. Do not hand-edit.",
|
|
"room": {"w": bounds[1] - bounds[0], "d": bounds[3] - bounds[2], "h": ceiling},
|
|
"rooms": [{"id": s, "name": rooms[s]["name"],
|
|
"at": [round(place[s][0], 3), round(place[s][1], 3)],
|
|
"size": [rooms[s]["room_width"], rooms[s]["room_depth"]]}
|
|
for s in place],
|
|
"counts": {"rooms": len(place),
|
|
"racks": len([p for p in pieces if p["kind_of"] == "rack"]),
|
|
"crates": n_crates, "stock_records": stock, "slabs": len(slabs)},
|
|
"spawn": {"at": [round(best[0], 2), round(best[1], 2)]},
|
|
"walls": walls,
|
|
"slabs": slabs,
|
|
"smashables": smashables,
|
|
}
|
|
p = pathlib.Path(args.out)
|
|
p.write_text(json.dumps(out, indent=1))
|
|
print(f"[import_store] {len(place)} rooms stitched: "
|
|
+ ", ".join(rooms[s]["name"] for s in place))
|
|
print(f"[import_store] footprint {out['room']['w']:.2f} x {out['room']['d']:.2f} x {ceiling}")
|
|
print(f"[import_store] separation pass moved {moved:.2f} m total")
|
|
print(f"[import_store] {out['counts']['racks']} racks, {n_crates} crates, "
|
|
f"{len(walls)} interior walls, {stock:,} records of stock")
|
|
print(f"[import_store] spawn in {rooms[hub]['name']} at ({best[0]:.2f}, {best[1]:.2f}), "
|
|
f"clearance {best_clear:.2f} m")
|
|
print(f"[import_store] -> {p} ({p.stat().st_size / 1024:.0f} KB)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|