PROCITY/web/js/world/interior_mode.js
m3ultra 8c275c711b Lane F R11: wire interior audio beds + audio smokes + gate scope
Interior audio (interior_mode): enter -> PROCITY.audio.playInterior(room.audio) with Lane C's
{musicKey,toneKey} (fade in), exit -> stopInterior() (0.7s fade). Guarded/no-op when
muted/noassets/pre-gesture -- silent-and-happy.

New smoke_audio gate (flags harness, fresh Chromium per the R10 standing law): pre-gesture silence
(ctx not unlocked), 0 boot errors, ?mute=1 muted, ?noassets=1 zero audio fetches, enter/exit
AudioNode leak-free (12 cycles, 8 shops with a music bed, live sources bounded at 5). Gesture via
keyboard -- a mouse-click gesture tripped PointerLockControls in headless.

scaffold_check: scoped the Math.random generation-ban to exempt runtime FX PLAYBACK
(web/js/world/audio.js) -- Lane B's footstep SFX variant/gain jitter is transient, not world
generation (which shop plays which bed is seeded upstream in interiors.js). Noted to B.

Soak with audio on PASS (heap 48MB stable, 0 errors, no drift). qa.sh --strict GREEN 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:13:19 +10:00

284 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane F — interior_mode.js [integration glue, F-owned]
// Bridges Lane B's street shell to Lane C's buildInterior library — this is the wiring behind the
// headline "every door opens". Ownership stays clean: Lane B owns the shell + mode machine, Lane C
// owns the room, Lane F owns THIS bridge (a separate module so the shell edits are a thin, marked
// seam rather than logic sprinkled through B's files).
//
// Contract (verified against the real landed code, 2026-07-14):
// • buildInterior(shop, THREE) → { group(self-lit), spawn:{x,z,ry}, exits:[{x,z,w,toStreet}],
// _debug.grid:{W,D,cols,rows,cw,cd,occ,cellOf}, dispose() }
// • Room origin = centre, floor y=0. spawn is ~1.5m inside the front door facing Z into the shop;
// the (only) street exit sits at the front wall, same x as spawn.
// • occ cells: 0 free · 1 fitting · 2 wall(border). We collide off occ==1 + room bounds, exactly
// like Lane C's interior_test.html walk loop, so behaviour matches the tester the room was tuned in.
//
// The interior renders into its OWN dark THREE.Scene (the street scene is frozen, not disposed —
// CITY_SPEC L3 "pause, not dispose"), so returning to the street is instant.
import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/interiors.js';
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
const RADIUS = 0.30; // player collision radius inside the room
const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
// Lane D shopkeeper — one per interior, at the counter. With a rig `fleet` (§3.4) the keeper is a
// shared GLB actor; without one it's an asset-free placeholder. Either way it greets the player.
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed, fleet });
let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback)
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
const _fwd = new THREE.Vector3();
const _right = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
const _digRay = new THREE.Raycaster();
// [Lane F §F2] crate-riffle wiring (v2, ?dig=1). Lane C ships dig.js + gates the flag; F owns the
// in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's
// interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor
// (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs.
function binUnderAim() {
if (!current) return null;
const bins = [];
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
if (!bins.length) return null;
_digRay.setFromCamera({ x: 0, y: 0 }, camera); // crosshair-centre ray
const hit = _digRay.intersectObjects(bins, true)[0];
if (hit && hit.distance < 3.2) {
let o = hit.object;
while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
return o;
}
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's
// world position, so it works even before the bin's GLB mesh has finished loading (async) and is
// forgiving of imperfect aim — stand next to a bin, press E.
const cp = camera.position, wp = new THREE.Vector3();
let best = null, bd = 2.5;
for (const b of bins) { b.getWorldPosition(wp); const d = wp.distanceTo(cp); if (d < bd) { bd = d; best = b; } }
return best;
}
function openDig(bin) {
if (!dig) dig = createDig(THREE, renderer);
if (dig.active) return;
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic pos)
dig.open({
seed: binSeed((currentShop && currentShop.seed) || 1, key), count: 16,
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (item) => wallet.buy(item) : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
});
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
}
// [F R9 — Lane C buy-anywhere] E aimed at a book spine / toy box shelf (userData.buyMesh) buys the
// nearest item over the same wallet: collapse the quad in place + bank it. Parody stock has no buyMesh
// → no-op ("not for sale"). Records keep the dig. Reuses collapseBuyItem (Lane C stockpack export).
const _buyRay = new THREE.Raycaster(), _lp = new THREE.Vector3();
function buyShelfUnderAim() {
if (!wallet || !current) return;
const meshes = [];
current.group.traverse((o) => { if (o.userData && o.userData.buyMesh && o.userData.buyItems && o.userData.buyItems.length) meshes.push(o); });
if (!meshes.length) return;
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
const hit = _buyRay.intersectObjects(meshes, false)[0];
if (!hit || hit.distance > 3.0) return;
const mesh = hit.object;
_lp.copy(hit.point); mesh.worldToLocal(_lp);
let best = null, bd = Infinity;
for (const it of mesh.userData.buyItems) {
if (it.sold) continue;
const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2;
if (d < bd) { bd = d; best = it; }
}
if (!best) return;
const item = best.item || {};
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
} else {
flashToast('not enough cash');
}
}
if (digEnabled || wallet) {
addEventListener('keydown', (e) => {
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle
buyShelfUnderAim(); // shelf → buy
});
}
// a small unobtrusive banner (F-owned; the street HUD is hidden in interior mode)
const banner = document.createElement('div');
banner.style.cssText =
'position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:20;display:none;' +
'background:rgba(20,16,10,.82);color:#f4efe6;padding:8px 16px;border-radius:8px;' +
'font:14px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
document.body.appendChild(banner);
// [F R9] transient buy-feedback toast (shelf-buy). Separate from the persistent banner.
const toast = document.createElement('div');
toast.style.cssText =
'position:fixed;left:50%;bottom:60px;transform:translateX(-50%);z-index:21;display:none;' +
'background:rgba(14,28,18,.92);color:#8fffb0;padding:8px 16px;border-radius:8px;' +
'font:600 14px -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
document.body.appendChild(toast);
let _toastT = 0;
function flashToast(msg) { toast.textContent = msg; toast.style.display = 'block'; _toastT = 1.6; }
// interior-grid collision — mirrors interior_test.html walkable(): room bounds + occ==1 fittings.
function walkable(x, z) {
const g = current._debug.grid;
if (Math.abs(x) > g.W / 2 - RADIUS || Math.abs(z) > g.D / 2 - RADIUS) return false;
for (const [ox, oz] of [[RADIUS, 0], [-RADIUS, 0], [0, RADIUS], [0, -RADIUS]]) {
const [cx, cz] = g.cellOf(x + ox, z + oz);
if (g.occ[cz * g.cols + cx] === 1) return false; // walls are handled by the bounds clamp
}
return true;
}
function nearestExitDist(px, pz) {
let best = Infinity;
for (const e of current.exits) {
if (e.toStreet === false) continue;
const d = Math.hypot(px - e.x, pz - e.z);
if (d < best) best = d;
}
return best;
}
// enter: build the room, show it, drop the player just inside the door. Returns the Lane C handle.
function enter(shop, name) {
if (current) exit(); // never stack interiors
// [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null →
// Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter }); // static room sleeves
currentShop = shop;
scene.add(current.group);
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
window.PROCITY.audio && window.PROCITY.audio.playInterior(current.audio);
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) {
const st = counter.userData.keeperStand;
keepers.spawn(current.group, { x: st.x, z: st.z, ry: st.ry, shopId: shop.id, type: shop.type });
}
// [F R9 — interior presence, C→D→F seam] browser rigs at C's browse points, one per patronage
// occupant, capped at 3 (≤3 points). D owns occupancy truth + the rig (KeeperManager browse pose,
// ~1 draw, no player-greet); F reads occupancy and places them. Freed with the room by exit()'s
// keepers.disposeAll(). occupants[i].pedIndex → the exact ped who ducked in off the street.
if (occupancyOf && current.browsePoints && current.browsePoints.length) {
const occ = occupancyOf(shop.id) || { count: 0, occupants: [] };
const pts = current.browsePoints.slice(0, Math.min(occ.count | 0, 3));
pts.forEach((pt, i) => keepers.spawn(current.group, {
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
pedIndex: occ.occupants && occ.occupants[i] ? occ.occupants[i].pedIndex : null,
seedKey: `${shop.id}#${i}`,
}));
}
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
camera.position.set(current.spawn.x, EYE, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
exitArmed = false;
let hasBins = false;
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
+ (hasBins ? ' · E: riffle a record bin' : '');
banner.style.display = 'block';
return current;
}
// update: walk + render the interior. Returns true once the player reaches a street exit
// (the shell then calls exit() + setMode('street') — update never disposes on its own).
function update(dt, keys) {
if (!current) return false;
if (_toastT > 0) { _toastT -= dt; if (_toastT <= 0) toast.style.display = 'none'; } // [R9] buy toast decay
// [F2] riffling: the dig owns the whole frame (its own scene/camera + cursor input). No interior
// walk, no exit check — the player is "in the crate" until they close it (Esc / WALK OUT button).
if (dig && dig.active) {
if (dig.update) dig.update(dt);
renderer.autoClear = true;
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame
renderer.render(dig.scene, dig.camera);
return false;
}
const f = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) - (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0);
const s = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);
if (f || s) {
camera.getWorldDirection(_fwd); _fwd.y = 0; _fwd.normalize();
_right.crossVectors(_fwd, _up).normalize();
const dx = _fwd.x * f + _right.x * s, dz = _fwd.z * f + _right.z * s;
const len = Math.hypot(dx, dz) || 1;
const step = SPEED * dt;
const nx = (dx / len) * step, nz = (dz / len) * step;
const p = camera.position;
if (walkable(p.x + nx, p.z)) p.x += nx; // per-axis → slide along walls/fittings
if (walkable(p.x, p.z + nz)) p.z += nz;
p.y = EYE;
}
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so
// renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)
renderer.render(scene, camera);
const de = nearestExitDist(camera.position.x, camera.position.z);
if (!exitArmed && de > ARM_DIST) exitArmed = true;
return exitArmed && de < EXIT_DIST;
}
// exit: dispose the room (frees GPU + removes group), restore the player to the street door.
function exit() {
if (!current) return;
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
current.dispose();
current = null;
currentShop = null;
currentAdapter = null;
banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0;
if (doorReturn) {
camera.position.set(doorReturn.x, doorReturn.y, doorReturn.z);
camera.rotation.set(0, doorReturn.ry, 0, 'YXZ');
}
}
return {
enter,
update,
exit,
scene,
keepers,
get active() { return !!current; },
get current() { return current; },
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
};
}