PROCITY/web/js/interiors/dig.js
m3ultra 3724a65505 Lane C R7 C1: ?stock=real — real GODVERSE sleeves in the crates
The stockAdapter seam built in v1 finally eats. Lane E's stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig
riffle behind ?stock=real. Economy stays a stub (buying = toast).

- Contract handshake (task 1, unblocked E): confirmed E's index schema as-is
  in LANE_C_NOTES (items[{id,title,artist,price,price_band,atlas,uv}]; uv
  origin top-left, C flips V for WebGL; fail-soft; per-type packs).
- stockpack.js (new): preloadStockPack/getStockPack (cached) + makeStockAdapter.
  Atlas texture + material are shared/cached (like depot GLBs); each sleeve is
  a cover PLANE sampling the atlas at its baked UV, so all real sleeves in a
  room batch to ONE draw (record: 59 draws).
- stock.js binFan: real cover planes (else parody canvas). dig.js: riffles real
  covers, pull card shows title/artist/$price/band. interiors.js: opts.stock=
  'real' wires the adapter (warn-once + parody fallback if pack absent). Test
  page reads ?stock=real, preloads record/book/toy, passes the adapter to the dig.

Validated (E's staged 24-sample; real covers drop in unchanged when E gets the
DB): determinism 0/30, leak-free rooms + real dig (0/0 over 25 cycles), draws
<=139 all rooms (record 59), fail-soft on no-pack types, ?dig=1&stock=real
leak-free. Flag-off + ?noassets untouched (procedural path byte-identical).
qa.sh --strict GREEN. F smoke hook + E contract in LANE_C_NOTES. Shot
docs/shots/laneC/stock_real_r7.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:23:23 +10:00

