// combat/boss.js (Lane B) — the boss framework, and its first boss: the Pyloric Guardian. // // Built to LANE_C_NOTES §Boss specs, which is the authority on phases/telegraphs/windows. // C's design pillar for this fight, quoted so nobody tunes it away: "stomach coat-drain is // continuous, so this is a RACE AGAINST YOUR COAT, not its hp". Every number below serves that // - the windows are short so the fight is long, and the fight is long so the drain is the clock. // // WHY THIS IS SMALL. A boss here is not a new entity system: it is a SCHEDULE over the enemy // pool that already exists. // - nodes and vents are ordinary enemies (`mucosal_node`, `turret`), so Lane B's weapons hit // them, the InstancedMesh draws them and enemy:die reports them, all for free; // - the fight's identity lives in WHEN they can be hurt, which is two flags on the entity // (`invuln`, `armor`) that enemies.hurt() honours; // - so this file is a state machine and nothing else. The second and third bosses should be // able to reuse it without touching enemies.js again. // // The arena churn (C: "pushes you at the walls", x1.5 in P2, reverses as P3's tell) is NOT here. // It wants a force on the player that only arena mode can express, and it is the one part of the // spec that is a feel change rather than a schedule. Noted in NOTES for the round that tunes it. import { BALANCE as B } from './balance.js'; // C's table, verbatim: phase -> { open window, cycle period, node count, armored count }. // `windowScale` (difficulty.window) multiplies every window; C's scaling law. // `hp` is Lane B's to set — C's spec says so ("numbers are design intent, not measured; they // need B's framework to tune against"). Sized against the cannon: 11 shots/s x 12 damage = 132 // dps, so a 2.5 s window is ~330 damage of PERFECT uptime. At 150 hp a rim node survives its // first window, which is what makes the fight a fight — the ring outlives the opening, so you // come back for it, and coming back is what lets the stomach's coat-drain be the real clock. // P3's core is 60 = exactly the "x5 hits" C specifies. const PHASES = [ { id: 1, open: 2.5, period: 8.0, nodes: 4, hp: 150, armored: 0, vents: 3, ventEvery: 3.0 }, { id: 2, open: 1.8, period: 5.0, nodes: 4, hp: 150, armored: 2, vents: 3, ventEvery: 2.4 }, { id: 3, open: 1.2, period: 4.0, nodes: 1, hp: 60, armored: 0, vents: 3, ventEvery: 2.0, doom: 25 }, ]; const TELEGRAPH = 2.0; // C: "a deep gulp 2 s before every open" const RIM_RHO = 0.82; // fraction of the safe radius the nodes sit at - the iris rim export function createBoss({ bus, rng, player, enemies, flags = {} }) { const rand = rng ? rng('boss') : () => 0.5; const windowScale = Number(flags.window) > 0 ? Number(flags.window) : 1; let active = null; // null | the running fight const offs = []; function spawnRing(s, phase) { const out = []; for (let i = 0; i < phase.nodes; i++) { // Evenly spaced around the cross-section, rotated a little each phase so a player who // memorised one safe approach has to re-read the ring. const theta = (i / phase.nodes) * Math.PI * 2 + rand() * 0.5; const e = enemies.spawn('mucosal_node', { s, theta, rho: RIM_RHO }); if (!e) continue; e.hp = phase.hp; // per-phase: rim nodes are tanky, the core is 5 hits e.invuln = true; // the iris starts shut e.armor = i < phase.armored; // C's P2: two of the four are armoured out.push(e); } return out; } function spawnVents(s, count) { const out = []; for (let i = 0; i < count; i++) { // C: "3 fixed vents spit acid". A turret already fires a telegraphed homing projectile on // a cooldown, which is what a vent is - so a vent IS a turret, at a fixed arc, and the // acid spout costs zero new code. const e = enemies.spawn('turret', { s: s - 6, theta: (i / count) * Math.PI * 2 + 0.4, rho: 0.98 }); if (e) out.push(e); } return out; } function setPhase(f, n) { f.phase = PHASES[n]; f.phaseIdx = n; for (const e of f.nodes) if (e.alive) e.alive = false; // old ring is gone f.nodes = spawnRing(f.s, f.phase); f.open = false; f.t = 0; f.doomT = f.phase.doom ?? 0; for (const v of f.vents) if (v.alive) v.cfg = { ...v.cfg, fireEvery: f.phase.ventEvery }; bus.emit('boss:phase', { id: f.id, phase: f.phase.id, nodes: f.nodes.length }); bus.emit('audio:cue', { name: 'overheat' }); // the ring changes gear } function start(ev) { if (active) return; const f = { id: ev.id ?? 'pyloric_guardian', s: ev.s ?? 0, nodes: [], vents: [], phase: PHASES[0], phaseIdx: 0, open: false, t: 0, doomT: 0, warned: false, done: false, }; f.vents = spawnVents(f.s, PHASES[0].vents); active = f; setPhase(f, 0); bus.emit('boss:start', { id: f.id, s: f.s, phases: PHASES.length }); return f; } function finish(f, outcome) { if (f.done) return; f.done = true; for (const e of [...f.nodes, ...f.vents]) if (e.alive) e.alive = false; active = null; bus.emit('boss:end', { id: f.id, outcome }); bus.emit('audio:cue', { name: outcome === 'win' ? 'gate_pass' : 'death' }); } function update(dt) { const f = active; if (!f || f.done) return; const live = f.nodes.filter((e) => e.alive); // Ring cleared -> next phase, or the fight is over. if (!live.length) { if (f.phaseIdx + 1 < PHASES.length) setPhase(f, f.phaseIdx + 1); else finish(f, 'win'); return; } // P3's doom timer: it stops opening and tries to seal permanently. Run it down whatever the // iris is doing - the only escape is killing the core, which is why P3's ring is one node. if (f.doomT > 0) { f.doomT -= dt; if (f.doomT <= 0) { player?.kill?.('sealed'); finish(f, 'sealed'); return; } } // The cycle. `open` is the whole fight: nodes are only shootable while the sphincter relaxes. const openFor = f.phase.open * windowScale; f.t += dt; if (f.t >= f.phase.period) f.t -= f.phase.period; const wasOpen = f.open; f.open = f.t < openFor; // Telegraph: the deep gulp, TELEGRAPH seconds before the window comes round again. const untilOpen = f.t < openFor ? 0 : f.phase.period - f.t; if (!f.open && untilOpen <= TELEGRAPH && !f.warned) { f.warned = true; bus.emit('boss:telegraph', { id: f.id, phase: f.phase.id, eta: untilOpen }); bus.emit('audio:cue', { name: 'warn_reflux_surge' }); } if (f.open) f.warned = false; if (f.open !== wasOpen) { for (const e of live) e.invuln = !f.open; bus.emit('boss:window', { id: f.id, open: f.open, phase: f.phase.id }); bus.emit('audio:cue', { name: f.open ? 'surge_start' : 'surge_end' }); } } offs.push(bus.on('level:event', (ev) => { if (ev?.type === 'boss') start(ev); })); // A death mid-fight resets the encounter rather than leaving a half-killed ring in the arena: // C put a checkpoint at 2260 precisely so a wipe costs seconds, and that promise only holds if // the boss re-arms with it. offs.push(bus.on('player:death', () => { if (active) finish(active, 'wipe'); })); return { update, get active() { return active; }, get phase() { return active?.phase.id ?? 0; }, get open() { return !!active?.open; }, reset() { if (active) finish(active, 'reset'); }, dispose() { for (const off of offs) off(); if (active) finish(active, 'dispose'); }, }; }