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>
168 lines
7.4 KiB
JavaScript
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'); },
|
|
};
|
|
}
|