destroyulator/web/juice.js
Monster Robot Party ca8b22c3c0 Lane 3: browser smash toy in web/ — FP record store, Rapier destruction, 3GOD props
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>
2026-07-14 20:14:32 +10:00

251 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.

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