guts/web/js/combat/enemies.js
type-two 7709484ea7 [lane B] The Pyloric Guardian: the first boss, built to C's spec
GUTS has had three bosses designed and none implemented. This is the first, and the framework
the other two should reuse. Built to LANE_C_NOTES §Boss specs, phase table and all.

WHY IT IS SMALL. A boss here is not a parallel entity system, it is a SCHEDULE over the enemy
pool. The nodes and vents are ordinary enemies, so weapons hit them, the InstancedMesh draws
them and enemy:die reports them for free; the fight's identity lives entirely in WHEN they can
be hurt, which is two flags (`invuln`, `armor`) honoured by enemies.hurt(). boss.js is a state
machine and nothing else.

Faithful to C's table:
  P1  4 rim nodes, iris opens 2.5s every 8s
  P2  4 nodes, opens 1.8s every 5s, TWO armoured — and only the antacid torpedo strips them
  P3  the core alone, 60hp = C's "x5 hits", plus the 25s doom timer that kills you if you stall
Telegraphs are C's too: a "deep gulp" 2s before each window (boss:telegraph + audio), and nodes
go amber -> white-hot exactly when they become shootable — one uniform driven off `invuln`, so
the thing that glows IS the thing you can hurt, always.

Vents are turrets at fixed arcs: a turret already fires a telegraphed homing projectile on a
cooldown, which is what an acid vent is.

hp is B's call (C: "numbers need B's framework to tune against"). 150 per rim node means a ring
outlives its first window, so you must come back for it — which is what lets the stomach's
coat-drain be the real clock. C's pillar, verified: parked in the lair for 20s without fighting
back, coat goes 100 -> 0. "A race against your coat, not its hp."

Verified end-to-end: a paced pilot (11 shots/s, ~70% uptime) wins in 38.8s across 9 windows at
0.38 open-duty (spec: 31-36%); phases report 4/0 armoured, 4/2 armoured, 1 core; damage is
refused while the iris is shut; the two armoured nodes consumed exactly 2 torpedo strips.

Not included: C's arena churn (a force on the player, the one part of the spec that is a feel
change rather than a schedule) — noted for the round that tunes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:56 +10:00

609 lines
29 KiB
JavaScript

