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>
214 lines
10 KiB
JavaScript
214 lines
10 KiB
JavaScript
// 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.2–4.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);
|
||
}
|