RuneBackground.tsx

3.3 KB · raw

1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4  opacity?: number;5}67const FONT_SIZE = 14;8// Wider row + column spacing so the visible rune count is ~40% of the9// original spec — quieter, more deliberate drift. Opacity stays the same.10const LINE_H = FONT_SIZE * 3.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    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={canvas}115      width={320}116      height={520}117      class="peach-rune-background"118      aria-hidden="true"119    />120  );121}