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>
This commit is contained in:
m3ultra 2026-07-14 12:08:12 +10:00
parent 8b7ca9aae4
commit 0e9d3fb0f2
12 changed files with 2178 additions and 0 deletions

147
C-progress.md Normal file
View File

@ -0,0 +1,147 @@
# LANE C — INTERIORS · progress (PROCITY-C)
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-14 · owner: PROCITY-C · reviewer: Fable
---
## What shipped
All files under my lane's ownership (`web/js/interiors/*`, `web/interior_test.html`) — no other lane's
files touched. Lane A's `core/registry.js` hadn't landed, so the lane is **fully standalone** (its 9
type recipes live in `theme.js`) with a `mergeRegistry()` hook for Lane F to wire the registry later.
| file | role |
|---|---|
| [interiors.js](web/js/interiors/interiors.js) | **public API**`buildInterior(shop, THREE, opts)` |
| [theme.js](web/js/interiors/theme.js) | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
| [shell.js](web/js/interiors/shell.js) | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
| [fittings.js](web/js/interiors/fittings.js) | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
| [layout.js](web/js/interiors/layout.js) | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, **guaranteed door→counter flood-fill path** |
| [stock.js](web/js/interiors/stock.js) | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + `stockAdapter` hook for BaseGod data |
| [context.js](web/js/interiors/context.js) | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free `disposeAll()` |
| [glb.js](web/js/interiors/glb.js) | **optional** GLB hero-prop upgrade — reads Lane E's `manifest.json`, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
| [interior_test.html](web/interior_test.html) | standalone test page (below) |
## Public API (the contract Lane B/F build against)
```js
import { buildInterior } from './js/interiors/interiors.js';
import * as THREE from 'three';
const room = buildInterior(shop, THREE, opts?);
scene.add(room.group);
// … player walks; on hitting room.exits[i], fire procity:exitShop …
room.dispose(); // frees ALL per-room GPU resources; removes group from parent
// room = {
// group, // THREE.Group, self-contained (lights included)
// spawn: { x, z, ry }, // enter just inside the door, facing Z (into the shop)
// exits: [ { x, z, w, toStreet } ], // door back to street
// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
// dims: { W, D, H, archetype, type },
// placement, // deterministic summary (deep-equal per seed)
// pathOK, buildMs, counts(), dispose()
// }
```
`shop` is tolerant — recognized fields `{ id, type, name, seed, storeys, lot:{w,d} }`:
- **seed** (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
- **type** — any CITY_SPEC type (`record opshop toy book video pawn milkbar dept stall`) **or** a
thriftgod/Overpass alias (`music charity pawnbroker market video_games …`); defaults to `opshop`.
- **lot** `{w,d}` metres — the room adapts to it (never exceeds it); absent → archetype ranges decide.
- **storeys** drives ceiling height 3.24.5m.
- `opts`: `{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }`.
Registry override is a **one-time global**`import { mergeRegistry } from interiors.js; mergeRegistry(registry)`
at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).
**Coordinate convention** (shared with a future Lane B wiring): `+Z` = door/street side, `Z` = back,
origin = room centre, floor `y = 0`. Everything is in the returned `group`'s local space.
### For Lane B (door wiring, later)
`buildInterior` is a pure function — call it on `procity:enterShop`, add `room.group` to the scene,
teleport the player to `room.spawn`. On `procity:exitShop` (player reaches `room.exits[0]`) call
`room.dispose()` and restore the street. The street window fake is Lane B's; I own the inside view
(a bright canvas street backdrop plane is already behind the glazing).
### For content phase (BaseGod / GODVERSE)
Pass `opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null`. Returning a texture
skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/
fittings changes needed. `slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }`.
## Test page — `web/interior_test.html`
Serve: `cd web && python3 -m http.server 8130` → open `/interior_test.html`.
- seed input · type dropdown (9) · archetype override (auto + 5) · lot `w×d`/auto · re-roll · seed ±
- first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
- debug toggles: **wireframe** (F), **occupancy grid** (G), **path** (spawn/counter markers)
- **▶ 50-room soak test**: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs
baseline, and determinism (same seed → identical placement)
## Acceptance — all met
| requirement | result |
|---|---|
| Same seed twice → identical room (deep-equal placement) | ✅ 0 mismatches / 810 builds |
| All 9 types × 5 archetypes render sensibly | ✅ contact sheet `docs/shots/laneC/_grid_9types_5archetypes.jpg` |
| Build < 50ms/room | steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops not a real per-room cost) |
| Dispose leak-free (info.memory → baseline) | ✅ soak leak: geo 0, tex 0 |
| Flood-fill door→counter always exists | ✅ 0 path-fails, 0 carves / 810 builds |
| Runs with zero assets & zero network | ✅ seeded flat-colour fallback under every texture; map only assigned on load |
**Full sweep** (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
`throws 0 · pathFail 0 · determinismFail 0 · carved 0`. Warm perf: all < 50ms (worst 33.2ms).
**Soak** (50 rooms): `avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0`.
## Key decisions & notes for review
- **90sDJsim `fittings.js` ported** (it was written to be lifted): `clothesRack`, `shelfUnit`,
`recordBin`, `table`, `counter`, `pegboard`, garment-silhouette canvas trick. Extended with the
fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner,
magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
- **thriftgod `buildShop` ported**: the 5 archetypes, the shuffled wall-slot system, bins with fanned
sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
- **Determinism** via independent seeded sub-streams keyed by a salt string (`ctx.stream('wallpaper')`
etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
- **Leak-free** via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
are freed in `dispose()`; globally-cached file textures (`core/loaders.loadTex`) are shared and not
disposed (bounded cache). Dispose-before-texture-load race is guarded (`_disposed`).
- **Perf**: shared-geometry cache (`ctx.boxGeo/planeGeo/cylGeo`) collapses the hundreds of identical
stock items in a room down to ~80110 geometries; spine density tuned so a book barn is ~600 meshes.
- **GLB upgrades**: `web/assets/manifest.json` (Lane E) is read if present, primitive fallback always.
Not present yet → 100% primitives, which is why the test page runs asset-free.
## Integration with sibling lanes (landed in parallel)
- **Lane A `core/registry.js`** — its `SHOP_TYPE_IDS` match my 9 recipe keys exactly. I kept the
interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail),
with `mergeRegistry()` as the one-time override seam for Lane F.
- **Lane E `web/assets/manifest.json`** — its `fittings` section (9 depot GLBs with footprints) is now
wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet;
verified the fallback (unreachable depot → primitives persist, no crash, no leak).
## Adversarial review — 5 findings, all fixed
Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee /
three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and
re-verified (830-build sweep + 50-room soak green after each fix):
| # | sev | file | defect | fix |
|---|---|---|---|---|
| 1 | med | interiors.js | per-build `opts.registry` permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states | removed per-build option; registry override is now a one-time global `mergeRegistry()` (Lane F setup) |
| 2 | low | interiors.js | explicit `null` opts threw (`opts = {}` default only covers `undefined`) | normalize `opts = opts \|\| {}` |
| 3 | low | shell.js | empty `archetypeBias` array → `bias[0][0]` on `[]` throws | fall back to uniform bias when missing **or** empty |
| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in `places` (dangling after dispose) | collect `places` from survivors at the end, not incrementally at placement |
| 5 | low | layout.js | `rebuildOcc` re-stamped wall-mounted fittings that `placeAtWall` never stamped → phantom floor obstacles after a blocker-pull | mark wall-mounted `noStamp`; skip in `rebuildOcc` **and** exclude from the removable pool |
Verify evidence (post-fix): `nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical ·
emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0,
leakTex 0}`. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
## Not done (out of scope for v1 / depends on other lanes)
- Real item data / economy (content phase — `stockAdapter` hook is ready).
- GLB hero props are **wired** (`glb.js`, opt-in) but not visually validated end-to-end — needs the
depot GLBs actually uploaded (Lane E) + `useGLB` flipped on by Lane B/F. Primitive path is the tested baseline.
- Interior-mapping shader for street windows (Lane B stretch goal).
- Back-room beyond the blocked doorway (v2).

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

284
web/interior_test.html Normal file
View File

@ -0,0 +1,284 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane C — Interior Test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; font: 13px/1.4 -apple-system, system-ui, sans-serif; background: #14110c; color: #e8e0d0; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#ui {
position: fixed; top: 10px; left: 10px; z-index: 10; width: 268px;
background: rgba(24,20,14,0.92); border: 1px solid #4a4030; border-radius: 8px; padding: 12px;
backdrop-filter: blur(6px); box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
#ui h1 { margin: 0 0 8px; font-size: 14px; letter-spacing: .5px; color: #ffd75e; }
#ui .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }
#ui label { flex: 0 0 66px; color: #b7ab90; }
#ui select, #ui input { flex: 1; background: #211c14; color: #e8e0d0; border: 1px solid #4a4030; border-radius: 5px; padding: 4px 6px; font: inherit; }
#ui button { flex: 1; background: #3a3120; color: #ffe9a8; border: 1px solid #6a5a38; border-radius: 5px; padding: 6px; font: inherit; cursor: pointer; }
#ui button:hover { background: #4a3f28; }
#ui .btnrow { display: flex; gap: 6px; margin-top: 6px; }
#ui .toggles { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: #b7ab90; }
#ui .toggles label { flex: none; }
#hud { margin-top: 10px; padding-top: 8px; border-top: 1px solid #3a3226; font-size: 12px; color: #c9bfa4; white-space: pre-line; }
#hud b { color: #ffd75e; font-weight: 600; }
#soak { margin-top: 8px; font-size: 11.5px; color: #9fd6a0; white-space: pre-line; min-height: 14px; }
#soak.bad { color: #f3a0a0; }
#hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 10;
background: rgba(24,20,14,0.85); border: 1px solid #4a4030; border-radius: 6px; padding: 6px 12px; color: #b7ab90; }
#hint b { color: #ffd75e; }
</style>
</head>
<body>
<div id="app"></div>
<div id="ui">
<h1>PROCITY · Lane C</h1>
<div class="row"><label>Seed</label><input id="seed" type="number" value="1990" /></div>
<div class="row"><label>Type</label><select id="type"></select></div>
<div class="row"><label>Archetype</label><select id="arch"></select></div>
<div class="row"><label>Lot w×d</label><input id="lot" type="text" value="auto" /></div>
<div class="btnrow">
<button id="reroll">re-roll ⟳</button>
<button id="prev">◀ seed</button>
<button id="next">seed ▶</button>
</div>
<div class="toggles">
<label><input type="checkbox" id="tgWire"> wireframe</label>
<label><input type="checkbox" id="tgGrid"> occupancy</label>
<label><input type="checkbox" id="tgPath"> path</label>
</div>
<div class="btnrow"><button id="soakBtn">▶ 50-room soak test</button></div>
<div id="soak"></div>
<div id="hud"></div>
</div>
<div id="hint">Click to walk · <b>WASD</b> move · <b>mouse</b> look · <b>Esc</b> release · <b>G</b> grid · <b>F</b> wireframe</div>
<script type="importmap">
{
"imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS } from './js/interiors/interiors.js';
// ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 200);
const controls = new PointerLockControls(camera, renderer.domElement);
renderer.domElement.addEventListener('click', () => controls.lock());
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ── UI setup ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const typeSel = $('type'), archSel = $('arch');
for (const t of SHOP_TYPES) { const o = document.createElement('option'); o.value = t; o.textContent = t; typeSel.appendChild(o); }
archSel.appendChild(new Option('auto', 'auto'));
for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
let current = null; // the live interior
let gridHelper = null, pathHelper = null;
let wire = false, showGrid = false, showPath = false;
function parseLot(v) {
const m = String(v).match(/(\d+(?:\.\d+)?)\s*[x×,\s]\s*(\d+(?:\.\d+)?)/);
return m ? { w: +m[1], d: +m[2] } : null;
}
function rebuild() {
if (current) current.dispose();
clearDebug();
const seed = parseInt($('seed').value, 10) >>> 0;
const type = typeSel.value;
const arch = archSel.value === 'auto' ? undefined : archSel.value;
const lot = parseLot($('lot').value);
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
current = buildInterior(shop, THREE, { archetype: arch });
scene.add(current.group);
// place the player at the spawn
camera.position.set(current.spawn.x, 1.6, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0);
applyWire();
if (showGrid) buildGridHelper();
if (showPath) buildPathHelper();
updateHUD();
}
function updateHUD() {
const d = current.dims, c = current.counts();
$('hud').innerHTML =
`<b>${current.recipe.label}</b> · ${d.archetype}\n` +
`room <b>${d.W.toFixed(1)}×${d.D.toFixed(1)}×${d.H.toFixed(1)}</b> m\n` +
`build <b>${current.buildMs.toFixed(1)}</b> ms · path <b>${current.pathOK ? 'ok' : 'FAIL'}</b>${current.carved ? ' (carved)' : ''}\n` +
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
`places ${current.places.length} · counter ${current.recipe.counterPos}`;
}
// ── debug overlays ───────────────────────────────────────────────────────────
function clearDebug() {
for (const h of [gridHelper, pathHelper]) if (h) { scene.remove(h); h.traverse(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); }
gridHelper = pathHelper = null;
}
function buildGridHelper() {
const g = current._debug.grid;
const grp = new THREE.Group();
const geo = new THREE.PlaneGeometry(g.cw * 0.92, g.cd * 0.92);
const matOcc = new THREE.MeshBasicMaterial({ color: '#d6402e', transparent: true, opacity: 0.4, side: THREE.DoubleSide });
const matWall = new THREE.MeshBasicMaterial({ color: '#2e6ad6', transparent: true, opacity: 0.25, side: THREE.DoubleSide });
for (let cz = 0; cz < g.rows; cz++) for (let cx = 0; cx < g.cols; cx++) {
const v = g.occ[cz * g.cols + cx];
if (v === 0) continue;
const m = new THREE.Mesh(geo, v === 2 ? matWall : matOcc);
m.rotation.x = -Math.PI / 2;
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.03, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
}
gridHelper = grp; scene.add(grp);
}
function buildPathHelper() {
const g = current._debug.grid;
const start = current._debug.spawnCell, target = current._debug.targetCell;
const grp = new THREE.Group();
const mk = (idx, col) => {
const cx = idx % g.cols, cz = (idx / g.cols) | 0;
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.05, 12), new THREE.MeshBasicMaterial({ color: col }));
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.06, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
};
mk(start, '#3ad66a'); mk(target, '#ffd75e');
pathHelper = grp; scene.add(grp);
}
function applyWire() {
current.group.traverse(o => {
if (!o.isMesh) return;
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) if (m && 'wireframe' in m) m.wireframe = wire;
});
}
// ── controls / events ─────────────────────────────────────────────────────────
$('reroll').onclick = () => { $('seed').value = (Math.floor(Math.random() * 1e6)); rebuild(); };
$('next').onclick = () => { $('seed').value = (+$('seed').value + 1); rebuild(); };
$('prev').onclick = () => { $('seed').value = Math.max(0, +$('seed').value - 1); rebuild(); };
typeSel.onchange = rebuild; archSel.onchange = rebuild;
$('seed').onchange = rebuild; $('lot').onchange = rebuild;
$('tgWire').onchange = e => { wire = e.target.checked; applyWire(); };
$('tgGrid').onchange = e => { showGrid = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
$('tgPath').onchange = e => { showPath = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
const keys = {};
addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyG') { $('tgGrid').checked = !showGrid; $('tgGrid').onchange({ target: $('tgGrid') }); }
if (e.code === 'KeyF') { $('tgWire').checked = !wire; $('tgWire').onchange({ target: $('tgWire') }); }
});
addEventListener('keyup', e => { keys[e.code] = false; });
// ── first-person movement with occupancy collision + room clamp ────────────────
const RADIUS = 0.28, SPEED = 3.2;
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; // fitting blocks; walls handled by bounds
}
return true;
}
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
function move(dt) {
if (!controls.isLocked || !current) return;
let f = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
let s = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
if (!f && !s) return;
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
if (walkable(p.x, p.z + nz)) p.z += nz;
}
// ── soak test: build+dispose 50 rooms, assert leak-free + <50ms + deterministic
async function soak(N = 50) {
const el = $('soak'); el.classList.remove('bad'); el.textContent = 'running soak…';
if (current) { current.dispose(); current = null; clearDebug(); }
await new Promise(r => requestAnimationFrame(r));
const sampleShop = (i) => ({ id: 'soak' + i, type: SHOP_TYPES[i % SHOP_TYPES.length],
name: 'SOAK ' + i, seed: (1000 + i * 2654435761) >>> 0, storeys: 1 + (i % 3 === 0 ? 1 : 0) });
// warmup so shared file textures are in cache before we take the baseline
const warm = buildInterior(sampleShop(0), THREE); scene.add(warm.group); renderer.render(scene, camera);
scene.remove(warm.group); warm.dispose();
await new Promise(r => requestAnimationFrame(r));
const baseGeo = renderer.info.memory.geometries, baseTex = renderer.info.memory.textures;
let total = 0, worst = 0, detFail = 0;
for (let i = 0; i < N; i++) {
const shop = sampleShop(i);
const t0 = performance.now();
const r = buildInterior(shop, THREE);
const ms = performance.now() - t0; total += ms; worst = Math.max(worst, ms);
scene.add(r.group); renderer.render(scene, camera); // force GPU upload so info.memory is real
// determinism: same seed twice ⇒ identical placement list
const r2 = buildInterior(shop, THREE);
if (JSON.stringify(r.placement) !== JSON.stringify(r2.placement)) detFail++;
r2.dispose();
scene.remove(r.group); r.dispose();
}
renderer.render(scene, camera);
const leakGeo = renderer.info.memory.geometries - baseGeo;
const leakTex = renderer.info.memory.textures - baseTex;
const avg = total / N;
const pass = leakGeo <= 0 && leakTex <= 0 && worst < 50 && detFail === 0;
el.classList.toggle('bad', !pass);
el.textContent =
`${pass ? '✅ PASS' : '❌ CHECK'} · ${N} rooms\n` +
`avg ${avg.toFixed(1)}ms · worst ${worst.toFixed(1)}ms (budget 50)\n` +
`leak: geo ${leakGeo} · tex ${leakTex} (want ≤0)\n` +
`determinism: ${detFail === 0 ? 'identical ✓' : detFail + ' MISMATCH'}`;
rebuild();
}
$('soakBtn').onclick = () => soak(50);
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function frame() {
const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
move(dt);
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
// expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, rebuild, get current() { return current; }, scene, camera, renderer };
rebuild();
frame();
</script>
</body>
</html>

168
web/js/interiors/context.js Normal file
View File

@ -0,0 +1,168 @@
// PROCITY Lane C — build context: the seed + disposal backbone every interior module shares.
//
// Two hard constraints this file exists to satisfy (CITY_SPEC + LANE_C acceptance):
// 1. DETERMINISM. All randomness in an interior derives from `shop.seed`. We hand out
// INDEPENDENT sub-streams keyed by a salt string, so adding a fitting in one subsystem
// never shifts the wallpaper pick in another. Math.random() is banned in generation.
// 2. LEAK-FREE DISPOSAL. The 50-room soak must return renderer.info.memory to baseline.
// Every geometry / material / CanvasTexture we mint per room is tracked and freed in
// dispose(). Globally-cached FILE textures (loaders.loadTex) are shared across rooms by
// design and are NOT disposed here — that's a bounded cache, not a leak.
import { mulberry32, xmur3 } from '../core/prng.js';
import * as THREE_NS from 'three';
// Deterministic 32-bit hash of a salt string → xor'd into the seed for an independent stream.
const hash = (s) => xmur3(s)();
// One shared TextureLoader for per-surface tiled loads (each surface needs its own repeat).
const _texLoader = new THREE_NS.TextureLoader();
export class Ctx {
constructor(THREE, seed) {
this.THREE = THREE;
this.seed = seed >>> 0;
this._geometries = new Set(); // per-room BufferGeometry — dispose all
this._materials = new Set(); // per-room Material — dispose all
this._canvasTex = new Set(); // per-room CanvasTexture + tiled file textures — dispose all
this._streams = new Map(); // salt → mulberry32 fn (memoized so a salt is stable)
this._geoCache = new Map(); // dims-key → shared geometry (stock items reuse; disposed once)
this._disposed = false;
}
// An independent, memoized seeded stream for a subsystem. stream('wallpaper') always returns
// the same sequence for a given seed, regardless of what other subsystems do.
stream(salt) {
let s = this._streams.get(salt);
if (!s) { s = mulberry32((this.seed ^ hash(salt)) >>> 0); this._streams.set(salt, s); }
return s;
}
// A one-shot seeded value 0..1 (fresh stream, not advancing anyone else's).
rand(salt) { return mulberry32((this.seed ^ hash(salt)) >>> 0)(); }
// ---- tracked resource factories -------------------------------------------------
geom(g) { this._geometries.add(g); return g; }
// Shared geometry caches — the same box/plane/cyl dims are reused across the many stock items in a
// room (a book barn has hundreds of identically-sized spines). Cuts allocation + build time hugely;
// each cached geometry is tracked once and disposed once. Dims rounded to 2mm to key the cache.
boxGeo(w, h, d) {
const k = `b${Math.round(w * 500)},${Math.round(h * 500)},${Math.round(d * 500)}`;
let g = this._geoCache.get(k);
if (!g) { g = this.geom(new this.THREE.BoxGeometry(w, h, d)); this._geoCache.set(k, g); }
return g;
}
planeGeo(w, h) {
const k = `p${Math.round(w * 500)},${Math.round(h * 500)}`;
let g = this._geoCache.get(k);
if (!g) { g = this.geom(new this.THREE.PlaneGeometry(w, h)); this._geoCache.set(k, g); }
return g;
}
cylGeo(r, h, seg) {
const k = `c${Math.round(r * 500)},${Math.round(h * 500)},${seg}`;
let g = this._geoCache.get(k);
if (!g) { g = this.geom(new this.THREE.CylinderGeometry(r, r, h, seg)); this._geoCache.set(k, g); }
return g;
}
// MeshStandardMaterial with sane 90s-matte defaults; colour accepts hex/number/THREE.Color.
mat(color = 0xcccccc, roughness = 0.9, opts = {}) {
const m = new this.THREE.MeshStandardMaterial({
color: new this.THREE.Color(color), roughness, metalness: 0, ...opts,
});
this._materials.add(m);
return m;
}
// Cheap box helper — the workhorse. Geometry shared via cache; material tracked for disposal.
box(w, h, d, material, x = 0, y = 0, z = 0, parent = null) {
const me = new this.THREE.Mesh(this.boxGeo(w, h, d), material);
me.position.set(x, y, z);
if (parent) parent.add(me);
return me;
}
plane(w, h, material, parent = null) {
const me = new this.THREE.Mesh(this.planeGeo(w, h), material);
if (parent) parent.add(me);
return me;
}
// Cylinder helper (rails, uprights, spinner posts). rz rotates for horizontal rails.
cyl(r, h, material, x = 0, y = 0, z = 0, parent = null, rz = 0, seg = 12) {
const me = new this.THREE.Mesh(this.cylGeo(r, h, seg), material);
me.position.set(x, y, z);
me.rotation.z = rz;
if (parent) parent.add(me);
return me;
}
// Per-room CanvasTexture (tracked → disposed). Use for signs and stock sleeves.
canvasTexture(canvas) {
const t = new this.THREE.CanvasTexture(canvas);
t.colorSpace = this.THREE.SRGBColorSpace;
t.anisotropy = 4;
this._canvasTex.add(t);
return t;
}
// Skin a material with a tiled file texture, thriftgod-style: the map is assigned ONLY inside the
// load callback (so nothing renders a mapless-but-marked texture → no "no image data" warnings,
// and the flat-colour fallback shows until the file resolves). Each tiled surface gets its OWN
// texture (per-wall/floor repeat differs). The texture is TRACKED immediately for disposal; if the
// room is disposed before the load finishes, the callback no-ops (guarded by _disposed).
skin(material, url, { repeat = null, tint = 0xcccccc } = {}) {
const t = _texLoader.load(url, () => {
if (this._disposed) return;
material.map = t; material.color.set(tint); material.needsUpdate = true;
}, undefined, () => { /* missing asset → fallback colour stays */ });
t.colorSpace = this.THREE.SRGBColorSpace;
t.anisotropy = 4;
if (repeat) { t.wrapS = t.wrapT = this.THREE.RepeatWrapping; t.repeat.set(repeat[0], repeat[1]); }
this._canvasTex.add(t); // per-room texture → freed in disposeAll()
return material;
}
disposeAll() {
this._disposed = true; // late texture-load callbacks will no-op after this
for (const g of this._geometries) g.dispose();
for (const m of this._materials) m.dispose();
for (const t of this._canvasTex) t.dispose();
this._geometries.clear();
this._materials.clear();
this._canvasTex.clear();
this._streams.clear();
this._geoCache.clear();
}
// Snapshot for the leak assertion / debugging.
counts() {
return {
geometries: this._geometries.size,
materials: this._materials.size,
canvasTextures: this._canvasTex.size,
};
}
}
// Seeded helpers that take an explicit stream fn (so callers stay deterministic).
export const pick = (r, arr) => arr[(r() * arr.length) | 0];
export const irange = (r, lo, hi) => lo + ((r() * (hi - lo + 1)) | 0);
export const frange = (r, lo, hi) => lo + r() * (hi - lo);
export const chance = (r, p) => r() < p;
export function shuffle(r, arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = (r() * (i + 1)) | 0;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Weighted pick: items = [{...,weight}]; returns the item.
export function weighted(r, items) {
const total = items.reduce((s, it) => s + (it.weight || 1), 0);
let t = r() * total;
for (const it of items) { t -= (it.weight || 1); if (t <= 0) return it; }
return items[items.length - 1];
}

View File

@ -0,0 +1,369 @@
// PROCITY Lane C — parametric fittings kit. Ported from 90sDJsim/web/world/fittings.js (the kit was
// "written to be lifted") and extended with the fittings the CITY_SPEC registry needs: cube/metal/
// book/VHS shelving, glass case, fridge, magazine rack, spinner rack, armchair, escalator prop,
// returns slot, barred window, listening corner, crate.
//
// Every builder is `(ctx, o, r) → Fitting` where:
// ctx : the shared Ctx (tracked box/mat/cyl for leak-free disposal, canvas textures)
// o : sizing opts (w/h/d/count…), all optional — sensible defaults per fitting
// r : a seeded stream (mulberry32) so a shop is byte-identical every revisit
//
// Fitting = {
// group, // THREE.Group built at LOCAL origin, sitting on floor y=0,
// // footprint centred on (0,0). The layout placer positions it.
// footprint: { w, d }, // floor bounding box (metres) for the occupancy grid
// height, // top of the fitting (metres)
// slots: [ StockSlot… ], // where stock.js should place visual stock (may be empty)
// places: [ mesh… ], // interactables to surface in buildInterior().places
// }
//
// StockSlot = { kind:'sleeve'|'spine'|'box'|'garment'|'treasure'|'snack'|'magazine',
// x,y,z, ry, run, depth, height, count, lean } (local to group)
//
// GLB UPGRADE: if Lane E's web/assets/manifest.json maps a fitting id → depot GLB, buildInterior
// swaps the primitive for the GLB (placeholder-persists). The kit itself is 100% primitives so the
// test page runs with zero assets and zero network.
const MAT = {
chrome: (ctx) => ctx.mat('#b8bcc4', 0.35, { metalness: 0.8 }),
wire: (ctx) => ctx.mat('#8a8d94', 0.4, { metalness: 0.6 }),
wood: (ctx) => ctx.mat('#6d4a30', 0.8),
darkwood: (ctx) => ctx.mat('#4a3524', 0.8),
lightwood: (ctx) => ctx.mat('#9c8d76', 0.75),
peg: (ctx) => ctx.mat('#c8a878', 0.85),
glass: (ctx) => ctx.mat('#bfe0ea', 0.06, { transparent: true, opacity: 0.22 }),
metal: (ctx) => ctx.mat('#33343c', 0.5, { metalness: 0.5 }),
black: (ctx) => ctx.mat('#2a2a30', 0.5, { metalness: 0.35 }),
};
// A muted 90s-Australian stock/garment palette for placeholder colours (real skins come via stock).
const GARB = ['#7a4a5a', '#4a5a7a', '#5a7a4a', '#8a7a4a', '#6a4a7a', '#a06a5a', '#4a6a6a', '#907a8a',
'#b0894a', '#4f6270', '#75565a', '#5d6b45'];
const garb = (ctx, r) => ctx.mat(GARB[(r() * GARB.length) | 0], 0.85);
// ───────────────────────────────────────────────────────────────────────────────────
// RECORD STORE
// Angled record bins on a timber base; each bin fans a run of leaning sleeves.
function recordBin(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.2, d = 0.72, bins = o.bins ?? (2 + (r() * 2 | 0));
const wood = MAT.wood(ctx);
ctx.box(w, 0.5, d, wood, 0, 0.25, 0, g); // base cabinet
const slots = [];
for (let b = 0; b < bins; b++) {
const cx = -w / 2 + (w / bins) * (b + 0.5);
// faceW = sleeve width across the bin; run = packing length front-to-back; tilt = fan-back angle.
slots.push({ kind: 'sleeve', x: cx, y: 0.5, z: 0, faceW: (w / bins) * 0.82, run: d * 0.82,
height: 0.32, count: 14, tilt: -0.42 });
}
g.userData = { kind: 'bin', interactable: true };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: 0.9, slots, places: [g] };
}
// Open plastic/timber crate of records on the floor — the dig-through classic.
function crate(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 0.55, d = o.d || 0.45, h = 0.34;
const m = ctx.mat(r() < 0.5 ? '#8a6a45' : '#4a5a6a', 0.8);
const th = 0.02;
ctx.box(w, th, d, m, 0, h * 0.72 - th / 2, 0, g); // raised floor (records ride up)
ctx.box(th, h, d, m, -w / 2, h / 2, 0, g); ctx.box(th, h, d, m, w / 2, h / 2, 0, g);
ctx.box(w, h, th, m, 0, h / 2, -d / 2, g); ctx.box(w, h * 0.6, th, m, 0, h * 0.3, d / 2, g);
const slots = [{ kind: 'sleeve', x: 0, y: h * 0.72, z: 0, faceW: w * 0.82, run: d * 0.82,
height: 0.28, count: 12, tilt: -0.3 }];
g.userData = { kind: 'bin', interactable: true };
return { group: g, footprint: { w: w + 0.06, d: d + 0.06 }, height: h, slots, places: [g] };
}
// A listening corner: a low bench + a boxy hi-fi + headphones on a stand.
function listeningCorner(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const wood = MAT.darkwood(ctx), black = MAT.black(ctx);
ctx.box(1.0, 0.42, 0.5, MAT.wood(ctx), 0, 0.21, 0, g); // bench
ctx.box(0.5, 0.16, 0.34, black, -0.2, 0.5, 0, g); // amp/deck
ctx.box(0.16, 0.04, 0.16, MAT.metal(ctx), -0.2, 0.6, 0, g); // platter
ctx.cyl(0.02, 0.7, MAT.chrome(ctx), 0.34, 0.35, 0, g); // headphone stand post
ctx.box(0.16, 0.14, 0.1, black, 0.34, 0.74, 0, g); // cans
g.userData = { kind: 'prop' };
return { group: g, footprint: { w: 1.1, d: 0.6 }, height: 0.9, slots: [], places: [] };
}
// ───────────────────────────────────────────────────────────────────────────────────
// SHELVING FAMILY
function _shelfFrame(ctx, g, w, h, d, m, shelves) {
[-w / 2, w / 2].forEach(sx => ctx.box(0.05, h, d, m, sx, h / 2, 0, g)); // uprights
ctx.box(w, 0.04, d, m, 0, 0.04, 0, g); // base
ctx.box(w, 0.04, d, m, 0, h, 0, g); // top
ctx.box(w, h, 0.03, MAT.darkwood(ctx), 0, h / 2, -d / 2 + 0.015, g); // back panel
const levels = [];
for (let i = 1; i <= shelves; i++) { const sy = h * i / (shelves + 1); ctx.box(w, 0.04, d, m, 0, sy, 0, g); levels.push(sy); }
return levels;
}
// Timber/laminate shelf unit (bric-a-brac). Wall-hugging.
function wallShelf(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.4, h = o.h || 1.8, d = o.d || 0.4, shelves = o.shelves ?? 4;
const levels = _shelfFrame(ctx, g, w, h, d, MAT.lightwood(ctx), shelves);
const slots = levels.map(sy => ({ kind: 'box', x: 0, y: sy + 0.02, z: 0.02, ry: 0,
run: w - 0.24, depth: d * 0.7, height: h / (shelves + 2), count: 4, lean: false }));
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// Wire/metal shelving (op-shop, pawn, milkbar back-of-house look).
function metalShelf(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.2, h = o.h || 1.9, d = o.d || 0.42, shelves = o.shelves ?? 4;
const levels = _shelfFrame(ctx, g, w, h, d, MAT.wire(ctx), shelves);
const slots = levels.map(sy => ({ kind: 'box', x: 0, y: sy + 0.02, z: 0, ry: 0,
run: w - 0.2, depth: d * 0.72, height: h / (shelves + 2), count: 5, lean: false }));
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// Cube / pigeonhole shelving (toy, dept). A grid of open cubes, each a little display box.
function cubeShelf(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const cols = o.cols ?? (2 + (r() * 2 | 0)), rows = o.rows ?? 4;
const cw = 0.42, ch = 0.42, d = o.d || 0.4;
const w = cols * cw, h = rows * ch;
const m = ctx.mat(r() < 0.5 ? '#c9b28a' : '#d0d3d8', 0.75);
for (let c = 0; c <= cols; c++) ctx.box(0.03, h, d, m, -w / 2 + c * cw, h / 2, 0, g);
for (let rr = 0; rr <= rows; rr++) ctx.box(w, 0.03, d, m, 0, rr * ch, 0, g);
ctx.box(w, h, 0.02, MAT.darkwood(ctx), 0, h / 2, -d / 2, g);
const slots = [];
for (let c = 0; c < cols; c++) for (let rr = 0; rr < rows; rr++)
slots.push({ kind: 'box', x: -w / 2 + (c + 0.5) * cw, y: rr * ch + 0.05, z: 0.02, ry: 0,
run: cw - 0.1, depth: d * 0.6, height: ch - 0.08, count: 1, lean: false });
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// Tall bookshelf, many narrow shelves fanned with leaning spines.
function bookshelf(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.0, h = o.h || 2.1, d = o.d || 0.3, shelves = o.shelves ?? 5;
const levels = _shelfFrame(ctx, g, w, h, d, MAT.darkwood(ctx), shelves);
const slots = levels.map(sy => ({ kind: 'spine', x: 0, y: sy + 0.02, z: 0, ry: 0,
run: w - 0.16, depth: d * 0.7, height: h / (shelves + 2) - 0.04, count: 11, lean: true }));
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// VHS aisle — double-sided tall shelf of tape spines, the video-store centrepiece row.
function vhsAisle(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.6, h = o.h || 1.7, d = o.d || 0.5, shelves = o.shelves ?? 4;
const m = ctx.mat('#3a3f4a', 0.6);
[-w / 2, w / 2].forEach(sx => ctx.box(0.06, h, d, m, sx, h / 2, 0, g));
ctx.box(w, 0.05, d, m, 0, 0.05, 0, g); ctx.box(w, 0.1, d, m, 0, h, 0, g);
ctx.box(w, h, 0.03, m, 0, h / 2, 0, g); // central spine divider (double-sided)
const slots = [];
for (let i = 1; i <= shelves; i++) {
const sy = h * i / (shelves + 1);
ctx.box(w, 0.04, d, m, 0, sy, 0, g);
slots.push({ kind: 'spine', x: 0, y: sy + 0.02, z: d / 4, ry: 0, run: w - 0.18, depth: d * 0.35, height: h / (shelves + 2) - 0.03, count: 12, lean: false });
slots.push({ kind: 'spine', x: 0, y: sy + 0.02, z: -d / 4, ry: Math.PI, run: w - 0.18, depth: d * 0.35, height: h / (shelves + 2) - 0.03, count: 12, lean: false });
}
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// Spinner rack — rotating wire stand (paperbacks / postcards).
function spinnerRack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const h = o.h || 1.6, r0 = 0.4;
const wire = MAT.wire(ctx);
ctx.cyl(0.03, h, wire, 0, h / 2, 0, g);
ctx.cyl(0.28, 0.04, wire, 0, 0.04, 0, g);
const slots = [];
for (let tier = 0; tier < 4; tier++) {
const ty = 0.5 + tier * 0.32;
for (let f = 0; f < 4; f++) {
const a = f / 4 * Math.PI * 2;
slots.push({ kind: 'box', x: Math.cos(a) * r0, y: ty, z: Math.sin(a) * r0, ry: Math.PI / 2 - a,
run: 0.28, depth: 0.04, height: 0.26, count: 1, lean: false });
}
}
return { group: g, footprint: { w: 2 * r0 + 0.2, d: 2 * r0 + 0.2 }, height: h, slots, places: [] };
}
// ───────────────────────────────────────────────────────────────────────────────────
// GARMENTS
// Straight clothes rail on two uprights, hung with garment slots.
function clothesRack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.6, h = o.h || 1.6, n = o.count ?? (9 + (r() * 4 | 0));
const chrome = MAT.chrome(ctx);
[-w / 2, w / 2].forEach(sx => { ctx.cyl(0.02, h, chrome, sx, h / 2, 0, g); ctx.box(0.45, 0.03, 0.45, chrome, sx, 0.02, 0, g); });
ctx.cyl(0.018, w, chrome, 0, h - 0.05, 0, g, Math.PI / 2);
const slots = [{ kind: 'garment', x: 0, y: h - 0.05, z: 0, ry: 0, run: w - 0.2, depth: 0.34, height: 0.52, count: n, lean: false }];
return { group: g, footprint: { w: w + 0.1, d: 0.5 }, height: h, slots, places: [] };
}
// ───────────────────────────────────────────────────────────────────────────────────
// TABLES / COUNTERS / CASES
// Trestle / bargain table with scattered goods on top.
function trestleTable(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.4, h = o.h || 0.75, d = o.d || 0.7;
const wood = MAT.wood(ctx), dark = MAT.darkwood(ctx);
ctx.box(w, 0.05, d, wood, 0, h, 0, g);
[[-w / 2 + 0.08, -d / 2 + 0.08], [w / 2 - 0.08, -d / 2 + 0.08], [-w / 2 + 0.08, d / 2 - 0.08], [w / 2 - 0.08, d / 2 - 0.08]]
.forEach(([lx, lz]) => ctx.box(0.06, h, 0.06, dark, lx, h / 2, lz, g));
const slots = [{ kind: 'box', x: 0, y: h + 0.03, z: 0, ry: 0, run: w - 0.3, depth: d - 0.2, height: 0.2, count: o.items ?? 7, lean: false, scatter: true }];
return { group: g, footprint: { w, d }, height: h + 0.25, slots, places: [] };
}
// Shop counter with till and register screen. `interactable` — the pay point.
function counter(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.8, h = o.h || 1.0, d = o.d || 0.6;
const wood = MAT.wood(ctx), dark = MAT.darkwood(ctx);
const body = ctx.box(w, h, d, wood, 0, h / 2, 0, g);
ctx.box(w + 0.1, 0.06, d + 0.1, dark, 0, h, 0, g); // benchtop
ctx.box(0.34, 0.22, 0.28, MAT.black(ctx), w / 2 - 0.35, h + 0.15, 0, g); // till
const scr = ctx.plane(0.14, 0.1, ctx.mat('#0a1a0a', 0.4, { emissive: new ctx.THREE.Color('#123') }), g);
scr.position.set(w / 2 - 0.35, h + 0.28, 0.14); scr.rotation.x = -0.5;
body.userData = { kind: 'counter', interactable: true };
g.userData = { kind: 'counter', interactable: true };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g] };
}
// Glass display case — timber base + tinted glass top, a few "treasures" inside.
function glassCase(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.2, d = o.d || 0.5;
ctx.box(w, 0.72, d, ctx.mat('#5a4632', 0.6), 0, 0.36, 0, g); // base
ctx.box(w, 0.46, d, MAT.glass(ctx), 0, 0.95, 0, g); // glass top
const slots = [{ kind: 'treasure', x: 0, y: 0.78, z: 0, ry: 0, run: w - 0.2, depth: d - 0.15, height: 0.16, count: 3 + (r() * 3 | 0), lean: false }];
g.userData = { kind: 'case', interactable: true };
return { group: g, footprint: { w, d }, height: 1.2, slots, places: [g] };
}
// ───────────────────────────────────────────────────────────────────────────────────
// SPECIALS
// Wall pegboard hung with small goods (video posters / pawn tools / hardware).
function pegboard(ctx, o = {}, r) {
// Built centred on local y=0 so it mounts by its centre at mountY.
const g = new ctx.THREE.Group();
const w = o.w || 1.4, h = o.h || 1.2;
ctx.box(w, h, 0.03, MAT.peg(ctx), 0, 0, 0, g);
const slots = [{ kind: 'box', x: 0, y: 0, z: 0.05, ry: 0, run: w - 0.3, depth: 0.05, height: h - 0.3, count: o.items ?? 8, lean: false, scatter: true, wall: true }];
return { group: g, footprint: { w, d: 0.12 }, height: h, slots, places: [], wallMounted: true, mountY: 1.4 };
}
// Drinks fridge — glass door, glowing interior, stacked cans/bottles (milk bar).
function fridge(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 0.9, h = o.h || 2.0, d = o.d || 0.6;
const shell = ctx.mat('#e8ecef', 0.5);
ctx.box(w, h, d, shell, 0, h / 2, 0, g);
ctx.box(w - 0.08, h - 0.5, 0.02, ctx.mat('#0c1418', 0.7, { emissive: new ctx.THREE.Color('#0a1a24') }), 0, h / 2 + 0.05, d / 2 - 0.03, g); // dark interior
ctx.box(w - 0.06, h - 0.46, 0.02, MAT.glass(ctx), 0, h / 2 + 0.05, d / 2, g); // glass door
const slots = [];
for (let i = 0; i < 4; i++) slots.push({ kind: 'snack', x: 0, y: 0.4 + i * 0.42, z: d / 2 - 0.12, ry: 0, run: w - 0.16, depth: 0.1, height: 0.3, count: 8, lean: false });
g.userData = { kind: 'fridge', interactable: true };
return { group: g, footprint: { w, d }, height: h, slots, places: [g] };
}
// Magazine rack — angled tiers of mag faces (milk bar window, book shop).
function magazineRack(ctx, o = {}, r) {
// Floor-standing tiered rack (leans against a wall / sits in the window). Base at y=0.
const g = new ctx.THREE.Group();
const w = o.w || 1.0, h = o.h || 1.4, tiers = 4;
const m = ctx.mat('#7a5a3a', 0.75);
ctx.box(w, 0.04, 0.35, m, 0, 0.06, 0, g);
const slots = [];
for (let t = 0; t < tiers; t++) {
const ty = 0.35 + t * (h - 0.4) / tiers;
ctx.box(w, 0.03, 0.28, m, 0, ty, -0.08, g);
slots.push({ kind: 'magazine', x: 0, y: ty + 0.02, z: 0.02, tilt: -0.35, run: w - 0.1, depth: 0.24, height: (h - 0.4) / tiers - 0.02, count: 4, lean: true });
}
return { group: g, footprint: { w, d: 0.4 }, height: h, slots, places: [] };
}
// Reading armchair — a cosy corner nook (book shop).
function armchair(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const m = ctx.mat(['#6a4a3a', '#4a5a4a', '#5a4a5a'][(r() * 3) | 0], 0.9);
ctx.box(0.7, 0.16, 0.7, m, 0, 0.42, 0, g); // seat
ctx.box(0.7, 0.5, 0.14, m, 0, 0.7, -0.28, g); // back
ctx.box(0.12, 0.4, 0.7, m, -0.29, 0.55, 0, g); ctx.box(0.12, 0.4, 0.7, m, 0.29, 0.55, 0, g); // arms
ctx.box(0.7, 0.42, 0.7, m, 0, 0.21, 0, g); // base
g.userData = { kind: 'prop' };
return { group: g, footprint: { w: 0.8, d: 0.8 }, height: 0.95, slots: [], places: [] };
}
// Barred shop window (pawn) — a wall-mounted frame with vertical bars over dim glass.
function barredWindow(ctx, o = {}, r) {
// Built centred on local y=0 so it mounts by its centre at mountY.
const g = new ctx.THREE.Group();
const w = o.w || 1.4, h = o.h || 1.1;
ctx.box(w, h, 0.06, ctx.mat('#4a4038', 0.7), 0, 0, -0.03, g); // frame/backing
ctx.box(w - 0.12, h - 0.12, 0.02, ctx.mat('#26303a', 0.3, { transparent: true, opacity: 0.5 }), 0, 0, 0.01, g);
const bar = MAT.metal(ctx);
const nb = 6;
for (let i = 0; i <= nb; i++) ctx.cyl(0.015, h - 0.1, bar, -w / 2 + 0.06 + i * ((w - 0.12) / nb), 0, 0.04, g);
return { group: g, footprint: { w, d: 0.12 }, height: h, slots: [], places: [], wallMounted: true, mountY: 1.2 };
}
// Returns slot — a wall/counter chute with a "RETURNS" plate (video store).
function returnsSlot(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
ctx.box(0.5, 0.8, 0.4, ctx.mat('#5a4632', 0.7), 0, 0.4, 0, g);
ctx.box(0.34, 0.06, 0.05, MAT.black(ctx), 0, 0.62, 0.2, g); // the slot
g.userData = { kind: 'returns', interactable: true };
return { group: g, footprint: { w: 0.5, d: 0.4 }, height: 0.8, slots: [], places: [g] };
}
// Escalator prop — the dept-store grand-hall centrepiece (static, decorative).
function escalator(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 1.2, len = o.len || 4.0, rise = o.rise || 2.2;
const metal = MAT.metal(ctx), rail = MAT.chrome(ctx);
// inclined stepped ramp
const steps = 12;
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1);
ctx.box(w, 0.12, len / steps + 0.02, metal, 0, 0.1 + t * rise, -len / 2 + t * len, g);
}
ctx.box(w + 0.1, 0.3, len, ctx.mat('#3a3f47', 0.6), 0, rise / 2 - 0.1, 0, g); // side skirt
// inclined handrails (a box the length of the slope, tilted to match the ramp)
const slope = Math.hypot(rise, len), ang = Math.atan2(rise, len);
[-w / 2 - 0.05, w / 2 + 0.05].forEach(sx => {
const rl = ctx.box(0.06, 0.06, slope, rail, sx, 0.9 + rise / 2, 0, g);
rl.rotation.x = ang;
});
g.userData = { kind: 'prop' };
return { group: g, footprint: { w: w + 0.2, d: len }, height: rise + 1.0, slots: [], places: [] };
}
// Art frame (wall decor) — a framed canvas plane. Colour placeholder (art-*.jpg is Lane E future).
function artFrame(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const sz = o.w || (0.7 + r() * 0.5);
ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, -0.01, g); // frame
const artCol = new ctx.THREE.Color().setHSL(r(), 0.35, 0.45);
ctx.plane(sz, sz, ctx.mat(artCol, 0.7), g);
return { group: g, footprint: { w: sz, d: 0.08 }, height: sz, slots: [], places: [], wallMounted: true, mountY: 1.5 };
}
export const FITTINGS = {
recordBin, crate, listeningCorner,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack,
clothesRack,
trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
};
// Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash).
export function buildFitting(kind, ctx, o, r) {
const fn = FITTINGS[kind] || FITTINGS.wallShelf;
const fit = fn(ctx, o || {}, r);
// frameCount = children present before any procedural stock is added, so the optional GLB upgrade
// (glb.js) can hide just the primitive frame and keep the stock sitting on the detailed prop.
fit.frameCount = fit.group.children.length;
return fit;
}

