/* * Holy Grail · Spectrum LIVE patch * CCB-00-07 — 將 window.HG2Atoms.Spectrum 從假動畫覆寫成 WebSocket 訂閱版 * * 載入順序:必須在 hg2-atoms.jsx 之後、hg2-hub.jsx 之前 * * 機制: * 1. 監視 active pilder(觀察 .hub-crumb 內 PLD-XXX 文字 + window.HG2.PILDERS 反查 id) * 2. 為每個 active pilder 開一條 WebSocket 到 /v1/resonance/{id} * 3. WS frame.bars 寫進全域 store + 通知所有訂閱 React component * 4. 覆寫 window.HG2Atoms.Spectrum 為 LiveSpectrum: * - 有 WS frame → 用 server 真實 bars + 顯示 ◆ LIVE 字樣 * - 沒有 WS frame → fallback 到原本演算法(保證不退化) */ (function () { if (!window.HG2Atoms || !window.HG2Atoms.Spectrum) { console.warn('[HG2/resonance-live] HG2Atoms.Spectrum 不存在,patch 取消'); return; } // ── 全域 store ───────────────────────────────────────── const ResonanceStore = { frame: null, // 最新 WS frame pilderId: null, // 當前訂閱的 pilder id ws: null, subscribers: new Set(), lastFrameAt: 0, }; function notify() { ResonanceStore.subscribers.forEach((fn) => fn()); } function openWS(id) { if (ResonanceStore.ws) { try { ResonanceStore.ws.close(); } catch (_) {} ResonanceStore.ws = null; } if (!id) { ResonanceStore.frame = null; notify(); return; } const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/v1/resonance/' + encodeURIComponent(id); const ws = new WebSocket(url); ResonanceStore.ws = ws; ws.onopen = () => { console.log('%c[HG2/resonance] WS open · ' + id, 'color:#6FB8FF;font-weight:bold'); }; ws.onmessage = (e) => { try { const d = JSON.parse(e.data); if (d.kind === 'hello') { console.log('%c[HG2/resonance] hello · ' + d.pilder_code + ' · ' + d.rate_hz + 'Hz · state=' + d.initial_state, 'color:' + d.theme.energy); return; } if (d.kind === 'frame') { ResonanceStore.frame = d; ResonanceStore.lastFrameAt = performance.now(); notify(); } } catch (err) { console.warn('[HG2/resonance] parse error', err); } }; ws.onerror = (e) => { console.warn('[HG2/resonance] WS error', e); }; ws.onclose = () => { ResonanceStore.frame = null; notify(); }; } // ── 監視 active pilder(以 hub-crumb 顯示的 PLD-code 為準)── function pollActive() { const codeEl = document.querySelector('.hub-crumb .energy'); if (!codeEl) return; const code = (codeEl.textContent || '').trim(); const pilders = (window.HG2 && window.HG2.PILDERS) || []; const p = pilders.find((x) => x.code === code); if (!p) return; if (p.id !== ResonanceStore.pilderId) { ResonanceStore.pilderId = p.id; openWS(p.id); } } setInterval(pollActive, 250); // ── 覆寫 Spectrum 元件 ───────────────────────────────── const OriginalSpectrum = window.HG2Atoms.Spectrum; const { useState, useEffect, useRef } = React; function LiveSpectrum({ stable = true, intensity = 1 }) { const [, force] = useState(0); const tickRef = useRef(0); useEffect(() => { const fn = () => { tickRef.current += 1; force(tickRef.current); }; ResonanceStore.subscribers.add(fn); return () => { ResonanceStore.subscribers.delete(fn); }; }, []); const frame = ResonanceStore.frame; const isStale = frame && (performance.now() - ResonanceStore.lastFrameAt > 2000); const isLive = !!frame && !isStale; // Fallback:沒 WS 資料時退回原本動畫(教練看 mock 模式仍可動) if (!isLive) { return React.createElement( 'div', { style: { position: 'relative', width: '100%', height: '100%' } }, React.createElement(OriginalSpectrum, { stable, intensity }), React.createElement( 'div', { style: { position: 'absolute', top: 4, right: 4, fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.16em', color: '#FF4D4D', padding: '2px 6px', border: '1px solid rgba(255,77,77,0.55)', }, }, isStale ? '◯ STALE' : '◯ MOCK' ) ); } // LIVE 渲染 const W = 600, H = 150; const bars = frame.bars || []; const N = bars.length || 56; const energy = (frame.theme && frame.theme.energy) || 'var(--energy)'; const isWarden = frame.warden_pulse; const labelColor = isWarden ? 'var(--gold)' : energy; const labelText = isWarden ? '◆ WARDEN · ' + (frame.state || '').toUpperCase() : '◆ LIVE · ' + (frame.state || '').toUpperCase() + ' · ' + (frame.intensity * 100).toFixed(0) + '%'; return React.createElement( 'div', { style: { position: 'relative', width: '100%', height: '100%' } }, React.createElement( 'svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', style: { width: '100%', height: '100%' }, }, React.createElement( 'defs', null, React.createElement( 'linearGradient', { id: 'v2-spec-live', x1: 0, x2: 0, y1: 0, y2: 1 }, React.createElement('stop', { offset: '0%', stopColor: energy, stopOpacity: 0.95 }), React.createElement('stop', { offset: '100%', stopColor: energy, stopOpacity: 0.08 }) ) ), [0.25, 0.5, 0.75].map((p, i) => React.createElement('line', { key: 'g' + i, x1: 0, x2: W, y1: H * p, y2: H * p, stroke: 'var(--hair)', strokeDasharray: '2 4', }) ), bars.map((h, i) => { const x = (i / N) * W; const bh = Math.max(2, Math.min(H - 4, H * 0.85 * Math.max(0.05, h))); return React.createElement('rect', { key: i, x: x + 1.5, y: H - bh, width: W / N - 3, height: bh, fill: isWarden && bh > H * 0.4 ? 'var(--gold)' : 'url(#v2-spec-live)', }); }) ), // LIVE 標籤 React.createElement( 'div', { style: { position: 'absolute', top: 4, right: 4, fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.16em', color: labelColor, padding: '2px 6px', border: '1px solid ' + labelColor, background: 'rgba(0,0,0,0.4)', }, }, labelText ) ); } window.HG2Atoms.Spectrum = LiveSpectrum; window.HG2_RESONANCE = ResonanceStore; // expose for debug console.log('%c[HG2/resonance-live] Spectrum patched · subscribing to /v1/resonance', 'color:#E8C268;font-weight:bold'); })();