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>
151 lines
7.6 KiB
JavaScript
151 lines
7.6 KiB
JavaScript
// props.js — the prop house. Loads GLBs live from the 3GOD depot
|
|
// (https://digalot.fyi/3god, GLB at /a/<file>) with a local web/assets/ mirror as
|
|
// fallback so the demo never hard-fails offline. GLTFLoader + DRACOLoader are
|
|
// wired exactly like thriftgod's. Read-only against the depot — no writes, ever
|
|
// (the depot's open-mode/SSRF issue is a write-side problem; see PLAN.md).
|
|
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
|
|
|
export const DEPOT = 'https://digalot.fyi/3god';
|
|
|
|
const gltfLoader = new GLTFLoader();
|
|
{ const d = new DRACOLoader(); d.setDecoderPath('https://unpkg.com/three@0.175.0/examples/jsm/libs/draco/'); gltfLoader.setDRACOLoader(d); }
|
|
|
|
// The catalog. `depot` = filename on 3GOD; `local` = mirror in web/assets/.
|
|
// material tags feed juice.js voices; kind drives interaction in main.js.
|
|
export const CATALOG = {
|
|
record: { local: 'record.glb', material: 'vinyl', kind: 'record', brittle: true, grabbable: true },
|
|
crate: { local: 'crate.glb', material: 'wood', kind: 'crate' },
|
|
rack: { local: 'rack.glb', material: 'wood', kind: 'rack' },
|
|
cashbot: { depot: 'cashbot.glb', local: 'cashbot.glb', material: 'steel', kind: 'prop' },
|
|
bin: { depot: 'council-bin.glb', local: 'council-bin.glb', material: 'steel', kind: 'prop' },
|
|
// 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)}`; }
|
|
function localURL(file) { return `./assets/${encodeURIComponent(file)}`; }
|
|
|
|
// tidy a freshly-loaded gltf scene: drop shadow flags (cheap), keep materials.
|
|
function prep(scene) {
|
|
scene.traverse(o => {
|
|
if (o.isMesh) { o.castShadow = false; o.receiveShadow = false; o.frustumCulled = true; }
|
|
});
|
|
return scene;
|
|
}
|
|
|
|
const _cache = new Map(); // id -> Promise<{scene, source}>
|
|
|
|
// Load a prop by catalog id. Tries the live depot first, then the local mirror.
|
|
// Resolves to { scene: THREE.Object3D, source: 'depot'|'local' } or null.
|
|
export async function loadProp(id) {
|
|
const def = CATALOG[id];
|
|
if (!def) { console.warn('[props] unknown prop', id); return null; }
|
|
if (_cache.has(id)) return cloneResult(await _cache.get(id));
|
|
|
|
const promise = (async () => {
|
|
// 1) live depot
|
|
if (def.depot) {
|
|
try {
|
|
const gltf = await gltfLoader.loadAsync(depotURL(def.depot));
|
|
return { scene: prep(gltf.scene), source: 'depot' };
|
|
} catch (e) { /* fall through to local */ }
|
|
}
|
|
// 2) local mirror
|
|
if (def.local) {
|
|
try {
|
|
const gltf = await gltfLoader.loadAsync(localURL(def.local));
|
|
return { scene: prep(gltf.scene), source: 'local' };
|
|
} catch (e) { console.warn('[props] local load failed', id, e && e.message); }
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
_cache.set(id, promise);
|
|
const res = await promise;
|
|
return res ? cloneResult(res) : null;
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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) {
|
|
const def = CATALOG[id];
|
|
if (!def) return null;
|
|
if (_fracCache.has(id)) {
|
|
const tmpl = await _fracCache.get(id);
|
|
return tmpl ? tmpl.clone(true) : 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) {
|
|
// clone so each placement is independent (materials shared is fine here)
|
|
return { scene: res.scene.clone(true), source: res.source };
|
|
}
|
|
|
|
// Fetch the live shelf listing (for a quick "N props on the shelf" readout).
|
|
export async function fetchShelf() {
|
|
try {
|
|
const r = await fetch(`${DEPOT}/api/list`, { method: 'GET' });
|
|
const j = await r.json();
|
|
return Array.isArray(j.assets) ? j.assets : [];
|
|
} catch (e) { return []; }
|
|
}
|