66
web/js/interiors/glb.js Normal file
View File

@ -0,0 +1,66 @@
// 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);
}

View File

@ -0,0 +1,117 @@
// 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;
}

365
web/js/interiors/layout.js Normal file
View File

@ -0,0 +1,365 @@
// PROCITY Lane C — the placer. Turns a recipe + room shell into a believable, non-overlapping,
// walkable arrangement of fittings + wall decor, seeded from shop.seed.
//
// Guarantees (LANE_C acceptance):
// • nothing overlaps — every fitting reserves cells in an occupancy grid before placing
// • wall art/signs don't clash — the thriftgod shuffled wall-slot system draws from one pool
// • door → counter is walkable — flood-fill verifies connectivity; blockers are pulled until it is
// • density varies by shop — count per fitting scales by recipe.clutter × area × seed
//
// Coordinates match shell.js: +Z = door/street side, Z = back. Origin = room centre, floor y=0.
import { buildFitting } from './fittings.js';
import * as stock from './stock.js';
import { frange, shuffle } from './context.js';
const CELL = 0.4; // occupancy grid resolution (m)
const PAD = 0.12; // clearance padding around each fitting (m)
// ── grid helpers ─────────────────────────────────────────────────────────────────────
function makeGrid(W, D) {
const cols = Math.max(3, Math.round(W / CELL));
const rows = Math.max(3, Math.round(D / CELL));
const cw = W / cols, cd = D / rows;
const occ = new Uint8Array(cols * rows); // 0 free, 1 fitting, 2 wall(border)
const reserved = new Uint8Array(cols * rows); // walkable but no placement (door corridor, till front)
for (let cx = 0; cx < cols; cx++) for (let cz = 0; cz < rows; cz++)
if (cx === 0 || cz === 0 || cx === cols - 1 || cz === rows - 1) occ[cz * cols + cx] = 2;
const cellOf = (x, z) => [
Math.min(cols - 1, Math.max(0, Math.floor((x + W / 2) / cw))),
Math.min(rows - 1, Math.max(0, Math.floor((z + D / 2) / cd))),
];
return { cols, rows, cw, cd, W, D, occ, reserved, cellOf };
}
// Rotated-AABB half extents of a footprint at yaw ry.
function halfExtents(fw, fd, ry) {
const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
return [(c * fw + s * fd) / 2 + PAD, (s * fw + c * fd) / 2 + PAD];
}
function rectCells(grid, x, z, hw, hd) {
const a = grid.cellOf(x - hw, z - hd), b = grid.cellOf(x + hw, z + hd);
return { cx0: a[0], cz0: a[1], cx1: b[0], cz1: b[1] };
}
// Is the padded footprint clear? (wall cells value 2 are allowed — shelves hug walls.)
function areaFree(grid, rect) {
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
for (let cx = rect.cx0; cx <= rect.cx1; cx++) {
const v = grid.occ[cz * grid.cols + cx];
if (v === 1) return false;
if (grid.reserved[cz * grid.cols + cx]) return false;
}
return true;
}
function stampRect(grid, rect) {
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
for (let cx = rect.cx0; cx <= rect.cx1; cx++)
if (grid.occ[cz * grid.cols + cx] === 0) grid.occ[cz * grid.cols + cx] = 1;
}
function rebuildOcc(grid, placed) {
for (let i = 0; i < grid.occ.length; i++) if (grid.occ[i] === 1) grid.occ[i] = 0;
// Skip noStamp (wall-mounted) fittings — placeAtWall never stamped them at placement, so re-stamping
// here would inject phantom floor obstacles beside a wall the player can actually walk past, and
// could trigger needless fitting-pulls or a carve when the path was fine.
for (const p of placed) if (!p.noStamp) stampRect(grid, p.rect);
}
function reserveRect(grid, x0, z0, x1, z1) {
const a = grid.cellOf(x0, z0), b = grid.cellOf(x1, z1);
for (let cz = a[1]; cz <= b[1]; cz++) for (let cx = a[0]; cx <= b[0]; cx++)
grid.reserved[cz * grid.cols + cx] = 1;
}
// 4-connected flood fill over walkable cells (occ==0). Returns Set of reachable indices.
function floodFill(grid, startIdx) {
const seen = new Uint8Array(grid.cols * grid.rows);
const stack = [startIdx]; seen[startIdx] = 1;
const out = new Set([startIdx]);
while (stack.length) {
const i = stack.pop(), cx = i % grid.cols, cz = (i / grid.cols) | 0;
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx, nz = cz + dz;
if (nx < 0 || nz < 0 || nx >= grid.cols || nz >= grid.rows) continue;
const ni = nz * grid.cols + nx;
if (seen[ni] || grid.occ[ni] !== 0) continue;
seen[ni] = 1; out.add(ni); stack.push(ni);
}
}
return out;
}
// ── counter placement per recipe.counterPos ──────────────────────────────────────────
function placeCounter(ctx, recipe, W, D, r) {
const pos = recipe.counterPos || 'corner';
const cw = Math.min(2.2, Math.max(1.4, W * 0.42));
let x, z, ry, front;
if (pos === 'door') { // milk bar: faces the door, front-left
x = -W * 0.12; z = D / 2 - 1.8; ry = 0; front = { x, z: z + 0.9 };
} else if (pos === 'forward') { // pawn/stall: counter up front, goods behind
x = -W * 0.05; z = D / 2 - 2.4; ry = 0; front = { x, z: z + 0.95 };
} else if (pos === 'back') { // along the back wall, facing in
x = -W * 0.1; z = -D / 2 + 0.7; ry = Math.PI; front = { x, z: z + 0.95 };
} else { // 'corner' — back-east, facing into the room
x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z };
}
const f = buildFitting('counter', ctx, { w: cw }, r);
f.group.position.set(x, 0, z); f.group.rotation.y = ry;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
return { fitting: f, x, z, ry, hw, hd, front, priority: 9, kind: 'counter' };
}
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
function makeWallSlots(W, D, r) {
const slots = [];
const nZ = Math.max(2, Math.floor((D - 3.2) / 1.7));
for (let i = 0; i < nZ; i++) {
const z = -D / 2 + 1.6 + i * ((D - 3.4) / Math.max(1, nZ - 1));
slots.push({ wall: 'west', x: -W / 2, z, ry: Math.PI / 2, used: false });
slots.push({ wall: 'east', x: W / 2, z, ry: -Math.PI / 2, used: false });
}
const nX = Math.max(1, Math.floor((W - 3.0) / 1.7));
for (let i = 0; i < nX; i++) {
const x = -W / 2 + 1.5 + i * ((W - 3.0) / Math.max(1, nX - 1));
slots.push({ wall: 'north', x, z: -D / 2, ry: 0, used: false });
}
shuffle(r, slots);
return slots;
}
// ── free-standing candidate positions per zone ────────────────────────────────────────
function zoneCandidates(zone, W, D, doorCx, r) {
const out = [];
if (zone === 'window') {
const z = D / 2 - 1.4;
for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI });
} else if (zone === 'aisle') {
for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7)
for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 });
} else if (zone === 'centre') {
for (let z = D / 2 - 3.5; z >= -D / 2 + 2.2; z -= 2.0)
for (let x = -W / 4; x <= W / 4; x += 1.8) out.push({ x, z, ry: 0 });
}
return shuffle(r, out);
}
// ── main entry ────────────────────────────────────────────────────────────────────────
export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
const { W, D } = dims;
const doorCx = shell.door.x;
const grid = makeGrid(W, D);
const rCount = ctx.stream('lay-count'), rPlace = ctx.stream('lay-place'), rWall = ctx.stream('lay-wall');
// Reserve the door corridor (walkable, no fittings) and the spawn cell.
reserveRect(grid, doorCx - 0.7, D / 2 - 2.0, doorCx + 0.7, D / 2 - 0.4);
const placed = [];
const roomGroup = shell.group;
// 1) COUNTER first (always present, reachability target).
const counter = placeCounter(ctx, recipe, W, D, rCount);
counter.rect = rectCells(grid, counter.x, counter.z, counter.hw, counter.hd);
stampRect(grid, counter.rect);
placed.push(counter);
// reserve the strip in front of the till so nothing blocks service
reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
const area = W * D;
const wallSlots = makeWallSlots(W, D, rWall);
for (const spec of recipe.fittings) {
if (spec.kind === 'counter') continue;
// count scales with clutter × area, clamped to [min,max]
const density = recipe.clutter * (area / 70);
let n = Math.round(frange(rCount, spec.min, spec.max + 0.99) * Math.min(1.3, density));
n = Math.max(spec.min, Math.min(spec.max, n));
for (let i = 0; i < n; i++) {
const f = buildFitting(spec.kind, ctx, {}, ctx.stream(`fit-${spec.kind}-${i}`));
let ok = false;
if (spec.zone === 'wall' || f.wallMounted) {
ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec);
} else if (spec.zone === 'counter') {
ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec);
} else {
ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec);
}
if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ }
}
}
// 3) PATH GUARANTEE: door → counter. Pull low-priority blockers until reachable; carve if we must.
const spawnCell = idxOf(grid, shell.spawn.x, shell.spawn.z);
const targetCell = idxOf(grid, counter.front.x, counter.front.z);
let pathOK = reaches(grid, spawnCell, targetCell);
let carved = false;
const removable = () => placed
.filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor
.sort((a, b) => a.priority - b.priority);
while (!pathOK) {
const pool = removable();
if (!pool.length) break;
const victim = pool[0];
placed.splice(placed.indexOf(victim), 1);
if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
victim.removed = true;
rebuildOcc(grid, placed);
pathOK = reaches(grid, spawnCell, targetCell);
}
if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
// 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at
// placement time) so interactables belong only to fittings that actually made it into the room —
// a fitting pulled by the path loop must not leave a phantom interactable in the returned list.
const places = [...(shell.places || [])];
for (const p of placed) {
if (p.removed) continue;
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
places.push(...(p.fitting.places || []));
}
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
x: round(p.x), z: round(p.z), ry: round(p.ry), fw: round(p.fitting.footprint.w), fd: round(p.fitting.footprint.d),
})).concat(decor.summary);
return {
placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved,
spawnCell, targetCell, placement,
};
}
// ── placement strategies ──────────────────────────────────────────────────────────────
function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) {
const cands = zoneCandidates(zone, W, D, doorCx, r);
for (const c of cands) {
const ry = c.ry + (r() - 0.5) * 0.4;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
const rect = rectCells(grid, c.x, c.z, hw, hd);
if (!areaFree(grid, rect)) continue;
f.group.position.set(c.x, 0, c.z); f.group.rotation.y = ry;
stampRect(grid, rect);
placed.push({ fitting: f, x: c.x, z: c.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
return true;
}
return false;
}
function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) {
const backOff = f.wallMounted ? 0.06 : f.footprint.d / 2 + 0.05;
for (const slot of wallSlots) {
if (slot.used) continue;
const ry = slot.ry;
let x = slot.x, z = slot.z;
if (slot.wall === 'west') x += backOff; else if (slot.wall === 'east') x -= backOff; else z += backOff;
const y = f.wallMounted ? (f.mountY || 1.4) : 0;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
const rect = rectCells(grid, x, z, hw, hd);
if (!f.wallMounted && !areaFree(grid, rect)) continue;
f.group.position.set(x, y, z); f.group.rotation.y = ry;
if (!f.wallMounted) stampRect(grid, rect);
slot.used = true;
// noStamp: wall-mounted fittings hang above head height and don't block the floor — keep rebuildOcc
// consistent with this placement (see rebuildOcc).
placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind, noStamp: f.wallMounted });
return true;
}
return false;
}
function placeNearCounter(ctx, grid, f, counter, r, placed, spec) {
// treasures / cases beside the till, on the goods side of the counter
const side = counter.ry === 0 ? { x: counter.x + (r() < 0.5 ? -1.3 : 1.3), z: counter.z } : { x: counter.x, z: counter.z + (r() < 0.5 ? -1.3 : 1.3) };
const ry = counter.ry;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
const rect = rectCells(grid, side.x, side.z, hw, hd);
if (!areaFree(grid, rect)) return false;
f.group.position.set(side.x, 0, side.z); f.group.rotation.y = ry;
stampRect(grid, rect);
placed.push({ fitting: f, x: side.x, z: side.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
return true;
}
// ── wall decor (art + signage) ─────────────────────────────────────────────────────────
function placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, r) {
const meshes = [], summary = [];
const free = wallSlots.filter(s => !s.used);
const nArt = Math.min(free.length, 2 + (r() * 3 | 0));
const nSign = Math.min(Math.max(0, free.length - nArt), 1 + (r() * 2 | 0));
let fi = 0;
for (let i = 0; i < nArt && fi < free.length; i++, fi++) {
const slot = free[fi];
const sz = 0.7 + r() * 0.5;
const artCol = new ctx.THREE.Color().setHSL(r(), 0.32, 0.42);
const frame = ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, 0, roomGroup);
const canvas = ctx.plane(sz, sz, ctx.mat(artCol, 0.75), roomGroup);
// inward normal per wall so the canvas sits a hair in front of its frame (no z-fight)
const nx = slot.wall === 'west' ? 1 : slot.wall === 'east' ? -1 : 0;
const nz = slot.wall === 'north' ? 1 : 0;
const y = 1.5 + r() * 0.3, tilt = (r() - 0.5) * 0.05;
const baseX = slot.wall === 'west' ? -W / 2 + 0.04 : slot.wall === 'east' ? W / 2 - 0.04 : slot.x;
const baseZ = slot.wall === 'north' ? -D / 2 + 0.04 : slot.z;
frame.position.set(baseX, y, baseZ); frame.rotation.y = slot.ry; frame.rotation.z = tilt;
canvas.position.set(baseX + nx * 0.03, y, baseZ + nz * 0.03); canvas.rotation.y = slot.ry; canvas.rotation.z = tilt;
meshes.push(frame, canvas);
summary.push({ kind: 'art', wall: slot.wall, x: round(slot.x), z: round(slot.z) });
}
const signs = recipe.signFlavour || [];
for (let i = 0; i < nSign && fi < free.length; i++, fi++) {
const slot = free[fi];
const txt = signs[(r() * signs.length) | 0] || 'SALE';
const mesh = makeSignPlane(ctx, txt);
const off = 0.05;
const x = slot.wall === 'west' ? -W / 2 + off : slot.wall === 'east' ? W / 2 - off : slot.x;
const z = slot.wall === 'north' ? -D / 2 + off : slot.z;
mesh.position.set(x, 2.05, z); mesh.rotation.y = slot.ry;
roomGroup.add(mesh);
meshes.push(mesh);
summary.push({ kind: 'sign', wall: slot.wall, text: txt, x: round(slot.x), z: round(slot.z) });
}
return { meshes, summary };
}
function makeSignPlane(ctx, text) {
const cv = document.createElement('canvas'); cv.width = 256; cv.height = 64;
const x = cv.getContext('2d');
x.fillStyle = '#f5f2ea'; x.fillRect(0, 0, 256, 64);
x.strokeStyle = '#333'; x.lineWidth = 3; x.strokeRect(3, 3, 250, 58);
x.fillStyle = '#c1121f'; x.font = 'bold 30px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText(String(text).slice(0, 18), 128, 34);
const tex = ctx.canvasTexture(cv);
const m = ctx.mat('#ffffff', 0.7); m.map = tex; m.needsUpdate = true;
const mesh = ctx.plane(1.3, 0.32, m);
return mesh;
}
// ── small utils ─────────────────────────────────────────────────────────────────────
function idxOf(grid, x, z) { const [cx, cz] = grid.cellOf(x, z); return cz * grid.cols + cx; }
function reaches(grid, startIdx, targetIdx) {
if (grid.occ[startIdx] !== 0) { const s = nearestFree(grid, startIdx); if (s < 0) return false; startIdx = s; }
if (grid.occ[targetIdx] !== 0) { const t = nearestFree(grid, targetIdx); if (t < 0) return false; targetIdx = t; }
return floodFill(grid, startIdx).has(targetIdx);
}
function nearestFree(grid, idx) {
const cx0 = idx % grid.cols, cz0 = (idx / grid.cols) | 0;
for (let rad = 1; rad < 6; rad++)
for (let dz = -rad; dz <= rad; dz++) for (let dx = -rad; dx <= rad; dx++) {
const nx = cx0 + dx, nz = cz0 + dz;
if (nx < 1 || nz < 1 || nx >= grid.cols - 1 || nz >= grid.rows - 1) continue;
if (grid.occ[nz * grid.cols + nx] === 0) return nz * grid.cols + nx;
}
return -1;
}
// Clear a Manhattan corridor between two cells (last-resort guarantee).
function carveCorridor(grid, startIdx, targetIdx) {
let cx = startIdx % grid.cols, cz = (startIdx / grid.cols) | 0;
const tx = targetIdx % grid.cols, tz = (targetIdx / grid.cols) | 0;
const clear = (x, z) => { if (x > 0 && z > 0 && x < grid.cols - 1 && z < grid.rows - 1) grid.occ[z * grid.cols + x] = 0; };
while (cz !== tz) { clear(cx, cz); cz += Math.sign(tz - cz); }
while (cx !== tx) { clear(cx, cz); cx += Math.sign(tx - cx); }
clear(tx, tz);
}
const round = (v) => Math.round(v * 1000) / 1000;

