PROCITY/web/js/interiors/shell.js
m3ultra 0e9d3fb0f2 Lane C: on-demand seeded shop interiors (library + test page)
Every shop door opens into a unique, believable, seeded interior — generated
in ~4ms, byte-identical every revisit (shop.seed), themed by shop.type.

- interiors.js: buildInterior(shop, THREE, opts) public API — pure fn of shop,
  returns {group, spawn, exits, places, dims, placement, dispose()}.
- theme.js: 9 CITY_SPEC type recipes (archetype/wallpaper/floor bias, clutter,
  counter pos, fittings mix, stock kind, signage) + type aliasing + one-time
  mergeRegistry() override seam for Lane F. Standalone (no hard registry dep).
- shell.js: room shell from lot x archetype (cosy/gallery/wide/hall/pokey),
  glazed shopfront + street backdrop, blocked back doorway, interior lighting.
- fittings.js: parametric kit ported from 90sDJsim + extended (bins, crates,
  4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner
  racks, armchair, escalator, pegboard, barred window, returns slot, art).
- layout.js: per-archetype zones, thriftgod shuffled wall-slot system,
  occupancy grid, guaranteed door->counter flood-fill path (pull/carve).
- stock.js: v1 visual stock (pooled canvas sleeves/spines/boxes/garments/snacks
  with price stickers) + stockAdapter hook for BaseGod content later.
- context.js: seed sub-streams + shared-geometry cache + leak-free disposeAll().
- glb.js: optional GLB hero-prop upgrade via Lane E manifest (off by default,
  primitive fallback).
- interior_test.html: standalone page — seed/type/archetype, first-person walk,
  wireframe/occupancy/path debug, 50-room soak (perf + leak + determinism).

Acceptance (verified): same seed -> identical placement (0/810 mismatches);
9 types x 5 archetypes render sensibly (docs/shots/laneC grid); build <50ms
(steady ~4ms, soak worst 8ms); leak-free dispose (geo/tex delta 0); door->counter
path always exists (0 fails, 0 carves); runs with zero assets and zero network.
Adversarial multi-agent review: 5 findings, all fixed and re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:08:12 +10:00

