import { onCleanup, onMount } from "solid-js"; const FONT_SIZE = 15; const LINE_H = FONT_SIZE * 2.8; const CHAR_W = FONT_SIZE * 1.7; const DENSITY = 0.30; const SPEEDS = [5, 7, 4]; const GLOW_COUNT = 2; const FALLBACK_ACCENT = "#F49C70"; function readAccentHex(): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue("--peach-accent") .trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw; return FALLBACK_ACCENT; } export function LockRuneCanvas() { let canvas!: HTMLCanvasElement; onMount(() => { const ctx = canvas.getContext("2d"); if (!ctx) return; let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70]; function setAccent(hex: string) { accentRgb = [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]; } setAccent(readAccentHex()); const accentObserver = new MutationObserver(() => { setAccent(readAccentHex()); }); accentObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"], }); const W = canvas.offsetWidth || 360; const H = canvas.offsetHeight || 600; canvas.width = W; canvas.height = H; const NUM_ROWS = Math.ceil(H / LINE_H) + 1; const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 2; // Deterministic sparse grid — 30% of positions occupied const runeString: (string | null)[] = Array.from( { length: NUM_ROWS * CHARS_PER_ROW }, (_, i) => { const hash = (i * 2654435761) >>> 0; if ((hash % 100) >= DENSITY * 100) return null; return String.fromCodePoint(0xe000 + (hash % 256)); }, ); const offsets = Array.from({ length: NUM_ROWS }, () => Math.random() * CHARS_PER_ROW * CHAR_W, ); interface Glow { row: number; col: number; phase: number; speed: number; lifetime: number; age: number; } function spawnGlow(): Glow { let row = 0, col = 0, attempts = 0; do { row = Math.floor(Math.random() * NUM_ROWS); col = Math.floor(Math.random() * CHARS_PER_ROW); attempts++; } while (!runeString[row * CHARS_PER_ROW + col] && attempts < 20); return { row, col, phase: Math.random(), speed: 0.3 + Math.random() * 0.3, lifetime: 3000 + Math.random() * 4000, age: 0, }; } const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawnGlow); let lastTime = performance.now(); let raf = 0; let running = false; const frame = (now: number) => { const dt = Math.min(now - lastTime, 64); lastTime = now; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#1a1410"; ctx.fillRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "alphabetic"; const glowMap = new Map(); for (const g of glows) { g.age += dt; g.phase = (g.phase + (g.speed * dt) / 1000) % 1; const env = Math.sin((g.age / g.lifetime) * Math.PI); const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5; glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60); if (g.age >= g.lifetime) Object.assign(g, spawnGlow()); } const [ar, ag, ab] = accentRgb; for (let row = 0; row < NUM_ROWS; row++) { const speed = SPEEDS[row % SPEEDS.length]; const totalW = CHARS_PER_ROW * CHAR_W; offsets[row] = (offsets[row] + (speed * dt) / 1000) % totalW; const y = row * LINE_H + FONT_SIZE + 12; for (let col = 0; col < CHARS_PER_ROW; col++) { const char = runeString[row * CHARS_PER_ROW + col]; if (!char) continue; const x = col * CHAR_W - offsets[row] + CHAR_W; if (x < -CHAR_W || x > W + CHAR_W) continue; const glowA = glowMap.get(`${row},${col}`) ?? 0; if (glowA > 0.05) { ctx.save(); ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`; ctx.shadowBlur = 12; ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`; ctx.fillText(char, x, y); ctx.restore(); } else { ctx.fillStyle = "rgba(245,240,234,0.18)"; ctx.fillText(char, x, y); } } } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; lastTime = performance.now(); raf = requestAnimationFrame(frame); }; const stop = () => { if (!running) return; running = false; cancelAnimationFrame(raf); }; const onVisibility = () => { if (document.hidden) stop(); else start(); }; document.addEventListener("visibilitychange", onVisibility); document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); onCleanup(() => { stop(); accentObserver.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }); }); return ( ); }