213
web/js/interiors/shell.js Normal file
View File

@ -0,0 +1,213 @@
// 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);
}

213
web/js/interiors/stock.js Normal file
View File

@ -0,0 +1,213 @@
// PROCITY Lane C — visual stock (v1). Fills the fittings' `slots` with product silhouettes:
// canvas-texture cardboard sleeves / spines / boxes / snacks / magazines with price stickers
// (the dig.js sleeve trick — coloured cardboard + sticker). Real item data is the content phase;
// this stays purely cosmetic and byte-identical per seed.
//
// stock.fill(ctx, fitting, { shop, recipe, stockKind, adapter }, r)
// iterates fitting.slots and adds child meshes into fitting.group.
//
// stockAdapter hook (content phase): `adapter(shop, slotKind) => { texture } | { mesh } | null`.
// If it returns a texture, that texture skins the item face; a mesh replaces the item entirely;
// null (default) → procedural placeholder. BaseGod / GODVERSE data plugs in here without touching
// layout or fittings code.
//
// PERF + LEAK SAFETY: face textures are POOLED per room (a small seeded set reused across many
// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
import { pick } from './context.js';
// Muted 90s stock palette (cardboard, plastic, faded card).
const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
const SPINE = ['#7a2a2a', '#2a4a7a', '#2a6a4a', '#7a5a2a', '#5a2a6a', '#2a5a6a', '#6a6a2a', '#7a3a5a'];
const SNACK = ['#e04a3a', '#f0b428', '#3a9ae0', '#4ac04a', '#e04a9a', '#f07028', '#a04ae0'];
// ── per-room face-texture pool ──────────────────────────────────────────────────────
// A pooled canvas "face" for a stock kind: coloured card + a little price sticker + label bars.
function makeFace(ctx, kind, r) {
const cv = document.createElement('canvas');
cv.width = 64; cv.height = kind === 'spine' ? 128 : 64;
const x = cv.getContext('2d');
const pal = kind === 'spine' ? SPINE : kind === 'snack' ? SNACK : CARD;
const base = pick(r, pal);
x.fillStyle = base; x.fillRect(0, 0, cv.width, cv.height);
// faded border
x.strokeStyle = 'rgba(0,0,0,0.18)'; x.lineWidth = 3; x.strokeRect(2, 2, cv.width - 4, cv.height - 4);
if (kind === 'spine') {
// title bars down the spine
x.fillStyle = 'rgba(255,255,255,0.82)';
for (let i = 0; i < 3; i++) x.fillRect(10, 18 + i * 30 + (r() * 6 | 0), cv.width - 20, 8);
x.fillStyle = 'rgba(0,0,0,0.3)'; x.fillRect(8, cv.height - 20, cv.width - 16, 10);
} else {
// a cover blob + label lines
x.fillStyle = 'rgba(255,255,255,0.16)';
x.beginPath(); x.arc(32 + (r() - 0.5) * 16, 26, 12 + r() * 6, 0, 6.3); x.fill();
x.fillStyle = 'rgba(255,255,255,0.75)';
x.fillRect(8, 44, 34, 5); x.fillRect(8, 52, 22, 4);
}
// the op-shop price sticker — a fluoro dot with a $ scrawl
const sx = cv.width - 18, sy = 14;
x.fillStyle = '#f4e11a'; x.beginPath(); x.arc(sx, sy, 9, 0, 6.3); x.fill();
x.fillStyle = '#c1121f'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText('$' + (1 + (r() * 9 | 0)), sx, sy + 1);
return ctx.canvasTexture(cv);
}
function facePool(ctx, kind, r, n = 6) {
const key = `_facePool_${kind}`;
if (!ctx[key]) { ctx[key] = []; for (let i = 0; i < n; i++) ctx[key].push(makeFace(ctx, kind, r)); }
return ctx[key];
}
// A garment silhouette (ported from 90sDJsim garmentCanvas), pooled per room.
const GARMENTS = ['tee', 'hoodie', 'jacket', 'dress', 'pants', 'shirt'];
function makeGarment(ctx, r) {
const shape = GARMENTS[(r() * GARMENTS.length) | 0];
const color = new ctx.THREE.Color().setHSL(r(), 0.45, 0.4 + r() * 0.2);
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 160;
const x = cv.getContext('2d');
const col = '#' + color.getHexString();
const dark = '#' + color.clone().offsetHSL(0, 0.05, -0.14).getHexString();
x.fillStyle = col; x.strokeStyle = dark; x.lineWidth = 4; x.lineJoin = 'round';
const P = pts => { x.beginPath(); x.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) x.lineTo(pts[i][0], pts[i][1]); x.closePath(); x.fill(); x.stroke(); };
if (shape === 'pants') P([[40, 8], [88, 8], [90, 152], [70, 152], [64, 62], [58, 152], [38, 152]]);
else if (shape === 'dress') { P([[48, 20], [80, 20], [104, 152], [24, 152]]); x.fillStyle = dark; x.beginPath(); x.ellipse(64, 22, 11, 6, 0, 0, 6.3); x.fill(); }
else {
P([[16, 46], [46, 26], [46, 60], [22, 78]]); P([[112, 46], [82, 26], [82, 60], [106, 78]]);
P([[42, 24], [86, 24], [86, 142], [42, 142]]);
x.fillStyle = dark; x.beginPath(); x.ellipse(64, 26, 12, 7, 0, 0, 6.3); x.fill();
if (shape === 'hoodie') { x.fillStyle = col; x.beginPath(); x.arc(64, 24, 15, Math.PI, 0); x.fill(); x.stroke(); }
}
x.fillStyle = 'rgba(255,255,255,0.22)'; x.fillRect(55, 62, 18, 20);
return ctx.canvasTexture(cv);
}
function garmentPool(ctx, r, n = 8) {
if (!ctx._garmentPool) { ctx._garmentPool = []; for (let i = 0; i < n; i++) ctx._garmentPool.push(makeGarment(ctx, r)); }
return ctx._garmentPool;
}
// ── fill entry point ─────────────────────────────────────────────────────────────────
export function fill(ctx, fitting, opts, r) {
for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r);
}
function fillSlot(ctx, parent, slot, opts, r) {
switch (slot.kind) {
case 'sleeve': return binFan(ctx, parent, slot, opts, r);
case 'garment': return hang(ctx, parent, slot, opts, r);
case 'treasure': return treasures(ctx, parent, slot, opts, r);
default: return shelfLine(ctx, parent, slot, opts, r); // box / spine / snack / magazine
}
}
// Ask the adapter for a face texture (content phase); null → pooled procedural face.
function faceTex(ctx, kind, opts, r) {
if (opts.adapter) {
const got = opts.adapter(opts.shop, kind);
if (got && got.texture) return got.texture;
}
return pick(r, facePool(ctx, kind === 'magazine' ? 'card' : kind, r));
}
// Packed leaning sleeves in a bin/crate; front few wear covers (dig.js look).
function binFan(ctx, parent, slot, opts, r) {
const n = slot.count || 12;
const fw = slot.faceW || 0.3, h = slot.height || 0.3, run = slot.run || 0.5, tilt = slot.tilt ?? -0.4;
const step = run / Math.max(1, n);
const pool = facePool(ctx, 'card', r);
for (let i = 0; i < n; i++) {
const z = -run / 2 + (i + 0.5) * step;
const front = i >= n - 4; // front sleeves show a cover
const faceMat = front ? ctx.mat('#ffffff', 0.7) : ctx.mat(pick(r, CARD), 0.85);
if (front) { faceMat.map = pick(r, pool); faceMat.color.set('#ffffff'); faceMat.needsUpdate = true; }
const sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent);
sl.rotation.x = tilt + (r() - 0.5) * 0.05;
sl.rotation.z = (r() - 0.5) * 0.04;
}
}
// Neat row along local X. Handles box (upright), spine (edge-out), snack (small), magazine (leaning).
function shelfLine(ctx, parent, slot, opts, r) {
const kind = slot.kind;
const run = slot.run || 0.8, depth = slot.depth || 0.3, clear = slot.height || 0.3;
const scatter = slot.scatter;
const n = Math.max(1, slot.count || 4);
const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r);
if (scatter) { // tables / pegboards: random small goods
for (let i = 0; i < n; i++) {
const s = 0.09 + r() * 0.11;
const m = ctx.mat(pick(r, CARD), 0.85);
if (r() < 0.5) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; }
const b = ctx.box(s, slot.wall ? s * 1.4 : s, slot.wall ? 0.03 : s, m,
slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2),
slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent);
b.rotation.y = (r() - 0.5) * 0.6;
}
return;
}
if (kind === 'spine') { // books / VHS: thin spines edge-out
const th = Math.min(0.03, run / (n + 2));
const step = (run - 0.04) / n;
for (let i = 0; i < n; i++) {
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
const bh = clear * (0.8 + r() * 0.2);
const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent);
if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
}
return;
}
if (kind === 'magazine') { // leaning mag faces
const step = run / n;
for (let i = 0; i < n; i++) {
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
const b = ctx.box(step * 0.9, clear * 0.9, 0.01, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + clear * 0.45, slot.z, parent);
b.rotation.x = slot.tilt ?? -0.35;
}
return;
}
// default 'box' / 'snack': upright cartons in a row, faces forward (+Z)
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
const step = run / n;
for (let i = 0; i < n; i++) {
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
const m = ctx.mat('#ffffff', 0.7);
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
const side = ctx.mat(pick(r, kind === 'snack' ? SNACK : CARD), 0.85);
const g = ctx.boxGeo(bw * 0.88, bh, bd); // shared geometry
const mesh = new ctx.THREE.Mesh(g, [side, side, side, side, m, side]); // face (+Z) wears the cover
mesh.position.set(slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01);
parent.add(mesh); // face `m` + `side` already tracked via ctx.mat; geometry via ctx.geom
}
}
// Garments hung along a rail.
function hang(ctx, parent, slot, opts, r) {
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
const pool = garmentPool(ctx, r);
for (let i = 0; i < n; i++) {
const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1));
const m = ctx.mat('#ffffff', 0.92, { transparent: true, alphaTest: 0.4, side: ctx.THREE.DoubleSide });
m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
const g = ctx.planeGeo(0.34, h);
const p = new ctx.THREE.Mesh(g, m);
p.position.set(gx, slot.y - 0.05 - h / 2, slot.z + (r() - 0.5) * 0.03);
p.rotation.y = (r() - 0.5) * 0.15;
parent.add(p);
}
}
// Small treasures on a glass-case shelf.
function treasures(ctx, parent, slot, opts, r) {
const n = slot.count || 4, run = slot.run || 1.0;
const cols = ['#c8a13a', '#b0b0b8', '#8a5a3a', '#3a6a8a', '#8a3a5a'];
for (let i = 0; i < n; i++) {
const s = 0.06 + r() * 0.06;
const b = ctx.box(s * (1 + r()), s, s, ctx.mat(pick(r, cols), 0.4, { metalness: 0.3 }),
slot.x - run / 2 + (run) * ((i + 0.5) / n), slot.y + s / 2, slot.z + (r() - 0.5) * (slot.depth || 0.2) * 0.5, parent);
b.rotation.y = r() * Math.PI;
}
}

