LockRuneCanvas.tsx

5.1 KB · raw

1import { onCleanup, onMount } from "solid-js";23const FONT_SIZE = 15;4const LINE_H = FONT_SIZE * 2.8;5const CHAR_W = FONT_SIZE * 1.7;6const DENSITY = 0.30;7const SPEEDS = [5, 7, 4];8const GLOW_COUNT = 2;9const FALLBACK_ACCENT = "#F49C70";1011function readAccentHex(): string {12  const raw = getComputedStyle(document.documentElement)13    .getPropertyValue("--peach-accent")14    .trim();15  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;16  return FALLBACK_ACCENT;17}1819export function LockRuneCanvas() {20  let canvas!: HTMLCanvasElement;2122  onMount(() => {23    const ctx = canvas.getContext("2d");24    if (!ctx) return;2526    let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];2728    function setAccent(hex: string) {29      accentRgb = [30        parseInt(hex.slice(1, 3), 16),31        parseInt(hex.slice(3, 5), 16),32        parseInt(hex.slice(5, 7), 16),33      ];34    }3536    setAccent(readAccentHex());3738    const accentObserver = new MutationObserver(() => {39      setAccent(readAccentHex());40    });41    accentObserver.observe(document.documentElement, {42      attributes: true,43      attributeFilter: ["style"],44    });4546    const W = canvas.offsetWidth || 360;47    const H = canvas.offsetHeight || 600;48    canvas.width = W;49    canvas.height = H;5051    const NUM_ROWS = Math.ceil(H / LINE_H) + 1;52    const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 2;5354    // Deterministic sparse grid — 30% of positions occupied55    const runeString: (string | null)[] = Array.from(56      { length: NUM_ROWS * CHARS_PER_ROW },57      (_, i) => {58        const hash = (i * 2654435761) >>> 0;59        if ((hash % 100) >= DENSITY * 100) return null;60        return String.fromCodePoint(0xe000 + (hash % 256));61      },62    );6364    const offsets = Array.from({ length: NUM_ROWS }, () =>65      Math.random() * CHARS_PER_ROW * CHAR_W,66    );6768    interface Glow {69      row: number;70      col: number;71      phase: number;72      speed: number;73      lifetime: number;74      age: number;75    }7677    function spawnGlow(): Glow {78      let row = 0, col = 0, attempts = 0;79      do {80        row = Math.floor(Math.random() * NUM_ROWS);81        col = Math.floor(Math.random() * CHARS_PER_ROW);82        attempts++;83      } while (!runeString[row * CHARS_PER_ROW + col] && attempts < 20);84      return {85        row, col,86        phase: Math.random(),87        speed: 0.3 + Math.random() * 0.3,88        lifetime: 3000 + Math.random() * 4000,89        age: 0,90      };91    }9293    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawnGlow);9495    let lastTime = performance.now();96    let raf = 0;97    let running = false;9899    const frame = (now: number) => {100      const dt = Math.min(now - lastTime, 64);101      lastTime = now;102103      ctx.clearRect(0, 0, W, H);104      ctx.fillStyle = "#1a1410";105      ctx.fillRect(0, 0, W, H);106      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;107      ctx.textBaseline = "alphabetic";108109      const glowMap = new Map<string, number>();110      for (const g of glows) {111        g.age += dt;112        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;113        const env = Math.sin((g.age / g.lifetime) * Math.PI);114        const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;115        glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60);116        if (g.age >= g.lifetime) Object.assign(g, spawnGlow());117      }118119      const [ar, ag, ab] = accentRgb;120121      for (let row = 0; row < NUM_ROWS; row++) {122        const speed = SPEEDS[row % SPEEDS.length];123        const totalW = CHARS_PER_ROW * CHAR_W;124        offsets[row] = (offsets[row] + (speed * dt) / 1000) % totalW;125        const y = row * LINE_H + FONT_SIZE + 12;126127        for (let col = 0; col < CHARS_PER_ROW; col++) {128          const char = runeString[row * CHARS_PER_ROW + col];129          if (!char) continue;130131          const x = col * CHAR_W - offsets[row] + CHAR_W;132          if (x < -CHAR_W || x > W + CHAR_W) continue;133134          const glowA = glowMap.get(`${row},${col}`) ?? 0;135136          if (glowA > 0.05) {137            ctx.save();138            ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`;139            ctx.shadowBlur = 12;140            ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`;141            ctx.fillText(char, x, y);142            ctx.restore();143          } else {144            ctx.fillStyle = "rgba(245,240,234,0.18)";145            ctx.fillText(char, x, y);146          }147        }148      }149150      raf = requestAnimationFrame(frame);151    };152153    const start = () => {154      if (running) return;155      running = true;156      lastTime = performance.now();157      raf = requestAnimationFrame(frame);158    };159160    const stop = () => {161      if (!running) return;162      running = false;163      cancelAnimationFrame(raf);164    };165166    const onVisibility = () => {167      if (document.hidden) stop();168      else start();169    };170    document.addEventListener("visibilitychange", onVisibility);171172    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);173174    onCleanup(() => {175      stop();176      accentObserver.disconnect();177      document.removeEventListener("visibilitychange", onVisibility);178    });179  });180181  return (182    <canvas183      ref={canvas}184      class="peach-lock-rune-canvas"185    />186  );187}