The level the whole game started as, and the only one that isn't authored. robotmonster.party/store/ is assembled procedurally from Postgres (the wowplatter virtual_* schema, served by recordgod), and this game's sites are already data — so tools/import_store.py reads the live shop and emits assets/store/shop_floor.json: the actual 6.66 x 9.51 m floor, 35 racks across 21 archetypes, 278 bins carrying their real genre labels (HARD TRANCE, $12-$15 EURO/ITALO HOUSE), 21,159 records of stock. Levels.record_store() loads it; 'store' joins ORDER as the seventh site. The shop's schema IS the cascade. virtual_crate.rack_id already records which rack holds which bin, so bins arrive frozen and supported_by their rack and none of the structure is authored. To carry that, smashables gained two optional generic keys — 'id' and 'supported_by' — so any level can express support as data. Every existing level is unaffected. Two reconciliations, because a storefront is a renderer: - Mesh sizes are a lie: 21 archetypes share a few stand-in GLBs, so each piece carries 'fit' (the archetype's real dims from the shop's table) and _glb_piece scales the visual to it and builds the collider from it. Also fixed racks being flattened to the floor — pos_y is a piece's BOTTOM, which is what sit_on means, so the ceiling beams, aircon and wall shelves now stay up where they belong. - Nothing collides in a renderer: racks that sat inside each other for years get separated (weighted, so racks barely move and aisles keep their shape — 3 m of correction across 35), rack footprints inset 1.5 cm a side for the few genuinely wedged units, and the importer AUDITS ITS OWN OUTPUT with the same test probe_overlap runs. Bins are deliberately not separated from their rack: cargo inside a container is what the shop means, they spawn frozen, and the audit skips frozen bodies because a frozen body cannot be depenetrated across the room. dev/probe_store.gd: 22 racks hold bins, 255 labels survived, and smashing the rack carrying 22 bins releases exactly those 22 while the other 256 stay frozen. All gates green: smoke clean, 7/7 sites 0.00 m/s, overlap CLEAN, LANE9 probes pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
14 KiB
Python
319 lines
14 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 main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--url", default=URL)
|
|
ap.add_argument("--offline")
|
|
ap.add_argument("--out", default=str(OUT))
|
|
args = ap.parse_args()
|
|
|
|
d = fetch(args.url, args.offline)
|
|
space = d["space"]
|
|
W, D, H = space["room_width"], space["room_depth"], space["ceiling_height"]
|
|
# The shop's origin is a room CORNER; the game centres bounds on 0.
|
|
cx, cz = W / 2.0, D / 2.0
|
|
|
|
rack_types = {t["id"]: t for t in d["rack_types"]}
|
|
crate_types = {c["id"]: c for c in d["crate_types"]}
|
|
levels = {}
|
|
for l in d["rack_type_levels"]:
|
|
levels.setdefault(l["rack_type_id"], {})[l.get("level_index")] = l
|
|
|
|
racks = [r for r in d["racks"]
|
|
if r.get("visible") == "y" and r.get("space_id") == space["id"]]
|
|
crates_by_rack = {}
|
|
for c in d["crates"]:
|
|
if c.get("visible") == "y" and c.get("rack_id"):
|
|
crates_by_rack.setdefault(c["rack_id"], []).append(c)
|
|
|
|
pieces, slabs, footprints = [], [], []
|
|
n_crates = 0
|
|
stock = 0
|
|
|
|
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"] - cx, r["pos_z"] - cz
|
|
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)
|
|
|
|
if name in AS_SLAB:
|
|
# benches, wall shelves and the roof beam: structure, not props
|
|
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 + (r.get("pos_y") or 0.0), 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"))
|
|
rid = f"r{r['id']}"
|
|
# "fit": the archetype's REAL dimensions. 21 archetypes share a handful of
|
|
# stand-in meshes, so the mesh's own size is a lie — the shop's table is the truth.
|
|
# pos_y is the piece's BOTTOM in the shop's data — the same thing sit_on means to
|
|
# the game. Without it the ceiling beams, the aircon and the wall shelves all get
|
|
# dropped onto the floor, where they stand inside the floor racks.
|
|
base = r.get("pos_y") or 0.0
|
|
pieces.append({"kind_of": "rack", "glb": glb, "kind": kind, "id": rid,
|
|
"x": x, "z": z, "y": base, "yaw": yaw, "w": 8.0,
|
|
"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})
|
|
|
|
# Separate the RACKS only, before their bins are placed, so a rack and everything it
|
|
# carries move together. Bins are deliberately NOT separated from their own rack: a
|
|
# bin sitting in a rack is cargo inside a container, which is what the shop means and
|
|
# what the game wants. They spawn frozen, and the overlap audit skips frozen bodies
|
|
# precisely because a frozen body cannot be depenetrated across the room.
|
|
moved = resolve_overlaps(pieces, iters=400)
|
|
# A few racks in the real shop are genuinely wedged — three big units in a row that
|
|
# cannot all fit without shoving the aisle around. An archetype's width is a NOMINAL
|
|
# envelope, so take a centimetre and a half off each side of every rack's footprint:
|
|
# invisible at any distance, and it buys the solver the clearance it needs. Heights
|
|
# are untouched, so nothing sinks into the floor or leaves the ceiling.
|
|
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)
|
|
left = audit(pieces)
|
|
for depth, na, nb in left[:6]:
|
|
print(f"[import_store] STILL OVERLAPPING {depth * 100:.0f} cm: {na} / {nb}")
|
|
|
|
for p in [p for p in pieces if p["kind_of"] == "rack"]:
|
|
r = p["rack"]
|
|
for c in crates_by_rack.get(r["id"], []):
|
|
ct = crate_types.get(c["crate_type_id"], {})
|
|
cname = ct.get("name", "")
|
|
if cname in SKIP_CRATE_TYPES:
|
|
continue
|
|
# crate pos is LOCAL to its rack (pos_y already carries the shelf height)
|
|
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,
|
|
"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)],
|
|
"supported_by": p["id"]}
|
|
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
|
|
|
|
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:
|
|
# frozen + supported: bins don't jitter at spawn (the stillness gate stays
|
|
# green) and they go live exactly when their rack does
|
|
e["frozen"] = True
|
|
e["supported_by"] = p["supported_by"]
|
|
if p.get("label"):
|
|
e["label"] = p["label"]
|
|
smashables.append(e)
|
|
|
|
# --- a measured spawn point ---------------------------------------------------------
|
|
# 35 racks in a 6.66 x 9.51 room leaves very little open floor, and spawning inside a
|
|
# rack is not a level. Scan a grid for the clearest spot in the front half of the shop.
|
|
best, best_clear = (0.0, D / 2.0 - 0.9), -1.0
|
|
step = 0.25
|
|
x0, x1 = -cx + 0.6, cx - 0.6
|
|
z0, z1 = -cz + 0.6, cz - 0.6
|
|
gx = x0
|
|
while gx <= x1:
|
|
gz = z0
|
|
while gz <= z1:
|
|
clear = min(((gx - fx) ** 2 + (gz - fz) ** 2) ** 0.5 - fr
|
|
for fx, fz, fr in footprints) if footprints else 9.9
|
|
# prefer the front of the shop (larger z), where the door is
|
|
score = clear + (gz + cz) * 0.05
|
|
if clear > 0.55 and score > best_clear:
|
|
best_clear, best = score, (gx, gz)
|
|
gz += step
|
|
gx += step
|
|
|
|
out = {
|
|
"_source": args.url if not args.offline else args.offline,
|
|
"_note": "GENERATED by tools/import_store.py from the live shop. Do not hand-edit.",
|
|
"room": {"w": W, "d": D, "h": H},
|
|
"counts": {"racks": len(racks), "crates": n_crates, "stock_records": stock,
|
|
"slabs": len(slabs)},
|
|
"spawn": {"at": [round(best[0], 2), round(best[1], 2)]},
|
|
"slabs": slabs,
|
|
"smashables": smashables,
|
|
}
|
|
p = pathlib.Path(args.out)
|
|
p.write_text(json.dumps(out, indent=1))
|
|
print(f"[import_store] room {W} x {D} x {H}")
|
|
print(f"[import_store] separation pass moved {moved:.2f} m total")
|
|
print(f"[import_store] {len(racks)} racks, {n_crates} crates, {len(slabs)} slabs, "
|
|
f"{stock:,} records of stock")
|
|
print(f"[import_store] spawn at ({best[0]:.2f}, {best[1]:.2f}), 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())
|