// 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', }; 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'); // --- 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` : ''))); 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(); }, }; }