LANE4 (web): catalog Lane 2 batch, printer boss, 404 fix, drag-to-look
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>
This commit is contained in:
parent
51210215e2
commit
9dd6824c14
BIN
web/assets/crate.fractured.glb
Normal file
BIN
web/assets/crate.fractured.glb
Normal file
Binary file not shown.
BIN
web/assets/crt-monitor.glb
Normal file
BIN
web/assets/crt-monitor.glb
Normal file
Binary file not shown.
BIN
web/assets/filing-cabinet.glb
Normal file
BIN
web/assets/filing-cabinet.glb
Normal file
Binary file not shown.
BIN
web/assets/office-printer.glb
Normal file
BIN
web/assets/office-printer.glb
Normal file
Binary file not shown.
BIN
web/assets/rack.fractured.glb
Normal file
BIN
web/assets/rack.fractured.glb
Normal file
Binary file not shown.
BIN
web/assets/record.fractured.glb
Normal file
BIN
web/assets/record.fractured.glb
Normal file
Binary file not shown.
114
web/main.js
114
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 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);
|
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 controls = new PointerLockControls(camera, renderer.domElement);
|
||||||
const startEl = $('start');
|
const startEl = $('start');
|
||||||
let paused = true, booted = false;
|
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() {
|
function safeLock() {
|
||||||
|
if (dragLook) { paused = false; startEl.classList.add('hidden'); return; }
|
||||||
|
lockPending = true;
|
||||||
try {
|
try {
|
||||||
const p = renderer.domElement.requestPointerLock();
|
const p = renderer.domElement.requestPointerLock();
|
||||||
if (p && p.catch) p.catch(() => { startEl.classList.remove('hidden'); });
|
if (p && typeof p.then === 'function') p.then(() => { lockPending = false; }, onLockFail);
|
||||||
} catch (e) { startEl.classList.remove('hidden'); }
|
} 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(); } };
|
$('goBtn').onclick = () => { if (booted) { juice.resumeAudio(); safeLock(); } };
|
||||||
startEl.onclick = e => { if (booted && e.target.id !== 'shareBtn') { 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'); });
|
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 ----------------
|
// ---------------- juice ----------------
|
||||||
const juice = createJuice({
|
const juice = createJuice({
|
||||||
camera, scene,
|
camera, scene,
|
||||||
@ -249,11 +302,20 @@ function smashProp(prop, power = 1.1) {
|
|||||||
const dir = { x: _fwd.x, y: 0, z: _fwd.z };
|
const dir = { x: _fwd.x, y: 0, z: _fwd.z };
|
||||||
const t = prop.body.translation();
|
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);
|
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') {
|
if (prop.kind === 'rack') {
|
||||||
phys.knock(prop, dir, power); // topple the support → dependents pancake
|
phys.knock(prop, dir, power); // topple the support → dependents pancake
|
||||||
} else if (prop.shatterable) {
|
} else if (prop.shatterable || prop.isBoss) {
|
||||||
phys.shatter(prop, dir, power); // crate / bin / record → shards
|
phys.shatter(prop, dir, prop.isBoss ? power * 1.6 : power); // crate / bin / record / boss → chunks
|
||||||
} else {
|
} else {
|
||||||
phys.knock(prop, dir, power); // cashbot / mpc → comedic topple + steel ring
|
phys.knock(prop, dir, power); // cashbot / mpc → comedic topple + steel ring
|
||||||
}
|
}
|
||||||
@ -265,7 +327,9 @@ function onClick() {
|
|||||||
if (lookRecord) { pullRecord(nearestRecord()); return; }
|
if (lookRecord) { pullRecord(nearestRecord()); return; }
|
||||||
if (lookProp) { smashProp(lookProp, 1.15); 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 => {
|
addEventListener('keydown', e => {
|
||||||
if (e.code === 'KeyE') { if (held) throwHeld(); else if (lookRecord) pullRecord(nearestRecord()); }
|
if (e.code === 'KeyE') { if (held) throwHeld(); else if (lookRecord) pullRecord(nearestRecord()); }
|
||||||
});
|
});
|
||||||
@ -338,6 +402,11 @@ async function build() {
|
|||||||
|
|
||||||
layoutRoom({ crateRes, rackRes });
|
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)
|
// flavor props from the live depot (local fallback for cashbot/bin; others optional)
|
||||||
loadDepotFlavor();
|
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() {
|
async function loadDepotFlavor() {
|
||||||
// positions kept clear of the back-wall racks (x∈{-4.2,0,4.2}, z≈-7.6),
|
// 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)
|
// the mid pyramid (≈-3.5,1) and the record bin (0.5,3.2)
|
||||||
|
|||||||
70
web/props.js
70
web/props.js
@ -24,6 +24,26 @@ export const CATALOG = {
|
|||||||
// live-only flavor from the depot (no local mirror — absent gracefully if offline)
|
// 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' },
|
mpc: { depot: 'akai-mpc-live-iii-6fe39f.glb', material: 'steel', kind: 'prop' },
|
||||||
gameboy: { depot: 'gameboybox-3c6cb9.glb', material: 'cardboard', 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)}`; }
|
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): <base>.fractured.glb.
|
// 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.
|
// 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<Object3D|null>
|
||||||
export async function loadFractured(id) {
|
export async function loadFractured(id) {
|
||||||
const def = CATALOG[id];
|
const def = CATALOG[id];
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
const base = (def.depot || def.local || '').replace(/\.glb$/i, '');
|
if (_fracCache.has(id)) {
|
||||||
if (!base) return null;
|
const tmpl = await _fracCache.get(id);
|
||||||
const tryURLs = [];
|
return tmpl ? tmpl.clone(true) : null;
|
||||||
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;
|
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) {
|
function cloneResult(res) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user