Roster round-2, batch 2 — three new archetypes, mostly reusing existing behaviour branches: - virion (norovirus): a tiny fast swarmer, its own pool of 40 so a cloud reads as a swarm. Shares the seeker branch (chase + dissolve on contact). The arithmetic threat. -> L5. - driller (H. pylori): a TANKY corkscrewer (hp 50). Seeker chase + a rotating perpendicular drift (the helix), and it does NOT dissolve on contact — it grinds you and keeps drilling, so you must out-damage it. A new behaviour branch. -> L3 stomach; previewed in L2 at s2240. - beacon (Faecalibacterium): the reward-state commensal. Shares the commensal branch, heals AND gives the biggest standing (25) with no coat cost. A bright cyan octahedral gem, the special one of the family. -> L5; previewed in the L2 breather at s1580. Verified in-engine: driller corkscrews + survives contact; virion is tiny + dissolves; beacon heals coat and spikes standing to 50% in two tends. Three clean distinct silhouettes (gem / cone / dot-swarm). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
584 lines
27 KiB
JavaScript
584 lines
27 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,
|
|
},
|
|
// 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;
|
|
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);
|
|
pool.mat.uniforms.uFlash.value = hot ? 0.6 : 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();
|
|
},
|
|
};
|
|
}
|