From ab91c86f81afa2166700ccc7dd9bde70a8d5ce7b Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:28:26 +1000 Subject: [PATCH] feat(backgrounds): add matrix-rain background --- src/components/ui/backgrounds/matrix-rain.tsx | 128 ++++++++++++++++++ src/components/ui/backgrounds/registry.ts | 40 ++++++ src/components/ui/backgrounds/types.ts | 1 + src/locales/en.json | 3 + src/locales/fa.json | 3 + src/locales/ru.json | 3 + src/locales/zh.json | 3 + src/utils/backgroundConfig.ts | 1 + 8 files changed, 182 insertions(+) create mode 100644 src/components/ui/backgrounds/matrix-rain.tsx diff --git a/src/components/ui/backgrounds/matrix-rain.tsx b/src/components/ui/backgrounds/matrix-rain.tsx new file mode 100644 index 0000000..d83854e --- /dev/null +++ b/src/components/ui/backgrounds/matrix-rain.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber, safeSelect } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +const FONT_SIZE = 16; +const BASE_STEP_MS = 80; + +const CHARSETS: Record = { + katakana: + 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンガギグゲゴザジズゼゾダヂヅデドバビブベボ', + latin: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + binary: '01', +}; + +interface MatrixState { + ctx: CanvasRenderingContext2D; + drops: number[]; + active: boolean[]; + acc: number; + w: number; + h: number; + dpr: number; +} + +export default function MatrixRainBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#00ff41'); + const density = clampNumber(settings.density, 10, 100, 70); + const speed = clampNumber(settings.speed, 0.2, 3, 1); + const charset = safeSelect( + settings.charset, + ['katakana', 'latin', 'binary'] as const, + 'katakana', + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const initColumns = (width: number, height: number) => { + const cols = Math.max(1, Math.floor(width / FONT_SIZE)); + return { + drops: Array.from({ length: cols }, () => Math.floor(Math.random() * (height / FONT_SIZE))), + active: Array.from({ length: cols }, () => Math.random() * 100 < density), + }; + }; + + const { drops, active } = initColumns(w, h); + stateRef.current = { ctx, drops, active, acc: 0, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const next = initColumns(nw, nh); + stateRef.current.drops = next.drops; + stateRef.current.active = next.active; + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [density]); + + useAnimationLoop( + (_time, delta) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, drops, active, w, h } = state; + const chars = CHARSETS[charset]; + + state.acc += delta; + const stepMs = BASE_STEP_MS / speed; + if (state.acc < stepMs) return; + state.acc %= stepMs; + + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = 'rgba(0,0,0,0.12)'; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; + + ctx.fillStyle = color; + ctx.font = `${FONT_SIZE}px monospace`; + + for (let i = 0; i < drops.length; i++) { + if (!active[i]) continue; + const char = chars[Math.floor(Math.random() * chars.length)]; + ctx.fillText(char, i * FONT_SIZE, drops[i] * FONT_SIZE); + + if (drops[i] * FONT_SIZE > h && Math.random() > 0.975) { + drops[i] = 0; + active[i] = Math.random() * 100 < density; + } + drops[i]++; + } + }, + [color, density, speed, charset], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 86666b0..8b7b832 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -21,6 +21,7 @@ const backgroundImports: Record, () => Promise import('./fireflies'), snowfall: () => import('./snowfall'), starfield: () => import('./starfield'), + 'matrix-rain': () => import('./matrix-rain'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -53,6 +54,7 @@ export const backgroundComponents: Record< fireflies: lazy(() => import('./fireflies')), snowfall: lazy(() => import('./snowfall')), starfield: lazy(() => import('./starfield')), + 'matrix-rain': lazy(() => import('./matrix-rain')), }; // Registry of all background definitions with settings for the editor @@ -683,4 +685,42 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'matrix-rain', + labelKey: 'admin.backgrounds.matrixRain', + descriptionKey: 'admin.backgrounds.matrixRainDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#00ff41' }, + { + key: 'density', + label: 'admin.backgrounds.density', + type: 'number', + min: 10, + max: 100, + step: 5, + default: 70, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.2, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'charset', + label: 'admin.backgrounds.charset', + type: 'select', + default: 'katakana', + options: [ + { label: 'Katakana', value: 'katakana' }, + { label: 'Latin', value: 'latin' }, + { label: 'Binary', value: 'binary' }, + ], + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index 503a169..7822dd3 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -17,6 +17,7 @@ export type BackgroundType = | 'fireflies' | 'snowfall' | 'starfield' + | 'matrix-rain' | 'none'; export interface AnimationConfig { diff --git a/src/locales/en.json b/src/locales/en.json index b632a80..328453a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1159,6 +1159,8 @@ "snowfallDesc": "Falling snow with wind drift", "starfield": "Starfield", "starfieldDesc": "Stars flying toward the viewer", + "matrixRain": "Matrix Rain", + "matrixRainDesc": "Falling columns of glyphs", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", @@ -1181,6 +1183,7 @@ "multicolor": "Multicolor", "lineColor": "Line Color", "wind": "Wind", + "charset": "Charset", "bgColor": "Background Color", "fillColor": "Fill Color", "color1": "Color 1", diff --git a/src/locales/fa.json b/src/locales/fa.json index 003b34b..acd5fac 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1041,6 +1041,8 @@ "snowfallDesc": "برف در حال بارش با وزش باد", "starfield": "میدان ستارگان", "starfieldDesc": "پرواز در میان ستاره‌ها", + "matrixRain": "باران ماتریکس", + "matrixRainDesc": "ستون‌های نمادهای در حال سقوط", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", @@ -1063,6 +1065,7 @@ "multicolor": "حالت چندرنگ", "lineColor": "رنگ خطوط", "wind": "باد", + "charset": "مجموعه نویسه‌ها", "bgColor": "رنگ پس‌زمینه", "fillColor": "رنگ پر کردن", "color1": "رنگ ۱", diff --git a/src/locales/ru.json b/src/locales/ru.json index 064f965..e98dc1a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1189,6 +1189,8 @@ "snowfallDesc": "Падающий снег с ветром", "starfield": "Starfield", "starfieldDesc": "Полёт сквозь звёздное небо", + "matrixRain": "Matrix Rain", + "matrixRainDesc": "Падающие колонки символов", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", @@ -1211,6 +1213,7 @@ "multicolor": "Разноцветный режим", "lineColor": "Цвет линий", "wind": "Ветер", + "charset": "Набор символов", "bgColor": "Цвет фона", "fillColor": "Цвет заливки", "color1": "Цвет 1", diff --git a/src/locales/zh.json b/src/locales/zh.json index e94944e..3de039b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1041,6 +1041,8 @@ "snowfallDesc": "随风飘落的雪花", "starfield": "星空", "starfieldDesc": "向观察者飞来的星星", + "matrixRain": "黑客帝国雨", + "matrixRainDesc": "下落的字符列", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", @@ -1063,6 +1065,7 @@ "multicolor": "多彩模式", "lineColor": "线条颜色", "wind": "风力", + "charset": "字符集", "bgColor": "背景颜色", "fillColor": "填充颜色", "color1": "颜色1", diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts index c980803..cacc9dd 100644 --- a/src/utils/backgroundConfig.ts +++ b/src/utils/backgroundConfig.ts @@ -22,6 +22,7 @@ const VALID_TYPES: ReadonlySet = new Set([ 'fireflies', 'snowfall', 'starfield', + 'matrix-rain', 'none', ]);