// core/assets.js (Lane D) — the manifest loader. Implements the asset contract in docs/TECH.md. // // THE LAW THIS FILE EXISTS TO ENFORCE: every asset is optional at runtime. Nothing in here // throws, ever. A missing manifest, a 404, a corrupt WebP, a box with no network — all resolve // to `null`, and the consumer draws its procedural fallback. GUTS must be fully playable with // an empty assets/gen/, and `?localassets=0` exists so you can prove it on demand. // // Usage (boot constructs it once and passes it to every lane factory): // const assets = await createAssets({ flags, renderer }); // const e = assets.get('textures', 'wall_esophagus_a'); // raw manifest entry | null // const t = assets.texture('wall_esophagus_a'); // {map, normalMap, tile} | null // if (t) mat.uniforms.uDetail.value = t.map; // else: keep the procedural path import * as THREE from 'three'; const MANIFEST_URL = new URL('../../assets/manifest.json', import.meta.url); const ASSETS_BASE = new URL('../../assets/', import.meta.url); // The 3GOD depot (house shared mesh store). `depot:foo.glb` in a manifest resolves here. // Nothing SHIPPED may use it — the game must run offline (README law) — but it lets Lane D // point at depot meshes while prototyping without copying them into the repo. const DEPOT_BASE = 'https://digalot.fyi/3god/'; const EMPTY = { textures: {}, matcaps: {}, models: {}, audio: { beds: {}, sfx: {} } }; /** `depot:x` -> depot URL; anything else -> resolved against web/assets/. */ function resolve(url) { if (typeof url !== 'string' || !url) return null; if (url.startsWith('depot:')) return DEPOT_BASE + url.slice(6); if (/^https?:\/\//.test(url)) return url; return new URL(url, ASSETS_BASE).href; } export async function createAssets({ flags = {}, renderer = null } = {}) { const loader = new THREE.TextureLoader(); const cache = new Map(); // url -> THREE.Texture (one GPU upload per file) const maxAniso = renderer?.capabilities?.getMaxAnisotropy?.() ?? 1; let manifest = EMPTY; // ?localassets=0 => pretend the manifest is empty. This is the fallback-path test switch: // if the game looks broken with it on, someone has violated the optional-asset law. if (flags.localassets === false) { console.info('[assets] ?localassets=0 — empty manifest, procedural fallbacks only'); } else { try { const r = await fetch(MANIFEST_URL, { cache: 'no-cache' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); manifest = { ...EMPTY, ...(await r.json()) }; } catch (e) { // Not an error condition — an asset-free checkout is a supported way to run GUTS. console.info('[assets] no manifest, running asset-free —', e.message); manifest = EMPTY; } } function loadTexture(url, { srgb = true, repeat = null } = {}) { const abs = resolve(url); if (!abs) return null; if (cache.has(abs)) return cache.get(abs); let tex; try { // TextureLoader is async under the hood; a decode failure lands in onError, where we // just mark the texture dead rather than letting it reject into nothing. tex = loader.load(abs, undefined, undefined, () => { console.info(`[assets] failed to decode ${url} — consumer should fall back`); tex.userData.failed = true; }); } catch (e) { console.info(`[assets] failed to load ${url} —`, e.message); return null; } tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.anisotropy = maxAniso; if (repeat) tex.repeat.set(repeat[0], repeat[1]); cache.set(abs, tex); return tex; } const api = { manifest, /** THE contract (TECH.md): raw manifest entry, or null on any miss. Never throws. */ get(category, name) { const cat = manifest?.[category]; if (!cat) return null; if (category === 'audio') return null; // audio is nested; use audioUrl() return cat[name] ?? null; }, /** * Wall texture ready to plug into a material, or null. * `tile` = [repeats around theta, units of s per repeat] (LANE_D_NOTES §tiling). * The stub/world UV is (theta/2pi, s-in-units), so repeat = (tile[0], 1/tile[1]) * is pre-applied here — Lane A shouldn't have to rediscover the convention. */ texture(name) { const e = api.get('textures', name); if (!e) return null; const tile = Array.isArray(e.tile) && e.tile.length === 2 ? e.tile : [4, 16]; const repeat = [tile[0], 1 / tile[1]]; const map = loadTexture(e.url, { srgb: true, repeat }); if (!map) return null; return { map, normalMap: e.normal ? loadTexture(e.normal, { srgb: false, repeat }) : null, tile, repeat, }; }, /** Matcap texture (clamped, non-tiling), or null. */ matcap(name) { const e = api.get('matcaps', name); if (!e) return null; const t = loadTexture(e.url, { srgb: true }); if (t) t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping; return t; }, /** Model manifest entry (url resolved), or null. Lane D ships GLBs from round 2. */ model(name) { const e = api.get('models', name); if (!e) return null; return { ...e, url: resolve(e.url) }; }, /** * Best playable audio URL for this browser, or null. * cat: 'beds' | 'sfx'. Safari can't do Opus-in-Ogg, so we dual-ship and probe. */ audioUrl(cat, name) { const e = manifest?.audio?.[cat]?.[name]; if (!e) return null; const probe = document.createElement('audio'); const canOgg = !!probe.canPlayType('audio/ogg; codecs="opus"'); const pick = (canOgg && e.ogg) ? e.ogg : (e.m4a || e.ogg); return pick ? resolve(pick) : null; }, /** Raw audio entry ({ogg, m4a, loop, gain, seconds}) or null — for Lane E's engine. */ audio(cat, name) { return manifest?.audio?.[cat]?.[name] ?? null; }, /** Names present in a category — lets a lane iterate what actually shipped. */ list(category) { if (category === 'audio') return Object.keys(manifest?.audio ?? {}); return Object.keys(manifest?.[category] ?? {}); }, dispose() { for (const t of cache.values()) t.dispose(); cache.clear(); }, }; return api; }