diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js index e401b04..c480fec 100644 --- a/web/js/combat/balance.js +++ b/web/js/combat/balance.js @@ -91,6 +91,14 @@ export const BALANCE = { // low hp so you CAN clear it with the cannon — spend heat to disperse it, or fly around. spore_cloud: { hp: 14, radius: 3.2, score: 0, auraRadius: 4.5, auraDps: 22, auraTick: 0.25, life: 5, pool: 12 }, + + // latcher (Giardia) — a foe that CHASES like a seeker, then CLAMPS onto your hull (its + // ventral suction disc) and drains `drain` coat/tick while it rides. The new verb is + // "shake it off": a boost's i-frames throw it, else it lets go after `attachMax`s, then + // can't re-grab for `reattachCd`s. speed 20 > flow so it catches you; the counterplay is + // timing a boost, not out-running it. + latcher: { hp: 16, radius: 1.4, score: 160, speed: 20, turn: 2.6, aggroS: 55, + drain: 8, drainTick: 0.3, attachMax: 3.5, reattachCd: 1.5, pool: 12 }, }, darts: { pool: 48, radius: 0.45 }, diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index 2ab1f18..e620958 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -87,6 +87,13 @@ const ARCHETYPES = { color: EMISSIVE.acid, foe: false, }, + // latcher (Giardia) — chases, CLAMPS to your hull, drains coat until a boost shakes it. + // Amber; a spiky tetrahedron (the trophozoite) so a clinging one reads on the hull. + latcher: { + cfg: B.enemies.latcher, + geo: () => new THREE.TetrahedronGeometry(B.enemies.latcher.radius * 1.3, 0), + color: EMISSIVE.hostile, + }, }; export function createEnemies({ scene, world, bus, rng, assets = null }) { @@ -293,6 +300,33 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { bus.emit('flora:tended', { s: e.s, gain: cfg.gain }); // gain undefined for flora => default } } + } else if (e.type === 'latcher') { + // Giardia: chase, CLAMP, drain — until a boost shakes it. Boost becomes a DODGE here. + if (e.attached) { + e.phase += dt * 3; // spin on the hull + const off = e.radius + player.radius; + e.s = ps.s; e.x = ps.x + Math.cos(e.phase) * off; e.y = ps.y + Math.sin(e.phase) * off; + e.attachT -= dt; e.auraT -= dt; + if (ps.alive && e.auraT <= 0) { player.damage(cfg.drain, 'latch'); e.auraT = cfg.drainTick; } + if (ps.iframeT > 0 || e.attachT <= 0 || !ps.alive) { // boost i-frames throw it; else it lets go + e.attached = false; e.cooldown = cfg.reattachCd; + e.vs = -6; e.vx = Math.cos(e.phase) * 8; e.vy = Math.sin(e.phase) * 8; // flung clear + } + } else { + e.cooldown = Math.max(0, (e.cooldown || 0) - dt); // can't re-grab instantly + const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y; + const d = Math.hypot(ds, dx, dy) || 1e-5; + if (ps.alive && Math.abs(ds) < cfg.aggroS) { + 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 (ps.alive && e.cooldown <= 0 && d < cfg.radius + player.radius) { + e.attached = true; e.attachT = cfg.attachMax; e.auraT = 0; // CLAMP + } + } } 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 diff --git a/web/js/levels/L2_esophagus.json b/web/js/levels/L2_esophagus.json index b2aeb8d..9061ddd 100644 --- a/web/js/levels/L2_esophagus.json +++ b/web/js/levels/L2_esophagus.json @@ -114,6 +114,7 @@ { "s": 1680, "type": "checkpoint", "name": "Thoracic Run" }, { "s": 1700, "type": "hazard", "kind": "reflux_surge", "speed": 16, "from": -60, "span": 260, "warn": 3.0, "lethal": false, "neutralizable": true, "note": "TEACH reflux - the most important event in the level. A burp: 16 u/s against a 14 u/s current, so even a timid player at throttle 1.15 (16.1 u/s) escapes it. Touching it hurts and does not kill. When the real surge arrives at s=3000 the player already knows the verb." }, { "s": 1900, "type": "spawn", "enemy": "bolus_chunk", "count": 3, "spread": 100 }, + { "s": 1940, "type": "spawn", "enemy": "giardia_latch", "count": 1, "note": "MICROBE PASS (round 2) preview - Giardia belongs to L4 (villi maze) but its grab works in a flow tube, so one rides here to teach the new verb: it clamps to your hull and drains coat until you BOOST it off. Boost is now a dodge. Placed on the open thoracic run where you have room and a charged boost; C relocates it to L4 when that level lands." }, { "s": 2000, "type": "checkpoint", "name": "Bronchial Narrowing" }, { "s": 2060, "type": "spawn", "enemy": "candida_bloom", "count": 3, "theta": [0.2, 2.4, 4.6], "note": "Three arcs covered in the level's twistiest tube: each is a surprise on a blind bend. This is the beat that makes curviness a mechanic." }, diff --git a/web/js/levels/enemies.js b/web/js/levels/enemies.js index 16e5228..2606ded 100644 --- a/web/js/levels/enemies.js +++ b/web/js/levels/enemies.js @@ -23,7 +23,7 @@ * Round-2 microbe pass added `flora` (the first friend), `spore_pod` (a distinct-mesh foe), * `phage` (the first ally — a friendly seeker that hunts foes), and `mucus_grazer` (a commensal * that trades your coat for faster standing). */ -export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod', 'phage', 'mucus_grazer', 'cdiff']; +export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod', 'phage', 'mucus_grazer', 'cdiff', 'latcher']; /** * Fields: @@ -117,6 +117,13 @@ export const ENEMIES = Object.freeze({ 'keeps hurting the ground it died on (shoot it to disperse, or fly around). The colon\'s ' + 'definitive foe; belongs to L5, registered so C can place it when L5 gets its pass.', }, + giardia_latch: { + archetype: 'latcher', biome: 'small_intestine', tint: 0xff5a2a, wall: false, + note: 'Giardia duodenalis — the "smiley-face" trophozoite that clamps its ventral suction ' + + 'disc onto the gut wall and carpets it, starving you (malabsorption). In-game it chases, ' + + 'CLAMPS to your hull, and drains coat until you BOOST it off — the first foe that gives ' + + 'boost a defensive job. Belongs to L4 (the villi maze); registered for its encounter pass.', + }, // ---- passthrough: bare archetype names -------------------------------------------- // Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and