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";10111213const 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; 130131 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}