Four audio/comms cues wired to the wrong condition. All four are written content that has been
in the repo for rounds, firing at the wrong moment or never firing at all.
THE DEATH STING PLAYED AT LIVING PLAYERS. finish() mapped every non-win outcome to `death` — and
`death` is not a stinger, it is a ~12 s noise table (synth.js). So surviving L1's Mast Cell
("degranulated"), outrunning the Tapeworm ("escaped") and clearing the Sovereign's Toll all told
an alive player they had died, over the top of the next twelve seconds of play. Outcomes are
classified now: won/spared/communion triumph, alive-but-it-went-badly gets a downbeat, actually
dead gets the sting, and housekeeping (reset/dispose) is silent because nothing happened to the
player at all. Verified: surviving the Mast Cell no longer emits `death`; dying still does.
THE PHASE TELL WAS A SUSTAINING HISS. boss.js used `overheat` as its phase-change cue, but that
layer sustains until the engine sends `overheat_clear` on a combat:state.overheated falling edge
— which a boss never produces. Every phase change leaked a ~12 s steam hiss and ducked the mix
under it for the rest of the fight. Three sites, now short cues.
THE CREW HAS NEVER MENTIONED HULL DAMAGE. comms.js gates its two hull lines on
`kind === 'hull_hit'`, but `kind` is the damage SOURCE ('dart'|'acid'|'gas'…), so that branch has
never once been true in the game's life and every hit read as a coat graze. damage() already
computes `leak > 0` for its own audio cue — the bus event now carries it as `hull`.
AND IT NAMED THE WRONG ORGAN. There was exactly ONE boss line — "that is the pylorus. it does not
open for you." — fired for all five bosses, so the tutorial's Mast Cell and the campaign's final
boss were both announced as a stomach valve. Per-boss lines now, off boss:start (which is the only
place the id is known, and for the Sovereign whether there is a fight at all). The Mast Cell's is
doing real work: "wait — wait. that is a mast cell. it is not hunting you." / "do NOT shoot it."
The crew is the game's only tutorialisation channel, and that is the lesson L1 exists to teach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
// ui/comms.js (Lane E) — the surgical team on the other end of the feed.
|
||
//
|
||
// GDD calls the tone "Star Fox arcade flow" and "playful-gross medical sci-fi" but the game
|
||
// had nobody in it except you. This is the comms window: a voice that tutorialises L1, calls
|
||
// your position so the tube stops being anonymous black, and carries the humour the docs
|
||
// describe but never sited anywhere.
|
||
//
|
||
// No portrait art (D has a hero-mesh queue and doesn't need faces on it) — a signal panel
|
||
// with a line-work waveform reads more "instrument feed" than a face would anyway.
|
||
//
|
||
// Clock: its own timer, NOT player:state. player.js:103 early-returns before the emit while
|
||
// dead, so player:state stops exactly when the death line needs to show and dismiss.
|
||
// Round-3 note filed to B in NOTES.
|
||
|
||
const WHO = {
|
||
voss: { name: 'voss', role: 'attending', color: '#39e6ff' }, // terse; the calm one
|
||
park: { name: 'park', role: 'resident', color: '#7dffb0' }, // too excited, always
|
||
adeyemi: { name: 'adeyemi', role: 'immunology', color: '#ffb13a' }, // apologetic — and amber,
|
||
}; // the colour of the cells
|
||
// attacking you. His fault.
|
||
// Lines are grouped by trigger key. Delivery rotates through each group deterministically —
|
||
// no RNG at all: this repo hashes and seeds everything, and round-robin has the nicer
|
||
// property anyway that it cannot repeat a line back-to-back.
|
||
const LINES = {
|
||
start: [
|
||
['voss', 'endo-1, you are in. mind the walls — that is a patient.'],
|
||
],
|
||
checkpoint: [
|
||
['voss', (n) => `mark. ${n}.`],
|
||
['park', (n) => `${n}! i have only ever seen that in the atlas.`],
|
||
['voss', (n) => `${n}. on schedule.`],
|
||
],
|
||
warn_aortic_squeeze: [
|
||
['voss', 'aortic arch — the wall squeezes on the beat. thread it.'],
|
||
],
|
||
warn_reflux_surge: [
|
||
['voss', 'reflux. it is coming up behind you. go. GO.'],
|
||
],
|
||
// keys are the real HAZARDS ids (levels/enemies.js) — checked, not guessed. L2 authors
|
||
// exactly three. The lines teach what C's registry note says the hazard IS; this is the
|
||
// tutorialisation channel the GDD asks for and L1 has no other vehicle for.
|
||
warn_ring_gate: [
|
||
['voss', 'ring gate. it opens on a beat — match the beat, do not ram it.'],
|
||
['park', 'peristaltic ring! ease the throttle, arrive when it opens!'],
|
||
],
|
||
warn: [
|
||
['voss', (k) => `hazard ahead — ${String(k).replace(/_/g, ' ')}.`],
|
||
],
|
||
hull_hit: [
|
||
['park', 'that was hull! that was the actual ship!'],
|
||
['voss', 'you are bleeding structure. do not trade hits.'],
|
||
],
|
||
coat_hit: [
|
||
['adeyemi', 'coat is taking it. that is what it is for.'],
|
||
],
|
||
coat_low: [
|
||
['adeyemi', 'your coat is nearly gone. find mucin, please.'],
|
||
['voss', 'coat critical. next hit is hull.'],
|
||
],
|
||
sample: [
|
||
['park', 'specimen! oh, that is going straight in my thesis.'],
|
||
['park', 'got it! that is three years of somebody’s grant.'],
|
||
],
|
||
surf: [
|
||
['park', 'he is riding the peristalsis. he is RIDING it.'],
|
||
],
|
||
combo: [
|
||
['park', (n) => `${n} in a row! did everyone see that?`],
|
||
['voss', 'do not showboat in a live patient.'],
|
||
],
|
||
death: [
|
||
['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.'],
|
||
],
|
||
};
|
||
|
||
// seconds a line holds before it yields; higher pri interrupts a lower one mid-line
|
||
const HOLD = 3.0;
|
||
const PRI = { death: 9, boss: 8, warn: 7, complete: 6, coat_low: 5, hull_hit: 4, checkpoint: 3 };
|
||
// per-key silence so the crew doesn't chatter over a firefight
|
||
const COOLDOWN = { coat_hit: 12, hull_hit: 9, combo: 8, surf: 20, checkpoint: 0, sample: 0 };
|
||
|
||
// Keys are `family` or `family_variant` (`warn` / `warn_reflux_surge`). Look up the exact key
|
||
// first, then fall back to its family — otherwise every bespoke hazard line inherits the
|
||
// default priority of 1 and the surge warning, the single most urgent line in the level, can
|
||
// be talked over by a resident admiring a specimen. Exact keys still win: `hull_hit` is 4, not
|
||
// whatever `hull` would be.
|
||
const family = (key) => key.slice(0, key.indexOf('_') + 1 || key.length).replace(/_$/, '');
|
||
const priOf = (key) => PRI[key] ?? PRI[family(key)] ?? 1;
|
||
const cdOf = (key) => COOLDOWN[key] ?? COOLDOWN[family(key)] ?? 0;
|
||
|
||
const CSS = `
|
||
.comms { position:absolute; left:18px; bottom:70px; width:330px;
|
||
font:11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
letter-spacing:.1em; text-transform:uppercase;
|
||
border-left:1px solid currentColor; padding:6px 0 6px 9px;
|
||
opacity:0; transition:opacity .18s ease-out; text-shadow:0 0 6px rgba(0,0,0,.95); }
|
||
.comms.on { opacity:.95; }
|
||
.comms .who { display:flex; align-items:center; gap:7px; font-size:9px; letter-spacing:.22em; }
|
||
.comms .role { opacity:.45; }
|
||
.comms .txt { color:#dff2ff; margin-top:3px; letter-spacing:.08em; opacity:.9;
|
||
text-transform:none; font-size:12px; }
|
||
/* the "carrier" — line-work waveform, CSS-animated so a talking head costs zero JS/frame */
|
||
.comms .wave { display:flex; align-items:flex-end; gap:2px; height:9px; }
|
||
.comms .wave i { width:2px; height:100%; background:currentColor; transform-origin:bottom;
|
||
animation:commsWave .52s ease-in-out infinite; }
|
||
.comms .wave i:nth-child(2){ animation-delay:.09s } .comms .wave i:nth-child(3){ animation-delay:.18s }
|
||
.comms .wave i:nth-child(4){ animation-delay:.27s } .comms .wave i:nth-child(5){ animation-delay:.36s }
|
||
@keyframes commsWave { 0%,100%{ transform:scaleY(.25) } 50%{ transform:scaleY(1) } }
|
||
@media (prefers-reduced-motion:reduce){ .comms .wave i{ animation:none; transform:scaleY(.6) } }
|
||
`;
|
||
|
||
export function createComms({ bus, flags = {}, mount = null } = {}) {
|
||
const host = mount ?? document.getElementById('ui');
|
||
if (!host || flags.shots) return { dispose() {} }; // ?shots=1 = clean plate, same as the HUD
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = CSS;
|
||
document.head.appendChild(style);
|
||
|
||
const root = document.createElement('div');
|
||
root.className = 'comms';
|
||
root.innerHTML = `<div class="who"><span class="wave"><i></i><i></i><i></i><i></i><i></i></span>` +
|
||
`<span class="name"></span><span class="role"></span></div><div class="txt"></div>`;
|
||
host.appendChild(root);
|
||
const nameEl = root.querySelector('.name');
|
||
const roleEl = root.querySelector('.role');
|
||
const txtEl = root.querySelector('.txt');
|
||
|
||
const rot = new Map(); // key -> next index (deterministic rotation)
|
||
const last = new Map(); // key -> clock reading at last delivery
|
||
|
||
// Wall-clock, read — never accumulated. Counting `+= 0.1` per timer tick assumes the timer
|
||
// is punctual; a backgrounded tab throttles setTimeout to ~1s and the count silently becomes
|
||
// fiction (lines then hold ~10x too long). It must be wall time and not sim time because the
|
||
// one moment this box MUST work — death — is the moment player:state stops emitting.
|
||
const now = () => performance.now() / 1000;
|
||
const t0 = now();
|
||
let clock = 0, current = null, timer = null, disposed = false;
|
||
|
||
const tick = () => {
|
||
if (disposed) return;
|
||
clock = now() - t0;
|
||
if (current && clock >= current.until) hide();
|
||
timer = setTimeout(tick, 100);
|
||
};
|
||
timer = setTimeout(tick, 100);
|
||
|
||
function hide() {
|
||
current = null;
|
||
root.classList.remove('on');
|
||
}
|
||
|
||
function say(key, arg) {
|
||
const group = LINES[key];
|
||
if (!group?.length) return;
|
||
const cd = cdOf(key);
|
||
if (cd && clock - (last.get(key) ?? -1e9) < cd) return;
|
||
const pri = priOf(key);
|
||
if (current && pri < current.pri) return; // never talk over something worse
|
||
|
||
const i = rot.get(key) ?? 0;
|
||
rot.set(key, (i + 1) % group.length);
|
||
const [whoId, line] = group[i];
|
||
const who = WHO[whoId];
|
||
|
||
nameEl.textContent = who.name;
|
||
roleEl.textContent = who.role;
|
||
root.style.color = who.color;
|
||
txtEl.textContent = typeof line === 'function' ? line(arg) : line;
|
||
root.classList.add('on');
|
||
|
||
last.set(key, clock);
|
||
current = { pri, until: clock + HOLD };
|
||
bus.emit('audio:cue', { name: 'comms_open' }); // E's own audio engine picks this up
|
||
}
|
||
|
||
const offs = [];
|
||
offs.push(bus.on('level:event', (ev) => {
|
||
if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name);
|
||
}));
|
||
// 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?.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'); }));
|
||
offs.push(bus.on('combo', (e) => { if ((e?.n ?? 0) >= 5) say('combo', e.n); }));
|
||
offs.push(bus.on('player:surf', (e) => { if (e?.active) say('surf'); }));
|
||
|
||
// coat_low is the one continuous signal worth a line — edge-triggered here so it fires on
|
||
// the crossing, not every frame we're under the threshold.
|
||
let wasLow = false;
|
||
offs.push(bus.on('player:state', (p) => {
|
||
const low = p.coatMax ? p.coat / p.coatMax < 0.25 : false;
|
||
if (low && !wasLow) say('coat_low');
|
||
wasLow = low;
|
||
}));
|
||
|
||
say('start');
|
||
|
||
return {
|
||
say, // exposed for the ?fakebus harness + round-3 scripting
|
||
dispose() {
|
||
disposed = true;
|
||
clearTimeout(timer);
|
||
for (const off of offs) off();
|
||
root.remove();
|
||
style.remove();
|
||
},
|
||
};
|
||
}
|