import { onCleanup, onMount } from "solid-js"; const FONT_SIZE = 15; const LINE_HEIGHT = FONT_SIZE * 2.3; const LETTER_WIDTH = FONT_SIZE * 1.35; const LETTER_SPACING = FONT_SIZE * 0.18; const SPEEDS = [5, 7, 4]; const GLOW_COUNT = 2; const FALLBACK_ACCENT = "#F49C70"; // 30% of string positions are runes; the rest are null (skipped in draw). // Same cyclic-scroll mechanic as RuneCanvas — no isolated-character weirdness. const RUNE_STRING_LEN = 80; const DENSITY = 0.30; const RUNE_STRING: (string | null)[] = Array.from( { length: RUNE_STRING_LEN }, (_, i) => { const hash = (i * 2654435761) >>> 0; if ((hash % 100) >= DENSITY * 100) return null; return String.fromCodePoint(0xe000 + (hash % 256)); }, ); 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 CELL_W = LETTER_WIDTH + LETTER_SPACING; const SYMBOLS_PER_ROW = Math.ceil(W / CELL_W) + 4; const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1; const TOTAL_STRING_W = RUNE_STRING_LEN * CELL_W || 1; interface Glow { row: number; col: number; phase: number; speed: number; lifetime: number; age: number; } const spawn = (): Glow => ({ row: Math.floor(Math.random() * NUM_ROWS), col: Math.floor(Math.random() * SYMBOLS_PER_ROW), 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 }, spawn); const offsets: number[] = Array.from({ length: NUM_ROWS }, () => Math.random() * TOTAL_STRING_W, ); 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, spawn()); } const [ar, ag, ab] = accentRgb; for (let row = 0; row < NUM_ROWS; row++) { const speed = SPEEDS[row % SPEEDS.length]; offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_W; const y = row * LINE_HEIGHT + FONT_SIZE + 12; for (let col = 0; col < SYMBOLS_PER_ROW; col++) { const x = col * CELL_W - (offsets[row] % CELL_W); if (x < -CELL_W || x > W + CELL_W) continue; const baseIdx = Math.floor(offsets[row] / CELL_W); const strIdx = (((baseIdx + col) % RUNE_STRING_LEN) + RUNE_STRING_LEN) % RUNE_STRING_LEN; const char = RUNE_STRING[strIdx]; if (!char) continue; // sparse position — blank 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 ; }