/** * 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 = `
--
--
GUST INCOMING
GARDEN 100%
`; 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) => `
${e.text}
`).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 ``; }).join(''); card.innerHTML = `

FORECAST

90 seconds of storm. Pick your night, then rig for it.

${rows}
A change means the corners that were slack all storm are the loaded ones after it.
`; 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]) => `
${k}${v}
`).join(''); card.innerHTML = `

AFTERMATH

${r.subtitle}

${rows}
${r.verdict}
`; 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; }