RuneBackground.tsx

3.3 KB · raw

1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4  opacity?: number;5}67const FONT_SIZE = 14;8// Tighter row spacing — denser field of runes for a richer texture that9// matches the Android RuneGridBackground's visual density.10const LINE_H = FONT_SIZE * 2.8;11const CHAR_W = FONT_SIZE * 2.2;12// Three slow drift speeds (px/sec) — much quieter than the export canvas.13// Atmosphere, not focus.14const SPEEDS = [8, 11, 6];15const RUNE_LEN = 120;1617// Deterministic rune string drawn from the PeachScript PUA range18// (U+E000..U+E0FF). Same characters every render so the background19// has a consistent texture across mounts.20function 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}