guts/web/js/combat/index.js
type-two 7709484ea7 [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>
2026-07-25 22:50:56 +10:00

148 lines
7.7 KiB
JavaScript

// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards
// + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour.
//
// Update order inside a frame is load-bearing:
// boot: player.update(dt) -> combat.update(dt)
// here: enemies.update(dt) -> weapons.update(dt) -> hazards.update(dt) -> pickups.update(dt)
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
// Hazards after weapons so a torpedo fired THIS frame can neutralize a surge THIS frame
// (weapons emits level:neutralize on detonation; hazards listens).
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 }) {
const enemies = createEnemies({ scene, world, bus, rng, assets });
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));
const score = { value: 0, kills: 0, samples: 0 };
// Fan a group of `count` out along s. C's `spread` is the authored span of the group
// ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps
// a count from stacking at a single arclength.
const fan = (ev, i) => {
const n = ev.count ?? 1;
if (n <= 1) return ev.s ?? 0;
const span = ev.spread ?? (n - 1) * 3.5;
return (ev.s ?? 0) + (i / (n - 1)) * span;
};
// C authors `theta` as a scalar (whole group) or an array (per-member — "two blooms
// covering opposite arcs"). Placement is composition, so C's word is law here.
const thetaFor = (ev, i) =>
(Array.isArray(ev.theta) ? ev.theta[i % ev.theta.length] : (ev.theta ?? null));
// Lane C's events arrive on the bus as the player crosses their `s`. Spawns and pickups are
// consumed here; hazards run their own timeline off world.level.events (they need lookahead
// for the warn telegraph — see hazards.js header); checkpoints/gates are boot's (the pump
// owns respawn/complete semantics); unknown types are ignored on purpose so C can add event
// kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev) return;
if (ev.type === 'spawn') {
// Levels name enemies in fiction ("bolus_chunk"); pools are keyed by archetype
// ("floater"). levels/enemies.js is the join table (C's catalogue, round-2 task #1).
const spec = getEnemy(ev.enemy);
if (!spec) { console.warn(`[combat] spawn "${ev.enemy}" is not in levels/enemies.js — skipped`); return; }
for (let i = 0; i < (ev.count ?? 1); i++)
enemies.spawn(spec.archetype, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'pickup') {
for (let i = 0; i < (ev.count ?? 1); i++)
pickups.spawn(ev.kind, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'biofilm') {
biofilmGate(ev);
}
});
const offDie = bus.on('enemy:die', (e) => {
score.value += e.score ?? 0;
score.kills++;
});
// pickups.js applies the effect (refill/ammo) and announces; the score lives here so
// combat:state stays the one scoreboard E reads.
const offPickup = bus.on('pickup', (e) => {
score.value += e.score ?? 0;
score.samples += e.sample ?? 0;
});
// BIOME STANDING — the flora reputation loop (V2_IDEAS: "don't shoot the biome, it helps you
// in level 5"). Tend flora (fly through its aura -> flora:tended) to earn it; shoot flora
// (flora:harmed) to lose a chunk. At the symbiotic threshold the biome BUDS phage allies near
// you that hunt your foes — the payoff. A `standing` event feeds a future HUD meter (Lane E).
// ponytail: one run-scoped float, not a system. Persistence/tiers/L5-gate come when C needs them.
// Scaled 0..100 so E can meter it honestly: tending fills the bar, a full bar buds an ally
// and drops it to 50 (the recharge floor), harming flora empties a big chunk. Numbers are a
// first pass — C/playtest tunes the feel.
let standing = 0, phageBudCd = 0;
const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); };
const offTended = bus.on('flora:tended', (e) => { setStanding(standing + (e?.gain ?? 5)); budPhage(); });
const offHarmed = bus.on('flora:harmed', () => setStanding(standing - 35));
function budPhage() {
if (standing < 100 || phageBudCd > 0) return; // a FULL bar buds an ally
if (enemies.live.filter((e) => e.type === 'phage' && e.alive).length >= 3) return; // ally cap
if (enemies.spawn('phage', { s: player.state.s + 8 })) {
setStanding(50); // recharge floor — the next ally is halfway earned
phageBudCd = 3; // seconds between buds (decremented below)
bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born
}
}
// biofilm gate — the reputation CASH-OUT (V2_IDEAS: "it helps you in level 5"). Cross one with
// high BIOME STANDING and the native flora part the biofilm: a wave of phage allies buds and
// your coat is refilled, then the goodwill is spent. Low standing => it stays sealed and you run
// the gauntlet raw. Placed as a `biofilm` level event (`need`/`allies`/`coat` optional). Emits
// `biofilm {opened}` so E can voice it. The L5 payoff, built as a mechanism usable anywhere.
function biofilmGate(ev) {
const opened = standing >= (ev.need ?? 60);
if (opened) {
for (let i = 0; i < (ev.allies ?? 3); i++) enemies.spawn('phage', { s: player.state.s + 6 + i * 4 });
player.refill({ coat: ev.coat ?? 40 });
setStanding(20); // spent the goodwill
}
bus.emit('biofilm', { opened, s: ev.s });
bus.emit('audio:cue', { name: opened ? 'gate_pass' : 'gate_hit' });
}
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);
pickups.update(dt);
// combat's slice of the HUD state (E reads player:state for the flight half)
bus.emit('combat:state', {
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
score: score.value, kills: score.kills, samples: score.samples, enemies: enemies.count,
});
}
return {
update,
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); boss.reset(); },
dispose() {
offEvent(); offDie(); offPickup(); offTended(); offHarmed();
boss.dispose();
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
},
};
}