feat(virtual): spring-physics crate digging — riffle, hinge-flip, pull-to-inspect
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 <noreply@anthropic.com>
This commit is contained in:
parent
8b48010b50
commit
3e3b08dc90
164
webstore/dig.js
Normal file
164
webstore/dig.js
Normal file
@ -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 = `<div class="x">✕</div><h3>${d.title || d.sku}</h3>`
|
||||
+ `<div class="meta">${d.artist || ''}</div>`
|
||||
+ `<div class="price">$${d.price ?? '—'}</div>`
|
||||
+ `<div class="meta">condition ${d.condition || '—'} · DealGod value: <i>— (key wire pending)</i></div>`
|
||||
+ `<button>Add to cart</button>`;
|
||||
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; } };
|
||||
}
|
||||
@ -32,6 +32,7 @@
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
||||
import { createDig } from './dig.js';
|
||||
|
||||
const num = (v, d = 0) => (v == null || isNaN(+v) ? d : +v);
|
||||
const rad = THREE.MathUtils.degToRad;
|
||||
@ -50,10 +51,11 @@ addEventListener('resize', () => {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
const dig = createDig(renderer, { onClose: () => start.style.display = 'flex' });
|
||||
const start = document.getElementById('start');
|
||||
start.onclick = () => controls.lock();
|
||||
controls.addEventListener('lock', () => start.style.display = 'none');
|
||||
controls.addEventListener('unlock', () => start.style.display = 'flex');
|
||||
controls.addEventListener('unlock', () => { if (!dig.active) start.style.display = 'flex'; });
|
||||
|
||||
// --- movement ---
|
||||
const keys = {};
|
||||
@ -134,20 +136,13 @@ fetch('/virtual/scene').then(r => r.json()).then(data => {
|
||||
|
||||
// --- dig: click a crate (raycast from screen centre) ---
|
||||
const ray = new THREE.Raycaster();
|
||||
const digEl = document.getElementById('dig');
|
||||
renderer.domElement.addEventListener('click', () => {
|
||||
if (!controls.isLocked) return;
|
||||
if (!controls.isLocked || dig.active) return;
|
||||
ray.setFromCamera(new THREE.Vector2(0, 0), camera);
|
||||
const hit = ray.intersectObjects(crateMeshes, false)[0];
|
||||
if (hit && hit.distance < 3) {
|
||||
const { crateId, name } = hit.object.userData;
|
||||
digEl.style.display = 'block';
|
||||
digEl.textContent = `digging crate #${crateId} ${name ? '· ' + name : ''}…`;
|
||||
fetch(`/virtual/crate/${crateId}/records`).then(r => r.json()).then(d => {
|
||||
digEl.textContent = `crate #${crateId}: ${d.records.length} records — ${d.records.slice(0,3).map(r=>r.title||r.sku).join(' · ')}`;
|
||||
setTimeout(() => digEl.style.display = 'none', 4000);
|
||||
// TODO(next): spring-physics flip view — see RECORDGOD_PLAN §digging
|
||||
});
|
||||
if (hit && hit.distance < 3.5) {
|
||||
dig.open(hit.object.userData.crateId); // enter the spring-physics dig view
|
||||
controls.unlock();
|
||||
}
|
||||
});
|
||||
|
||||
@ -156,6 +151,7 @@ const clock = new THREE.Clock();
|
||||
function tick() {
|
||||
requestAnimationFrame(tick);
|
||||
const dt = Math.min(clock.getDelta(), 0.1);
|
||||
if (dig.active) { dig.update(dt); renderer.render(dig.scene, dig.camera); return; }
|
||||
if (controls.isLocked) {
|
||||
const speed = 3 * dt;
|
||||
vel.set((keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0), 0, (keys.KeyS ? 1 : 0) - (keys.KeyW ? 1 : 0));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user