// combat/enemies.js (Lane B) — the enemy framework and its round-1 archetypes.
//
// Enemies live in the SAME spline space as the player: (s, x, y). That's the whole trick —
// a seeker's pursuit is three float subtractions, not a pathfind, and "is it in front of
// me" is `e.s > player.s`. World position is derived once per frame for rendering and hit
// tests, never stored as truth.
//
// Behaviours are one flat update pass over a live array. Visuals are one InstancedMesh per
// type (ART_BIBLE emissive colours), so the whole roster is 3 draws + 1 for darts. When
// Lane D's GLBs land, swap the pool's geometry and the logic above never notices.
//
// Determinism: every random here comes from the seeded rng('enemies') stream.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _dir = new THREE.Vector3(), _v = new THREE.Vector3();
const FORWARD = new THREE.Vector3(0, 0, 1);
const BUCKET = 20; // u of s per broad-phase bucket
// The bacteriophage silhouette, built from primitives. The phage is the ONE microbe that is
// geometric, not blobby — an icosahedral capsid on a tail sheath with splayed leg fibres — so
// image->3D (Trellis/Hunyuan) webs its thin legs into a lump (measured: a 1.27x1.41x0.94 blob),
// while merged cylinders nail the iconic "lander" at ~140 tris. Authored along +Z (travel dir):
// legs reach forward, capsid trails.
const _up = new THREE.Vector3(0, 1, 0);
function phageGeo(r) {
const parts = [];
const head = new THREE.IcosahedronGeometry(r * 0.82, 0);
head.translate(0, 0, -r * 0.75);
parts.push(head);
const tail = new THREE.CylinderGeometry(r * 0.22, r * 0.22, r * 0.9, 6); // BOLD sheath
tail.rotateX(Math.PI / 2); // Y-axis -> Z-axis
tail.translate(0, 0, -r * 0.1);
parts.push(tail);
const N = 6;
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2;
const leg = new THREE.CylinderGeometry(r * 0.1, r * 0.06, r * 1.0, 4); // thick, tapered
leg.translate(0, r * 0.5, 0); // base at origin, extends +Y
// splay WIDE (small z) so a head-on phage reads as a 6-point star, not a dot
const dir = new THREE.Vector3(Math.cos(a), Math.sin(a), 0.55).normalize();
leg.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(_up, dir));
leg.translate(0, 0, r * 0.35); // shift to the +Z baseplate
parts.push(leg);
}
// Icosahedron is non-indexed, cylinders are indexed — mergeGeometries needs all-or-none, so
// drop every index before merging.
return mergeGeometries(parts.map((p) => (p.index ? p.toNonIndexed() : p)), false);
}
// Round-1 roster. `geo` is the procedural fallback; D's model swaps in via assets.get.
const ARCHETYPES = {
floater: {
cfg: B.enemies.floater,
geo: () => new THREE.IcosahedronGeometry(B.enemies.floater.radius, 1),
color: EMISSIVE.hostile,
},
seeker: {
cfg: B.enemies.seeker,
geo: () => new THREE.ConeGeometry(B.enemies.seeker.radius, B.enemies.seeker.radius * 2.6, 6),
color: EMISSIVE.hostile,
},
turret: {
cfg: B.enemies.turret,
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,
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).
spore_pod: {
cfg: B.enemies.spore_pod,
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. Silhouette
// is the iconic capsid+tail+legs, built procedurally (phageGeo) — image->3D can't hold the
// thin legs, and the shape is geometric anyway.
phage: {
cfg: B.enemies.phage,
geo: () => phageGeo(B.enemies.phage.radius),
color: EMISSIVE.ally,
foe: false,
},
// mucus_grazer (Akkermansia) — a commensal that NIBBLES your coat (it eats mucin) but signals
// gut health hardest. Cyan (still flora, readability law), but a distinct amoeba-blob silhouette
// so the coat cost reads as "a different bug", not a betrayal by the healing rod.
mucus_grazer: {
cfg: B.enemies.mucus_grazer,
geo: () => new THREE.DodecahedronGeometry(B.enemies.mucus_grazer.radius, 0),
color: EMISSIVE.neutral,
foe: false,
},
// cdiff (C. difficile) — a foe floater whose corpse leaves a spore_cloud (kill()). Amber,
// an angular octahedron so it reads distinct from the round yeast cluster.
cdiff: {
cfg: B.enemies.cdiff,
geo: () => new THREE.OctahedronGeometry(B.enemies.cdiff.radius, 0),
color: EMISSIVE.hostile,
},
// spore_cloud — the lingering corrosive zone C. diff drops on death. Acid-green (readability
// law: acid/corrosive), foe:false so the phage ignores it and clearing it never scores.
spore_cloud: {
cfg: B.enemies.spore_cloud,
geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_cloud.radius, 1),
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,
},
// virion (norovirus) — a tiny fast swarmer, its own pool so a cloud of them reads as a swarm.
// Shares the seeker behaviour (chase + dissolve on contact). Amber.
virion: {
cfg: B.enemies.virion,
geo: () => new THREE.IcosahedronGeometry(B.enemies.virion.radius, 0),
color: EMISSIVE.hostile,
},
// driller (H. pylori) — a tanky corkscrewing chaser. Amber; a long cone (the spiral drill).
driller: {
cfg: B.enemies.driller,
geo: () => { const g = new THREE.ConeGeometry(B.enemies.driller.radius * 0.6, B.enemies.driller.radius * 2.6, 5); g.rotateX(Math.PI / 2); return g; },
color: EMISSIVE.hostile,
},
// mucosal_node — the Pyloric Guardian's rim nodes. No movement branch (a node bolted to an
// iris should not drift); combat/boss.js drives their vulnerability. Amber like any hostile,
// and boss.js swaps the pool's flash uniform to white-hot while the window is open — C's
// telegraph, "nodes amber -> white-hot when vulnerable", for free.
mucosal_node: {
cfg: B.enemies.mucosal_node,
geo: () => new THREE.DodecahedronGeometry(B.enemies.mucosal_node.radius, 0),
color: EMISSIVE.hostile,
},
// beacon (Faecalibacterium) — the reward commensal. Cyan (flora family), a bright octahedral
// gem so it reads as the special one; shares the commensal branch (heal + big standing).
beacon: {
cfg: B.enemies.beacon,
geo: () => new THREE.OctahedronGeometry(B.enemies.beacon.radius, 0),
color: EMISSIVE.neutral,
foe: false,
},
};
export function createEnemies({ scene, world, bus, rng, assets = null }) {
const rand = rng ? rng('enemies') : () => 0.5;
const pools = {};
let nextId = 1;
const live = [];
const buckets = new Map();
let onDeath = null, onBlast = null; // set by combat/index.js
const _playerPos = new THREE.Vector3(); // player world pos, resolved once per frame
for (const [type, def] of Object.entries(ARCHETYPES)) {
const glb = assets?.get?.('models', type) ?? null;
// 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;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0;
scene.add(mesh);
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false };
}
// --- darts: the turret's homing projectile (hostile => white-hot) ------------------
const dartGeo = new THREE.ConeGeometry(B.darts.radius, B.darts.radius * 3, 5);
dartGeo.rotateX(Math.PI / 2); // author along +Z like every projectile
const dartMat = emissiveMat(EMISSIVE.hostileShot, { additive: true });
const dartMesh = new THREE.InstancedMesh(dartGeo, dartMat, B.darts.pool);
dartMesh.frustumCulled = false;
dartMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
dartMesh.count = 0;
scene.add(dartMesh);
const darts = Array.from({ length: B.darts.pool }, () => ({
alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(),
s: 0, sSpeed: 0, life: 0, turn: 0, damage: 0,
}));
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
// --- spawn ------------------------------------------------------------------------
// Charter API: spawn(type, {s, theta, rho}). `type` is an ARCHETYPE — combat/index.js
// resolves C's fiction ids (`bolus_chunk`) to archetypes before calling us.
//
// `rho` is a FRACTION of the safe radius (0..1), never an absolute distance — TECH §Event
// fields. That is C's placement law and it is the only thing that survives a tube whose
// radius varies with s: rho 0.92 means "pinned to the wall" everywhere, at any radius.
// Round 1 read it as absolute, which silently parked C's wall-pinned biopsy samples and
// candida blooms ~1 unit off the centreline, i.e. in the middle of the racing line.
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
const pool = pools[type];
if (!pool) { console.warn(`[combat] unknown enemy archetype "${type}"`); return null; }
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
const cfg = pool.cfg;
s = THREE.MathUtils.clamp(s, 0, world.length);
const th = theta ?? rand() * Math.PI * 2;
// free room for this body's centre: the safe radius less its own half-width, so rho 1.0
// is flush against the wall rather than half-buried in it
const free = Math.max(0, world.wallRho(s, th) - cfg.radius);
const r = rho != null
? THREE.MathUtils.clamp(rho, 0, 1) * free // C's fraction
: (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded
const e = {
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,
pos: new THREE.Vector3(), flash: 0, phase: rand() * Math.PI * 2,
cooldown: rand() * (cfg.fireEvery ?? 1), // desync turret volleys
auraT: 0, life: cfg.life ?? 0, // life: only spore_cloud counts down
};
live.push(e);
pool.slots.push(e);
bus.emit('enemy:spawn', { id: e.id, type });
return e;
}
// --- damage / death ---------------------------------------------------------------
let comboN = 0, comboT = 0;
function hurt(e, dmg, kind = 'hit') {
if (!e.alive) return;
// Boss gates (combat/boss.js owns both flags; nothing else sets them).
// invuln — the Guardian's iris is shut, so its nodes are behind muscle. Not a miss: it
// pings so the player learns "wait for the window" instead of "my gun is broken".
// armor — LANE_C_NOTES P2: two regrown nodes are armoured and ONLY the antacid torpedo
// strips them. The torpedo spends its armour-stripping hit rather than damaging.
if (e.invuln) { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; }
if (e.armor) {
if (kind !== 'torpedo') { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; }
e.armor = false;
bus.emit('audio:cue', { name: 'enemy_hit' });
return;
}
e.hp -= dmg;
e.flash = 1;
if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
kill(e, kind);
}
function kill(e, kind) {
e.alive = false;
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);
return;
}
if (e.type === 'cdiff') { // the corpse keeps hurting: leave a spore cloud
const sp = spawn('spore_cloud', { s: e.s });
if (sp) { sp.x = e.x; sp.y = e.y; } // at the death spot, not a random arc
}
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 });
bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e);
}
// --- broad phase ------------------------------------------------------------------
function rebuildBuckets() {
buckets.clear();
for (const e of live) {
const b = Math.floor(e.s / BUCKET);
let arr = buckets.get(b);
if (!arr) buckets.set(b, (arr = []));
arr.push(e);
}
}
// Visits every live enemy whose s-bucket could contain something within `radius` of `s`.
// Callers still do the exact world-space distance test — this only narrows the field.
function forEachNear(s, radius, fn) {
const lo = Math.floor((s - radius - BUCKET) / BUCKET);
const hi = Math.floor((s + radius + BUCKET) / BUCKET);
for (let b = lo; b <= hi; b++) {
const arr = buckets.get(b);
if (!arr) continue;
for (const e of arr) if (e.alive) fn(e);
}
}
// --- behaviours -------------------------------------------------------------------
function update(dt, player) {
const ps = player.state;
// resolve the player's world position ONCE — darts and turrets all want it, and
// world.sample() allocates (see NOTES → Lane A: an out-param sample would kill the
// last of our per-frame garbage).
discToWorld(_playerPos, world.sample(ps.s), ps.x, ps.y);
for (const e of live) {
if (!e.alive) continue;
e.flash = Math.max(0, e.flash - dt * 6);
const cfg = e.cfg;
if (e.type === 'spore_cloud') {
// A lingering corrosive spore cloud (C. diff's parting gift). Static — it does NOT move;
// it just damages on contact and expires after `life`. Killing the C. diff isn't clean.
e.life -= dt;
if (e.life <= 0) { kill(e, 'expire'); continue; }
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.damage(cfg.auraDps * cfg.auraTick, 'spore');
e.auraT = cfg.auraTick;
}
}
} else if (e.type === 'floater' || e.type === 'spore_pod' || e.type === 'cdiff') {
// drifts and bobs; taxes the lane it occupies rather than chasing. spore_pod (yeast) and
// cdiff (C. difficile) share this exactly — floaters with their own pool/mesh. cdiff's
// twist is only at death (kill() drops a spore_cloud), never in how it moves.
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) {
// discrete ticks, not per-frame: continuous player:damage would spam the bus
// and pin E's damage flash on (see flight/player.js damage/drain split)
player.damage(cfg.auraDps * cfg.auraTick, 'aura');
e.auraT = cfg.auraTick;
}
}
} else if (e.type === 'flora' || e.type === 'mucus_grazer' || e.type === 'beacon') {
// COMMENSALS. Drift like a floater; brushing the aura raises BIOME STANDING (flora:tended)
// — that is the reputation you bank. The coat effect is data-driven and sign-flipped:
// flora sheds `coat` (a mobile mucin lane) + small standing (default +5).
// mucus_grazer nibbles `coatCost` (Akkermansia eats mucin) + big standing (`gain`).
// Neither ever "attacks" you; both stay shootable, which is the reputation trap (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) {
if (cfg.coat) player.refill({ coat: cfg.coat });
if (cfg.coatCost) player.damage(cfg.coatCost, 'graze');
e.auraT = cfg.auraTick;
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
// 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' || e.type === 'virion') {
// pursue in spline space — a chase for three float subtractions. virion (norovirus)
// shares it exactly: tiny, fast, dissolves on contact — a swarm, not a squad.
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 && d < cfg.radius + player.radius) {
player.damage(cfg.contact, 'contact');
onBlast?.(e.pos, 1.6, 0.25);
kill(e, 'contact'); // pepsin dissolves on the hit (GDD)
continue;
}
} else if (e.type === 'driller') {
// H. pylori: a tanky corkscrewing chaser. Seeker pursuit + a rotating perpendicular
// drift (the helix) so it's hard to pin, and — unlike the seeker — it does NOT dissolve
// on contact: it grinds you and keeps drilling. You have to out-damage it.
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.phase += dt * cfg.spin; // corkscrew
e.s += e.vs * dt;
e.x += (e.vx + Math.cos(e.phase) * cfg.corkscrew) * dt;
e.y += (e.vy + Math.sin(e.phase) * cfg.corkscrew) * dt;
if (ps.alive && d < cfg.radius + player.radius) {
e.auraT -= dt;
if (e.auraT <= 0) { player.damage(cfg.contact, 'contact'); e.auraT = 0.25; } // grinds, persists
}
} else if (e.type === 'turret') {
// wall-mounted; fires only at what's in range and still ahead of it
e.cooldown -= dt;
if (ps.alive && e.cooldown <= 0 && Math.abs(e.s - ps.s) < cfg.rangeS) {
fireDart(e);
e.cooldown = cfg.fireEvery;
}
}
// everything stays inside the tube
const rho = Math.hypot(e.x, e.y);
const maxR = world.wallRho(e.s, Math.atan2(e.x, e.y)) - e.radius * 0.5;
if (rho > maxR && rho > 1e-5) {
const k = maxR / rho;
e.x *= k; e.y *= k;
if (e.type === 'seeker') { e.vx *= 0.4; e.vy *= 0.4; }
}
e.s = THREE.MathUtils.clamp(e.s, 0, world.length);
discToWorld(e.pos, world.sample(e.s), e.x, e.y);
}
updateDarts(dt, player);
reap();
rebuildBuckets();
render();
if (comboT > 0 && (comboT -= dt) <= 0 && comboN) { comboN = 0; bus.emit('combo', { n: 0 }); }
}
function fireDart(e) {
const d = darts.find((x) => !x.alive);
if (!d) return;
d.alive = true;
d.pos.copy(e.pos);
_dir.copy(_playerPos).sub(e.pos).normalize(); // aim at where the player is now
d.vel.copy(_dir).multiplyScalar(e.cfg.dartSpeed);
d.life = e.cfg.dartLife;
d.s = e.s;
d.sSpeed = d.vel.dot(world.sample(e.s).tan);
d.turn = e.cfg.dartTurn;
d.damage = e.cfg.dartDamage;
bus.emit('audio:cue', { name: 'dart' });
}
function updateDarts(dt, player) {
const ps = player.state;
let n = 0;
for (const d of darts) {
if (!d.alive) continue;
// Turn-rate-limited homing: swing the velocity toward the player by at most turn·dt
// radians. Turn radius (dartSpeed/dartTurn = 13.3 u) is wider than the tube itself,
// so a dart can never corner you — it only punishes flying straight. That's the design.
if (ps.alive) {
const speed = d.vel.length();
_dir.copy(_playerPos).sub(d.pos);
if (_dir.lengthSq() > 1e-8 && speed > 1e-5) {
_dir.normalize();
_v.copy(d.vel).divideScalar(speed); // current heading, unit
const angle = Math.acos(THREE.MathUtils.clamp(_v.dot(_dir), -1, 1));
if (angle > 1e-4) {
// nlerp toward the target heading, capped at the turn budget for this frame.
// At these step sizes it tracks slerp closely and costs no allocation.
const t = Math.min(1, (d.turn * dt) / angle);
_v.lerp(_dir, t).normalize();
d.vel.copy(_v).multiplyScalar(speed);
}
}
}
d.pos.addScaledVector(d.vel, dt);
d.s += d.sSpeed * dt;
d.life -= dt;
let done = d.life <= 0;
if (!done && ps.alive) {
const r = player.radius + B.darts.radius;
if (d.pos.distanceToSquared(_playerPos) <= r * r) {
if (player.damage(d.damage, 'dart')) onBlast?.(d.pos, 1.2, 0.2);
done = true;
}
}
if (!done && world.collide(d.pos, B.darts.radius)) done = true;
if (done) { d.alive = false; continue; }
_dir.copy(d.vel).normalize();
_q.setFromUnitVectors(FORWARD, _dir);
dartMesh.setMatrixAt(n++, _m.compose(d.pos, _q, _scale.set(1, 1, 1)));
}
dartMesh.count = n;
dartMesh.instanceMatrix.needsUpdate = true;
}
function reap() {
for (let i = live.length - 1; i >= 0; i--) if (!live[i].alive) live.splice(i, 1);
for (const pool of Object.values(pools))
for (let i = pool.slots.length - 1; i >= 0; i--) if (!pool.slots[i].alive) pool.slots.splice(i, 1);
}
function render() {
for (const [type, pool] of Object.entries(pools)) {
let n = 0;
for (const e of pool.slots) {
if (!e.alive) continue;
if (type === 'seeker' || type === 'phage' || type === 'virion' || type === 'driller') { // point along heading
_dir.set(e.vs, e.vx, e.vy);
_q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
} else {
_q.setFromAxisAngle(FORWARD, e.phase);
}
pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
}
pool.mesh.count = n;
pool.mesh.instanceMatrix.needsUpdate = true;
// one flash uniform per type: a hit reads as the whole cohort blinking, which is
// wrong. Round 2: per-instance colour attribute. Noted in NOTES.
const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
// LANE_C_NOTES telegraph: "nodes amber -> white-hot when vulnerable". The boss's open
// window is already expressed as `invuln`, so the tell costs one uniform write and can
// never disagree with the hitbox — the thing that glows IS the thing you can hurt.
const openNode = type === 'mucosal_node' && pool.slots.some((e) => e.alive && !e.invuln);
pool.mat.uniforms.uFlash.value = hot ? 0.6 : (openNode ? 0.45 : 0);
}
}
return {
spawn, hurt, forEachNear, update,
get count() { return live.length; },
get live() { return live; },
setBlastFx(fn) { onBlast = fn; },
setOnDeath(fn) { onDeath = fn; },
clear() { for (const e of live) e.alive = false; for (const d of darts) d.alive = false; reap(); },
dispose() {
for (const pool of Object.values(pools)) {
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
}
scene.remove(dartMesh); dartMesh.dispose(); dartGeo.dispose(); dartMat.dispose();
live.length = 0; buckets.clear();
},
};
}