1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4 opacity?: number;5}67const FONT_SIZE = 14;8910const LINE_H = FONT_SIZE * 3.8;11const CHAR_W = FONT_SIZE * 2.2;121314const SPEEDS = [8, 11, 6];15const RUNE_LEN = 120;1617181920function buildRuneString(): string {21 const out: string[] = [];22 for (let i = 0; i < RUNE_LEN; i++) {23 out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));24 }25 return out.join("");26}2728export function RuneBackground(props: RuneBackgroundProps) {29 let canvas!: HTMLCanvasElement;30 let raf = 0;31 let running = false;3233 onMount(() => {34 const ctx = canvas.getContext("2d");35 if (!ctx) return;3637 const W = canvas.width;38 const H = canvas.height;39 const baseOpacity = props.opacity ?? 0.07;4041 const runes = buildRuneString();42 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;43 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;44 const TOTAL_W = runes.length * CHAR_W;4546 const offsets = Array.from({ length: NUM_ROWS }, () =>47 Math.random() * TOTAL_W,48 );4950 let last = performance.now();5152 const frame = (now: number) => {53 const dt = Math.min(now - last, 64);54 last = now;5556 ctx.clearRect(0, 0, W, H);57 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;58 ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;59 ctx.textBaseline = "alphabetic";6061 for (let row = 0; row < NUM_ROWS; row++) {62 const speed = SPEEDS[row % SPEEDS.length];63 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;6465 const y = row * LINE_H + FONT_SIZE + 8;66 const baseIdx = Math.floor(offsets[row] / CHAR_W);67 const xOffset = offsets[row] % CHAR_W;6869 for (let col = 0; col < CHARS_PER_ROW; col++) {70 const x = col * CHAR_W - xOffset;71 if (x < -CHAR_W || x > W + CHAR_W) continue;72 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;73 ctx.fillText(runes[idx]!, x, y);74 }75 }7677 raf = requestAnimationFrame(frame);78 };7980 const start = () => {81 if (running) return;82 running = true;83 last = performance.now();84 raf = requestAnimationFrame(frame);85 };8687 const stop = () => {88 if (!running) return;89 running = false;90 cancelAnimationFrame(raf);91 };9293 const onVisibility = () => {94 if (document.hidden) stop();95 else start();96 };97 document.addEventListener("visibilitychange", onVisibility);9899 const fonts = document.fonts;100 if (fonts) {101 fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);102 } else {103 start();104 }105106 onCleanup(() => {107 stop();108 document.removeEventListener("visibilitychange", onVisibility);109 });110 });111112 return (113 <canvas114 ref={(el) => { canvas = el; }}115 width={320}116 height={520}117 class="peach-rune-background"118 aria-hidden="true"119 />120 );121}