214 lines
10 KiB
JavaScript
Raw Permalink 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 C — the room shell: floor / walls / ceiling / glazed shopfront / lights.
//
// Sized from the shop's lot (lot.w × lot.d) adapted to one of the 5 thriftgod archetypes
// (cosy / gallery / wide / hall / pokey). Ceiling height 3.24.5m by storeys. Materials are seeded
// picks of wall-*.jpg wallpapers + tex-carpet/lino/boards floors (all already in web/assets/gen/,
// all with flat-colour fallbacks). The shopfront (south, +Z) has real glazing you can see a hint of
// street through — a bright backdrop plane beyond the glass sells the inside-looking-out view. The
// back wall (north, Z) has a blocked doorway (v1). Door + exit live on the shopfront.
//
// Coordinate convention (shared with layout.js):
// +Z = south = the street side (door + glazing). Z = north = back of shop.
// Player spawns just inside the door facing Z (into the shop). Origin = room centre, floor y=0.
import { pick, frange } from './context.js';
// The 5 archetypes: [wMin,wMax] × [dMin,dMax] footprint ranges + base ceiling height (m).
export const ARCHETYPES = {
cosy: { w: [7, 9], d: [8, 10], h: 3.4 }, // cosy squarish
gallery: { w: [6, 7], d: [11, 14], h: 3.4 }, // narrow deep gallery
wide: { w: [10, 12], d: [7, 9], h: 3.6 }, // wide shallow
hall: { w: [9, 11], d: [10, 13], h: 4.0 }, // big high-ceilinged hall
pokey: { w: [6, 7], d: [7, 8], h: 3.2 }, // pokey little room
};
export const ARCHETYPE_KEYS = Object.keys(ARCHETYPES);
// Floor names live under web/assets/gen/tex-<name>.jpg ; wallpapers under wall-<name>.jpg.
const FLOOR_FALLBACK = { // seeded flat colour beneath each floor texture
'carpet-swirl': '#6b5b45', 'carpet-mustard': '#8a7038', 'carpet-greygreen': '#5d6b56',
'lino-check': '#b9b2a0', 'lino-cork': '#9c7a52', 'boards-polished': '#7a5533',
};
const WALL_FALLBACK = {
'floral-cream': '#d8cdb4', 'floral-gold': '#c9b18a', 'damask-mauve': '#b9a6b4',
'stripe-sage': '#bfc8b2', 'geo-orange': '#caa06a', 'diamond-green': '#a9c0a4',
'trellis-blue': '#aebfcf', 'woodchip-white': '#d7d2c6',
};
// Choose an archetype from a recipe's weighted bias (or honour an explicit override).
export function chooseArchetype(recipe, rArch, override) {
if (override && ARCHETYPES[override]) return override;
// fall back to a uniform bias if the recipe's is missing OR empty (e.g. an authored registry patch)
const bias = (recipe.archetypeBias && recipe.archetypeBias.length)
? recipe.archetypeBias : ARCHETYPE_KEYS.map(k => [k, 1]);
const total = bias.reduce((s, [, w]) => s + w, 0);
let t = rArch() * total;
for (const [k, w] of bias) { t -= w; if (t <= 0) return k; }
return bias[0][0];
}
// Compute interior dims {W,D,H} from archetype + lot + storeys, all seeded/deterministic.
export function computeDims(ctx, recipe, archetype, lot, storeys) {
const a = ARCHETYPES[archetype] || ARCHETYPES.cosy;
const rw = ctx.stream('dim-w'), rd = ctx.stream('dim-d');
let W = frange(rw, a.w[0], a.w[1]);
let D = frange(rd, a.d[0], a.d[1]);
if (lot && lot.w > 0 && lot.d > 0) { // adapt to the real lot: never exceed it
const availW = Math.max(4, lot.w - 0.6); // 0.3m wall thickness each side
const availD = Math.max(4, lot.d - 0.6);
W = Math.min(W, availW);
D = Math.min(D, availD);
}
W = Math.round(W * 20) / 20; D = Math.round(D * 20) / 20; // 5cm grid → stable deep-equal
const st = Math.max(1, storeys || 1);
let H = a.h + (st - 1) * 0.4;
H = Math.max(3.2, Math.min(4.5, H));
return { W, D, H };
}
// Build the shell into a fresh Group. Returns geometry + spawn/exit metadata for the placer.
export function buildShell(ctx, { recipe, archetype, dims }) {
const { W, D, H } = dims;
const THREE = ctx.THREE;
const group = new THREE.Group();
group.userData = { kind: 'interior', archetype, type: recipe.key };
const rMat = ctx.stream('shell-mat');
const floorName = pick(rMat, recipe.floorBias);
const wallName = pick(rMat, recipe.wallpaperBias);
// ── floor ──
const floorMat = ctx.mat(FLOOR_FALLBACK[floorName] || '#8a7a60', 0.95);
ctx.skin(floorMat, `assets/gen/tex-${floorName}.jpg`, { repeat: [Math.ceil(W / 3), Math.ceil(D / 3)] });
const floor = ctx.plane(W, D, floorMat, group);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
// ── ceiling ──
const ceil = ctx.plane(W, D, ctx.mat('#d9d4c8', 1.0), group);
ceil.rotation.x = Math.PI / 2; ceil.position.y = H;
// ── side + back walls (west X, east +X, north Z) ──
const wallColor = WALL_FALLBACK[wallName] || '#cfc4ae';
const wallDefs = [
{ x: -W / 2, z: 0, ry: Math.PI / 2, w: D, name: 'west' },
{ x: W / 2, z: 0, ry: -Math.PI / 2, w: D, name: 'east' },
{ x: 0, z: -D / 2, ry: 0, w: W, name: 'north' },
];
const walls = {};
for (const def of wallDefs) {
const m = ctx.mat(wallColor, 0.9);
ctx.skin(m, `assets/gen/wall-${wallName}.jpg`, { repeat: [def.w / 2.5, H / 2.5] });
const me = ctx.plane(def.w, H, m, group);
me.position.set(def.x, H / 2, def.z);
me.rotation.y = def.ry;
walls[def.name] = me;
}
// Back doorway (blocked v1): a dark recessed frame on the north wall.
const doorwayW = 1.1, doorwayH = 2.2;
const backX = (ctx.rand('backdoor') - 0.5) * (W - 3);
ctx.box(doorwayW + 0.2, doorwayH + 0.2, 0.08, ctx.mat('#3a3128', 0.8), backX, doorwayH / 2, -D / 2 + 0.05, group); // frame
ctx.box(doorwayW, doorwayH, 0.04, ctx.mat('#15110c', 0.9), backX, doorwayH / 2, -D / 2 + 0.10, group); // dark void
// ── shopfront (south, +Z): glazing + door opening + street backdrop ──
const front = buildShopfront(ctx, group, { W, H, D, wallColor, wallName });
// ── lighting (travels with the group) ──
group.add(new THREE.AmbientLight(0xfff4de, 0.75));
const lightXs = W > 9 ? [-W / 3, 0, W / 3] : [-W / 4, W / 4];
const lightZs = D > 11 ? [-D / 3, 0, D / 3] : [-D / 4, D / 4];
for (const lx of lightXs) for (const lz of lightZs) {
const l = new THREE.PointLight(0xfff0d0, 14, 12, 1.6);
l.position.set(lx, H - 0.3, lz);
group.add(l);
ctx.box(0.7, 0.06, 0.18, ctx.mat('#f5f2ea', 0.4, { emissive: new THREE.Color('#4a4636') }), lx, H - 0.06, lz, group); // fixture
}
return {
group, dims, archetype, floorName, wallName, walls,
spawn: { x: front.doorCx, z: D / 2 - 1.5, ry: 0 }, // just inside, facing Z into the shop
exits: [{ x: front.doorCx, z: D / 2, w: front.doorW, toStreet: true }],
door: { x: front.doorCx, w: front.doorW, z: D / 2 },
places: front.places,
};
}
// The glazed shopfront wall with a door gap and a bright street backdrop behind the glass.
function buildShopfront(ctx, group, { W, H, D, wallColor }) {
const THREE = ctx.THREE;
const z = D / 2;
const th = 0.12;
const doorW = 1.3, doorH = 2.2;
const doorCx = W * 0.26; // door on the right quarter
const doorL = doorCx - doorW / 2, doorR = doorCx + doorW / 2;
const sill = 0.9, head = 2.35; // window band
const winL = -W / 2 + 0.12, winR = doorL - 0.18; // glazing fills left of the door
const wallMat = () => ctx.mat(wallColor, 0.9);
const solid = (w, h, x, y) => { if (w > 0.01 && h > 0.01) ctx.box(w, h, th, wallMat(), x, y, z, group); };
// left return pillar
solid(0.12, H, -W / 2 + 0.06, H / 2);
// sill (below window)
solid(winR - winL, sill, (winL + winR) / 2, sill / 2);
// head/spandrel (above window)
solid(winR - winL, H - head, (winL + winR) / 2, (head + H) / 2);
// pillar between window and door
solid(doorL - winR, H, (winR + doorL) / 2, H / 2);
// lintel above door
solid(doorW, H - doorH, doorCx, (doorH + H) / 2);
// right of door → east return
solid(W / 2 - doorR, H, (doorR + W / 2) / 2, H / 2);
// the glass pane
const glass = ctx.mat('#bfe0ea', 0.05, { transparent: true, opacity: 0.16 });
const pane = ctx.box(winR - winL, head - sill, 0.02, glass, (winL + winR) / 2, (sill + head) / 2, z - 0.02, group);
// a mullion or two
const mull = ctx.mat('#4a4038', 0.7);
ctx.box(0.05, head - sill, 0.05, mull, (winL + winR) / 2, (sill + head) / 2, z - 0.02, group);
// door frame trim + a glass door leaf (the exit)
ctx.box(doorW + 0.1, 0.08, 0.14, mull, doorCx, doorH, z, group);
ctx.box(0.08, doorH, 0.14, mull, doorL, doorH / 2, z, group);
ctx.box(0.08, doorH, 0.14, mull, doorR, doorH / 2, z, group);
const leaf = ctx.box(doorW - 0.16, doorH - 0.1, 0.03, ctx.mat('#bfe0ea', 0.05, { transparent: true, opacity: 0.2 }), doorCx, doorH / 2, z - 0.03, group);
leaf.userData = { kind: 'exit', interactable: true, toStreet: true };
// little EXIT plate above the door, facing inward
const plate = ctx.plane(0.6, 0.16, ctx.mat('#7a6a55', 0.5, { emissive: new THREE.Color('#2a2416') }), group);
plate.position.set(doorCx, doorH + 0.16, z - 0.06); plate.rotation.y = Math.PI;
// ── street backdrop beyond the glazing: an unlit gradient plane (sky → horizon → footpath) ──
const backdrop = makeStreetBackdrop(ctx, W);
backdrop.position.set(0, H / 2, z + 1.6);
backdrop.rotation.y = Math.PI; // face back into the shop
group.add(backdrop);
return { doorCx, doorW, places: [leaf] };
}
// Cheap inside-looking-out backdrop: a canvas gradient with a hint of buildings + footpath.
// Unlit (MeshBasicMaterial) so it reads as bright daylight regardless of interior lights.
function makeStreetBackdrop(ctx, W) {
const THREE = ctx.THREE;
const cv = document.createElement('canvas'); cv.width = 256; cv.height = 128;
const x = cv.getContext('2d');
const sky = x.createLinearGradient(0, 0, 0, 128);
sky.addColorStop(0, '#8fb6d8'); sky.addColorStop(0.55, '#cfe0ec'); sky.addColorStop(0.62, '#d8d2c4');
sky.addColorStop(1, '#9c968a');
x.fillStyle = sky; x.fillRect(0, 0, 256, 128);
// a row of muted facades across the horizon
const cols = ['#8a7a68', '#9a8a6a', '#7a8a80', '#a08a7a', '#7a7488'];
for (let i = 0; i < 8; i++) {
x.fillStyle = cols[i % cols.length];
const bw = 24 + (i * 37 % 20), bx = i * 33 - 8, bh = 30 + (i * 53 % 34);
x.fillRect(bx, 62 - bh, bw, bh);
}
x.fillStyle = '#6f6a60'; x.fillRect(0, 78, 256, 50); // footpath
x.fillStyle = '#5a5650'; x.fillRect(0, 74, 256, 4); // kerb line
const tex = ctx.canvasTexture(cv);
const mat = new THREE.MeshBasicMaterial({ map: tex });
ctx._materials.add(mat);
const w = Math.max(W, 6) * 1.4;
const geo = ctx.geom(new THREE.PlaneGeometry(w, w * 0.5));
return new THREE.Mesh(geo, mat);
}