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>
This commit is contained in:
Monster Robot Party 2026-07-14 20:14:32 +10:00
parent ad0ca62f3e
commit ca8b22c3c0
11 changed files with 1343 additions and 0 deletions

56
web/README.md Normal file
View File

@ -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/<file>` 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/<slug>`),
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 `<name>.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).

BIN
web/assets/cashbot.glb Normal file

Binary file not shown.

BIN
web/assets/council-bin.glb Normal file

Binary file not shown.

BIN
web/assets/crate.glb Normal file

Binary file not shown.

BIN
web/assets/rack.glb Normal file

Binary file not shown.

BIN
web/assets/record.glb Normal file

Binary file not shown.

140
web/index.html Normal file
View File

@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>DESTROYULATOR — smash the record store</title>
<meta name="description" content="A one-click browser smash toy: walk a record store first-person and wreck the place. Monster Robot Party × 3GOD.">
<!-- No build step. Three.js + Rapier over an importmap, exactly like thriftgod. -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.175.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.175.0/examples/jsm/",
"@dimforge/rapier3d-compat": "https://esm.sh/@dimforge/rapier3d-compat@0.14.0"
}
}
</script>
<style>
:root { --ink:#f2e8cf; --gold:#ffd75e; }
* { box-sizing:border-box; }
html, body { margin:0; height:100%; background:#0d0b08; overflow:hidden;
font-family:-apple-system, system-ui, "Segoe UI", sans-serif; color:var(--ink); -webkit-user-select:none; user-select:none; }
#wrap { position:fixed; inset:0; }
/* the canvas gets CSS-transform screenshake (decoupled from the camera) */
#c { position:absolute; inset:0; width:100%; height:100%; display:block; will-change:transform; }
/* crosshair */
#cross { position:fixed; left:50%; top:50%; width:20px; height:20px; margin:-10px 0 0 -10px;
pointer-events:none; z-index:5; opacity:.9; }
#cross::before, #cross::after { content:""; position:absolute; background:rgba(255,255,255,.85);
box-shadow:0 0 2px rgba(0,0,0,.7); }
#cross::before { left:9px; top:3px; width:2px; height:14px; }
#cross::after { top:9px; left:3px; height:2px; width:14px; }
#cross.hand::before, #cross.hand::after { background:var(--gold); }
#cross.hand { transform:scale(1.35); }
/* combo HUD */
#combo { position:fixed; right:26px; top:22px; z-index:6; text-align:right; pointer-events:none;
text-shadow:0 2px 10px #000, 0 0 3px #000; transition:opacity .3s; opacity:0; }
#combo .n { font-size:64px; font-weight:800; line-height:.9; color:var(--gold); font-variant-numeric:tabular-nums; }
#combo .x { font-size:20px; font-weight:700; letter-spacing:.05em; }
#combo .lbl { font-size:12px; letter-spacing:.28em; opacity:.7; text-transform:uppercase; }
/* smashed tally */
#tally { position:fixed; left:20px; top:18px; z-index:6; font-size:13px; letter-spacing:.05em;
text-shadow:0 1px 4px #000; opacity:.85; }
#tally b { color:var(--gold); font-size:17px; font-variant-numeric:tabular-nums; }
/* controls hint */
#hint { position:fixed; left:50%; bottom:16px; transform:translateX(-50%); z-index:6;
background:rgba(10,8,5,.55); backdrop-filter:blur(4px); padding:7px 16px; border-radius:22px;
font-size:12.5px; letter-spacing:.02em; white-space:nowrap; box-shadow:0 4px 20px rgba(0,0,0,.4); }
#hint b { color:var(--gold); }
/* start / pause overlay */
#start { position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:6px; cursor:pointer;
background:radial-gradient(120% 90% at 50% 30%, rgba(30,22,14,.72), rgba(6,5,3,.94)); text-align:center; }
#start h1 { font-size:min(11vw,74px); margin:0; letter-spacing:.02em; color:var(--gold);
text-shadow:0 4px 0 #6b4a12, 0 8px 30px rgba(0,0,0,.6); font-weight:800; }
#start .sub { font-size:15px; opacity:.85; max-width:440px; margin:2px 20px 0; line-height:1.5; }
#start .go { margin-top:22px; padding:13px 30px; border-radius:30px; background:var(--gold);
color:#221b06; font-weight:800; font-size:16px; letter-spacing:.03em;
box-shadow:0 8px 24px rgba(0,0,0,.45); }
#start .keys { margin-top:20px; font-size:12.5px; opacity:.7; letter-spacing:.04em; line-height:1.9; }
#start .keys kbd { background:#2a2318; border:1px solid #4a3f2b; border-bottom-width:2px; border-radius:5px;
padding:1px 7px; font:inherit; font-size:12px; color:var(--ink); }
#boot { margin-top:16px; font-size:12px; opacity:.55; min-height:16px; }
/* buttons row */
#tools { position:fixed; right:20px; bottom:16px; z-index:25; display:flex; gap:8px; }
#tools button { pointer-events:auto; cursor:pointer; border:0; border-radius:20px; padding:8px 15px;
font:inherit; font-size:13px; font-weight:700; color:#221b06; background:var(--gold);
box-shadow:0 4px 14px rgba(0,0,0,.35); }
#tools button.ghost { background:rgba(30,24,16,.75); color:var(--ink); border:1px solid #4a3f2b; }
/* share modal */
#share { position:fixed; inset:0; z-index:30; display:none; align-items:center; justify-content:center;
background:rgba(4,3,2,.8); backdrop-filter:blur(6px); }
#share .card { background:#15110b; border:1px solid #3a352c; border-radius:16px; padding:18px;
max-width:min(92vw,720px); box-shadow:0 20px 60px rgba(0,0,0,.6); }
#share img { max-width:100%; border-radius:10px; display:block; }
#share .cap { margin:12px 2px 4px; font-size:15px; }
#share .cap b { color:var(--gold); }
#share .row { display:flex; gap:8px; margin-top:12px; flex-wrap:wrap; }
#share a.dl, #share button { text-decoration:none; cursor:pointer; border:0; border-radius:22px;
padding:10px 18px; font:inherit; font-size:14px; font-weight:700; color:#221b06; background:var(--gold); }
#share button.ghost { background:rgba(40,32,20,.9); color:var(--ink); border:1px solid #4a3f2b; }
.hidden { display:none !important; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
</div>
<div id="cross"></div>
<div id="tally">smashed <b id="tallyN">0</b></div>
<div id="combo">
<div class="n" id="comboN">0</div>
<div class="x" id="comboX">HITS</div>
<div class="lbl">combo</div>
</div>
<div id="hint"><b>WASD</b> move · <b>click</b> smash · <b>E</b> pull a record · <b>click</b> to throw</div>
<div id="tools">
<button id="shareBtn" class="ghost">📸 share your mess</button>
</div>
<div id="start">
<h1>DESTROYULATOR</h1>
<div class="sub">The record store, and permission to wreck it. Walk in, pull a disc, and put the whole place through it.</div>
<div class="go" id="goBtn">CLICK TO WRECK →</div>
<div class="keys">
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move &nbsp;·&nbsp; <kbd>mouse</kbd> look &nbsp;·&nbsp;
<kbd>click</kbd> smash &nbsp;·&nbsp; <kbd>E</kbd> pull record &nbsp;·&nbsp; <kbd>Esc</kbd> pause
</div>
<div id="boot">warming up the physics…</div>
</div>
<div id="share">
<div class="card">
<img id="shareImg" alt="your mess">
<div class="cap" id="shareCap"></div>
<div class="row">
<a class="dl" id="shareDl" download="destroyulator-mess.png">⬇︎ download</a>
<button class="ghost" id="shareClose">back to smashing</button>
</div>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>

250
web/juice.js Normal file
View File

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

488
web/main.js Normal file
View File

@ -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 <b>${n}</b> ${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); });

305
web/physics.js Normal file
View File

@ -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; },
};
}

104
web/props.js Normal file
View File

@ -0,0 +1,104 @@
// props.js — the prop house. Loads GLBs live from the 3GOD depot
// (https://digalot.fyi/3god, GLB at /a/<file>) 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): <base>.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 []; }
}