guts/web/js/combat/boss.js
type-two 7709484ea7 [lane B] The Pyloric Guardian: the first boss, built to C's spec
GUTS has had three bosses designed and none implemented. This is the first, and the framework
the other two should reuse. Built to LANE_C_NOTES §Boss specs, phase table and all.

WHY IT IS SMALL. A boss here is not a parallel entity system, it is a SCHEDULE over the enemy
pool. The nodes and vents are ordinary enemies, so weapons hit them, the InstancedMesh draws
them and enemy:die reports them for free; the fight's identity lives entirely in WHEN they can
be hurt, which is two flags (`invuln`, `armor`) honoured by enemies.hurt(). boss.js is a state
machine and nothing else.

Faithful to C's table:
  P1  4 rim nodes, iris opens 2.5s every 8s
  P2  4 nodes, opens 1.8s every 5s, TWO armoured — and only the antacid torpedo strips them
  P3  the core alone, 60hp = C's "x5 hits", plus the 25s doom timer that kills you if you stall
Telegraphs are C's too: a "deep gulp" 2s before each window (boss:telegraph + audio), and nodes
go amber -> white-hot exactly when they become shootable — one uniform driven off `invuln`, so
the thing that glows IS the thing you can hurt, always.

Vents are turrets at fixed arcs: a turret already fires a telegraphed homing projectile on a
cooldown, which is what an acid vent is.

hp is B's call (C: "numbers need B's framework to tune against"). 150 per rim node means a ring
outlives its first window, so you must come back for it — which is what lets the stomach's
coat-drain be the real clock. C's pillar, verified: parked in the lair for 20s without fighting
back, coat goes 100 -> 0. "A race against your coat, not its hp."

Verified end-to-end: a paced pilot (11 shots/s, ~70% uptime) wins in 38.8s across 9 windows at
0.38 open-duty (spec: 31-36%); phases report 4/0 armoured, 4/2 armoured, 1 core; damage is
refused while the iris is shut; the two armoured nodes consumed exactly 2 torpedo strips.

Not included: C's arena churn (a force on the player, the one part of the spec that is a feel
change rather than a schedule) — noted for the round that tunes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:56 +10:00

168 lines
7.4 KiB
JavaScript

// 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'); },
};
}