Four audio/comms cues wired to the wrong condition. All four are written content that has been
in the repo for rounds, firing at the wrong moment or never firing at all.
THE DEATH STING PLAYED AT LIVING PLAYERS. finish() mapped every non-win outcome to `death` — and
`death` is not a stinger, it is a ~12 s noise table (synth.js). So surviving L1's Mast Cell
("degranulated"), outrunning the Tapeworm ("escaped") and clearing the Sovereign's Toll all told
an alive player they had died, over the top of the next twelve seconds of play. Outcomes are
classified now: won/spared/communion triumph, alive-but-it-went-badly gets a downbeat, actually
dead gets the sting, and housekeeping (reset/dispose) is silent because nothing happened to the
player at all. Verified: surviving the Mast Cell no longer emits `death`; dying still does.
THE PHASE TELL WAS A SUSTAINING HISS. boss.js used `overheat` as its phase-change cue, but that
layer sustains until the engine sends `overheat_clear` on a combat:state.overheated falling edge
— which a boss never produces. Every phase change leaked a ~12 s steam hiss and ducked the mix
under it for the rest of the fight. Three sites, now short cues.
THE CREW HAS NEVER MENTIONED HULL DAMAGE. comms.js gates its two hull lines on
`kind === 'hull_hit'`, but `kind` is the damage SOURCE ('dart'|'acid'|'gas'…), so that branch has
never once been true in the game's life and every hit read as a coat graze. damage() already
computes `leak > 0` for its own audio cue — the bus event now carries it as `hull`.
AND IT NAMED THE WRONG ORGAN. There was exactly ONE boss line — "that is the pylorus. it does not
open for you." — fired for all five bosses, so the tutorial's Mast Cell and the campaign's final
boss were both announced as a stomach valve. Per-boss lines now, off boss:start (which is the only
place the id is known, and for the Sovereign whether there is a fight at all). The Mast Cell's is
doing real work: "wait — wait. that is a mast cell. it is not hunting you." / "do NOT shoot it."
The crew is the game's only tutorialisation channel, and that is the lesson L1 exists to teach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
464 lines
25 KiB
JavaScript
464 lines
25 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.
|
|
//
|
|
// Two things beyond the schedule, both added once the primitives existed:
|
|
// churn — a force on the player (C: "pushes you at the walls"). hazards.js owns the level-side
|
|
// `churn` kind; a boss can carry its own via `def.churn` + per-phase `churnMul`.
|
|
// flee — the boss's own s advances and its ring rides along, so the encounter becomes a
|
|
// DISTANCE race you can lose without dying. That is the Tapeworm's whole pressure.
|
|
|
|
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.
|
|
//
|
|
// THE REGISTRY. A boss is a list of phases plus a few knobs; everything mechanical is shared.
|
|
// Adding the Sovereign below required no change to enemies.js at all, which is the claim the
|
|
// Guardian's header made and this is the test of it.
|
|
// open/period the vulnerability window and its cycle. open >= period = always shootable.
|
|
// nodes/hp the ring spawned for that phase; `armored` of them need a torpedo first.
|
|
// vents turrets at fixed arcs (their homing dart IS an acid spout / a spore jet).
|
|
// bloom {every, kinds, cap} — adds spawned during the phase (quorum sensing).
|
|
// doom seconds before the phase kills you if you have not finished it.
|
|
const BOSSES = {
|
|
pyloric_guardian: {
|
|
telegraph: 2.0, // C: "a deep gulp 2 s before every open"
|
|
node: 'mucosal_node',
|
|
rho: 0.82, // the iris rim
|
|
// C's arena churn, which the first Guardian commit deferred because nothing in the tree
|
|
// applied a force to the player. hazards.js has a `churn` kind now, so this is the same
|
|
// idea owned by the boss instead of the level: `out` shoves you AT THE WALLS (C's words),
|
|
// `spin` drags you around the cross-section, and the whole thing reverses on `period` so it
|
|
// cannot simply be leaned against. `churnMul` per phase — C asks for x1.5 in P2.
|
|
churn: { out: 9, spin: 12, period: 8 },
|
|
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, churnMul: 1.5 },
|
|
{ id: 3, open: 1.2, period: 4.0, nodes: 1, hp: 60, armored: 0, vents: 3, ventEvery: 2.0, doom: 25, churnMul: 1.8 },
|
|
],
|
|
},
|
|
|
|
// LANE_C_NOTES §3. The final boss: hull attrition, inverting the Guardian. C's spec makes the
|
|
// gas pockets the fight — "shoot one and it detonates, destroying adjacent plates outright, and
|
|
// hurting you inside ~15 units. Optimal play = detonate 2-3 from max range." That needs no
|
|
// special-casing here: pockets are entities whose kill() already chains a blast through
|
|
// everything nearby, so seeding the ring with them via `bloom` makes them the boss's own worst
|
|
// weakness. The player's problem is that they are also the only light in the room.
|
|
the_blockage: {
|
|
telegraph: 1.5, // C: "a full-canal groan 1.5 s before each contraction"
|
|
node: 'mucosal_node', // the armoured plates
|
|
rho: 0.72,
|
|
phases: [
|
|
// P1 The Face — 5 plates, continuous (C: "the pockets are the timer", not an iris).
|
|
// hp is high because the windows are NOT: C's spec makes P1 continuous ("the pockets are
|
|
// the timer"), so unlike the Guardian nothing gates your dps but your own aim. First cut at
|
|
// 120 gave a 25.7s finale — shorter than the mid-game boss, which is the wrong shape for a
|
|
// campaign. The pockets are the intended shortcut: a detonation does 90 to every plate in
|
|
// range, so using the room is worth roughly a third of a plate per blast.
|
|
{ id: 1, open: 9, period: 9, nodes: 5, hp: 220, armored: 0, vents: 3, ventEvery: 2.8,
|
|
bloom: { every: 4.0, kinds: ['gas_pocket'], cap: 6, rearmAt: 99 } },
|
|
// P2 Fragmentation — the mass breaks into 4 tumbling chunks; the core opens intermittently.
|
|
{ id: 2, open: 2.0, period: 6.0, nodes: 4, hp: 230, armored: 1, vents: 3, ventEvery: 2.2,
|
|
bloom: { every: 3.4, kinds: ['gas_pocket', 'virion'], cap: 8, rearmAt: 99 } },
|
|
// P3 The Push — the colon contracts and drives it toward the exit with you behind it.
|
|
// 30s doom: if it plugs the rectum, you lose.
|
|
{ id: 3, open: 1.6, period: 5.0, nodes: 1, hp: 280, armored: 0, vents: 4, ventEvery: 1.8,
|
|
doom: 30, bloom: { every: 2.6, kinds: ['gas_pocket'], cap: 5, rearmAt: 99 } },
|
|
],
|
|
},
|
|
|
|
// LANE_C_NOTES §2. The distance race — "it does not fight you, it RUNS, and the canal is its
|
|
// body". The pressure is losing it, not dying to it, which needed one new idea in the schedule:
|
|
// `flee`. The boss's own s advances every frame and its segments ride along at fixed offsets,
|
|
// so the ring is strung out down the tube ahead of you. Fall `loseAt` units behind and it is
|
|
// gone — the encounter ends, you live, and you have lost it. C: "a chase you can lose without
|
|
// dying." Its speed sits just under a boosting player's, so keeping up is a throttle
|
|
// commitment that costs you the room to dodge with.
|
|
tapeworm: {
|
|
telegraph: 1.2,
|
|
node: 'mucosal_node', // proglottid weak points (the genital pore, which is real)
|
|
rho: 0.55,
|
|
flee: { speed: 17, loseAt: 110, spread: 14 },
|
|
phases: [
|
|
// P1 The Tail — 6 proglottids, each vulnerable only as its pore rolls into view.
|
|
{ id: 1, open: 1.8, period: 4.0, nodes: 6, hp: 70, armored: 0, vents: 0, ventEvery: 9 },
|
|
// P2 Constriction — it coils into 3 rotating rings; the weak points are the joints.
|
|
{ id: 2, open: 1.5, period: 3.6, nodes: 3, hp: 110, armored: 1, vents: 2, ventEvery: 2.6 },
|
|
// P3 The Scolex — it turns and charges. Suckers exposed only as it lunges.
|
|
{ id: 3, open: 1.4, period: 4.0, nodes: 1, hp: 150, armored: 0, vents: 3, ventEvery: 1.9 },
|
|
],
|
|
},
|
|
|
|
// LANE_C_NOTES §5. The restraint boss, and the one that is not shaped like a boss at all —
|
|
// hence `kind: 'sentinel'` and its own update path. There is no ring, no window and no hp bar
|
|
// you are meant to empty: there is a cell on patrol, and a question. Damage is the FAILURE
|
|
// state, so every number here describes a consequence rather than a fight.
|
|
mast_cell: {
|
|
kind: 'sentinel',
|
|
telegraph: 0,
|
|
node: 'mast_cell',
|
|
hp: 400, // the threshold the entity spawns with (balance.js agrees)
|
|
passS: 70, // travel this far past it and the encounter resolves, either way
|
|
// P2 Degranulation — triggered by ANY damage. Histamine: the cavity fills from the walls
|
|
// inward (droplets pinned at high rho ARE the swelling, expressed in entities) and the coat
|
|
// drain doubles. Note it never chases and never shoots: the level got worse, not the cell.
|
|
// drain is NET-of-regen or it is nothing: the player regenerates coat at 8/s (tuning.js
|
|
// coat.regen) and drain() does not reset that timer, so the first cut at 5/s meant getting
|
|
// histamine actually HEALED you (measured: coat 80 -> 100 during a degranulation). 14 leaves
|
|
// a real -6/s bleed; the cascade has to be felt or the lesson has no teeth.
|
|
degran: { every: 1.7, cap: 10, rho: 0.9, drain: 14 },
|
|
// P3 Anaphylaxis — past half. The airway is closing, and now there is a clock.
|
|
ana: { at: 0.5, doom: 25, every: 0.9, cap: 18, drain: 22 }, // -14/s net: the airway closing
|
|
},
|
|
|
|
// LANE_C_NOTES §4. The reputation boss. `standing` gates the whole encounter on entry, and
|
|
// keeps mattering: every commensal you kill in the lair re-armours a plate (see onHarm).
|
|
appendix_sovereign: {
|
|
telegraph: 1.5, // C: "a rising chorus 1.5 s before a quorum bloom"
|
|
node: 'mucosal_node', // the matrix plates / feeding channels reuse the node body
|
|
rho: 0.7,
|
|
standing: { spare: 70, soften: 30 }, // >= spare: no fight at all. >= soften: P1 only.
|
|
phases: [
|
|
// P1 — the matrix. ALL plates armoured, so the cannon genuinely cannot start this fight:
|
|
// only the antacid torpedo disperses matrix. Always "open": a biofilm has no iris, its
|
|
// defence is chemistry, and the gate on progress is AMMO.
|
|
{ id: 1, open: 9, period: 9, nodes: 4, hp: 90, armored: 4, vents: 2, ventEvery: 3.2 },
|
|
// P2 — quorum bloom. Channels exposed; it calls for defenders on a timer and re-armours
|
|
// when the room gets crowded. The gate on progress is HEAT.
|
|
{ id: 2, open: 7, period: 7, nodes: 3, hp: 110, armored: 0, vents: 2, ventEvery: 2.6,
|
|
bloom: { every: 3.2, kinds: ['virion', 'virion', 'spore_pod'], cap: 14, rearmAt: 10 } },
|
|
// P3 — the reseed. It runs for the colon; 20 s to stop it or it lives and the secret is
|
|
// lost. Short exposures while it swims.
|
|
{ id: 3, open: 2.0, period: 5.0, nodes: 1, hp: 80, armored: 0, vents: 2, ventEvery: 2.2, doom: 20 },
|
|
],
|
|
},
|
|
};
|
|
|
|
export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = null }) {
|
|
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(f, 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(f.def.node, { s: f.s, theta, rho: f.def.rho });
|
|
if (!e) continue;
|
|
// A fleeing boss is strung out DOWN THE TUBE, not packed into one cross-section: each
|
|
// segment keeps its own offset so the worm reads as a body you chase along, not a ring.
|
|
if (f.def.flee) { e.dS = i * f.def.flee.spread; e.s = f.s + e.dS; }
|
|
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 = f.def.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, f.phase);
|
|
f.open = false;
|
|
f.t = 0;
|
|
f.bloomT = 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 });
|
|
// NOT 'overheat': that cue SUSTAINS until the engine sends overheat_clear on a
|
|
// combat:state.overheated falling edge, which a boss never produces — so every phase
|
|
// change leaked a ~12 s steam hiss and ducked the mix under it for the whole fight.
|
|
bus.emit('audio:cue', { name: 'gate_hit' }); // the ring changes gear
|
|
}
|
|
|
|
function start(ev) {
|
|
if (active) return;
|
|
const def = BOSSES[ev.id] ?? BOSSES.pyloric_guardian;
|
|
const f = {
|
|
id: ev.id ?? 'pyloric_guardian', def, s: ev.s ?? 0,
|
|
// `phases` is absent on a sentinel (it has no rings and no windows) — optional all the
|
|
// way down rather than a fake one-entry table, so the sentinel path stays honestly separate.
|
|
nodes: [], vents: [], phase: def.phases?.[0] ?? null, phaseIdx: 0,
|
|
open: false, t: 0, bloomT: 0, doomT: 0, warned: false, done: false,
|
|
};
|
|
|
|
// The sentinel spawns one cell and no vents, because it is not attacking anybody.
|
|
if (def.kind === 'sentinel') {
|
|
f.tier = 0; f.spawnT = 0; f.doomT = 0;
|
|
f.cell = enemies.spawn(def.node, { s: f.s, rho: 0.25 });
|
|
if (f.cell) f.cell.hp = def.hp;
|
|
active = f;
|
|
bus.emit('boss:start', { id: f.id, s: f.s, phases: 3, tier: 'patrol' });
|
|
return f;
|
|
}
|
|
|
|
// LANE_C_NOTES §4: the Sovereign is the one boss whose existence is decided before you
|
|
// arrive. KARMA (the unspendable conduct ledger, not the spendable standing meter) is read
|
|
// ONCE, on entry — a verdict on how you played the campaign, not on how recently you cashed
|
|
// your reputation in for an ally.
|
|
if (def.standing) {
|
|
const st = getKarma ? getKarma() : 0;
|
|
f.standing = st;
|
|
if (st >= def.standing.spare) {
|
|
// The Communion. The reward for five levels of restraint is being spared a boss.
|
|
active = f;
|
|
bus.emit('boss:start', { id: f.id, s: f.s, phases: 0, tier: 'communion', standing: st });
|
|
player?.refill?.({ coat: 999, hull: 40 });
|
|
for (let i = 0; i < 3; i++) enemies.spawn('phage', { s: f.s + 6 + i * 4 });
|
|
finish(f, 'communion');
|
|
return f;
|
|
}
|
|
// The Toll: it does not know you, so it shows you the matrix and nothing worse.
|
|
f.maxPhase = st >= def.standing.soften ? 1 : def.phases.length;
|
|
}
|
|
|
|
f.vents = spawnVents(f.s, def.phases[0].vents);
|
|
active = f;
|
|
setPhase(f, 0);
|
|
bus.emit('boss:start', {
|
|
id: f.id, s: f.s, phases: f.maxPhase ?? def.phases.length,
|
|
tier: f.maxPhase === 1 ? 'toll' : 'full', standing: f.standing,
|
|
});
|
|
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;
|
|
// The sentinel's cell survives a RESOLVED encounter on purpose — you spared it, so it lives,
|
|
// and it should still be drifting there as you leave. But a wipe/reset is not a resolution:
|
|
// the encounter re-arms on respawn (boot's rewindTo), and leaving the old body behind stacks
|
|
// a second Mast Cell in the cavity every time you die. Measured: 1 -> 2 cells after one death.
|
|
if (f.cell?.alive && (outcome === 'wipe' || outcome === 'reset' || outcome === 'dispose')) {
|
|
f.cell.alive = false;
|
|
}
|
|
active = null;
|
|
bus.emit('boss:end', { id: f.id, outcome });
|
|
// `spared` and `communion` are WINS that involve no killing, and the instrument has to agree
|
|
// with the design or it teaches the opposite lesson: a triumphant cue for walking away is
|
|
// most of how L1 tells the player that walking away was the answer.
|
|
// The `death` cue is a ~12 s noise-table sting, not a stinger — playing it at a LIVING player
|
|
// is the one lie an audio layer must never tell, and it was firing for every non-win. So:
|
|
// won / spared / communion -> a triumph,
|
|
// still alive but it went badly (degranulated, escaped, survived the cascade) -> a downbeat
|
|
// "that did not go well", NOT a death,
|
|
// actually dead (sealed by the Guardian, anaphylaxis, wiped) -> the death sting, correctly,
|
|
// housekeeping (reset/dispose) -> silence, because nothing happened to the player at all.
|
|
const WON = new Set(['win', 'spared', 'communion']);
|
|
const DIED = new Set(['sealed', 'anaphylaxis', 'wipe']);
|
|
const SILENT = new Set(['reset', 'dispose', 'lost']);
|
|
if (!SILENT.has(outcome)) {
|
|
bus.emit('audio:cue', { name: WON.has(outcome) ? 'gate_pass' : DIED.has(outcome) ? 'death' : 'surge_end' });
|
|
}
|
|
}
|
|
|
|
// The sentinel: L1's Mast Cell. Its entire state is "has the player hurt it yet", and the
|
|
// player's own restraint is the only input. Nothing here attacks — the escalation is the LEVEL
|
|
// getting worse, which is the point C's spec makes: the punishment is not damage, it is
|
|
// consequence, and it is authored by the person holding the trigger.
|
|
function updateSentinel(f, dt) {
|
|
const e = f.cell;
|
|
const ps = player?.state;
|
|
if (!e || !ps) { finish(f, 'lost'); return; }
|
|
|
|
const frac = e.alive ? e.hp / f.def.hp : 0; // killed outright = maximum cascade
|
|
if (frac < 0.999 && f.tier === 0) {
|
|
f.tier = 1; // DEGRANULATION — the first hit does this
|
|
bus.emit('boss:phase', { id: f.id, phase: 2, reason: 'degranulation' });
|
|
bus.emit('audio:cue', { name: 'enemy_hit' });
|
|
}
|
|
if (f.tier === 1 && frac <= f.def.ana.at) {
|
|
f.tier = 2; // ANAPHYLAXIS — and now a clock
|
|
f.doomT = f.def.ana.doom;
|
|
bus.emit('boss:phase', { id: f.id, phase: 3, reason: 'anaphylaxis' });
|
|
bus.emit('audio:cue', { name: 'death' });
|
|
}
|
|
|
|
if (f.tier > 0) {
|
|
const cfg = f.tier === 2 ? f.def.ana : f.def.degran;
|
|
player.drain?.(cfg.drain * dt); // histamine: the coat goes, continuously
|
|
f.spawnT -= dt;
|
|
if (f.spawnT <= 0) {
|
|
f.spawnT = cfg.every;
|
|
const crowd = enemies.live.filter((x) => x.alive && x.type === 'floater').length;
|
|
// Pinned near the wall and closing in: this IS "the cavity swells shut", built from
|
|
// entities because a runtime tube radius is not in Lane A's contract. Noted in NOTES.
|
|
if (crowd < cfg.cap) enemies.spawn('floater', { s: ps.s + 20 + rand() * 40, rho: f.def.degran.rho });
|
|
}
|
|
if (f.doomT > 0) {
|
|
f.doomT -= dt;
|
|
if (f.doomT <= 0) { player.kill?.('anaphylaxis'); finish(f, 'anaphylaxis'); return; }
|
|
}
|
|
}
|
|
|
|
// Resolution: you always leave. The only question is what state you leave in.
|
|
if (ps.s > f.s + f.def.passS) {
|
|
if (f.tier === 0) {
|
|
// SPARED. The best outcome in the encounter is the one where nothing happened, and the
|
|
// game has to say so out loud or the lesson does not land.
|
|
player.refill?.({ coat: 40 });
|
|
bus.emit('flora:tended', { s: f.s, gain: 25 }); // it IS the biome; conduct is credited
|
|
finish(f, 'spared');
|
|
} else {
|
|
finish(f, f.tier === 2 ? 'anaphylaxis-survived' : 'degranulated');
|
|
}
|
|
}
|
|
}
|
|
|
|
function update(dt) {
|
|
const f = active;
|
|
if (!f || f.done) return;
|
|
if (f.def.kind === 'sentinel') { updateSentinel(f, dt); return; }
|
|
|
|
const live = f.nodes.filter((e) => e.alive);
|
|
|
|
// Ring cleared -> next phase, or the fight is over. `maxPhase` is the Sovereign's Toll tier:
|
|
// a neutral biome shows you the matrix and, once you have cleared it, decides that will do.
|
|
const lastPhase = f.maxPhase ?? f.def.phases.length;
|
|
if (!live.length) {
|
|
if (f.phaseIdx + 1 < lastPhase) setPhase(f, f.phaseIdx + 1);
|
|
else finish(f, 'win');
|
|
return;
|
|
}
|
|
|
|
// Quorum bloom (Sovereign P2): it calls for defenders on a timer, and when the room gets
|
|
// crowded it re-armours a channel — so letting the population run is how you lose ground.
|
|
// Population is the clock here, which is why this phase reads as a heat-management fight.
|
|
const bl = f.phase.bloom;
|
|
if (bl) {
|
|
f.bloomT -= dt;
|
|
if (f.bloomT <= 0) {
|
|
f.bloomT = bl.every;
|
|
const crowd = enemies.live.filter((e) => e.alive && bl.kinds.includes(e.type)).length;
|
|
if (crowd < bl.cap) {
|
|
const kind = bl.kinds[(f.bloomN = (f.bloomN ?? 0) + 1) % bl.kinds.length];
|
|
enemies.spawn(kind, { s: f.s + (rand() - 0.5) * 20 });
|
|
bus.emit('audio:cue', { name: 'enemy_hit' });
|
|
}
|
|
if (crowd >= bl.rearmAt) { // above quorum: the matrix comes back
|
|
const bare = live.find((e) => !e.armor);
|
|
if (bare) { bare.armor = true; bus.emit('boss:rearm', { id: f.id, reason: 'quorum' }); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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; }
|
|
}
|
|
|
|
const ps = player?.state;
|
|
|
|
// CHURN — a boss that stirs its own room. `out` presses you toward the wall, `spin` drags you
|
|
// around it, and both reverse on `period` so the answer keeps moving. Per-phase `churnMul`.
|
|
if (f.def.churn && ps?.alive) {
|
|
const c = f.def.churn, mul = f.phase.churnMul ?? 1;
|
|
f.churnT = (f.churnT ?? 0) + dt;
|
|
const dir = Math.sin((f.churnT * Math.PI * 2) / c.period) >= 0 ? 1 : -1;
|
|
const rho = Math.hypot(ps.x, ps.y) || 1e-5;
|
|
const ux = ps.x / rho, uy = ps.y / rho; // outward radial
|
|
player.shove((ux * c.out + -uy * c.spin * dir) * mul * dt,
|
|
(uy * c.out + ux * c.spin * dir) * mul * dt);
|
|
}
|
|
|
|
// FLEE — the Tapeworm. It runs, its segments ride along at their spawn offsets, and losing it
|
|
// is a real outcome: you live, the encounter ends, and it got away with the level's miniboss.
|
|
if (f.def.flee && ps) {
|
|
f.s += f.def.flee.speed * dt;
|
|
for (const e of f.nodes) if (e.alive) e.s = f.s + (e.dS ?? 0);
|
|
if (f.s - ps.s > f.def.flee.loseAt) { finish(f, 'escaped'); 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 (or the Sovereign's rising chorus) before the window returns.
|
|
const untilOpen = f.t < openFor ? 0 : f.phase.period - f.t;
|
|
if (!f.open && untilOpen <= f.def.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); }));
|
|
|
|
// LANE_C_NOTES §4, the mechanic that makes the Sovereign this boss and not another: the lair
|
|
// is seeded with your own flora, and the Sovereign FEEDS ON DYSBIOSIS. Every commensal your
|
|
// stray fire kills re-armours a plate — the fight gets harder by your hand, in real time,
|
|
// while a boss is in your face. It is the only encounter where the right move is regularly to
|
|
// stop shooting. (Gated on `standing` so only reputation bosses care.)
|
|
offs.push(bus.on('flora:harmed', () => {
|
|
const f = active;
|
|
if (!f || f.done || !f.def.standing) return;
|
|
const bare = f.nodes.find((e) => e.alive && !e.armor);
|
|
if (bare) bare.armor = true;
|
|
bus.emit('boss:rearm', { id: f.id, reason: 'dysbiosis' });
|
|
bus.emit('audio:cue', { name: 'enemy_hit' });
|
|
}));
|
|
// 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 ?? active?.tier ?? 0; },
|
|
get open() { return !!active?.open; },
|
|
reset() { if (active) finish(active, 'reset'); },
|
|
dispose() { for (const off of offs) off(); if (active) finish(active, 'dispose'); },
|
|
};
|
|
}
|