[lane B+C+D] Microbes, friend and foe: the GLB loader, a friendly commensal, and a distinct-mesh yeast

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>
This commit is contained in:
type-two 2026-07-19 15:13:30 +10:00
parent 464f5c8a18
commit 0ac11ae040
6 changed files with 141 additions and 5 deletions

View File

@ -179,6 +179,9 @@ Ratified round 2 (round-1 requests):
- `hazard:warn {kind, s, eta}` — fires `warn` seconds before a hazard (C events carry `warn`).
- `hazard:proximity {kind, distance}` — rear-chaser telemetry (reflux surge); drives E's
rear indicator, C's most important UI dependency.
- `flora:tended {s}` / `flora:harmed {s}` — a friendly microbe's aura gave the player coat /
a friendly microbe was killed by the player (round-2 microbe pass; seeds a future BIOME
STANDING reputation meter — "don't shoot the biome, it helps you in level 5", V2_IDEAS).
## Asset manifest contract (D owns `assets.js` + `manifest.json`)

View File

@ -49,6 +49,20 @@ export const BALANCE = {
// only punish a straight line. Dodgeable by design (charter).
turret: { hp: 40, radius: 1.6, score: 250, fireEvery: 1.9, rangeS: 55,
dartSpeed: 24, dartTurn: 1.8, dartLife: 4.5, dartDamage: 14, pool: 12 },
// flora — the first FRIEND. A commensal (Lactobacillus &c) that drifts like a floater but
// HELPS: brush its aura and it sheds `coat` mucus per tick instead of taking it. score 0 and
// no combo (enemies.js kill()): it sits in the enemy pool so it is shootable, but shooting
// it is the reputation trap, never a reward. `coat` kept small + no parking in a flow tube =
// a trickle, not a fountain. hp low: a stray pellet shouldn't wipe a colony you meant to spare.
flora: { hp: 18, radius: 2.0, score: 0, drift: 1.2, bob: 0.4,
auraRadius: 3.6, coat: 6, auraTick: 0.3, pool: 24 },
// spore_pod — a foe budding-yeast/Candida cluster. Mechanically the floater (drift + corrosive
// aura) with its OWN pool so its blobby mesh reads distinct from tumbling food debris. The
// first entity authored to be skinned by a Trellis mesh (assets/models/spore_pod.glb).
spore_pod: { hp: 30, radius: 2.4, score: 120, drift: 1.4, bob: 0.5,
auraRadius: 4.4, auraDps: 20, auraTick: 0.2, pool: 16 },
},
darts: { pool: 48, radius: 0.45 },

View File

@ -37,6 +37,21 @@ const ARCHETYPES = {
geo: () => new THREE.CylinderGeometry(B.enemies.turret.radius * 0.5, B.enemies.turret.radius, B.enemies.turret.radius * 1.6, 7),
color: EMISSIVE.hostile,
},
// flora — the first FRIEND. Cyan (ART_BIBLE: neutral flora reads friendly), drifts and
// sheds coat; behaviour below. Fallback silhouette is a rod (a bacillus), the shape a
// probiotic GLB will replace.
flora: {
cfg: B.enemies.flora,
geo: () => new THREE.CapsuleGeometry(B.enemies.flora.radius * 0.45, B.enemies.flora.radius * 1.1, 3, 8),
color: EMISSIVE.neutral,
},
// spore_pod — a foe yeast cluster. Its own pool so its blobby GLB doesn't reskin the
// food-debris floater; behaviour shares the floater branch (drift + corrosive aura).
spore_pod: {
cfg: B.enemies.spore_pod,
geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_pod.radius, 1),
color: EMISSIVE.hostile,
},
};
export function createEnemies({ scene, world, bus, rng, assets = null }) {
@ -50,7 +65,12 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
for (const [type, def] of Object.entries(ARCHETYPES)) {
const glb = assets?.get?.('models', type) ?? null;
const geo = glb?.geometry ?? def.geo();
// GLBs ship normalized to a unit bounding sphere (assets.js); scale to this archetype's
// radius so a model and its procedural fallback read the same size. Clone first — the
// manifest geometry is shared and the pool owns its own scaled copy (disposed below).
const geo = glb?.geometry
? glb.geometry.clone().scale(def.cfg.radius, def.cfg.radius, def.cfg.radius)
: def.geo();
const mat = emissiveMat(def.color);
const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool);
mesh.frustumCulled = false;
@ -128,6 +148,15 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
function kill(e, kind) {
e.alive = false;
if (e.type === 'flora') {
// Shooting a friendly commensal: no score, no combo — and a signal for the (future)
// BIOME STANDING meter. "Don't shoot the biome", made mechanical.
bus.emit('flora:harmed', { s: e.s });
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, kind });
bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e);
return;
}
comboN++; comboT = B.combo.window;
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: e.cfg.score, kind });
bus.emit('combo', { n: comboN });
@ -171,8 +200,9 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
e.flash = Math.max(0, e.flash - dt * 6);
const cfg = e.cfg;
if (e.type === 'floater') {
// drifts and bobs; taxes the lane it occupies rather than chasing
if (e.type === 'floater' || e.type === 'spore_pod') {
// drifts and bobs; taxes the lane it occupies rather than chasing. spore_pod shares
// this behaviour exactly — it's a floater with its own pool/mesh (a yeast cluster).
e.phase += dt * cfg.bob;
e.s += Math.sin(e.phase) * cfg.drift * dt;
e.x += Math.cos(e.phase * 0.7) * cfg.drift * dt;
@ -188,6 +218,24 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
e.auraT = cfg.auraTick;
}
}
} else if (e.type === 'flora') {
// The FRIEND. Drifts like a floater but its aura HEALS: brush past and it sheds a
// little mucus coat (the "mobile mucin lane"). Never damages you. It's still in the
// enemy pool, so you CAN shoot it — and that is the reputation trap, handled in kill().
e.phase += dt * cfg.bob;
e.s += Math.sin(e.phase) * cfg.drift * dt;
e.x += Math.cos(e.phase * 0.7) * cfg.drift * dt;
e.y += Math.sin(e.phase * 0.9) * cfg.drift * dt;
if (ps.alive) {
const d = Math.hypot(e.s - ps.s, e.x - ps.x, e.y - ps.y);
e.auraT -= dt;
if (d < cfg.auraRadius + player.radius && e.auraT <= 0) {
player.refill({ coat: cfg.coat }); // friendly: gives coat instead of taking it
e.auraT = cfg.auraTick;
bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter
}
}
} else if (e.type === 'seeker') {
// pursue in spline space — a chase for three float subtractions
const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;

View File

@ -12,6 +12,8 @@
// 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);
@ -64,6 +66,55 @@ export async function createAssets({ flags = {}, renderer = null } = {}) {
}
}
// --- 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;
@ -170,6 +221,8 @@ export async function createAssets({ flags = {}, renderer = null } = {}) {
dispose() {
for (const t of cache.values()) t.dispose();
cache.clear();
for (const g of modelGeoms) g.dispose();
modelGeoms.length = 0;
},
};
return api;

