diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..b75b513 --- /dev/null +++ b/web/README.md @@ -0,0 +1,56 @@ +# DESTROYULATOR — web smash toy (Lane 3) + +A shareable, **one-click browser toy**: walk a record store first-person, pull a +record, and wreck the place. Not a port of the Godot game — a different product in +the same universe, built on thriftgod's proven FP-interior patterns and wired to the +same [3GOD](https://digalot.fyi/3god) prop depot. + +**Zero build step.** Three.js + Rapier load over an importmap, exactly like thriftgod. + +## Run it + +```sh +cd web && python3 -m http.server 8099 +# then open http://localhost:8099 +``` + +Chrome and Safari both work (Safari is the weakest WASM target — it's tested). +Append `?debug` to expose a `window.DESTROY` handle for driving the sim without +Pointer Lock (embedded frames / smoke tests). + +## Controls + +- **WASD / arrows** — move · **mouse** — look (click to grab pointer lock) +- **click** — smash whatever you're looking at +- **E** (or click) on a record — pull it out of the bin; **click** again to throw it +- **Esc** — pause · **📸 share your mess** — screenshot + smash-count caption + +## Architecture + +| File | Role | +|---|---| +| `index.html` | Importmap (three r0.175 + `@dimforge/rapier3d-compat`), HUD, boot | +| `main.js` | FP controller, room, prop placement, interaction, record ritual, share, loop | +| `physics.js` | Rapier world, one body per prop, **support-graph cascade**, **pre-fractured swap**, debris pooling | +| `props.js` | GLTFLoader/DRACOLoader; loads props live from 3GOD `/a/` with a `assets/` mirror fallback | +| `juice.js` | Screenshake, hitstop, particle bursts, per-material Web-Audio smash voices, combo counter | +| `assets/` | Offline mirror: `record/crate/rack` (from the Godot lane) + `cashbot/council-bin` (from 3GOD) | + +### Shared contracts (see `../LANES/PLAN.md`) + +- **Props** load from the 3GOD depot at runtime (`GET /api/list`, GLB at `/a/`), + falling back to the local `assets/` mirror so the demo never hard-fails offline. + The depot serves `Access-Control-Allow-Origin: *`, so cross-origin loading works. +- **Fracture format** (contract #2): a smashable with a sibling `.fractured.glb` + (root children named `chunk_*`) is swapped in on smash — each chunk becomes a + short-lived dynamic body. Until Lane 2 ships those, brittle props use generative + shards. `props.loadFractured()` already probes for them. +- **Read-only** against the depot. No write calls (the depot's open-mode/SSRF issue + is a write-side concern — see PLAN.md). + +## Notes + +- Rapier is `-compat` (WASM inlined as base64) so `await RAPIER.init()` needs no extra + fetch and **no COOP/COEP headers**. +- Active physics bodies are capped and settled debris is despawned, so a long smashing + session stays bounded (JS GC is the ceiling). diff --git a/web/assets/cashbot.glb b/web/assets/cashbot.glb new file mode 100644 index 0000000..dbf7786 Binary files /dev/null and b/web/assets/cashbot.glb differ diff --git a/web/assets/council-bin.glb b/web/assets/council-bin.glb new file mode 100644 index 0000000..492adf8 Binary files /dev/null and b/web/assets/council-bin.glb differ diff --git a/web/assets/crate.glb b/web/assets/crate.glb new file mode 100644 index 0000000..b479986 Binary files /dev/null and b/web/assets/crate.glb differ diff --git a/web/assets/rack.glb b/web/assets/rack.glb new file mode 100644 index 0000000..3e004dc Binary files /dev/null and b/web/assets/rack.glb differ diff --git a/web/assets/record.glb b/web/assets/record.glb new file mode 100644 index 0000000..80a9165 Binary files /dev/null and b/web/assets/record.glb differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..32ca653 --- /dev/null +++ b/web/index.html @@ -0,0 +1,140 @@ + + + + + +DESTROYULATOR — smash the record store + + + + + + + + +
+ +
+ +
+ +
smashed 0
+ +
+
0
+
HITS
+
combo
+
+ +
WASD move · click smash · E pull a record · click to throw
+ +
+ +
+ +
+

DESTROYULATOR

