diff --git a/web/assets/crate.fractured.glb b/web/assets/crate.fractured.glb new file mode 100644 index 0000000..721176e Binary files /dev/null and b/web/assets/crate.fractured.glb differ diff --git a/web/assets/crt-monitor.glb b/web/assets/crt-monitor.glb new file mode 100644 index 0000000..6d7aa2f Binary files /dev/null and b/web/assets/crt-monitor.glb differ diff --git a/web/assets/filing-cabinet.glb b/web/assets/filing-cabinet.glb new file mode 100644 index 0000000..0281841 Binary files /dev/null and b/web/assets/filing-cabinet.glb differ diff --git a/web/assets/office-printer.glb b/web/assets/office-printer.glb new file mode 100644 index 0000000..9cca8ac Binary files /dev/null and b/web/assets/office-printer.glb differ diff --git a/web/assets/rack.fractured.glb b/web/assets/rack.fractured.glb new file mode 100644 index 0000000..864aab4 Binary files /dev/null and b/web/assets/rack.fractured.glb differ diff --git a/web/assets/record.fractured.glb b/web/assets/record.fractured.glb new file mode 100644 index 0000000..86d07ca Binary files /dev/null and b/web/assets/record.fractured.glb differ diff --git a/web/main.js b/web/main.js index 2aac938..78f3ecc 100644 --- a/web/main.js +++ b/web/main.js @@ -52,22 +52,75 @@ const key = new THREE.DirectionalLight(0xfff6ea, 1.35); key.position.set(4, 8, 3 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) ---------------- +// ---------------- 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 && p.catch) p.catch(() => { startEl.classList.remove('hidden'); }); - } catch (e) { startEl.classList.remove('hidden'); } + 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', () => { paused = false; startEl.classList.add('hidden'); }); +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, @@ -249,11 +302,20 @@ function smashProp(prop, power = 1.1) { 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); + + // 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) { - phys.shatter(prop, dir, power); // crate / bin / record → shards + } 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 } @@ -265,7 +327,9 @@ function onClick() { if (lookRecord) { pullRecord(nearestRecord()); return; } if (lookProp) { smashProp(lookProp, 1.15); return; } } -addEventListener('mousedown', e => { if (e.button === 0) onClick(); }); +// 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()); } }); @@ -338,6 +402,11 @@ async function build() { 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(); @@ -414,6 +483,35 @@ function layoutRoom({ crateRes, rackRes }) { } } +// 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) diff --git a/web/props.js b/web/props.js index ca44a81..641517f 100644 --- a/web/props.js +++ b/web/props.js @@ -24,6 +24,26 @@ export const CATALOG = { // 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' }, + + // --- Lane 2's Destroyulator workplace batch (16 props on the 3GOD shelf, each with a + // .fractured.glb sibling → real chunk destruction via loadFractured + physics.js). The + // printer is the boss. `local` = mirrored into web/assets/ for offline fallback. + 'office-printer': { depot: 'office-printer.glb', local: 'office-printer.glb', material: 'steel', kind: 'printer' }, + 'office-desk': { depot: 'office-desk.glb', material: 'wood', kind: 'prop' }, + 'crt-monitor': { depot: 'crt-monitor.glb', local: 'crt-monitor.glb', material: 'glass', kind: 'prop', brittle: true }, + 'desk-phone': { depot: 'desk-phone.glb', material: 'steel', kind: 'prop' }, + 'filing-cabinet': { depot: 'filing-cabinet.glb', local: 'filing-cabinet.glb', material: 'steel', kind: 'prop' }, + 'water-cooler': { depot: 'water-cooler.glb', material: 'steel', kind: 'prop' }, + 'office-chair': { depot: 'office-chair.glb', material: 'steel', kind: 'prop' }, + 'fluorescent-light':{ depot: 'fluorescent-light.glb', material: 'glass', kind: 'prop', brittle: true }, + 'coffee-mug': { depot: 'coffee-mug.glb', material: 'glass', kind: 'prop', brittle: true }, + 'potted-plant': { depot: 'potted-plant.glb', material: 'wood', kind: 'prop' }, + 'wall-clock': { depot: 'wall-clock.glb', material: 'wood', kind: 'prop' }, + 'cardboard-box': { depot: 'cardboard-box.glb', material: 'cardboard', kind: 'prop' }, + 'turntable': { depot: 'turntable.glb', material: 'wood', kind: 'prop' }, + 'amp-speaker': { depot: 'amp-speaker.glb', material: 'wood', kind: 'prop' }, + 'cash-register': { depot: 'cash-register.glb', material: 'steel', kind: 'prop' }, + 'wall-shelf': { depot: 'wall-shelf.glb', material: 'wood', kind: 'prop' }, }; function depotURL(file) { return `${DEPOT}/a/${encodeURIComponent(file)}`; } @@ -71,22 +91,48 @@ export async function loadProp(id) { // Try to fetch the pre-fractured sibling (Lane 2 contract #2): .fractured.glb. // Returns an Object3D whose direct children are chunk_* meshes, or null if none. +// +// Result (template OR the null miss) is cached per id — CRUCIAL: without it a prop that +// has no fractured sibling (e.g. record before B.4) re-fetched + 404'd on EVERY spawn. +// Now it's one lookup per id for the life of the page; callers still clone the template. +const _fracCache = new Map(); // id -> Promise 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 */ } + if (_fracCache.has(id)) { + const tmpl = await _fracCache.get(id); + return tmpl ? tmpl.clone(true) : null; } - return null; + const promise = (async () => { + const tryURLs = []; + if (def.depot) tryURLs.push(depotURL(def.depot.replace(/\.glb$/i, '') + '.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 container = chunkContainer(gltf.scene); // the node whose CHILDREN are chunk_* + if (container) return prep(container); + } catch (e) { /* not shipped / 404 — cached as a miss below, tried once */ } + } + return null; // cached miss: brittle props fall back to generative shards, no re-fetch + })(); + _fracCache.set(id, promise); + const tmpl = await promise; + return tmpl ? tmpl.clone(true) : null; +} + +// Blender exports the chunks under a `fractured_root` wrapper, so they're NOT direct +// children of the loaded scene. physics.js iterates a template's direct children, so we +// return the node that actually parents the chunk_* meshes (the scene root itself if the +// chunks sit there, otherwise the wrapper). Its own transform is identity, so each chunk's +// local position stays the centroid-in-model-space the consumer expects. +function chunkContainer(root) { + if (root.children.some(c => /^chunk/i.test(c.name))) return root; + let found = null; + root.traverse(o => { + if (!found && o.children && o.children.some(c => /^chunk/i.test(c.name))) found = o; + }); + return found; } function cloneResult(res) {