// 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 = { stylus: 'STYLUS', rca: 'RCA', crossfader: 'X-FADER', fuse: 'FUSE', power: 'POWER', }; const NODE_COLOR: Record = { 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 = {}; private progressEl!: HTMLDivElement; private stampsEl!: HTMLDivElement; private stampEls: Record = {}; 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 | null = null; private winTimer: ReturnType | 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'); } } })); } }