1import { onCleanup, onMount } from "solid-js";2345678910111213interface RecoveryRuneBackgroundProps {14 15 accentColor?: string;16}171819const DEFAULT_ACCENT = "rgba(217, 104, 48, 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;45 let raf = 0;46 let running = false;47 48 const rowTime: number[] = [];4950 onMount(() => {51 const ctx = canvas.getContext("2d");52 if (!ctx) return;5354 const W = canvas.width;55 const H = canvas.height;56 57 const baseOpacity = 0.15;58 const accentColor = props.accentColor ?? DEFAULT_ACCENT;5960 const runes = buildRuneString();61 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;62 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;63 const TOTAL_W = runes.length * CHAR_W;6465 const offsets = Array.from({ length: NUM_ROWS }, () =>66 Math.random() * TOTAL_W,67 );68 69 const speedMults = Array.from({ length: NUM_ROWS }, () =>70 0.6 + Math.random() * 0.8,71 );7273 74 for (let i = 0; i < NUM_ROWS; i++) {75 rowTime[i] = Math.random() * 30000;76 }7778 let last = performance.now();7980 const frame = (now: number) => {81 const dt = Math.min(now - last, 64);82 last = now;8384 ctx.clearRect(0, 0, W, H);85 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;86 ctx.textBaseline = "alphabetic";8788 for (let row = 0; row < NUM_ROWS; row++) {89 rowTime[row] += dt;9091 92 const noise = smoothNoise(row, rowTime[row]);93 const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4);9495 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;9697 const y = row * LINE_H + FONT_SIZE + 8;98 const baseIdx = Math.floor(offsets[row] / CHAR_W);99 const xOffset = offsets[row] % CHAR_W;100101 for (let col = 0; col < CHARS_PER_ROW; col++) {102 const x = col * CHAR_W - xOffset;103 if (x < -CHAR_W || x > W + CHAR_W) continue;104 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;105106 107 const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row]));108 const charOpacity = baseOpacity * (0.6 + charNoise * 0.8);109 ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`);110 ctx.fillText(runes[idx]!, x, y);111 }112 }113114 raf = requestAnimationFrame(frame);115 };116117 const start = () => {118 if (running) return;119 running = true;120 last = performance.now();121 raf = requestAnimationFrame(frame);122 };123124 const stop = () => {125 if (!running) return;126 running = false;127 cancelAnimationFrame(raf);128 };129130 const onVisibility = () => {131 if (document.hidden) stop();132 else start();133 };134 document.addEventListener("visibilitychange", onVisibility);135 const fontReady = document.fonts?.load(`${FONT_SIZE}px PeachScript`);136 if (fontReady) fontReady.finally(start);137 else start();138139 onCleanup(() => {140 stop();141 document.removeEventListener("visibilitychange", onVisibility);142 });143 });144145 return (146 <canvas147 ref={canvas}148 width={320}149 height={520}150 class="peach-rune-background peach-rune-background--recovery"151 aria-hidden="true"152 />153 );154}