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)";11121314const 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 62 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 114 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}