From 3e3b08dc90cd67272aa1b2cbb91fb1fa4eb12418 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 19 Jun 2026 18:35:56 +1000 Subject: [PATCH] =?UTF-8?q?feat(virtual):=20spring-physics=20crate=20diggi?= =?UTF-8?q?ng=20=E2=80=94=20riffle,=20hinge-flip,=20pull-to-inspect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dig.js: enter a crate → records stand packed in a bin; scroll/drag riffles a damped cursor through them; each sleeve hinges forward at its bottom edge as you pass (smoothstep wave); tap the front sleeve to pull it out and inspect (price/condition/add-to-cart panel). Depth-LOD cover loading (±8 around cursor). Procedural fwip/thunk audio, no asset files. Wired into index.html: click crate → dig scene; render loop branches on dig.active. Co-Authored-By: Claude Opus 4.8 --- webstore/dig.js | 164 ++++++++++++++++++++++++++++++++++++++++++++ webstore/index.html | 20 +++--- 2 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 webstore/dig.js diff --git a/webstore/dig.js b/webstore/dig.js new file mode 100644 index 0000000..4ce8e5f --- /dev/null +++ b/webstore/dig.js @@ -0,0 +1,164 @@ +import * as THREE from 'three'; + +// Crate-digging: a self-contained inspect scene. Enter on a crate → records stand packed in a +// bin; scroll/drag riffles a damped "cursor" through them; each sleeve hinges forward at its +// bottom edge as you pass it; tap the front sleeve to pull it out and inspect (price/condition/ +// add-to-cart). Procedural fwip/thunk audio — no asset files. + +const REC_W = 0.31, REC_H = 0.31, GAP = 0.013, MAXFLIP = 1.95, BACK = -0.12; + +export function createDig(renderer, { onClose } = {}) { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0a0a0c); + 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); + + scene.add(new THREE.AmbientLight(0xffffff, 0.45)); + 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(new THREE.BoxGeometry(0.46, 0.02, 1.1), + new THREE.MeshStandardMaterial({ color: 0x16161a, roughness: 0.9 })); + floor.position.set(0, -0.011, -0.45); scene.add(floor); + + const loader = new THREE.TextureLoader(); loader.crossOrigin = 'anonymous'; + let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1; + + function 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 = ` + .dg-hint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:rgba(0,0,0,.6);color:#eee;padding:6px 14px;border-radius:20px;font:13px system-ui;pointer-events:none;display:none} + .dg-cur{position:fixed;left:50%;top:22px;transform:translateX(-50%);color:#fff;font:500 16px system-ui;text-shadow:0 1px 4px #000;pointer-events:none;text-align:center;max-width:80%;display:none} + .dg-panel{position:fixed;right:6%;top:50%;transform:translateY(-50%);width:280px;background:rgba(16,16,20,.94);border:1px solid #333;border-radius:12px;padding:18px;color:#eee;font:14px system-ui;display:none} + .dg-panel h3{margin:.1em 0;font-weight:500;color:#ff5db1} + .dg-panel .meta{color:#aaa;font-size:13px;margin:4px 0 10px} + .dg-panel .price{font-size:24px;font-weight:500} + .dg-panel button{margin-top:14px;width:100%;padding:10px;border:0;border-radius:8px;background:#ff5db1;color:#1a0a14;font:500 14px system-ui;cursor:pointer} + .dg-panel .x{position:absolute;top:8px;right:13px;cursor:pointer;color:#888;font-size:18px}`; + document.head.appendChild(css); + const hint = el('div', 'dg-hint', 'scroll / drag to flip · click a sleeve to pull · Esc to step back'); + const curLbl = el('div', 'dg-cur'); + const panel = el('div', 'dg-panel'); + document.body.append(hint, curLbl, panel); + + // --- 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.random() * 2 - 1) * Math.pow(1 - i / n, 2); + 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.22; + 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 buildStack(records) { + recs.forEach(r => scene.remove(r.group)); recs = []; + records.forEach((rec, i) => { + const grp = new THREE.Group(); grp.position.set(0, 0, -i * GAP - 0.05); + const mat = new THREE.MeshStandardMaterial({ color: 0x2b2b33, roughness: 0.75 }); + const mesh = new THREE.Mesh(new THREE.BoxGeometry(REC_W, REC_H, 0.0035), mat); + mesh.position.y = REC_H / 2; // hinge at group origin = bin floor + grp.add(mesh); grp.rotation.x = BACK; + scene.add(grp); + recs.push({ group: grp, mat, rec, angle: BACK, loaded: false, homeZ: -i * GAP - 0.05 }); + }); + cursor = 0; vel = 0; lastFloor = -1; loadCoversNear(); updateCur(); + } + + function loadCoversNear() { + const c = Math.round(cursor); + for (let i = Math.max(0, c - 8); i < Math.min(recs.length, c + 9); i++) { + const r = recs[i]; if (r.loaded) continue; r.loaded = true; + const url = r.rec.thumb; if (!url) continue; + loader.load(url, tx => { tx.colorSpace = THREE.SRGBColorSpace; r.mat.map = tx; r.mat.color.set(0xffffff); r.mat.needsUpdate = true; }, undefined, () => {}); + } + } + + function updateCur() { + const r = recs[Math.round(cursor)]; + curLbl.textContent = r ? `${r.rec.title || r.rec.sku}${r.rec.artist ? ' — ' + r.rec.artist : ''}` : ''; + } + + function pull() { + const r = recs[Math.round(cursor)]; if (!r) return; + pulled = r; r.pullT = 0; blip('thunk'); + const d = r.rec; + panel.innerHTML = `

