RuneBackground.tsx

3.0 KB · raw

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;10// Three slow drift speeds (px/sec) — much quieter than the export canvas.11// Atmosphere, not focus.12const SPEEDS = [8, 11, 6];13const RUNE_LEN = 120;1415// Deterministic rune string drawn from the PeachScript PUA range16// (U+E000..U+E0FF). Same characters every render so the background17// has a consistent texture across mounts.18function 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}