destroyulator/web/props.js
Monster Robot Party ca8b22c3c0 Lane 3: browser smash toy in web/ — FP record store, Rapier destruction, 3GOD props
A shareable one-click web toy (not a port of the Godot game): walk a record store
first-person, pull a record, and wreck the place in the browser. Built on
thriftgod's pointer-lock FP-interior patterns, zero build step (three r0.175 +
@dimforge/rapier3d-compat over an importmap).

- physics.js: Rapier WASM world, one body per prop, support-graph cascade
  (smash a rack → its crates pancake), pre-fractured swap (chunk_* bodies) with
  generative-shard fallback until Lane 2 ships fractured GLBs, capped debris pool.
- props.js: GLTFLoader/DRACOLoader; loads props live from the 3GOD depot
  (/a/<file>, CORS-open) with a local web/assets/ mirror fallback so it never
  hard-fails offline. Read-only against the depot.
- juice.js: camera-trauma screenshake, hitstop, per-material particle bursts and
  procedural Web-Audio smash voices (wood/cardboard/vinyl/glass/steel), combo HUD.
- main.js: FP controller, record-store room, grab/smash raycasting, record ritual
  (pull → held → throw → shatter), "share your mess" canvas screenshot.

Assets mirrored offline: record/crate/rack (from the Godot lane) + cashbot/
council-bin (from 3GOD). Verified in-browser: props load, smashing spawns shards +
particles + combo, rack cascade pancakes its crates, record ritual and share work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:14:32 +10:00

105 lines
4.5 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' },
};
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.
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 */ }
}
return null;
}
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 []; }
}