[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>
This commit is contained in:
parent
4ee6ae49a1
commit
7709484ea7
@ -60,6 +60,42 @@
|
||||
"web"
|
||||
],
|
||||
"port": 8142
|
||||
},
|
||||
{
|
||||
"name": "guts-boss",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8143",
|
||||
"--directory",
|
||||
"web"
|
||||
],
|
||||
"port": 8143
|
||||
},
|
||||
{
|
||||
"name": "guts-boss2",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8144",
|
||||
"--directory",
|
||||
"web"
|
||||
],
|
||||
"port": 8144
|
||||
},
|
||||
{
|
||||
"name": "guts-boss3",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8148",
|
||||
"--directory",
|
||||
"web"
|
||||
],
|
||||
"port": 8148
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -111,6 +111,16 @@ export const BALANCE = {
|
||||
driller: { hp: 50, radius: 1.6, score: 220, speed: 16, turn: 2.0, contact: 18, aggroS: 60,
|
||||
spin: 4, corkscrew: 6, pool: 8 },
|
||||
|
||||
// mucosal_node — the Pyloric Guardian's rim nodes (LANE_C_NOTES §Boss specs). Fixed: no
|
||||
// movement branch in enemies.update, which is exactly right for a node bolted to an iris.
|
||||
// It is combat/boss.js that makes them a boss — the phase cycle flips `invuln` so they are
|
||||
// only shootable while the sphincter relaxes. This `hp` is only the default — boss.js sets
|
||||
// it per phase (150 for the rim rings, 60 for P3's core, which is C's "x5 hits").
|
||||
// pool 12, not 9: a fight spawns 4 + 4 + 1 rings, and the retired ones only leave the pool
|
||||
// when enemies.reap() next runs. Sizing to the exact total would make phase 3 depend on
|
||||
// reap timing, and a boss that silently spawns no core is the worst bug in the file.
|
||||
mucosal_node: { hp: 150, radius: 2.6, score: 400, pool: 12 },
|
||||
|
||||
// beacon (Faecalibacterium) — the reward-state commensal. The most anti-inflammatory bug in
|
||||
// you; a screen of these IS a healthy gut. Shares the commensal branch: heals AND gives the
|
||||
// biggest standing (gain 25) with no coat cost. Rare (pool 10) — the pure payoff node.
|
||||
|
||||
167
web/js/combat/boss.js
Normal file
167
web/js/combat/boss.js
Normal file
@ -0,0 +1,167 @@
|
||||
// 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'); },
|
||||
};
|
||||
}
|
||||
@ -139,6 +139,15 @@ const ARCHETYPES = {
|
||||
geo: () => { const g = new THREE.ConeGeometry(B.enemies.driller.radius * 0.6, B.enemies.driller.radius * 2.6, 5); g.rotateX(Math.PI / 2); return g; },
|
||||
color: EMISSIVE.hostile,
|
||||
},
|
||||
// mucosal_node — the Pyloric Guardian's rim nodes. No movement branch (a node bolted to an
|
||||
// iris should not drift); combat/boss.js drives their vulnerability. Amber like any hostile,
|
||||
// and boss.js swaps the pool's flash uniform to white-hot while the window is open — C's
|
||||
// telegraph, "nodes amber -> white-hot when vulnerable", for free.
|
||||
mucosal_node: {
|
||||
cfg: B.enemies.mucosal_node,
|
||||
geo: () => new THREE.DodecahedronGeometry(B.enemies.mucosal_node.radius, 0),
|
||||
color: EMISSIVE.hostile,
|
||||
},
|
||||
// beacon (Faecalibacterium) — the reward commensal. Cyan (flora family), a bright octahedral
|
||||
// gem so it reads as the special one; shares the commensal branch (heal + big standing).
|
||||
beacon: {
|
||||
@ -235,6 +244,18 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
let comboN = 0, comboT = 0;
|
||||
function hurt(e, dmg, kind = 'hit') {
|
||||
if (!e.alive) return;
|
||||
// Boss gates (combat/boss.js owns both flags; nothing else sets them).
|
||||
// invuln — the Guardian's iris is shut, so its nodes are behind muscle. Not a miss: it
|
||||
// pings so the player learns "wait for the window" instead of "my gun is broken".
|
||||
// armor — LANE_C_NOTES P2: two regrown nodes are armoured and ONLY the antacid torpedo
|
||||
// strips them. The torpedo spends its armour-stripping hit rather than damaging.
|
||||
if (e.invuln) { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; }
|
||||
if (e.armor) {
|
||||
if (kind !== 'torpedo') { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; }
|
||||
e.armor = false;
|
||||
bus.emit('audio:cue', { name: 'enemy_hit' });
|
||||
return;
|
||||
}
|
||||
e.hp -= dmg;
|
||||
e.flash = 1;
|
||||
if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
|
||||
@ -561,7 +582,11 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
// one flash uniform per type: a hit reads as the whole cohort blinking, which is
|
||||
// wrong. Round 2: per-instance colour attribute. Noted in NOTES.
|
||||
const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
|
||||
pool.mat.uniforms.uFlash.value = hot ? 0.6 : 0;
|
||||
// LANE_C_NOTES telegraph: "nodes amber -> white-hot when vulnerable". The boss's open
|
||||
// window is already expressed as `invuln`, so the tell costs one uniform write and can
|
||||
// never disagree with the hitbox — the thing that glows IS the thing you can hurt.
|
||||
const openNode = type === 'mucosal_node' && pool.slots.some((e) => e.alive && !e.invuln);
|
||||
pool.mat.uniforms.uFlash.value = hot ? 0.6 : (openNode ? 0.45 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { createEnemies } from './enemies.js';
|
||||
import { createWeapons } from './weapons.js';
|
||||
import { createHazards } from './hazards.js';
|
||||
import { createPickups } from './pickups.js';
|
||||
import { createBoss } from './boss.js';
|
||||
import { getEnemy } from '../levels/enemies.js';
|
||||
|
||||
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
|
||||
@ -19,6 +20,8 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
||||
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
|
||||
const hazards = createHazards({ scene, world, bus, rng, player });
|
||||
const pickups = createPickups({ scene, world, bus, rng, player, weapons });
|
||||
// Bosses are a SCHEDULE over the enemy pool, not a parallel entity system — see boss.js.
|
||||
const boss = createBoss({ bus, rng, player, enemies, flags });
|
||||
|
||||
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
|
||||
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
|
||||
@ -113,6 +116,9 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
||||
|
||||
function update(dt) {
|
||||
if (phageBudCd > 0) phageBudCd -= dt;
|
||||
// Before enemies.update: the boss decides this frame's invulnerability, so the window a
|
||||
// player shoots into is the one they were just told about rather than one frame stale.
|
||||
boss.update(dt);
|
||||
enemies.update(dt, player);
|
||||
weapons.update(dt);
|
||||
hazards.update(dt);
|
||||
@ -127,13 +133,14 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
||||
|
||||
return {
|
||||
update,
|
||||
enemies, weapons, hazards, pickups, score,
|
||||
enemies, weapons, hazards, pickups, boss, score,
|
||||
// Respawn support (boot calls this from its death->checkpoint glue): re-arm hazard
|
||||
// timelines at/ahead of the respawn s. Enemies and uncollected pickups persist — the
|
||||
// pump never re-fires past events, so nothing double-spawns.
|
||||
reset(fromS = 0) { hazards.reset(fromS); },
|
||||
reset(fromS = 0) { hazards.reset(fromS); boss.reset(); },
|
||||
dispose() {
|
||||
offEvent(); offDie(); offPickup(); offTended(); offHarmed();
|
||||
boss.dispose();
|
||||
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user