SPRINT8 gate 1, carried since Sprint 6. week.js is a thin wrapper over the phase machine, not a new system: it answers three questions between rounds — which storm is tonight, how much money is there, and is it over. Zero THREE, pure data, so the ladder and the money are testable at no cost. The forecast card stops being a difficulty picker and becomes NIGHT n OF 5 with the ladder as pips, tonight's storm, and the bank. Aftermath itemises the settlement (fee / bonus / gear recovered / collateral / spent → bank), and E's dawn tint comes up on their 2.2s ease BEFORE the scoreboard — their note is right that you sell the survival first. E's diptych and copy are wired verbatim. Two economy decisions, both made because measuring the week exposed them: A lost night pays a FRACTION of the fee. Zero is unrecoverable and reads as punishment; full pay means failure only costs the broken shackle and the bank only ever climbs. Gear comes back at HALF — a full refund makes hardware free, so the only sink is breakage and "broke = game over" becomes unreachable. Half is also honest: a shackle that rode out a gale has been fatigued, which DESIGN.md already says. And the one that matters: REACHING night five is not SURVIVING it. My first draft said it was, and playing the week found it — a $20-carabiner player loses four gardens, never dips under the broke line because a cheap rig barely costs anything, and got handed "THE WEEK HELD — everything's still where you put it" over four dead gardens. The end card is the last thing this game says; it doesn't get to lie the way the aftermath verdict used to. Words now scale with gardens held: clean (5), scraped (3-4, E's own alternate copy), solvent (0-2, mine — "STILL TRADING · The books balanced. The garden didn't." E never wrote words for that ending because nobody knew the game could produce it). Two economy questions I could NOT settle from a harness and have documented rather than quietly tuned: a competent week runs the bank 80 → 350, so night five stops being a decision; and a floor-scraping week may never actually go broke. My bad-player model handed back a fixed $30 of gear whatever it spent, which flattered the bottom of the curve — the same class of mistake this repo keeps catching me in. Both want a human playing five nights. Selftest 270 pass; the single red is gate 0's balance line, which is B+D's this sprint and was already red on main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
519 lines
23 KiB
JavaScript
519 lines
23 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; }
|
||
|
||
/* --- 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; }
|
||
|
||
/* 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 = `
|
||
<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);
|
||
|
||
// 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) => `<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({ key, def }, wk, onGo) {
|
||
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;
|
||
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 `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
|
||
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
|
||
}).join('');
|
||
|
||
card.innerHTML = `<div class="card">
|
||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||
<div class="pips">${pips}</div>
|
||
<h2>${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||
<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'}${
|
||
hail ? ' · hail forecast' : ''}
|
||
${change ? `· change at ${change.t}s` : '· no change forecast'}</div>
|
||
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||
<div class="stat" style="color:#8ba0ad;margin-top:10px">
|
||
${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.'}
|
||
</div>
|
||
${change ? `<div class="stat" style="color:#8ba0ad">
|
||
A change means the corners that were slack all storm are the loaded ones after it.
|
||
</div>` : ''}
|
||
<button class="go">RIG IT</button>
|
||
</div>`;
|
||
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 = `<div class="card endcard ${won ? 'win' : 'lose'}"
|
||
style="background-image:url(./models/textures/${art})">
|
||
<div class="endtext">
|
||
<h1>${head}</h1>
|
||
<h2>${sub}</h2>
|
||
<div class="row"><span>nights survived</span><b>${nights}/5</b></div>
|
||
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
|
||
<div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
|
||
<div class="kicker">${kick}</div>
|
||
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
|
||
</div>
|
||
</div>`;
|
||
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;
|
||
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'],
|
||
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
|
||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).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.
|
||
const money = w ? `
|
||
<div class="ledger">
|
||
<div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||
<div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
|
||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||
${w.collateral ? `<div class="row bad"><span>collateral</span><b>−$${w.collateral}</b></div>` : ''}
|
||
<div class="row"><span>spent on the rig</span><b>−$${w.spent}</b></div>
|
||
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
|
||
</div>` : '';
|
||
|
||
const next = !w ? 'PLAY AGAIN'
|
||
: w.outcome === 'continue' ? `NIGHT ${w.night + 1} →`
|
||
: w.outcome === 'win' ? 'SEE THE WEEK'
|
||
: 'SEE THE DAMAGE';
|
||
|
||
card.innerHTML = `<div class="card">
|
||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||
<h2>${r.subtitle}</h2>
|
||
${rows}
|
||
${money}
|
||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||
<button class="go">${next}</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;
|
||
}
|