// 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 }); } }); 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. 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', () => { setStanding(standing + 2); budPhage(); }); const offHarmed = bus.on('flora:harmed', () => setStanding(standing - 25)); function budPhage() { if (standing < 40 || phageBudCd > 0) return; 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(standing - 20); // spend standing to bud phageBudCd = 3; // seconds between buds (decremented below) bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born } } 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(); }, }; }