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>
232 lines
9.6 KiB
JavaScript
232 lines
9.6 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 .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();
|
||
},
|
||
};
|
||
}
|