meaning "miss nothing" Audit item 13 — the systems the levels were authored against, none of which worked. THE COMBO MULTIPLIER DID NOT MULTIPLY. balance.js's own comment cites the GDD line "score x style multiplier"; what shipped was a 2.5 s window and a counter. The HUD printed "x7 chain", comms shouted about it at 5, the music added a layer at 3 — and combat/index.js added the flat base score every time. Every par.score in every level was authored against a lever that did not exist, which is exactly why they all sat at 75-94% of the un-multiplied ceiling. Now step 0.12, cap 2.5 at a 14-chain: reachable in L4's ileum and nowhere else, because a cap you hit every fight is not a skill expression. Verified in the running game: six chained floaters pay 100/112/124/136/148/160 where they used to pay 600 flat. ALLIES COUNTED AS KILLS. combat/index.js did an unconditional score.kills++ on enemy:die, and the ally branch of enemies.js emits enemy:die with score 0 — so shooting a lactobacillus reef, the one act this game's whole reputation loop exists to punish, ticked your PATHOGENS tally up by four. The pause card and the operative record told you that you were doing well at the exact moment the biome decided otherwise. enemy:die carries `foe` now. Verified: seven deaths, six kills. THE PAR CHECK WAS COUNTING A THIRD OF THE GAME. scoreBudget() summed spawn events and nothing else — not the 500 points a biopsy sample is worth, not the several thousand a boss puts in the room. Four of six levels therefore shipped a permanent "par.score exceeds the kill budget" warning, and a warning that is wrong two thirds of the time is one everybody learns to scroll past. It counts pickups and boss rings now (nodes once per phase — a floor, since a phase re-rings for as long as the fight lasts and no honest ceiling exists). Six levels, zero warnings, and the ratios that were 94/75/52/59/44/32% of a fictional budget are now real. AND THE CHECK THAT SHOULD HAVE CAUGHT L1. The only par test asked whether par exceeded the ceiling. L1 sat at 94% of it: par the tutorial and you had to kill all eight foes, take all seven orbs and all three samples — miss ONE sample and the medal was gone. That is a clear-sheet requirement wearing par's clothes, in the level that exists to teach. par.score is 1900 now (71%), and the validator warns above 85% so the next one gets caught by a machine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
9.1 KiB
JavaScript
167 lines
9.1 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, flags });
|
|
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;
|
|
// Allies do not count. This was an unconditional ++, so flora and spent phages — which
|
|
// score 0 precisely because they are not pathogens — still raised the PATHOGENS tally on
|
|
// the pause card and the operative record.
|
|
if (e.foe !== false) 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();
|
|
},
|
|
};
|
|
}
|