/** * 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, leadFor } 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; } /* --- the week ---------------------------------------------------------- */ #hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; } #hud-card .pip.now { color:#7ee0ff; } #hud-card .pip.held { color:#7fce6a; } #hud-card .pip.lost { color:#ff8f86; } #hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; } #hud-card .ledger .row b { color:#a8f0b8; } #hud-card .ledger .row.bad b { color:#ff8f86; } #hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } #hud-card .ledger .row.total b { color:#fff; } /* SPRINT11 — the job sheet and the invoice. STRUCTURE ONLY, deliberately: the letterhead and the invoice styling are Lane E's this sprint (§gate 2), and everything below is the plainest thing that reads correctly so E can restyle it without unpicking layout. The class names are the contract — .letterhead, .brief, .jobsheet, .tomorrow — and E owns what they look like. */ #hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline; margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d; font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; } #hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; } #hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a; background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; } #hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; } #hud-card .jobsheet .row b { color:#a8f0b8; } #hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; } #hud-card .jobsheet .row.total b { color:#fff; } /* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */ #hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px; font-size:12px; color:#6d8494; } #hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; } /* E's diptych. The art is the card; the words sit in the left third they left clear for exactly this. */ #hud-card .endcard { background-size:cover; background-position:center right; max-width:none; width:min(92vw,900px); min-height:min(70vh,520px); display:flex; align-items:center; padding:0; border:1px solid #24343d; } #hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px; background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); } #hud-card .endcard h1 { font-size:26px; } #hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; } /* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */ /* The screen blend is the point: it lifts the black storm scene into morning without touching the card on top. The 2.2s ease matters more than the colours. (No backticks in here — this whole block is a JS template literal.) */ #dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5; transition:opacity 2.2s ease; mix-blend-mode:screen; background:linear-gradient(to top, rgba(255,178,102,.30) 0%, /* the sun that finally turned up */ rgba(255,150,96,.16) 22%, rgba(120,132,168,.10) 55%, rgba(38,48,84,.16) 100%); } /* night still up there */ #dawn.on { opacity:1; } `; /** * @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); // The morning. Sits UNDER the card and over the frozen storm scene — see the // `screen` blend in the CSS, which lifts the black night into dawn without // touching the card on top of it. Lane E shipped this as ten lines rather than // a ninth texture nobody loads, and they were right. const dawn = document.createElement('div'); dawn.id = 'dawn'; document.body.appendChild(dawn); 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({ key, def, site, tomorrowDef }, wk, onGo) { // Integrator merge (Sprint 8): A's week card shape + C's forecastLines — // MEASURED numbers, banded by lead (the old inline estimate read 30 m/s // for a storm that really gusts 32.3). Tonight is lead 0 = exact; when // the week wants to show tomorrow's card, pass its lead and it hedges. const f = forecastLines(def, 0); const change = (def.events ?? []).find((e) => e.type === 'windchange'); const night = (def.sky?.darkness ?? 0) > 0.6; const hail = !!def.hail; // The ladder, as pips. You can see what you've survived and how far is // left — which is most of what makes night four frightening. const pips = Array.from({ length: wk.nights }, (_, i) => { const done = i < wk.night - 1; const now = i === wk.night - 1; const held = done && wk.log[i]?.won; return `${ now ? '◆' : done ? (held ? '●' : '○') : '·'}`; }).join(''); // A different yard announces itself — the corner block is somewhere you've // never rigged, and that's most of what makes night three land. const siteLine = site ? `
${site.name}${site.blurb ? ` — ${site.blurb}` : ''}
` : ''; // SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief: // client wants X, budget Y, forecast Z", and this card had Z and nothing // else. The client and the brief are data (week.js NIGHTS), so an // unbriefed night degrades to the old storm card rather than an empty // letterhead — which is also what keeps every existing test honest. const job = wk.job ?? {}; const letterhead = job.client ? `
${job.client}
` : ''; const brief = job.brief ? `

${job.brief}

` : ''; // BUDGET Y — and this is the actual feature, not the flavour. The fee has // existed since Sprint 8 and the player has never seen it until the money // was already spent: you decided what to rig without knowing what the job // was worth, which is a reveal, not a decision. Quoting the maxima up front // makes the shop a judgement ("is $45 of garden bonus worth a $30 // shackle?") instead of a guess. const q = wk.quote; const schedule = q ? `
base — the job$${q.base}
garden bonus — if the bed livesup to $${q.garden}
clean bonus — nothing of theirs broken$${q.clean}
the job's worthup to $${q.total}
` : ''; // Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their // `forecastLines(def, lead)` has carried the lead param for two sprints // with nothing ever calling it above 0 — this is the call site. // // `lead` is a 0..1 haze dial (confidence = 1 − lead), NOT "nights out" — I // read it as nights first, passed 1, and the card advertised "forecast // confidence 0%": a forecast admitting it knows nothing, printed as news. // // C's `leadFor` maps "N nights out" onto that dial — 0.25 for tomorrow of // five (75% confidence). Safe to print because the band RESOLVES: verified // 4020 samples, 0 violations — tomorrow's band always contains tonight's, // so the number tightens toward the truth and never rules out what it // previously allowed. (Integrated at SPRINT11 merge; was a 0.6 placeholder.) const tf = tomorrowDef ? forecastLines(tomorrowDef, leadFor(1, wk.nights)) : null; const tomorrow = tf ? `
tomorrow ${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}
` : ''; card.innerHTML = `
${letterhead}

