1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4 opacity?: number;5}67const FONT_SIZE = 14;8const LINE_H = FONT_SIZE * 2.4;9const CHAR_W = FONT_SIZE * 1.4;101112const SPEEDS = [8, 11, 6];13const RUNE_LEN = 120;1415161718function buildRuneString(): string {19 const out: string[] = [];20 for (let i = 0; i < RUNE_LEN; i++) {21 out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));22 }23 return out.join("");24}2526export function RuneBackground(props: RuneBackgroundProps) {27 let canvas!: HTMLCanvasElement;28 let raf = 0;29 let running = false;3031 onMount(() => {32 const ctx = canvas.getContext("2d");33 if (!ctx) return;3435 const W = canvas.width;36 const H = canvas.height;37 const baseOpacity = props.opacity ?? 0.07;3839 const runes = buildRuneString();40 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;41 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;42 const TOTAL_W = runes.length * CHAR_W;4344 const offsets = Array.from({ length: NUM_ROWS }, () =>45 Math.random() * TOTAL_W,46 );4748 let last = performance.now();4950 const frame = (now: number) => {51 const dt = Math.min(now - last, 64);52 last = now;5354 ctx.clearRect(0, 0, W, H);55 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;56 ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;57 ctx.textBaseline = "alphabetic";5859 for (let row = 0; row < NUM_ROWS; row++) {60 const speed = SPEEDS[row % SPEEDS.length];61 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;6263 const y = row * LINE_H + FONT_SIZE + 8;64 const baseIdx = Math.floor(offsets[row] / CHAR_W);65 const xOffset = offsets[row] % CHAR_W;6667 for (let col = 0; col < CHARS_PER_ROW; col++) {68 const x = col * CHAR_W - xOffset;69 if (x < -CHAR_W || x > W + CHAR_W) continue;70 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;71 ctx.fillText(runes[idx]!, x, y);72 }73 }7475 raf = requestAnimationFrame(frame);76 };7778 const start = () => {79 if (running) return;80 running = true;81 last = performance.now();82 raf = requestAnimationFrame(frame);83 };8485 const stop = () => {86 if (!running) return;87 running = false;88 cancelAnimationFrame(raf);89 };9091 const onVisibility = () => {92 if (document.hidden) stop();93 else start();94 };95 document.addEventListener("visibilitychange", onVisibility);9697 document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);9899 onCleanup(() => {100 stop();101 document.removeEventListener("visibilitychange", onVisibility);102 });103 });104105 return (106 <canvas107 ref={canvas}108 width={320}109 height={520}110 class="peach-rune-background"111 aria-hidden="true"112 />113 );114}