Added an interior draw-budget sweep to flags_check.py (decision #2): enter all 9 shop types,
assert <=350 draws/room, dig on so it also confirms C1's batching kept ?dig=1 riffling.
The sweep caught a real bug in interior_mode.js (F-owned): the interior/dig render never called
renderer.info.reset() while the shell runs autoReset=false (for the composer), so
renderer.info.render.calls ACCUMULATED every frame — inflating any interior draw read (and the
HUD were it shown). Fixed: reset per interior frame before both the dig and the walk render.
True per-frame result after the fix: all 9 room types <=350 draws (worst dept 96) — C1 batching
(35981ad) verified. flags_check GREEN (regression + interior budget + 4 flag smokes + all-on combo).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
11 KiB
JavaScript
212 lines
11 KiB
JavaScript
// 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 } 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)
|
||
|
||
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 }) {
|
||
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 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,
|
||
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||
});
|
||
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
||
}
|
||
|
||
if (digEnabled) {
|
||
addEventListener('keydown', (e) => {
|
||
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
|
||
const bin = binUnderAim();
|
||
if (bin) openDig(bin);
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
|
||
// 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
|
||
current = buildInterior(shop, THREE, { useGLB }); // [Lane F §3.4] depot GLB fittings when assets are on
|
||
currentShop = shop;
|
||
scene.add(current.group);
|
||
// 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 });
|
||
}
|
||
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;
|
||
// [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;
|
||
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;
|
||
banner.style.display = 'none';
|
||
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
|
||
};
|
||
}
|