diff --git a/docs/TECH.md b/docs/TECH.md index 777b951..e4e9a35 100644 --- a/docs/TECH.md +++ b/docs/TECH.md @@ -180,8 +180,10 @@ Ratified round 2 (round-1 requests): - `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). + a friendly microbe was killed by the player (round-2 microbe pass; feeds BIOME STANDING). +- `standing {value}` — BIOME STANDING 0..100 (combat/index.js owns it). Tending flora raises it; + harming flora drops it; crossing the threshold buds phage allies. E can meter it. ("Don't + shoot the biome, it helps you" — V2_IDEAS, now partly real.) ## Asset manifest contract (D owns `assets.js` + `manifest.json`) diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js index cc50623..18a619d 100644 --- a/web/js/combat/balance.js +++ b/web/js/combat/balance.js @@ -63,6 +63,14 @@ export const BALANCE = { // 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 }, + + // phage — the ALLY (bacteriophage). A friendly seeker that hunts the nearest FOE and rams + // it (`punch`), bursting after `bursts` hits (a phage lyses its host and dies). FAST (26 > + // every foe) so it actually catches things; FRAGILE (hp 8) so a stray pellet or a foe pops + // it. score 0 — an ally never scores. Budded by high BIOME STANDING (combat/index.js), the + // payoff for tending flora. escort* = where it loiters when no foe is in `huntS` range. + phage: { hp: 8, radius: 1.1, score: 0, speed: 26, turn: 3.2, punch: 20, bursts: 2, + huntS: 70, escortLead: 10, escortR: 5, pool: 8 }, }, darts: { pool: 48, radius: 0.45 }, diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index 8e8aea2..f5b2b3f 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -44,6 +44,7 @@ const ARCHETYPES = { 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, + foe: false, }, // 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). @@ -52,6 +53,16 @@ const ARCHETYPES = { geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_pod.radius, 1), color: EMISSIVE.hostile, }, + // phage — the ALLY. A friendly seeker that hunts the nearest FOE and rams it (a bacteriophage + // bursts its host). Teal, distinct from cyan flora. Budded by high BIOME STANDING. Fallback + // silhouette is a bare icosahedron = the phage's real icosahedral capsid (a Hunyuan mesh with + // tail fibres is the eventual upgrade). + phage: { + cfg: B.enemies.phage, + geo: () => new THREE.IcosahedronGeometry(B.enemies.phage.radius, 0), + color: EMISSIVE.ally, + foe: false, + }, }; export function createEnemies({ scene, world, bus, rng, assets = null }) { @@ -77,7 +88,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.count = 0; scene.add(mesh); - pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] }; + pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false }; } // --- darts: the turret's homing projectile (hostile => white-hot) ------------------ @@ -122,7 +133,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { : (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded const e = { - id: nextId++, type, cfg, + id: nextId++, type, cfg, foe: pool.foe, s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention vs: 0, vx: 0, vy: 0, hp: cfg.hp, radius: cfg.radius, alive: true, @@ -148,10 +159,11 @@ 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 }); + if (!e.foe) { + // An ally died (flora or phage): never scores, never combos. Flora killed by the player's + // own fire (not 'spent') signals the BIOME STANDING meter — "don't shoot the biome", made + // mechanical. A phage just bursts ('spent') or is popped, quietly. + if (e.type === 'flora' && kind !== 'spent') 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); @@ -236,6 +248,33 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter } } + } else if (e.type === 'phage') { + // The ALLY. A friendly seeker that hunts the nearest FOE (not the player) and rams it — + // a bacteriophage bursts its host. No foe in range => it escorts, easing to a standoff + // point that orbits just ahead of the player. ponytail: nearest-foe is an O(phages·live) + // scan; with pool 8 and ~30 live it is ~free — swap to forEachNear only if it ever bites. + let target = null, best = cfg.huntS * cfg.huntS; + for (const o of live) { + if (!o.alive || !o.foe) continue; // hunt foes only, never flora/other phages + const d2 = (o.s - e.s) ** 2 + (o.x - e.x) ** 2 + (o.y - e.y) ** 2; + if (d2 < best) { best = d2; target = o; } + } + e.phase += dt * 1.5; // slow orbit while escorting + const tS = target ? target.s : ps.s + cfg.escortLead; + const tX = target ? target.x : ps.x + Math.cos(e.phase) * cfg.escortR; + const tY = target ? target.y : ps.y + Math.sin(e.phase) * cfg.escortR; + const ds = tS - e.s, dx = tX - e.x, dy = tY - e.y; + const d = Math.hypot(ds, dx, dy) || 1e-5; + const k = Math.min(1, cfg.turn * dt); + e.vs += ((ds / d) * cfg.speed - e.vs) * k; + e.vx += ((dx / d) * cfg.speed - e.vx) * k; + e.vy += ((dy / d) * cfg.speed - e.vy) * k; + e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt; + if (target && d < cfg.radius + target.radius) { + hurt(target, cfg.punch, 'phage'); // ram it + onBlast?.(e.pos, 1.2, 0.2); + if ((e.hits = (e.hits || 0) + 1) >= cfg.bursts) { kill(e, 'spent'); continue; } + } } 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; @@ -356,7 +395,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { let n = 0; for (const e of pool.slots) { if (!e.alive) continue; - if (type === 'seeker') { + if (type === 'seeker' || type === 'phage') { // hunters point along their heading _dir.set(e.vs, e.vx, e.vy); _q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD); } else { diff --git a/web/js/combat/index.js b/web/js/combat/index.js index bb85944..c76ce09 100644 --- a/web/js/combat/index.js +++ b/web/js/combat/index.js @@ -71,7 +71,27 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null score.samples += e.sample ?? 0; }); + // BIOME STANDING — the flora reputation loop (V2_IDEAS: "don't shoot the biome, it helps you + // in level 5"). Tend flora (fly through its aura -> flora:tended) to earn it; shoot flora + // (flora:harmed) to lose a chunk. At the symbiotic threshold the biome BUDS phage allies near + // you that hunt your foes — the payoff. A `standing` event feeds a future HUD meter (Lane E). + // ponytail: one run-scoped float, not a system. Persistence/tiers/L5-gate come when C needs them. + let standing = 0, phageBudCd = 0; + const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); }; + const offTended = bus.on('flora:tended', () => { setStanding(standing + 2); budPhage(); }); + const offHarmed = bus.on('flora:harmed', () => setStanding(standing - 25)); + function budPhage() { + if (standing < 40 || phageBudCd > 0) return; + if (enemies.live.filter((e) => e.type === 'phage' && e.alive).length >= 3) return; // ally cap + if (enemies.spawn('phage', { s: player.state.s + 8 })) { + setStanding(standing - 20); // spend standing to bud + phageBudCd = 3; // seconds between buds (decremented below) + bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born + } + } + function update(dt) { + if (phageBudCd > 0) phageBudCd -= dt; enemies.update(dt, player); weapons.update(dt); hazards.update(dt); @@ -92,7 +112,7 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null // pump never re-fires past events, so nothing double-spawns. reset(fromS = 0) { hazards.reset(fromS); }, dispose() { - offEvent(); offDie(); offPickup(); + offEvent(); offDie(); offPickup(); offTended(); offHarmed(); enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose(); }, }; diff --git a/web/js/flight/emissive.js b/web/js/flight/emissive.js index fd89a51..ddb3a66 100644 --- a/web/js/flight/emissive.js +++ b/web/js/flight/emissive.js @@ -16,6 +16,9 @@ export const EMISSIVE = { hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive) + ally: { core: 0x33ffbe, rim: 0xd6fff0 }, // teal — an active friendly unit (phage). + // distinct from cyan "don't shoot" flora: + // this one HELPS, and reads as its own thing. pickup: { core: 0x7dffb0, rim: 0xdaffe9 }, gate: { core: 0xb06aff, rim: 0xe6d0ff }, acid: { core: 0xc8ff3a, rim: 0xeaffb0 }, diff --git a/web/js/levels/enemies.js b/web/js/levels/enemies.js index d7248c0..821ab90 100644 --- a/web/js/levels/enemies.js +++ b/web/js/levels/enemies.js @@ -20,8 +20,9 @@ // Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck). /** 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']; + * Round-2 microbe pass added `flora` (the first friend), `spore_pod` (a distinct-mesh foe), + * and `phage` (the first ally — a friendly seeker that hunts foes). */ +export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod', 'phage']; /** * Fields: @@ -92,6 +93,13 @@ export const ENEMIES = Object.freeze({ '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.', }, + phage: { + archetype: 'phage', biome: '*', tint: 0x33ffbe, wall: false, + note: 'Bacteriophage — the most abundant biological entity in you; hunts one bacterial ' + + 'species and bursts it. THE ALLY: a friendly seeker that chases your FOES, not you ' + + '(teal, not the cyan "don\'t shoot" flora). Budded by high BIOME STANDING — the payoff ' + + 'for tending flora instead of spraying it. Placeable by C, but mainly biome-spawned.', + }, // ---- passthrough: bare archetype names -------------------------------------------- // Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and