The model landed in Sprint 6 and nothing ever called it, which is why this kept
getting carried: `forecastFor` existed while the card went on estimating inline.
So this is the wiring, not more API.
`forecastLines(def, lead)` turns a storm into the card's two stat lines, already
worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card
always has); further out it hedges into ranges and prints a confidence line. The
bands always contain the truth — a forecast may be vague, never wrong, or a
player who rigs for the top of the stated range gets ambushed.
Two things the card gains beyond the bands:
- The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase
+ powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust
power is drawn per gust and rides a ramp. The wild night now says 116 km/h
because that is what hits you.
- The card finally MENTIONS HAIL. Hail has been the garden score since decision
13 and the forecast never said so — you were being scored on something the
card didn't tell you was coming. Now: "hail likely" on the wild night, silent
on the gentle one, "possible" when it's too far out to promise.
hud.js is Lane A's file and A is meant to build the week uninterrupted, so this
is deliberately a 3-line swap that keeps their markup, classes and wording — the
week's card rewrite can carry it or drop `forecastLines` and call `forecastFor`
directly. `lead` defaults to 0, so nothing changes until the week passes one.
Verified live at both ends: tonight renders exact; five nights receding into the
week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail
possible"). Selftest 265/0/0 + gate 0's 2 skips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
395 lines
16 KiB
JavaScript
395 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';
|
|
import { forecastLines } from './weather.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) {
|
|
// Numbers via Lane C's forecastLines(def, lead): MEASURED rather than
|
|
// estimated (the old inline `baseCurve peak + powBase + powRamp` read 30
|
|
// m/s for storm_02, which really gusts to 32.3), and banded by how far out
|
|
// the night is. `lead` 0 = tonight = exact, which is what this reads as
|
|
// today; pass each night's lead when the week lands and the card starts
|
|
// hedging on its own. Same wording as before — only the numbers changed.
|
|
const rows = storms.map(({ key, def, lead = 0 }) => {
|
|
const f = forecastLines(def, lead);
|
|
return `<button class="storm" data-key="${key}">
|
|
<div class="name">${f.name || key.replace(/_/g, ' ').toUpperCase()}${f.night ? ' · NIGHT' : ''}</div>
|
|
<div class="stat">${f.wind}</div>
|
|
<div class="stat">${f.rain}</div>
|
|
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</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;
|
|
}
|