/* Cosmic Bagua compass — dark, glowing, with light-beam effects */ const COSMIC_DATA = { trigrams: [ { sym: "☰", name: "乾" }, { sym: "☱", name: "兑" }, { sym: "☲", name: "离" }, { sym: "☳", name: "震" }, { sym: "☴", name: "巽" }, { sym: "☵", name: "坎" }, { sym: "☶", name: "艮" }, { sym: "☷", name: "坤" }, ], branches: ["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"], solarTerms: [ "冬至","小寒","大寒","立春","雨水","惊蛰", "春分","清明","谷雨","立夏","小满","芒种", "夏至","小暑","大暑","立秋","处暑","白露", "秋分","寒露","霜降","立冬","小雪","大雪" ], mountains: [ "壬","子","癸","丑","艮","寅","甲","卯","乙","辰","巽","巳", "丙","午","丁","未","坤","申","庚","酉","辛","戌","乾","亥" ], hexagrams: [ "乾","坤","屯","蒙","需","讼","师","比", "小畜","履","泰","否","同人","大有","谦","豫", "随","蛊","临","观","噬嗑","贲","剥","复", "无妄","大畜","颐","大过","坎","离","咸","恒", "遁","大壮","晋","明夷","家人","睽","蹇","解", "损","益","夬","姤","萃","升","困","井", "革","鼎","震","艮","渐","归妹","丰","旅", "巽","兑","涣","节","中孚","小过","既济","未济" ], starSigils: [ "角","亢","氐","房","心","尾","箕", "斗","牛","女","虚","危","室","壁", "奎","娄","胃","昴","毕","觜","参", "井","鬼","柳","星","张","翼","轸" ], }; /* Cosmic Ring */ function CosmicRing({ items, innerR, outerR, rotation, onRotate, fontSize, glowColor, textColor, divider, fillEven, fillOdd, zIndex = 1, renderItem, accentIndices = [], }) { const ref = React.useRef(null); const drag = React.useRef(null); const n = items.length; const step = 360 / n; const cx = outerR, cy = outerR; const onPointerDown = (e) => { const rect = ref.current.getBoundingClientRect(); const ox = rect.left + rect.width / 2; const oy = rect.top + rect.height / 2; const a0 = Math.atan2(e.clientY - oy, e.clientX - ox) * 180 / Math.PI; drag.current = { a0, r0: rotation, ox, oy }; e.currentTarget.setPointerCapture(e.pointerId); }; const onPointerMove = (e) => { if (!drag.current) return; const { a0, r0 } = drag.current; const a = Math.atan2(e.clientY - drag.current.oy, e.clientX - drag.current.ox) * 180 / Math.PI; onRotate(r0 + (a - a0)); }; const onPointerUp = (e) => { drag.current = null; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} }; const sectors = []; for (let i = 0; i < n; i++) { const a1 = (i * step - 90) * Math.PI / 180; const a2 = ((i + 1) * step - 90) * Math.PI / 180; const x1o = cx + outerR * Math.cos(a1), y1o = cy + outerR * Math.sin(a1); const x2o = cx + outerR * Math.cos(a2), y2o = cy + outerR * Math.sin(a2); const x1i = cx + innerR * Math.cos(a1), y1i = cy + innerR * Math.sin(a1); const x2i = cx + innerR * Math.cos(a2), y2i = cy + innerR * Math.sin(a2); const large = step > 180 ? 1 : 0; const d = `M ${x1o} ${y1o} A ${outerR} ${outerR} 0 ${large} 1 ${x2o} ${y2o} L ${x2i} ${y2i} A ${innerR} ${innerR} 0 ${large} 0 ${x1i} ${y1i} Z`; sectors.push( ); } const midR = (innerR + outerR) / 2; const labels = items.map((it, i) => { const ang = i * step + step / 2 - 90; const rad = ang * Math.PI / 180; const x = cx + midR * Math.cos(rad); const y = cy + midR * Math.sin(rad); const rot = ang + 90; const isAccent = accentIndices.includes(i); const content = renderItem ? renderItem(it, i, isAccent) : it; return ( {typeof content === "string" ? {content} : content} ); }); return ( {sectors} {labels} ); } /* Glowing Taiji */ function CosmicTaiji({ size, rotation = 0, hue = "gold" }) { const colors = hue === "cyan" ? { light: "#9ff5ff", dark: "#0a1a26", glow: "#5ed8ff" } : { light: "#ffe9a8", dark: "#1a0e05", glow: "#ffce5c" }; return ( ); } /* Vertical light beams rising and descending */ function LightBeams({ baseR, hue = "gold" }) { const beams = React.useMemo(() => { const arr = []; const N = 24; for (let i = 0; i < N; i++) { const ang = (i * 360 / N) * Math.PI / 180; const r = baseR + 20 + Math.random() * 30; arr.push({ x: r * Math.cos(ang - Math.PI / 2), y: r * Math.sin(ang - Math.PI / 2), delay: Math.random() * 4, dur: 2.5 + Math.random() * 2.5, height: 80 + Math.random() * 220, width: 1.5 + Math.random() * 2.5, }); } return arr; }, [baseR]); const beamColor = hue === "cyan" ? "#5ed8ff" : "#ffce5c"; return (
{beams.map((b, i) => (
))}
); } /* Constellation dots orbiting outside */ function OrbitingStars({ baseR, hue = "gold" }) { const dots = React.useMemo(() => { return Array.from({ length: 60 }, () => ({ ang: Math.random() * 360, r: baseR + 60 + Math.random() * 200, size: 1 + Math.random() * 2.5, blink: Math.random() * 4, blinkDur: 2 + Math.random() * 4, })); }, [baseR]); const c = hue === "cyan" ? "#9ff5ff" : "#ffe9a8"; return (
{dots.map((d, i) => { const rad = d.ang * Math.PI / 180; return (
); })}
); } /* ── Gear Sound (Web Audio API) ── */ function playGearSound(totalMs) { try { const actx = new (window.AudioContext || window.webkitAudioContext)(); const dur = totalMs / 1000; const accelSec = 3; const now = actx.currentTime; // Pink noise buffer — mechanical rumble const noiseFrames = Math.min(Math.ceil(actx.sampleRate * dur), actx.sampleRate * 12); const noiseBuf = actx.createBuffer(1, noiseFrames, actx.sampleRate); const nd = noiseBuf.getChannelData(0); let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0; for (let i = 0; i < noiseFrames; i++) { const w = Math.random()*2-1; b0=0.99886*b0+w*0.0555179; b1=0.99332*b1+w*0.0750759; b2=0.96900*b2+w*0.1538520; b3=0.86650*b3+w*0.3104856; b4=0.55000*b4+w*0.5329522; b5=-0.7616*b5-w*0.0168980; nd[i] = (b0+b1+b2+b3+b4+b5+w*0.5362)*0.05; } const noiseSrc = actx.createBufferSource(); noiseSrc.buffer = noiseBuf; const rumbleF = actx.createBiquadFilter(); rumbleF.type = 'bandpass'; rumbleF.Q.value = 1.5; rumbleF.frequency.setValueAtTime(80, now); rumbleF.frequency.linearRampToValueAtTime(320, now + accelSec); rumbleF.frequency.linearRampToValueAtTime(50, now + dur); const rumbleG = actx.createGain(); rumbleG.gain.setValueAtTime(0, now); rumbleG.gain.linearRampToValueAtTime(0.55, now + accelSec); rumbleG.gain.linearRampToValueAtTime(0, now + dur); noiseSrc.connect(rumbleF); rumbleF.connect(rumbleG); rumbleG.connect(actx.destination); noiseSrc.start(now); noiseSrc.stop(now + dur); // Metallic click buffer — gear teeth const clickFrames = Math.floor(actx.sampleRate * 0.02); const clickBuf = actx.createBuffer(1, clickFrames, actx.sampleRate); const cd = clickBuf.getChannelData(0); for (let i = 0; i < clickFrames; i++) { cd[i] = (Math.random()*2-1) * Math.exp(-i/(actx.sampleRate*0.003)) * 1.5; } let t = now + 0.05; const end = now + dur - 0.1; while (t < end) { const el = t - now; let ivl; if (el < accelSec) { const k = Math.pow(el / accelSec, 1.8); ivl = 0.30 - 0.26 * k; // 0.30s → 0.04s } else { const k = Math.pow((el - accelSec) / (dur - accelSec), 1.5); ivl = 0.04 + 0.28 * k; // 0.04s → 0.32s } ivl = Math.max(0.02, ivl); const env = el < accelSec ? el/accelSec : 1-(el-accelSec)/(dur-accelSec); const src = actx.createBufferSource(); src.buffer = clickBuf; const g = actx.createGain(); g.gain.value = 0.4 * Math.max(0.05, env); src.connect(g); g.connect(actx.destination); try { src.start(t); } catch {} t += ivl; } setTimeout(() => { try { actx.close(); } catch {} }, (dur + 1) * 1000); } catch(e) { /* audio unavailable */ } } /* The Cosmic Compass */ function CosmicCompass({ tweaks }) { const baseR = 360; const accentColors = tweaks.hue === "cyan" ? { glow: "#5ed8ff", text: "#cfeeff", divider: "rgba(94,216,255,0.55)", fillA: "rgba(20,60,90,0.55)", fillB: "rgba(8,28,48,0.65)" } : { glow: "#ffce5c", text: "#f1dba0", divider: "rgba(255,206,92,0.5)", fillA: "rgba(80,40,8,0.55)", fillB: "rgba(40,20,4,0.65)" }; // 4 cardinal indices for mountains: 子 卯 午 酉 → indices 1, 7, 13, 19 const cardinalsMountains = [1, 7, 13, 19]; const ringSpec = [ { key: "trigrams", inner: 80, outer: 115, items: COSMIC_DATA.trigrams.map(t => t.name), font: 18 }, { key: "branches", inner: 115, outer: 150, items: COSMIC_DATA.branches, font: 20 }, { key: "terms", inner: 150, outer: 190, items: COSMIC_DATA.solarTerms, font: 11, renderItem: (s) => ( {s[0]} {s[1]} ) }, { key: "mountains", inner: 190, outer: 232, items: COSMIC_DATA.mountains, font: 22, accentIndices: cardinalsMountains }, { key: "hexagrams", inner: 232, outer: 295, items: COSMIC_DATA.hexagrams, font: 13, renderItem: (s) => s.length === 1 ? {s} : ( {s[0]} {s[1]} ) }, { key: "stars", inner: 295, outer: baseR, items: [...COSMIC_DATA.starSigils, ...COSMIC_DATA.starSigils.slice(0, 36)].slice(0, 36), font: 16 }, ]; const [rotations, setRotations] = React.useState( Object.fromEntries(ringSpec.map(r => [r.key, 0])) ); const burstRef = React.useRef(null); const stoppedRef = React.useRef(false); // compass-stop 事件设为 true,burst 时清除 // 用 ref 持有最新的 tweaks,避免 RAF 闭包捕获旧值 const autoSpinRef = React.useRef(tweaks.autoSpin); const spinSpeedRef = React.useRef(tweaks.spinSpeed); React.useEffect(() => { autoSpinRef.current = tweaks.autoSpin; }, [tweaks.autoSpin]); React.useEffect(() => { spinSpeedRef.current = tweaks.spinSpeed; }, [tweaks.spinSpeed]); React.useEffect(() => { const onBurst = (e) => { stoppedRef.current = false; // 新一轮起卦,清除停止标记 const dur = (e.detail && e.detail.duration) || 30000; const maxM = (e.detail && e.detail.maxMult) || 80; burstRef.current = { start: performance.now(), duration: dur, maxMult: maxM }; playGearSound(dur); // 齿轮声音 }; const onStop = () => { stoppedRef.current = true; // 起卦结束,标记停止 burstRef.current = null; }; window.addEventListener('compass-burst', onBurst); window.addEventListener('compass-stop', onStop); return () => { window.removeEventListener('compass-burst', onBurst); window.removeEventListener('compass-stop', onStop); }; }, []); React.useEffect(() => { let raf, last = performance.now(); const tick = (t) => { const dt = Math.min(0.05, (t - last) / 1000); last = t; const burst = burstRef.current; let burstMult = 1; if (burst) { const elapsed = t - burst.start; if (elapsed < burst.duration) { const ACCEL = 3000; // 前 3s 加速 const maxM = burst.maxMult || 80; if (elapsed < ACCEL) { // 加速阶段:ease-in,从 1x → maxMult const k = elapsed / ACCEL; burstMult = 1 + maxM * Math.pow(k, 2); } else { // 减速阶段:ease-out,从 maxMult → 1 const k = (elapsed - ACCEL) / (burst.duration - ACCEL); burstMult = 1 + maxM * Math.pow(1 - k, 3.2); } } else { burstRef.current = null; } } // stopped 优先级最高;其次 burst;最后 autoSpin const active = !stoppedRef.current && (burstRef.current || autoSpinRef.current); if (active) { setRotations(prev => { const next = { ...prev }; ringSpec.forEach((r, i) => { const dir = i % 2 === 0 ? 1 : -1; const base = (spinSpeedRef.current || 6) * (1 - i * 0.08); const speed = base * burstMult; next[r.key] = (prev[r.key] || 0) + dir * speed * dt; }); return next; }); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); // 依赖为空——通过 ref 读取最新值,不需要重建闭包 // Pulsing aura const [pulse, setPulse] = React.useState(0); React.useEffect(() => { let raf; const tick = () => { setPulse(performance.now() / 1000); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); const pulseScale = 1 + Math.sin(pulse * 1.4) * 0.04; const pulseOpacity = 0.45 + Math.sin(pulse * 1.4) * 0.2; return (
{/* Pulsing aura behind everything */}
{/* Orbiting starfield */} {tweaks.showStars && } {/* Light beams rising/descending */} {tweaks.showBeams && }
{/* Outer glowing rings */}
{/* Tick marks */} {Array.from({ length: 72 }).map((_, i) => { const ang = (i * 5 - 90) * Math.PI / 180; const r1 = baseR - 4; const r2 = baseR + 12; const major = i % 9 === 0; return ( ); })} {ringSpec.map((r, i) => ( setRotations(p => ({ ...p, [r.key]: v }))} fontSize={r.font} glowColor={accentColors.glow} textColor={accentColors.text} divider={accentColors.divider} fillEven={accentColors.fillA} fillOdd={accentColors.fillB} renderItem={r.renderItem} accentIndices={r.accentIndices || []} zIndex={10 + (ringSpec.length - i)} /> ))} {/* Cardinal cross */}
); } window.CosmicCompass = CosmicCompass;