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