destroyulator/web/physics.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

306 lines
13 KiB
JavaScript

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