${d.title || d.sku}

` + + `
${d.artist || ''}
` + + `
$${d.price ?? '—'}
` + + `
condition ${d.condition || '—'} · DealGod value: — (key wire pending)
` + + ``; + panel.style.display = 'block'; + panel.querySelector('.x').onclick = unpull; + panel.querySelector('button').onclick = () => { curLbl.textContent = 'added: ' + (d.title || d.sku); }; + } + + function unpull() { + if (!pulled) return; + pulled.group.position.set(0, 0, pulled.homeZ); + pulled = null; panel.style.display = 'none'; updateCur(); + } + + function update(dt) { + if (!active) return; + vel *= Math.pow(0.0008, dt); // damped momentum + cursor += vel * dt; + if (cursor < 0) { cursor = 0; vel = 0; } + if (cursor > recs.length - 1) { cursor = recs.length - 1; vel = 0; } + const fl = Math.floor(cursor); + if (fl !== lastFloor) { lastFloor = fl; blip('fwip'); loadCoversNear(); updateCur(); } + recs.forEach((r, i) => { + const f = THREE.MathUtils.smoothstep(cursor - i, -0.4, 1.2); // 0 upright → 1 flipped forward + 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.pullT = Math.min(1, pulled.pullT + dt * 3); + 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 (guarded by active) --- + let dragY = null, moved = 0; + function onWheel(e) { if (!active) return; e.preventDefault(); vel += e.deltaY * 0.012; } + function onDown(e) { if (!active) return; dragY = e.clientY; moved = 0; } + function 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; } + function onUp() { if (!active) return; if (moved < 4 && !pulled) pull(); dragY = null; } + function 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); + + async function open(crateId) { + active = true; hint.style.display = 'block'; curLbl.style.display = 'block'; + try { + const res = await fetch(`/virtual/crate/${crateId}/records`).then(r => r.json()); + buildStack(res.records || []); + } catch (e) { curLbl.textContent = 'failed to load crate'; } + } + + function close() { + active = false; pulled = null; + [hint, curLbl, panel].forEach(e => e.style.display = 'none'); + recs.forEach(r => scene.remove(r.group)); recs = []; + if (onClose) onClose(); + } + + return { scene, camera, open, update, close, get active() { return active; } }; +} diff --git a/webstore/index.html b/webstore/index.html index 542cd37..bfbe2d0 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -32,6 +32,7 @@