feat(backgrounds): add matrix-rain background

This commit is contained in:
Boris Kovalskii
2026-06-11 10:28:26 +10:00
parent 3df2ef0f39
commit ab91c86f81
8 changed files with 182 additions and 0 deletions

View File

@@ -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<string, unknown>;
}
const FONT_SIZE = 16;
const BASE_STEP_MS = 80;
const CHARSETS: Record<string, string> = {
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<HTMLCanvasElement>(null);
const stateRef = useRef<MatrixState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -21,6 +21,7 @@ const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<u
fireflies: () => import('./fireflies'), fireflies: () => import('./fireflies'),
snowfall: () => import('./snowfall'), snowfall: () => import('./snowfall'),
starfield: () => import('./starfield'), starfield: () => import('./starfield'),
'matrix-rain': () => import('./matrix-rain'),
}; };
/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ /** 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')), fireflies: lazy(() => import('./fireflies')),
snowfall: lazy(() => import('./snowfall')), snowfall: lazy(() => import('./snowfall')),
starfield: lazy(() => import('./starfield')), starfield: lazy(() => import('./starfield')),
'matrix-rain': lazy(() => import('./matrix-rain')),
}; };
// Registry of all background definitions with settings for the editor // 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' },
],
},
],
},
]; ];

View File

@@ -17,6 +17,7 @@ export type BackgroundType =
| 'fireflies' | 'fireflies'
| 'snowfall' | 'snowfall'
| 'starfield' | 'starfield'
| 'matrix-rain'
| 'none'; | 'none';
export interface AnimationConfig { export interface AnimationConfig {

View File

@@ -1159,6 +1159,8 @@
"snowfallDesc": "Falling snow with wind drift", "snowfallDesc": "Falling snow with wind drift",
"starfield": "Starfield", "starfield": "Starfield",
"starfieldDesc": "Stars flying toward the viewer", "starfieldDesc": "Stars flying toward the viewer",
"matrixRain": "Matrix Rain",
"matrixRainDesc": "Falling columns of glyphs",
"geminiEffect": "Gemini Effect", "geminiEffect": "Gemini Effect",
"geminiEffectDesc": "SVG effect like Google Gemini", "geminiEffectDesc": "SVG effect like Google Gemini",
"speed": "Speed", "speed": "Speed",
@@ -1181,6 +1183,7 @@
"multicolor": "Multicolor", "multicolor": "Multicolor",
"lineColor": "Line Color", "lineColor": "Line Color",
"wind": "Wind", "wind": "Wind",
"charset": "Charset",
"bgColor": "Background Color", "bgColor": "Background Color",
"fillColor": "Fill Color", "fillColor": "Fill Color",
"color1": "Color 1", "color1": "Color 1",

View File

@@ -1041,6 +1041,8 @@
"snowfallDesc": "برف در حال بارش با وزش باد", "snowfallDesc": "برف در حال بارش با وزش باد",
"starfield": "میدان ستارگان", "starfield": "میدان ستارگان",
"starfieldDesc": "پرواز در میان ستاره‌ها", "starfieldDesc": "پرواز در میان ستاره‌ها",
"matrixRain": "باران ماتریکس",
"matrixRainDesc": "ستون‌های نمادهای در حال سقوط",
"geminiEffect": "افکت Gemini", "geminiEffect": "افکت Gemini",
"geminiEffectDesc": "افکت SVG مشابه Google Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini",
"speed": "سرعت", "speed": "سرعت",
@@ -1063,6 +1065,7 @@
"multicolor": "حالت چندرنگ", "multicolor": "حالت چندرنگ",
"lineColor": "رنگ خطوط", "lineColor": "رنگ خطوط",
"wind": "باد", "wind": "باد",
"charset": "مجموعه نویسه‌ها",
"bgColor": "رنگ پس‌زمینه", "bgColor": "رنگ پس‌زمینه",
"fillColor": "رنگ پر کردن", "fillColor": "رنگ پر کردن",
"color1": "رنگ ۱", "color1": "رنگ ۱",

View File

@@ -1189,6 +1189,8 @@
"snowfallDesc": "Падающий снег с ветром", "snowfallDesc": "Падающий снег с ветром",
"starfield": "Starfield", "starfield": "Starfield",
"starfieldDesc": "Полёт сквозь звёздное небо", "starfieldDesc": "Полёт сквозь звёздное небо",
"matrixRain": "Matrix Rain",
"matrixRainDesc": "Падающие колонки символов",
"geminiEffect": "Gemini Effect", "geminiEffect": "Gemini Effect",
"geminiEffectDesc": "SVG-эффект как на Google Gemini", "geminiEffectDesc": "SVG-эффект как на Google Gemini",
"speed": "Скорость", "speed": "Скорость",
@@ -1211,6 +1213,7 @@
"multicolor": "Разноцветный режим", "multicolor": "Разноцветный режим",
"lineColor": "Цвет линий", "lineColor": "Цвет линий",
"wind": "Ветер", "wind": "Ветер",
"charset": "Набор символов",
"bgColor": "Цвет фона", "bgColor": "Цвет фона",
"fillColor": "Цвет заливки", "fillColor": "Цвет заливки",
"color1": "Цвет 1", "color1": "Цвет 1",

View File

@@ -1041,6 +1041,8 @@
"snowfallDesc": "随风飘落的雪花", "snowfallDesc": "随风飘落的雪花",
"starfield": "星空", "starfield": "星空",
"starfieldDesc": "向观察者飞来的星星", "starfieldDesc": "向观察者飞来的星星",
"matrixRain": "黑客帝国雨",
"matrixRainDesc": "下落的字符列",
"geminiEffect": "Gemini效果", "geminiEffect": "Gemini效果",
"geminiEffectDesc": "类似Google Gemini的SVG效果", "geminiEffectDesc": "类似Google Gemini的SVG效果",
"speed": "速度", "speed": "速度",
@@ -1063,6 +1065,7 @@
"multicolor": "多彩模式", "multicolor": "多彩模式",
"lineColor": "线条颜色", "lineColor": "线条颜色",
"wind": "风力", "wind": "风力",
"charset": "字符集",
"bgColor": "背景颜色", "bgColor": "背景颜色",
"fillColor": "填充颜色", "fillColor": "填充颜色",
"color1": "颜色1", "color1": "颜色1",

View File

@@ -22,6 +22,7 @@ const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
'fireflies', 'fireflies',
'snowfall', 'snowfall',
'starfield', 'starfield',
'matrix-rain',
'none', 'none',
]); ]);