LockRuneCanvas.tsx

6.4 KB · raw

1import { onCleanup, onMount } from "solid-js";23const FONT_SIZE = 14;4const PARTICLE_COUNT = 5;5const GRAVITY = 55;       // px/s² pull toward target, scales with 1/dist6const MAX_SPEED = 90;     // px/s cap so nothing shoots through the input7const ABSORB_DIST = 18;   // px from target center — particle disappears8const FADE_OUT_DIST = 60; // px — fade starts here9const FADE_IN_MS = 600;   // fade-in duration on spawn10const FALLBACK_ACCENT = "#F49C70";11const FALLBACK_RUNE_IDLE_RGB = [245, 240, 234] as const;12const FALLBACK_RUNE_IDLE_LIGHT_RGB = [34, 24, 9] as const;1314const RUNE_CHARS = Array.from(15  { length: 80 },16  (_, i) => String.fromCodePoint(0xe000 + i),17);1819function randomRune(): string {20  return RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)];21}2223function readAccentHex(): string {24  const raw = getComputedStyle(document.documentElement)25    .getPropertyValue("--peach-accent")26    .trim();27  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;28  return FALLBACK_ACCENT;29}3031function isLightTheme(): boolean {32  return document.documentElement.classList.contains("light");33}3435interface Particle {36  x: number;37  y: number;38  vx: number;39  vy: number;40  char: string;41  age: number;42  glow: boolean;43}4445function spawnParticle(46  tx: number,47  ty: number,48  W: number,49  H: number,50): Particle {51  // Spawn anywhere in the canvas that isn't already near the target.52  // Bias toward the upper region since the input is near the bottom.53  let x: number, y: number, attempts = 0;54  do {55    x = 20 + Math.random() * (W - 40);56    y = 10 + Math.random() * (H * 0.80);57    attempts++;58  } while (Math.hypot(x - tx, y - ty) < 80 && attempts < 12);5960  // Initial velocity: slow drift with a slight clockwise or counterclockwise61  // curl so particles trace curves rather than straight lines.62  const dx = tx - x, dy = ty - y;63  const len = Math.hypot(dx, dy) || 1;64  const speed = 4 + Math.random() * 8;65  const curl = (Math.random() < 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.4);66  const vx = (dx / len + (-dy / len) * curl) * speed;67  const vy = (dy / len + (dx / len) * curl) * speed;6869  return { x, y, vx, vy, char: randomRune(), age: 0, glow: Math.random() < 0.18 };70}7172export function LockRuneCanvas() {73  let canvas!: HTMLCanvasElement;7475  onMount(() => {76    const ctx = canvas.getContext("2d");77    if (!ctx) return;7879    let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];80    let light = isLightTheme();8182    function setAccent(hex: string) {83      accentRgb = [84        parseInt(hex.slice(1, 3), 16),85        parseInt(hex.slice(3, 5), 16),86        parseInt(hex.slice(5, 7), 16),87      ];88    }8990    setAccent(readAccentHex());9192    const observer = new MutationObserver(() => {93      setAccent(readAccentHex());94      light = isLightTheme();95    });96    observer.observe(document.documentElement, {97      attributes: true,98      attributeFilter: ["style", "class"],99    });100101    const W = canvas.offsetWidth || 320;102    const H = canvas.offsetHeight || 520;103    canvas.width = W;104    canvas.height = H;105106    // Target: center of the password input, measured relative to the canvas.107    // Default to center-bottom if input not found.108    let tx = W / 2;109    let ty = H * 0.80;110    const inputEl = canvas.closest(".lock-screen")111      ?.querySelector(".lock-password-input") as HTMLElement | null;112    if (inputEl) {113      const cr = canvas.getBoundingClientRect();114      const ir = inputEl.getBoundingClientRect();115      tx = ir.left + ir.width / 2 - cr.left;116      ty = ir.top + ir.height / 2 - cr.top;117    }118119    const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () =>120      spawnParticle(tx, ty, W, H),121    );122123    // Stagger ages so they don't all fade in simultaneously124    for (const p of particles) p.age = Math.random() * FADE_IN_MS;125126    let lastTime = performance.now();127    let raf = 0;128    let running = false;129130    const frame = (now: number) => {131      const dt = Math.min(now - lastTime, 64);132      lastTime = now;133      const dt_s = dt / 1000;134135      ctx.clearRect(0, 0, W, H);136      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;137      ctx.textBaseline = "middle";138139      const idleRgb = light ? FALLBACK_RUNE_IDLE_LIGHT_RGB : FALLBACK_RUNE_IDLE_RGB;140      const [ar, ag, ab] = accentRgb;141      const [ir, ig, ib] = idleRgb;142143      for (const p of particles) {144        p.age += dt;145146        const dx = tx - p.x;147        const dy = ty - p.y;148        const dist = Math.hypot(dx, dy) || 1;149150        // Absorbed — respawn151        if (dist < ABSORB_DIST) {152          Object.assign(p, spawnParticle(tx, ty, W, H));153          continue;154        }155156        // Gravitational pull — stronger as particle gets closer157        const gForce = GRAVITY * (200 / dist);158        p.vx += (dx / dist) * gForce * dt_s;159        p.vy += (dy / dist) * gForce * dt_s;160161        // Speed cap162        const spd = Math.hypot(p.vx, p.vy);163        if (spd > MAX_SPEED) {164          p.vx = (p.vx / spd) * MAX_SPEED;165          p.vy = (p.vy / spd) * MAX_SPEED;166        }167168        p.x += p.vx * dt_s;169        p.y += p.vy * dt_s;170171        // Opacity envelope: fade in over FADE_IN_MS, fade out near target172        const fadeIn = Math.min(1, p.age / FADE_IN_MS);173        const fadeOut = Math.min(1, (dist - ABSORB_DIST) / FADE_OUT_DIST);174        const alpha = 0.14 * fadeIn * fadeOut;175        if (alpha < 0.005) continue;176177        if (p.glow) {178          ctx.save();179          ctx.shadowColor = `rgba(${ar},${ag},${ab},${alpha * 3.5})`;180          ctx.shadowBlur = 10;181          ctx.fillStyle = `rgba(${ar},${ag},${ab},${alpha * 2.5})`;182          ctx.fillText(p.char, p.x, p.y);183          ctx.restore();184        } else {185          ctx.fillStyle = `rgba(${ir},${ig},${ib},${alpha})`;186          ctx.fillText(p.char, p.x, p.y);187        }188      }189190      raf = requestAnimationFrame(frame);191    };192193    const start = () => {194      if (running) return;195      running = true;196      lastTime = performance.now();197      raf = requestAnimationFrame(frame);198    };199200    const stop = () => {201      if (!running) return;202      running = false;203      cancelAnimationFrame(raf);204    };205206    const onVisibility = () => {207      if (document.hidden) stop();208      else start();209    };210    document.addEventListener("visibilitychange", onVisibility);211212    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);213214    onCleanup(() => {215      stop();216      observer.disconnect();217      document.removeEventListener("visibilitychange", onVisibility);218    });219  });220221  return <canvas ref={canvas} class="peach-lock-rune-canvas" />;222}