v3.0-alpha band room. New pub recipe (venue:true): stage + PA + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (keeper), sticky carpet, blocked back door = green room.
buildInterior returns for venues: room.watchPoints[] (6-8 seeded audience poses {x,z,ry,dance} facing the stage, dance ~1/3, counter.stand ry convention); room.stage {x,z,w,d,deckY,frontZ,bandPoses[3]} for Lane D's band; room.audio.gigKey ('gig-'+genreKey) set when Lane F passes opts.gig.
Flags-off byte-identical: venue code runs only for recipe.venue on its own seed sub-streams; non-pub rooms have watchPoints:[], stage:null, unchanged placement/audio.
Verified: 40-seed pub sweep (0 throws/carves/path-fails, 8 watchPoints, dance 0.38) + 60-room mixed soak (0 det/path fails, leak geo0/tex0, worst 19ms). Non-pub unchanged. Interface for D/F: docs/LANES/LANE_C_PUB.md. scaffold + manifest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
11 KiB
JavaScript
184 lines
11 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} ], // VENUE (R12, pub only, else []) — 6..8 audience poses facing the
|
|
// // stage; dance ~1/3 true. Same frame + ry convention as counter.stand.
|
|
// stage: { x,z,w,d,deckY,frontZ,bandPoses }, // VENUE (R12, else null) — the band stage; bandPoses[3] on the deck.
|
|
// 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, opts) {
|
|
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): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
|
|
// while the gig is on. A quiet-night pub has no opts.gig → normal interior (room-tone + maybe radio).
|
|
const gig = opts && opts.gig;
|
|
if (gig && (gig.on || gig.genreKey)) audio.gigKey = 'gig-' + (gig.genreKey || 'pubrock');
|
|
return audio;
|
|
}
|
|
|
|
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)
|
|
watchPoints: lay.watchPoints || [], // venue (R12): [ {x,z,ry,dance,slotIndex} ] 6..8 audience poses (else [])
|
|
stage: lay.stage || null, // venue (R12): { x,z,w,d,deckY,frontZ,bandPoses[3] } (else null)
|
|
audio: audioFor(recipe.key, ctx, opts), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
|
|
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;
|
|
}
|