Consume Lane 2's props + close the review gaps in the browser toy. props.js: all 16 depot slugs added to CATALOG (material/kind per prop; printer is the boss). loadFractured() now (a) caches its result per id — including the null miss — so a prop with no fractured sibling stops re-fetching + 404-ing on EVERY spawn (the record. fractured.glb spam), and (b) returns the real chunk container (the fractured_root wrapper Blender exports), so physics.js's direct-children chunk swap actually fires. main.js: loadHeroProps() places the printer BOSS + office set dressing (desk, crt-monitor, filing-cabinet, water-cooler) with their fractured templates. The boss soaks 4 hits then bursts into its 16 GLB chunks (kept non-shatterable so a topple can't break it early). Pointer-lock fallback (shareability fix): if the lock request is refused — iframes/embedded hosts, where the toy used to dead-end on the title screen — we fall back to DRAG-TO-LOOK (hold-drag rotates, a tap still smashes) so the shareable link always plays. Assets: office-printer / crt-monitor / filing-cabinet mirrored into web/assets/ (offline fallback); record/crate/rack.fractured.glb generated + mirrored so the record ritual smashes into real chunks. The three store fractured GLBs were also pushed to the 3GOD shelf. Verified in-browser via ?debug: printer aimSmash→"printer" then 16 chunks; thrown record → 8 chunks; debris drains 16→0; record.fractured.glb serves 200 (no 404); drag-look rotates the camera when pointer lock is unavailable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
587 lines
25 KiB
JavaScript
587 lines
25 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 + 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 <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 });
|
|
|
|
// 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); });
|