// 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 { 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 // 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. Fallback // silhouette is a bare icosahedron = the phage's real icosahedral capsid (a Hunyuan mesh with // tail fibres is the eventual upgrade). phage: { cfg: B.enemies.phage, geo: () => new THREE.IcosahedronGeometry(B.enemies.phage.radius, 0), color: EMISSIVE.ally, 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, }; 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; } 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 === 'floater' || e.type === 'spore_pod') { // drifts and bobs; taxes the lane it occupies rather than chasing. spore_pod shares // this behaviour exactly — it's a floater with its own pool/mesh (a yeast cluster). 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') { // The FRIEND. Drifts like a floater but its aura HEALS: brush past and it sheds a // little mucus coat (the "mobile mucin lane"). Never damages you. It's still in the // enemy pool, so you CAN shoot it — and that is the reputation trap, handled in 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) { player.refill({ coat: cfg.coat }); // friendly: gives coat instead of taking it e.auraT = cfg.auraTick; bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter } } } 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') { // pursue in spline space — a chase for three float subtractions 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 === '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') { // hunters point along their 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(); }, }; }