guts/web/js/combat/index.js
type-two 3c059f20db [lane B+C+F] The biofilm gate: the reputation cash-out, "it helps you in level 5" made real
The deeper payoff of the flora loop. A new `biofilm` level event: cross one and combat checks
BIOME STANDING — high enough (>= need), the native flora PART the biofilm, budding a wave of phage
allies + refilling coat, then spending the goodwill; too low, it stays sealed and you run the
gauntlet raw. Emits `biofilm {opened}` so E can voice both.

- combat/index.js: biofilmGate(ev) on the level:event pump (fields need/allies/coat).
- levels/index.js: the selfcheck knows the `biofilm` type now (validates need 0..100).
- L2: one demoed at s2500 — the opening flora/akkermansia/beacon let you BANK standing, and the
  gate cashes it into a phage escort right before the hiatus + reflux finale.

The mechanism is complete; L5 also gets the fast-route/Appendix branch when that level + world
support land. Verified in-engine: high standing -> opened, 3 phages, coat 50->90; low -> sealed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:16:12 +10:00

141 lines
7.3 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 { 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 });
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;
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, 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); },
dispose() {
offEvent(); offDie(); offPickup(); offTended(); offHarmed();
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
},
};
}