import { onCleanup, onMount } from "solid-js"; interface RuneBackgroundProps { opacity?: number; } const FONT_SIZE = 14; // Tighter row spacing — denser field of runes for a richer texture that // matches the Android RuneGridBackground's visual density. const LINE_H = FONT_SIZE * 2.8; const CHAR_W = FONT_SIZE * 2.2; // Three slow drift speeds (px/sec) — much quieter than the export canvas. // Atmosphere, not focus. const SPEEDS = [8, 11, 6]; const RUNE_LEN = 120; // Deterministic rune string drawn from the PeachScript PUA range // (U+E000..U+E0FF). Same characters every render so the background // has a consistent texture across mounts. function buildRuneString(): string { const out: string[] = []; for (let i = 0; i < RUNE_LEN; i++) { out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff))); } return out.join(""); } export function RuneBackground(props: RuneBackgroundProps) { let canvas!: HTMLCanvasElement; let raf = 0; let running = false; onMount(() => { if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const W = canvas.width; const H = canvas.height; const baseOpacity = props.opacity ?? 0.07; const runes = buildRuneString(); const NUM_ROWS = Math.ceil(H / LINE_H) + 1; const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4; const TOTAL_W = runes.length * CHAR_W; const offsets = Array.from({ length: NUM_ROWS }, () => Math.random() * TOTAL_W, ); let last = performance.now(); const frame = (now: number) => { const dt = Math.min(now - last, 64); last = now; ctx.clearRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "alphabetic"; ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`; for (let row = 0; row < NUM_ROWS; row++) { const speed = SPEEDS[row % SPEEDS.length]; offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W; const y = row * LINE_H + FONT_SIZE + 8; const baseIdx = Math.floor(offsets[row] / CHAR_W); const xOffset = offsets[row] % CHAR_W; for (let col = 0; col < CHARS_PER_ROW; col++) { const x = col * CHAR_W - xOffset; if (x < -CHAR_W || x > W + CHAR_W) continue; const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length; ctx.fillText(runes[idx]!, x, y); } } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; last = 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); const fonts = document.fonts; if (fonts) { fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); } else { start(); } onCleanup(() => { stop(); document.removeEventListener("visibilitychange", onVisibility); }); }); return ( { canvas = el; }} width={320} height={520} class="peach-rune-background" aria-hidden="true" /> ); }