The round-2 microbe pass, vertical slice. Cross-lane (integrator call): the point was one
thread proven end to end — art pipeline in, friend and foe out.
- core/assets.js (D): the GLB loader, finally wired. GLTFLoader was vendored but never
imported and the manifest had 0 models, so enemies.js's `glb?.geometry` hook was dead code.
Now every manifest model preloads (AWAITED — pools set geometry at construction, a late load
can't retrofit an InstancedMesh) into ONE BufferGeometry normalized to a unit bounding
sphere; get('models',n).geometry lights up the hook. Optional-asset law intact: any failure
-> no geometry -> procedural fallback.
- combat/enemies.js + balance.js (B): two new archetypes.
flora — the FIRST FRIEND. Cyan (ART_BIBLE: flora reads friendly). Drifts like a
floater but its aura HEALS (sheds coat), never harms. Shootable — and shooting
it is the reputation trap: kill() gives no score, no combo, emits flora:harmed.
Verified: the aura fires player.refill({coat}); a shot flora scores 0.
spore_pod — a foe yeast cluster: the floater's drift+aura, but its OWN pool so its blobby
GLB will not reskin the food-debris floater.
Pool geometry now scales a GLB by the archetype radius (GLBs ship unit-normalized).
- levels/enemies.js (C): ARCHETYPES += flora, spore_pod; catalogue entries lacto_drifter
(Lactobacillus) + yeast_pod (Candida). L2 gets a light teach beat in the calm opening — a
friendly reef at s180, one yeast at s300, both off the racing line.
Ships playable as glowing primitives (a capsule + an icosahedron), exactly like the rest of
the round-1 roster; the Trellis/Hunyuan meshes drop in via the manifest with no code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
9.8 KiB
JavaScript
230 lines
9.8 KiB
JavaScript
// 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';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
|
|
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;
|
|
|
|
// Miss ledger. The optional-asset law has one nasty edge, found by Lane A in round 1: ask
|
|
// for a name that doesn't exist (a typo, or a slug that drifted from a biome id) and you
|
|
// get the procedural fallback FOREVER, with no error — the game looks fine and the texture
|
|
// is simply never seen. So every miss is recorded, and each distinct one says so once.
|
|
// `assets.misses()` lists them; noisy-by-design under ?localassets=0, so stay quiet there.
|
|
const missed = new Set();
|
|
const noteMiss = (key) => {
|
|
if (missed.has(key)) return;
|
|
missed.add(key);
|
|
if (flags.localassets !== false) console.info(`[assets] miss: ${key} — using fallback`);
|
|
};
|
|
|
|
// ?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;
|
|
}
|
|
}
|
|
|
|
// --- models: GLB -> ready geometry ------------------------------------------------------
|
|
// enemies/ship want a THREE geometry, not a URL. Load + merge each manifest GLB into ONE
|
|
// BufferGeometry, normalized to a UNIT bounding sphere centred at the origin (the consumer
|
|
// scales it to its own radius). AWAITED before we return the api: pools set their geometry at
|
|
// construction time, so a late async load could never retrofit an already-built InstancedMesh.
|
|
// The optional-asset law still holds — any failure just leaves the entry without `.geometry`
|
|
// and the consumer draws its procedural primitive. Attributes are stripped to position+normal:
|
|
// that is all emissiveMat reads, and it lets mergeGeometries succeed across mismatched submeshes.
|
|
const modelGeoms = [];
|
|
async function preloadModels() {
|
|
const entries = Object.entries(manifest.models || {});
|
|
if (!entries.length) return;
|
|
const gl = new GLTFLoader();
|
|
await Promise.all(entries.map(async ([name, e]) => {
|
|
const url = resolve(e.url);
|
|
if (!url) return;
|
|
try {
|
|
const gltf = await gl.loadAsync(url);
|
|
const geos = [];
|
|
gltf.scene.updateWorldMatrix(true, true);
|
|
gltf.scene.traverse((o) => {
|
|
if (!o.isMesh || !o.geometry) return;
|
|
const g = o.geometry.clone();
|
|
g.applyMatrix4(o.matrixWorld); // bake node transforms in
|
|
for (const a of Object.keys(g.attributes))
|
|
if (a !== 'position' && a !== 'normal') g.deleteAttribute(a);
|
|
geos.push(g);
|
|
});
|
|
if (!geos.length) throw new Error('no meshes in glb');
|
|
const geo = geos.length === 1 ? geos[0] : mergeGeometries(geos, false);
|
|
if (!geo) throw new Error('merge failed (attribute mismatch)');
|
|
geo.computeBoundingSphere();
|
|
const bs = geo.boundingSphere;
|
|
if (bs && bs.radius > 1e-6) {
|
|
geo.translate(-bs.center.x, -bs.center.y, -bs.center.z);
|
|
geo.scale(1 / bs.radius, 1 / bs.radius, 1 / bs.radius);
|
|
}
|
|
if (!geo.attributes.normal) geo.computeVertexNormals();
|
|
e.geometry = geo; // get('models',name).geometry
|
|
e.scene = gltf.scene; // ship.js reads .scene
|
|
modelGeoms.push(geo);
|
|
} catch (err) {
|
|
noteMiss(`models/${name}`);
|
|
console.info(`[assets] model ${name} failed (${err.message}) — using fallback`);
|
|
}
|
|
}));
|
|
}
|
|
await preloadModels();
|
|
|
|
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()
|
|
const e = cat[name] ?? null;
|
|
if (!e) noteMiss(`${category}/${name}`);
|
|
return e;
|
|
},
|
|
|
|
/** Names asked for that the manifest didn't have. Check this if art "isn't showing up". */
|
|
misses() {
|
|
return [...missed];
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
for (const g of modelGeoms) g.dispose();
|
|
modelGeoms.length = 0;
|
|
},
|
|
};
|
|
return api;
|
|
}
|