diff --git a/.claude/launch.json b/.claude/launch.json index 7957fe6..e97ad95 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } \ No newline at end of file diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js index 9b03d53..63e0b73 100644 --- a/web/js/combat/balance.js +++ b/web/js/combat/balance.js @@ -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. diff --git a/web/js/combat/boss.js b/web/js/combat/boss.js new file mode 100644 index 0000000..33a8bcd --- /dev/null +++ b/web/js/combat/boss.js @@ -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'); }, + }; +} diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index 6ac2224..7cf4d74 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -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); } } diff --git a/web/js/combat/index.js b/web/js/combat/index.js index d26699c..6a8b1ae 100644 --- a/web/js/combat/index.js +++ b/web/js/combat/index.js @@ -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(); }, };