1import { onCleanup, onMount } from "solid-js";23const FONT_SIZE = 14;4const PARTICLE_COUNT = 5;5const GRAVITY = 14; 6const MAX_SPEED = 28; 7const ABSORB_DIST = 18; 8const FADE_OUT_DIST = 80; 9const FADE_IN_MS = 1400; 10const 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 52 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 61 62 const dx = tx - x, dy = ty - y;63 const len = Math.hypot(dx, dy) || 1;64 const speed = 1.5 + Math.random() * 3;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 107 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 124 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 151 if (dist < ABSORB_DIST) {152 Object.assign(p, spawnParticle(tx, ty, W, H));153 continue;154 }155156 157 const gForce = GRAVITY * (200 / dist);158 p.vx += (dx / dist) * gForce * dt_s;159 p.vy += (dy / dist) * gForce * dt_s;160161 162 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 172 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}