+
The record store, and permission to wreck it. Walk in, pull a disc, and put the whole place through it.
+
CLICK TO WRECK →
+
+ WASD move  ·  mouse look  ·  + click smash  ·  E pull record  ·  Esc pause +
+
warming up the physics…
+
+ +
+
+ your mess +
+
+ ⬇︎ download + +
+
+
+ + + + diff --git a/web/juice.js b/web/juice.js new file mode 100644 index 0000000..f5bd34c --- /dev/null +++ b/web/juice.js @@ -0,0 +1,250 @@ +// 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(); }, + }; +} diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..2aac938 --- /dev/null +++ b/web/main.js @@ -0,0 +1,488 @@ +// main.js — DESTROYULATOR web smash toy. Orchestrates the first-person controller +// (forked from thriftgod's pointer-lock WASD), the record store room, prop loading +// from 3GOD, Rapier destruction (physics.js) and the feel layer (juice.js). +// +// Not a port of the Godot game — a shareable browser toy in the same universe, +// consuming the same 3GOD prop pipeline. Zero build step: importmap only. + +import * as THREE from 'three'; +import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; +import { initPhysics, createPhysics } from './physics.js'; +import { loadProp, loadFractured, fetchShelf, CATALOG } from './props.js'; +import { createJuice, MATERIALS } from './juice.js'; + +const $ = id => document.getElementById(id); +const boot = $('boot'); + +// ---------------- renderer / scene / camera ---------------- +const canvas = $('c'); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +renderer.outputColorSpace = THREE.SRGBColorSpace; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x2c2618); +scene.fog = new THREE.Fog(0x2c2618, 20, 46); + +const camera = new THREE.PerspectiveCamera(74, 16 / 9, 0.05, 100); +camera.position.set(0, 1.65, 6); +scene.add(camera); + +// Robust sizing: some hosts report innerWidth/innerHeight = 0 at load, which +// would make camera.aspect NaN and poison the projection (and the pick ray). +// Size from the canvas's laid-out box, with a sane fallback, and re-run on any +// layout change via ResizeObserver. +function viewSize() { + const w = canvas.clientWidth || innerWidth || 1280; + const h = canvas.clientHeight || innerHeight || 720; + return [Math.max(1, w), Math.max(1, h)]; +} +function resize() { + const [w, h] = viewSize(); + camera.aspect = w / h; camera.updateProjectionMatrix(); + renderer.setSize(w, h, false); // false → CSS (100%/100%) keeps controlling display size +} +resize(); +addEventListener('resize', resize); +try { new ResizeObserver(resize).observe(canvas); } catch (e) {} + +// lights — cheap, holds 60fps with a roomful of props +scene.add(new THREE.HemisphereLight(0xfff4e6, 0x40372a, 1.35)); +const key = new THREE.DirectionalLight(0xfff6ea, 1.35); key.position.set(4, 8, 3); scene.add(key); +const fill = new THREE.PointLight(0xffe2b4, 0.9, 44); fill.position.set(-3, 3.2, 2); scene.add(fill); +const fill2 = new THREE.PointLight(0xd8e0ff, 0.55, 40); fill2.position.set(4, 3.2, -3); scene.add(fill2); + +// ---------------- pointer lock (thriftgod's safeLock pattern) ---------------- +const controls = new PointerLockControls(camera, renderer.domElement); +const startEl = $('start'); +let paused = true, booted = false; + +function safeLock() { + try { + const p = renderer.domElement.requestPointerLock(); + if (p && p.catch) p.catch(() => { startEl.classList.remove('hidden'); }); + } catch (e) { startEl.classList.remove('hidden'); } +} +$('goBtn').onclick = () => { if (booted) { juice.resumeAudio(); safeLock(); } }; +startEl.onclick = e => { if (booted && e.target.id !== 'shareBtn') { juice.resumeAudio(); safeLock(); } }; +controls.addEventListener('lock', () => { paused = false; startEl.classList.add('hidden'); }); +controls.addEventListener('unlock', () => { paused = true; if (!shareOpen) startEl.classList.remove('hidden'); }); + +// ---------------- juice ---------------- +const juice = createJuice({ + camera, scene, + dom: { canvas, combo: $('combo'), comboN: $('comboN'), comboX: $('comboX'), tallyN: $('tallyN') }, +}); + +// ---------------- room ---------------- +const ROOM = { w: 16, d: 18, h: 3.6 }; +let phys = null; + +const woodTex = makeGridTexture('#7a6242', '#5f4c32', 8); +const floorTex = makeGridTexture('#544636', '#463a2c', 12); + +function makeGridTexture(a, b, reps) { + const cv = document.createElement('canvas'); cv.width = cv.height = 128; + const ctx = cv.getContext('2d'); + ctx.fillStyle = a; ctx.fillRect(0, 0, 128, 128); + ctx.strokeStyle = b; ctx.lineWidth = 8; + ctx.strokeRect(0, 0, 128, 128); + const tx = new THREE.CanvasTexture(cv); tx.wrapS = tx.wrapT = THREE.RepeatWrapping; + tx.repeat.set(reps, reps); tx.colorSpace = THREE.SRGBColorSpace; + return tx; +} + +function buildRoom() { + const { w, d, h } = ROOM; + const floorMat = new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.95 }); + const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat); + floor.rotation.x = -Math.PI / 2; scene.add(floor); + + const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), new THREE.MeshStandardMaterial({ color: 0x413628, roughness: 1 })); + ceil.rotation.x = Math.PI / 2; ceil.position.y = h; scene.add(ceil); + + const wallMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.9, side: THREE.FrontSide }); + const mkWall = (ww, hh, x, y, z, ry) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(ww, hh), wallMat); + m.position.set(x, y, z); m.rotation.y = ry; scene.add(m); + }; + mkWall(w, h, 0, h / 2, -d / 2, 0); // back + mkWall(w, h, 0, h / 2, d / 2, Math.PI); // front + mkWall(d, h, -w / 2, h / 2, 0, Math.PI / 2); // left + mkWall(d, h, w / 2, h / 2, 0, -Math.PI / 2); // right + + // static colliders: floor + 4 walls (thick slabs just outside the room) + phys.addStaticBox(new THREE.Vector3(0, -0.5, 0), new THREE.Vector3(w / 2, 0.5, d / 2)); + const t = 0.5; + phys.addStaticBox(new THREE.Vector3(0, h / 2, -d / 2 - t), new THREE.Vector3(w / 2, h, t)); + phys.addStaticBox(new THREE.Vector3(0, h / 2, d / 2 + t), new THREE.Vector3(w / 2, h, t)); + phys.addStaticBox(new THREE.Vector3(-w / 2 - t, h / 2, 0), new THREE.Vector3(t, h, d / 2)); + phys.addStaticBox(new THREE.Vector3(w / 2 + t, h / 2, 0), new THREE.Vector3(t, h, d / 2)); + phys.addStaticBox(new THREE.Vector3(0, h + t, 0), new THREE.Vector3(w / 2, t, d / 2)); // ceiling +} + +// place a loaded prop clone at a world transform and register it with physics +function placeProp(scene3d, worldPos, opts = {}) { + const g = new THREE.Group(); + g.add(scene3d); + if (opts.rotY) g.rotation.y = opts.rotY; + if (opts.scale) g.scale.setScalar(opts.scale); + g.position.copy(worldPos); + scene.add(g); + g.userData.tintHex = (MATERIALS[opts.material] || MATERIALS.wood).particles; + return phys.addProp(g, opts); +} + +// ---------------- record ritual (lite) ---------------- +// A bin of pull-able record meshes (decoration, not physics). Look at one, press +// E / click to slide it out into your hands; click again to throw + smash it. +const recordBin = { records: [], mesh: null }; +let recordTemplate = null; +let held = null; // { mesh, spin } +let pulling = null; // { rec, t } + +function buildRecordBin(pos) { + if (!recordTemplate) return; + const bin = new THREE.Group(); bin.position.copy(pos); scene.add(bin); + recordBin.mesh = bin; + const N = 9; + for (let i = 0; i < N; i++) { + const r = recordTemplate.clone(true); + r.rotation.x = -0.18 + i * 0.01; // leaned back, riffle-style + // sit them high enough to clearly protrude above the crate lip (targetable) + r.position.set(((i * 40503 >>> 0) % 100 - 50) / 900, 0.34, -0.14 + i * 0.05); + bin.add(r); + recordBin.records.push({ mesh: r, taken: false, home: r.position.clone() }); + } +} + +function nearestRecord() { + // the front-most un-taken record the ray is looking near + for (let i = recordBin.records.length - 1; i >= 0; i--) { + if (!recordBin.records[i].taken) return recordBin.records[i]; + } + return null; +} + +function pullRecord(rec) { + if (!rec || rec.taken || held || pulling) return; + rec.taken = true; + pulling = { rec, t: 0 }; + juice.playThrow(); +} + +function updatePull(dt) { + if (pulling) { + pulling.t = Math.min(1, pulling.t + dt * 3.2); + const e = pulling.t * pulling.t * (3 - 2 * pulling.t); // smoothstep + const m = pulling.rec.mesh; + // slide up out of the bin + m.position.lerpVectors(pulling.rec.home, new THREE.Vector3(pulling.rec.home.x, 0.55, 0.25), e); + if (pulling.t >= 1) { + // hand it off to "held" + recordBin.mesh.remove(m); + m.position.set(0, 0, 0); m.rotation.set(0, 0, 0); + scene.add(m); + held = { mesh: m, spin: 0 }; + pulling = null; + } + } + if (held) { + held.spin += dt * 3; + // float it in front of the camera, a touch low and off to the side (in-hand) + const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd); + const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize(); + const pos = camera.position.clone().add(fwd.multiplyScalar(0.95)).add(right.multiplyScalar(0.22)); + pos.y -= 0.22; + held.mesh.position.lerp(pos, Math.min(1, dt * 18)); + held.mesh.rotation.y = held.spin; + held.mesh.rotation.x = -0.35; + } +} + +function throwHeld() { + if (!held) return; + const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd); + const start = held.mesh.position.clone(); + scene.remove(held.mesh); + // spawn a real physics record at the throw point + const g = new THREE.Group(); g.add(held.mesh); + held.mesh.position.set(0, 0, 0); held.mesh.rotation.set(0, 0, 0); + g.position.copy(start); scene.add(g); + g.userData.tintHex = MATERIALS.vinyl.particles; + const prop = phys.addProp(g, { material: 'vinyl', kind: 'record', brittle: true, mass: 0.3, fractured: fracturedTemplates.record }); + const speed = 13; + prop.body.setLinvel({ x: fwd.x * speed, y: fwd.y * speed + 1.2, z: fwd.z * speed }, true); + prop.body.setAngvel({ x: (Math.random() - 0.5) * 20, y: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 }, true); + held = null; + juice.playThrow(); +} + +// ---------------- interaction (center ray) ---------------- +const ray = new THREE.Raycaster(); +const cross = $('cross'); +const _fwd = new THREE.Vector3(); +let lookProp = null, lookRecord = false; + +function updateLook() { + if (paused) return; + camera.updateMatrixWorld(); // cast from where the camera is THIS frame + ray.setFromCamera({ x: 0, y: 0 }, camera); + const hits = ray.intersectObjects(scene.children, true); + lookProp = null; lookRecord = false; + for (const h of hits) { + if (h.distance > 4.2) break; + // a pull-able record in the bin? + let o = h.object, rec = null; + while (o) { if (o.userData && o.userData.__binRec) { rec = o.userData.__binRec; break; } o = o.parent; } + if (rec && !rec.taken) { lookRecord = true; break; } + const prop = phys.propAt(h.object); + if (prop && !prop.smashed) { lookProp = prop; break; } + } + const wantHand = lookRecord || !!lookProp || !!held; + cross.classList.toggle('hand', wantHand); +} + +function smashProp(prop, power = 1.1) { + if (!prop || prop.smashed) return; + camera.getWorldDirection(_fwd); + const dir = { x: _fwd.x, y: 0, z: _fwd.z }; + const t = prop.body.translation(); + const wp = new THREE.Vector3(t.x + prop.center.x, t.y + prop.center.y, t.z + prop.center.z); + juice.smash(wp, prop.material, power); + if (prop.kind === 'rack') { + phys.knock(prop, dir, power); // topple the support → dependents pancake + } else if (prop.shatterable) { + phys.shatter(prop, dir, power); // crate / bin / record → shards + } else { + phys.knock(prop, dir, power); // cashbot / mpc → comedic topple + steel ring + } +} + +function onClick() { + if (paused || !booted) return; + if (held) { throwHeld(); return; } + if (lookRecord) { pullRecord(nearestRecord()); return; } + if (lookProp) { smashProp(lookProp, 1.15); return; } +} +addEventListener('mousedown', e => { if (e.button === 0) onClick(); }); +addEventListener('keydown', e => { + if (e.code === 'KeyE') { if (held) throwHeld(); else if (lookRecord) pullRecord(nearestRecord()); } +}); + +// ---------------- movement (thriftgod loop) ---------------- +const keys = {}; +addEventListener('keydown', e => { keys[e.code] = true; }); +addEventListener('keyup', e => { keys[e.code] = false; }); +const vel = new THREE.Vector3(); +const lastJitter = new THREE.Vector3(); + +function move(dt) { + const sp = 3.4 * dt; + const R = keys.KeyD || keys.ArrowRight, Le = keys.KeyA || keys.ArrowLeft, + Bk = keys.KeyS || keys.ArrowDown, Fw = keys.KeyW || keys.ArrowUp; + vel.set((R ? 1 : 0) - (Le ? 1 : 0), 0, (Bk ? 1 : 0) - (Fw ? 1 : 0)); + if (vel.lengthSq()) { + vel.normalize(); + controls.moveRight(vel.x * sp); controls.moveForward(-vel.z * sp); + } + // keep the player inside the room + const m = 0.35; + camera.position.x = Math.max(-ROOM.w / 2 + m, Math.min(ROOM.w / 2 - m, camera.position.x)); + camera.position.z = Math.max(-ROOM.d / 2 + m, Math.min(ROOM.d / 2 - m, camera.position.z)); + camera.position.y = 1.65; +} + +// ---------------- share-your-mess ---------------- +let shareOpen = false; +$('shareBtn').onclick = openShare; +$('shareClose').onclick = closeShare; +function openShare() { + render(); // ensure the buffer is fresh + const url = renderer.domElement.toDataURL('image/png'); + $('shareImg').src = url; + $('shareDl').href = url; + const n = juice.totalSmashed; + $('shareCap').innerHTML = n > 0 + ? `You put ${n} ${n === 1 ? 'thing' : 'things'} through it. The staff will not be pleased.` + : `A suspiciously tidy record store. Go break something.`; + $('share').style.display = 'flex'; + shareOpen = true; + if (controls.isLocked) document.exitPointerLock(); +} +function closeShare() { $('share').style.display = 'none'; shareOpen = false; startEl.classList.remove('hidden'); } + +// ---------------- boot ---------------- +const fracturedTemplates = {}; // id -> fractured Object3D (from Lane 2, if shipped) + +async function build() { + boot.textContent = 'starting physics…'; + await initPhysics(); + phys = createPhysics(scene); + phys.setSmashHandler((prop, wp, power, shattered) => { + // a real impact fired by the sim (thrown record hits a wall, crate slams floor) + juice.smash(wp, prop.material, power); + }); + + buildRoom(); + + boot.textContent = 'loading props from 3GOD…'; + // core smashables (local mirror, always present) + const [recordRes, crateRes, rackRes] = await Promise.all([ + loadProp('record'), loadProp('crate'), loadProp('rack'), + ]); + recordTemplate = recordRes ? recordRes.scene : null; + + // try to pull a pre-fractured record from Lane 2's pipeline (graceful if absent) + try { fracturedTemplates.record = await loadFractured('record'); } catch (e) {} + + layoutRoom({ crateRes, rackRes }); + + // flavor props from the live depot (local fallback for cashbot/bin; others optional) + loadDepotFlavor(); + + // a little "N on the shelf" readout, best-effort + fetchShelf().then(list => { if (list.length) boot.dataset.shelf = list.length; }); + + booted = true; + boot.textContent = 'ready — click to wreck'; + requestAnimationFrame(tick); +} + +function layoutRoom({ crateRes, rackRes }) { + const { w, d } = ROOM; + + // racks along the back wall — fixed supports holding crates that pancake + if (rackRes) { + for (let i = -1; i <= 1; i++) { + const rackClone = i === -1 ? rackRes.scene : rackRes.scene.clone(true); + const rack = placeProp(rackClone, new THREE.Vector3(i * 4.2, 0, -d / 2 + 1.4), + { material: 'wood', kind: 'rack', fixed: true, rotY: 0 }); + // stack two crates on each rack; smashing the rack drops them + if (crateRes) { + for (let k = 0; k < 2; k++) { + const crate = placeProp(crateRes.scene.clone(true), + new THREE.Vector3(i * 4.2 - 0.2 + k * 0.4, 0.82 + k * 0.34, -d / 2 + 1.4), + { material: 'wood', kind: 'crate', fixed: true }); + phys.support(rack, crate); + } + } + } + } + + // a pyramid of crates mid-floor to smash + topple + if (crateRes) { + const base = new THREE.Vector3(-3.5, 0, 1); + const rows = [3, 2, 1]; + let y = 0; + const placed = []; + rows.forEach((count, row) => { + const layer = []; + for (let c = 0; c < count; c++) { + const x = base.x + (c - (count - 1) / 2) * 0.4 + row * 0.2; + const cr = placeProp(crateRes.scene.clone(true), new THREE.Vector3(x, y, base.z), + { material: 'wood', kind: 'crate', fixed: true }); // held until the base is smashed + layer.push(cr); + } + // link support: each upper crate rests on the layer below + if (placed.length) layer.forEach(up => placed[placed.length - 1].forEach(dn => phys.support(dn, up))); + placed.push(layer); + y += 0.34; + }); + // a few loose floor crates to knock around + [[3.5, 2.2], [4.5, 0.5], [2.6, -1.5]].forEach(([x, z]) => + placeProp(crateRes.scene.clone(true), new THREE.Vector3(x, 0, z), { material: 'wood', kind: 'crate', fixed: true })); + } + + // the record bin you riffle + pull from + if (recordTemplate) { + buildRecordBin(new THREE.Vector3(0.5, 0, 3.2)); + // tag bin records so the ray can find them + recordBin.records.forEach(rec => rec.mesh.traverse(o => { o.userData.__binRec = rec; })); + // an OPEN-TOP wooden crate around them, so records protrude and stay + // targetable from above (a solid box would occlude them) + const shellMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.9 }); + const crate = new THREE.Group(); crate.position.set(0.5, 0, 3.2); + const CW = 0.72, CH = 0.34, TH = 0.04; + const wall = (w, h, d, x, y, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), shellMat); m.position.set(x, y, z); crate.add(m); }; + wall(CW, TH, CW, 0, TH / 2, 0); // floor + wall(CW, CH, TH, 0, CH / 2, -CW / 2 + TH / 2); // back + wall(CW, CH, TH, 0, CH / 2, CW / 2 - TH / 2); // front + wall(TH, CH, CW, -CW / 2 + TH / 2, CH / 2, 0); // left + wall(TH, CH, CW, CW / 2 - TH / 2, CH / 2, 0); // right + scene.add(crate); + } +} + +async function loadDepotFlavor() { + // positions kept clear of the back-wall racks (x∈{-4.2,0,4.2}, z≈-7.6), + // the mid pyramid (≈-3.5,1) and the record bin (0.5,3.2) + const flavor = [ + { id: 'cashbot', pos: [-6, 0, 5.5], material: 'steel', rotY: 0.6 }, // greeter by the door + { id: 'bin', pos: [-w2() + 1.0, 0, -1.5], material: 'steel' }, + { id: 'bin', pos: [w2() - 1.0, 0, 3.5], material: 'steel' }, + { id: 'mpc', pos: [6, 0, 0], material: 'steel' }, + { id: 'gameboy', pos: [-6, 0, -3.5], material: 'cardboard' }, + ]; + for (const f of flavor) { + try { + const res = await loadProp(f.id); + if (!res) continue; + const def = CATALOG[f.id]; + placeProp(res.scene, new THREE.Vector3(f.pos[0], f.pos[1], f.pos[2]), + { material: f.material || def.material, kind: def.kind, scale: f.scale, rotY: f.rotY, fixed: true }); + } catch (e) { /* depot prop unavailable — skip gracefully */ } + } +} +function w2() { return ROOM.w / 2; } +function d2() { return ROOM.d / 2; } + +// ---------------- main loop ---------------- +const clock = new THREE.Clock(); +let frame = 0; + +function render() { renderer.render(scene, camera); } + +function stepFrame(dt) { + // undo last frame's camera shake offset before moving (keeps movement authoritative) + camera.position.sub(lastJitter); + + if (!paused && !shareOpen) move(dt); + + updatePull(dt); + if (++frame % 4 === 0) updateLook(); + + phys.step(dt, juice.frozen()); + juice.update(dt); + + // re-apply camera shake for this frame + lastJitter.copy(juice.camJitter); + camera.position.add(lastJitter); + + render(); +} + +function tick() { + requestAnimationFrame(tick); + stepFrame(Math.min(clock.getDelta(), 0.05)); +} + +// Debug handle, only attached with ?debug — lets you drive the sim without a +// Pointer Lock (e.g. embedded frames / headless smoke tests) and step it by hand. +if (location.search.includes('debug')) { + window.DESTROY = { + get scene() { return scene; }, get camera() { return camera; }, get phys() { return phys; }, juice, + unpause() { paused = false; startEl.classList.add('hidden'); }, + look(yaw, pitch = 0) { camera.rotation.set(pitch, yaw, 0, 'YXZ'); }, + teleport(x, y, z) { camera.position.set(x, y, z); lastJitter.set(0, 0, 0); }, + aimSmash() { updateLook(); if (lookProp) { smashProp(lookProp, 1.2); return lookProp.kind; } return null; }, + pull() { updateLook(); if (lookRecord) { pullRecord(nearestRecord()); return 'pulling'; } return 'no record in view'; }, + throwR() { const had = !!held; throwHeld(); return had ? 'thrown' : 'nothing held'; }, + sim(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) stepFrame(dt); return this.state(); }, + counts() { return { props: phys && phys.propCount, debris: phys && phys.debrisCount, smashed: juice.totalSmashed }; }, + state() { return { held: !!held, pulling: !!pulling, recordsLeft: recordBin.records.filter(r => !r.taken).length }; }, + }; +} + +// kick it off +build().catch(err => { boot.textContent = 'boot error: ' + (err && err.message || err); console.error(err); }); diff --git a/web/physics.js b/web/physics.js new file mode 100644 index 0000000..8bb148f --- /dev/null +++ b/web/physics.js @@ -0,0 +1,305 @@ +// physics.js — Rapier (WASM) destruction. One rigid body per smashable prop, +// box colliders auto-sized from the GLB bounding box (mirrors the Godot lane's +// `_body_local_aabb`). Implements: +// • a support-graph cascade — fixed bodies hold up others; smashing a support +// wakes its dependents to dynamic ("knock the legs, it pancakes"). +// • a pre-fractured swap — when a prop ships a `.fractured.glb` (Lane 2 +// contract #2) we hide the intact mesh and spawn each chunk as a short-lived +// dynamic body; brittle props with no fracture get generative shards. +// • a debris cap with sleep/despawn so JS GC never becomes the ceiling. + +import * as THREE from 'three'; +import RAPIER from '@dimforge/rapier3d-compat'; + +let ready = false; +export async function initPhysics() { + if (!ready) { await RAPIER.init(); ready = true; } + return RAPIER; +} + +const _box = new THREE.Box3(); +const _size = new THREE.Vector3(); +const _center = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _v = new THREE.Vector3(); + +// AABB of an object in its OWN local frame — its position/rotation are excluded +// (so the box is what the body's collider needs), but scale is kept (a scaled +// prop wants a scaled collider). setFromObject alone returns a world-space box, +// which would double-offset a prop placed away from the origin. +const _sp = new THREE.Vector3(), _sq = new THREE.Quaternion(); +export function localAABB(obj) { + _sp.copy(obj.position); _sq.copy(obj.quaternion); + obj.position.set(0, 0, 0); obj.quaternion.identity(); + obj.updateWorldMatrix(true, true); + _box.setFromObject(obj); + obj.position.copy(_sp); obj.quaternion.copy(_sq); + obj.updateWorldMatrix(true, true); + _box.getSize(_size); + _box.getCenter(_center); + return { size: _size.clone(), center: _center.clone() }; +} + +export function createPhysics(scene) { + const world = new RAPIER.World({ x: 0, y: -12.5, z: 0 }); + const eventQueue = new RAPIER.EventQueue(true); + + // every prop we sim: { body, mesh, material, kind, collHandle, ... } + const props = []; + const byCollider = new Map(); // collider.handle -> prop + const debris = []; // short-lived shards { body, mesh, ttl } + const MAX_DEBRIS = 140; + + // ---- factory: a simulated box body that drives a mesh ---- + function boxColliderDesc(size, center, { restitution = 0.15, friction = 0.85, density = 1 } = {}) { + return RAPIER.ColliderDesc + .cuboid(Math.max(0.02, size.x / 2), Math.max(0.02, size.y / 2), Math.max(0.02, size.z / 2)) + .setTranslation(center.x, center.y, center.z) + .setRestitution(restitution).setFriction(friction).setDensity(density); + } + + // Register a mesh as a smashable prop. `fixed` props are supports until disturbed. + function addProp(mesh, opts = {}) { + const { material = 'wood', kind = 'prop', fixed = false, mass = 1, + brittle = false, fractured = null, grabbable = false } = opts; + const shatterable = opts.shatterable ?? (brittle || kind === 'crate' || kind === 'bin'); + const { size, center } = localAABB(mesh); + const p = mesh.position, q = mesh.quaternion; + const desc = (fixed ? RAPIER.RigidBodyDesc.fixed() : RAPIER.RigidBodyDesc.dynamic()) + .setTranslation(p.x, p.y, p.z) + .setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) + .setLinearDamping(0.12).setAngularDamping(0.25) + .setCcdEnabled(true); + const body = world.createRigidBody(desc); + const cd = boxColliderDesc(size, center, { + restitution: brittle ? 0.05 : 0.18, + friction: 0.9, + density: mass / Math.max(0.03, size.x * size.y * size.z), + }).setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS) + .setContactForceEventThreshold(brittle ? 6 : 14); + const coll = world.createCollider(cd, body); + + const prop = { + body, mesh, coll, material, kind, brittle, fractured, grabbable, shatterable, + fixed, size: size.clone(), center: center.clone(), + supports: [], supportedBy: [], smashed: false, dynamicSince: fixed ? -1 : 0, + }; + byCollider.set(coll.handle, prop); + props.push(prop); + mesh.userData.prop = prop; + return prop; + } + + // Static world geometry (floor / walls) — colliders only, no mesh sync. + function addStaticBox(center, half, friction = 0.95) { + const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(center.x, center.y, center.z)); + world.createCollider(RAPIER.ColliderDesc.cuboid(half.x, half.y, half.z).setFriction(friction), body); + return body; + } + + // Declare "b rests on a" — smashing/toppling a frees b to fall. + function support(a, b) { a.supports.push(b); b.supportedBy.push(a); } + + function wake(prop) { + if (prop.smashed) return; + if (prop.fixed) { prop.fixed = false; prop.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true); prop.dynamicSince = tNow; } + prop.body.wakeUp(); + } + + // Cascade: when a support is destroyed/toppled, its dependents go dynamic. + function cascade(prop) { + for (const dep of prop.supports) { + if (dep.fixed && !dep.smashed) { + wake(dep); + // a little nudge so the pancake reads immediately + dep.body.applyImpulse({ x: (Math.random() - 0.5) * 0.6, y: -0.3, z: (Math.random() - 0.5) * 0.6 }, true); + } + } + } + + // ---- shattering ---- + // hide the intact mesh, spawn chunks (from a fractured template if present, + // else generative cubes), each a short-lived dynamic body inheriting velocity. + function shatter(prop, hitDir, power) { + if (prop.smashed) return; + prop.smashed = true; + + // inherit the intact body's velocity for the debris + let lin = { x: 0, y: 0, z: 0 }; + try { const lv = prop.body.linvel(); lin = { x: lv.x, y: lv.y, z: lv.z }; } catch (e) {} + const origin = prop.body.translation(); + const rot = prop.body.rotation(); + + // free any dependents first (support gone) + cascade(prop); + + // remove the intact body + mesh + byCollider.delete(prop.coll.handle); + world.removeRigidBody(prop.body); + prop.mesh.visible = false; + if (prop.mesh.parent) prop.mesh.parent.remove(prop.mesh); + const idx = props.indexOf(prop); if (idx >= 0) props.splice(idx, 1); + + const chunks = prop.fractured + ? spawnFracturedChunks(prop, origin, rot) + : spawnGenerativeShards(prop, origin); + + // kick every chunk outward + inherit velocity + for (const ch of chunks) { + const dir = _v.set(ch.mesh.position.x - origin.x, ch.mesh.position.y - origin.y, ch.mesh.position.z - origin.z); + if (dir.lengthSq() < 1e-4) dir.set(hitDir.x, 0.4, hitDir.z); + dir.normalize(); + const s = (1.2 + power * 2.2); + ch.body.setLinvel({ x: lin.x + dir.x * s + hitDir.x * power * 2, + y: lin.y + Math.abs(dir.y) * s + 1.5, + z: lin.z + dir.z * s + hitDir.z * power * 2 }, true); + ch.body.setAngvel({ x: (Math.random() - 0.5) * 12, y: (Math.random() - 0.5) * 12, z: (Math.random() - 0.5) * 12 }, true); + } + return chunks; + } + + const _shardGeo = new THREE.BoxGeometry(1, 1, 1); + function spawnGenerativeShards(prop, origin) { + const out = []; + const n = prop.brittle ? 7 : 5; + const s = prop.size; + const baseColor = new THREE.Color(prop.mesh.userData.tintHex || 0x8a5a2b); + for (let i = 0; i < n; i++) { + const sx = s.x * (0.22 + Math.random() * 0.3); + const sy = s.y * (0.22 + Math.random() * 0.3); + const sz = s.z * (0.22 + Math.random() * 0.3); + const mat = new THREE.MeshStandardMaterial({ color: baseColor.clone().offsetHSL(0, 0, (Math.random() - 0.5) * 0.1), roughness: 0.85 }); + const mesh = new THREE.Mesh(_shardGeo, mat); + mesh.scale.set(sx, sy, sz); + const px = origin.x + (Math.random() - 0.5) * s.x * 0.5; + const py = origin.y + prop.center.y + (Math.random() - 0.5) * s.y * 0.5; + const pz = origin.z + (Math.random() - 0.5) * s.z * 0.5; + mesh.position.set(px, py, pz); + scene.add(mesh); + const body = world.createRigidBody( + RAPIER.RigidBodyDesc.dynamic().setTranslation(px, py, pz).setLinearDamping(0.2).setAngularDamping(0.4)); + world.createCollider(RAPIER.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2).setRestitution(0.2).setFriction(0.8).setDensity(0.6), body); + const d = { body, mesh, ttl: 3.5 + Math.random() * 2 }; + debris.push(d); out.push(d); + } + trimDebris(); + return out; + } + + function spawnFracturedChunks(prop, origin, rot) { + const out = []; + const tmpl = prop.fractured; // THREE.Object3D whose direct children are chunk_* meshes + _q.set(rot.x, rot.y, rot.z, rot.w); + for (const child of tmpl.children) { + if (!/^chunk/i.test(child.name)) continue; + const mesh = child.clone(true); + // chunk origin is its own centroid, in the intact model's local space + const local = new THREE.Vector3().copy(child.position); + local.applyQuaternion(_q); + const px = origin.x + local.x, py = origin.y + local.y, pz = origin.z + local.z; + mesh.position.set(px, py, pz); + mesh.quaternion.copy(_q); + scene.add(mesh); + const { size, center } = localAABB(child); + const body = world.createRigidBody( + RAPIER.RigidBodyDesc.dynamic().setTranslation(px, py, pz) + .setRotation({ x: rot.x, y: rot.y, z: rot.z, w: rot.w }) + .setLinearDamping(0.2).setAngularDamping(0.4)); + world.createCollider( + RAPIER.ColliderDesc.cuboid(Math.max(0.01, size.x / 2), Math.max(0.01, size.y / 2), Math.max(0.01, size.z / 2)) + .setTranslation(center.x, center.y, center.z).setRestitution(0.15).setFriction(0.85).setDensity(0.7), body); + const d = { body, mesh, ttl: 4 + Math.random() * 2 }; + debris.push(d); out.push(d); + } + // if the template had no usable chunks, fall back so a smash never no-ops + return out.length ? out : spawnGenerativeShards(prop, origin); + } + + function trimDebris() { + // remove settled/oldest debris beyond the cap so body count stays bounded + while (debris.length > MAX_DEBRIS) { + const d = debris.shift(); + removeDebris(d); + } + } + function removeDebris(d) { + try { world.removeRigidBody(d.body); } catch (e) {} + if (d.mesh) { if (d.mesh.parent) d.mesh.parent.remove(d.mesh); + if (d.mesh.material && d.mesh.material.dispose) d.mesh.material.dispose(); } + } + + // ---- knock a prop dynamic (non-brittle smash): topple + cascade, no shards ---- + function knock(prop, hitDir, power) { + if (prop.smashed) return; + wake(prop); + cascade(prop); + const c = prop.center; + prop.body.applyImpulseAtPoint( + { x: hitDir.x * power * 6, y: 1.2 + power * 2, z: hitDir.z * power * 6 }, + { x: prop.body.translation().x + c.x * 0.6, y: prop.body.translation().y + prop.size.y * 0.7, z: prop.body.translation().z + c.z * 0.6 }, + true); + prop.body.applyTorqueImpulse({ x: (Math.random() - 0.5) * power * 2, y: (Math.random() - 0.5), z: (Math.random() - 0.5) * power * 2 }, true); + } + + // ---- per-frame ---- + let tNow = 0; + let onSmash = null; // callback(prop, worldPos, power) + function setSmashHandler(fn) { onSmash = fn; } + + function step(dt, frozen) { + tNow += dt; + if (!frozen) { + world.timestep = Math.min(1 / 30, Math.max(1 / 240, dt)); + world.step(eventQueue); + + // contact-force events → smashes. Threshold set per collider above. + eventQueue.drainContactForceEvents(ev => { + const a = byCollider.get(ev.collider1()); + const b = byCollider.get(ev.collider2()); + const mag = ev.totalForceMagnitude(); + for (const prop of [a, b]) { + if (!prop || prop.smashed || prop.fixed) continue; + // calibrated to measured impacts: a thrown record onto the floor is + // ~30, a hard crate slam is higher; gentle settling is a few N. + const power = Math.min(1.5, mag / 45); + if (power < 0.14) continue; // ignore gentle settling contacts + const t = prop.body.translation(); + const wp = new THREE.Vector3(t.x + prop.center.x, t.y + prop.center.y, t.z + prop.center.z); + // brittle (vinyl/glass) shatters on any real impact; crates/bins need a hard slam + const willShatter = prop.shatterable && power > (prop.brittle ? 0.2 : 0.9); + if (willShatter) shatter(prop, { x: 0, y: 0, z: 0 }, power); + if (onSmash) onSmash(prop, wp, power, willShatter); + } + }); + } + + // sync dynamic prop meshes + for (const prop of props) { + if (prop.fixed) continue; + const t = prop.body.translation(), r = prop.body.rotation(); + prop.mesh.position.set(t.x, t.y, t.z); + prop.mesh.quaternion.set(r.x, r.y, r.z, r.w); + } + // sync + age debris + for (let i = debris.length - 1; i >= 0; i--) { + const d = debris[i]; + const t = d.body.translation(), r = d.body.rotation(); + d.mesh.position.set(t.x, t.y, t.z); + d.mesh.quaternion.set(r.x, r.y, r.z, r.w); + d.ttl -= dt; + // despawn when its life is up, or once it's settled and off the cap budget + if (d.ttl <= 0 || (d.body.isSleeping() && d.ttl < 2 && debris.length > MAX_DEBRIS * 0.6)) { + removeDebris(d); debris.splice(i, 1); + } + } + } + + return { + world, RAPIER, addProp, addStaticBox, support, shatter, knock, wake, cascade, + step, setSmashHandler, + propAt(mesh) { let o = mesh; while (o) { if (o.userData && o.userData.prop) return o.userData.prop; o = o.parent; } return null; }, + get props() { return props; }, + get debrisCount() { return debris.length; }, + get propCount() { return props.length; }, + }; +} diff --git a/web/props.js b/web/props.js new file mode 100644 index 0000000..ca44a81 --- /dev/null +++ b/web/props.js @@ -0,0 +1,104 @@ +// props.js — the prop house. Loads GLBs live from the 3GOD depot +// (https://digalot.fyi/3god, GLB at /a/) with a local web/assets/ mirror as +// fallback so the demo never hard-fails offline. GLTFLoader + DRACOLoader are +// wired exactly like thriftgod's. Read-only against the depot — no writes, ever +// (the depot's open-mode/SSRF issue is a write-side problem; see PLAN.md). + +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; + +export const DEPOT = 'https://digalot.fyi/3god'; + +const gltfLoader = new GLTFLoader(); +{ const d = new DRACOLoader(); d.setDecoderPath('https://unpkg.com/three@0.175.0/examples/jsm/libs/draco/'); gltfLoader.setDRACOLoader(d); } + +// The catalog. `depot` = filename on 3GOD; `local` = mirror in web/assets/. +// material tags feed juice.js voices; kind drives interaction in main.js. +export const CATALOG = { + record: { local: 'record.glb', material: 'vinyl', kind: 'record', brittle: true, grabbable: true }, + crate: { local: 'crate.glb', material: 'wood', kind: 'crate' }, + rack: { local: 'rack.glb', material: 'wood', kind: 'rack' }, + cashbot: { depot: 'cashbot.glb', local: 'cashbot.glb', material: 'steel', kind: 'prop' }, + bin: { depot: 'council-bin.glb', local: 'council-bin.glb', material: 'steel', kind: 'prop' }, + // live-only flavor from the depot (no local mirror — absent gracefully if offline) + mpc: { depot: 'akai-mpc-live-iii-6fe39f.glb', material: 'steel', kind: 'prop' }, + gameboy: { depot: 'gameboybox-3c6cb9.glb', material: 'cardboard', kind: 'prop' }, +}; + +function depotURL(file) { return `${DEPOT}/a/${encodeURIComponent(file)}`; } +function localURL(file) { return `./assets/${encodeURIComponent(file)}`; } + +// tidy a freshly-loaded gltf scene: drop shadow flags (cheap), keep materials. +function prep(scene) { + scene.traverse(o => { + if (o.isMesh) { o.castShadow = false; o.receiveShadow = false; o.frustumCulled = true; } + }); + return scene; +} + +const _cache = new Map(); // id -> Promise<{scene, source}> + +// Load a prop by catalog id. Tries the live depot first, then the local mirror. +// Resolves to { scene: THREE.Object3D, source: 'depot'|'local' } or null. +export async function loadProp(id) { + const def = CATALOG[id]; + if (!def) { console.warn('[props] unknown prop', id); return null; } + if (_cache.has(id)) return cloneResult(await _cache.get(id)); + + const promise = (async () => { + // 1) live depot + if (def.depot) { + try { + const gltf = await gltfLoader.loadAsync(depotURL(def.depot)); + return { scene: prep(gltf.scene), source: 'depot' }; + } catch (e) { /* fall through to local */ } + } + // 2) local mirror + if (def.local) { + try { + const gltf = await gltfLoader.loadAsync(localURL(def.local)); + return { scene: prep(gltf.scene), source: 'local' }; + } catch (e) { console.warn('[props] local load failed', id, e && e.message); } + } + return null; + })(); + + _cache.set(id, promise); + const res = await promise; + return res ? cloneResult(res) : null; +} + +// Try to fetch the pre-fractured sibling (Lane 2 contract #2): .fractured.glb. +// Returns an Object3D whose direct children are chunk_* meshes, or null if none. +export async function loadFractured(id) { + const def = CATALOG[id]; + if (!def) return null; + const base = (def.depot || def.local || '').replace(/\.glb$/i, ''); + if (!base) return null; + const tryURLs = []; + if (def.depot) tryURLs.push(depotURL(base + '.fractured.glb')); + if (def.local) tryURLs.push(localURL(def.local.replace(/\.glb$/i, '') + '.fractured.glb')); + for (const url of tryURLs) { + try { + const gltf = await gltfLoader.loadAsync(url); + const hasChunks = gltf.scene.children.some(c => /^chunk/i.test(c.name)); + if (hasChunks) return prep(gltf.scene); + } catch (e) { /* not shipped yet — brittle props use generative shards */ } + } + return null; +} + +function cloneResult(res) { + // clone so each placement is independent (materials shared is fine here) + return { scene: res.scene.clone(true), source: res.source }; +} + +// Fetch the live shelf listing (for a quick "N props on the shelf" readout). +export async function fetchShelf() { + try { + const r = await fetch(`${DEPOT}/api/list`, { method: 'GET' }); + const j = await r.json(); + return Array.isArray(j.assets) ? j.assets : []; + } catch (e) { return []; } +}