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>
67 lines
3.6 KiB
JavaScript
67 lines
3.6 KiB
JavaScript
// PROCITY Lane C — optional GLB-upgrade layer. Reads Lane E's web/assets/manifest.json and swaps a
|
||
// primitive fitting for a detailed depot GLB where one exists ("reserve the detailed GLB hero props
|
||
// for where the camera gets close" — RESEARCH). Strictly ADDITIVE and OFF by default:
|
||
// • the whole lane runs 100% on primitives with no manifest and no network (LANE_C acceptance);
|
||
// • enable per build with opts.useGLB (interiors.js), or globally via preloadManifest().
|
||
//
|
||
// Pattern (house law): promise-cached loader, PLACEHOLDER-PERSISTS — the primitive shows immediately
|
||
// and stays until the GLB resolves; if the depot is unreachable the primitive stays forever (never a
|
||
// crash, never a blank). The GLB is footprint-fitted to the primitive it replaces, so occupancy/paths
|
||
// already computed by layout.js stay valid.
|
||
|
||
import { loadGLB } from '../core/loaders.js';
|
||
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
|
||
|
||
// my fitting kind → manifest.fittings id (only kinds with a depot GLB; others stay primitive)
|
||
const KIND_TO_GLB = {
|
||
crate: 'record_crate', recordBin: 'record_crate',
|
||
metalShelf: 'wire_shelf', wallShelf: 'wire_shelf',
|
||
clothesRack: 'clothes_rack',
|
||
bookshelf: 'bookshelf',
|
||
cubeShelf: 'cube_shelf_wide',
|
||
counter: 'counter',
|
||
trestleTable: 'work_table',
|
||
};
|
||
|
||
let _manifestP = null;
|
||
// Fetch + cache the manifest. Returns null (not throw) if absent/unreachable → primitives everywhere.
|
||
export function preloadManifest(url = 'assets/manifest.json') {
|
||
return (_manifestP ||= fetch(url).then(r => (r.ok ? r.json() : null)).catch(() => null));
|
||
}
|
||
export function manifestReady() { return _manifestP && typeof _manifestP.then === 'function' ? _manifestP : Promise.resolve(null); }
|
||
|
||
// Upgrade one placed fitting in-place, if the manifest maps its kind. No-op when disabled/unavailable.
|
||
// `manifest` is the resolved object (or null). ctx is the room build context (for the _disposed guard).
|
||
export function upgradeFitting(ctx, fitting, kind, manifest) {
|
||
if (!manifest || !manifest.fittings) return;
|
||
const id = KIND_TO_GLB[kind];
|
||
const entry = id && manifest.fittings[id];
|
||
if (!entry || !entry.file) return;
|
||
|
||
loadGLB(`depot:${entry.file}`).then(gltf => {
|
||
if (!gltf || ctx._disposed || !fitting.group.parent) return; // room rebuilt / disposed mid-flight
|
||
const THREE = ctx.THREE;
|
||
const inst = skeletonClone(gltf.scene); // clone-safe even if skinned
|
||
// fit the GLB to the primitive's footprint (GLB convention: origin at base, facing −Z, metres)
|
||
const bb = new THREE.Box3().setFromObject(inst);
|
||
const size = new THREE.Vector3(); bb.getSize(size);
|
||
const want = fitting.footprint, targetW = Math.max(want.w, want.d);
|
||
const glbW = Math.max(size.x, size.z) || 1;
|
||
const s = targetW / glbW;
|
||
inst.scale.setScalar(s);
|
||
inst.position.y -= bb.min.y * s; // plant on the floor
|
||
// hide the primitive FRAME (the first frameCount children) but keep procedural stock — which was
|
||
// added after the frame — visible on top of the detailed GLB shelf/counter.
|
||
const frameCount = fitting.frameCount ?? fitting.group.children.length;
|
||
for (let i = 0; i < frameCount && i < fitting.group.children.length; i++) fitting.group.children[i].visible = false;
|
||
inst.userData = { glbUpgrade: true };
|
||
fitting.group.add(inst);
|
||
});
|
||
}
|
||
|
||
// Upgrade every placed fitting in a room. Called from buildInterior when GLB mode is on.
|
||
export function upgradeRoom(ctx, placed, manifest) {
|
||
if (!manifest) return;
|
||
for (const p of placed) if (!p.removed) upgradeFitting(ctx, p.fitting, p.kind, manifest);
|
||
}
|