RuneCanvas.tsx

5.7 KB · raw

1import { onCleanup, onMount } from "solid-js";23interface RuneCanvasProps {4  runeString: string;5  width: number;6  height: number;7}89const FONT_SIZE = 17;10const LINE_HEIGHT = FONT_SIZE * 2.3;11const LETTER_WIDTH = FONT_SIZE * 1.35;12const LETTER_SPACING = FONT_SIZE * 0.18;13const SPEEDS = [18, 24, 14];14const FALLBACK_ACCENT = "#F49C70";1516function readAccentHex(): string {17  const raw = getComputedStyle(document.documentElement)18    .getPropertyValue("--peach-accent")19    .trim();20  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;21  return FALLBACK_ACCENT;22}2324export function RuneCanvas(props: RuneCanvasProps) {25  let canvas!: HTMLCanvasElement;26  let raf = 0;27  let running = false;28  let accentHex = FALLBACK_ACCENT;29  let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];3031  function setAccent(hex: string) {32    accentHex = hex;33    accentRgb = [34      parseInt(hex.slice(1, 3), 16),35      parseInt(hex.slice(3, 5), 16),36      parseInt(hex.slice(5, 7), 16),37    ];38  }3940  onMount(() => {41    const ctx = canvas.getContext("2d");42    if (!ctx) return;4344    setAccent(readAccentHex());4546    const accentObserver = new MutationObserver(() => {47      const next = readAccentHex();48      if (next !== accentHex) setAccent(next);49    });50    accentObserver.observe(document.documentElement, {51      attributes: true,52      attributeFilter: ["style"],53    });5455    const W = props.width;56    const H = props.height;5758    const SYMBOLS_PER_ROW =59      Math.ceil(W / (LETTER_WIDTH + LETTER_SPACING)) + 4;60    const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;61    const TOTAL_VISIBLE = NUM_ROWS * SYMBOLS_PER_ROW;62    const GLOW_COUNT = Math.min(8, Math.max(3, Math.floor(TOTAL_VISIBLE * 0.03)));6364    interface Glow {65      row: number;66      col: number;67      phase: number;68      speed: number;69      lifetime: number;70      age: number;71    }7273    const spawn = (): Glow => ({74      row: Math.floor(Math.random() * NUM_ROWS),75      col: Math.floor(Math.random() * SYMBOLS_PER_ROW),76      phase: Math.random(),77      speed: 0.4 + Math.random() * 0.4,78      lifetime: 2000 + Math.random() * 3000,79      age: 0,80    });8182    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);8384    const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>85      Math.random() * props.runeString.length * (LETTER_WIDTH + LETTER_SPACING),86    );8788    const TOTAL_STRING_WIDTH =89      props.runeString.length * (LETTER_WIDTH + LETTER_SPACING) || 1;9091    let lastTime = performance.now();9293    const frame = (now: number) => {94      const dt = Math.min(now - lastTime, 64); // clamp huge dt after pause95      lastTime = now;9697      ctx.clearRect(0, 0, W, H);98      ctx.fillStyle = "#1a1410";99      ctx.fillRect(0, 0, W, H);100      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;101      ctx.textBaseline = "alphabetic";102103      const glowMap = new Map<string, number>();104      for (const g of glows) {105        g.age += dt;106        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;107        const lifetimeFraction = g.age / g.lifetime;108        const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);109        const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;110        const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;111        glowMap.set(`${g.row},${g.col}`, alpha);112        if (g.age >= g.lifetime) Object.assign(g, spawn());113      }114115      const [ar, ag, ab] = accentRgb;116      const runes = props.runeString;117      const runeLen = runes.length;118      if (runeLen === 0) {119        raf = requestAnimationFrame(frame);120        return;121      }122123      for (let row = 0; row < NUM_ROWS; row++) {124        const speed = SPEEDS[row % SPEEDS.length];125        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_WIDTH;126        const y = row * LINE_HEIGHT + FONT_SIZE + 12;127128        for (let col = 0; col < SYMBOLS_PER_ROW; col++) {129          const x =130            col * (LETTER_WIDTH + LETTER_SPACING) -131            (offsets[row] % (LETTER_WIDTH + LETTER_SPACING));132          if (x < -LETTER_WIDTH || x > W + LETTER_WIDTH) continue;133134          const baseIdx = Math.floor(offsets[row] / (LETTER_WIDTH + LETTER_SPACING));135          const strIdx = (((baseIdx + col) % runeLen) + runeLen) % runeLen;136          const char = runes[strIdx];137138          const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;139140          if (glowAlpha > 0.01) {141            ctx.save();142            ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;143            ctx.shadowBlur = 10;144            ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;145            ctx.fillText(char, x, y);146            ctx.restore();147          } else {148            ctx.fillStyle = "rgba(245, 240, 234, 0.82)";149            ctx.fillText(char, x, y);150          }151        }152      }153154      raf = requestAnimationFrame(frame);155    };156157    const start = () => {158      if (running) return;159      running = true;160      lastTime = performance.now();161      raf = requestAnimationFrame(frame);162    };163164    const stop = () => {165      if (!running) return;166      running = false;167      cancelAnimationFrame(raf);168    };169170    const onVisibility = () => {171      if (document.hidden) stop();172      else start();173    };174    document.addEventListener("visibilitychange", onVisibility);175176    // Font-load guard: render boxes if font isn't ready, so wait for it.177    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);178179    onCleanup(() => {180      stop();181      accentObserver.disconnect();182      document.removeEventListener("visibilitychange", onVisibility);183    });184  });185186  return (187    <canvas188      ref={canvas}189      width={props.width}190      height={props.height}191      class="peach-rune-canvas"192      style={{193        display: "block",194        width: `${props.width}px`,195        height: `${props.height}px`,196      }}197    />198  );199}