John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
17 KiB
JavaScript
264 lines
17 KiB
JavaScript
// PROCITY Lane C — public API. The Vuntra move: every shop door opens into a unique, believable,
|
||
// seeded interior, generated on demand in <50ms, byte-identical every revisit (shop.seed), themed
|
||
// by shop.type. This is a standalone library + test page; Lane B wires it to real doors later via
|
||
// the procity:enterShop / procity:exitShop events.
|
||
//
|
||
// import { buildInterior } from './js/interiors/interiors.js';
|
||
// import * as THREE from 'three';
|
||
// const room = buildInterior(shop, THREE); // shop = a CityPlan shop record + its lot dims
|
||
// scene.add(room.group);
|
||
// // … player walks around, hits room.exits …
|
||
// room.dispose(); // frees all per-room geometry/material/textures
|
||
//
|
||
// buildInterior(shop, THREE, opts?) → {
|
||
// group, // THREE.Group, self-contained (lights included). Add to a scene.
|
||
// spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door)
|
||
// exits: [ { x, z, w, toStreet } ], // door(s) back to the street
|
||
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
|
||
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
|
||
// // stands, in room-local space, facing the customer.
|
||
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
|
||
// // browser rigs, facing the goods (same frame + ry as counter.stand)
|
||
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
|
||
// // room-tone), seeded per shop. musicKey may be null. gigKey set
|
||
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
|
||
// watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
|
||
// // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
|
||
// // frame + ry convention as counter.stand (rig-front = local −Z).
|
||
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses,backline }, // VENUE (else null) — the band stage. bandPoses[4]:
|
||
// // front-line trio (guitar/vocal/bass) on the deck + a drummer (role
|
||
// // 'drums', seated) up-stage on the riser. Each pose carries y (front
|
||
// // line = deckY, drummer = riserY). ry=π faces the audience. backline[]
|
||
// // (v3.1) = up-stage amp slots {x,z,ry,y} where Lane D plants the
|
||
// // guitar_amp GLB (primitive fallback under ?noassets).
|
||
// dims: { W, D, H, archetype, type }, // room metrics
|
||
// placement, // deterministic placement summary (deep-equal per seed)
|
||
// pathOK, // door→counter connectivity held (always true; carved if needed)
|
||
// dispose(), // release GPU resources; removes group from its parent
|
||
// }
|
||
//
|
||
// `shop` is tolerant. Recognized fields: { id, type, name, seed, storeys, lot:{w,d} }.
|
||
// • seed : uint32 — the ONLY randomness source (falls back to a hash of id/name if absent)
|
||
// • type : any CITY_SPEC type or thriftgod alias (see theme.js); defaults to 'opshop'
|
||
// • lot : { w, d } metres — room adapts to it; if absent, archetype ranges decide the size
|
||
// • storeys: ceiling height driver (1 → 3.2m … up to 4.5m)
|
||
// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, stock?: 'real', stockBase?,
|
||
// fittingOpts? }
|
||
// • fittingOpts (R39): { [fittingKind]: {…} } — per-build override of a recipe's own fitting opts.
|
||
// `{ rummageBin: { dig: true } }` turns the op shop's rummage bin into a DIGGABLE bin (§39). Omit
|
||
// ⇒ recipe defaults ⇒ byte-identical to pre-R39.
|
||
// • stockBase (v5.0): which real-stock pack `stock:'real'` resolves. Omit → the town-wide packs
|
||
// (`assets/models/`, today's behaviour). Pass `assets/stock_godverse/<godverseShopId>/` for a shop
|
||
// with its own real crate. The pack cache is keyed by (type, base) — see stockpack.js + LANE_C_PUB §7.
|
||
//
|
||
// Registry override (Lane F): call mergeRegistry(registry) ONCE at init to let authored registry data
|
||
// override recipe fields. It is deliberately NOT a per-build option — a per-call mutation of the shared
|
||
// recipe table would break the same-seed determinism guarantee for every later build.
|
||
|
||
import { Ctx } from './context.js';
|
||
import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js';
|
||
import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js';
|
||
import { layout } from './layout.js';
|
||
import { batchRoom } from './batch.js';
|
||
import { preloadManifest, upgradeRoom } from './glb.js';
|
||
import { getStockPack, makeStockAdapter } from './stockpack.js';
|
||
import { xmur3 } from '../core/prng.js';
|
||
|
||
export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
|
||
export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
|
||
export { preloadStockPack, getStockPack, makeStockAdapter } from './stockpack.js'; // ?stock=real
|
||
export { WARDROBE_BASE } from './stockpack.js'; // [R42] the op-shop pack's base — declared once (F's tidy)
|
||
|
||
let glbManifest = null; // resolved manifest cache (once the depot answers)
|
||
const _warned = new Set();
|
||
function warnOnce(msg) { if (!_warned.has(msg)) { _warned.add(msg); console.warn('[interiors] ' + msg); } }
|
||
|
||
function normalizeShop(shop) {
|
||
const s = shop || {};
|
||
let seed = s.seed;
|
||
if (seed == null) seed = xmur3(`${s.id ?? ''}:${s.name ?? ''}:${s.type ?? ''}`)();
|
||
const lot = s.lot && s.lot.w && s.lot.d ? { w: +s.lot.w, d: +s.lot.d }
|
||
: (s.lotW && s.lotD ? { w: +s.lotW, d: +s.lotD } : null);
|
||
return {
|
||
id: s.id ?? 'shop',
|
||
type: s.type ?? 'opshop',
|
||
name: s.name ?? '',
|
||
seed: seed >>> 0,
|
||
storeys: s.storeys ?? 1,
|
||
lot,
|
||
raw: s,
|
||
};
|
||
}
|
||
|
||
// ── interior audio contract (round 11) ───────────────────────────────────────────────
|
||
// buildInterior returns room.audio = { musicKey, toneKey } — keys into Lane E's manifest.audio (a music
|
||
// bed + an interior room-tone). Lane F's interior_mode plays them on enter / fades on exit; Lane B's
|
||
// audio.js resolves key → file. Missing key / ?mute=1 / ?noassets=1 → silence (house audio law), so this
|
||
// is pure data: buildInterior stays synchronous and asset-free, it only names what SHOULD play.
|
||
// The maps mirror the manifest's per-type `types` arrays so the type→bed grouping has one source of truth.
|
||
const ROOMTONE_BY_TYPE = { // manifest.audio.ambience entries with scope:'interior'
|
||
milkbar: 'roomtone-milkbar',
|
||
video: 'roomtone-video', dept: 'roomtone-video', arcade: 'roomtone-video',
|
||
}; // default → 'roomtone-retail'
|
||
const MUSIC_BY_TYPE = { // manifest.audio.music dedicated beds
|
||
record: 'record-shop', // "record shop plays music" — always on
|
||
milkbar: 'milkbar', video: 'video-synth',
|
||
dept: 'arcade', arcade: 'arcade',
|
||
}; // default → null (no dedicated bed)
|
||
// The genre a venue kind plays — mirrors registry.SHOP_TYPES[kind].genre so C resolves the gigKey even
|
||
// standalone (the gig plan carries shop.genreKey; this is the fallback when a test builds a bare venue
|
||
// without A's conversion). ROUND13 §Lane C: pub → pubrock, band_room → grunge, rsl → covers.
|
||
const GENRE_BY_TYPE = { pub: 'pubrock', band_room: 'grunge', rsl: 'covers' };
|
||
|
||
function audioFor(type, ctx, opts, shop) {
|
||
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
|
||
let musicKey = MUSIC_BY_TYPE[type] || null;
|
||
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
|
||
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
|
||
// revisit and adding it never shifts another subsystem's seeded picks.
|
||
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
|
||
const audio = { musicKey, toneKey };
|
||
// Venue gig (R12/13): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
|
||
// while the gig is on. A quiet-night venue has no opts.gig → normal interior (room-tone + maybe radio).
|
||
// The genre resolves gig.genreKey → the venue's own shop.genreKey (A sets it) → the kind default. The
|
||
// manifest key IS the gigKey, canonical form `gig-<genreKey>` — no mapping table (ROUND13 debt #1). This
|
||
// string is byte-identical to citygen's `gigKeyFor(genreKey)` (selfcheck asserts it); it is hand-built,
|
||
// NOT imported, on purpose — the interiors lib stays standalone (never imports citygen). Keep the form,
|
||
// don't privately rename it (the R12 trap) and don't import citygen (breaks standalone).
|
||
const gig = opts && opts.gig;
|
||
if (gig && (gig.on || gig.genreKey)) {
|
||
const genreKey = gig.genreKey || (shop && shop.genreKey) || GENRE_BY_TYPE[type] || 'pubrock';
|
||
audio.gigKey = 'gig-' + genreKey;
|
||
}
|
||
return audio;
|
||
}
|
||
|
||
// ── audio EMITTERS (round 28) — WHERE a key sounds from ────────────────────────────
|
||
// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the
|
||
// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING
|
||
// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note
|
||
// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference
|
||
// to the room group and would have to walk a scene it can't reach. The line was the stale thing).
|
||
//
|
||
// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not
|
||
// a constant — measured across 8 types × 4 seeds, diagonals run 9.81–15.63 m, and scaling by the room
|
||
// keeps the door→stage proximity in a consistent 0.42–0.59 band from a pokey band_room to a hall pub.
|
||
// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk
|
||
// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game.
|
||
//
|
||
// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree
|
||
// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms
|
||
// are built in room-local coords at the origin, is the room's own centre, forever. Measured via
|
||
// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no
|
||
// error, no warning, and it never changes as you cross the room. Use a distance-gain against
|
||
// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters.
|
||
const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source
|
||
const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in
|
||
|
||
function emittersFor(lay, dims) {
|
||
const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach
|
||
const em = {};
|
||
if (lay.stage) {
|
||
// ear height above the deck, at the band's centre — not the deck surface
|
||
em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z),
|
||
r, floor: EMITTER_FLOOR.stage };
|
||
}
|
||
if (lay.counter) {
|
||
em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z,
|
||
r, floor: EMITTER_FLOOR.counter };
|
||
}
|
||
return em;
|
||
}
|
||
|
||
export function buildInterior(shop, THREE, opts) {
|
||
opts = opts || {}; // tolerate undefined AND explicit null
|
||
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
|
||
|
||
const norm = normalizeShop(shop);
|
||
const recipe = getRecipe(norm.type);
|
||
const ctx = new Ctx(THREE, norm.seed);
|
||
// [Lane F R30 seam — SLEEP=TOMORROW, LANE_F_NOTES §30.3] opts.stockDay salts ONLY the stock pick
|
||
// streams (layout.js `stk-*`): the room/fittings/audio streams are untouched, so the shop is the same
|
||
// shop every day and only what's in the crates rotates. Absent (no game / day 1 / real-sourced) ⇒ ''.
|
||
ctx.stockSalt = opts.stockDay != null ? ':d' + (opts.stockDay >>> 0) : '';
|
||
|
||
// room shape → dims → shell
|
||
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
||
const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys);
|
||
const shell = buildShell(ctx, { recipe, archetype, dims });
|
||
|
||
// fittings + wall decor + stock + walkable-path guarantee. ?stock=real feeds a real-stock pack through
|
||
// the stockAdapter seam (preload it first); missing pack → parody canvas (warn once).
|
||
// `opts.stockBase` (v5.0) selects WHICH pack: omit it for the town-wide packs, or pass a per-shop base
|
||
// (`assets/stock_godverse/<godverseShopId>/`) for a shop with its own real crate. The pack cache is keyed
|
||
// by (type, base), so a per-shop pack and the town-wide pack coexist — see stockpack.js.
|
||
let adapter = opts.stockAdapter || null;
|
||
if (!adapter && opts.stock === 'real') {
|
||
const pack = getStockPack(recipe.key, opts.stockBase);
|
||
if (pack) adapter = makeStockAdapter(pack);
|
||
else warnOnce(`stock=real: no pack for "${recipe.key}" @${opts.stockBase || 'default base'} (preload it) — using parody canvas`);
|
||
}
|
||
// [R39] opts.fittingOpts = { <fittingKind>: {…} } — per-build overrides of a recipe's own fitting
|
||
// opts (`spec.o`). Pure data, no stream reads; omitted ⇒ every recipe's declared defaults, i.e.
|
||
// byte-identical to pre-R39. The one live use is `{ rummageBin: { dig: true } }` — see §39.
|
||
// [R41] opts.noKit — the control arm for §41.4's 110 new fittings (see layout.js). Diagnostics only.
|
||
const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm, fittingOpts: opts.fittingOpts,
|
||
noKit: opts.noKit });
|
||
|
||
// DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes
|
||
// inside each fitting group into a few merged geometries. Fitting groups (and their userData) are
|
||
// kept, so bins stay raycast targets for ?dig=1. Runs BEFORE the GLB upgrade so its frame-hide sees
|
||
// the merged fixture meshes. opts.noBatch disables it (diagnostics only).
|
||
if (!opts.noBatch) batchRoom(ctx, shell.group);
|
||
|
||
// OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
|
||
// unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
|
||
// or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
|
||
let glbReady = Promise.resolve(); // resolves when GLB upgrades settle (immediately if off)
|
||
if (opts.useGLB) {
|
||
const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
|
||
if (manifest) glbReady = upgradeRoom(ctx, lay.placed, manifest, recipe.key);
|
||
else glbReady = preloadManifest(opts.manifestUrl).then(m => {
|
||
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m, recipe.key); }
|
||
});
|
||
}
|
||
|
||
const group = shell.group;
|
||
const buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
|
||
|
||
let disposed = false;
|
||
const api = {
|
||
group,
|
||
spawn: shell.spawn,
|
||
exits: shell.exits,
|
||
places: lay.places,
|
||
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
|
||
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
|
||
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
|
||
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
|
||
// { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound
|
||
// from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒
|
||
// room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged).
|
||
audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) },
|
||
dims: { ...dims, archetype, type: recipe.key },
|
||
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
||
placement: lay.placement,
|
||
pathOK: lay.pathOK,
|
||
carved: lay.carved,
|
||
pulls: lay.pulls, // [R41] { pulled, restored, lost } — what the door→counter path
|
||
// guarantee cost this room. lost > 0 means a real blocker.
|
||
glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled
|
||
buildMs,
|
||
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
|
||
counts: () => ctx.counts(),
|
||
dispose() {
|
||
if (disposed) return;
|
||
disposed = true;
|
||
if (group.parent) group.parent.remove(group);
|
||
// drop lights/children refs, then free tracked GPU resources
|
||
ctx.disposeAll();
|
||
},
|
||
};
|
||
return api;
|
||
}
|