Audit item 12.
THE WHOLE COHORT WAS BLINKING. Enemy flash was ONE uFlash uniform per type, and the comment
above it said so and deferred the fix: shoot one bolus chunk in a room of eight and all eight
blinked white. That is not weak feedback, it is WRONG feedback — the single signal that tells
you which body absorbed your shot was being broadcast to every body that did not, in a fogged
tube where telling them apart is the entire skill. Per-instance now: an `aFlash`
InstancedBufferAttribute written at the same compacted draw index as the matrix (they must
match, or the flash lands on somebody else's body once anything in the pool dies). Verified
with five floaters drawn and the middle one shot: aFlash reads [0, 0, 0.9, 0, 0].
emissive.js declares the attribute unconditionally, so a plain Mesh or a pool that never
attaches one gets WebGL's default 0 and renders exactly as before. uFlash stays for the one
effect that IS per-cohort — the boss's open-node tell, where every node opens at once.
THE RETICLE ONLY KNEW ABOUT THE TRIGGER. setHot was driven by `intent.fire`, so the instrument
in the middle of the screen reported whether the player's own finger was down and said nothing
about whether the shot connected. In a game where a foe can be hit-and-unharmed (armoured
plate), hit-and-shielded (the Guardian's shut iris) or simply missed in the fog, that is the
most useful thing it could have been telling you — and the shut-iris case is exactly the one
where a player without feedback concludes the gun is broken. enemies.js now announces
`enemy:hit {dmg, blocked, dead}` and the reticle pops for it: 0.35 blocked, 0.7 landed, 1.0
kill. The tell is a SIZE pop, not brightness alone, because firing already brightens it and one
signal cannot do two jobs — firing is dimmed to 0.55 to make the room.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
700 lines
34 KiB
JavaScript
700 lines
34 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;
|
|
// One float per instance: how hard THIS body was just hit. emissive.js declares `aFlash`
|
|
// unconditionally, so a pool that never attaches this simply renders as it always did.
|
|
const aFlash = new THREE.InstancedBufferAttribute(new Float32Array(def.cfg.pool), 1);
|
|
aFlash.setUsage(THREE.DynamicDrawUsage);
|
|
geo.setAttribute('aFlash', aFlash);
|
|
scene.add(mesh);
|
|
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false, aFlash };
|
|
}
|
|
|
|
// --- 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.
|
|
// `enemy:hit` exists so the UI can answer the question the player is actually asking —
|
|
// did that connect, and did it do anything? Nothing announced a landed shot before: the
|
|
// reticle knew only whether the trigger was down. `blocked` is the important one: hitting
|
|
// a shut iris or an armoured plate is NOT a miss, and a player who cannot tell the two
|
|
// apart concludes the gun is broken.
|
|
if (e.invuln) {
|
|
e.flash = 1;
|
|
bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: true, dead: false });
|
|
bus.emit('audio:cue', { name: 'gate_hit' });
|
|
return;
|
|
}
|
|
if (e.armor) {
|
|
if (kind !== 'torpedo') {
|
|
e.flash = 1;
|
|
bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: true, dead: false });
|
|
bus.emit('audio:cue', { name: 'gate_hit' });
|
|
return;
|
|
}
|
|
e.armor = false;
|
|
bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: false, dead: false });
|
|
bus.emit('audio:cue', { name: 'enemy_hit' });
|
|
return;
|
|
}
|
|
e.hp -= dmg;
|
|
e.flash = 1;
|
|
bus.emit('enemy:hit', { id: e.id, type: e.type, dmg, blocked: false, dead: e.hp <= 0, foe: e.foe !== false });
|
|
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);
|
|
}
|
|
// Same index for both, or the flash lands on somebody else's body: `n` is the
|
|
// COMPACTED draw index, which shifts every time anything in the pool dies.
|
|
pool.aFlash.array[n] = e.flash;
|
|
pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
|
|
}
|
|
pool.mesh.count = n;
|
|
pool.mesh.instanceMatrix.needsUpdate = true;
|
|
pool.aFlash.needsUpdate = true;
|
|
// THE HIT LANDS ON THE THING YOU HIT. This was one uFlash uniform for the whole type, and
|
|
// the comment here said so: shoot one bolus chunk in a room of eight and all eight blinked
|
|
// white. That is not weak feedback, it is WRONG feedback — the one signal telling you
|
|
// which body absorbed the shot was being broadcast to every body that did not. Per-instance
|
|
// now (aFlash above); uFlash is left to the only thing that is genuinely per-cohort:
|
|
// 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. This
|
|
// one IS per-cohort — every open node is open at once — so a uniform is right for it.
|
|
const openNode = type === 'mucosal_node' && pool.slots.some((e) => e.alive && !e.invuln);
|
|
pool.mat.uniforms.uFlash.value = 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();
|
|
},
|
|
};
|
|
}
|