destroyulator/web/main.js
Monster Robot Party ca8b22c3c0 Lane 3: browser smash toy in web/ — FP record store, Rapier destruction, 3GOD props
A shareable one-click web toy (not a port of the Godot game): walk a record store
first-person, pull a record, and wreck the place in the browser. Built on
thriftgod's pointer-lock FP-interior patterns, zero build step (three r0.175 +
@dimforge/rapier3d-compat over an importmap).

- physics.js: Rapier WASM world, one body per prop, support-graph cascade
  (smash a rack → its crates pancake), pre-fractured swap (chunk_* bodies) with
  generative-shard fallback until Lane 2 ships fractured GLBs, capped debris pool.
- props.js: GLTFLoader/DRACOLoader; loads props live from the 3GOD depot
  (/a/<file>, CORS-open) with a local web/assets/ mirror fallback so it never
  hard-fails offline. Read-only against the depot.
- juice.js: camera-trauma screenshake, hitstop, per-material particle bursts and
  procedural Web-Audio smash voices (wood/cardboard/vinyl/glass/steel), combo HUD.
- main.js: FP controller, record-store room, grab/smash raycasting, record ritual
  (pull → held → throw → shatter), "share your mess" canvas screenshot.

Assets mirrored offline: record/crate/rack (from the Godot lane) + cashbot/
council-bin (from 3GOD). Verified in-browser: props load, smashing spawns shards +
particles + combo, rack cascade pancakes its crates, record ritual and share work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:14:32 +10:00

489 lines
20 KiB
JavaScript

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