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>
306 lines
13 KiB
JavaScript
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; },
|
|
};
|
|
}
|