// 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 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 }) { 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 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 const _fwd = new THREE.Vector3(); const _right = new THREE.Vector3(); const _up = new THREE.Vector3(0, 1, 0); // 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 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; banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`; 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; 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.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; keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room current.dispose(); current = 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; }, }; }