NIGHT ${wk.night} OF ${wk.nights}

${pips}

${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}

${siteLine} ${brief}
${f.wind}
${f.rain}${hail ? ' · hail forecast' : ''}
${f.confidence ? `
${f.confidence}
` : ''} ${tomorrow} ${schedule}
in the bank$${wk.bank}
${hail ? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.' : 'Nothing tonight can hurt the garden. Learn the anchors.'}
${change ? `
A change means the corners that were slack all storm are the loaded ones after it.
` : ''}
`; card.classList.add('on'); hud.setVisible(false); card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); }); }, /** * E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point, * not the colours: the storm ends, the light comes up, and THEN you're told * how you did. Sell the survival before the scoreboard. */ setDawn(on) { dawn.classList.toggle('on', !!on); }, /** * The end of the week, either way. E's diptych is on disk and their words are * in THREADS — I took the primaries verbatim; the trade talking, no triumph. * * @param {object} s week.js settlement * @param {() => void} onRestart */ showEndCard(s, onRestart) { const won = s.outcome === 'win'; const art = won ? 'card_win.jpg' : 'card_gameover.jpg'; // The words scale with what was actually held — reaching night five is not // the same as saving five gardens, and the end card is the last thing the // game says. E's primary copy for a clean week, E's own alternate for a // scraped one, and a third for the ending nobody knew existed until the // economy was measured: solvent, with the gardens dead behind you. const COPY = { clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.", "Nobody thanks you for the storm that did nothing. That's the job."], scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.', 'Some of it held. You know which bits didn\'t.'], solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.', 'You can be paid all week and still have nothing to point at.'], gameover: ['OFF THE JOB', 'Broke, with the week still running.', 'The wind never sent an invoice. Everyone else did.'], }; const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover']; const nights = s.night; card.innerHTML = `

${head}

${sub}

nights survived${nights}/5
gardens held${s.held ?? 0}/5
in the bank$${s.bankAfter}
${kick}
`; card.classList.add('on'); hud.setVisible(false); card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); }); }, /** * @param {object} r {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win} * @param {() => void} onAgain */ showAftermath(r, onAgain) { const w = r.week; // What happened. The money is the ledger's business — collateral used to be // listed here AND priced there, which is one fact told twice; the invoice // itemises it now, so this stays the job and that stays the bill. const rows = [ ['garden', `${r.hp.toFixed(0)}%`], ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`], ['what got through', r.hailBlocked], ['hardware lost', r.bill ? `$${r.bill}` : 'none'], ].map(([k, v]) => `
${k}${v}
`).join(''); // The money, itemised. A settlement you can't read is a number you can't // argue with, and arguing with it is how you learn the shop. // // SPRINT11 — this is the INVOICE now, so it answers the job sheet line for // line: base, garden, clean. Each row names what it was FOR, and the two // that can come up empty say why rather than vanishing — a bonus that // silently isn't there teaches nothing, and "clean bonus — you took the // carport" is the sentence that makes the trap land. E's copy for the // collateral row, verbatim from THREADS: "the carport — $180". const collateralRows = w && w.collateral ? (r.collateral ?? []).map((c) => `
${c.what}−$${c.cost}
`).join('') : ''; const cleanRow = w ? (w.clean ? `
clean bonus — nothing of theirs broken+$${w.clean}
` : `
clean bonus — forfeited$0
`) : ''; const money = w ? `
${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
garden bonus — ${r.hp.toFixed(0)}% of the bed+$${w.bonus}
${cleanRow}
gear recovered+$${w.refund}
${collateralRows}
spent on the rig−$${w.spent}
in the bank$${w.bankBefore} → $${w.bankAfter}
` : ''; const next = !w ? 'PLAY AGAIN' : w.outcome === 'continue' ? `NIGHT ${w.night + 1} →` : w.outcome === 'win' ? 'SEE THE WEEK' : 'SEE THE DAMAGE'; card.innerHTML = `
${w?.client ? `
${w.client}invoice
` : ''}

MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}

${r.subtitle}

${rows} ${money}
${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; }