LANE10: SITE 7 — the real shop, imported from its own database
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>
This commit is contained in:
parent
14ebc1d6f5
commit
8e61640c38
63
LANES/LANE10-record-store.md
Normal file
63
LANES/LANE10-record-store.md
Normal file
@ -0,0 +1,63 @@
|
||||
# LANE 10 — SITE 7: MONSTER ROBOT PARTY
|
||||
|
||||
*The level the whole game started as. Spec'd and executed by Fable, 2026-08-04.*
|
||||
|
||||
## The idea
|
||||
|
||||
Every other site is authored. This one is **imported from the real shop**. The live
|
||||
storefront at `robotmonster.party/store/` is procedurally assembled from Postgres rows
|
||||
(`virtual_rack`, `virtual_crate`, `virtual_rack_type*`, `virtual_crate_type` — the
|
||||
wowplatter schema, now served by recordgod/storegod), and the game's sites are already
|
||||
data. So Site 7 is not hand-placed: a converter reads the shop and emits a level.
|
||||
|
||||
`GET https://robotmonster.party/virtual/scene` returns the shop floor:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| room | 6.66 m × 9.51 m, 2.89 m ceiling |
|
||||
| racks | 35 placed, across 21 archetypes |
|
||||
| crates | 302, every one on a rack |
|
||||
| labels | real: `HARD TRANCE`, `$12-$15 EURO/ITALO HOUSE`, `SOUND TRACKS, SPOKEN WORD & COMEDY` |
|
||||
| stock | **22,049 records** across the bins (`n_items`) |
|
||||
|
||||
The rack archetype names (`wooden-rack-type-long`, `white-tub-3x3`, `chiu-long`) are the
|
||||
same names as the GLBs in `3D-STORE/`, because both were generated from the same table.
|
||||
|
||||
## Why this is the best-fitting level in the game
|
||||
|
||||
The shop's schema **is** the cascade. `virtual_crate.rack_id` already says which rack
|
||||
holds which bin, so `rack.supports = [its crates]` falls out of the data — knock a rack
|
||||
and the real bins that really sit on it really go over. Nothing to author.
|
||||
|
||||
## Deliverables
|
||||
|
||||
### 1. Generic data-driven cascade *(`Main.gd`, small)*
|
||||
Extend the `smashables` spec entry with two optional keys so a level can express support
|
||||
without code: `"id"` (a name other entries can refer to) and `"supported_by"` (the id of
|
||||
the piece holding this one up). Main builds in order, keeps `id -> Smashable`, and wires
|
||||
`holder.supports.append(piece)`. Every existing level is unaffected.
|
||||
|
||||
### 2. `tools/import_store.py` — the converter
|
||||
Fetches the live scene (`--url`, or `--offline <file>`), filters to the shop-floor space,
|
||||
and emits `game/assets/store/shop_floor.json`:
|
||||
- world-space rack positions, **re-centred** (the shop's origin is a corner; the game
|
||||
centres its bounds on 0)
|
||||
- each crate placed by `rack_pos + rotate_y(crate_local, rack.rotation_y)`, carrying its
|
||||
real `label` and `n_items`
|
||||
- archetype → game GLB + material mapping (tubs are `plastic`, timber racks `wood`,
|
||||
columns and aircon `steel`)
|
||||
- crates spawn **frozen** and supported by their rack: they don't jitter at spawn (the
|
||||
stillness gate stays green) and they go live exactly when the rack does
|
||||
- a **measured spawn point** — a grid scan for open floor, because a 6.66 × 9.51 room
|
||||
holding 35 racks has very little of it, and spawning inside a rack is not a level
|
||||
|
||||
Re-runnable: when the real shop is rearranged, re-run it and the level changes.
|
||||
|
||||
### 3. `Levels.record_store()` — the site
|
||||
Loads the JSON and returns a normal Floorplan spec. Timber floor, white walls, the
|
||||
brand's pink as accent. Added to `Levels.ORDER` as the seventh site.
|
||||
|
||||
## Gates
|
||||
Headless smoke clean · `probe_levels` **7/7** at 0.00 m/s · `probe_overlap` CLEAN ·
|
||||
the LANE9 probes still green. Plus `dev/probe_store.gd`: the site builds, the rack→crate
|
||||
cascade is wired, and smashing one rack releases exactly its own bins.
|
||||
@ -12,10 +12,11 @@ rest of your day. Your score is your payslip: **wages earned minus damages cause
|
||||
during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive
|
||||
filing cabinet. Falling Down, scored by payroll.
|
||||
|
||||
**Six sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
|
||||
basement, a greengrocer where everything is soft except the bottles, and the Backrooms — where the walls are procedurally generated and everything
|
||||
**Seven sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
|
||||
basement, a greengrocer where everything is soft except the bottles, the Backrooms — where the walls are procedurally generated and everything
|
||||
you're there to destroy is invisible until you coat it in extinguisher powder, some of
|
||||
which you breathe) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
|
||||
which you breathe, and the real Monster Robot Party shop — imported live from the
|
||||
storefront's own database, bins, genre labels and all) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
|
||||
Clock). Sites are data, not code — see `game/scripts/Levels.gd`.
|
||||
|
||||
Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).
|
||||
|
||||
@ -21,7 +21,7 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
|
||||
| **1–6 · wheel · Q** | pick a weapon · cycle · swap to previous |
|
||||
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
|
||||
| **M** | cycle game mode (Cubicle Hell / Gauntlet / Total Destruction / Against the Clock) |
|
||||
| **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0) |
|
||||
| **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0 / Monster Robot Party) |
|
||||
| **G** | restart a Gauntlet run |
|
||||
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
|
||||
| **E** | work at the station you're standing at (locks you in place); else hop on / off an office chair under the crosshair; else pick up whatever's there |
|
||||
@ -46,6 +46,7 @@ generator can later emit the same structure.
|
||||
| **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers |
|
||||
| **THE GREENGROCER** | bright, pale, warm strips | everything is **soft** except the bottles — see below |
|
||||
| **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** |
|
||||
| **MONSTER ROBOT PARTY** | timber, white, shop pink | the real shop, **imported from the live storefront** — see below |
|
||||
|
||||
`dev/probe_levels.gd` builds every one and reports residual motion after a full second.
|
||||
All six must read `0.00 m/s`.
|
||||
@ -259,6 +260,44 @@ tick on the meter — it's what makes the room hostile rather than merely beige.
|
||||
per object but each object bills at its own value, so the meltdown decision is *smash a
|
||||
lot of cheap things fast* versus *one expensive swing*.
|
||||
|
||||
### MONSTER ROBOT PARTY — the only site that isn't authored
|
||||
|
||||
The shop the whole game started as, and the only one **imported** rather than written.
|
||||
The live storefront at `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 real shop and emits
|
||||
`assets/store/shop_floor.json`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| room | 6.66 m × 9.51 m, 2.89 m ceiling — the actual floor |
|
||||
| racks | 35, across 21 archetypes |
|
||||
| bins | 278, each carrying its **real genre label** (`HARD TRANCE`, `$12-$15 EURO/ITALO HOUSE`) |
|
||||
| stock | **21,159 records** |
|
||||
|
||||
**The shop's schema is the cascade.** `virtual_crate.rack_id` already records which rack
|
||||
holds which bin, so every bin arrives frozen and `supported_by` its rack — knock a rack
|
||||
over and the actual bins that actually sit on it actually go. None of it is authored; it
|
||||
comes off the shop floor. `dev/probe_store.gd` proves it: smashing the rack that carries
|
||||
22 bins releases exactly those 22 and leaves the other 256 frozen.
|
||||
|
||||
Two things the importer has to reconcile, because a web storefront is a renderer:
|
||||
|
||||
- **The mesh sizes are a lie.** 21 archetypes share a handful of stand-in GLBs, so each
|
||||
piece carries a `fit` — the archetype's real width/height/depth from the shop's own
|
||||
table — and the game scales the visual to it and builds the collider from it.
|
||||
- **Nothing collides in a renderer.** A rack can sit a few centimetres inside its
|
||||
neighbour and look fine for years; Jolt disagrees violently. The importer separates
|
||||
them (weighted, so racks barely move and the aisles keep their shape — 3 m of total
|
||||
correction across 35 racks), insets rack footprints by 1.5 cm a side, and then **audits
|
||||
its own output** with the same test `dev/probe_overlap.gd` runs.
|
||||
|
||||
Re-run it when the real shop is rearranged and the level follows:
|
||||
|
||||
```sh
|
||||
python3 tools/import_store.py
|
||||
```
|
||||
|
||||
## The fun layer (LANE9)
|
||||
|
||||
Three antagonists to the *tidy* impulse, all riding systems that already exist:
|
||||
|
||||
5577
game/assets/store/shop_floor.json
Normal file
5577
game/assets/store/shop_floor.json
Normal file
File diff suppressed because it is too large
Load Diff
82
game/dev/probe_store.gd
Normal file
82
game/dev/probe_store.gd
Normal file
@ -0,0 +1,82 @@
|
||||
extends SceneTree
|
||||
|
||||
## SITE 7 contract — the imported shop:
|
||||
## - it builds, with racks AND bins
|
||||
## - the cascade came off the shop's own data: racks hold bins via `supported_by`
|
||||
## - smashing one rack releases exactly ITS bins, and no others
|
||||
## - the real genre labels survived the import
|
||||
##
|
||||
## Godot --headless --path game --script dev/probe_store.gd
|
||||
|
||||
func _initialize() -> void:
|
||||
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
|
||||
get_root().add_child(main)
|
||||
await process_frame
|
||||
main._load_level("store")
|
||||
for i in 70:
|
||||
await physics_frame
|
||||
|
||||
var racks: Array = []
|
||||
var bins: Array = []
|
||||
var labelled := 0
|
||||
for n in get_nodes_in_group("smashable"):
|
||||
var s := n as Smashable
|
||||
if s == null:
|
||||
continue
|
||||
if s.supports.size() > 0:
|
||||
racks.append(s)
|
||||
if s.freeze:
|
||||
bins.append(s)
|
||||
if s.has_meta("label"):
|
||||
labelled += 1
|
||||
print("racks holding bins: %d frozen bins: %d labelled bins: %d" % [
|
||||
racks.size(), bins.size(), labelled])
|
||||
if racks.is_empty() or bins.is_empty():
|
||||
print("FAIL: the shop did not import (no racks holding bins)")
|
||||
quit(1)
|
||||
return
|
||||
if labelled < 20:
|
||||
print("FAIL: the real genre labels did not survive the import")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# find the rack carrying the most bins and knock it over
|
||||
var best: Smashable = racks[0]
|
||||
for r in racks:
|
||||
if r.supports.size() > best.supports.size():
|
||||
best = r
|
||||
var mine: Array = []
|
||||
for b in best.supports:
|
||||
if is_instance_valid(b):
|
||||
mine.append(b)
|
||||
var others: Array = []
|
||||
for b in bins:
|
||||
if is_instance_valid(b) and not mine.has(b):
|
||||
others.append(b)
|
||||
var others_frozen_before := 0
|
||||
for b in others:
|
||||
if (b as RigidBody3D).freeze:
|
||||
others_frozen_before += 1
|
||||
|
||||
print("smashing '%s' — it carries %d bins" % [best.name, mine.size()])
|
||||
best.smash(Vector3(0, 1, 0), 999.0)
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
|
||||
var released := 0
|
||||
for b in mine:
|
||||
if is_instance_valid(b) and not (b as RigidBody3D).freeze:
|
||||
released += 1
|
||||
var others_still := 0
|
||||
for b in others:
|
||||
if is_instance_valid(b) and (b as RigidBody3D).freeze:
|
||||
others_still += 1
|
||||
print("its bins released: %d/%d other bins still frozen: %d/%d" % [
|
||||
released, mine.size(), others_still, others_frozen_before])
|
||||
|
||||
if released == mine.size() and others_still == others_frozen_before:
|
||||
print("STORE OK")
|
||||
quit(0)
|
||||
else:
|
||||
print("FAIL: cascade released the wrong set of bins")
|
||||
quit(1)
|
||||
@ -20,7 +20,7 @@ class_name Levels
|
||||
##
|
||||
## Godot 4.7 GDScript 2.0.
|
||||
|
||||
const ORDER := ["scranton", "pawnee", "house", "basement", "grocer", "backrooms"]
|
||||
const ORDER := ["scranton", "pawnee", "house", "basement", "grocer", "backrooms", "store"]
|
||||
|
||||
static func get_level(id: String) -> Dictionary:
|
||||
match id:
|
||||
@ -29,6 +29,7 @@ static func get_level(id: String) -> Dictionary:
|
||||
"basement": return basement()
|
||||
"backrooms": return backrooms()
|
||||
"grocer": return grocer()
|
||||
"store": return record_store()
|
||||
return scranton()
|
||||
|
||||
static func next_id(id: String) -> String:
|
||||
@ -717,3 +718,82 @@ static func grocer() -> Dictionary:
|
||||
"stand": Vector3(-8.6, 0.0, -4.0), "shredder": Vector3(-6.4, 0.0, -5.4)},
|
||||
"spawn": {"at": Vector3(0.0, 0.0, 6.4), "yaw": PI},
|
||||
}
|
||||
|
||||
# ================================================================ SITE 7
|
||||
## THE MONSTER ROBOT PARTY — the shop the whole game started as.
|
||||
##
|
||||
## The only site that is IMPORTED rather than authored. The live storefront at
|
||||
## robotmonster.party/store/ is assembled procedurally from Postgres (the wowplatter
|
||||
## virtual_* schema, served by recordgod), and this game's sites are data, so
|
||||
## tools/import_store.py reads the real shop and emits assets/store/shop_floor.json:
|
||||
## 35 racks, 278 bins carrying their real genre labels, and 21,159 records of stock.
|
||||
##
|
||||
## The shop's schema IS the cascade. virtual_crate.rack_id already records which rack
|
||||
## holds which bin, so every crate arrives frozen and `supported_by` its rack — knock a
|
||||
## rack over and the actual bins that actually sit on it actually go. None of that is
|
||||
## authored; it comes off the shop floor.
|
||||
##
|
||||
## Re-run the importer when the real shop is rearranged and this level follows.
|
||||
const STORE_JSON := "res://assets/store/shop_floor.json"
|
||||
|
||||
static func record_store() -> Dictionary:
|
||||
var raw := FileAccess.get_file_as_string(STORE_JSON)
|
||||
var data = JSON.parse_string(raw) if raw != "" else null
|
||||
if data == null:
|
||||
push_warning("[store] %s missing — run tools/import_store.py" % STORE_JSON)
|
||||
data = {"room": {"w": 6.66, "d": 9.51, "h": 2.89}, "smashables": [], "slabs": [],
|
||||
"spawn": {"at": [0.0, 3.0]}}
|
||||
|
||||
var room: Dictionary = data.get("room", {})
|
||||
var w: float = float(room.get("w", 6.66))
|
||||
var dp: float = float(room.get("d", 9.51))
|
||||
var h: float = float(room.get("h", 2.89))
|
||||
|
||||
# JSON has no Vector types — rehydrate into what Floorplan/Main expect.
|
||||
var smash: Array = []
|
||||
for e in data.get("smashables", []):
|
||||
var s: Dictionary = e.duplicate()
|
||||
var a: Array = s["at"]
|
||||
s["at"] = Vector2(float(a[0]), float(a[1]))
|
||||
smash.append(s)
|
||||
var slabs: Array = []
|
||||
for e in data.get("slabs", []):
|
||||
var sz: Array = e["size"]
|
||||
var at: Array = e["at"]
|
||||
slabs.append({"size": Vector3(float(sz[0]), float(sz[1]), float(sz[2])),
|
||||
"at": Vector3(float(at[0]), float(at[1]), float(at[2])),
|
||||
"mat": String(e.get("mat", "wood"))})
|
||||
var sp: Array = data.get("spawn", {}).get("at", [0.0, 3.0])
|
||||
|
||||
return {
|
||||
"name": "MONSTER ROBOT PARTY",
|
||||
"subtitle": "your own shop. you know exactly what everything cost.",
|
||||
"bounds": {"x0": -w * 0.5, "x1": w * 0.5, "z0": -dp * 0.5, "z1": dp * 0.5, "h": h},
|
||||
"palette": {
|
||||
"floor": Color(0.42, 0.31, 0.22), "wall": Color(0.93, 0.92, 0.90),
|
||||
"ceiling": Color(0.17, 0.16, 0.19), "trim": Color(0.19, 0.18, 0.21),
|
||||
"partition": Color(0.35, 0.20, 0.34), "wood": Color(0.52, 0.36, 0.20),
|
||||
"accent": Color(1.0, 0.36, 0.62), # the shop's own pink
|
||||
},
|
||||
"floor_style": "timber",
|
||||
"ceiling": "flat",
|
||||
"walls": [],
|
||||
# the shopfront: glazing down the street side, where the window display is
|
||||
"windows": [{"a": "z", "at": dp * 0.5, "from": -w * 0.5, "to": w * 0.5,
|
||||
"y0": 0.55, "y1": 2.45, "blinds": false}],
|
||||
"lights": {"xs": [-1.9, 1.9], "zs": [-3.4, -1.1, 1.2, 3.5],
|
||||
"color": Color(1.0, 0.93, 0.86), "energy": 2.1, "style": "strip",
|
||||
"key_energy": 1.5},
|
||||
"sun": {"color": Color(1.0, 0.95, 0.88), "energy": 0.9, "rot": Vector3(-38, 14, 0)},
|
||||
"env": {"bg": Color(0.72, 0.76, 0.82), "ambient": Color(0.58, 0.55, 0.56),
|
||||
"ambient_energy": 0.62, "exposure": 1.02},
|
||||
"clusters": [],
|
||||
"chairs": [],
|
||||
"props": [],
|
||||
"slabs": slabs,
|
||||
"smashables": smash,
|
||||
"stacks": [],
|
||||
# a few playable records loose on the counter side, for the ritual
|
||||
"records": [],
|
||||
"spawn": {"at": Vector3(float(sp[0]), 0.0, float(sp[1])), "yaw": PI},
|
||||
}
|
||||
|
||||
@ -246,11 +246,33 @@ func _populate() -> void:
|
||||
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
|
||||
|
||||
# --- everything else this site contains, straight from its spec ---
|
||||
# Two optional keys let a level express STRUCTURE as data instead of code: "id" names
|
||||
# a piece, "supported_by" points at the id of whatever holds it up. Wiring happens
|
||||
# after the loop so an entry may refer to a holder declared later. This is what makes
|
||||
# the record store importable — the shop's own DB already knows which rack holds which
|
||||
# bin, so the cascade comes straight off `virtual_crate.rack_id`. See LANE10.
|
||||
var by_id: Dictionary = {}
|
||||
var links: Array = []
|
||||
for e in _plan.smashables():
|
||||
var d: Dictionary = e
|
||||
_glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
|
||||
var fit := Vector3.ZERO
|
||||
if d.has("fit"):
|
||||
var f: Array = d["fit"]
|
||||
fit = Vector3(float(f[0]), float(f[1]), float(f[2]))
|
||||
var res := _glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
|
||||
d["at"], bool(d.get("frozen", false)), float(d.get("sit_on", 0.0)),
|
||||
float(d.get("yaw", 0.0)))
|
||||
float(d.get("yaw", 0.0)), false, fit)
|
||||
var piece := res["piece"] as Smashable
|
||||
if d.has("id"):
|
||||
by_id[String(d["id"])] = piece
|
||||
if d.has("supported_by"):
|
||||
links.append([String(d["supported_by"]), piece])
|
||||
if d.has("label"):
|
||||
piece.set_meta("label", String(d["label"]))
|
||||
for link in links:
|
||||
var holder := by_id.get(link[0]) as Smashable
|
||||
if holder != null and is_instance_valid(holder):
|
||||
holder.supports.append(link[1])
|
||||
|
||||
# --- crates of records, where the site has any. Records stand side by side ON the
|
||||
# --- crate lid, offset by more than a sleeve is thick: they used to be stacked 3 cm
|
||||
@ -491,7 +513,7 @@ func _glb_radius(path: String) -> float:
|
||||
## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns
|
||||
## { piece: Smashable, top: float } so a caller can stack the next thing on top.
|
||||
func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0,
|
||||
yaw := 0.0, centred := false) -> Dictionary:
|
||||
yaw := 0.0, centred := false, fit := Vector3.ZERO) -> Dictionary:
|
||||
var s := Smashable.new()
|
||||
s.kind = kind
|
||||
s.start_frozen = frozen
|
||||
@ -512,6 +534,13 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on :=
|
||||
var ab := _body_local_aabb(s, vis)
|
||||
if ab.size == Vector3.ZERO:
|
||||
ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback
|
||||
# `fit`: this piece's REAL dimensions, when the caller knows them better than the mesh
|
||||
# does. The record store maps 21 shop archetypes onto a handful of stand-in meshes, so
|
||||
# the mesh's own size is a lie — scale the visual to the truth and measure again, and
|
||||
# both the render and the collider match the shop that was imported.
|
||||
if fit != Vector3.ZERO and ab.size.x > 0.001 and ab.size.y > 0.001 and ab.size.z > 0.001:
|
||||
vis.scale = Vector3(fit.x / ab.size.x, fit.y / ab.size.y, fit.z / ab.size.z)
|
||||
ab = _body_local_aabb(s, vis)
|
||||
var col := CollisionShape3D.new()
|
||||
# Round produce gets a SPHERE. A box collider on an apple is why a pyramid needed
|
||||
# hand-tuned lattice spacing to nest, and why nothing ever rolled: the corners hit
|
||||
|
||||
318
tools/import_store.py
Normal file
318
tools/import_store.py
Normal file
@ -0,0 +1,318 @@
|
||||
#!/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())
|
||||
Loading…
Reference in New Issue
Block a user