L1 has been a 2-event skeleton since round 1, waiting (in its own status field) on "Lane B's arena-mode controller". That exists now, so C's round2_plan is built as written: molar crush-cycles, the saliva tide, amylase droplets, the three samples exactly where C put them (behind the molars / under the tongue / a tonsillar crypt), and the epiglottis exam. 24 events. The teaching order is the level: move (orbs, no current) -> LOOK (the arena opens and you can stop, which no other level allows) -> shoot (one droplet, then two) -> coat (the molar ring grazes you, mucin is right there) -> RESTRAINT -> flow returns (the tide, the funnel) -> the exam. Every verb alone, in the calmest room in the game, before anything is asked of it. Two hazards are deliberately re-used rather than invented: the molar crush IS a ring_gate and the saliva tide IS a non-lethal surge, so L2's gates and its reflux finale become callbacks instead of new ideas. THE MAST CELL (LANE_C_NOTES §Boss specs 5) sits at the exact midpoint. A sentinel immune cell: cyan, no attack, no chase, inert aura (auraDps 0 — it shares the floater's drift branch and its aura does literally nothing). Fly past and the encounter just ends. Shoot it and it degranulates — droplets close in from the walls (which is how "the cavity swells shut" is expressed, because a runtime tube radius is not in Lane A's contract), the coat bleeds, and past half health the airway closes on a 25s timer. You always get through. The only question is what you get through. It needed a new boss `kind: 'sentinel'` with its own update path, because forcing a thing with no rings and no windows through the phase machine would have been worse than a branch. Damage is the failure state, so every number describes a consequence rather than a fight. Verified, all three player choices: SPARE -> "spared", coat untouched at 100, karma +25, score 0. SHOOT -> "degranulated", coat 100->56. KILL -> "anaphylaxis-survived", coat 100->6. Score is 0 in every case: shooting it buys literally nothing, so no medal can ever reward the mistake. Two bugs the testing paid for: `def.phases[0]` crashed on a boss that has no phases, and the first degranulation drain (5/s) was UNDER the player's 8/s coat regen — measured coat going 80 -> 100 during a histamine cascade, i.e. the punishment was healing you. Now 14 and 22, which are net -6/s and -14/s against regen. L1 is no longer a skeleton: its checkpoint-gap and zero-samples warnings are gone, and the campaign now opens on a real tutorial instead of an empty mouth with a gate at the end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
359 lines
18 KiB
JavaScript
359 lines
18 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.
|
|
//
|
|
// 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
|
|
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 },
|
|
],
|
|
},
|
|
|
|
// 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;
|
|
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 });
|
|
bus.emit('audio:cue', { name: 'overheat' }); // 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;
|
|
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.
|
|
const good = outcome === 'win' || outcome === 'spared' || outcome === 'communion';
|
|
bus.emit('audio:cue', { name: good ? 'gate_pass' : 'death' });
|
|
}
|
|
|
|
// 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: 'overheat' });
|
|
}
|
|
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; }
|
|
}
|
|
|
|
// 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: 'overheat' });
|
|
}));
|
|
// 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'); },
|
|
};
|
|
}
|