236
web/js/interiors/theme.js Normal file
View File

@ -0,0 +1,236 @@
// PROCITY Lane C — type theming. Every CITY_SPEC shop type gets a distinct interior recipe so a
// `record` shop instantly reads different from a `toy` shop the moment the door opens.
//
// LANE_C says "import SHOP_TYPES from core/registry.js" — Lane A hasn't landed registry.js yet, so
// this lane stays FULLY STANDALONE: the canonical recipes live here. When registry.js exists, Lane F
// can call `mergeRegistry(SHOP_TYPES)` to let authored registry data override any field. No hard
// dependency, so the test page runs with zero other lanes.
// Fitting `kind`s referenced below must exist in fittings.js FITTINGS registry.
// Zones the layout placer understands: 'window' (front, by glazing), 'aisle' (centre rows),
// 'wall' (perimeter, uses wall-slots), 'centre' (free-standing island), 'counter' (till corner).
//
// Recipe fields:
// archetypeBias : weighted room-shape preference (keys of shell.ARCHETYPES) — 'auto' picks from here
// wallpaperBias : biased pick from web/assets/gen/wall-*.jpg (names without prefix/suffix)
// floorBias : biased pick from carpet/lino/board floor names
// clutter : 0..1 density multiplier (some shops crammed, some sparse)
// counterPos : 'door' | 'forward' | 'corner' | 'back' — where the till lives
// fittings : the mix. Each {kind, zone, min, max, priority}. Counts scale with clutter+area.
// priority: higher = more essential; flood-fill removes low-priority first if the
// door→counter path is ever blocked. Counter is always highest.
// stockKind : what stock.js renders into this shop's carriers
// signFlavour : seeded inside signage strings, themed
export const ARCH = { cosy: 'cosy', gallery: 'gallery', wide: 'wide', hall: 'hall', pokey: 'pokey' };
const RECIPES = {
// ── RECORD ─────────────────────────────────────────────────────────────────────
record: {
label: 'Record Store',
archetypeBias: [['cosy', 3], ['gallery', 2], ['pokey', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage', 'geo-orange'],
floorBias: ['boards-polished', 'carpet-greygreen', 'lino-cork'],
clutter: 0.85,
counterPos: 'corner',
fittings: [
{ kind: 'recordBin', zone: 'aisle', min: 2, max: 5, priority: 5 },
{ kind: 'crate', zone: 'centre', min: 1, max: 3, priority: 3 },
{ kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 },
{ kind: 'listeningCorner', zone: 'window', min: 1, max: 1, priority: 4 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'sleeves',
signFlavour: ['NEW ARRIVALS', 'VINYL $5 EA', 'SORRY NO REFUNDS', '2ND HAND CDs', 'JAZZ · SOUL · FUNK'],
},
// ── OP SHOP ────────────────────────────────────────────────────────────────────
opshop: {
label: 'Op Shop',
archetypeBias: [['cosy', 3], ['wide', 2], ['hall', 1]],
wallpaperBias: ['floral-cream', 'damask-mauve', 'trellis-blue', 'floral-gold'],
floorBias: ['carpet-swirl', 'carpet-mustard', 'lino-check'],
clutter: 1.0,
counterPos: 'corner',
fittings: [
{ kind: 'clothesRack', zone: 'centre', min: 2, max: 5, priority: 5 },
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
{ kind: 'bookshelf', zone: 'wall', min: 1, max: 2, priority: 3 },
{ kind: 'trestleTable', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'garments',
signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'],
},
// ── TOY ────────────────────────────────────────────────────────────────────────
toy: {
label: 'Toy Shop',
archetypeBias: [['wide', 3], ['cosy', 2], ['hall', 1]],
wallpaperBias: ['geo-orange', 'diamond-green', 'trellis-blue'],
floorBias: ['lino-check', 'carpet-greygreen', 'lino-cork'],
clutter: 0.75,
counterPos: 'corner',
fittings: [
{ kind: 'cubeShelf', zone: 'wall', min: 2, max: 4, priority: 5 },
{ kind: 'trestleTable', zone: 'aisle', min: 1, max: 3, priority: 3 },
{ kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 4 },
{ kind: 'metalShelf', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'boxes',
signFlavour: ['NEW! ', 'AS SEEN ON TV', 'PLAY & SAVE', 'BATTERIES EXTRA', 'LAYBY WELCOME'],
},
// ── BOOK ───────────────────────────────────────────────────────────────────────
book: {
label: 'Book Barn',
archetypeBias: [['hall', 3], ['gallery', 2], ['cosy', 1]],
wallpaperBias: ['damask-mauve', 'woodchip-white', 'floral-gold'],
floorBias: ['carpet-greygreen', 'boards-polished', 'carpet-swirl'],
clutter: 0.9,
counterPos: 'corner',
fittings: [
{ kind: 'bookshelf', zone: 'aisle', min: 3, max: 5, priority: 5 },
{ kind: 'bookshelf', zone: 'wall', min: 2, max: 3, priority: 4 },
{ kind: 'spinnerRack', zone: 'centre', min: 1, max: 2, priority: 3 },
{ kind: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'spines',
signFlavour: ['ALL BOOKS $2', 'FICTION AM', 'RARE & COLLECTABLE', 'NO HAGGLING (haggle at till)', 'POETRY ↑'],
},
// ── VIDEO ──────────────────────────────────────────────────────────────────────
video: {
label: 'Video Rental',
archetypeBias: [['gallery', 3], ['cosy', 2], ['wide', 1]],
wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
floorBias: ['carpet-swirl', 'carpet-greygreen', 'lino-cork'],
clutter: 0.85,
counterPos: 'door',
fittings: [
{ kind: 'vhsAisle', zone: 'aisle', min: 2, max: 4, priority: 5 },
{ kind: 'vhsAisle', zone: 'wall', min: 1, max: 2, priority: 4 },
{ kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 },
{ kind: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'spines',
signFlavour: ['NEW RELEASES', 'OVERNIGHT $3', 'BE KIND REWIND', 'WEEKLY $2', 'DROP RETURNS HERE'],
},
// ── PAWN ───────────────────────────────────────────────────────────────────────
pawn: {
label: 'Pawnbroker',
archetypeBias: [['pokey', 3], ['cosy', 2], ['gallery', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage', 'damask-mauve'],
floorBias: ['lino-cork', 'boards-polished', 'lino-check'],
clutter: 0.7,
counterPos: 'forward', // counter is right up front; goods behind it
fittings: [
{ kind: 'glassCase', zone: 'counter', min: 1, max: 3, priority: 6 },
{ kind: 'pegboard', zone: 'wall', min: 1, max: 3, priority: 4 }, // wall hooks
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 3 },
{ kind: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'mixed',
signFlavour: ['WE BUY ANYTHING', 'CASH LOANS', 'GOLD · TOOLS · GEAR', 'ID REQUIRED', 'UNREDEEMED PLEDGES'],
},
// ── MILK BAR ─────────────────────────────────────────────────────────────────────
milkbar: {
label: 'Milk Bar',
archetypeBias: [['pokey', 3], ['cosy', 2]],
wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
floorBias: ['lino-check', 'lino-cork', 'carpet-mustard'],
clutter: 0.8,
counterPos: 'door', // counter faces the door — you're served on the way in
fittings: [
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
{ kind: 'fridge', zone: 'wall', min: 1, max: 3, priority: 6 },
{ kind: 'magazineRack', zone: 'window', min: 1, max: 2, priority: 4 },
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
{ kind: 'cubeShelf', zone: 'aisle', min: 0, max: 1, priority: 2 },
],
stockKind: 'snacks',
signFlavour: ['MILK · BREAD · SMOKES', 'PIES HOT', 'PAPERS IN', 'PHONECARDS', 'NO SCHOOL KIDS AT LUNCH'],
},
// ── DEPT (anchor) ──────────────────────────────────────────────────────────────
dept: {
label: 'Department Store',
archetypeBias: [['hall', 4], ['wide', 1]],
wallpaperBias: ['floral-gold', 'damask-mauve', 'trellis-blue'],
floorBias: ['carpet-greygreen', 'carpet-swirl', 'boards-polished'],
clutter: 0.7,
counterPos: 'corner',
fittings: [
{ kind: 'cubeShelf', zone: 'aisle', min: 2, max: 4, priority: 4 },
{ kind: 'clothesRack', zone: 'centre', min: 2, max: 4, priority: 4 },
{ kind: 'glassCase', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'metalShelf', zone: 'wall', min: 2, max: 4, priority: 3 },
{ kind: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'mixed',
signFlavour: ['MANCHESTER ↑', 'MENSWEAR', 'HABERDASHERY', 'GROUND FLOOR', 'STAFF ONLY'],
},
// ── STALL (market) ───────────────────────────────────────────────────────────────
stall: {
label: 'Market Stall',
archetypeBias: [['wide', 3], ['hall', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage'], // barely walled; open feel
floorBias: ['lino-cork', 'boards-polished'],
clutter: 0.95,
counterPos: 'forward',
fittings: [
{ kind: 'trestleTable', zone: 'aisle', min: 3, max: 6, priority: 5 },
{ kind: 'crate', zone: 'centre', min: 2, max: 5, priority: 3 },
{ kind: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 8 },
],
stockKind: 'boxes',
signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'],
},
};
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.
const ALIAS = {
music: 'record', records: 'record',
charity: 'opshop', second_hand: 'opshop', secondhand: 'opshop', antiques: 'opshop', op_shop: 'opshop',
toys: 'toy', toyshop: 'toy',
books: 'book', bookshop: 'book', bookbarn: 'book',
video_games: 'video', games: 'video', vhs: 'video', rental: 'video',
pawnbroker: 'pawn', pawnshop: 'pawn',
milk_bar: 'milkbar', deli: 'milkbar', corner_store: 'milkbar',
department: 'dept', anchor: 'dept',
market: 'stall', stalls: 'stall',
};
export const SHOP_TYPES = Object.keys(RECIPES);
export function canonicalType(type) {
if (!type) return 'opshop';
const t = String(type).toLowerCase();
if (RECIPES[t]) return t;
if (ALIAS[t]) return ALIAS[t];
return 'opshop'; // sensible default: an op shop takes anything
}
export function getRecipe(type) {
const key = canonicalType(type);
return { key, ...RECIPES[key] };
}
// Lane F hook: let an authored registry override recipe fields without editing this lane.
// registry: { [typeKey]: partialRecipe }. Shallow-merges over the built-in recipe.
export function mergeRegistry(registry) {
if (!registry) return;
for (const [k, patch] of Object.entries(registry)) {
const key = canonicalType(k);
if (RECIPES[key]) Object.assign(RECIPES[key], patch);
}
}