HardYards/web/world/js/hud.js
m3ultra 2b66ce4b9a Give the game a face: HUD, mouse prep, forecast and aftermath (gate 1)
SPRINT4 §Lane A 1-5. Everything the game already simulated was invisible to a
stranger; you can now play a whole round with eyes and a mouse and never open the
console.

hud.js only ever READS — it takes no decisions and owns no state worth keeping,
so it can be this chatty without anything drifting. Corner load bars are
world-anchored rather than screen-anchored because a number in the corner of the
screen can't tell you WHICH shackle is about to go, and that is the entire
"...the shackle, I knew about the shackle" moment. They hold constant screen size
with distance: a house corner is 18 m away from the posts, where a world-sized
bar is a few unreadable pixels.

Prep is Lane B's createRiggingUI wired to the phase machine — their picking was
already complete, this is the hands. Forecast reads the three authored storms, so
the difficulty select came free: Sea Breeze / Southerly Buster / Wild Night with
real peak wind, gust character and change time.

Two bugs the wiring surfaced, both found by playing rather than reading:
- The rigging session survived "play again": a second round started at $35 with
  three corners already hung, and the four anchors you clicked were silently
  ignored because the session was full. resetRig() walks it back through the
  session's own public moves, so the economy stays the one source of truth.
- The forecast card's lifetime was owned by its own button, so any other route
  into prep left a card floating over a live game eating input. The phase owns it
  now.

Verified by playing it: good rig on the wild night wins at 53%, the same yard's
cheap drum-tight span loses 4 corners, the gentle night with a good rig ends at
93% "not a leaf out of place". Selftest 184/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:32:02 +10:00

393 lines
16 KiB
JavaScript

