PROCITY/web/js/world/interior_mode.js
m3ultra aa4ed4fdca Lane F: integration — interiors + keepers + street peds + QA harness
interior_mode.js bridges Lane B's enterShop seam to Lane C buildInterior and Lane D
keepers (spawn at counter keeperStand, greet, leak-free over 5 cycles). CitizenSim
wired into the street loop (pop 140, ~96 active midday, worst ~191 draws). tools/:
qa.sh gate runner (all 4 landed gates GREEN --strict), scaffold/consistency checks,
shots.py + soak.py browser harnesses. docs/shots/ reference tree, LANE_F_NOTES
runbook, V2_IDEAS parking lot.

(F's inline seam edits to index.html/skins.js landed with the Lane B commit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:29:03 +10:00

143 lines
6.9 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 } 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 }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
// Lane D shopkeeper — one per interior, at the counter. No fleet passed → asset-free placeholder
// actor (the rig fleet is a later §3.4 upgrade); still turns to greet the player. Ticked in update().
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed });
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);
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; },
};
}