// 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 }, // R11 — keys into Lane E manifest.audio (interior music bed + // // room-tone), seeded per shop. musicKey may be null (no music). // 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? } // // 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) function audioFor(type, ctx) { 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'; return { musicKey, toneKey }; } 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 Lane E's GODVERSE pack // through the stockAdapter seam (preload it first); missing pack → parody canvas (warn once). let adapter = opts.stockAdapter || null; if (!adapter && opts.stock === 'real') { const pack = getStockPack(recipe.key); if (pack) adapter = makeStockAdapter(pack); else warnOnce(`stock=real: no pack for "${recipe.key}" (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) audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop 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; }