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 55 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}