// 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? } // • stockBase (v5.0): which real-stock pack `stock:'real'` resolves. Omit → the town-wide packs // (`assets/models/`, today's behaviour). Pass `assets/stock_godverse//` 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 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-` — 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); // 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//`) 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`); } const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm }); // 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, 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; }