Two boss specs written to C's format (LANE_C_NOTES §Boss specs, round 3 additions) and the
first of them built. Both extend the roster along C's own law — one boss, one pressure — onto
the two axes the game had systems for and no boss pointed at: reputation (§4 Sovereign) and
restraint (§5 Mast Cell, specced only).
THE SOVEREIGN. The appendix is not vestigial: it is a biofilm reservoir where the gut's flora
shelter and re-seed the colon after a purge, and the whole fight is that fact. It is the only
boss whose existence is decided before you arrive — read once on entry:
karma >= 70 THE COMMUNION — no fight at all. Full coat, a permanent phage escort. The
reward for five levels of restraint is being spared a boss.
karma >= 30 THE TOLL — P1 only.
below THE SOVEREIGN — three phases. P1's matrix is immune to the cannon and only the
antacid torpedo disperses it (a mature biofilm's whole defining trait); P2 is a
quorum bloom that re-armours when the room gets crowded; P3 is a 20s reseed run.
And the mechanic that makes it this boss: the lair is seeded with YOUR OWN FLORA, and it feeds
on dysbiosis — every commensal your stray fire kills re-armours a plate, live, mid-fight.
KARMA vs STANDING — a real bug found in testing, and worth the extra number. `standing` is a
SPENDABLE meter: a full bar is consumed to bud an ally, dropping to a 50 floor. That meant a
perfect player could never HOLD >= 70, so the Communion was almost unreachable — verified: a
symbiotic pilot showed standing 50 and was demoted to "toll". `karma` is the same inputs with
nothing spending it. The Sovereign's tier and gates' `secretTo` branches read karma; E's bar
still reads standing.
Also: boss.js generalised to a registry, which was the claim its header made — the Sovereign
needed zero changes to enemies.js. LS_appendix (3 segments, 800 units, 16 events), reached from
L4's ileocecal valve via `secretTo`/`secretNeed` — anatomically exact, the appendix hangs off
the cecum right past that valve. The secret is hidden behind conduct, not behind a wall.
C's selfcheck caught par.time as a free medal and the fix improved the design: par 160 sits
under the timid traversal, so only a Communion run (which has no fight) can beat it. The time
medal rewards the same conduct the level is about.
Verified: all three tiers branch correctly (communion = 0 nodes + 4 phages; toll = 1 phase;
full = 3 phases, 4 armoured); killing a commensal mid-fight re-armours a plate (reason
"dysbiosis"); the valve routes karma 100 -> LS_appendix and karma 0 -> L5. Levels 5/5 valid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
8.9 KiB
JavaScript
164 lines
8.9 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, getKarma: () => karma });
|
|
|
|
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.
|
|
// TWO numbers, because they do two different jobs and conflating them is a bug I shipped once:
|
|
// standing — a SPENDABLE meter. Fills as you tend, and a full bar is CONSUMED to bud an ally
|
|
// (dropping to a 50 floor). It is a resource.
|
|
// karma — a LEDGER of conduct. Same inputs, but nothing ever spends it. This is what the
|
|
// Appendix Sovereign and the secret door read, because "how did you treat the
|
|
// biome across the campaign" must not be answerable by "how recently did you cash
|
|
// in". With one number, budding an ally at 100 dropped you to 50 and quietly made
|
|
// the Sovereign's best outcome unreachable.
|
|
let standing = 0, karma = 0, phageBudCd = 0;
|
|
const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); };
|
|
const setKarma = (v) => { karma = Math.max(0, Math.min(100, v)); bus.emit('karma', { value: karma }); };
|
|
const offTended = bus.on('flora:tended', (e) => {
|
|
const g = e?.gain ?? 5;
|
|
setStanding(standing + g); setKarma(karma + g); budPhage();
|
|
});
|
|
const offHarmed = bus.on('flora:harmed', () => { setStanding(standing - 35); setKarma(karma - 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,
|
|
/** BIOME STANDING 0..100 — the spendable meter (E's HUD bar). */
|
|
get standing() { return standing; },
|
|
/** KARMA 0..100 — the unspendable conduct ledger. boot reads it for `secretTo` branches. */
|
|
get karma() { return karma; },
|
|
// 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();
|
|
},
|
|
};
|
|
}
|