R28 ledger #3. My own idea, parked since R7. Brief stated acceptance only, so I scoped it. THE TRAP MY OWN v3.0 NOTE SET: it said "userData.audioEmitter = musicKey so Lane B can position/spatialise the bed". Both halves wrong. (1) Shape: the engine holds no reference to the room group and would traverse a scene it cannot reach — emitters belong on room.audio as data, riding the EXISTING playInterior() call (no lifecycle, no traverse, no dispose hook). (2) Method: "spatialise" invites a PannerNode. NOTHING in this tree sets ctx.listener (zero hits), so a panner resolves against a listener frozen at (0,0,0) — which, because rooms are built room-local at the origin, is the room's own CENTRE, forever. Measured, OfflineAudioContext, real pub stage bed: panner, listener never set (the engine as it SHIPS): RMS 0.106905 door / 0.106905 stage -> ratio 1.0000, bit-identical panner + listener tracking the player: RMS 0.055665 / 0.500000 -> 8.98x It plays, throws nothing, warns nothing, and never changes by one sample as you cross the room — while failing at the only thing it exists to do. "Routed through a panner?" yes. "Audible in a venue?" yes. "In a shop?" yes. All green, all worthless. -> ASSERT THE DELTA (gain at door != gain at stage), NEVER THE PRESENCE. Vacuous-gate law, 4th application — and this one had no author to blame but me. The house idiom was already right: tram/spill/gig-spill are all hand-rolled distance-gains vs PROCITY.camera.position. No listener, no panner. SHIPPED (C's files only): - room.audio.emitters {stage?, counter?} — room-local, 3dp, 18/18 same-seed deep-equal. `r` (reach) is the ROOM DIAGONAL, not a constant: measured diagonals 9.81-15.63m, and scaling to the room holds door->stage proximity in a 0.42-0.59 band from a pokey band_room to a hall pub. `floor` is the room-filling residue, never 0 — a PA fills a pub; what changes as you walk in is the BALANCE, not the presence. Swing 2.65-4.52 dB (John R20: subtle changes are great). - THE TILL RINGS FROM THE COUNTER — audible positioning in a normal shop with NO ENGINE CHANGE, via B's already-public playSfx(key,{gain}): C does its own distance math, hands B a scalar. Measured through the real BUY button, real record shop: 0.9489 @0.60m vs 0.6436 @4.19m = 3.37 dB; negative control (no emitters) fires at exactly 1.0. sfx-till.ogg has been on disk since 15 Jul and had NEVER ONCE PLAYED. Routing, not content — as the brief said. - ?mute=1 now actually silences the crate riffle (below). WHY THE VENUE HALF IS AN ASK: a one-shot resolves its gain once, at fire time, from a position C already knows. A bed must be re-gained every frame inside the engine's update() loop — B's file. Structural, not political. B has a ~12-line paste-ready diff in the house idiom; audio.js untouched. The letter of "consulted not modified" could have been met by reaching through B's exposed state.layers diag to rewire its graph from outside — that backdoor is strictly worse than the edit it avoids. Refused. TWO BUGS FOUND: - F DROPS EMITTERS ON THE ONE NIGHT THEY MATTER. interior_mode.js:289 rebuilds the spec on a gig night ({musicKey: ra.gigKey, toneKey: WALLA_KEY}) and every unnamed field dies. Measured on a real gig-night pub: quietNight_emittersSurvive TRUE, gigNight_emittersSurvive FALSE. Silent, fail-soft, no error. -> F: don't just spread `ra`; pass emitters DELIBERATELY, gig-night only — on a quiet night the radio + room-tone genuinely ARE room-filling. - ?mute=1 NEVER SILENCED THE RIFFLE (R5->R28). dig.js opened a SECOND, private AudioContext straight to destination, breaking both stated house laws (ONE AudioContext; mute forces silence). It survived three epochs because NO GATE ASSERTS MUTE => SILENCE: the engine returns its silent surface and looks honest from its own side, while the sound came from somewhere it never knew existed. A gate that asks the engine whether it is muted can only ever answer yes. Fixed via B's PUBLIC .muted getter (consulted, not modified), URL fallback when standalone. Measured with a positive control so the probe provably CAN see sound: live 1 ctx / 1 source; muted 0 / 0. Blips stay procedural on purpose — zero-fetch, deterministic, still work under ?noassets=1 where a sampled riffle cannot. Only mute was broken; only mute was fixed. -> F: assert SILENCE, not muted-state. HARNESS HONESTY: the first till rig reported the same clean 3.37 dB — from a CONTAMINATED rig (createDig appends a panel per call, close() doesn't remove it, so querySelector clicked trial 1's stale button every time). Tell: panelUp false on all three, and the NEGATIVE CONTROL returning 0.6436 where it must return 1.0. The gains matched the formula exactly; I refused them anyway and re-ran isolated (panelCount 1, panelUp true, control 1.0). Never report a number from a rig you have watched lie — even when you like the number. QA GREEN 6/0/0/0. Docs: LANE_C_AUDIO.md -> v3.1 (contract + both asks + every measurement).
248 lines
16 KiB
JavaScript
248 lines
16 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? }
|
||
// • 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
|
||
|
||
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);
|
||
|
||
// 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`);
|
||
}
|
||
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;
|
||
}
|