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>
This commit is contained in:
parent
916b26102c
commit
d0c91faf79
107
webstore/dig.js
107
webstore/dig.js
@ -4,15 +4,22 @@ import * as THREE from 'three';
|
||||
// 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 REC_W = 0.31, REC_H = 0.31, GAP = 0.013, MAXFLIP = 1.95, BACK = -0.12;
|
||||
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, 0.01, 50);
|
||||
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);
|
||||
@ -29,7 +36,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
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{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}
|
||||
@ -63,32 +70,76 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
} 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 grp = new THREE.Group(); grp.position.set(0, 0, -i * GAP - 0.05);
|
||||
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(REC_W, REC_H, 0.0035), mat);
|
||||
mesh.position.y = REC_H / 2; // hinge at group origin = bin floor
|
||||
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: -i * GAP - 0.05 });
|
||||
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 - 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, () => {});
|
||||
}
|
||||
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 : ''}` : '';
|
||||
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() {
|
||||
@ -98,11 +149,26 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
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>`
|
||||
+ `<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 = () => { curLbl.textContent = 'added: ' + (d.title || d.sku); };
|
||||
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() {
|
||||
@ -116,7 +182,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
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; }
|
||||
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) => {
|
||||
@ -127,8 +193,10 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
});
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +215,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
|
||||
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 || []);
|
||||
@ -155,8 +224,10 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user