View File

@ -85,7 +85,9 @@
"events": [
{ "s": 20, "type": "checkpoint", "name": "Upper Esophageal Sphincter" },
{ "s": 120, "type": "pickup", "kind": "nutrient_orb", "count": 3, "spread": 40, "note": "Orb lines teach the racing line before they score anything." },
{ "s": 180, "type": "spawn", "enemy": "lacto_drifter", "count": 4, "spread": 90, "rho": 0.7, "note": "MICROBE PASS (round 2) - the first FRIEND, taught first, in the calmest stretch. A Lactobacillus reef drifting off the racing line (cyan = friendly, ART_BIBLE). Brush it and it sheds mucus coat; SHOOT it and you have harmed the biome (flora:harmed) - the seed of the BIOME STANDING loop. Teaches 'don't shoot cyan' before any level charges for it." },
{ "s": 240, "type": "spawn", "enemy": "bolus_chunk", "count": 1, "note": "TEACH bolus: one chunk, slow, unmissable, harmless if you simply steer." },
{ "s": 300, "type": "spawn", "enemy": "yeast_pod", "count": 1, "rho": 0.6, "note": "MICROBE PASS (round 2) - the spore_pod foe's teach beat: one drifting Candida cluster, off-line, harmless if you steer. A distinct blobby silhouette (Trellis-skinned), not the food-debris floater." },
{ "s": 340, "type": "pickup", "kind": "nutrient_orb", "count": 3, "spread": 40 },
{ "s": 420, "type": "checkpoint", "name": "Cervical Narrowing" },

View File

@ -19,8 +19,9 @@
//
// Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck).
/** The only archetypes Lane B implements in round 1. Selfcheck enforces membership. */
export const ARCHETYPES = ['floater', 'seeker', 'turret'];
/** The archetypes Lane B implements. Selfcheck enforces membership.
* Round-2 microbe pass added `flora` (the first friend) and `spore_pod` (a distinct-mesh foe). */
export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod'];
/**
* Fields:
@ -77,6 +78,21 @@ export const ENEMIES = Object.freeze({
'fiction. Belongs to L4; listed so the catalogue matches balance.js\'s vocabulary.',
},
// ---- round-2 microbe pass: the first FRIEND, and a distinct-mesh foe ---------------
lacto_drifter: {
archetype: 'flora', biome: 'esophagus', tint: 0x39e6ff, wall: false,
note: 'Lactobacillus — a friendly commensal that acidifies the neighbourhood and out-' +
'competes pathogens. Cyan (ART_BIBLE: flora reads friendly). Brush its aura and it ' +
'sheds mucus coat; SHOOT it and you have harmed the biome (flora:harmed) — the seed ' +
'of the BIOME STANDING reputation loop. The first entity that rewards NOT firing.',
},
yeast_pod: {
archetype: 'spore_pod', biome: 'esophagus', tint: 0xffb347, wall: false,
note: 'Candida budding-yeast cluster — a drifting colony with a corrosive contact aura. ' +
'Mechanically a floater, but its own archetype so it carries a distinct blobby ' +
'silhouette: the first foe authored to be skinned by a Trellis mesh.',
},
// ---- passthrough: bare archetype names --------------------------------------------
// Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and
// Lane B is never broken by this catalogue landing mid-round. Not for use in shipped