PROCITY/web/js/interiors/interiors.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

118 lines
5.5 KiB
JavaScript

// PROCITY Lane C — public API. The Vuntra move: every shop door opens into a unique, believable,
// seeded interior, generated on demand in <50ms, byte-identical every revisit (shop.seed), themed
// by shop.type. This is a standalone library + test page; Lane B wires it to real doors later via
// the procity:enterShop / procity:exitShop events.
//
// import { buildInterior } from './js/interiors/interiors.js';
// import * as THREE from 'three';
// const room = buildInterior(shop, THREE); // shop = a CityPlan shop record + its lot dims
// scene.add(room.group);
// // … player walks around, hits room.exits …
// room.dispose(); // frees all per-room geometry/material/textures
//
// buildInterior(shop, THREE, opts?) → {
// group, // THREE.Group, self-contained (lights included). Add to a scene.
// spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door)
// exits: [ { x, z, w, toStreet } ], // door(s) back to the street
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
// dispose(), // release GPU resources; removes group from its parent
// }
//
// `shop` is tolerant. Recognized fields: { id, type, name, seed, storeys, lot:{w,d} }.
// • seed : uint32 — the ONLY randomness source (falls back to a hash of id/name if absent)
// • type : any CITY_SPEC type or thriftgod alias (see theme.js); defaults to 'opshop'
// • lot : { w, d } metres — room adapts to it; if absent, archetype ranges decide the size
// • storeys: ceiling height driver (1 → 3.2m … up to 4.5m)
// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter? }
//
// Registry override (Lane F): call mergeRegistry(registry) ONCE at init to let authored registry data
// override recipe fields. It is deliberately NOT a per-build option — a per-call mutation of the shared
// recipe table would break the same-seed determinism guarantee for every later build.
import { Ctx } from './context.js';
import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js';
import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js';
import { layout } from './layout.js';
import { preloadManifest, upgradeRoom } from './glb.js';
import { xmur3 } from '../core/prng.js';
export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
let glbManifest = null; // resolved manifest cache (once the depot answers)
function normalizeShop(shop) {
const s = shop || {};
let seed = s.seed;
if (seed == null) seed = xmur3(`${s.id ?? ''}:${s.name ?? ''}:${s.type ?? ''}`)();
const lot = s.lot && s.lot.w && s.lot.d ? { w: +s.lot.w, d: +s.lot.d }
: (s.lotW && s.lotD ? { w: +s.lotW, d: +s.lotD } : null);
return {
id: s.id ?? 'shop',
type: s.type ?? 'opshop',
name: s.name ?? '',
seed: seed >>> 0,
storeys: s.storeys ?? 1,
lot,
raw: s,
};
}
export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
const norm = normalizeShop(shop);
const recipe = getRecipe(norm.type);
const ctx = new Ctx(THREE, norm.seed);
// room shape → dims → shell
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys);
const shell = buildShell(ctx, { recipe, archetype, dims });
// fittings + wall decor + stock + walkable-path guarantee
const lay = layout(ctx, { recipe, dims, shell, adapter: opts.stockAdapter || null, shop: norm });
// OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
// unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
// or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
if (opts.useGLB) {
const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
if (manifest) upgradeRoom(ctx, lay.placed, manifest);
else preloadManifest(opts.manifestUrl).then(m => {
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; upgradeRoom(ctx, lay.placed, m); }
});
}
const group = shell.group;
const buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
let disposed = false;
const api = {
group,
spawn: shell.spawn,
exits: shell.exits,
places: lay.places,
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,
pathOK: lay.pathOK,
carved: lay.carved,
buildMs,
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
counts: () => ctx.counts(),
dispose() {
if (disposed) return;
disposed = true;
if (group.parent) group.parent.remove(group);
// drop lights/children refs, then free tracked GPU resources
ctx.disposeAll();
},
};
return api;
}