From 0ac11ae0404511c91a107992bdd3b1cb7e371398 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 15:13:30 +1000 Subject: [PATCH] [lane B+C+D] Microbes, friend and foe: the GLB loader, a friendly commensal, and a distinct-mesh yeast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/TECH.md | 3 ++ web/js/combat/balance.js | 14 +++++++++ web/js/combat/enemies.js | 54 +++++++++++++++++++++++++++++++-- web/js/core/assets.js | 53 ++++++++++++++++++++++++++++++++ web/js/levels/L2_esophagus.json | 2 ++ web/js/levels/enemies.js | 20 ++++++++++-- 6 files changed, 141 insertions(+), 5 deletions(-) diff --git a/docs/TECH.md b/docs/TECH.md index a8c8b29..777b951 100644 --- a/docs/TECH.md +++ b/docs/TECH.md @@ -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`) diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js index 2442eab..cc50623 100644 --- a/web/js/combat/balance.js +++ b/web/js/combat/balance.js @@ -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 }, diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index def0957..8e8aea2 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -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; diff --git a/web/js/core/assets.js b/web/js/core/assets.js index 90781bc..7d55ca2 100644 --- a/web/js/core/assets.js +++ b/web/js/core/assets.js @@ -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; diff --git a/web/js/levels/L2_esophagus.json b/web/js/levels/L2_esophagus.json index d84ef8a..2d58b25 100644 --- a/web/js/levels/L2_esophagus.json +++ b/web/js/levels/L2_esophagus.json @@ -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" }, diff --git a/web/js/levels/enemies.js b/web/js/levels/enemies.js index 1d30744..d7248c0 100644 --- a/web/js/levels/enemies.js +++ b/web/js/levels/enemies.js @@ -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