diff --git a/C-progress.md b/C-progress.md new file mode 100644 index 0000000..9959103 --- /dev/null +++ b/C-progress.md @@ -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). diff --git a/docs/shots/laneC/.gitkeep b/docs/shots/laneC/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/shots/laneC/_grid_9types_5archetypes.jpg b/docs/shots/laneC/_grid_9types_5archetypes.jpg new file mode 100644 index 0000000..8cd7f04 Binary files /dev/null and b/docs/shots/laneC/_grid_9types_5archetypes.jpg differ diff --git a/web/interior_test.html b/web/interior_test.html new file mode 100644 index 0000000..8866ce0 --- /dev/null +++ b/web/interior_test.html @@ -0,0 +1,284 @@ + + + + + +PROCITY · Lane C — Interior Test + + + +
+
+

PROCITY · Lane C

+
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
+
Click to walk · WASD move · mouse look · Esc release · G grid · F wireframe
+ + + + + diff --git a/web/js/interiors/context.js b/web/js/interiors/context.js new file mode 100644 index 0000000..7e45ed8 --- /dev/null +++ b/web/js/interiors/context.js @@ -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]; +} diff --git a/web/js/interiors/fittings.js b/web/js/interiors/fittings.js new file mode 100644 index 0000000..381bef8 --- /dev/null +++ b/web/js/interiors/fittings.js @@ -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; +} diff --git a/web/js/interiors/glb.js b/web/js/interiors/glb.js new file mode 100644 index 0000000..d1a8b03 --- /dev/null +++ b/web/js/interiors/glb.js @@ -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); +} diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js new file mode 100644 index 0000000..5312206 --- /dev/null +++ b/web/js/interiors/interiors.js @@ -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; +} diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js new file mode 100644 index 0000000..fb57f7f --- /dev/null +++ b/web/js/interiors/layout.js @@ -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; diff --git a/web/js/interiors/shell.js b/web/js/interiors/shell.js new file mode 100644 index 0000000..a83e118 --- /dev/null +++ b/web/js/interiors/shell.js @@ -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-.jpg ; wallpapers under wall-.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); +} diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js new file mode 100644 index 0000000..6186b6a --- /dev/null +++ b/web/js/interiors/stock.js @@ -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; + } +} diff --git a/web/js/interiors/theme.js b/web/js/interiors/theme.js new file mode 100644 index 0000000..14d54f0 --- /dev/null +++ b/web/js/interiors/theme.js @@ -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); + } +}