/**
* SHADES — the face. Lane A owns this file.
*
* Everything the game already simulates is invisible to a stranger without this:
* loads, the gust warning, whether the garden is actually getting hit. So the
* rule here is that the HUD only ever READS. It takes no decisions, owns no
* state worth keeping, and if you deleted it the sim would run identically —
* which is also why it can be this chatty without anything drifting.
*
* Two things earn their place as world-anchored rather than screen-anchored:
* the corner load bars (a number in a corner of the screen can't tell you WHICH
* shackle is about to go, and that's the whole "…the shackle, I knew about the
* shackle" moment) and the repair prompt, which Lane D already owns.
*/
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;
const CSS = `
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
text-shadow:0 1px 3px #000a; user-select:none; }
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
border-radius:6px; padding:8px 10px; }
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
#hud-clock { color:#8ba0ad; }
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
opacity:0; transition:opacity .12s; }
#hud-gust.on { opacity:1; }
#hud-garden { bottom:12px; left:12px; min-width:210px; }
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
#hud-carry { bottom:12px; right:12px; text-align:right; }
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
#hud-events div { color:#ffd27a; margin-top:2px; }
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
color:#93a6b2; opacity:.85; white-space:nowrap; }
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
background:#060a0dc4; backdrop-filter:blur(3px);
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
border-bottom:1px solid #1c262d; }
#hud-card .row b { font-weight:700; color:#fff; }
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
font:inherit; transition:border-color .15s,background .15s; }
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
#hud-card .storm .stat { color:#8ba0ad; }
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
#hud-card .go:hover { background:#255033; }
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
`;
/**
* @param {object} d
* @param {THREE.Scene} d.scene
* @param {THREE.Camera} d.camera
* @param {object} d.game the phase machine
* @param {object} d.world
* @param {object} d.wind the router
* @param {object} d.player
* @param {object} d.rig SailRig
* @param {object} d.garden {hp}
* @param {() => object} d.getSky skyfx is rebuilt per storm, so read it late
* @param {() => number} d.getWindTime
* @param {{text:string,t:number}[]} d.events
*/
export function createHud(d) {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'hud';
root.innerHTML = `
<div class="panel" id="hud-top">
<div id="hud-wind">--</div>
<div id="hud-clock">--</div>
</div>
<div id="hud-gust">GUST INCOMING</div>
<div class="panel" id="hud-garden">
<div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
<div id="hud-bar"><i></i></div>
<div id="hud-shade" style="color:#8ba0ad"></div>
</div>
<div class="panel" id="hud-carry" style="display:none"></div>
<div id="hud-events"></div>
<div id="hud-help"></div>
`;
document.body.appendChild(root);
const card = document.createElement('div');
card.id = 'hud-card';
document.body.appendChild(card);
const $ = (id) => root.querySelector(id);
const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
const elGardenLabel = $('#hud-garden-label');
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
// --- world-anchored corner load bars ------------------------------------
// A bar per corner, floating at the corner it describes. Geometry rather than
// a redrawn canvas: the fill is a scaled quad, so animating it is free and a
// flogging corner's bar can track it at 60 Hz without touching a texture.
const barGroup = new THREE.Group();
barGroup.visible = false;
d.scene.add(barGroup);
const quad = new THREE.PlaneGeometry(1, 1);
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
const BAR_W = 0.9, BAR_H = 0.1;
const bars = [];
function ensureBars(n) {
while (bars.length < n) {
const holder = new THREE.Group();
const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
}));
bg.scale.set(BAR_W, BAR_H, 1);
bg.position.x = -BAR_W / 2;
const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: BAR_OK, depthTest: false,
}));
fill.position.x = -BAR_W / 2;
fill.scale.set(0.001, BAR_H * 0.72, 1);
const label = makeLabel();
label.position.y = 0.19;
holder.add(bg, fill, label);
holder.renderOrder = 999;
barGroup.add(holder);
bars.push({ holder, fill, label, lastText: '' });
}
for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
}
function makeLabel() {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const tex = new THREE.CanvasTexture(c);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
sp.scale.set(1.5, 0.375, 1);
sp.userData.ctx = c.getContext('2d');
sp.userData.tex = tex;
return sp;
}
function drawLabel(sprite, text, color) {
const g = sprite.userData.ctx;
g.clearRect(0, 0, 256, 64);
g.font = 'bold 30px ui-monospace, Menlo, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.lineWidth = 6;
g.strokeStyle = '#0a1016';
g.strokeText(text, 128, 32);
g.fillStyle = color;
g.fillText(text, 128, 32);
sprite.userData.tex.needsUpdate = true;
}
// Labels are the only per-frame texture cost here, so they redraw on a slow
// tick and only when the shown value actually changes.
let labelT = 0;
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _cam = new THREE.Vector3();
// --- helpers ------------------------------------------------------------
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
function cornerPos(i) {
if (d.rig.cornerPos) return d.rig.cornerPos(i);
const c = d.rig.corners[i];
return c?.anchor?.pos ?? null;
}
const hud = {
/** Set false while a card is up — the storm HUD shouldn't peer through it. */
setVisible(on) { root.style.display = on ? '' : 'none'; },
setHelp(text) { elHelp.textContent = text; },
/**
* @param {number} dt
* @param {number} t wind/storm time
*/
update(dt, t) {
const phase = d.game.phase;
const storm = phase === 'storm';
// --- wind + clock ---
const speed = d.wind.speedAt(d.player.pos, t);
elWind.textContent = `${speed.toFixed(1)} m/s ${kmh(speed).toFixed(0)} km/h`;
elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
elClock.textContent = storm
? `STORM ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
: phase.toUpperCase();
// --- gust telegraph ---
// The contract promises >=1.2 s of warning; this is where the player
// actually gets to spend it.
const tel = storm ? d.wind.gustTelegraph(t) : null;
elGust.classList.toggle('on', !!tel);
if (tel) elGust.textContent = `GUST INCOMING ${tel.eta.toFixed(1)}s · ${kmh(tel.power).toFixed(0)} km/h`;
// --- garden ---
const hp = d.garden.hp;
elPct.textContent = `${hp.toFixed(0)}%`;
elBar.style.width = `${clamp01(hp / 100) * 100}%`;
elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
// Decision 7: rain shadow is what matters at night, sun coverage by day.
// Showing whichever is load-bearing right now stops the number being a
// fact about nothing.
const sky = d.getSky?.();
if (storm && sky?.rainShadowOver) {
const dry = sky.rainShadowOver(d.world.gardenBed);
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
} else {
const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
}
// --- carried item ---
const carrying = d.player.carrying;
elCarry.style.display = carrying ? '' : 'none';
if (carrying) elCarry.textContent = `carrying: ${carrying}`;
// --- event ticker ---
const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');
// --- corner bars ---
barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
if (!barGroup.visible) return;
ensureBars(d.rig.corners.length);
labelT += dt;
const redraw = labelT >= 0.12;
if (redraw) labelT = 0;
d.camera.getWorldQuaternion(_q);
d.camera.getWorldPosition(_cam);
for (let i = 0; i < d.rig.corners.length; i++) {
const c = d.rig.corners[i];
const b = bars[i];
const p = cornerPos(i);
if (!p) { b.holder.visible = false; continue; }
_p.set(p.x, p.y, p.z);
b.holder.position.copy(_p);
b.holder.position.y += 0.45;
b.holder.quaternion.copy(_q);
// Hold roughly constant screen size. A house corner is ~18 m away when
// you're standing at the posts, and at that range a world-sized bar is
// a few unreadable pixels — which defeats the entire point of putting it
// out there rather than in a corner of the screen. Clamped so it doesn't
// balloon when you walk right up to a corner to repair it.
const dist = _p.distanceTo(_cam);
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
const frac = clamp01((c.load || 0) / c.hw.rating);
b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
b.fill.material.color.setHex(barColor(frac, c.broken));
if (redraw) {
const text = c.broken
? `${c.anchorId.toUpperCase()} BLOWN`
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(c.hw.rating / 1000).toFixed(1)}kN`;
if (text !== b.lastText) {
b.lastText = text;
drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
}
}
}
},
// --- cards ------------------------------------------------------------
/**
* The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free — three authored storms already bracket the
* range, so letting the player pick one IS the choice.
*
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast(storms, onPick) {
const rows = storms.map(({ key, def }) => {
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
return `<button class="storm" data-key="${key}">
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
</button>`;
}).join('');
card.innerHTML = `<div class="card">
<h1>FORECAST</h1>
<h2>90 seconds of storm. Pick your night, then rig for it.</h2>
${rows}
<div class="stat" style="color:#8ba0ad;margin-top:12px">
A change means the corners that were slack all storm are the loaded ones after it.
</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
for (const b of card.querySelectorAll('.storm')) {
b.addEventListener('click', () => { hud.hideCard(); onPick(b.dataset.key); });
}
},
/**
* @param {object} r {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
* @param {() => void} onAgain
*/
showAftermath(r, onAgain) {
const rows = [
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
['budget left', `$${r.budgetLeft}`],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
card.innerHTML = `<div class="card">
<h1>AFTERMATH</h1>
<h2>${r.subtitle}</h2>
${rows}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
<button class="go">PLAY AGAIN</button>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
},
hideCard() {
card.classList.remove('on');
card.innerHTML = '';
hud.setVisible(true);
},
get cardOpen() { return card.classList.contains('on'); },
dispose() {
root.remove(); card.remove(); style.remove();
d.scene.remove(barGroup);
quad.dispose();
for (const b of bars) {
b.fill.material.dispose();
b.label.material.map?.dispose();
b.label.material.dispose();
}
},
};
return hud;
}