// ==UserScript==
// @name Падающие листья и другие, на свой выбор & Black Russia Forum
// @namespace http://tampermonkey.net/
// @version 2.1
// @description Adds persistent falling particles (sakura, leaves, etc.) with advanced controls (wind, glow, selection) to forum.blackrussia.online
// @author M. Ageev Purpl [06]
// @match https://forum.blackrussia.online/*
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- КОНФИГУРАЦИЯ И НАСТРОЙКИ ПО УМОЛЧАНИЮ ---
const allParticleTypes = ['🌸', '🍁', '🍂', '🍃', '🌿', '❄️', '✨']; // Все доступные типы частиц
const defaultSettings = {
effectEnabled: true, // Эффект включен по умолчанию
panelVisible: false, // Панель скрыта по умолчанию
density: 50, // Плотность
speed: 1.5, // Скорость
size: 20, // Размер
wind: 0.3, // Сила ветра
glowEnabled: false, // Свечение выключено
selectedTypes: ['🌸', '🍁', '🍂', '🍃'], // Какие типы выбраны по умолчанию
};
// --- ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ---
let settings = {}; // Текущие настройки (загрузятся или будут по умолчанию)
let particles = [];
let canvas, ctx;
let animationFrameId;
let controlPanel, settingsButton; // Элементы UI
// --- УПРАВЛЕНИЕ НАСТРОЙКАМИ (СОХРАНЕНИЕ/ЗАГРУЗКА) ---
async function loadSettings() {
const savedSettings = await GM_getValue('fallingLeavesSettings_v2', defaultSettings);
// Проверка на случай, если сохраненная структура неполная (после обновления скрипта)
settings = { ...defaultSettings, ...savedSettings };
// Убедимся, что selectedTypes - это массив
if (!Array.isArray(settings.selectedTypes)) {
settings.selectedTypes = defaultSettings.selectedTypes;
}
console.log("Falling Leaves Settings Loaded:", settings);
}
async function saveSettings() {
// Не сохраняем массив частиц или контекст canvas
const settingsToSave = { ...settings };
await GM_setValue('fallingLeavesSettings_v2', settingsToSave);
// console.log("Falling Leaves Settings Saved:", settingsToSave);
}
// --- СОЗДАНИЕ И НАСТРОЙКА CANVAS ---
function setupCanvas() {
canvas = document.createElement('canvas');
document.body.appendChild(canvas);
ctx = canvas.getContext('2d');
canvas.style.position = 'fixed';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.zIndex = '9998'; // Чуть ниже панели управления
canvas.style.pointerEvents = 'none';
canvas.style.display = settings.effectEnabled ? 'block' : 'none'; // Учитываем настройку вкл/выкл
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
if (canvas) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
}
// --- УПРАВЛЕНИЕ ЧАСТИЦАМИ ---
function createParticle(index) {
// Выбираем тип только из выбранных пользователем
const availableTypes = settings.selectedTypes.length > 0 ? settings.selectedTypes : [allParticleTypes[0]]; // Если ничего не выбрано, берем первый тип
const char = availableTypes[Math.floor(Math.random() * availableTypes.length)];
const sizeVariation = (Math.random() - 0.5) * settings.size * 0.5; // +-25% variation
const speedVariation = (Math.random() - 0.5) * settings.speed * 0.5; // +-25% variation
const baseSpeed = Math.max(0.5, Math.min(5, settings.speed + speedVariation)); // Ограничиваем скорость
return {
x: Math.random() * canvas.width,
y: -Math.random() * canvas.height * 0.5, // Начинают чуть выше экрана
char: char,
size: Math.max(10, Math.min(40, settings.size + sizeVariation)), // Ограничиваем размер
speedY: baseSpeed,
// Скорость ветра зависит от настройки и немного случайна
speedX: (settings.wind / 2 + (Math.random() - 0.5) * settings.wind) * baseSpeed * 0.5,
opacity: 0.7 + Math.random() * 0.3,
rotation: Math.random() * 360,
rotationSpeed: (Math.random() - 0.5) * 2 // Скорость вращения
};
}
function createParticles(num) {
particles = [];
const targetNum = Math.min(Math.max(0, num), 300); // Ограничение 0-300
for (let i = 0; i < targetNum; i++) {
particles.push(createParticle(i));
}
}
function updateParticles() {
if (!ctx || !settings.effectEnabled) return;
particles.forEach((p, index) => {
p.y += p.speedY;
p.x += p.speedX;
p.rotation += p.rotationSpeed;
// Возвращаем частицу наверх, если она ушла за пределы экрана
// Увеличиваем буферную зону для X, чтобы учесть ветер
if (p.y > canvas.height + p.size || p.x < -p.size - Math.abs(settings.wind * 50) || p.x > canvas.width + p.size + Math.abs(settings.wind * 50)) {
particles[index] = createParticle(index);
particles[index].y = -p.size; // Сразу над экраном
particles[index].x = Math.random() * canvas.width; // Случайная X позиция
}
});
}
function drawParticles() {
if (!ctx || !settings.effectEnabled) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Настройки свечения
if (settings.glowEnabled) {
ctx.shadowBlur = 10; // Размер свечения
ctx.shadowColor = 'rgba(255, 255, 220, 0.6)'; // Цвет свечения (бледно-желтый)
} else {
ctx.shadowBlur = 0; // Убираем свечение
ctx.shadowColor = 'transparent';
}
particles.forEach(p => {
ctx.save();
ctx.font = `${p.size}px Arial`;
ctx.globalAlpha = p.opacity;
// Центрируем вращение и рисуем
ctx.translate(p.x + p.size / 2, p.y + p.size / 2);
ctx.rotate(p.rotation * Math.PI / 180);
ctx.fillText(p.char, -p.size / 2, p.size / 2); // Рисуем относительно центра
ctx.restore();
});
// Сбрасываем тень после отрисовки всех частиц, если она была включена
if (settings.glowEnabled) {
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1.0; // Сбрасываем прозрачность
}
// --- АНИМАЦИОННЫЙ ЦИКЛ ---
function animate() {
updateParticles();
drawParticles();
animationFrameId = requestAnimationFrame(animate);
}
function stopAnimation() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}
function startAnimation() {
if (!animationFrameId && settings.effectEnabled && settings.density > 0) {
// Пересоздаем частицы при старте, чтобы они соответствовали настройкам
createParticles(settings.density);
animate();
}
}
// --- УПРАВЛЕНИЕ ЭФФЕКТОМ (ВКЛ/ВЫКЛ) ---
function setEffectEnabled(enabled) {
settings.effectEnabled = enabled;
if (canvas) {
canvas.style.display = enabled ? 'block' : 'none';
}
if (enabled) {
startAnimation();
} else {
stopAnimation();
}
updateControlsState(); // Обновить состояние контролов в панели
saveSettings();
}
// --- ПАНЕЛЬ УПРАВЛЕНИЯ И ИКОНКА НАСТРОЕК ---
function createSettingsButton() {
settingsButton = document.createElement('button');
settingsButton.id = 'fallingLeavesSettingsButton';
settingsButton.innerHTML = '⚙️'; // Иконка шестеренки
settingsButton.title = 'Настройки падающих частиц';
settingsButton.style.position = 'fixed';
settingsButton.style.bottom = '10px';
settingsButton.style.left = '10px';
settingsButton.style.zIndex = '10001'; // Выше всего, кроме самой панели
settingsButton.style.background = 'rgba(0, 0, 0, 0.6)';
settingsButton.style.color = 'white';
settingsButton.style.border = 'none';
settingsButton.style.borderRadius = '50%';
settingsButton.style.width = '40px';
settingsButton.style.height = '40px';
settingsButton.style.fontSize = '20px';
settingsButton.style.lineHeight = '40px';
settingsButton.style.textAlign = 'center';
settingsButton.style.cursor = 'pointer';
settingsButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)';
settingsButton.style.transition = 'transform 0.2s ease';
settingsButton.addEventListener('mouseover', () => { settingsButton.style.transform = 'scale(1.1)'; });
settingsButton.addEventListener('mouseout', () => { settingsButton.style.transform = 'scale(1)'; });
settingsButton.addEventListener('click', toggleControlPanel);
document.body.appendChild(settingsButton);
}
function createControlPanel() {
controlPanel = document.createElement('div');
controlPanel.id = 'fallingLeavesControlPanel';
// Стили панели (похожи на предыдущую версию, но управляются через `display`)
controlPanel.style.position = 'fixed';
controlPanel.style.bottom = '60px'; // Выше иконки настроек
controlPanel.style.left = '10px';
controlPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
controlPanel.style.color = 'white';
controlPanel.style.padding = '15px';
controlPanel.style.borderRadius = '8px';
controlPanel.style.zIndex = '10000'; // Чуть ниже иконки
controlPanel.style.fontFamily = 'Arial, sans-serif';
controlPanel.style.fontSize = '13px';
controlPanel.style.minWidth = '250px';
controlPanel.style.boxShadow = '0 4px 10px rgba(0,0,0,0.4)';
controlPanel.style.display = settings.panelVisible ? 'block' : 'none'; // Начальное состояние видимости
controlPanel.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
// --- Стили для мобильных устройств ---
const styleSheet = document.createElement("style");
styleSheet.type = "text/css";
styleSheet.innerText = `
#fallingLeavesControlPanel { /* Добавим transition для плавности */
transition: opacity 0.2s ease-out, visibility 0.2s ease-out;
opacity: ${settings.panelVisible ? '1' : '0'};
visibility: ${settings.panelVisible ? 'visible' : 'hidden'};
}
#fallingLeavesControlPanel.visible {
opacity: 1;
visibility: visible;
}
@media (max-width: 600px) {
#fallingLeavesControlPanel {
font-size: 12px;
padding: 10px;
min-width: 200px;
bottom: 55px; /* Чуть ниже на мобильных */
}
#fallingLeavesControlPanel .control-row label,
#fallingLeavesControlPanel .particle-type-selector label {
display: block; /* Метки над элементами */
margin-bottom: 3px;
}
#fallingLeavesControlPanel input[type="range"] { width: 100%; }
#fallingLeavesControlPanel .particle-type-selector { grid-template-columns: repeat(3, 1fr); gap: 5px;} /* 3 колонки для чекбоксов */
}
#fallingLeavesControlPanel input[type="range"] {
width: 130px; height: 6px; vertical-align: middle; margin: 0 5px; cursor: pointer;
}
#fallingLeavesControlPanel .control-row, #fallingLeavesControlPanel .checkbox-row {
margin-bottom: 8px; display: flex; align-items: center; flex-wrap: wrap;
}
#fallingLeavesControlPanel .control-row label { min-width: 70px; display: inline-block; }
#fallingLeavesControlPanel .value-display { min-width: 30px; display: inline-block; text-align: right; font-weight: bold; }
#fallingLeavesControlPanel hr { border: none; border-top: 1px solid rgba(255,255,255,0.2); margin: 10px 0; }
#fallingLeavesControlPanel input[type="checkbox"] { margin-right: 5px; cursor: pointer; vertical-align: middle;}
#fallingLeavesControlPanel .particle-type-selector {
margin-top: 5px;
display: grid; /* Используем grid для чекбоксов */
grid-template-columns: repeat(4, auto); /* 4 колонки */
gap: 8px; /* Промежуток между чекбоксами */
font-size: 16px; /* Размер эмодзи */
}
#fallingLeavesControlPanel .particle-type-selector label {
display: flex;
align-items: center;
cursor: pointer;
}
`;
document.head.appendChild(styleSheet);
// --- Элементы управления ---
// 1. Вкл/Выкл Эффекта (главный)
const enableRow = document.createElement('div');
enableRow.className = 'checkbox-row';
const enableLabel = document.createElement('label');
enableLabel.htmlFor = 'effectEnabledCheckbox';
enableLabel.textContent = 'Включить эффект:';
enableLabel.style.fontWeight = 'bold';
const enableCheckbox = document.createElement('input');
enableCheckbox.type = 'checkbox';
enableCheckbox.id = 'effectEnabledCheckbox';
enableCheckbox.checked = settings.effectEnabled;
enableCheckbox.addEventListener('change', (e) => {
setEffectEnabled(e.target.checked);
});
enableRow.appendChild(enableCheckbox);
enableRow.appendChild(enableLabel);
controlPanel.appendChild(enableRow);
controlPanel.appendChild(document.createElement('hr'));
// 2. Плотность
const densityRow = createSliderRow('Плотность:', 'densitySlider', 0, 300, settings.density, 1, (value) => {
settings.density = value;
// Пересоздаем частицы немедленно при изменении плотности
createParticles(settings.density);
if (settings.density === 0 && animationFrameId) stopAnimation();
else if (settings.density > 0 && !animationFrameId && settings.effectEnabled) startAnimation();
saveSettings();
});
controlPanel.appendChild(densityRow);
// 3. Скорость
const speedRow = createSliderRow('Скорость:', 'speedSlider', 0.1, 5, settings.speed, 0.1, (value) => {
settings.speed = value;
saveSettings();
// Скорость обновится для новых/пересозданных частиц
});
controlPanel.appendChild(speedRow);
// 4. Размер
const sizeRow = createSliderRow('Размер:', 'sizeSlider', 10, 40, settings.size, 1, (value) => {
settings.size = value;
saveSettings();
// Размер обновится для новых/пересозданных частиц
});
controlPanel.appendChild(sizeRow);
// 5. Ветер
const windRow = createSliderRow('Ветер:', 'windSlider', -2, 2, settings.wind, 0.1, (value) => {
settings.wind = value;
saveSettings();
// Ветер обновится для новых/пересозданных частиц
});
controlPanel.appendChild(windRow);
controlPanel.appendChild(document.createElement('hr'));
// 6. Свечение
const glowRow = document.createElement('div');
glowRow.className = 'checkbox-row';
const glowLabel = document.createElement('label');
glowLabel.htmlFor = 'glowCheckbox';
glowLabel.textContent = 'Свечение частиц:';
const glowCheckbox = document.createElement('input');
glowCheckbox.type = 'checkbox';
glowCheckbox.id = 'glowCheckbox';
glowCheckbox.checked = settings.glowEnabled;
glowCheckbox.addEventListener('change', (e) => {
settings.glowEnabled = e.target.checked;
saveSettings();
// Эффект применится при следующей отрисовке
});
glowRow.appendChild(glowCheckbox);
glowRow.appendChild(glowLabel);
controlPanel.appendChild(glowRow);
// 7. Выбор типов частиц
const typesFieldset = document.createElement('fieldset');
typesFieldset.style.border = '1px solid rgba(255,255,255,0.3)';
typesFieldset.style.borderRadius = '4px';
typesFieldset.style.padding = '5px 10px 10px 10px';
typesFieldset.style.marginTop = '10px';
const typesLegend = document.createElement('legend');
typesLegend.textContent = 'Типы частиц';
typesLegend.style.padding = '0 5px';
typesFieldset.appendChild(typesLegend);
const typesContainer = document.createElement('div');
typesContainer.className = 'particle-type-selector';
allParticleTypes.forEach(type => {
const typeLabel = document.createElement('label');
const typeCheckbox = document.createElement('input');
typeCheckbox.type = 'checkbox';
typeCheckbox.value = type;
typeCheckbox.checked = settings.selectedTypes.includes(type);
typeCheckbox.addEventListener('change', (e) => {
const char = e.target.value;
if (e.target.checked) {
if (!settings.selectedTypes.includes(char)) {
settings.selectedTypes.push(char);
}
} else {
settings.selectedTypes = settings.selectedTypes.filter(t => t !== char);
}
// Не позволяем убрать последний выбранный тип (опционально)
// if (settings.selectedTypes.length === 0 && allParticleTypes.length > 0) {
// e.target.checked = true; // Возвращаем галочку
// settings.selectedTypes.push(char); // Добавляем обратно
// alert("Должен быть выбран хотя бы один тип частиц.");
// return;
// }
saveSettings();
// Изменения вступят в силу при пересоздании частиц
});
typeLabel.appendChild(typeCheckbox);
typeLabel.appendChild(document.createTextNode(type)); // Добавляем сам символ
typesContainer.appendChild(typeLabel);
});
typesFieldset.appendChild(typesContainer);
controlPanel.appendChild(typesFieldset);
document.body.appendChild(controlPanel);
updateControlsState(); // Обновляем состояние контролов при создании
}
// Вспомогательная функция для создания строки со слайдером
function createSliderRow(labelText, id, min, max, value, step, onChange) {
const row = document.createElement('div');
row.className = 'control-row';
const label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
const slider = document.createElement('input');
slider.type = 'range';
slider.id = id;
slider.min = min.toString();
slider.max = max.toString();
slider.step = step.toString();
slider.value = value.toString();
const valueDisplay = document.createElement('span');
valueDisplay.className = 'value-display';
// Форматируем значение в зависимости от шага
valueDisplay.textContent = Number.isInteger(step) ? value.toString() : value.toFixed(1);
slider.addEventListener('input', (e) => {
const newValue = parseFloat(e.target.value);
valueDisplay.textContent = Number.isInteger(step) ? newValue.toString() : newValue.toFixed(1);
onChange(newValue); // Вызываем колбэк с новым значением
});
row.appendChild(label);
row.appendChild(slider);
row.appendChild(valueDisplay);
return row;
}
function toggleControlPanel() {
settings.panelVisible = !settings.panelVisible;
if (controlPanel) {
// Используем классы для управления видимостью через CSS
controlPanel.style.display = 'block'; // Сначала делаем блочным, чтобы transition сработал
requestAnimationFrame(() => { // Ждем следующего кадра для применения стилей
if (settings.panelVisible) {
controlPanel.style.opacity = '1';
controlPanel.style.visibility = 'visible';
} else {
controlPanel.style.opacity = '0';
controlPanel.style.visibility = 'hidden';
// Можно добавить задержку перед display: none, если нужно
// setTimeout(() => { if(!settings.panelVisible) controlPanel.style.display = 'none'; }, 200);
}
});
}
saveSettings();
}
// Обновление состояния (disabled) контролов в зависимости от главного переключателя
function updateControlsState() {
if (!controlPanel) return;
const controlsToDisable = controlPanel.querySelectorAll('input:not(#effectEnabledCheckbox), fieldset');
controlsToDisable.forEach(control => {
control.disabled = !settings.effectEnabled;
control.style.opacity = settings.effectEnabled ? '1' : '0.5';
control.style.cursor = settings.effectEnabled ? '' : 'not-allowed';
});
}
// --- ИНИЦИАЛИЗАЦИЯ ---
async function init() {
console.log("Falling Leaves Script Initializing (v2.0)...");
await loadSettings(); // Загружаем настройки ПЕРЕД созданием UI
setupCanvas();
createControlPanel(); // Панель создается всегда
createSettingsButton(); // Кнопка настроек создается всегда
if (settings.effectEnabled) {
startAnimation(); // Запускаем анимацию, если она включена в настройках
}
console.log("Falling Leaves Script Ready!");
}
// Запускаем скрипт после загрузки страницы
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init(); // Инициализировать немедленно
}
})();