diff --git a/web/js/combat/boss.js b/web/js/combat/boss.js index 8891409..a58f078 100644 --- a/web/js/combat/boss.js +++ b/web/js/combat/boss.js @@ -202,7 +202,10 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n f.doomT = f.phase.doom ?? 0; for (const v of f.vents) if (v.alive) v.cfg = { ...v.cfg, fireEvery: f.phase.ventEvery }; bus.emit('boss:phase', { id: f.id, phase: f.phase.id, nodes: f.nodes.length }); - bus.emit('audio:cue', { name: 'overheat' }); // the ring changes gear + // NOT 'overheat': that cue SUSTAINS until the engine sends overheat_clear on a + // combat:state.overheated falling edge, which a boss never produces — so every phase + // change leaked a ~12 s steam hiss and ducked the mix under it for the whole fight. + bus.emit('audio:cue', { name: 'gate_hit' }); // the ring changes gear } function start(ev) { @@ -272,8 +275,19 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n // `spared` and `communion` are WINS that involve no killing, and the instrument has to agree // with the design or it teaches the opposite lesson: a triumphant cue for walking away is // most of how L1 tells the player that walking away was the answer. - const good = outcome === 'win' || outcome === 'spared' || outcome === 'communion'; - bus.emit('audio:cue', { name: good ? 'gate_pass' : 'death' }); + // The `death` cue is a ~12 s noise-table sting, not a stinger — playing it at a LIVING player + // is the one lie an audio layer must never tell, and it was firing for every non-win. So: + // won / spared / communion -> a triumph, + // still alive but it went badly (degranulated, escaped, survived the cascade) -> a downbeat + // "that did not go well", NOT a death, + // actually dead (sealed by the Guardian, anaphylaxis, wiped) -> the death sting, correctly, + // housekeeping (reset/dispose) -> silence, because nothing happened to the player at all. + const WON = new Set(['win', 'spared', 'communion']); + const DIED = new Set(['sealed', 'anaphylaxis', 'wipe']); + const SILENT = new Set(['reset', 'dispose', 'lost']); + if (!SILENT.has(outcome)) { + bus.emit('audio:cue', { name: WON.has(outcome) ? 'gate_pass' : DIED.has(outcome) ? 'death' : 'surge_end' }); + } } // The sentinel: L1's Mast Cell. Its entire state is "has the player hurt it yet", and the @@ -289,7 +303,7 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n if (frac < 0.999 && f.tier === 0) { f.tier = 1; // DEGRANULATION — the first hit does this bus.emit('boss:phase', { id: f.id, phase: 2, reason: 'degranulation' }); - bus.emit('audio:cue', { name: 'overheat' }); + bus.emit('audio:cue', { name: 'enemy_hit' }); } if (f.tier === 1 && frac <= f.def.ana.at) { f.tier = 2; // ANAPHYLAXIS — and now a clock @@ -431,7 +445,7 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n const bare = f.nodes.find((e) => e.alive && !e.armor); if (bare) bare.armor = true; bus.emit('boss:rearm', { id: f.id, reason: 'dysbiosis' }); - bus.emit('audio:cue', { name: 'overheat' }); + bus.emit('audio:cue', { name: 'enemy_hit' }); })); // A death mid-fight resets the encounter rather than leaving a half-killed ring in the arena: // C put a checkpoint at 2260 precisely so a wipe costs seconds, and that promise only holds if diff --git a/web/js/flight/player.js b/web/js/flight/player.js index a172568..b3dbdb3 100644 --- a/web/js/flight/player.js +++ b/web/js/flight/player.js @@ -60,7 +60,11 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null st.coat -= toCoat; const leak = amount - toCoat; if (leak > 0) st.hull = Math.max(0, st.hull - leak); - bus.emit('player:damage', { amount, kind }); + // `hull` is the fact the audio cue below has always had and the bus event never carried: + // comms.js has two hull lines gated on `kind === 'hull_hit'`, but `kind` is the damage + // SOURCE ('dart'|'acid'|'gas'…), so that branch has never once been true and the crew has + // never mentioned hull damage in the game's life. + bus.emit('player:damage', { amount, kind, hull: leak > 0 }); bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' }); if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); } return true; diff --git a/web/js/ui/comms.js b/web/js/ui/comms.js index 2586884..f1199e9 100644 --- a/web/js/ui/comms.js +++ b/web/js/ui/comms.js @@ -72,8 +72,30 @@ const LINES = { ['voss', 'we lost the feed. re-acquiring.'], ['adeyemi', 'that was the immune system. i am sorry. they think you are a parasite.'], ], + // One line per boss. There used to be exactly one — "that is the pylorus" — fired for all five, + // so the tutorial's mast cell and the campaign's final boss were both announced as a stomach + // valve. The crew is the game's only tutorialisation channel; naming the wrong organ in it is + // worse than saying nothing. boss: [ + ['voss', 'contact. hold position and read it.'], + ], + boss_mast_cell: [ + ['adeyemi', 'wait — wait. that is a mast cell. it is not hunting you.'], + ['park', 'do NOT shoot it. whatever you do.'], + ], + boss_pyloric_guardian: [ ['voss', 'that is the pylorus. it does not open for you.'], + ['park', 'the ring only relaxes to pass chyme. hit the nodes when it does.'], + ], + boss_tapeworm: [ + ['park', 'taenia. metres of it, and it is running — do not lose it.'], + ], + boss_the_blockage: [ + ['voss', 'there it is. the whole lumen, impacted.'], + ['adeyemi', 'the gas pockets are load-bearing. use them, from range.'], + ], + boss_appendix_sovereign: [ + ['adeyemi', 'the reservoir. everything the gut kept safe is in there.'], ], complete: [ ['voss', 'clear. next segment.'], @@ -184,13 +206,18 @@ export function createComms({ bus, flags = {}, mount = null } = {}) { const offs = []; offs.push(bus.on('level:event', (ev) => { if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name); - else if (ev.type === 'boss') say('boss'); + })); + // boss:start rather than the raw level event, because only the boss knows its own id — and for + // the Sovereign, whether there is a fight at all (a symbiotic player gets the Communion). + offs.push(bus.on('boss:start', (e) => { + const k = `boss_${e?.id}`; + say(LINES[k] ? k : 'boss'); })); offs.push(bus.on('hazard:warn', (e) => { const k = `warn_${e?.kind}`; say(LINES[k] ? k : 'warn', e?.kind); })); - offs.push(bus.on('player:damage', (e) => say(e?.kind === 'hull_hit' ? 'hull_hit' : 'coat_hit'))); + offs.push(bus.on('player:damage', (e) => say(e?.hull ? 'hull_hit' : 'coat_hit'))); offs.push(bus.on('player:death', () => say('death'))); offs.push(bus.on('level:complete', () => say('complete'))); offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); }));