// 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 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(); }, }; }