A shareable one-click web toy (not a port of the Godot game): walk a record store first-person, pull a record, and wreck the place in the browser. Built on thriftgod's pointer-lock FP-interior patterns, zero build step (three r0.175 + @dimforge/rapier3d-compat over an importmap). - physics.js: Rapier WASM world, one body per prop, support-graph cascade (smash a rack → its crates pancake), pre-fractured swap (chunk_* bodies) with generative-shard fallback until Lane 2 ships fractured GLBs, capped debris pool. - props.js: GLTFLoader/DRACOLoader; loads props live from the 3GOD depot (/a/<file>, CORS-open) with a local web/assets/ mirror fallback so it never hard-fails offline. Read-only against the depot. - juice.js: camera-trauma screenshake, hitstop, per-material particle bursts and procedural Web-Audio smash voices (wood/cardboard/vinyl/glass/steel), combo HUD. - main.js: FP controller, record-store room, grab/smash raycasting, record ritual (pull → held → throw → shatter), "share your mess" canvas screenshot. Assets mirrored offline: record/crate/rack (from the Godot lane) + cashbot/ council-bin (from 3GOD). Verified in-browser: props load, smashing spawns shards + particles + combo, rack cascade pancakes its crates, record ritual and share work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
// juice.js — the feel layer. Screenshake, hitstop, particle bursts, procedural
|
||
// smash audio (one voice per material), and a combo counter. This is the same
|
||
// recipe as the Godot lane's Juice.gd, ported to Web Audio + the DOM/canvas.
|
||
//
|
||
// Screenshake is applied as a CSS transform on the <canvas> element, which keeps
|
||
// it completely decoupled from PointerLockControls (which owns the camera
|
||
// quaternion). A tiny positional camera jitter is layered on for parallax.
|
||
|
||
import * as THREE from 'three';
|
||
|
||
// per-material palette + physical character. Keys match props.js material tags.
|
||
export const MATERIALS = {
|
||
wood: { hex: 0xa5763f, particles: 0x8a5a2b, brittle: false },
|
||
cardboard: { hex: 0xbfa06a, particles: 0xb08a4a, brittle: false },
|
||
vinyl: { hex: 0x1b1b22, particles: 0x2a2a33, brittle: true },
|
||
glass: { hex: 0xbfe6ea, particles: 0xdff4f6, brittle: true },
|
||
steel: { hex: 0x9aa3ad, particles: 0xc4ccd4, brittle: false },
|
||
};
|
||
|
||
export function createJuice({ camera, scene, dom }) {
|
||
// ---------- screenshake (trauma model, Squirrel Eiserloh) ----------
|
||
let trauma = 0; // 0..1, decays every frame; shake ~ trauma^2
|
||
let shakeSeed = 1; // deterministic wobble (no Math.random in the loop path)
|
||
const canvas = dom.canvas;
|
||
const camJitter = new THREE.Vector3();
|
||
|
||
function addTrauma(amount) { trauma = Math.min(1, trauma + amount); }
|
||
|
||
// ---------- hitstop ----------
|
||
let hitstop = 0; // seconds of frozen physics remaining
|
||
function addHitstop(sec) { hitstop = Math.max(hitstop, sec); }
|
||
function frozen() { return hitstop > 0; }
|
||
|
||
// ---------- particles: one pooled Points cloud, recycled ----------
|
||
const MAXP = 900;
|
||
const pgeo = new THREE.BufferGeometry();
|
||
const ppos = new Float32Array(MAXP * 3);
|
||
const pcol = new Float32Array(MAXP * 3);
|
||
pgeo.setAttribute('position', new THREE.BufferAttribute(ppos, 3));
|
||
pgeo.setAttribute('color', new THREE.BufferAttribute(pcol, 3));
|
||
const pmat = new THREE.PointsMaterial({ size: 0.055, vertexColors: true, transparent: true,
|
||
opacity: 0.95, depthWrite: false, sizeAttenuation: true });
|
||
const points = new THREE.Points(pgeo, pmat);
|
||
points.frustumCulled = false;
|
||
scene.add(points);
|
||
// parallel CPU-side particle state
|
||
const pvel = new Float32Array(MAXP * 3);
|
||
const plife = new Float32Array(MAXP); // remaining seconds
|
||
const pmax = new Float32Array(MAXP); // initial life (for fade)
|
||
let phead = 0;
|
||
const _c = new THREE.Color();
|
||
|
||
function burst(pos, materialKey, power = 1) {
|
||
const m = MATERIALS[materialKey] || MATERIALS.wood;
|
||
_c.setHex(m.particles);
|
||
const n = Math.min(MAXP, Math.round(14 + power * 26));
|
||
for (let k = 0; k < n; k++) {
|
||
const i = phead; phead = (phead + 1) % MAXP;
|
||
const i3 = i * 3;
|
||
ppos[i3] = pos.x; ppos[i3 + 1] = pos.y; ppos[i3 + 2] = pos.z;
|
||
// cheap deterministic-ish spread
|
||
const a = (shakeSeed = (shakeSeed * 16807) % 2147483647) / 2147483647 * Math.PI * 2;
|
||
const b = (shakeSeed = (shakeSeed * 16807) % 2147483647) / 2147483647;
|
||
const sp = (0.9 + b * 3.4) * (0.6 + power * 0.7);
|
||
pvel[i3] = Math.cos(a) * sp * (0.5 + b);
|
||
pvel[i3 + 1] = (1.4 + b * 3.6) * (0.6 + power * 0.5);
|
||
pvel[i3 + 2] = Math.sin(a) * sp * (0.5 + b);
|
||
// slight per-particle tint variation
|
||
const j = 0.82 + b * 0.35;
|
||
pcol[i3] = _c.r * j; pcol[i3 + 1] = _c.g * j; pcol[i3 + 2] = _c.b * j;
|
||
const life = 0.5 + b * 0.7;
|
||
plife[i] = life; pmax[i] = life;
|
||
}
|
||
pgeo.attributes.color.needsUpdate = true;
|
||
}
|
||
|
||
function updateParticles(dt) {
|
||
let any = false;
|
||
for (let i = 0; i < MAXP; i++) {
|
||
if (plife[i] <= 0) continue;
|
||
any = true;
|
||
const i3 = i * 3;
|
||
plife[i] -= dt;
|
||
if (plife[i] <= 0) { ppos[i3 + 1] = -9999; continue; } // park it out of sight
|
||
pvel[i3 + 1] -= 11 * dt; // gravity
|
||
ppos[i3] += pvel[i3] * dt;
|
||
ppos[i3 + 1] += pvel[i3 + 1] * dt;
|
||
ppos[i3 + 2] += pvel[i3 + 2] * dt;
|
||
if (ppos[i3 + 1] < 0.02) { ppos[i3 + 1] = 0.02; pvel[i3 + 1] *= -0.32; pvel[i3] *= 0.6; pvel[i3 + 2] *= 0.6; }
|
||
}
|
||
if (any) pgeo.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
// ---------- procedural smash audio (mirrors Godot Juice.gd::_synth) ----------
|
||
let actx = null;
|
||
function ctx() {
|
||
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} }
|
||
if (actx && actx.state === 'suspended') actx.resume();
|
||
return actx;
|
||
}
|
||
function noiseBuf(a, dur, shape) {
|
||
const n = Math.max(1, Math.floor(a.sampleRate * dur));
|
||
const buf = a.createBuffer(1, n, a.sampleRate), d = buf.getChannelData(0);
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / n;
|
||
let env = Math.pow(1 - t, shape === 'rip' ? 1.2 : 2.4);
|
||
d[i] = (Math.random() * 2 - 1) * env;
|
||
}
|
||
return buf;
|
||
}
|
||
function env(a, g, peak, dur, atk = 0.005) {
|
||
const t = a.currentTime;
|
||
g.gain.setValueAtTime(0.0001, t);
|
||
g.gain.exponentialRampToValueAtTime(peak, t + atk);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||
}
|
||
|
||
// each voice returns quickly; all are short and cheap.
|
||
const VOICES = {
|
||
wood(a, p) { // crunch: filtered noise thud + low body
|
||
const s = a.createBufferSource(); s.buffer = noiseBuf(a, 0.16, 'crunch');
|
||
const f = a.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 700 + p * 500; f.Q.value = 0.6;
|
||
const g = a.createGain(); env(a, g, 0.5 * p, 0.18);
|
||
s.connect(f); f.connect(g); g.connect(a.destination); s.start();
|
||
const o = a.createOscillator(); o.type = 'triangle'; o.frequency.setValueAtTime(150, a.currentTime);
|
||
o.frequency.exponentialRampToValueAtTime(48, a.currentTime + 0.16);
|
||
const og = a.createGain(); env(a, og, 0.4 * p, 0.2); o.connect(og); og.connect(a.destination);
|
||
o.start(); o.stop(a.currentTime + 0.22);
|
||
},
|
||
cardboard(a, p) { // rip: short bandpassed noise
|
||
const s = a.createBufferSource(); s.buffer = noiseBuf(a, 0.12, 'rip');
|
||
const f = a.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 1500; f.Q.value = 0.8;
|
||
const g = a.createGain(); env(a, g, 0.32 * p, 0.13); s.connect(f); f.connect(g); g.connect(a.destination); s.start();
|
||
},
|
||
vinyl(a, p) { // crack + warble — the signature record snap
|
||
const s = a.createBufferSource(); s.buffer = noiseBuf(a, 0.05, 'crunch');
|
||
const f = a.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 1800;
|
||
const g = a.createGain(); env(a, g, 0.4 * p, 0.06); s.connect(f); f.connect(g); g.connect(a.destination); s.start();
|
||
const o = a.createOscillator(); o.type = 'sawtooth';
|
||
o.frequency.setValueAtTime(420, a.currentTime);
|
||
o.frequency.exponentialRampToValueAtTime(120, a.currentTime + 0.22);
|
||
const lfo = a.createOscillator(); lfo.frequency.value = 30;
|
||
const lg = a.createGain(); lg.gain.value = 40; lfo.connect(lg); lg.connect(o.frequency); lfo.start();
|
||
const og = a.createGain(); env(a, og, 0.28 * p, 0.26); o.connect(og); og.connect(a.destination);
|
||
o.start(); o.stop(a.currentTime + 0.28); lfo.stop(a.currentTime + 0.28);
|
||
},
|
||
glass(a, p) { // tinkle: bright noise + a few high pings
|
||
const s = a.createBufferSource(); s.buffer = noiseBuf(a, 0.09, 'crunch');
|
||
const f = a.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 4000;
|
||
const g = a.createGain(); env(a, g, 0.3 * p, 0.1); s.connect(f); f.connect(g); g.connect(a.destination); s.start();
|
||
for (let k = 0; k < 4; k++) {
|
||
const o = a.createOscillator(); o.type = 'sine';
|
||
o.frequency.value = 2400 + Math.random() * 3400;
|
||
const og = a.createGain(); const dur = 0.12 + Math.random() * 0.18;
|
||
const t = a.currentTime + k * 0.012;
|
||
og.gain.setValueAtTime(0.0001, t); og.gain.exponentialRampToValueAtTime(0.14 * p, t + 0.004);
|
||
og.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||
o.connect(og); og.connect(a.destination); o.start(t); o.stop(t + dur + 0.02);
|
||
}
|
||
},
|
||
steel(a, p) { // ring: inharmonic partials + metallic transient
|
||
const s = a.createBufferSource(); s.buffer = noiseBuf(a, 0.04, 'crunch');
|
||
const f = a.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 3200; f.Q.value = 1.5;
|
||
const g = a.createGain(); env(a, g, 0.26 * p, 0.05); s.connect(f); f.connect(g); g.connect(a.destination); s.start();
|
||
const partials = [1, 2.76, 5.4, 8.9];
|
||
const base = 220 + Math.random() * 90;
|
||
partials.forEach((mult, k) => {
|
||
const o = a.createOscillator(); o.type = 'sine'; o.frequency.value = base * mult;
|
||
const og = a.createGain(); const dur = 0.5 - k * 0.08;
|
||
env(a, og, (0.16 / (k + 1)) * p, dur, 0.003); o.connect(og); og.connect(a.destination);
|
||
o.start(); o.stop(a.currentTime + dur + 0.05);
|
||
});
|
||
},
|
||
};
|
||
|
||
function playSmash(materialKey, power = 1) {
|
||
const a = ctx(); if (!a) return;
|
||
(VOICES[materialKey] || VOICES.wood)(a, Math.max(0.4, Math.min(1.4, power)));
|
||
}
|
||
|
||
// a soft "whump" for throws / grabs
|
||
function playThrow() {
|
||
const a = ctx(); if (!a) return;
|
||
const o = a.createOscillator(); o.type = 'sine';
|
||
o.frequency.setValueAtTime(320, a.currentTime);
|
||
o.frequency.exponentialRampToValueAtTime(90, a.currentTime + 0.14);
|
||
const g = a.createGain(); env(a, g, 0.16, 0.16); o.connect(g); g.connect(a.destination);
|
||
o.start(); o.stop(a.currentTime + 0.2);
|
||
}
|
||
|
||
// ---------- combo counter ----------
|
||
let combo = 0, comboTimer = 0, totalSmashed = 0;
|
||
const elCombo = dom.combo, elComboN = dom.comboN, elComboX = dom.comboX;
|
||
const elTallyN = dom.tallyN;
|
||
function bumpCombo() {
|
||
combo++; comboTimer = 2.4;
|
||
elComboN.textContent = combo;
|
||
elComboX.textContent = combo >= 2 ? `×${combo}` : 'HIT';
|
||
elCombo.style.opacity = '1';
|
||
const s = 1 + Math.min(0.7, combo * 0.05);
|
||
elComboN.style.transform = `scale(${s})`;
|
||
}
|
||
function countSmash() { totalSmashed++; elTallyN.textContent = totalSmashed; }
|
||
function updateCombo(dt) {
|
||
if (comboTimer > 0) {
|
||
comboTimer -= dt;
|
||
if (comboTimer <= 0) { combo = 0; elCombo.style.opacity = '0'; elComboN.style.transform = 'scale(1)'; }
|
||
}
|
||
}
|
||
|
||
// one call fires the whole package for a smash
|
||
function smash(pos, materialKey, power = 1) {
|
||
addTrauma(0.32 + power * 0.28);
|
||
addHitstop(0.045 + power * 0.02);
|
||
burst(pos, materialKey, power);
|
||
playSmash(materialKey, power);
|
||
bumpCombo(); countSmash();
|
||
}
|
||
|
||
// ---------- per-frame ----------
|
||
function update(dt) {
|
||
if (hitstop > 0) hitstop = Math.max(0, hitstop - dt);
|
||
updateParticles(dt);
|
||
updateCombo(dt);
|
||
|
||
// trauma decay + shake
|
||
trauma = Math.max(0, trauma - dt * 1.7);
|
||
const sh = trauma * trauma;
|
||
if (sh > 0.0005) {
|
||
const t = performance.now() * 0.001;
|
||
// multi-frequency wobble → less "vibrating", more "kick"
|
||
const dx = (Math.sin(t * 47) + Math.sin(t * 29) * 0.6) * sh;
|
||
const dy = (Math.sin(t * 53 + 1.3) + Math.sin(t * 37 + 0.7) * 0.6) * sh;
|
||
const rot = Math.sin(t * 41 + 0.4) * sh;
|
||
canvas.style.transform =
|
||
`translate(${dx * 16}px, ${dy * 14}px) rotate(${rot * 1.1}deg) scale(${1 + sh * 0.02})`;
|
||
camJitter.set(dx * 0.03, dy * 0.03, 0);
|
||
} else if (canvas.style.transform) {
|
||
canvas.style.transform = '';
|
||
camJitter.set(0, 0, 0);
|
||
}
|
||
}
|
||
|
||
return {
|
||
smash, burst, playSmash, playThrow, addTrauma, addHitstop, frozen,
|
||
update, get camJitter() { return camJitter; },
|
||
get totalSmashed() { return totalSmashed; },
|
||
resumeAudio() { ctx(); },
|
||
};
|
||
}
|