guts/web/js/ui/hud.js
type-two 4bcb942312 [lane E] The biome meter: the reputation you're building, made visible
A teal BIOME STANDING bar joins coat/hull in the HUD (matches the thin-cyan line-work; scale-
only per the 60fps law). Hidden until the first `standing` event, so a flora-less level never
shows a bar you can't fill. Brightens near full to telegraph an incoming phage ally.

combat/index.js: rescaled standing to a clean 0..100 (tend +5, harm -35) — a FULL bar buds an
ally and drops to a 50 recharge floor, so the meter reads as "charge it to summon help." First-
pass numbers; tune to taste.

Verified in-engine: tending flora reveals the bar and fills it proportionally (0.20 -> 0.45),
teal, below coat/hull.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:05:44 +10:00

232 lines
9.6 KiB
JavaScript
Raw Permalink 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 .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-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;
flowT.set(`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` : '')));
// 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();
},
};
}