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:
parent
ad0ca62f3e
commit
ca8b22c3c0
56
web/README.md
Normal file
56
web/README.md
Normal 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
BIN
web/assets/cashbot.glb
Normal file
Binary file not shown.
BIN
web/assets/council-bin.glb
Normal file
BIN
web/assets/council-bin.glb
Normal file
Binary file not shown.
BIN
web/assets/crate.glb
Normal file
BIN
web/assets/crate.glb
Normal file
Binary file not shown.
BIN
web/assets/rack.glb
Normal file
BIN
web/assets/rack.glb
Normal file
Binary file not shown.
BIN
web/assets/record.glb
Normal file
BIN
web/assets/record.glb
Normal file
Binary file not shown.
140
web/index.html
Normal file
140
web/index.html
Normal 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 · <kbd>mouse</kbd> look ·
|
||||
<kbd>click</kbd> smash · <kbd>E</kbd> pull record · <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
250
web/juice.js
Normal 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
488
web/main.js
Normal 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
305
web/physics.js
Normal 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
104
web/props.js
Normal 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 []; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user