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>
287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
// 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();
|
||
},
|
||
};
|
||
}
|