1import { onCleanup, onMount } from "solid-js";2345678910111213interface RecoveryRuneBackgroundProps {14 15 accentColor?: string;16}171819const DEFAULT_ACCENT = "rgba(229, 128, 92, 0.16)"; 202122const FONT_SIZE = 14;23const LINE_H = FONT_SIZE * 5.2; 24const CHAR_W = FONT_SIZE * 2.2;25const SPEEDS = [6, 9, 12, 7, 14]; 26const RUNE_LEN = 60; 2728function buildRuneString(): string {29 const out: string[] = [];30 for (let i = 0; i < RUNE_LEN; i++) {31 out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));32 }33 return out.join("");34}353637function smoothNoise(x: number, t: number): number {38 return (Math.sin(x * 0.07 + t * 0.0004) * 0.5 +39 Math.sin(x * 0.13 - t * 0.0006) * 0.3 +40 Math.sin(x * 0.19 + t * 0.0003) * 0.2);41}4243export function RecoveryRuneBackground(props: RecoveryRuneBackgroundProps) {44 let canvas: HTMLCanvasElement | undefined;45 let raf = 0;46 let running = false;47 48 const rowTime: number[] = [];4950 onMount(() => {51 if (!canvas) return;52 const ctx = canvas.getContext("2d");53 if (!ctx) return;5455 const W = canvas.width;56 const H = canvas.height;57 58 const baseOpacity = 0.15;59 const accentColor = props.accentColor ?? DEFAULT_ACCENT;6061 const runes = buildRuneString();62 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;63 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;64 const TOTAL_W = runes.length * CHAR_W;6566 const offsets = Array.from({ length: NUM_ROWS }, () =>67 Math.random() * TOTAL_W,68 );69 70 const speedMults = Array.from({ length: NUM_ROWS }, () =>71 0.6 + Math.random() * 0.8,72 );7374 75 for (let i = 0; i < NUM_ROWS; i++) {76 rowTime[i] = Math.random() * 30000;77 }7879 let last = performance.now();8081 const frame = (now: number) => {82 const dt = Math.min(now - last, 64);83 last = now;8485 ctx.clearRect(0, 0, W, H);86 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;87 ctx.textBaseline = "alphabetic";8889 for (let row = 0; row < NUM_ROWS; row++) {90 rowTime[row] += dt;9192 93 const noise = smoothNoise(row, rowTime[row]);94 const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4);9596 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;9798 const y = row * LINE_H + FONT_SIZE + 8;99 const baseIdx = Math.floor(offsets[row] / CHAR_W);100 const xOffset = offsets[row] % CHAR_W;101102 for (let col = 0; col < CHARS_PER_ROW; col++) {103 const x = col * CHAR_W - xOffset;104 if (x < -CHAR_W || x > W + CHAR_W) continue;105 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;106107 108 const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row]));109 const charOpacity = baseOpacity * (0.6 + charNoise * 0.8);110 ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`);111 ctx.fillText(runes[idx]!, x, y);112 }113 }114115 raf = requestAnimationFrame(frame);116 };117118 const start = () => {119 if (running) return;120 running = true;121 last = performance.now();122 raf = requestAnimationFrame(frame);123 };124125 const stop = () => {126 if (!running) return;127 running = false;128 cancelAnimationFrame(raf);129 };130131 const onVisibility = () => {132 if (document.hidden) stop();133 else start();134 };135 document.addEventListener("visibilitychange", onVisibility);136 const fontReady = document.fonts?.load(`${FONT_SIZE}px PeachScript`);137 if (fontReady) fontReady.finally(start);138 else start();139140 onCleanup(() => {141 stop();142 document.removeEventListener("visibilitychange", onVisibility);143 });144 });145146 return (147 <canvas148 ref={(el) => {149 canvas = el;150 }}151 width={320}152 height={520}153 class="peach-rune-background peach-rune-background--recovery"154 aria-hidden="true"155 />156 );157}