Генератор роста снежинок (ГРСН)

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Вороной из салютов — сверху гуще</title>
<style>
html, body {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    background: #000;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}

canvas {
    display: block;
    width: min(88vw, 88vh);
    height: min(88vw, 88vh);
    border-radius: 50%;
    box-shadow: 0 0 30px cyan;
    background: #000;
    cursor: crosshair;
}
</style>
</head>
<body>
    <canvas id="c" width="400" height="400"></canvas>

<script>
// ============================================================
// CANVAS
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

const W = 400, H = 400;
const CX = W / 2, CY = H / 2;
const RADIUS = W / 2;

// ============================================================
// ПАРАМЕТРЫ
// ============================================================
const GRAVITY = 0.06;
const FRICTION = 0.985;
const TRAIL_ALPHA = 0.10;
const EXPLOSION_SPEED = 3.0;

const SPARK_COUNT_SMALL = 25;
const SPARK_COUNT_MEDIUM = 45;
const SPARK_COUNT_BIG = 80;

const SPARK_DECAY = 0.0035;
const SPARK_WIDTH = 1;

const NUM_POINTS = 12;
let points = [];

// ============================================================
// СОСТОЯНИЕ
// ============================================================
let sparks = [];
let time = 0;

// ============================================================
// HSL → RGB
// ============================================================
function hslToRgb(h, s, l) {
    h = ((h % 360) + 360) % 360;
    let c = (1 - Math.abs(2 * l - 1)) * s;
    let x = c * (1 - Math.abs(((h / 60) % 2) - 1));
    let m = l - c / 2;
    let r = 0, g = 0, b = 0;
    if (h < 60)       { r = c; g = x; b = 0; }
    else if (h < 120) { r = x; g = c; b = 0; }
    else if (h < 180) { r = 0; g = c; b = x; }
    else if (h < 240) { r = 0; g = x; b = c; }
    else if (h < 300) { r = x; g = 0; b = c; }
    else              { r = c; g = 0; b = x; }
    return {
        R: Math.round((r + m) * 255),
        G: Math.round((g + m) * 255),
        B: Math.round((b + m) * 255)
    };
}

function inCircle(x, y, margin = 0) {
    let dx = x - CX;
    let dy = y - CY;
    let r = RADIUS - margin;
    return dx * dx + dy * dy <= r * r;
}

// ============================================================
// ГРАДИЕНТ ПЛОТНОСТИ: СВЕРХУ ГУЩЕ, СНИЗУ РЕЖЕ
// Возвращает множитель 0.15 .. 1.0
// y = 0 (верх) → 1.0 (максимум залпов)
// y = H (низ)  → 0.15 (минимум залпов)
// ============================================================
function densityFactor(y) {
    // Нормируем y в 0..1
    let t = Math.max(0, Math.min(1, y / H));
    // Вверху t=0 → 1.0, внизу t=1 → 0.15
    return 1.0 - t * 0.85;
}

// ============================================================
// ИНИЦИАЛИЗАЦИЯ ЯЧЕЕК
// ============================================================
function initVoronoiPoints() {
    points = [];
    for (let i = 0; i < NUM_POINTS; i++) {
        let angle = (i / NUM_POINTS) * Math.PI * 2;
        let r = RADIUS * (0.35 + Math.random() * 0.45);
        let x = CX + Math.cos(angle) * r + (Math.random() - 0.5) * 40;
        let y = CY + Math.sin(angle) * r + (Math.random() - 0.5) * 40;
        if (!inCircle(x, y, 20)) {
            x = CX + Math.cos(angle) * r;
            y = CY + Math.sin(angle) * r;
        }
        points.push({
            x, y,
            hue: (i / NUM_POINTS) * 360
        });
    }
}

function ownerOf(x, y) {
    let best = 0, bestD = Infinity;
    for (let i = 0; i < points.length; i++) {
        let dx = x - points[i].x;
        let dy = y - points[i].y;
        let d = dx * dx + dy * dy;
        if (d < bestD) { bestD = d; best = i; }
    }
    return best;
}

function distanceToBorder(x, y) {
    let d1 = Infinity, d2 = Infinity;
    for (let i = 0; i < points.length; i++) {
        let dx = x - points[i].x;
        let dy = y - points[i].y;
        let d = dx * dx + dy * dy;
        if (d < d1) {
            d2 = d1;
            d1 = d;
        } else if (d < d2) {
            d2 = d;
        }
    }
    return Math.sqrt(d2) - Math.sqrt(d1);
}

// ============================================================
// ЗАЛП В ТОЧКЕ
// ============================================================
function launchAt(x, y, hue, countMode) {
    if (!inCircle(x, y, 4)) return;

    let count;
    if (countMode === 'big')      count = SPARK_COUNT_BIG;
    else if (countMode === 'medium') count = SPARK_COUNT_MEDIUM;
    else                          count = SPARK_COUNT_SMALL;

    count = Math.floor(count * (0.8 + Math.random() * 0.4));

    let type = Math.random();
    let hue2 = (hue + 120 + Math.random() * 120) % 360;

    for (let i = 0; i < count; i++) {
        let a, speed, h_i;

        if (type < 0.6) {
            a = Math.random() * Math.PI * 2;
            speed = EXPLOSION_SPEED * (0.4 + Math.random() * 0.8);
            h_i = hue + (Math.random() - 0.5) * 30;
        } else if (type < 0.85) {
            a = Math.random() * Math.PI * 2;
            speed = EXPLOSION_SPEED * (0.9 + Math.random() * 0.15);
            h_i = hue;
        } else {
            a = Math.random() * Math.PI * 2;
            speed = EXPLOSION_SPEED * (0.5 + Math.random() * 0.7);
            h_i = (i % 2 === 0) ? hue : hue2;
        }

        sparks.push({
            x, y,
            vx: Math.cos(a) * speed,
            vy: Math.sin(a) * speed,
            hue: h_i,
            life: 1.0,
            decay: SPARK_DECAY * (0.6 + Math.random() * 0.7),
            size: 1.0 + Math.random() * 1.2
        });
    }

    if (countMode === 'big') {
        sparks.push({
            x, y, vx: 0, vy: 0,
            hue: hue,
            life: 1.0, decay: 0.10,
            size: 8, flash: true
        });
    }
}

// ============================================================
// ГЕНЕРАЦИЯ УЗОРА
// ============================================================
let scanAngle = 0;
let scanRadius = 0;

function scanAndLaunch() {
    time++;

    // === 1) ЗАЛПЫ ИЗ ЦЕНТРОВ ЯЧЕЕК ===
    // Каждый кадр проходим по всем ячейкам и с вероятностью,
    // зависящей от Y-координаты, запускаем залп.
    for (let i = 0; i < points.length; i++) {
        let p = points[i];
        let df = densityFactor(p.y);

        // Верхние ячейки стреляют почти каждый кадр,
        // нижние — редко
        if (Math.random() < df * 0.35) {
            launchAt(p.x, p.y, p.hue, df > 0.6 ? 'big' : 'medium');
        }
    }

    // === 2) ДОПОЛНИТЕЛЬНЫЕ ЗАЛПЫ СВЕРХУ ===
    // Дополнительный поток залпов в верхней половине круга
    if (time % 2 === 0) {
        // Верхняя половина: y от 0 до CY
        for (let k = 0; k < 3; k++) {
            let x = CX + (Math.random() - 0.5) * 2 * RADIUS * 0.8;
            let y = Math.random() * CY * 0.9;
            if (!inCircle(x, y, 20)) continue;

            let idx = ownerOf(x, y);
            let p = points[idx];
            let df = densityFactor(y);
            if (Math.random() < df) {
                launchAt(x, y, p.hue, 'small');
            }
        }
    }

    // === 3) СКАНИРОВАНИЕ ПО СПИРАЛИ ===
    for (let step = 0; step < 4; step++) {
        scanRadius += 1;
        if (scanRadius > RADIUS - 10) {
            scanRadius = 10;
            scanAngle += Math.PI / 16;
            if (scanAngle > Math.PI * 2) scanAngle = 0;
        }

        let x = CX + Math.cos(scanAngle) * scanRadius;
        let y = CY + Math.sin(scanAngle) * scanRadius;
        if (!inCircle(x, y, 15)) continue;

        let df = densityFactor(y);
        let border = distanceToBorder(x, y);

        // На границе — залп смешанным цветом
        if (border < 8) {
            // Даже на границе плотность зависит от Y
            if (Math.random() < df) {
                let i1 = ownerOf(x, y);
                let i2 = -1, bestD = Infinity;
                for (let i = 0; i < points.length; i++) {
                    if (i === i1) continue;
                    let d = (x - points[i].x) ** 2 + (y - points[i].y) ** 2;
                    if (d < bestD) { bestD = d; i2 = i; }
                }
                if (i2 >= 0) {
                    let h1 = points[i1].hue;
                    let h2 = points[i2].hue;
                    let dh = ((h2 - h1 + 540) % 360) - 180;
                    let hue = (h1 + dh * 0.5 + 360) % 360;
                    launchAt(x, y, hue, df > 0.6 ? 'medium' : 'small');
                }
            }
        }
        // Внутри ячейки, но близко к границе
        else if (border < 25 && Math.random() < df * 0.5) {
            let i = ownerOf(x, y);
            launchAt(x, y, points[i].hue, 'small');
        }
    }
}

// ============================================================
// ОБНОВЛЕНИЕ
// ============================================================
function update() {
    ctx.fillStyle = `rgba(0, 0, 0, ${TRAIL_ALPHA})`;
    ctx.fillRect(0, 0, W, H);

    scanAndLaunch();

    // --- Искры ---
    for (let i = sparks.length - 1; i >= 0; i--) {
        let s = sparks[i];

        if (s.flash) {
            if (inCircle(s.x, s.y, -s.size)) {
                let c = hslToRgb(s.hue, 1.0, 0.75);
                ctx.beginPath();
                ctx.arc(s.x, s.y, s.size * s.life, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${c.R},${c.G},${c.B},${s.life * 0.5})`;
                ctx.fill();
            }
            s.life -= s.decay;
            if (s.life <= 0) sparks.splice(i, 1);
            continue;
        }

        s.x += s.vx;
        s.y += s.vy;
        s.vx *= FRICTION;
        s.vy *= FRICTION;
        s.vy += GRAVITY;

        if (inCircle(s.x, s.y, -2)) {
            let brightness = 0.55 + s.life * 0.15;
            let c = hslToRgb(s.hue, 1.0, brightness);

            let px = s.x - s.vx;
            let py = s.y - s.vy;

            ctx.beginPath();
            ctx.moveTo(px, py);
            ctx.lineTo(s.x, s.y);
            ctx.strokeStyle = `rgba(${c.R},${c.G},${c.B},${Math.min(s.life, 1)})`;
            ctx.lineWidth = s.size * s.life * SPARK_WIDTH;
            ctx.lineCap = 'round';
            ctx.stroke();
        }

        s.life -= s.decay;

        if (s.life <= 0 || !inCircle(s.x, s.y, -30)) {
            sparks.splice(i, 1);
        }
    }

    // Подсветка якорей
    for (let p of points) {
        let c = hslToRgb(p.hue, 1.0, 0.6);
        ctx.beginPath();
        ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${c.R},${c.G},${c.B},0.9)`;
        ctx.fill();
    }
}

function loop() {
    update();
    requestAnimationFrame(loop);
}

// ============================================================
// КЛИК
// ============================================================
canvas.addEventListener('click', (e) => {
    let rect = canvas.getBoundingClientRect();
    let x = (e.clientX - rect.left) * (W / rect.width);
    let y = (e.clientY - rect.top) * (H / rect.height);
    if (inCircle(x, y)) {
        let idx = ownerOf(x, y);
        launchAt(points[idx].x, points[idx].y, points[idx].hue, 'big');
    }
});

// ============================================================
// СТАРТ
// ============================================================
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

initVoronoiPoints();
loop();
</script>
</body>
</html>

вот чисто теоретически эта версия вам нравится ?))) если да то надо подумать над экономией… а то этот монстр просто не экономно ест ресурсы…
и главное салюты внедрим)))

Мне кажется со стрелочными часами будет интересней смотреться.