The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.
The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.
- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.
Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.
Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
9.9 KiB
JavaScript
236 lines
9.9 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;
|
||
// 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` : '')));
|
||
|
||
// 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();
|
||
},
|
||
};
|
||
}
|