1import { onCleanup, onMount } from "solid-js";23interface RuneCanvasProps {4 runeString: string;5 width: number;6 height: number;7}89const FONT_SIZE = 17;10const LINE_HEIGHT = FONT_SIZE * 2.3;11const LETTER_WIDTH = FONT_SIZE * 1.35;12const LETTER_SPACING = FONT_SIZE * 0.18;13const SPEEDS = [18, 24, 14];14const FALLBACK_ACCENT = "#F49C70";1516function readAccentHex(): string {17 const raw = getComputedStyle(document.documentElement)18 .getPropertyValue("--peach-accent")19 .trim();20 if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;21 return FALLBACK_ACCENT;22}2324export function RuneCanvas(props: RuneCanvasProps) {25 let canvas!: HTMLCanvasElement;26 let raf = 0;27 let running = false;28 let accentHex = FALLBACK_ACCENT;29 let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];3031 function setAccent(hex: string) {32 accentHex = hex;33 accentRgb = [34 parseInt(hex.slice(1, 3), 16),35 parseInt(hex.slice(3, 5), 16),36 parseInt(hex.slice(5, 7), 16),37 ];38 }3940 onMount(() => {41 const ctx = canvas.getContext("2d");42 if (!ctx) return;4344 setAccent(readAccentHex());4546 const accentObserver = new MutationObserver(() => {47 const next = readAccentHex();48 if (next !== accentHex) setAccent(next);49 });50 accentObserver.observe(document.documentElement, {51 attributes: true,52 attributeFilter: ["style"],53 });5455 const W = props.width;56 const H = props.height;5758 const SYMBOLS_PER_ROW =59 Math.ceil(W / (LETTER_WIDTH + LETTER_SPACING)) + 4;60 const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;61 const TOTAL_VISIBLE = NUM_ROWS * SYMBOLS_PER_ROW;62 const GLOW_COUNT = Math.min(8, Math.max(3, Math.floor(TOTAL_VISIBLE * 0.03)));6364 interface Glow {65 row: number;66 col: number;67 phase: number;68 speed: number;69 lifetime: number;70 age: number;71 }7273 const spawn = (): Glow => ({74 row: Math.floor(Math.random() * NUM_ROWS),75 col: Math.floor(Math.random() * SYMBOLS_PER_ROW),76 phase: Math.random(),77 speed: 0.4 + Math.random() * 0.4,78 lifetime: 2000 + Math.random() * 3000,79 age: 0,80 });8182 const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);8384 const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>85 Math.random() * props.runeString.length * (LETTER_WIDTH + LETTER_SPACING),86 );8788 const TOTAL_STRING_WIDTH =89 props.runeString.length * (LETTER_WIDTH + LETTER_SPACING) || 1;9091 let lastTime = performance.now();9293 const frame = (now: number) => {94 const dt = Math.min(now - lastTime, 64); 95 lastTime = now;9697 ctx.clearRect(0, 0, W, H);98 ctx.fillStyle = "#1a1410";99 ctx.fillRect(0, 0, W, H);100 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;101 ctx.textBaseline = "alphabetic";102103 const glowMap = new Map<string, number>();104 for (const g of glows) {105 g.age += dt;106 g.phase = (g.phase + (g.speed * dt) / 1000) % 1;107 const lifetimeFraction = g.age / g.lifetime;108 const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);109 const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;110 const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;111 glowMap.set(`${g.row},${g.col}`, alpha);112 if (g.age >= g.lifetime) Object.assign(g, spawn());113 }114115 const [ar, ag, ab] = accentRgb;116 const runes = props.runeString;117 const runeLen = runes.length;118 if (runeLen === 0) {119 raf = requestAnimationFrame(frame);120 return;121 }122123 for (let row = 0; row < NUM_ROWS; row++) {124 const speed = SPEEDS[row % SPEEDS.length];125 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_WIDTH;126 const y = row * LINE_HEIGHT + FONT_SIZE + 12;127128 for (let col = 0; col < SYMBOLS_PER_ROW; col++) {129 const x =130 col * (LETTER_WIDTH + LETTER_SPACING) -131 (offsets[row] % (LETTER_WIDTH + LETTER_SPACING));132 if (x < -LETTER_WIDTH || x > W + LETTER_WIDTH) continue;133134 const baseIdx = Math.floor(offsets[row] / (LETTER_WIDTH + LETTER_SPACING));135 const strIdx = (((baseIdx + col) % runeLen) + runeLen) % runeLen;136 const char = runes[strIdx];137138 const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;139140 if (glowAlpha > 0.01) {141 ctx.save();142 ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;143 ctx.shadowBlur = 10;144 ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;145 ctx.fillText(char, x, y);146 ctx.restore();147 } else {148 ctx.fillStyle = "rgba(245, 240, 234, 0.82)";149 ctx.fillText(char, x, y);150 }151 }152 }153154 raf = requestAnimationFrame(frame);155 };156157 const start = () => {158 if (running) return;159 running = true;160 lastTime = performance.now();161 raf = requestAnimationFrame(frame);162 };163164 const stop = () => {165 if (!running) return;166 running = false;167 cancelAnimationFrame(raf);168 };169170 const onVisibility = () => {171 if (document.hidden) stop();172 else start();173 };174 document.addEventListener("visibilitychange", onVisibility);175176 177 document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);178179 onCleanup(() => {180 stop();181 accentObserver.disconnect();182 document.removeEventListener("visibilitychange", onVisibility);183 });184 });185186 return (187 <canvas188 ref={canvas}189 width={props.width}190 height={props.height}191 class="peach-rune-canvas"192 style={{193 display: "block",194 width: `${props.width}px`,195 height: `${props.height}px`,196 }}197 />198 );199}