meaning "miss nothing" Audit item 13 — the systems the levels were authored against, none of which worked. THE COMBO MULTIPLIER DID NOT MULTIPLY. balance.js's own comment cites the GDD line "score x style multiplier"; what shipped was a 2.5 s window and a counter. The HUD printed "x7 chain", comms shouted about it at 5, the music added a layer at 3 — and combat/index.js added the flat base score every time. Every par.score in every level was authored against a lever that did not exist, which is exactly why they all sat at 75-94% of the un-multiplied ceiling. Now step 0.12, cap 2.5 at a 14-chain: reachable in L4's ileum and nowhere else, because a cap you hit every fight is not a skill expression. Verified in the running game: six chained floaters pay 100/112/124/136/148/160 where they used to pay 600 flat. ALLIES COUNTED AS KILLS. combat/index.js did an unconditional score.kills++ on enemy:die, and the ally branch of enemies.js emits enemy:die with score 0 — so shooting a lactobacillus reef, the one act this game's whole reputation loop exists to punish, ticked your PATHOGENS tally up by four. The pause card and the operative record told you that you were doing well at the exact moment the biome decided otherwise. enemy:die carries `foe` now. Verified: seven deaths, six kills. THE PAR CHECK WAS COUNTING A THIRD OF THE GAME. scoreBudget() summed spawn events and nothing else — not the 500 points a biopsy sample is worth, not the several thousand a boss puts in the room. Four of six levels therefore shipped a permanent "par.score exceeds the kill budget" warning, and a warning that is wrong two thirds of the time is one everybody learns to scroll past. It counts pickups and boss rings now (nodes once per phase — a floor, since a phase re-rings for as long as the fight lasts and no honest ceiling exists). Six levels, zero warnings, and the ratios that were 94/75/52/59/44/32% of a fictional budget are now real. AND THE CHECK THAT SHOULD HAVE CAUGHT L1. The only par test asked whether par exceeded the ceiling. L1 sat at 94% of it: par the tutorial and you had to kill all eight foes, take all seven orbs and all three samples — miss ONE sample and the medal was gone. That is a clear-sheet requirement wearing par's clothes, in the level that exists to teach. par.score is 1900 now (71%), and the validator warns above 85% so the next one gets caught by a machine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
671 lines
32 KiB
JavaScript
671 lines
32 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, comboMult } 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,
|
|
},
|
|
// gas_pocket — L5's flammable methane bubble. Acid-green (readability law: this WILL hurt you),
|
|
// static like the spore cloud, and in a biome whose fog is the densest in the game it is also
|
|
// the only thing you can see by. Detonation lives in kill().
|
|
gas_pocket: {
|
|
cfg: B.enemies.gas_pocket,
|
|
geo: () => new THREE.IcosahedronGeometry(B.enemies.gas_pocket.radius, 2),
|
|
color: EMISSIVE.acid,
|
|
foe: false,
|
|
},
|
|
// mast_cell — L1's sentinel. CYAN, and the colour is the whole encounter: the readability law
|
|
// says cyan is not your enemy, and the level is watching whether the player believes it. A big
|
|
// soft icosphere so it reads as a cell full of granules rather than a target. foe:false, so
|
|
// killing it scores nothing — the only thing shooting it buys you is the cascade in boss.js.
|
|
mast_cell: {
|
|
cfg: B.enemies.mast_cell,
|
|
geo: () => new THREE.IcosahedronGeometry(B.enemies.mast_cell.radius, 2),
|
|
color: EMISSIVE.neutral,
|
|
foe: false,
|
|
},
|
|
// 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, flags = {} }) {
|
|
// Wet-tissue sheen on entities (playtest: "more detailed looking enemies"). Optional twice
|
|
// over: absent if D's matcap has not shipped, and switchable with ?matcap=0 so the flat look
|
|
// can be A/B'd against it without a rebuild. It only ever modulates brightness — see
|
|
// emissive.js — so the friend/foe colour law is unaffected either way.
|
|
const entMatcap = flags.matcap === false ? null : (assets?.matcap?.('tissue_wet') ?? 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
|
|
// The player, captured each update(). kill() needs it for the gas-pocket blast and kill() is
|
|
// reached from hurt(), which weapons call directly — so it cannot take it as an argument.
|
|
let _player = null;
|
|
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, { matcap: entMatcap });
|
|
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;
|
|
|
|
// GAS POCKET DETONATION (GDD §L5): "shooting inside one detonates it — hurts everyone".
|
|
// Everyone means everyone: the blast hurts the player if they are inside it, wipes foes, and
|
|
// reaches other pockets, so a line of them CHAINS. Recursion terminates because `alive` is
|
|
// cleared above before the blast goes out — a pocket can never detonate itself twice.
|
|
if (e.type === 'gas_pocket') {
|
|
const cfg = e.cfg;
|
|
onBlast?.(e.pos, cfg.blast * 0.5, 0.5);
|
|
forEachNear(e.s, cfg.blast, (o) => {
|
|
if (o === e) return;
|
|
const d = Math.hypot(o.s - e.s, o.x - e.x, o.y - e.y);
|
|
if (d <= cfg.blast) hurt(o, cfg.blastEnemy, 'gas');
|
|
});
|
|
const ps = _player?.state;
|
|
if (ps?.alive) {
|
|
const d = Math.hypot(ps.s - e.s, ps.x - e.x, ps.y - e.y);
|
|
// Falls off with distance, so "detonate it from max range" (C's Blockage spec calls that
|
|
// the optimal play) is a real skill rather than a coin flip.
|
|
if (d <= cfg.blast) _player?.damage?.(cfg.blastPlayer * (1 - d / cfg.blast), 'gas');
|
|
}
|
|
bus.emit('audio:cue', { name: 'torpedo_blast' });
|
|
}
|
|
|
|
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 });
|
|
// `foe: false` is not decoration. combat/index.js counted EVERY enemy:die as a kill, so
|
|
// shooting a lactobacillus reef — the one act this game's reputation loop exists to
|
|
// punish — also ticked your PATHOGENS score up by four. The record said you were doing
|
|
// well at the exact moment the biome decided you were not.
|
|
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, foe: false, 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;
|
|
// The chain PAYS now. `score` is what the player is actually awarded; `base` and `mult` ride
|
|
// along so a readout can show the multiplier doing its work rather than an unexplained
|
|
// bigger number.
|
|
const mult = comboMult(comboN);
|
|
const award = Math.round(e.cfg.score * mult);
|
|
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: award, base: e.cfg.score, mult, foe: true, 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) {
|
|
_player = 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;
|
|
}
|
|
}
|
|
// mast_cell rides this branch too: it drifts a patrol and its aura is INERT (auraDps 0),
|
|
// which is the mechanical statement that it is not attacking you and never was.
|
|
} else if (e.type === 'floater' || e.type === 'spore_pod' || e.type === 'cdiff' || e.type === 'mast_cell') {
|
|
// 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();
|
|
},
|
|
};
|
|
}
|