RECORDGOD/webstore/dig.js
type-two d0c91faf79 feat(dig): port THRIFTGOD's evolved crate-flip back into /store
Per docs/DIG_FLIP_HANDOVER.md — same createDig contract, zero index.html changes:
- real format sizes + packing by thickness (FMT map; LP-only today, CDs inevitable)
- secondhand lean: per-sleeve seeded jitter (rotation.z + position.x)
- crate drawn around the stack with the lower front lip you flip over
- covers trickle-load (fillQueue 120ms) + near-cursor priority window (-10..+15)
- pull presents at arm's length on the view axis (-0.1, 0.23-h/2, 0.12, rot -0.33)
- Add to cart -> localStorage rg_cart (SKU-keyed, matches /shop/checkout), then
  the stack re-packs closed — no gap
- fix: dig camera aspect was NaN if the window loaded 0x0 and never resized;
  guard + resize listener (latent in ancestor + THRIFTGOD)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:07:28 +10:00

236 lines
12 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.

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.
// Mechanics + tuned numbers ported back from THRIFTGOD's evolved fork (docs/DIG_FLIP_HANDOVER.md).
const MAXFLIP = 1.95, BACK = -0.12;
// sleeve [width, height, thickness] per format — records, CDs, DVDs, tapes all riffle the same
const FMT = { lp: [0.31, 0.31, 0.0035], cd: [0.142, 0.125, 0.010], dvd: [0.136, 0.19, 0.014],
vhs: [0.105, 0.187, 0.025], cass: [0.11, 0.07, 0.017] };
export function createDig(renderer, { onClose } = {}) {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0c);
const camera = new THREE.PerspectiveCamera(45, (innerWidth / innerHeight) || 1, 0.01, 50);
camera.position.set(0, 0.5, 0.92);
camera.lookAt(0, 0.12, -0.2);
addEventListener('resize', () => { // index.html's resize handler only feeds the walk camera
camera.aspect = (innerWidth / innerHeight) || 1; camera.updateProjectionMatrix();
});
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;z-index:7}
.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) {}
}
// the crate drawn around the stack — floor/left/right/back + a LOWER front lip you flip over
let crate = null;
function buildCrate(maxW, maxH, depth) {
if (crate) scene.remove(crate);
crate = new THREE.Group();
// ponytail: plain wood colour — no wood texture in webstore/assets; add one + RepeatWrapping ×2 if it looks flat
const wood = new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.85 });
const W = maxW + 0.06, H = maxH * 0.72, D = depth + 0.10, th = 0.016;
const add = (w, h, d, x, y, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wood); m.position.set(x, y, z); crate.add(m); };
add(W, th, D, 0, -th / 2, -D / 2 + 0.05); // floor
add(th, H, D, -W / 2, H / 2, -D / 2 + 0.05); // left
add(th, H, D, W / 2, H / 2, -D / 2 + 0.05); // right
add(W, H, th, 0, H / 2, -D + 0.05); // back
add(W, H * 0.55, th, 0, H * 0.275, 0.05); // front lip (lower — you flip over it)
scene.add(crate);
}
function buildStack(records) {
recs.forEach(r => scene.remove(r.group)); recs = [];
let z = -0.05, maxW = 0.31, maxH = 0.31;
records.forEach((rec, i) => {
const [w, h, th] = FMT[rec.fmt] || FMT.lp;
maxW = Math.max(maxW, w); maxH = Math.max(maxH, h);
const grp = new THREE.Group(); grp.position.set(0, 0, z);
const mat = new THREE.MeshStandardMaterial({ color: 0x2b2b33, roughness: 0.75 });
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, th), mat);
mesh.position.y = h / 2; // hinge at group origin = bin floor
grp.add(mesh); grp.rotation.x = BACK;
// secondhand records don't stand to attention — every sleeve leans its own way
grp.rotation.z = (((i * 2654435761) >>> 0) % 100 - 50) / 1800;
grp.position.x = (((i * 40503) >>> 0) % 100 - 50) / 12000;
scene.add(grp);
recs.push({ group: grp, mat, rec, angle: BACK, loaded: false, homeZ: z });
z -= th + 0.010; // packed by real thickness — VHS riffles chunkier than vinyl
});
buildCrate(maxW, maxH, -z);
cursor = 0; vel = 0; lastFloor = -1; loadCoversNear(); updateCur();
fillQueue(); // trickle-load EVERY cover so the crate fills in behind you
}
let fillTimer = null;
function fillQueue() {
clearTimeout(fillTimer);
const next = recs.find(r => !r.loaded);
if (!next) return;
loadCover(next);
fillTimer = setTimeout(fillQueue, 120);
}
function loadCover(r) {
if (r.loaded) return;
r.loaded = true;
const url = r.rec.thumb; if (!url) return;
loader.load(url, tx => { tx.colorSpace = THREE.SRGBColorSpace; r.mat.map = tx; r.mat.color.set(0xffffff); r.mat.needsUpdate = true; }, undefined, () => {});
}
function loadCoversNear() {
const c = Math.round(cursor);
for (let i = Math.max(0, c - 10); i < Math.min(recs.length, c + 15); i++) loadCover(recs[i]);
}
function updateCur() {
const r = recs[Math.round(cursor)];
curLbl.textContent = r ? `${r.rec.title || r.rec.sku}${r.rec.artist ? ' — ' + r.rec.artist : ''}` : 'crate\'s empty — keep browsing';
}
// re-pack the survivors after a sleeve leaves the crate — no gap left behind
function repack() {
let z = -0.05;
recs.forEach(x => { x.homeZ = z; x.group.position.z = z; z -= (FMT[x.rec.fmt] || FMT.lp)[2] + 0.010; });
}
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 || '—'}</div>`
+ `<button>Add to cart</button>`;
panel.style.display = 'block';
panel.querySelector('.x').onclick = unpull;
panel.querySelector('button').onclick = () => {
// client cart, SKU-keyed — /shop/checkout re-prices server-side and rejects sold stock
try {
const cart = JSON.parse(localStorage.getItem('rg_cart') || '[]');
if (!cart.some(x => x.sku === d.sku)) {
cart.push({ sku: d.sku, release_id: d.release_id, title: d.title, artist: d.artist, price: d.price, thumb: d.thumb });
localStorage.setItem('rg_cart', JSON.stringify(cart));
}
} catch (e) {}
const i = recs.indexOf(r);
scene.remove(r.group); recs.splice(i, 1);
repack();
pulled = null; panel.style.display = 'none';
cursor = Math.min(cursor, Math.max(0, recs.length - 1));
curLbl.textContent = 'in your cart: ' + (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 = Math.max(0, 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);
// arm's length on the view axis: whole cover in frame, tilted to face you, panel-side clear
const h = (FMT[pulled.rec.fmt] || FMT.lp)[1];
pulled.group.position.lerp(new THREE.Vector3(-0.1, 0.23 - h / 2, 0.12), Math.min(1, dt * 5));
pulled.group.rotation.x += (-0.33 - 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';
camera.aspect = (innerWidth / innerHeight) || 1; camera.updateProjectionMatrix();
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;
clearTimeout(fillTimer);
[hint, curLbl, panel].forEach(e => e.style.display = 'none');
recs.forEach(r => scene.remove(r.group)); recs = [];
if (crate) { scene.remove(crate); crate = null; }
if (onClose) onClose();
}
return { scene, camera, open, update, close, get active() { return active; } };
}