1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4 opacity?: number;5}67const FONT_SIZE = 14;8910const LINE_H = FONT_SIZE * 2.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 if (!canvas) return;35 const ctx = canvas.getContext("2d");36 if (!ctx) return;3738 const W = canvas.width;39 const H = canvas.height;40 const baseOpacity = props.opacity ?? 0.07;4142 const runes = buildRuneString();43 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;44 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;45 const TOTAL_W = runes.length * CHAR_W;4647 const offsets = Array.from({ length: NUM_ROWS }, () =>48 Math.random() * TOTAL_W,49 );5051 let last = performance.now();5253 const frame = (now: number) => {54 const dt = Math.min(now - last, 64);55 last = now;5657 ctx.clearRect(0, 0, W, H);58 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;59 ctx.textBaseline = "alphabetic";60 ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;6162 for (let row = 0; row < NUM_ROWS; row++) {63 const speed = SPEEDS[row % SPEEDS.length];64 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;6566 const y = row * LINE_H + FONT_SIZE + 8;67 const baseIdx = Math.floor(offsets[row] / CHAR_W);68 const xOffset = offsets[row] % CHAR_W;6970 for (let col = 0; col < CHARS_PER_ROW; col++) {71 const x = col * CHAR_W - xOffset;72 if (x < -CHAR_W || x > W + CHAR_W) continue;73 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;74 ctx.fillText(runes[idx]!, x, y);75 }76 }7778 raf = requestAnimationFrame(frame);79 };8081 const start = () => {82 if (running) return;83 running = true;84 last = performance.now();85 raf = requestAnimationFrame(frame);86 };8788 const stop = () => {89 if (!running) return;90 running = false;91 cancelAnimationFrame(raf);92 };9394 const onVisibility = () => {95 if (document.hidden) stop();96 else start();97 };98 document.addEventListener("visibilitychange", onVisibility);99100 const fonts = document.fonts;101 if (fonts) {102 fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);103 } else {104 start();105 }106107 onCleanup(() => {108 stop();109 document.removeEventListener("visibilitychange", onVisibility);110 });111 });112113 return (114 <canvas115 ref={(el) => { canvas = el; }}116 width={320}117 height={520}118 class="peach-rune-background"119 aria-hidden="true"120 />121 );122}