C exposes room.stage.backline[] — 2 seeded up-stage amp slots {x,z,ry,y} (pure data) — and
drops its primitive ampStacks from placeStage. Lane D plants E's guitar_amp GLB at backline[0]
(D's primitive fallback under ?noassets): one unified backline, GLB when present.
- backline[0] = (stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22) == D's R15-verified amp
pose exactly, so plumbing D's plant to backline[0] moves nothing on screen. backline[1] = spare
stage-left mirror. ry=pi (audience-facing), y=deckY.
- Verified fresh (seed 20261990): slots up-stage, on-deck, clear of all 4 bandPoses (nearest-bass
0.87 pub / 0.70 band_room / 0.92 rsl) and all watchPoints (>=1.95 m); determinism byte-identical;
0 leftover ampStack groups; drawSweep venues 122/120/131 (was 126/124/135, -~4 draws each) <=350.
- Determinism/golden-safe: dropping the amp loop only drops draws on the 'stage' sub-stream (nothing
downstream reads it); placement snapshot excludes ampStacks; interiors aren't hashed; ?classic has
no venues. Disjoint from F's flip. LANE_C_PUB §3 amended (v3.1 marker).
C->D handshake: backline shape live. Verify with D once the GLB is wired at backline[0].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
12 KiB
JavaScript
202 lines
12 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? }
|
||
//
|
||
// 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-<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;
|
||
}
|
||
|
||
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: [ {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
|
||
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { 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;
|
||
}
|