Lane C R5 C2: crate-riffle dig behind ?dig=1 (v2)
Ported 90sDJsim/web/world/dig.js → web/js/interiors/dig.js. Walk to a record bin, press E, riffle its sleeves (scroll/drag), click the front sleeve to pull it, BUY IT. Faithful to the source: hinge-forward riffle, pull-and-inspect panel, procedural fwip/thunk audio. - Riffle items = seeded procedural sleeves per bin via the stockAdapter(shop, 'sleeve') seam (parody 90s-AU records now; real GODVERSE records feed the same seam in round 6+). Same bin ⇒ same order. - Buy is STUBBED (toast + remove-from-bin; getCash returns a constant) — economy is round 6+ per integrator decision #1. - Own scene + camera; leak-free: per-open GPU resources (sleeve textures/ materials, crate parts) freed on every close(), persistent set freed on dispose(). Dig soak (20× enter/leave) → leakGeo 0 / leakTex 0. - Flag-gated (?dig=1): buildInterior is UNTOUCHED, so ?dig=0 boot is byte-identical to v1 (prime law). The dig is wired only in the test page via an E-key raycast; F owns the shell input/mode seam — hook documented in docs/LANES/LANE_C_NOTES.md. Verified: riffle + pull + buy-stub work; dig soak leak-free; placement determinism unaffected; close returns to the interior. qa.sh --strict GREEN. Shot: docs/shots/laneC/dig_riffle_r5.jpg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
93a3168a99
commit
7131f530a4
@ -19,7 +19,16 @@ Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated
|
|||||||
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
|
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
|
||||||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). **→ F: C1 committed, v1.1 can be tagged.**
|
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). **→ F: C1 committed, v1.1 can be tagged.**
|
||||||
|
|
||||||
Still in flight this round: C2 `?dig=1` crate-riffle (v2, behind flag), C3 record re-measure (after E's bake).
|
**C2 — `?dig=1` crate-riffle (v2, behind flag).** Ported 90sDJsim `dig.js` → `web/js/interiors/dig.js`:
|
||||||
|
walk to a record bin, press **E**, riffle its sleeves (scroll/drag), click the front to pull, **BUY IT**
|
||||||
|
(STUBBED — toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin
|
||||||
|
via the `stockAdapter(shop,'sleeve')` seam (procedural fallback now — parody 90s-AU sleeves). Own scene +
|
||||||
|
camera, procedural fwip/thunk audio, **leak-free** (per-open resources freed on close → dig soak 20×
|
||||||
|
`leakGeo 0 / leakTex 0`). Flag-off (`?dig=0`) is **byte-identical** — `buildInterior` is untouched; the dig
|
||||||
|
is wired only in the test page (F owns the shell seam — hook documented in LANE_C_NOTES §"input/mode hook").
|
||||||
|
Shot: [dig_riffle_r5.jpg](docs/shots/laneC/dig_riffle_r5.jpg).
|
||||||
|
|
||||||
|
Still in flight: C3 record re-measure — blocked on Lane E's hero-prop bake landing (don't wait, per §Lane C).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
docs/shots/laneC/dig_riffle_r5.jpg
Normal file
BIN
docs/shots/laneC/dig_riffle_r5.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@ -70,6 +70,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
||||||
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS } from './js/interiors/interiors.js';
|
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS } from './js/interiors/interiors.js';
|
||||||
|
import { createDig, binSeed } from './js/interiors/dig.js';
|
||||||
|
|
||||||
// ── renderer / scene / camera ──────────────────────────────────────────────
|
// ── renderer / scene / camera ──────────────────────────────────────────────
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
@ -98,7 +99,7 @@ for (const t of SHOP_TYPES) { const o = document.createElement('option'); o.valu
|
|||||||
archSel.appendChild(new Option('auto', 'auto'));
|
archSel.appendChild(new Option('auto', 'auto'));
|
||||||
for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
|
for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
|
||||||
|
|
||||||
let current = null; // the live interior
|
let current = null, curSeed = 0; // the live interior + the seed it was built from (dig per-bin seeding)
|
||||||
let gridHelper = null, pathHelper = null;
|
let gridHelper = null, pathHelper = null;
|
||||||
let wire = false, showGrid = false, showPath = false, useGLB = false;
|
let wire = false, showGrid = false, showPath = false, useGLB = false;
|
||||||
let glbManifest = null; // Lane E manifest → GLB props load via the live depot: path (all 22 GLBs published).
|
let glbManifest = null; // Lane E manifest → GLB props load via the live depot: path (all 22 GLBs published).
|
||||||
@ -111,9 +112,11 @@ function parseLot(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rebuild() {
|
function rebuild() {
|
||||||
|
if (dig && dig.active) dig.close();
|
||||||
if (current) current.dispose();
|
if (current) current.dispose();
|
||||||
clearDebug();
|
clearDebug();
|
||||||
const seed = parseInt($('seed').value, 10) >>> 0;
|
const seed = parseInt($('seed').value, 10) >>> 0;
|
||||||
|
curSeed = seed;
|
||||||
const type = typeSel.value;
|
const type = typeSel.value;
|
||||||
const arch = archSel.value === 'auto' ? undefined : archSel.value;
|
const arch = archSel.value === 'auto' ? undefined : archSel.value;
|
||||||
const lot = parseLot($('lot').value);
|
const lot = parseLot($('lot').value);
|
||||||
@ -291,17 +294,55 @@ async function soak(N = 50) {
|
|||||||
}
|
}
|
||||||
$('soakBtn').onclick = () => soak(50);
|
$('soakBtn').onclick = () => soak(50);
|
||||||
|
|
||||||
|
// ── dig (?dig=1) — walk to a record bin, press E to riffle its sleeves (v2, flag-gated) ──
|
||||||
|
const DIG_ON = new URLSearchParams(location.search).has('dig');
|
||||||
|
let dig = null;
|
||||||
|
const _digRay = new THREE.Raycaster();
|
||||||
|
function binUnderAim() {
|
||||||
|
if (!current) return null;
|
||||||
|
const bins = []; current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||||
|
if (!bins.length) return null;
|
||||||
|
_digRay.setFromCamera({ x: 0, y: 0 }, camera);
|
||||||
|
const hit = _digRay.intersectObjects(bins, true)[0];
|
||||||
|
if (hit && hit.distance < 3.2) { let o = hit.object; while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent; return o; }
|
||||||
|
let best = null, bd = 2.4; const cp = camera.position, p = new THREE.Vector3();
|
||||||
|
for (const b of bins) { b.getWorldPosition(p); const d = p.distanceTo(cp); if (d < bd) { bd = d; best = b; } }
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function openDigOn(bin) {
|
||||||
|
if (!dig) dig = createDig(THREE, renderer);
|
||||||
|
if (controls.isLocked) controls.unlock();
|
||||||
|
const p = new THREE.Vector3(); bin.getWorldPosition(p);
|
||||||
|
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic position)
|
||||||
|
dig.open({ seed: binSeed(curSeed, key), count: 16, shopName: current.recipe.label,
|
||||||
|
getCash: () => 50, onBuy: () => true, onClose: () => {} });
|
||||||
|
}
|
||||||
|
if (DIG_ON) {
|
||||||
|
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });
|
||||||
|
document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin';
|
||||||
|
}
|
||||||
|
// enter+leave a dig per record room, assert leak-free (round-5 acceptance)
|
||||||
|
async function digSoak(N = 15) {
|
||||||
|
if (!dig) dig = createDig(THREE, renderer);
|
||||||
|
dig.open({ seed: 1, count: 16 }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
|
||||||
|
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
|
||||||
|
for (let i = 0; i < N; i++) { dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9) }); renderer.render(dig.scene, dig.camera); dig.close(); }
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
return { rooms: N, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
|
||||||
|
}
|
||||||
|
|
||||||
// ── loop ───────────────────────────────────────────────────────────────────────
|
// ── loop ───────────────────────────────────────────────────────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
function frame() {
|
function frame() {
|
||||||
const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
|
const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
|
||||||
move(dt);
|
if (dig && dig.active) { dig.update(dt); renderer.render(dig.scene, dig.camera); }
|
||||||
renderer.render(scene, camera);
|
else { move(dt); renderer.render(scene, camera); }
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
// expose for headless verification (screenshot harness, workflow checks)
|
// expose for headless verification (screenshot harness, workflow checks)
|
||||||
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, rebuild, get current() { return current; }, scene, camera, renderer };
|
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, rebuild, get current() { return current; }, scene, camera, renderer,
|
||||||
|
DIG_ON, digSoak, openDigOn, binUnderAim, get dig() { return dig; } };
|
||||||
|
|
||||||
rebuild();
|
rebuild();
|
||||||
frame();
|
frame();
|
||||||
|
|||||||
311
web/js/interiors/dig.js
Normal file
311
web/js/interiors/dig.js
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
// 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);
|
||||||
|
const face = new THREE.MeshStandardMaterial({ map: sleeveTexture(o), roughness: 0.75 }); oMat.add(face);
|
||||||
|
const 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 ? '' : 'bin’s 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);
|
||||||
|
|
||||||
|
// 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: the stockAdapter seam (real records later) or the seeded procedural sleeves now.
|
||||||
|
offers = (opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve')) || 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user