guts/web/js/ui/hud.js
type-two caa5f95339 [lane E] The boss banner: five fights stop being guesswork
boss.js has emitted boss:start / boss:phase / boss:window / boss:telegraph / boss:end since the
framework landed, and NOTHING has ever listened. Every boss in the game was therefore illegible:
the Pyloric Guardian's whole design is "shoot only while the iris relaxes", and the only tell a
player had was a node's flash uniform going white-hot — a cue nobody is told to read.

A top-centre banner, fed entirely by events the boss already broadcasts:
  - the boss's NAME (five of them; there was no way to know what you were fighting)
  - phase pips, filling as you break each phase
  - the state: SEALED / opening (blinking, off boss:telegraph) / VULNERABLE in green
The Sovereign's Communion reports `phases: 0`, so a symbiotic player gets "stand down" and no
pip rail rather than the promise of a battle they have just earned their way out of.

Verified in the Guardian's lair: banner on, "PYLORIC GUARDIAN / VULNERABLE", 3 pips, state
tracking boss.open exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 19:51:04 +10:00

287 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ui/hud.js (Lane E) — the instrument overlay. ART_BIBLE §Screens: thin cyan line-work,
// small caps, data-noise ticks. An instrument reading a live feed, not a game HUD.
//
// Bus-driven only (LANE_E charter): player:state + combat:state arrive every frame and ARE
// the clock — no rAF of our own, so the HUD cannot tick while the game is paused or absent.
// Level data comes from C's registry (sanctioned: ROUND2 §Lane E #2), never from B.
//
// 60fps law: build the DOM once, then only transform + textContent-on-change. Bars scale,
// they never resize — a width:% write per bar per frame is a layout pass we can't afford.
const C = {
line: '#39e6ff', // cyan line-work — ART_BIBLE emissive code, neutral/interactive
good: '#7dffb0',
warn: '#ffb13a',
bad: '#ff5a2a',
surf: '#b06aff',
ally: '#33ffbe', // teal — matches EMISSIVE.ally; the flora/phage reputation
allyHot: '#c9fff0', // near-full: an ally is imminent
};
const RAIL_W = 190; // px; shared by the CSS and the blip transform below
const CSS = `
.hud { position:absolute; inset:0; color:${C.line};
font:11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing:.14em; text-transform:uppercase;
text-shadow:0 0 6px rgba(0,0,0,.95); opacity:.92; }
.hud .q { position:absolute; display:flex; flex-direction:column; gap:5px; }
.hud .tl { top:16px; left:18px; }
.hud .tr { top:16px; right:18px; align-items:flex-end; }
.hud .bl { bottom:18px; left:18px; }
.hud .br { bottom:18px; right:18px; align-items:flex-end; }
.hud .bc { bottom:18px; left:50%; transform:translateX(-50%); align-items:center; }
.hud .tc { top:14px; left:50%; transform:translateX(-50%); align-items:center; gap:6px;
opacity:0; transition:opacity .35s ease; pointer-events:none; }
.hud .tc.on { opacity:1; }
.hud .boss-name { font-size:13px; letter-spacing:.30em; color:${C.bad}; }
.hud .boss-state { font-size:9.5px; letter-spacing:.26em; opacity:.85; }
.hud .boss-state.open { color:${C.good}; }
.hud .boss-state.shut { color:${C.warn}; opacity:.55; }
.hud .boss-state.tele { color:${C.warn}; animation:bosstele .5s steps(2,end) infinite; }
@keyframes bosstele { 50% { opacity:.25 } }
.hud .phase { display:flex; gap:5px; }
.hud .phase i { width:16px; height:3px; background:currentColor; opacity:.22; }
.hud .phase i.on { opacity:1; }
.hud .dim { opacity:.5; }
.hud .row { display:flex; align-items:center; gap:8px; white-space:nowrap; }
.hud .lbl { font-size:9px; opacity:.65; letter-spacing:.2em; }
/* bars: a hairline rail + a fill that only ever scales */
.hud .rail { position:relative; width:132px; height:5px;
border:1px solid currentColor; opacity:.85; }
.hud .fill { position:absolute; inset:1px; background:currentColor;
transform-origin:left center; transform:scaleX(1); }
.hud .rail.thin { height:3px; width:96px; }
/* progress rail — the gut-map's honest placeholder until the silhouette lands */
.hud .prog { position:relative; width:${RAIL_W}px; height:2px; background:currentColor;
opacity:.35; margin-top:3px; }
.hud .blip { position:absolute; top:-3px; left:0; width:2px; height:8px;
background:${C.line}; box-shadow:0 0 5px ${C.line}; transform:translateX(0); }
.hud .tick { position:absolute; top:-2px; width:1px; height:6px; background:${C.surf}; opacity:.6; }
.hud .big { font-size:15px; letter-spacing:.16em; }
.hud .chain { color:${C.warn}; font-size:12px; }
.hud .noise { font-size:9px; opacity:.28; letter-spacing:.3em; }
.hud .pips { display:flex; gap:3px; }
.hud .pip { width:6px; height:6px; border:1px solid currentColor; }
.hud .pip.on { background:currentColor; }
`;
const el = (tag, cls, parent) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (parent) parent.appendChild(n);
return n;
};
// A label + rail whose fill scales. set() is the hot path: one transform write, and a color
// write only when the danger band actually changes.
function makeBar(parent, label, thin = false) {
const row = el('div', 'row', parent);
const lbl = el('span', 'lbl', row);
lbl.textContent = label;
const rail = el('div', `rail${thin ? ' thin' : ''}`, row);
const fill = el('div', 'fill', rail);
let lastV = -1, lastC = '';
return {
set(v, color) {
v = v > 0 ? (v < 1 ? v : 1) : 0;
if (v !== lastV) { fill.style.transform = `scaleX(${v})`; lastV = v; }
if (color !== lastC) { rail.style.color = color; lastC = color; }
},
};
}
function makeText(parent, cls) {
const n = el('span', cls, parent);
let last = null;
return { set(s) { if (s !== last) { n.textContent = s; last = s; } }, node: n };
}
export function createHUD({ bus, flags = {}, level = null, mount = null } = {}) {
const host = mount ?? document.getElementById('ui');
if (!host) return { dispose() {} };
const style = el('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = el('div', 'hud', host);
// ?shots=1 = clean plate for other lanes' world evidence (same contract boot's #dbg keeps).
// E's own HUD shots are taken without the flag.
if (flags.shots) root.style.display = 'none';
const tl = el('div', 'q tl', root);
const tr = el('div', 'q tr', root);
const bl = el('div', 'q bl', root);
const br = el('div', 'q br', root);
const bc = el('div', 'q bc', root);
// --- top-centre: the boss banner ------------------------------------------------------
// boss.js has emitted boss:start/phase/window/telegraph/end since the framework landed and
// NOTHING has ever listened. That made every fight illegible: the Pyloric Guardian's entire
// design is "shoot only while the iris relaxes", and the only tell was a node's flash uniform.
// This is the same information the boss already broadcasts, made visible.
const tc = el('div', 'q tc', root);
const bossName = makeText(tc, 'boss-name');
const phaseWrap = el('div', 'phase', tc);
const bossState = makeText(tc, 'boss-state');
let phasePips = [];
// --- top-left: where we are -----------------------------------------------------------
const where = makeText(tl, 'big');
where.set(level?.name ?? '—');
const depth = makeText(tl, 'dim');
const progWrap = el('div', null, tl);
const prog = el('div', 'prog', progWrap);
const blip = el('div', 'blip', prog);
// Checkpoint ticks, drawn once from C's authored events. Deferred to the first player:state
// because the canal's length is a RUNTIME number (world.length, which B already divides into
// p.progress) — the level object carries segments, not a total, and asking it for one is how
// the rail silently rendered empty the first time.
const checkpoints = (level?.events ?? []).filter((ev) => ev.type === 'checkpoint');
let ticksPlaced = false;
// --- top-right: the scoreboard --------------------------------------------------------
const score = makeText(tr, 'big');
score.set('000000');
const chain = makeText(tr, 'chain');
const noise = makeText(tr, 'noise');
// --- bottom-left: what's keeping you alive --------------------------------------------
const coat = makeBar(bl, 'coat');
const hull = makeBar(bl, 'hull');
// biome standing — the flora reputation. Fills as you tend flora; a full bar buds a phage
// ally (it drops to the recharge floor). Starts hidden and reveals on the first `standing`
// event, so a level with no flora never shows an empty bar the player can't act on.
const biomeRow = el('div', 'row', bl);
biomeRow.style.display = 'none';
const biomeLbl = el('span', 'lbl', biomeRow);
biomeLbl.textContent = 'biome';
const biomeRail = el('div', 'rail thin', biomeRow);
const biomeFill = el('div', 'fill', biomeRail);
// --- bottom-right: what you're shooting with ------------------------------------------
const heat = makeBar(br, 'heat', true);
const ammoRow = el('div', 'row', br);
const ammoLbl = el('span', 'lbl', ammoRow);
ammoLbl.textContent = 'torpedo';
const pips = el('div', 'pips', ammoRow);
let pipEls = [];
// --- bottom-centre: the flight strip --------------------------------------------------
const strip = el('div', 'row', bc);
const spd = makeText(strip, 'big');
const flowT = makeText(strip, 'dim');
const boostT = makeText(strip, null);
// --- bus ------------------------------------------------------------------------------
const offs = [];
let frame = 0;
offs.push(bus.on('player:state', (p) => {
const coatF = p.coatMax ? p.coat / p.coatMax : 0;
const hullF = p.hullMax ? p.hull / p.hullMax : 0;
coat.set(coatF, coatF < 0.25 ? C.warn : C.line);
hull.set(hullF, hullF < 0.34 ? C.bad : hullF < 0.67 ? C.warn : C.good);
depth.set(`s ${(p.s | 0).toString().padStart(4, '0')} / ${p.length | 0} · ${p.biome ?? ''}`);
blip.style.transform = `translateX(${(p.progress > 1 ? 1 : p.progress) * RAIL_W}px)`;
if (!ticksPlaced && p.length > 0) {
for (const ev of checkpoints) {
el('div', 'tick', prog).style.left = `${(ev.s / p.length) * 100}%`;
}
ticksPlaced = true;
}
spd.set(`${p.speed.toFixed(1)}`);
spd.node.style.color = p.surfing ? C.surf : C.line;
// In a room there is no current and the throttle is thrust, so printing "flow 0 · thr 100%"
// would be an instrument telling two lies at once. Say what the controller actually is.
flowT.set(p.mode === 'arena'
? 'u/s · 6dof · free'
: `u/s · flow ${p.flow.toFixed(0)} · thr ${(p.throttle * 100) | 0}%`);
if (p.boostReady) { boostT.set('· boost rdy'); boostT.node.style.color = C.good; }
else {
boostT.set(`· boost ${(p.boostCd ?? 0).toFixed(1)}`);
boostT.node.style.color = C.line;
boostT.node.style.opacity = '.45';
}
// data-noise ticks — the feed is alive even when nothing is happening. 4 Hz-ish, and
// derived from s so it reads as telemetry rather than a random number generator.
if ((frame++ & 15) === 0) {
noise.set(`0x${(((p.s * 4099) ^ 0x5f3a) & 0xffff).toString(16).padStart(4, '0')}`);
}
}));
offs.push(bus.on('combat:state', (c) => {
const heatF = c.heatMax ? c.heat / c.heatMax : 0;
heat.set(heatF, c.overheated ? C.bad : heatF > 0.7 ? C.warn : C.line);
score.set(((c.score | 0) % 1000000).toString().padStart(6, '0'));
if (pipEls.length !== (c.ammoMax | 0)) { // rebuild only when the cap changes
pips.textContent = '';
pipEls = Array.from({ length: c.ammoMax | 0 }, () => el('div', 'pip', pips));
}
for (let i = 0; i < pipEls.length; i++) pipEls[i].classList.toggle('on', i < c.ammo);
}));
offs.push(bus.on('combo', (e) => chain.set(e.n > 1 ? `×${e.n} chain` : '')));
// --- boss banner ----------------------------------------------------------------------
const BOSS_NAMES = {
mast_cell: 'MAST CELL', pyloric_guardian: 'PYLORIC GUARDIAN', tapeworm: 'TAENIA',
the_blockage: 'THE BLOCKAGE', appendix_sovereign: 'THE SOVEREIGN',
};
const setState = (txt, cls) => {
bossState.set(txt);
bossState.node.className = `boss-state ${cls}`;
};
offs.push(bus.on('boss:start', (e) => {
bossName.set(BOSS_NAMES[e?.id] ?? 'CONTACT');
// The Sovereign's Communion is a boss that resolves to no fight at all — announcing phases
// for it would promise a battle the player has just earned their way out of.
const n = Math.max(0, e?.phases ?? 0);
phaseWrap.textContent = '';
phasePips = Array.from({ length: n }, () => el('i', null, phaseWrap));
if (phasePips[0]) phasePips[0].classList.add('on');
setState(n ? 'sealed' : 'stand down', n ? 'shut' : 'open');
tc.classList.add('on');
}));
offs.push(bus.on('boss:phase', (e) => {
const p = (e?.phase | 0);
phasePips.forEach((el_, i) => el_.classList.toggle('on', i < p));
}));
offs.push(bus.on('boss:window', (e) => setState(e?.open ? 'VULNERABLE' : 'sealed', e?.open ? 'open' : 'shut')));
offs.push(bus.on('boss:telegraph', () => setState('opening', 'tele')));
offs.push(bus.on('boss:end', () => tc.classList.remove('on')));
// biome standing — reveal on first event, then scale-only (60fps law). Brightens near full
// to telegraph an incoming ally.
let biomeShown = false, lastStand = -1, lastStandC = '';
offs.push(bus.on('standing', (e) => {
if (!biomeShown) { biomeRow.style.display = ''; biomeShown = true; }
const f = Math.max(0, Math.min(1, (e.value || 0) / 100));
if (f !== lastStand) { biomeFill.style.transform = `scaleX(${f})`; lastStand = f; }
const col = f >= 0.9 ? C.allyHot : C.ally;
if (col !== lastStandC) { biomeRail.style.color = col; lastStandC = col; }
}));
offs.push(bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint' && ev.name) where.set(ev.name);
}));
return {
dispose() {
for (const off of offs) off();
root.remove();
style.remove();
},
};
}