TURNCRAFT/src/ui/Hud.ts
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:22:11 +10:00

403 lines
16 KiB
TypeScript

// TURNCRAFT — Lane E. The HUD: a DOM overlay (not canvas) over the game canvas.
// Crosshair, 9-slot hotbar (Lane D's model via an injected getter), a 5-node
// quest tracker, an event subtitle line, the start overlay that gates the
// AudioContext + pointer lock, a pause overlay, and the win banner.
//
// The overlay is pointer-events:none except the start/pause/win screens, so it
// never eats gameplay input.
import { bus } from '../core/events';
import { SIGNAL_NODES, type SignalNode } from '../core/constants';
import { blockDef, type BlockId } from '../core/blocks';
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
export interface HotbarSlot { id: BlockId; count: number; }
export interface HotbarModel { slots: HotbarSlot[]; active: number; }
export interface HudOpts {
root?: HTMLElement;
/** Called ONCE when the player clicks the start splash (init audio + pointer lock). */
onStart?: () => void;
/** Called when the player clicks the pause overlay to resume (re-acquire pointer
* lock only — must NOT repeat first-start side effects). Falls back to a pure unpause. */
onResume?: () => void;
/** Injected read of Lane D's hotbar model. */
getHotbar?: () => HotbarModel;
}
const NODE_LABEL: Record<SignalNode, string> = {
stylus: 'STYLUS', rca: 'RCA', crossfader: 'X-FADER', fuse: 'FUSE', power: 'POWER',
};
const NODE_COLOR: Record<SignalNode, string> = {
stylus: '#d2d6dc', rca: '#ffb028', crossfader: '#4682ff', fuse: '#ff3028', power: '#3cff5a',
};
const CSS = `
.tc-hud, .tc-hud * { box-sizing: border-box; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.tc-hud { position:absolute; inset:0; pointer-events:none; color:#dfe2e6; user-select:none; z-index:10; }
.tc-cross { position:absolute; left:50%; top:50%; width:18px; height:18px; margin:-9px 0 0 -9px;
opacity:.6; }
.tc-cross::before, .tc-cross::after { content:""; position:absolute; background:#e8eaed; }
.tc-cross::before { left:8px; top:0; width:2px; height:18px; }
.tc-cross::after { top:8px; left:0; height:2px; width:18px; }
.tc-quest { position:absolute; top:14px; right:14px; display:flex; gap:8px; }
.tc-node { display:flex; flex-direction:column; align-items:center; gap:4px; opacity:.4;
transition:opacity .3s; }
.tc-node.lit { opacity:1; }
.tc-dot { width:16px; height:16px; border-radius:50%; background:#2a2a2e;
border:1px solid #444; box-shadow:none; transition:box-shadow .3s, background .3s; }
.tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; }
.tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; }
.tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; }
.tc-stamps { position:absolute; top:64px; right:14px; display:flex; gap:9px; }
.tc-stamp { width:15px; height:15px; border-radius:50%; border:2px solid #3a3a3e;
position:relative; opacity:.5; background:transparent;
transition:opacity .3s, background .3s, border-color .3s, box-shadow .3s; }
.tc-stamp::after { content:""; position:absolute; left:50%; top:50%; width:3px; height:3px;
margin:-1.5px 0 0 -1.5px; border-radius:50%; background:#101012; }
.tc-stamp.got { opacity:1; background:currentColor; border-color:currentColor;
box-shadow:0 0 10px 2px currentColor; }
.tc-stamp.pulse { animation:tcstamp .55s cubic-bezier(.2,1.6,.4,1); }
@keyframes tcstamp { 0% { transform:scale(1); } 40% { transform:scale(1.75); } 100% { transform:scale(1); } }
.tc-stamps.all .tc-stamp { border-color:#ffd24a; box-shadow:0 0 12px 3px rgba(255,210,74,.85); }
.tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%);
font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5);
opacity:0; transition:opacity .4s; white-space:nowrap; }
.tc-sub.show { opacity:1; }
.tc-torque { position:absolute; left:50%; bottom:104px; transform:translateX(-50%);
width:78px; height:78px; border-radius:50%; display:none; align-items:center;
justify-content:center; pointer-events:none;
background: conic-gradient(#ffb028 var(--deg,0deg), rgba(255,255,255,.10) 0); }
.tc-torque::before { content:""; position:absolute; inset:9px; border-radius:50%; background:rgba(12,12,14,.95); }
.tc-torque-val { position:relative; font-size:13px; color:#ffb028; letter-spacing:.04em; }
.tc-torque-cap { position:absolute; left:50%; bottom:90px; transform:translateX(-50%);
display:none; font-size:9px; letter-spacing:.16em; color:#9ab; }
.tc-hotbar { position:absolute; left:50%; bottom:22px; transform:translateX(-50%);
display:flex; gap:5px; padding:6px; background:rgba(12,12,14,.55); border:1px solid #333;
border-radius:6px; }
.tc-slot { width:44px; height:44px; border:2px solid #3a3a3e; border-radius:4px; position:relative;
background:#151517; display:flex; align-items:flex-end; justify-content:flex-end; }
.tc-slot.active { border-color:#ffb028; box-shadow:0 0 8px rgba(255,176,40,.6); }
.tc-swatch { position:absolute; inset:5px; border-radius:2px; }
.tc-count { position:relative; font-size:11px; padding:1px 3px; color:#fff;
text-shadow:0 1px 2px #000; }
.tc-key { position:absolute; top:2px; left:4px; font-size:9px; color:#889; }
.tc-overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; background:rgba(6,6,8,.82); pointer-events:auto; cursor:pointer;
text-align:center; }
.tc-overlay.hidden { display:none; }
.tc-title { font-size:56px; letter-spacing:.34em; margin-left:.34em; color:#eef;
text-shadow:0 0 24px rgba(70,130,255,.6); }
.tc-tagline { margin-top:18px; font-size:15px; letter-spacing:.2em; color:#9ab; }
.tc-blink { animation:tcblink 1.2s steps(2,end) infinite; }
@keyframes tcblink { 50% { opacity:.25; } }
.tc-pause { pointer-events:auto; }
.tc-win { background:rgba(4,6,14,.5); }
.tc-win .tc-title { color:#3cff5a; text-shadow:0 0 28px rgba(60,255,90,.7); }
`;
export class Hud {
private root: HTMLElement;
private el: HTMLDivElement;
private onStart?: () => void;
private onResume?: () => void;
private getHotbar?: () => HotbarModel;
private hotbarEl!: HTMLDivElement;
private slots: HTMLDivElement[] = [];
private nodeEls: Record<string, HTMLDivElement> = {};
private progressEl!: HTMLDivElement;
private stampsEl!: HTMLDivElement;
private stampEls: Record<string, HTMLDivElement> = {};
private bornAt = performance.now(); // gates join-replay stamp events (no pulse)
private subEl!: HTMLDivElement;
private torqueEl!: HTMLDivElement;
private torqueVal!: HTMLDivElement;
private torqueCap!: HTMLDivElement;
private startEl!: HTMLDivElement;
private pauseEl!: HTMLDivElement;
private winEl!: HTMLDivElement;
private subTimer: ReturnType<typeof setTimeout> | null = null;
private winTimer: ReturnType<typeof setTimeout> | null = null;
private disposers: (() => void)[] = [];
constructor(opts: HudOpts = {}) {
this.root = opts.root ?? document.getElementById('app') ?? document.body;
this.onStart = opts.onStart;
this.onResume = opts.onResume;
this.getHotbar = opts.getHotbar;
this.injectStyle();
this.el = document.createElement('div');
this.el.className = 'tc-hud';
this.buildCrosshair();
this.buildQuest();
this.buildStamps();
this.buildSubtitle();
this.buildTorque();
this.buildHotbar();
this.buildStart();
this.buildPause();
this.buildWin();
this.root.appendChild(this.el);
this.wireBus();
this.refreshHotbar();
}
// ── public API ──
refreshHotbar(): void {
const model = this.getHotbar?.();
for (let i = 0; i < 9; i++) {
const slot = this.slots[i];
const s = model?.slots[i];
const swatch = slot.querySelector('.tc-swatch') as HTMLDivElement;
const count = slot.querySelector('.tc-count') as HTMLDivElement;
if (s && s.id !== 0 && s.count > 0) {
const t = blockDef(s.id).tint;
swatch.style.background = `rgb(${t[0]},${t[1]},${t[2]})`;
swatch.style.display = 'block';
count.textContent = s.count > 1 ? String(s.count) : '';
} else {
swatch.style.display = 'none';
count.textContent = '';
}
slot.classList.toggle('active', (model?.active ?? 0) === i);
}
}
showSubtitle(text: string): void {
this.subEl.textContent = text;
this.subEl.classList.add('show');
if (this.subTimer) clearTimeout(this.subTimer);
this.subTimer = setTimeout(() => this.subEl.classList.remove('show'), 3200);
}
setPaused(paused: boolean): void {
this.pauseEl.classList.toggle('hidden', !paused);
}
// ── Headshell Workshop (additive; called by the workshop machine) ──
/** Show the radial torque gauge while a screw is being tightened (0..1). */
showTorque(v: number, label = 'TORQUE'): void {
const pct = Math.max(0, Math.min(1, v));
this.torqueEl.style.setProperty('--deg', `${pct * 360}deg`);
this.torqueVal.textContent = `${Math.round(pct * 100)}`;
this.torqueCap.textContent = label;
this.torqueEl.style.display = 'flex';
this.torqueCap.style.display = 'block';
}
hideTorque(): void {
this.torqueEl.style.display = 'none';
this.torqueCap.style.display = 'none';
}
/** Hide the start splash programmatically (e.g. after audio init succeeds). */
hideStart(): void { this.startEl.classList.add('hidden'); }
dispose(): void {
if (this.subTimer) clearTimeout(this.subTimer);
if (this.winTimer) clearTimeout(this.winTimer);
for (const d of this.disposers) d();
this.el.remove();
}
// ── build ──
private injectStyle(): void {
if (document.getElementById('tc-hud-style')) return;
const st = document.createElement('style');
st.id = 'tc-hud-style';
st.textContent = CSS;
document.head.appendChild(st);
}
private buildCrosshair(): void {
const c = document.createElement('div');
c.className = 'tc-cross';
this.el.appendChild(c);
}
private buildQuest(): void {
const wrap = document.createElement('div');
wrap.className = 'tc-quest';
for (const node of SIGNAL_NODES) {
const n = document.createElement('div');
n.className = 'tc-node';
const dot = document.createElement('div');
dot.className = 'tc-dot';
dot.style.color = NODE_COLOR[node];
const label = document.createElement('div');
label.className = 'tc-nlabel';
label.textContent = NODE_LABEL[node];
n.appendChild(dot); n.appendChild(label);
wrap.appendChild(n);
this.nodeEls[node] = n;
}
this.el.appendChild(wrap);
this.progressEl = document.createElement('div');
this.progressEl.className = 'tc-progress';
this.progressEl.textContent = `SIGNAL 0 / ${SIGNAL_NODES.length}`;
this.el.appendChild(this.progressEl);
}
// SIDE B (Lane S): the stamp tray — three record-stamp dots top-right under
// the signal lamps, one per crate world (accent-coloured when got, dim ring
// when not; gold rings once all three are pressed).
private buildStamps(): void {
this.stampsEl = document.createElement('div');
this.stampsEl.className = 'tc-stamps';
for (const k of WORLD_KEYS) {
const a = WORLD_DEFS[k].accent;
const dot = document.createElement('div');
dot.className = 'tc-stamp';
dot.style.color = `rgb(${a[0]},${a[1]},${a[2]})`; // currentColor drives .got
this.stampsEl.appendChild(dot);
this.stampEls[k] = dot;
}
this.el.appendChild(this.stampsEl);
}
private buildSubtitle(): void {
this.subEl = document.createElement('div');
this.subEl.className = 'tc-sub';
this.el.appendChild(this.subEl);
}
private buildTorque(): void {
this.torqueEl = document.createElement('div');
this.torqueEl.className = 'tc-torque';
this.torqueVal = document.createElement('div');
this.torqueVal.className = 'tc-torque-val';
this.torqueEl.appendChild(this.torqueVal);
this.torqueCap = document.createElement('div');
this.torqueCap.className = 'tc-torque-cap';
this.el.appendChild(this.torqueEl);
this.el.appendChild(this.torqueCap);
}
private buildHotbar(): void {
this.hotbarEl = document.createElement('div');
this.hotbarEl.className = 'tc-hotbar';
for (let i = 0; i < 9; i++) {
const slot = document.createElement('div');
slot.className = 'tc-slot';
const key = document.createElement('div');
key.className = 'tc-key';
key.textContent = String(i + 1);
const swatch = document.createElement('div');
swatch.className = 'tc-swatch';
swatch.style.display = 'none';
const count = document.createElement('div');
count.className = 'tc-count';
slot.appendChild(swatch); slot.appendChild(key); slot.appendChild(count);
this.hotbarEl.appendChild(slot);
this.slots.push(slot);
}
this.el.appendChild(this.hotbarEl);
}
private buildStart(): void {
const o = document.createElement('div');
o.className = 'tc-overlay';
const title = document.createElement('div');
title.className = 'tc-title';
title.textContent = 'TURNCRAFT';
const tag = document.createElement('div');
tag.className = 'tc-tagline tc-blink';
tag.textContent = 'click to drop the needle';
o.appendChild(title); o.appendChild(tag);
o.addEventListener('click', () => {
o.classList.add('hidden');
this.onStart?.();
});
this.startEl = o;
this.el.appendChild(o);
}
private buildPause(): void {
const o = document.createElement('div');
o.className = 'tc-overlay tc-pause hidden';
const title = document.createElement('div');
title.className = 'tc-title';
title.style.fontSize = '34px';
title.textContent = 'PAUSED';
const tag = document.createElement('div');
tag.className = 'tc-tagline';
tag.textContent = 'click to resume';
o.appendChild(title); o.appendChild(tag);
// resume must NOT repeat first-start side effects (audio (re)init, deck spin-up)
o.addEventListener('click', () => { this.setPaused(false); this.onResume?.(); });
this.pauseEl = o;
this.el.appendChild(o);
}
private buildWin(): void {
const o = document.createElement('div');
o.className = 'tc-overlay tc-win hidden';
o.style.pointerEvents = 'none';
const title = document.createElement('div');
title.className = 'tc-title';
title.style.fontSize = '40px';
title.textContent = 'THE MIX IS LIVE';
const tag = document.createElement('div');
tag.className = 'tc-tagline';
tag.textContent = 'ride the record';
o.appendChild(title); o.appendChild(tag);
this.winEl = o;
this.el.appendChild(o);
}
// ── bus ──
private wireBus(): void {
this.disposers.push(bus.on('signal:repair', (p) => {
const el = this.nodeEls[p.node];
if (el) el.classList.add('lit');
this.progressEl.textContent = `SIGNAL ${p.repaired} / ${p.total}`;
this.showSubtitle(`SIGNAL RESTORED: ${NODE_LABEL[p.node]}${p.repaired}/${p.total}`);
}));
this.disposers.push(bus.on('game:win', () => {
this.winEl.classList.remove('hidden');
this.showSubtitle('POWER RESTORED — THE BOOTH IS ALIVE');
// let the win banner breathe, then fade so sandbox play continues
if (this.winTimer) clearTimeout(this.winTimer);
this.winTimer = setTimeout(() => this.winEl.classList.add('hidden'), 6000);
}));
this.disposers.push(bus.on('block:break', () => this.refreshHotbar()));
this.disposers.push(bus.on('block:place', () => this.refreshHotbar()));
// Headshell Workshop (Lane D emits): radial torque gauge + transient prompts.
this.disposers.push(bus.on('workshop:torque', (p) => {
if (p.value === null) this.hideTorque();
else this.showTorque(p.value);
}));
this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text)));
// SIDE B (Lane S): stamp tray. The event's origin says whether this is a
// live win ('local'/'remote' → scale-pulse the dot) or join-snapshot
// replay ('replay' → light silently) — no wall-clock guessing.
this.disposers.push(bus.on('stamp:got', (p) => {
const dot = this.stampEls[p.world];
if (!dot) return;
dot.classList.add('got');
if (p.origin !== 'replay') {
dot.classList.remove('pulse');
void dot.offsetWidth; // restart the animation on repeat stamps
dot.classList.add('pulse');
}
}));
this.disposers.push(bus.on('stamp:all', (p) => {
this.stampsEl.classList.add('all');
if (p.origin !== 'replay') {
for (const k of WORLD_KEYS) {
const dot = this.stampEls[k];
dot.classList.remove('pulse');
void dot.offsetWidth;
dot.classList.add('pulse');
}
}
}));
}
}