332 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane C — crate riffle, ported from 90sDJsim/web/world/dig.js (itself from thriftgod/RecordGod).
// v2 headline behind `?dig=1`: walk to a record bin → flip through its sleeves, pull one, "buy" it.
//
// Scope (round 5): the riffled items are the EXISTING procedural stock — a seeded sleeve list per bin
// (same seed ⇒ same order). Real records feed in later via `stockAdapter(shop, 'sleeve')` (round 6+,
// GODVERSE/mint). **Buy is STUBBED** (a toast + remove-from-bin; getCash returns a constant).
//
// Self-contained: own scene + camera, procedural fwip/thunk audio (no assets), and LEAK-FREE — every
// geometry/material/CanvasTexture minted here is tracked and freed on close()/dispose(). Nothing here
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js';
const MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
// 90s-Australian crate genres → sleeve card colour.
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
const TITLES = ['Sunburnt', 'Back o Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0);
const out = [];
for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0];
out.push({
i, kind: 'record',
a: ARTISTS[(r() * ARTISTS.length) | 0],
t: TITLES[(r() * TITLES.length) | 0],
y: 1971 + ((r() * 28) | 0),
s: g[0], color: g[1],
cond: CONDS[(r() * CONDS.length) | 0],
price: 2 + ((r() * 28) | 0),
rare: r() < 0.12,
});
}
return out;
}
export function createDig(THREE, renderer) {
// ── tracked resources (leak-free) ──
// Persistent (p*): the fixed set — floor, backdrop, shared sleeve geometry — freed only on dispose().
// Per-open (o*): sleeve textures/materials + crate parts — freed on EVERY close() so repeated
// open/close (e.g. the soak entering a dig per interior) returns to baseline.
const pGeo = new Set(), pMat = new Set(), pTex = new Set();
const oGeo = new Set(), oMat = new Set(), oTex = new Set();
const trackGeo = g => (pGeo.add(g), g);
const trackMat = m => (pMat.add(m), m);
const trackTex = t => (pTex.add(t), t);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14110c);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.01, 50);
camera.position.set(0, 0.5, 0.92);
camera.lookAt(0, 0.12, -0.2);
const onResize = () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); };
addEventListener('resize', onResize);
scene.add(new THREE.AmbientLight(0xfff4de, 0.55));
const spot = new THREE.SpotLight(0xfff2e6, 26, 5, 0.7, 0.5, 1.2);
spot.position.set(0.4, 1.5, 0.9); spot.target.position.set(0, 0, -0.3);
scene.add(spot, spot.target);
const floor = new THREE.Mesh(trackGeo(new THREE.BoxGeometry(1.4, 0.02, 1.4)),
trackMat(new THREE.MeshStandardMaterial({ color: 0x2a2318, roughness: 0.9 })));
floor.position.set(0.3, -0.011, -0.35); scene.add(floor);
// a warm gradient backdrop (canvas — no external art), so the sleeves stay the subject
const back = new THREE.Mesh(trackGeo(new THREE.PlaneGeometry(5.5, 3.1)),
trackMat(new THREE.MeshBasicMaterial({ map: backdropTex() })));
back.position.set(0, 0.35, -2.8); scene.add(back);
function backdropTex() {
const cv = document.createElement('canvas'); cv.width = 64; cv.height = 48;
const x = cv.getContext('2d');
const g = x.createLinearGradient(0, 0, 0, 48);
g.addColorStop(0, '#2a2016'); g.addColorStop(1, '#0e0b07');
x.fillStyle = g; x.fillRect(0, 0, 64, 48);
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return trackTex(t);
}
// ── sleeve art (cardboard card + genre + $ sticker) — matches the interior stock aesthetic ──
function sleeveTexture(o) {
const cv = document.createElement('canvas'); cv.width = cv.height = 256;
const c = cv.getContext('2d');
c.fillStyle = o.color; c.fillRect(0, 0, 256, 256);
c.fillStyle = 'rgba(0,0,0,.22)'; c.fillRect(0, 0, 256, 12); c.fillRect(0, 244, 256, 12);
c.strokeStyle = 'rgba(255,255,255,.28)'; c.lineWidth = 3; c.strokeRect(14, 14, 228, 228);
c.fillStyle = 'rgba(255,255,255,.92)'; c.textAlign = 'center';
c.font = '700 22px "Courier New",monospace'; c.fillText(o.a.slice(0, 18), 128, 80);
c.fillStyle = 'rgba(255,255,255,.75)'; c.font = '17px "Courier New",monospace';
c.fillText('“' + o.t + '”', 128, 116);
c.fillStyle = 'rgba(0,0,0,.4)'; c.font = '14px "Courier New",monospace';
c.fillText(`${o.y} · ${o.s} · ${o.cond}`, 128, 210);
c.beginPath(); c.arc(210, 48, 30, 0, 7); c.fillStyle = '#f4e11a'; c.fill(); // price sticker
c.fillStyle = '#c1121f'; c.font = '700 20px Arial'; c.fillText('$' + o.price, 210, 55);
if (o.rare) { c.fillStyle = '#ffd23d'; c.font = '20px monospace'; c.fillText('★', 34, 232); }
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4;
oTex.add(t); return t; // per-open → freed on close()
}
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
const crateWood = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.85 }));
// ── DOM overlay (hint / cursor label / pull panel / walk-out) ──
const el = (tag, cls, txt) => { const e = document.createElement(tag); e.className = cls; if (txt) e.textContent = txt; return e; };
const css = el('style'); css.textContent = `
.pcdg-hint{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#d8d8e0;padding:6px 14px;font:13px "Courier New",monospace;pointer-events:none;display:none;z-index:60}
.pcdg-cur{position:fixed;left:50%;top:64px;transform:translateX(-50%);color:#ffd23d;font:700 15px "Courier New",monospace;text-shadow:0 1px 4px #000;pointer-events:none;text-align:center;max-width:80%;display:none;z-index:60}
.pcdg-panel{position:fixed;right:6%;top:50%;transform:translateY(-50%);width:250px;background:#0e0e16;border:1px solid #ffd23d;padding:16px;color:#d8d8e0;font:14px "Courier New",monospace;display:none;z-index:61}
.pcdg-panel h3{margin:.1em 0;color:#ffd23d;font-size:15px}
.pcdg-panel .meta{color:#8a8a9a;font-size:12px;margin:4px 0 8px}
.pcdg-panel .price{font-size:24px;color:#3dff8b}
.pcdg-panel button{margin-top:12px;width:100%;padding:9px;border:1px solid #666;background:#1d1d2b;color:#d8d8e0;font:inherit;cursor:pointer}
.pcdg-panel button:hover{border-color:#3dff8b;color:#3dff8b}
.pcdg-panel .x{position:absolute;top:6px;right:11px;cursor:pointer;color:#888}
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
.pcdg-toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#0e0e16;border:1px solid #3dff8b;color:#3dff8b;padding:14px 22px;font:700 15px "Courier New",monospace;z-index:62;display:none}`;
document.head.appendChild(css);
const hint = el('div', 'pcdg-hint', 'scroll / drag to riffle · click the front sleeve to pull it · Esc / WALK OUT to leave');
const curLbl = el('div', 'pcdg-cur');
const panel = el('div', 'pcdg-panel');
const outBtn = el('div', 'pcdg-out', '← WALK OUT');
const toast = el('div', 'pcdg-toast');
outBtn.onclick = () => close();
document.body.append(hint, curLbl, panel, outBtn, toast);
// ── procedural audio ──
let actx;
function blip(kind) {
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if (kind === 'fwip') {
const n = Math.floor(actx.sampleRate * 0.05), buf = actx.createBuffer(1, n, actx.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.sin(i * 0.7) * (1 - i / n)) * 0.6; // deterministic-ish fwip (no Math.random)
const s = actx.createBufferSource(); s.buffer = buf;
const f = actx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 2100; f.Q.value = 0.7;
const g = actx.createGain(); g.gain.value = 0.2;
s.connect(f); f.connect(g); g.connect(actx.destination); s.start();
} else {
const o = actx.createOscillator(); o.type = 'sine'; o.frequency.value = 105;
const g = actx.createGain(); g.gain.setValueAtTime(0.0001, actx.currentTime);
g.gain.exponentialRampToValueAtTime(0.5, actx.currentTime + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + 0.28);
o.connect(g); g.connect(actx.destination); o.start(); o.stop(actx.currentTime + 0.3);
}
} catch (e) {}
}
function buildCrate(depth) {
if (crate) { scene.remove(crate); crate = null; }
crate = new THREE.Group();
const [w, h] = SLEEVE;
const W = w + 0.06, H = h * 0.72, D = depth + 0.10, th = 0.016;
const add = (bw, bh, bd, x, y, z) => { const g = new THREE.BoxGeometry(bw, bh, bd); oGeo.add(g); const m = new THREE.Mesh(g, crateWood); m.position.set(x, y, z); crate.add(m); };
add(W, th, D, 0, -th / 2, -D / 2 + 0.05);
add(th, H, D, -W / 2, H / 2, -D / 2 + 0.05); add(th, H, D, W / 2, H / 2, -D / 2 + 0.05);
add(W, H, th, 0, H / 2, -D + 0.05); add(W, H * 0.55, th, 0, H * 0.275, 0.05);
scene.add(crate);
}
function buildStack(list) {
recs.forEach(r => scene.remove(r.group)); recs = [];
const [w, h, th] = SLEEVE;
let z = -0.05;
list.forEach((o, i) => {
const grp = new THREE.Group(); grp.position.set(0, 0, z);
let mesh;
if (o.real) { // ?stock=real: a cover PLANE sampling the pack atlas
const g = new THREE.PlaneGeometry(w, h); oGeo.add(g);
const [u0, v0, u1, v1] = o.pack.uvRect(o.item);
const a = g.attributes.uv; a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); a.needsUpdate = true;
mesh = new THREE.Mesh(g, o.pack.material(o.item)); // shared atlas material (not disposed here)
} else {
const face = new THREE.MeshStandardMaterial({ map: sleeveTexture(o), roughness: 0.75 }); oMat.add(face);
mesh = new THREE.Mesh(sleeveGeo, [sideMat, sideMat, sideMat, sideMat, face, sideMat]); // shared sleeve geo
}
mesh.position.y = h / 2;
grp.add(mesh); grp.rotation.x = BACK;
grp.rotation.z = (((i * 2654435761) >>> 0) % 100 - 50) / 1800; // each sleeve leans its own way (seeded by index)
grp.position.x = (((i * 40503) >>> 0) % 100 - 50) / 12000;
scene.add(grp);
recs.push({ group: grp, offer: o, angle: BACK, homeZ: z });
z -= th + 0.010;
});
buildCrate(Math.max(0.2, -z));
cursor = 0; vel = 0; lastFloor = -1; updateCur();
}
function updateCur() {
const r = recs[Math.round(cursor)];
curLbl.textContent = r ? `${r.offer.a} — “${r.offer.t}` : (recs.length ? '' : 'bins cleaned out');
}
function removeEntry(entry) {
const idx = recs.indexOf(entry); if (idx < 0) return;
scene.remove(entry.group); recs.splice(idx, 1);
let z = -0.05;
recs.forEach(x => { x.homeZ = z; x.group.position.z = z; z -= SLEEVE[2] + 0.010; });
cursor = Math.min(cursor, Math.max(0, recs.length - 1));
pulled = null; panel.style.display = 'none'; updateCur();
}
function showPanel(entry) {
const o = entry.offer;
const cash = getCashCb ? getCashCb() : 999;
panel.innerHTML = `<div class="x">✕</div><h3>${o.a}</h3>`
+ `<div class="meta">“${o.t}” · ${o.y} ${o.s} · ${o.cond}${o.rare ? ' · ★ RARE' : ''}</div>`
+ `<div class="price">$${o.price}</div>`
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'not enough cash' : 'BUY IT'}</button>`;
panel.style.display = 'block';
panel.querySelector('.x').onclick = unpull;
const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => {
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
const ok = onBuyCb ? onBuyCb(o) : true;
if (ok === false) { unpull(); return; }
blip('thunk');
showToast(`SOLD — ${o.a} · $${o.price}`);
removeEntry(entry);
};
}
let toastT = null;
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
function pull() { const r = recs[Math.round(cursor)]; if (!r) return; pulled = r; blip('thunk'); showPanel(r); }
function unpull() { if (pulled) pulled.group.position.set(pulled.group.position.x, 0, pulled.homeZ); pulled = null; panel.style.display = 'none'; updateCur(); }
function update(dt) {
if (!active) return;
vel *= Math.pow(0.0008, dt);
cursor += vel * dt;
if (cursor < 0) { cursor = 0; vel = 0; }
if (cursor > recs.length - 1) { cursor = Math.max(0, recs.length - 1); vel = 0; }
const fl = Math.floor(cursor);
if (fl !== lastFloor) { lastFloor = fl; blip('fwip'); updateCur(); }
recs.forEach((r, i) => {
const f = THREE.MathUtils.smoothstep(cursor - i, -0.4, 1.2);
const target = BACK + f * (MAXFLIP - BACK);
r.angle += (target - r.angle) * Math.min(1, dt * 12);
if (r !== pulled) r.group.rotation.x = r.angle;
});
if (pulled) {
pulled.group.position.lerp(new THREE.Vector3(0, 0.42, 0.55), Math.min(1, dt * 5));
pulled.group.rotation.x += (0 - pulled.group.rotation.x) * Math.min(1, dt * 6);
}
}
// ── input ──
let dragY = null, moved = 0;
const onWheel = e => { if (!active) return; e.preventDefault(); vel += e.deltaY * 0.012; };
const onDown = e => { if (!active) return; dragY = e.clientY; moved = 0; };
const onMove = e => { if (!active || dragY == null) return; const dy = e.clientY - dragY; moved += Math.abs(dy); cursor += dy * 0.02; vel = dy * 0.6; dragY = e.clientY; };
const onUp = () => { if (!active) return; if (moved < 4 && !pulled) pull(); dragY = null; };
const onKey = e => { if (!active) return; if (e.key === 'Escape') { e.stopPropagation(); if (pulled) unpull(); else close(); } };
renderer.domElement.addEventListener('wheel', onWheel, { passive: false });
renderer.domElement.addEventListener('pointerdown', onDown);
renderer.domElement.addEventListener('pointermove', onMove);
renderer.domElement.addEventListener('pointerup', onUp);
document.addEventListener('keydown', onKey, true);
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
function pickRealOffers(pack, seed, n) {
const rr = mulberry32(seed >>> 0), out = [];
for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0];
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: (it.price_band || '').toUpperCase(), y: '', cond: '', rare: it.price_band === 'grail' });
}
return out;
}
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash })
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
const count = opts.count || 16;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers);
if (opts.shopName) curLbl.textContent = opts.shopName + ' — ' + curLbl.textContent;
}
function freeOpen() { // free per-open GPU resources → each open/close is leak-free
recs.forEach(r => scene.remove(r.group)); recs = [];
if (crate) { scene.remove(crate); crate = null; }
for (const g of oGeo) g.dispose(); for (const m of oMat) m.dispose(); for (const t of oTex) t.dispose();
oGeo.clear(); oMat.clear(); oTex.clear();
}
function close() {
if (!active) return;
active = false; pulled = null;
[hint, curLbl, panel, outBtn, toast].forEach(e => e.style.display = 'none');
freeOpen();
const cb = onCloseCb; onCloseCb = null;
if (cb) cb();
}
// full teardown — frees the persistent GPU resources + removes listeners/DOM (call when the shell
// is done with the dig, e.g. leaving interior mode entirely).
function dispose() {
close();
removeEventListener('resize', onResize);
renderer.domElement.removeEventListener('wheel', onWheel);
renderer.domElement.removeEventListener('pointerdown', onDown);
renderer.domElement.removeEventListener('pointermove', onMove);
renderer.domElement.removeEventListener('pointerup', onUp);
document.removeEventListener('keydown', onKey, true);
for (const g of pGeo) g.dispose(); for (const m of pMat) m.dispose(); for (const t of pTex) t.dispose();
pGeo.clear(); pMat.clear(); pTex.clear();
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
}
return { scene, camera, open, close, update, dispose, get active() { return active; } };
}
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
export function binSeed(shopSeed, binId) {
return (xmur3(`dig:${shopSeed}:${binId}`)()) >>> 0;
}