LockRuneCanvas.tsx

5.2 KB · raw

1import { onCleanup, onMount } from "solid-js";23const FONT_SIZE = 15;4const LINE_HEIGHT = FONT_SIZE * 2.3;5const LETTER_WIDTH = FONT_SIZE * 1.35;6const LETTER_SPACING = FONT_SIZE * 0.18;7const SPEEDS = [5, 7, 4];8const GLOW_COUNT = 2;9const FALLBACK_ACCENT = "#F49C70";1011// 30% of string positions are runes; the rest are null (skipped in draw).12// Same cyclic-scroll mechanic as RuneCanvas — no isolated-character weirdness.13const RUNE_STRING_LEN = 80;14const DENSITY = 0.30;15const RUNE_STRING: (string | null)[] = Array.from(16  { length: RUNE_STRING_LEN },17  (_, i) => {18    const hash = (i * 2654435761) >>> 0;19    if ((hash % 100) >= DENSITY * 100) return null;20    return String.fromCodePoint(0xe000 + (hash % 256));21  },22);2324function readAccentHex(): string {25  const raw = getComputedStyle(document.documentElement)26    .getPropertyValue("--peach-accent")27    .trim();28  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;29  return FALLBACK_ACCENT;30}3132export function LockRuneCanvas() {33  let canvas!: HTMLCanvasElement;3435  onMount(() => {36    const ctx = canvas.getContext("2d");37    if (!ctx) return;3839    let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];4041    function setAccent(hex: string) {42      accentRgb = [43        parseInt(hex.slice(1, 3), 16),44        parseInt(hex.slice(3, 5), 16),45        parseInt(hex.slice(5, 7), 16),46      ];47    }4849    setAccent(readAccentHex());5051    const accentObserver = new MutationObserver(() => setAccent(readAccentHex()));52    accentObserver.observe(document.documentElement, {53      attributes: true,54      attributeFilter: ["style"],55    });5657    const W = canvas.offsetWidth || 360;58    const H = canvas.offsetHeight || 600;59    canvas.width = W;60    canvas.height = H;6162    const CELL_W = LETTER_WIDTH + LETTER_SPACING;63    const SYMBOLS_PER_ROW = Math.ceil(W / CELL_W) + 4;64    const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;65    const TOTAL_STRING_W = RUNE_STRING_LEN * CELL_W || 1;6667    interface Glow {68      row: number;69      col: number;70      phase: number;71      speed: number;72      lifetime: number;73      age: number;74    }7576    const spawn = (): Glow => ({77      row: Math.floor(Math.random() * NUM_ROWS),78      col: Math.floor(Math.random() * SYMBOLS_PER_ROW),79      phase: Math.random(),80      speed: 0.3 + Math.random() * 0.3,81      lifetime: 3000 + Math.random() * 4000,82      age: 0,83    });8485    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);8687    const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>88      Math.random() * TOTAL_STRING_W,89    );9091    let lastTime = performance.now();92    let raf = 0;93    let running = false;9495    const frame = (now: number) => {96      const dt = Math.min(now - lastTime, 64);97      lastTime = now;9899      ctx.clearRect(0, 0, W, H);100      ctx.fillStyle = "#1a1410";101      ctx.fillRect(0, 0, W, H);102      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;103      ctx.textBaseline = "alphabetic";104105      const glowMap = new Map<string, number>();106      for (const g of glows) {107        g.age += dt;108        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;109        const env = Math.sin((g.age / g.lifetime) * Math.PI);110        const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;111        glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60);112        if (g.age >= g.lifetime) Object.assign(g, spawn());113      }114115      const [ar, ag, ab] = accentRgb;116117      for (let row = 0; row < NUM_ROWS; row++) {118        const speed = SPEEDS[row % SPEEDS.length];119        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_W;120        const y = row * LINE_HEIGHT + FONT_SIZE + 12;121122        for (let col = 0; col < SYMBOLS_PER_ROW; col++) {123          const x = col * CELL_W - (offsets[row] % CELL_W);124          if (x < -CELL_W || x > W + CELL_W) continue;125126          const baseIdx = Math.floor(offsets[row] / CELL_W);127          const strIdx = (((baseIdx + col) % RUNE_STRING_LEN) + RUNE_STRING_LEN) % RUNE_STRING_LEN;128          const char = RUNE_STRING[strIdx];129          if (!char) continue; // sparse position — blank130131          const glowA = glowMap.get(`${row},${col}`) ?? 0;132133          if (glowA > 0.05) {134            ctx.save();135            ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`;136            ctx.shadowBlur = 12;137            ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`;138            ctx.fillText(char, x, y);139            ctx.restore();140          } else {141            ctx.fillStyle = "rgba(245,240,234,0.18)";142            ctx.fillText(char, x, y);143          }144        }145      }146147      raf = requestAnimationFrame(frame);148    };149150    const start = () => {151      if (running) return;152      running = true;153      lastTime = performance.now();154      raf = requestAnimationFrame(frame);155    };156157    const stop = () => {158      if (!running) return;159      running = false;160      cancelAnimationFrame(raf);161    };162163    const onVisibility = () => {164      if (document.hidden) stop();165      else start();166    };167    document.addEventListener("visibilitychange", onVisibility);168169    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);170171    onCleanup(() => {172      stop();173      accentObserver.disconnect();174      document.removeEventListener("visibilitychange", onVisibility);175    });176  });177178  return <canvas ref={canvas} class="peach-lock-rune-canvas" />;179}