LockRuneCanvas.tsx

5.6 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";10const FALLBACK_RUNE_IDLE = "rgba(245,240,234,0.16)";1112// 8% of string positions are runes; the rest are null (skipped).13// Cyclic-scroll mechanic identical to RuneCanvas — smooth, not jumpy.14const RUNE_STRING_LEN = 80;15const DENSITY = 0.08;16const RUNE_STRING: (string | null)[] = Array.from(17  { length: RUNE_STRING_LEN },18  (_, i) => {19    const hash = (i * 2654435761) >>> 0;20    if ((hash % 100) >= DENSITY * 100) return null;21    return String.fromCodePoint(0xe000 + (hash % 256));22  },23);2425function readAccentHex(): string {26  const raw = getComputedStyle(document.documentElement)27    .getPropertyValue("--peach-accent")28    .trim();29  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;30  return FALLBACK_ACCENT;31}3233function readRuneIdleColor(): string {34  return (35    getComputedStyle(document.documentElement)36      .getPropertyValue("--peach-rune-idle")37      .trim() || FALLBACK_RUNE_IDLE38  );39}4041export function LockRuneCanvas() {42  let canvas!: HTMLCanvasElement;4344  onMount(() => {45    const ctx = canvas.getContext("2d");46    if (!ctx) return;4748    let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];49    let runeIdleColor = readRuneIdleColor();5051    function setAccent(hex: string) {52      accentRgb = [53        parseInt(hex.slice(1, 3), 16),54        parseInt(hex.slice(3, 5), 16),55        parseInt(hex.slice(5, 7), 16),56      ];57    }5859    setAccent(readAccentHex());6061    // Watch both style (accent color) and class (light/dark theme) changes62    const observer = new MutationObserver(() => {63      setAccent(readAccentHex());64      runeIdleColor = readRuneIdleColor();65    });66    observer.observe(document.documentElement, {67      attributes: true,68      attributeFilter: ["style", "class"],69    });7071    const W = canvas.offsetWidth || 360;72    const H = canvas.offsetHeight || 600;73    canvas.width = W;74    canvas.height = H;7576    const CELL_W = LETTER_WIDTH + LETTER_SPACING;77    const SYMBOLS_PER_ROW = Math.ceil(W / CELL_W) + 4;78    const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;79    const TOTAL_STRING_W = RUNE_STRING_LEN * CELL_W || 1;8081    interface Glow {82      row: number;83      col: number;84      phase: number;85      speed: number;86      lifetime: number;87      age: number;88    }8990    const spawn = (): Glow => ({91      row: Math.floor(Math.random() * NUM_ROWS),92      col: Math.floor(Math.random() * SYMBOLS_PER_ROW),93      phase: Math.random(),94      speed: 0.3 + Math.random() * 0.3,95      lifetime: 3000 + Math.random() * 4000,96      age: 0,97    });9899    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);100101    const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>102      Math.random() * TOTAL_STRING_W,103    );104105    let lastTime = performance.now();106    let raf = 0;107    let running = false;108109    const frame = (now: number) => {110      const dt = Math.min(now - lastTime, 64);111      lastTime = now;112113      // Transparent canvas — CSS handles the lock screen background per theme114      ctx.clearRect(0, 0, W, H);115      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;116      ctx.textBaseline = "alphabetic";117118      const glowMap = new Map<string, number>();119      for (const g of glows) {120        g.age += dt;121        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;122        const env = Math.sin((g.age / g.lifetime) * Math.PI);123        const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;124        glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60);125        if (g.age >= g.lifetime) Object.assign(g, spawn());126      }127128      const [ar, ag, ab] = accentRgb;129130      for (let row = 0; row < NUM_ROWS; row++) {131        const speed = SPEEDS[row % SPEEDS.length];132        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_W;133        const y = row * LINE_HEIGHT + FONT_SIZE + 12;134135        for (let col = 0; col < SYMBOLS_PER_ROW; col++) {136          const x = col * CELL_W - (offsets[row] % CELL_W);137          if (x < -CELL_W || x > W + CELL_W) continue;138139          const baseIdx = Math.floor(offsets[row] / CELL_W);140          const strIdx =141            (((baseIdx + col) % RUNE_STRING_LEN) + RUNE_STRING_LEN) %142            RUNE_STRING_LEN;143          const char = RUNE_STRING[strIdx];144          if (!char) continue;145146          const glowA = glowMap.get(`${row},${col}`) ?? 0;147148          if (glowA > 0.05) {149            ctx.save();150            ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`;151            ctx.shadowBlur = 12;152            ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`;153            ctx.fillText(char, x, y);154            ctx.restore();155          } else {156            ctx.fillStyle = runeIdleColor;157            ctx.fillText(char, x, y);158          }159        }160      }161162      raf = requestAnimationFrame(frame);163    };164165    const start = () => {166      if (running) return;167      running = true;168      lastTime = performance.now();169      raf = requestAnimationFrame(frame);170    };171172    const stop = () => {173      if (!running) return;174      running = false;175      cancelAnimationFrame(raf);176    };177178    const onVisibility = () => {179      if (document.hidden) stop();180      else start();181    };182    document.addEventListener("visibilitychange", onVisibility);183184    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);185186    onCleanup(() => {187      stop();188      observer.disconnect();189      document.removeEventListener("visibilitychange", onVisibility);190    });191  });192193  return <canvas ref={canvas} class="peach-lock-rune-canvas" />;194}