// 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 + drag-to-look fallback ---------------- // Pointer Lock is the primary look control, but it's unavailable in embedded/iframe // contexts (no allow="pointer-lock") — where it used to dead-end on the title screen. // If the lock request is refused we fall back to DRAG-TO-LOOK so the shareable link // always plays: hold-drag rotates, a tap still smashes. const controls = new PointerLockControls(camera, renderer.domElement); const startEl = $('start'); let paused = true, booted = false; let dragLook = false; // true once we've fallen back to manual drag-look let lockPending = false; function enterDragLook() { lockPending = false; dragLook = true; paused = false; startEl.classList.add('hidden'); document.body.style.cursor = 'grab'; } function onLockFail() { if (!controls.isLocked) enterDragLook(); } function safeLock() { if (dragLook) { paused = false; startEl.classList.add('hidden'); return; } lockPending = true; try { const p = renderer.domElement.requestPointerLock(); if (p && typeof p.then === 'function') p.then(() => { lockPending = false; }, onLockFail); } catch (e) { onLockFail(); return; } // watchdog: browsers that neither reject the promise nor fire pointerlockerror setTimeout(() => { if (lockPending && !controls.isLocked && !dragLook) onLockFail(); }, 350); } document.addEventListener('pointerlockerror', onLockFail); $('goBtn').onclick = () => { if (booted) { juice.resumeAudio(); safeLock(); } }; startEl.onclick = e => { if (booted && e.target.id !== 'shareBtn') { juice.resumeAudio(); safeLock(); } }; controls.addEventListener('lock', () => { lockPending = false; paused = false; startEl.classList.add('hidden'); }); controls.addEventListener('unlock', () => { paused = true; if (!shareOpen) startEl.classList.remove('hidden'); }); // manual drag-look input (only active in the fallback mode) let dragging = false, dragMoved = 0, lastX = 0, lastY = 0; const LOOK_SPEED = 0.0026; const _lookEuler = new THREE.Euler(0, 0, 0, 'YXZ'); function applyLook(dYaw, dPitch) { _lookEuler.setFromQuaternion(camera.quaternion); _lookEuler.y += dYaw; _lookEuler.x = Math.max(-Math.PI / 2 + 0.02, Math.min(Math.PI / 2 - 0.02, _lookEuler.x + dPitch)); _lookEuler.z = 0; camera.quaternion.setFromEuler(_lookEuler); } renderer.domElement.addEventListener('mousedown', e => { if (!dragLook || e.button !== 0) return; dragging = true; dragMoved = 0; lastX = e.clientX; lastY = e.clientY; document.body.style.cursor = 'grabbing'; }); addEventListener('mousemove', e => { if (!dragLook || !dragging) return; const dx = e.clientX - lastX, dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; dragMoved += Math.abs(dx) + Math.abs(dy); applyLook(-dx * LOOK_SPEED, -dy * LOOK_SPEED); // drag right → look right (mouse-look parity) }); addEventListener('mouseup', e => { if (!dragLook || e.button !== 0) return; const wasDrag = dragging && dragMoved > 6; dragging = false; document.body.style.cursor = 'grab'; if (!wasDrag) onClick(); // a tap (not a look-drag) still smashes / pulls / throws }); // ---------------- 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); // the printer BOSS soaks several hits (rocks + juices), then bursts into its GLB chunks. // Placed non-shatterable so a topple can't auto-break it before the final blow lands. if (prop.isBoss && prop.bossHits > 1) { prop.bossHits--; juice.smash(wp, prop.material, power); phys.knock(prop, dir, power * 0.5); return; } juice.smash(wp, prop.material, prop.isBoss ? 1.9 : power); // boss dies loud if (prop.kind === 'rack') { phys.knock(prop, dir, power); // topple the support → dependents pancake } else if (prop.shatterable || prop.isBoss) { phys.shatter(prop, dir, prop.isBoss ? power * 1.6 : power); // crate / bin / record / boss → chunks } 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; } } // pointer-lock mode: a click is a smash. In drag-look mode the drag handlers own the // mouse (a tap → onClick on mouseup), so skip here to avoid a double-fire. addEventListener('mousedown', e => { if (e.button === 0 && !dragLook) 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 ${n} ${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 }); // Lane 2 hero props: the printer BOSS + workplace set dressing, with real fractured // chunk destruction. Awaited so the boss is present the instant play starts. boot.textContent = 'wheeling in the printer…'; await loadHeroProps(); // 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); } } // Lane 2's hero props — the printer boss + a little office set dressing, each with its // pre-fractured template so a smash spawns real GLB chunks (not generative cubes). GLBs // have origin at floor-centre (pipeline contract), so y=0 sits them on the floor. async function loadHeroProps() { const heroes = [ { id: 'office-printer', pos: [0, 0, -3.0], rotY: 0.0, boss: true }, { id: 'office-desk', pos: [2.4, 0, -3.3], rotY: -0.5 }, { id: 'crt-monitor', pos: [-2.4, 0, -3.0], rotY: 0.4 }, { id: 'filing-cabinet', pos: [-5.5, 0, -2.4], rotY: 0.2 }, { id: 'water-cooler', pos: [5.5, 0, -2.0], rotY: -0.3 }, ]; for (const h of heroes) { try { const res = await loadProp(h.id); if (!res) continue; let fractured = null; try { fractured = await loadFractured(h.id); } catch (e) {} const def = CATALOG[h.id]; const prop = placeProp(res.scene, new THREE.Vector3(h.pos[0], h.pos[1], h.pos[2]), { material: def.material, kind: def.kind, fixed: true, rotY: h.rotY, brittle: !!def.brittle, // boss stays non-shatterable so a topple can't break it early; smashProp shatters it shatterable: !h.boss, fractured, }); if (prop && h.boss) { prop.isBoss = true; prop.bossHits = 4; } } catch (e) { /* hero prop unavailable (offline, no mirror) — skip gracefully */ } } } 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); });