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>
489 lines
20 KiB
JavaScript
489 lines
20 KiB
JavaScript
// main.js — DESTROYULATOR web smash toy. Orchestrates the first-person controller
|
|
// (forked from thriftgod's pointer-lock WASD), the record store room, prop loading
|
|
// from 3GOD, Rapier destruction (physics.js) and the feel layer (juice.js).
|
|
//
|
|
// Not a port of the Godot game — a shareable browser toy in the same universe,
|
|
// consuming the same 3GOD prop pipeline. Zero build step: importmap only.
|
|
|
|
import * as THREE from 'three';
|
|
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
|
import { initPhysics, createPhysics } from './physics.js';
|
|
import { loadProp, loadFractured, fetchShelf, CATALOG } from './props.js';
|
|
import { createJuice, MATERIALS } from './juice.js';
|
|
|
|
const $ = id => document.getElementById(id);
|
|
const boot = $('boot');
|
|
|
|
// ---------------- renderer / scene / camera ----------------
|
|
const canvas = $('c');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x2c2618);
|
|
scene.fog = new THREE.Fog(0x2c2618, 20, 46);
|
|
|
|
const camera = new THREE.PerspectiveCamera(74, 16 / 9, 0.05, 100);
|
|
camera.position.set(0, 1.65, 6);
|
|
scene.add(camera);
|
|
|
|
// Robust sizing: some hosts report innerWidth/innerHeight = 0 at load, which
|
|
// would make camera.aspect NaN and poison the projection (and the pick ray).
|
|
// Size from the canvas's laid-out box, with a sane fallback, and re-run on any
|
|
// layout change via ResizeObserver.
|
|
function viewSize() {
|
|
const w = canvas.clientWidth || innerWidth || 1280;
|
|
const h = canvas.clientHeight || innerHeight || 720;
|
|
return [Math.max(1, w), Math.max(1, h)];
|
|
}
|
|
function resize() {
|
|
const [w, h] = viewSize();
|
|
camera.aspect = w / h; camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h, false); // false → CSS (100%/100%) keeps controlling display size
|
|
}
|
|
resize();
|
|
addEventListener('resize', resize);
|
|
try { new ResizeObserver(resize).observe(canvas); } catch (e) {}
|
|
|
|
// lights — cheap, holds 60fps with a roomful of props
|
|
scene.add(new THREE.HemisphereLight(0xfff4e6, 0x40372a, 1.35));
|
|
const key = new THREE.DirectionalLight(0xfff6ea, 1.35); key.position.set(4, 8, 3); scene.add(key);
|
|
const fill = new THREE.PointLight(0xffe2b4, 0.9, 44); fill.position.set(-3, 3.2, 2); scene.add(fill);
|
|
const fill2 = new THREE.PointLight(0xd8e0ff, 0.55, 40); fill2.position.set(4, 3.2, -3); scene.add(fill2);
|
|
|
|
// ---------------- pointer lock (thriftgod's safeLock pattern) ----------------
|
|
const controls = new PointerLockControls(camera, renderer.domElement);
|
|
const startEl = $('start');
|
|
let paused = true, booted = false;
|
|
|
|
function safeLock() {
|
|
try {
|
|
const p = renderer.domElement.requestPointerLock();
|
|
if (p && p.catch) p.catch(() => { startEl.classList.remove('hidden'); });
|
|
} catch (e) { startEl.classList.remove('hidden'); }
|
|
}
|
|
$('goBtn').onclick = () => { if (booted) { juice.resumeAudio(); safeLock(); } };
|
|
startEl.onclick = e => { if (booted && e.target.id !== 'shareBtn') { juice.resumeAudio(); safeLock(); } };
|
|
controls.addEventListener('lock', () => { paused = false; startEl.classList.add('hidden'); });
|
|
controls.addEventListener('unlock', () => { paused = true; if (!shareOpen) startEl.classList.remove('hidden'); });
|
|
|
|
// ---------------- juice ----------------
|
|
const juice = createJuice({
|
|
camera, scene,
|
|
dom: { canvas, combo: $('combo'), comboN: $('comboN'), comboX: $('comboX'), tallyN: $('tallyN') },
|
|
});
|
|
|
|
// ---------------- room ----------------
|
|
const ROOM = { w: 16, d: 18, h: 3.6 };
|
|
let phys = null;
|
|
|
|
const woodTex = makeGridTexture('#7a6242', '#5f4c32', 8);
|
|
const floorTex = makeGridTexture('#544636', '#463a2c', 12);
|
|
|
|
function makeGridTexture(a, b, reps) {
|
|
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
|
|
const ctx = cv.getContext('2d');
|
|
ctx.fillStyle = a; ctx.fillRect(0, 0, 128, 128);
|
|
ctx.strokeStyle = b; ctx.lineWidth = 8;
|
|
ctx.strokeRect(0, 0, 128, 128);
|
|
const tx = new THREE.CanvasTexture(cv); tx.wrapS = tx.wrapT = THREE.RepeatWrapping;
|
|
tx.repeat.set(reps, reps); tx.colorSpace = THREE.SRGBColorSpace;
|
|
return tx;
|
|
}
|
|
|
|
function buildRoom() {
|
|
const { w, d, h } = ROOM;
|
|
const floorMat = new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.95 });
|
|
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat);
|
|
floor.rotation.x = -Math.PI / 2; scene.add(floor);
|
|
|
|
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), new THREE.MeshStandardMaterial({ color: 0x413628, roughness: 1 }));
|
|
ceil.rotation.x = Math.PI / 2; ceil.position.y = h; scene.add(ceil);
|
|
|
|
const wallMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.9, side: THREE.FrontSide });
|
|
const mkWall = (ww, hh, x, y, z, ry) => {
|
|
const m = new THREE.Mesh(new THREE.PlaneGeometry(ww, hh), wallMat);
|
|
m.position.set(x, y, z); m.rotation.y = ry; scene.add(m);
|
|
};
|
|
mkWall(w, h, 0, h / 2, -d / 2, 0); // back
|
|
mkWall(w, h, 0, h / 2, d / 2, Math.PI); // front
|
|
mkWall(d, h, -w / 2, h / 2, 0, Math.PI / 2); // left
|
|
mkWall(d, h, w / 2, h / 2, 0, -Math.PI / 2); // right
|
|
|
|
// static colliders: floor + 4 walls (thick slabs just outside the room)
|
|
phys.addStaticBox(new THREE.Vector3(0, -0.5, 0), new THREE.Vector3(w / 2, 0.5, d / 2));
|
|
const t = 0.5;
|
|
phys.addStaticBox(new THREE.Vector3(0, h / 2, -d / 2 - t), new THREE.Vector3(w / 2, h, t));
|
|
phys.addStaticBox(new THREE.Vector3(0, h / 2, d / 2 + t), new THREE.Vector3(w / 2, h, t));
|
|
phys.addStaticBox(new THREE.Vector3(-w / 2 - t, h / 2, 0), new THREE.Vector3(t, h, d / 2));
|
|
phys.addStaticBox(new THREE.Vector3(w / 2 + t, h / 2, 0), new THREE.Vector3(t, h, d / 2));
|
|
phys.addStaticBox(new THREE.Vector3(0, h + t, 0), new THREE.Vector3(w / 2, t, d / 2)); // ceiling
|
|
}
|
|
|
|
// place a loaded prop clone at a world transform and register it with physics
|
|
function placeProp(scene3d, worldPos, opts = {}) {
|
|
const g = new THREE.Group();
|
|
g.add(scene3d);
|
|
if (opts.rotY) g.rotation.y = opts.rotY;
|
|
if (opts.scale) g.scale.setScalar(opts.scale);
|
|
g.position.copy(worldPos);
|
|
scene.add(g);
|
|
g.userData.tintHex = (MATERIALS[opts.material] || MATERIALS.wood).particles;
|
|
return phys.addProp(g, opts);
|
|
}
|
|
|
|
// ---------------- record ritual (lite) ----------------
|
|
// A bin of pull-able record meshes (decoration, not physics). Look at one, press
|
|
// E / click to slide it out into your hands; click again to throw + smash it.
|
|
const recordBin = { records: [], mesh: null };
|
|
let recordTemplate = null;
|
|
let held = null; // { mesh, spin }
|
|
let pulling = null; // { rec, t }
|
|
|
|
function buildRecordBin(pos) {
|
|
if (!recordTemplate) return;
|
|
const bin = new THREE.Group(); bin.position.copy(pos); scene.add(bin);
|
|
recordBin.mesh = bin;
|
|
const N = 9;
|
|
for (let i = 0; i < N; i++) {
|
|
const r = recordTemplate.clone(true);
|
|
r.rotation.x = -0.18 + i * 0.01; // leaned back, riffle-style
|
|
// sit them high enough to clearly protrude above the crate lip (targetable)
|
|
r.position.set(((i * 40503 >>> 0) % 100 - 50) / 900, 0.34, -0.14 + i * 0.05);
|
|
bin.add(r);
|
|
recordBin.records.push({ mesh: r, taken: false, home: r.position.clone() });
|
|
}
|
|
}
|
|
|
|
function nearestRecord() {
|
|
// the front-most un-taken record the ray is looking near
|
|
for (let i = recordBin.records.length - 1; i >= 0; i--) {
|
|
if (!recordBin.records[i].taken) return recordBin.records[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pullRecord(rec) {
|
|
if (!rec || rec.taken || held || pulling) return;
|
|
rec.taken = true;
|
|
pulling = { rec, t: 0 };
|
|
juice.playThrow();
|
|
}
|
|
|
|
function updatePull(dt) {
|
|
if (pulling) {
|
|
pulling.t = Math.min(1, pulling.t + dt * 3.2);
|
|
const e = pulling.t * pulling.t * (3 - 2 * pulling.t); // smoothstep
|
|
const m = pulling.rec.mesh;
|
|
// slide up out of the bin
|
|
m.position.lerpVectors(pulling.rec.home, new THREE.Vector3(pulling.rec.home.x, 0.55, 0.25), e);
|
|
if (pulling.t >= 1) {
|
|
// hand it off to "held"
|
|
recordBin.mesh.remove(m);
|
|
m.position.set(0, 0, 0); m.rotation.set(0, 0, 0);
|
|
scene.add(m);
|
|
held = { mesh: m, spin: 0 };
|
|
pulling = null;
|
|
}
|
|
}
|
|
if (held) {
|
|
held.spin += dt * 3;
|
|
// float it in front of the camera, a touch low and off to the side (in-hand)
|
|
const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd);
|
|
const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
|
|
const pos = camera.position.clone().add(fwd.multiplyScalar(0.95)).add(right.multiplyScalar(0.22));
|
|
pos.y -= 0.22;
|
|
held.mesh.position.lerp(pos, Math.min(1, dt * 18));
|
|
held.mesh.rotation.y = held.spin;
|
|
held.mesh.rotation.x = -0.35;
|
|
}
|
|
}
|
|
|
|
function throwHeld() {
|
|
if (!held) return;
|
|
const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd);
|
|
const start = held.mesh.position.clone();
|
|
scene.remove(held.mesh);
|
|
// spawn a real physics record at the throw point
|
|
const g = new THREE.Group(); g.add(held.mesh);
|
|
held.mesh.position.set(0, 0, 0); held.mesh.rotation.set(0, 0, 0);
|
|
g.position.copy(start); scene.add(g);
|
|
g.userData.tintHex = MATERIALS.vinyl.particles;
|
|
const prop = phys.addProp(g, { material: 'vinyl', kind: 'record', brittle: true, mass: 0.3, fractured: fracturedTemplates.record });
|
|
const speed = 13;
|
|
prop.body.setLinvel({ x: fwd.x * speed, y: fwd.y * speed + 1.2, z: fwd.z * speed }, true);
|
|
prop.body.setAngvel({ x: (Math.random() - 0.5) * 20, y: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 }, true);
|
|
held = null;
|
|
juice.playThrow();
|
|
}
|
|
|
|
// ---------------- interaction (center ray) ----------------
|
|
const ray = new THREE.Raycaster();
|
|
const cross = $('cross');
|
|
const _fwd = new THREE.Vector3();
|
|
let lookProp = null, lookRecord = false;
|
|
|
|
function updateLook() {
|
|
if (paused) return;
|
|
camera.updateMatrixWorld(); // cast from where the camera is THIS frame
|
|
ray.setFromCamera({ x: 0, y: 0 }, camera);
|
|
const hits = ray.intersectObjects(scene.children, true);
|
|
lookProp = null; lookRecord = false;
|
|
for (const h of hits) {
|
|
if (h.distance > 4.2) break;
|
|
// a pull-able record in the bin?
|
|
let o = h.object, rec = null;
|
|
while (o) { if (o.userData && o.userData.__binRec) { rec = o.userData.__binRec; break; } o = o.parent; }
|
|
if (rec && !rec.taken) { lookRecord = true; break; }
|
|
const prop = phys.propAt(h.object);
|
|
if (prop && !prop.smashed) { lookProp = prop; break; }
|
|
}
|
|
const wantHand = lookRecord || !!lookProp || !!held;
|
|
cross.classList.toggle('hand', wantHand);
|
|
}
|
|
|
|
function smashProp(prop, power = 1.1) {
|
|
if (!prop || prop.smashed) return;
|
|
camera.getWorldDirection(_fwd);
|
|
const dir = { x: _fwd.x, y: 0, z: _fwd.z };
|
|
const t = prop.body.translation();
|
|
const wp = new THREE.Vector3(t.x + prop.center.x, t.y + prop.center.y, t.z + prop.center.z);
|
|
juice.smash(wp, prop.material, power);
|
|
if (prop.kind === 'rack') {
|
|
phys.knock(prop, dir, power); // topple the support → dependents pancake
|
|
} else if (prop.shatterable) {
|
|
phys.shatter(prop, dir, power); // crate / bin / record → shards
|
|
} else {
|
|
phys.knock(prop, dir, power); // cashbot / mpc → comedic topple + steel ring
|
|
}
|
|
}
|
|
|
|
function onClick() {
|
|
if (paused || !booted) return;
|
|
if (held) { throwHeld(); return; }
|
|
if (lookRecord) { pullRecord(nearestRecord()); return; }
|
|
if (lookProp) { smashProp(lookProp, 1.15); return; }
|
|
}
|
|
addEventListener('mousedown', e => { if (e.button === 0) onClick(); });
|
|
addEventListener('keydown', e => {
|
|
if (e.code === 'KeyE') { if (held) throwHeld(); else if (lookRecord) pullRecord(nearestRecord()); }
|
|
});
|
|
|
|
// ---------------- movement (thriftgod loop) ----------------
|
|
const keys = {};
|
|
addEventListener('keydown', e => { keys[e.code] = true; });
|
|
addEventListener('keyup', e => { keys[e.code] = false; });
|
|
const vel = new THREE.Vector3();
|
|
const lastJitter = new THREE.Vector3();
|
|
|
|
function move(dt) {
|
|
const sp = 3.4 * dt;
|
|
const R = keys.KeyD || keys.ArrowRight, Le = keys.KeyA || keys.ArrowLeft,
|
|
Bk = keys.KeyS || keys.ArrowDown, Fw = keys.KeyW || keys.ArrowUp;
|
|
vel.set((R ? 1 : 0) - (Le ? 1 : 0), 0, (Bk ? 1 : 0) - (Fw ? 1 : 0));
|
|
if (vel.lengthSq()) {
|
|
vel.normalize();
|
|
controls.moveRight(vel.x * sp); controls.moveForward(-vel.z * sp);
|
|
}
|
|
// keep the player inside the room
|
|
const m = 0.35;
|
|
camera.position.x = Math.max(-ROOM.w / 2 + m, Math.min(ROOM.w / 2 - m, camera.position.x));
|
|
camera.position.z = Math.max(-ROOM.d / 2 + m, Math.min(ROOM.d / 2 - m, camera.position.z));
|
|
camera.position.y = 1.65;
|
|
}
|
|
|
|
// ---------------- share-your-mess ----------------
|
|
let shareOpen = false;
|
|
$('shareBtn').onclick = openShare;
|
|
$('shareClose').onclick = closeShare;
|
|
function openShare() {
|
|
render(); // ensure the buffer is fresh
|
|
const url = renderer.domElement.toDataURL('image/png');
|
|
$('shareImg').src = url;
|
|
$('shareDl').href = url;
|
|
const n = juice.totalSmashed;
|
|
$('shareCap').innerHTML = n > 0
|
|
? `You put <b>${n}</b> ${n === 1 ? 'thing' : 'things'} through it. The staff will not be pleased.`
|
|
: `A suspiciously tidy record store. Go break something.`;
|
|
$('share').style.display = 'flex';
|
|
shareOpen = true;
|
|
if (controls.isLocked) document.exitPointerLock();
|
|
}
|
|
function closeShare() { $('share').style.display = 'none'; shareOpen = false; startEl.classList.remove('hidden'); }
|
|
|
|
// ---------------- boot ----------------
|
|
const fracturedTemplates = {}; // id -> fractured Object3D (from Lane 2, if shipped)
|
|
|
|
async function build() {
|
|
boot.textContent = 'starting physics…';
|
|
await initPhysics();
|
|
phys = createPhysics(scene);
|
|
phys.setSmashHandler((prop, wp, power, shattered) => {
|
|
// a real impact fired by the sim (thrown record hits a wall, crate slams floor)
|
|
juice.smash(wp, prop.material, power);
|
|
});
|
|
|
|
buildRoom();
|
|
|
|
boot.textContent = 'loading props from 3GOD…';
|
|
// core smashables (local mirror, always present)
|
|
const [recordRes, crateRes, rackRes] = await Promise.all([
|
|
loadProp('record'), loadProp('crate'), loadProp('rack'),
|
|
]);
|
|
recordTemplate = recordRes ? recordRes.scene : null;
|
|
|
|
// try to pull a pre-fractured record from Lane 2's pipeline (graceful if absent)
|
|
try { fracturedTemplates.record = await loadFractured('record'); } catch (e) {}
|
|
|
|
layoutRoom({ crateRes, rackRes });
|
|
|
|
// flavor props from the live depot (local fallback for cashbot/bin; others optional)
|
|
loadDepotFlavor();
|
|
|
|
// a little "N on the shelf" readout, best-effort
|
|
fetchShelf().then(list => { if (list.length) boot.dataset.shelf = list.length; });
|
|
|
|
booted = true;
|
|
boot.textContent = 'ready — click to wreck';
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
function layoutRoom({ crateRes, rackRes }) {
|
|
const { w, d } = ROOM;
|
|
|
|
// racks along the back wall — fixed supports holding crates that pancake
|
|
if (rackRes) {
|
|
for (let i = -1; i <= 1; i++) {
|
|
const rackClone = i === -1 ? rackRes.scene : rackRes.scene.clone(true);
|
|
const rack = placeProp(rackClone, new THREE.Vector3(i * 4.2, 0, -d / 2 + 1.4),
|
|
{ material: 'wood', kind: 'rack', fixed: true, rotY: 0 });
|
|
// stack two crates on each rack; smashing the rack drops them
|
|
if (crateRes) {
|
|
for (let k = 0; k < 2; k++) {
|
|
const crate = placeProp(crateRes.scene.clone(true),
|
|
new THREE.Vector3(i * 4.2 - 0.2 + k * 0.4, 0.82 + k * 0.34, -d / 2 + 1.4),
|
|
{ material: 'wood', kind: 'crate', fixed: true });
|
|
phys.support(rack, crate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a pyramid of crates mid-floor to smash + topple
|
|
if (crateRes) {
|
|
const base = new THREE.Vector3(-3.5, 0, 1);
|
|
const rows = [3, 2, 1];
|
|
let y = 0;
|
|
const placed = [];
|
|
rows.forEach((count, row) => {
|
|
const layer = [];
|
|
for (let c = 0; c < count; c++) {
|
|
const x = base.x + (c - (count - 1) / 2) * 0.4 + row * 0.2;
|
|
const cr = placeProp(crateRes.scene.clone(true), new THREE.Vector3(x, y, base.z),
|
|
{ material: 'wood', kind: 'crate', fixed: true }); // held until the base is smashed
|
|
layer.push(cr);
|
|
}
|
|
// link support: each upper crate rests on the layer below
|
|
if (placed.length) layer.forEach(up => placed[placed.length - 1].forEach(dn => phys.support(dn, up)));
|
|
placed.push(layer);
|
|
y += 0.34;
|
|
});
|
|
// a few loose floor crates to knock around
|
|
[[3.5, 2.2], [4.5, 0.5], [2.6, -1.5]].forEach(([x, z]) =>
|
|
placeProp(crateRes.scene.clone(true), new THREE.Vector3(x, 0, z), { material: 'wood', kind: 'crate', fixed: true }));
|
|
}
|
|
|
|
// the record bin you riffle + pull from
|
|
if (recordTemplate) {
|
|
buildRecordBin(new THREE.Vector3(0.5, 0, 3.2));
|
|
// tag bin records so the ray can find them
|
|
recordBin.records.forEach(rec => rec.mesh.traverse(o => { o.userData.__binRec = rec; }));
|
|
// an OPEN-TOP wooden crate around them, so records protrude and stay
|
|
// targetable from above (a solid box would occlude them)
|
|
const shellMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.9 });
|
|
const crate = new THREE.Group(); crate.position.set(0.5, 0, 3.2);
|
|
const CW = 0.72, CH = 0.34, TH = 0.04;
|
|
const wall = (w, h, d, x, y, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), shellMat); m.position.set(x, y, z); crate.add(m); };
|
|
wall(CW, TH, CW, 0, TH / 2, 0); // floor
|
|
wall(CW, CH, TH, 0, CH / 2, -CW / 2 + TH / 2); // back
|
|
wall(CW, CH, TH, 0, CH / 2, CW / 2 - TH / 2); // front
|
|
wall(TH, CH, CW, -CW / 2 + TH / 2, CH / 2, 0); // left
|
|
wall(TH, CH, CW, CW / 2 - TH / 2, CH / 2, 0); // right
|
|
scene.add(crate);
|
|
}
|
|
}
|
|
|
|
async function loadDepotFlavor() {
|
|
// positions kept clear of the back-wall racks (x∈{-4.2,0,4.2}, z≈-7.6),
|
|
// the mid pyramid (≈-3.5,1) and the record bin (0.5,3.2)
|
|
const flavor = [
|
|
{ id: 'cashbot', pos: [-6, 0, 5.5], material: 'steel', rotY: 0.6 }, // greeter by the door
|
|
{ id: 'bin', pos: [-w2() + 1.0, 0, -1.5], material: 'steel' },
|
|
{ id: 'bin', pos: [w2() - 1.0, 0, 3.5], material: 'steel' },
|
|
{ id: 'mpc', pos: [6, 0, 0], material: 'steel' },
|
|
{ id: 'gameboy', pos: [-6, 0, -3.5], material: 'cardboard' },
|
|
];
|
|
for (const f of flavor) {
|
|
try {
|
|
const res = await loadProp(f.id);
|
|
if (!res) continue;
|
|
const def = CATALOG[f.id];
|
|
placeProp(res.scene, new THREE.Vector3(f.pos[0], f.pos[1], f.pos[2]),
|
|
{ material: f.material || def.material, kind: def.kind, scale: f.scale, rotY: f.rotY, fixed: true });
|
|
} catch (e) { /* depot prop unavailable — skip gracefully */ }
|
|
}
|
|
}
|
|
function w2() { return ROOM.w / 2; }
|
|
function d2() { return ROOM.d / 2; }
|
|
|
|
// ---------------- main loop ----------------
|
|
const clock = new THREE.Clock();
|
|
let frame = 0;
|
|
|
|
function render() { renderer.render(scene, camera); }
|
|
|
|
function stepFrame(dt) {
|
|
// undo last frame's camera shake offset before moving (keeps movement authoritative)
|
|
camera.position.sub(lastJitter);
|
|
|
|
if (!paused && !shareOpen) move(dt);
|
|
|
|
updatePull(dt);
|
|
if (++frame % 4 === 0) updateLook();
|
|
|
|
phys.step(dt, juice.frozen());
|
|
juice.update(dt);
|
|
|
|
// re-apply camera shake for this frame
|
|
lastJitter.copy(juice.camJitter);
|
|
camera.position.add(lastJitter);
|
|
|
|
render();
|
|
}
|
|
|
|
function tick() {
|
|
requestAnimationFrame(tick);
|
|
stepFrame(Math.min(clock.getDelta(), 0.05));
|
|
}
|
|
|
|
// Debug handle, only attached with ?debug — lets you drive the sim without a
|
|
// Pointer Lock (e.g. embedded frames / headless smoke tests) and step it by hand.
|
|
if (location.search.includes('debug')) {
|
|
window.DESTROY = {
|
|
get scene() { return scene; }, get camera() { return camera; }, get phys() { return phys; }, juice,
|
|
unpause() { paused = false; startEl.classList.add('hidden'); },
|
|
look(yaw, pitch = 0) { camera.rotation.set(pitch, yaw, 0, 'YXZ'); },
|
|
teleport(x, y, z) { camera.position.set(x, y, z); lastJitter.set(0, 0, 0); },
|
|
aimSmash() { updateLook(); if (lookProp) { smashProp(lookProp, 1.2); return lookProp.kind; } return null; },
|
|
pull() { updateLook(); if (lookRecord) { pullRecord(nearestRecord()); return 'pulling'; } return 'no record in view'; },
|
|
throwR() { const had = !!held; throwHeld(); return had ? 'thrown' : 'nothing held'; },
|
|
sim(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) stepFrame(dt); return this.state(); },
|
|
counts() { return { props: phys && phys.propCount, debris: phys && phys.debrisCount, smashed: juice.totalSmashed }; },
|
|
state() { return { held: !!held, pulling: !!pulling, recordsLeft: recordBin.records.filter(r => !r.taken).length }; },
|
|
};
|
|
}
|
|
|
|
// kick it off
|
|
build().catch(err => { boot.textContent = 'boot error: ' + (err && err.message || err); console.error(err); });
|