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

Вот как то графика немного кривовата получается у Алисы.

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Шар в коридоре: Финальная версия</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      background: #0b0b12;
      color: #fff;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      overflow: hidden;
    }

    h1 {
      margin: 0 0 8px 0;
      text-transform: uppercase;
      letter-spacing: 2px;
      text-shadow: 0 0 15px rgba(255,255,255,0.4);
    }

    p {
      color: #aaa;
      margin: 0 0 30px 0;
      max-width: 400px;
      text-align: center;
    }

    #game-area {
      position: relative;
      width: 100%;
      max-width: 600px;
      height: 320px;
      perspective: 1200px;
      overflow: hidden;
      border-radius: 12px;
      box-shadow: 0 0 40px rgba(0,0,0,0.6);
    }

    .floor {
      position: absolute;
      top: 0; left: 0; right: 0; bottom: 0;
      background: #2eab73;
      z-index: 1;
      transform-origin: top center;
      transform: perspective(1200px) rotateX(35deg) translateY(-10%);
    }

    .wall-left, .wall-right {
      position: absolute;
      top: 0; bottom: 0;
      width: 120px;
      background: linear-gradient(to bottom, #3b82f6, #1e40af);
      z-index: 2;
      box-shadow: inset 0 0 30px rgba(0,0,0,0.4);
      transform-origin: center;
    }

    .wall-left {
      left: -60px;
      transform: perspective(1200px) rotateX(35deg) skewY(-15deg) translateZ(20px);
    }

    .wall-right {
      right: -60px;
      transform: perspective(1200px) rotateX(35deg) skewY(15deg) translateZ(20px);
    }

    /* Линия удлинена до бортов */
    .track-line {
      position: absolute;
      top: 50%;
      left: 60px;
      right: 60px;
      height: 3px;
      background: #ffd600;
      z-index: 3;
      filter: drop-shadow(0 0 8px rgba(255,214,0,0.6));
      transform-origin: top center;
      transform: perspective(1200px) rotateX(35deg) translateY(-10%);
    }

    #ball {
      position: absolute;
      top: 50%;
      left: 80px;
      width: 40px;
      height: 40px;
      margin-top: -20px;
      margin-left: -20px;
      border-radius: 50%;
      background: radial-gradient(circle at 30% 30%, #ff5233, #b31000);
      box-shadow:
        inset -8px -8px 15px rgba(0,0,0,0.4),
        0 0 15px rgba(255,82,51,0.5);
      z-index: 4;
      will-change: left;
      transform-origin: top center;
      transform: perspective(1200px) rotateX(35deg) translateY(-10%);
    }

    #ball::before {
      content: '';
      position: absolute;
      top: 10px;
      left: 12px;
      width: 12px;
      height: 8px;
      background: rgba(255,255,255,0.8);
      border-radius: 50%;
      opacity: 0.9;
      filter: blur(2px);
      transform: perspective(1200px) rotateX(-35deg) translateY(10%);
    }

    #ball-shadow {
      position: absolute;
      top: calc(50% + 15px);
      left: 80px;
      width: 30px;
      height: 10px;
      background: rgba(0,0,0,0.5);
      border-radius: 50%;
      filter: blur(6px);
      z-index: 2;
      opacity: 0.6;
      will-change: left;
      transform-origin: top center;
      transform: perspective(1200px) rotateX(35deg) translateY(-10%);
    }

    #target-ring {
      position: absolute;
      top: 50%;
      left: 300px;
      width: 40px;
      height: 40px;
      margin-top: -20px;
      margin-left: -20px;
      border: 4px solid #ffd600;
      border-radius: 50%;
      box-shadow: 0 0 20px rgba(255,214,0,0.7);
      z-index: 3;
      filter: drop-shadow(0 0 10px rgba(255,214,0,0.8));
      transform-origin: top center;
      transform: perspective(1200px) rotateX(35deg) translateY(-10%);
    }

    #win-flash {
      position: absolute;
      top: 0; left: 0; right: 0; bottom: 0;
      background: radial-gradient(circle, #ffd600 0%, transparent 70%);
      opacity: 0;
      pointer-events: none;
      z-index: 10;
      transition: opacity 0.2s;
    }

    .controls { margin-top: 25px; }

    #start-btn {
      padding: 18px 50px;
      font-size: 19px;
      font-weight: bold;
      cursor: pointer;
      border: none;
      border-radius: 12px;
      background: linear-gradient(to bottom, #4a4a55, #2b2b33);
      color: white;
      box-shadow: 0 5px 15px rgba(0,0,0,0.4);
      text-transform: uppercase;
      letter-spacing: 1px;
    }

    #start-btn:hover { background: linear-gradient(to bottom, #6a6a75, #3b3b43); }
    #start-btn:active {
      transform: scale(0.98);
      background: linear-gradient(to bottom, #3a3a45, #1b1b23);
    }
    #start-btn:disabled {
      opacity: 0.6;
      cursor: not-allowed;
      filter: grayscale(1);
    }

    #status {
      margin-top: 15px;
      font-weight: bold;
      text-align: center;
      font-size: 18px;
    }
    #wins {
      margin-top: 8px;
      font-size: 14px;
      color: #888;
    }
  </style>
</head>
<body>

  <h1>Шар в коридоре</h1>
  <p>Держи кнопку — чем дольше, тем сильнее толчок. Попади в кольцо.</p>

  <div id="game-area">
    <div class="floor"></div>
    <div class="wall-left"></div>
    <div class="wall-right"></div>
    <div class="track-line"></div>
    <div id="target-ring"></div>
    <div id="ball"></div>
    <div id="ball-shadow"></div>
    <div id="win-flash"></div>
  </div>

  <div class="controls">
    <button id="start-btn">Держи меня</button>
  </div>
  <div id="status">Готов к старту</div>
  <div id="wins">Побед: 0 / 5</div>

  <script>
    // Границы — совпадают с внутренними краями синих бортов
    const WALL_LEFT = 60;
    const WALL_RIGHT = 540;
    const BALL_RADIUS = 20;
    const START_X = WALL_LEFT + BALL_RADIUS;   // 80
    const END_X = WALL_RIGHT - BALL_RADIUS;     // 520
    const MIN_TARGET_X = START_X + 40;
    const MAX_TARGET_X = END_X - 20;
    const FRICTION = 0.94;
    const BOUNCE_LOSS = 0.75;
    const MAX_WINS = 5;

    const ball = document.getElementById('ball');
    const shadow = document.getElementById('ball-shadow');
    const targetRing = document.getElementById('target-ring');
    const winFlash = document.getElementById('win-flash');
    const statusEl = document.getElementById('status');
    const winsEl = document.getElementById('wins');
    const startBtn = document.getElementById('start-btn');

    let currentX = START_X;
    let velocity = 0;
    let targetX = 0;
    let isRunning = false;
    let pressStartTime = 0;
    let winCount = 0;
    let isBallStopped = true;
    let lastDirection = 1; // 1 = вправо, -1 = влево

    function updateBall(x) {
      const clampedX = Math.max(START_X, Math.min(END_X, x));
      ball.style.left = clampedX + 'px';
      shadow.style.left = clampedX + 'px';
    }

    function startRun() {
      const heldMs = Date.now() - pressStartTime;
      const power = Math.min(heldMs / 150, 8);
      // Скорость в ту же сторону, куда шар катился в прошлый раз
      velocity = power * 10 * lastDirection;

      isRunning = true;
      isBallStopped = false;
      startBtn.disabled = true;
      statusEl.textContent = 'Летит...';
      winFlash.style.opacity = '0';

      runStep();
    }

    function runStep() {
      if (!isRunning) return;

      velocity *= FRICTION;
      currentX += velocity;

      // Отскок от правого борта
      if (currentX >= END_X) {
        currentX = END_X;
        velocity = -Math.abs(velocity) * BOUNCE_LOSS;
        lastDirection = -1;
      }
      // Отскок от левого борта
      else if (currentX <= START_X) {
        currentX = START_X;
        velocity = Math.abs(velocity) * BOUNCE_LOSS;
        lastDirection = 1;
      }

      updateBall(currentX);

      // Запоминаем направление, пока шар ещё движется
      if (velocity > 0.5) lastDirection = 1;
      else if (velocity < -0.5) lastDirection = -1;

      // Проверка остановки
      if (Math.abs(velocity) < 0.4) {
        velocity = 0;
        isBallStopped = true;

        if (Math.abs(currentX - targetX) < 12) {
          finishRun(true);
        } else {
          finishRun(false);
        }
        return;
      }

      requestAnimationFrame(runStep);
    }

    function finishRun(isWin) {
      isRunning = false;
      startBtn.disabled = false;

      if (isWin) {
        winCount++;
        statusEl.textContent = 'Попал! Победа №' + winCount;
        winsEl.textContent = 'Побед: ' + winCount + ' / ' + MAX_WINS;

        flashYellow(function() {
          if (winCount >= MAX_WINS) {
            winCount = 0;
            winsEl.textContent = 'Побед: 0 / 5';
            statusEl.textContent = 'Серия завершена! Новая игра';
            currentX = START_X;
            lastDirection = 1;
            updateBall(currentX);
          } else {
            statusEl.textContent = 'Держи кнопку для нового броска';
          }
          newTarget();
        });
      } else {
        statusEl.textContent = 'Мимо! Попробуй ещё';
        // Шар и кольцо остаются на месте
      }
    }

    function flashYellow(callback) {
      winFlash.style.opacity = '1';
      setTimeout(function() {
        winFlash.style.opacity = '0';
        if (callback) callback();
      }, 300);
    }

    function newTarget() {
      targetX = Math.floor(Math.random() * (MAX_TARGET_X - MIN_TARGET_X + 1)) + MIN_TARGET_X;
      targetRing.style.left = targetX + 'px';
    }

    // Мышь
    startBtn.addEventListener('mousedown', function(e) {
      e.preventDefault();
      if (isRunning || !isBallStopped) return;
      pressStartTime = Date.now();
    });

    startBtn.addEventListener('mouseup', function(e) {
      e.preventDefault();
      if (isRunning) return;
      if (pressStartTime > 0) startRun();
    });

    // Тач
    startBtn.addEventListener('touchstart', function(e) {
      e.preventDefault();
      if (isRunning || !isBallStopped) return;
      pressStartTime = Date.now();
    });
    startBtn.addEventListener('touchend', function(e) {
      e.preventDefault();
      if (isRunning) return;
      if (pressStartTime > 0) startRun();
    });

    startBtn.addEventListener('dragstart', function(e) { e.preventDefault(); });
    startBtn.addEventListener('contextmenu', function(e) { e.preventDefault(); });

    newTarget();
    updateBall(currentX);
  </script>
</body>
</html>

…хотелось бы с тенями что ли. Но подобие в игре ,по картинке, делать может.

сегодня у ии выходной похоже, не хочет генерировать нечего приемлемого качества))) а вы салюты то смотрели на тфт ? vможет все таки подхватите и сделайте то на что можно любоваться ?)))

игра

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Шар в коридоре</title>
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

  html, body {
    margin: 0; padding: 0;
    width: 100%; height: 100%;
    overflow: hidden;
    background: #05060d;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: #fff;
    user-select: none;
  }

  #scene {
    position: fixed;
    inset: 0;
    overflow: hidden;
    background: #1e8a52;
  }

  /* ============ ЗЕЛЁНЫЙ ПОЛ ============ */
  .floor {
    position: absolute;
    inset: 0;
    background:
      radial-gradient(ellipse 75% 65% at 50% 50%, #33c07a 0%, #2baa68 45%, #1e8a52 100%);
  }
  .floor::before {
    content: '';
    position: absolute;
    left: 0; right: 0; top: 0;
    height: 20%;
    background: linear-gradient(180deg,
      rgba(0,0,0,0.55) 0%,
      rgba(0,0,0,0.25) 60%,
      transparent 100%);
    pointer-events: none;
  }
  .floor::after {
    content: '';
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 16%;
    background: linear-gradient(0deg,
      rgba(0,0,0,0.4) 0%,
      rgba(0,0,0,0.15) 60%,
      transparent 100%);
    pointer-events: none;
  }

  /* ============ БОРТА ============ */
  .wall-left {
    position: absolute;
    top: 0; bottom: 0;
    left: 0;
    width: 18%;
    background: linear-gradient(90deg,
      #0a3a7e 0%,
      #1258b0 25%,
      #2878e0 60%,
      #4a98ff 100%);
    clip-path: polygon(0 0, 60% 0, 100% 100%, 0 100%);
    box-shadow: inset -20px 0 40px rgba(0,0,0,0.5);
    z-index: 3;
  }
  .wall-left::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg,
      transparent 0%,
      transparent 55%,
      rgba(0,0,0,0.5) 100%);
    clip-path: polygon(0 0, 60% 0, 100% 100%, 0 100%);
  }

  .wall-right {
    position: absolute;
    top: 0; bottom: 0;
    right: 0;
    width: 18%;
    background: linear-gradient(270deg,
      #0a3a7e 0%,
      #1258b0 25%,
      #2878e0 60%,
      #4a98ff 100%);
    clip-path: polygon(40% 0, 100% 0, 100% 100%, 0 100%);
    box-shadow: inset 20px 0 40px rgba(0,0,0,0.5);
    z-index: 3;
  }
  .wall-right::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(270deg,
      transparent 0%,
      transparent 55%,
      rgba(0,0,0,0.5) 100%);
    clip-path: polygon(40% 0, 100% 0, 100% 100%, 0 100%);
  }

  /* ============ ЖЁЛТАЯ ЛИНИЯ ============ */
  #track-line {
    position: absolute;
    left: 15%;
    right: 15%;
    top: 50%;
    height: 3px;
    margin-top: -1.5px;
    background: #ffd600;
    box-shadow:
      0 0 6px rgba(255,214,0,0.9),
      0 0 16px rgba(255,214,0,0.5);
    z-index: 4;
  }

  /* ============ КОЛЬЦО ============ */
  #ring {
    position: absolute;
    top: 50%;
    width: 200px;
    height: 200px;
    margin-top: -100px;
    margin-left: -100px;
    border: 5px solid #ffd600;
    border-radius: 50%;
    box-shadow:
      0 0 12px rgba(255,214,0,0.9),
      0 0 32px rgba(255,214,0,0.55);
    z-index: 5;
    pointer-events: none;
    transform: scaleY(1.4);
    transform-origin: center center;
    will-change: left;
  }

  /* ============ ШАР ============ */
  #ball {
    position: absolute;
    top: 50%;
    width: 90px;
    height: 90px;
    margin-top: -45px;
    margin-left: -45px;
    border-radius: 50%;
    background:
      radial-gradient(circle at 32% 26%,
        #ffb3b3 0%,
        #ff4a4a 18%,
        #e01c1c 45%,
        #a00808 78%,
        #500000 100%);
    box-shadow:
      inset -14px -18px 30px rgba(0,0,0,0.7),
      inset 10px 10px 22px rgba(255,180,180,0.55),
      0 0 40px rgba(255,60,60,0.5);
    z-index: 8;
    will-change: left, transform;
  }
  #ball::before {
    content: '';
    position: absolute;
    top: 14%;
    left: 18%;
    width: 26%;
    height: 20%;
    background: radial-gradient(ellipse, #ffffff 0%, rgba(255,255,255,0.7) 40%, transparent 75%);
    border-radius: 50%;
    filter: blur(3px);
  }
  #ball::after {
    content: '';
    position: absolute;
    bottom: 16%;
    right: 22%;
    width: 14%;
    height: 10%;
    background: radial-gradient(ellipse, rgba(255,255,255,0.5) 0%, transparent 70%);
    border-radius: 50%;
    filter: blur(4px);
  }

  /* ============ ТЕНЬ ШАРА ============ */
  #ball-shadow {
    position: absolute;
    top: calc(50% + 40px);
    width: 130px;
    height: 26px;
    margin-left: -65px;
    border-radius: 50%;
    background:
      radial-gradient(ellipse at 40% 50%,
        rgba(0,0,0,0.85) 0%,
        rgba(0,0,0,0.6) 35%,
        rgba(0,0,0,0.25) 65%,
        transparent 90%);
    filter: blur(6px);
    z-index: 6;
    pointer-events: none;
    will-change: left;
  }

  /* ============ ВСПЫШКА ПОБЕДЫ ============ */
  #win-flash {
    position: fixed;
    inset: 0;
    background: radial-gradient(circle at 50% 50%,
      rgba(255,214,0,0.9) 0%,
      rgba(255,214,0,0.2) 40%,
      transparent 70%);
    opacity: 0;
    pointer-events: none;
    z-index: 50;
    transition: opacity 0.25s ease;
  }

  /* ============ ЧАСТИЦЫ САЛЮТА ============ */
  .particle {
    position: fixed;
    border-radius: 50%;
    pointer-events: none;
    will-change: transform, opacity;
    z-index: 55;
  }

  /* ============ UI ============ */
  .hud {
    position: fixed;
    left: 50%;
    bottom: 22px;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    z-index: 30;
    width: min(92%, 500px);
  }

  #start-btn {
    position: relative;
    width: 100%;
    padding: 16px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    border: none;
    border-radius: 14px;
    background: linear-gradient(180deg, #3a6df0 0%, #2348b8 55%, #16296e 100%);
    color: white;
    box-shadow:
      0 6px 0 #0d1a45,
      0 12px 30px rgba(0,0,0,0.55),
      inset 0 1px 0 rgba(255,255,255,0.35);
    text-transform: uppercase;
    letter-spacing: 2px;
    transition: transform 0.08s ease, box-shadow 0.08s ease, filter 0.2s ease;
    overflow: hidden;
    touch-action: none;
  }
  #start-btn:hover:not(:disabled) { filter: brightness(1.15); }
  #start-btn:active:not(:disabled) {
    transform: translateY(4px);
    box-shadow:
      0 2px 0 #0d1a45,
      0 4px 12px rgba(0,0,0,0.5),
      inset 0 1px 0 rgba(255,255,255,0.35);
  }
  #start-btn:disabled {
    opacity: 0.55;
    cursor: not-allowed;
    filter: grayscale(0.6);
  }
  #power-fill {
    position: absolute;
    left: 0; top: 0; bottom: 0;
    width: 0%;
    background: linear-gradient(90deg, rgba(255,214,0,0.5), rgba(255,82,51,0.75));
    pointer-events: none;
  }
  #start-btn span { position: relative; z-index: 1; }

  #status {
    font-weight: 600;
    text-align: center;
    font-size: 15px;
    min-height: 20px;
    color: #fff;
    text-shadow: 0 0 12px rgba(0,0,0,0.8);
  }
  #wins {
    font-size: 12px;
    color: #dfe4ff;
    letter-spacing: 1px;
    text-shadow: 0 0 10px rgba(0,0,0,0.7);
  }

  .win-dots {
    display: flex;
    gap: 8px;
  }
  .win-dots i {
    width: 12px; height: 12px;
    border-radius: 50%;
    background: rgba(0,0,0,0.4);
    box-shadow: inset 0 0 4px rgba(0,0,0,0.7);
    transition: all 0.3s ease;
  }
  .win-dots i.on {
    background: radial-gradient(circle at 35% 35%, #ffe680, #ffd600 55%, #c9a400);
    box-shadow: 0 0 14px rgba(255,214,0,1);
  }

  .title {
    position: fixed;
    top: 14px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 30;
    text-align: center;
    pointer-events: none;
  }
  .title h1 {
    margin: 0;
    font-size: 20px;
    text-transform: uppercase;
    letter-spacing: 3px;
    color: #fff;
    text-shadow: 0 0 20px rgba(0,0,0,0.9), 0 2px 4px rgba(0,0,0,0.8);
  }
  .title p {
    margin: 3px 0 0;
    color: #e8ecff;
    font-size: 11px;
    letter-spacing: 1px;
    text-shadow: 0 0 10px rgba(0,0,0,0.9);
  }

  @media (max-width: 480px) {
    .title h1 { font-size: 15px; letter-spacing: 2px; }
    #start-btn { padding: 13px; font-size: 14px; }
    #ball { width: 70px; height: 70px; margin-top: -35px; margin-left: -35px; }
    #ball-shadow { width: 100px; height: 20px; margin-left: -50px; }
    #ring { width: 150px; height: 150px; margin-top: -75px; margin-left: -75px; }
  }
</style>
</head>
<body>

<div id="scene">
  <div class="floor"></div>
  <div class="wall-left"></div>
  <div class="wall-right"></div>
  <div id="track-line"></div>
  <div id="ring"></div>
  <div id="ball-shadow"></div>
  <div id="ball"></div>
</div>

<div id="win-flash"></div>

<div class="title">
  <h1>Шар в коридоре</h1>
  <p>Зажми — набери силу. Отпусти — шар покатится. Попади в кольцо.</p>
</div>

<div class="hud">
  <button id="start-btn"><div id="power-fill"></div><span>Держи меня</span></button>
  <div id="status">Готов к старту</div>
  <div id="wins">Побед: 0 / 5</div>
  <div class="win-dots" id="win-dots"></div>
</div>

<script>
(() => {
  'use strict';

  const BALL_R = 45;
  const FRICTION = 0.986;
  const MIN_SPEED = 0.4;
  const BOUNCE_LOSS = 0.7;
  const MAX_POWER = 9;
  const CHARGE_TIME = 1500;
  const HIT_TOLERANCE = 60;
  const MAX_WINS = 5;

  const sceneEl    = document.getElementById('scene');
  const ball       = document.getElementById('ball');
  const ballShadow = document.getElementById('ball-shadow');
  const ringEl     = document.getElementById('ring');
  const winFlash   = document.getElementById('win-flash');
  const statusEl   = document.getElementById('status');
  const winsEl     = document.getElementById('wins');
  const startBtn   = document.getElementById('start-btn');
  const powerFill  = document.getElementById('power-fill');
  const winDots    = document.getElementById('win-dots');

  let sceneW = 0, sceneH = 0;
  let startX = 0, endX = 0;
  let currentX = 0;
  let targetX = 0;
  let velocity = 0;
  let isRunning = false;
  let isCharging = false;
  let pressStartTime = 0;
  let chargeRAF = null;
  let winCount = 0;
  let ballRotation = 0;
  let lastDir = 1;

  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }

  function recalc() {
    sceneW = sceneEl.clientWidth;
    sceneH = sceneEl.clientHeight;

    const wallW = sceneW * 0.18;
    startX = wallW + BALL_R * 0.7;
    endX   = sceneW - wallW - BALL_R * 0.7;

    if (currentX === 0) currentX = startX;
    currentX = clamp(currentX, startX, endX);

    if (targetX === 0) targetX = sceneW * 0.62;
    targetX = clamp(targetX, startX + 120, endX - 80);

    render();
  }

  function render() {
    ball.style.left = currentX + 'px';
    ball.style.transform = `rotate(${ballRotation}deg)`;
    ballShadow.style.left = (currentX + 14) + 'px';
    ringEl.style.left = targetX + 'px';
  }

  function newTarget() {
    const min = sceneW * 0.45;
    const max = sceneW * 0.78;
    targetX = min + Math.random() * (max - min);
    ringEl.style.left = targetX + 'px';
  }

  function startCharge(e) {
    if (e.cancelable) e.preventDefault();
    if (isRunning || isCharging) return;
    isCharging = true;
    pressStartTime = performance.now();
    startBtn.style.filter = 'brightness(1.2)';
    const tick = () => {
      if (!isCharging) return;
      const held = performance.now() - pressStartTime;
      const p = Math.min(held / CHARGE_TIME, 1);
      powerFill.style.width = (p * 100) + '%';
      chargeRAF = requestAnimationFrame(tick);
    };
    tick();
  }

  function releaseCharge(e) {
    if (e && e.cancelable) e.preventDefault();
    if (!isCharging) return;
    isCharging = false;
    if (chargeRAF) cancelAnimationFrame(chargeRAF);
    chargeRAF = null;
    powerFill.style.width = '0%';
    startBtn.style.filter = '';

    const held = performance.now() - pressStartTime;
    const power = Math.min(held / CHARGE_TIME, 1) * MAX_POWER;

    let dir = lastDir;
    if (Math.abs(velocity) > 0.5) dir = velocity > 0 ? 1 : -1;

    velocity = power * 14 * dir;
    if (Math.abs(velocity) < 2) velocity = 2 * dir;

    isRunning = true;
    startBtn.disabled = true;
    statusEl.textContent = 'Шар катится...';
    winFlash.style.opacity = '0';
    runStep();
  }

  function runStep() {
    if (!isRunning) return;
    velocity *= FRICTION;
    const prevX = currentX;
    currentX += velocity;
    ballRotation += (currentX - prevX) / BALL_R * (180 / Math.PI);

    if (currentX >= endX) {
      currentX = endX;
      velocity = -Math.abs(velocity) * BOUNCE_LOSS;
      lastDir = -1;
      bounceFx(endX);
    } else if (currentX <= startX) {
      currentX = startX;
      velocity = Math.abs(velocity) * BOUNCE_LOSS;
      lastDir = 1;
      bounceFx(startX);
    }

    if (velocity > 0.5) lastDir = 1;
    else if (velocity < -0.5) lastDir = -1;

    render();

    if (Math.abs(velocity) < MIN_SPEED) {
      velocity = 0;
      isRunning = false;
      startBtn.disabled = false;
      const dx = Math.abs(currentX - targetX);
      if (dx < HIT_TOLERANCE) finishRun(true);
      else finishRun(false);
      return;
    }
    requestAnimationFrame(runStep);
  }

  function finishRun(isWin) {
    if (isWin) {
      winCount++;
      statusEl.textContent = `Попал! Победа №${winCount}`;
      winsEl.textContent = `Побед: ${winCount} / ${MAX_WINS}`;
      updateDots();

      // 🎆 ЗАПУСК САЛЮТА!
      const fireX = currentX;
      const fireY = sceneH * 0.5;
      launchFirework(fireX, fireY, 1.3);

      flashYellow(() => {
        if (winCount >= MAX_WINS) {
          winCount = 0;
          updateDots();
          winsEl.textContent = `Побед: 0 / ${MAX_WINS}`;
          statusEl.textContent = 'Серия завершена! Новая игра';
          currentX = startX;
          ballRotation = 0;
          lastDir = 1;
          render();
        } else {
          statusEl.textContent = 'Зажми кнопку для нового броска';
        }
        newTarget();
      });
    } else {
      statusEl.textContent = 'Мимо! Попробуй ещё';
    }
  }

  function updateDots() {
    winDots.innerHTML = '';
    for (let i = 0; i < MAX_WINS; i++) {
      const d = document.createElement('i');
      if (i < winCount) d.classList.add('on');
      winDots.appendChild(d);
    }
  }

  function flashYellow(cb) {
    winFlash.style.opacity = '1';
    setTimeout(() => {
      winFlash.style.opacity = '0';
      if (cb) cb();
    }, 320);
  }

  function bounceFx(x) {
    const f = document.createElement('div');
    f.style.cssText = `
      position:fixed; left:${x}px; top:50%;
      width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%;
      background:#8ad4ff; box-shadow:0 0 24px #8ad4ff;
      pointer-events:none; z-index:15;
    `;
    document.body.appendChild(f);
    f.animate(
      [{ transform: 'scale(0.4)', opacity: 1 }, { transform: 'scale(3)', opacity: 0 }],
      { duration: 300 }
    ).onfinish = () => f.remove();
  }

  /* ============================================================
     🎆 САЛЮТ — три типа частиц, гравитация, трение
     ============================================================ */

  const FIREWORK_COLORS = [
    '#ffd600', // жёлтый
    '#ff8a00', // оранжевый
    '#ff3060', // розово-красный
    '#4aa8ff', // голубой
    '#8affc8', // мятный
    '#c88aff', // сиреневый
    '#ffffff', // белый
  ];

  function launchFirework(x, y, scale = 1) {
    // 1. Центральная вспышка
    spawnFireworkFlash(x, y, scale);

    // 2. Основная волна — 60 круглых частиц
    const mainCount = Math.floor(60 * scale);
    const baseColor = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
    for (let i = 0; i < mainCount; i++) {
      const angle = (Math.PI * 2 * i) / mainCount + (Math.random() - 0.5) * 0.15;
      const speed = (180 + Math.random() * 180) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = Math.random() < 0.7
        ? baseColor
        : FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
      const size = (5 + Math.random() * 5) * scale;
      const life = 900 + Math.random() * 600;
      spawnFireworkParticle(x, y, dx, dy, color, size, life, 'circle');
    }

    // 3. Искры — мелкие, быстрые
    const sparkCount = Math.floor(24 * scale);
    for (let i = 0; i < sparkCount; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = (100 + Math.random() * 250) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = '#fff8c0';
      const size = (2 + Math.random() * 2) * scale;
      const life = 600 + Math.random() * 500;
      spawnFireworkParticle(x, y, dx, dy, color, size, life, 'spark');
    }

    // 4. Лепестки — крупные медленные
    const petalCount = Math.floor(8 * scale);
    for (let i = 0; i < petalCount; i++) {
      const angle = (Math.PI * 2 * i) / petalCount;
      const speed = (90 + Math.random() * 90) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
      const size = (10 + Math.random() * 6) * scale;
      const life = 1200 + Math.random() * 600;
      spawnFireworkParticle(x, y, dx, dy, color, size, life, 'petal');
    }
  }

  function spawnFireworkParticle(x, y, dx, dy, color, size, life, type) {
    const el = document.createElement('div');
    el.className = 'particle';

    if (type === 'spark') {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = color;
      el.style.boxShadow = `0 0 ${size * 3}px ${color}`;
    } else if (type === 'petal') {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = `radial-gradient(circle at 40% 40%, #fff 0%, ${color} 50%, transparent 85%)`;
      el.style.boxShadow = `0 0 ${size * 2}px ${color}, 0 0 ${size * 4}px ${color}`;
    } else {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = `radial-gradient(circle at 40% 40%, #fff 0%, ${color} 45%, ${color} 70%, transparent 90%)`;
      el.style.boxShadow = `0 0 ${size * 2.5}px ${color}`;
    }

    el.style.left = x + 'px';
    el.style.top  = y + 'px';
    el.style.transform = 'translate(-50%, -50%)';

    document.body.appendChild(el);

    const startTime = performance.now();
    const grav = 260;
    const friction = 0.985;
    let vx = dx, vy = dy;

    const startLeft = x;
    const startTop  = y;
    let lastT = startTime;
    let curX = x, curY = y;

    function frame(now) {
      const dt = Math.min((now - lastT) / 1000, 0.05);
      lastT = now;

      vy += grav * dt;
      vx *= Math.pow(friction, dt * 60);
      vy *= Math.pow(friction, dt * 60);

      curX += vx * dt;
      curY += vy * dt;

      const t = (now - startTime) / life;
      const opacity = Math.max(0, 1 - t);

      el.style.transform = `translate(${curX - startLeft}px, ${curY - startTop}px) translate(-50%,-50%) scale(${1 - t * 0.5})`;
      el.style.opacity = opacity;

      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        el.remove();
      }
    }
    requestAnimationFrame(frame);
  }

  function spawnFireworkFlash(x, y, scale) {
    const el = document.createElement('div');
    el.style.cssText = `
      position: fixed;
      left: ${x}px; top: ${y}px;
      width: ${120 * scale}px; height: ${120 * scale}px;
      margin-left: ${-60 * scale}px; margin-top: ${-60 * scale}px;
      border-radius: 50%;
      background: radial-gradient(circle, rgba(255,255,200,0.95) 0%, rgba(255,214,0,0.6) 30%, transparent 75%);
      pointer-events: none;
      z-index: 15;
      filter: blur(${4 * scale}px);
    `;
    document.body.appendChild(el);

    el.animate(
      [
        { transform: 'scale(0.2)', opacity: 1 },
        { transform: `scale(${2.2 * scale})`, opacity: 0 }
      ],
      { duration: 500, easing: 'cubic-bezier(.2,.8,.3,1)' }
    ).onfinish = () => el.remove();
  }

  /* ============================================================ */

  startBtn.addEventListener('pointerdown', (e) => {
    startBtn.setPointerCapture(e.pointerId);
    startCharge(e);
  });
  startBtn.addEventListener('pointerup', (e) => {
    try { startBtn.releasePointerCapture(e.pointerId); } catch(_) {}
    releaseCharge(e);
  });
  startBtn.addEventListener('pointercancel', (e) => releaseCharge(e));
  startBtn.addEventListener('dragstart', e => e.preventDefault());
  startBtn.addEventListener('contextmenu', e => e.preventDefault());

  let resizeT = null;
  window.addEventListener('resize', () => {
    clearTimeout(resizeT);
    resizeT = setTimeout(recalc, 120);
  });

  updateDots();
  recalc();
  newTarget();
  render();
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Салют — демо</title>
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

  html, body {
    margin: 0; padding: 0;
    width: 100%; height: 100%;
    overflow: hidden;
    background: radial-gradient(ellipse at 50% 40%, #141a34 0%, #070a1a 55%, #02030a 100%);
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: #fff;
    user-select: none;
    cursor: crosshair;
  }

  .info {
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
    pointer-events: none;
    z-index: 100;
  }
  .info h1 {
    margin: 0;
    font-size: 20px;
    letter-spacing: 4px;
    text-transform: uppercase;
    color: #fff;
    text-shadow: 0 0 20px rgba(255,214,0,0.7);
  }
  .info p {
    margin: 6px 0 0;
    font-size: 12px;
    color: #8a8aa0;
    letter-spacing: 1px;
  }

  /* Кнопки внизу */
  .controls {
    position: fixed;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    z-index: 100;
  }
  .controls button {
    padding: 12px 22px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 1px;
    text-transform: uppercase;
    border: 1px solid rgba(255,214,0,0.4);
    border-radius: 10px;
    background: rgba(30,20,10,0.7);
    color: #ffe680;
    cursor: pointer;
    backdrop-filter: blur(6px);
    transition: all 0.15s ease;
  }
  .controls button:hover {
    background: rgba(255,180,0,0.25);
    border-color: rgba(255,214,0,0.9);
    box-shadow: 0 0 20px rgba(255,214,0,0.5);
  }
  .controls button:active {
    transform: scale(0.96);
  }

  /* Частица */
  .particle {
    position: fixed;
    border-radius: 50%;
    pointer-events: none;
    will-change: transform, opacity;
    z-index: 10;
  }
</style>
</head>
<body>

<div class="info">
  <h1>Салют</h1>
  <p>Клик в любом месте — залп. Или нажми кнопку.</p>
</div>

<div class="controls">
  <button id="btn-one">Один залп</button>
  <button id="btn-big">Большой залп</button>
  <button id="btn-rain">Дождь салютов</button>
  <button id="btn-stop">Стоп</button>
</div>

<script>
(() => {
  'use strict';

  const COLORS = [
    '#ffd600', // жёлтый
    '#ff8a00', // оранжевый
    '#ff3060', // розово-красный
    '#4aa8ff', // голубой
    '#8affc8', // мятный
    '#c88aff', // сиреневый
    '#ffffff', // белый
  ];

  // ============ Салют ============
  function launchFirework(x, y, scale = 1) {
    spawnFlash(x, y, scale);

    // Основная волна
    const mainCount = Math.floor(60 * scale);
    const baseColor = COLORS[Math.floor(Math.random() * COLORS.length)];
    for (let i = 0; i < mainCount; i++) {
      const angle = (Math.PI * 2 * i) / mainCount + (Math.random() - 0.5) * 0.15;
      const speed = (180 + Math.random() * 180) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = Math.random() < 0.7
        ? baseColor
        : COLORS[Math.floor(Math.random() * COLORS.length)];
      const size = (5 + Math.random() * 5) * scale;
      const life = 900 + Math.random() * 600;
      spawnParticle(x, y, dx, dy, color, size, life, 'circle');
    }

    // Искры
    const sparkCount = Math.floor(24 * scale);
    for (let i = 0; i < sparkCount; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = (100 + Math.random() * 250) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = '#fff8c0';
      const size = (2 + Math.random() * 2) * scale;
      const life = 600 + Math.random() * 500;
      spawnParticle(x, y, dx, dy, color, size, life, 'spark');
    }

    // Лепестки
    const petalCount = Math.floor(8 * scale);
    for (let i = 0; i < petalCount; i++) {
      const angle = (Math.PI * 2 * i) / petalCount;
      const speed = (90 + Math.random() * 90) * scale;
      const dx = Math.cos(angle) * speed;
      const dy = Math.sin(angle) * speed;
      const color = COLORS[Math.floor(Math.random() * COLORS.length)];
      const size = (10 + Math.random() * 6) * scale;
      const life = 1200 + Math.random() * 600;
      spawnParticle(x, y, dx, dy, color, size, life, 'petal');
    }
  }

  // ============ Частица ============
  function spawnParticle(x, y, dx, dy, color, size, life, type) {
    const el = document.createElement('div');
    el.className = 'particle';

    if (type === 'spark') {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = color;
      el.style.boxShadow = `0 0 ${size * 3}px ${color}`;
    } else if (type === 'petal') {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = `radial-gradient(circle at 40% 40%, #fff 0%, ${color} 50%, transparent 85%)`;
      el.style.boxShadow = `0 0 ${size * 2}px ${color}, 0 0 ${size * 4}px ${color}`;
    } else {
      el.style.width = size + 'px';
      el.style.height = size + 'px';
      el.style.background = `radial-gradient(circle at 40% 40%, #fff 0%, ${color} 45%, ${color} 70%, transparent 90%)`;
      el.style.boxShadow = `0 0 ${size * 2.5}px ${color}`;
    }

    el.style.left = x + 'px';
    el.style.top  = y + 'px';
    el.style.transform = 'translate(-50%, -50%)';

    document.body.appendChild(el);

    const startTime = performance.now();
    const grav = 260;
    const friction = 0.985;
    let vx = dx, vy = dy;

    const startLeft = x;
    const startTop  = y;
    let lastT = startTime;
    let curX = x, curY = y;

    function frame(now) {
      const dt = Math.min((now - lastT) / 1000, 0.05);
      lastT = now;

      vy += grav * dt;
      vx *= Math.pow(friction, dt * 60);
      vy *= Math.pow(friction, dt * 60);

      curX += vx * dt;
      curY += vy * dt;

      const t = (now - startTime) / life;
      const opacity = Math.max(0, 1 - t);

      el.style.transform = `translate(${curX - startLeft}px, ${curY - startTop}px) translate(-50%,-50%) scale(${1 - t * 0.5})`;
      el.style.opacity = opacity;

      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        el.remove();
      }
    }
    requestAnimationFrame(frame);
  }

  // ============ Вспышка ============
  function spawnFlash(x, y, scale) {
    const el = document.createElement('div');
    el.style.cssText = `
      position: fixed;
      left: ${x}px; top: ${y}px;
      width: ${120 * scale}px; height: ${120 * scale}px;
      margin-left: ${-60 * scale}px; margin-top: ${-60 * scale}px;
      border-radius: 50%;
      background: radial-gradient(circle, rgba(255,255,200,0.95) 0%, rgba(255,214,0,0.6) 30%, transparent 75%);
      pointer-events: none;
      z-index: 15;
      filter: blur(${4 * scale}px);
    `;
    document.body.appendChild(el);

    el.animate(
      [
        { transform: 'scale(0.2)', opacity: 1 },
        { transform: `scale(${2.2 * scale})`, opacity: 0 }
      ],
      { duration: 500, easing: 'cubic-bezier(.2,.8,.3,1)' }
    ).onfinish = () => el.remove();
  }

  // ============ Управление ============
  document.addEventListener('click', (e) => {
    if (e.target.closest('button')) return;
    launchFirework(e.clientX, e.clientY, 1);
  });

  document.getElementById('btn-one').addEventListener('click', () => {
    launchFirework(window.innerWidth / 2, window.innerHeight / 2, 1);
  });

  document.getElementById('btn-big').addEventListener('click', () => {
    const x = window.innerWidth / 2;
    const y = window.innerHeight * 0.45;
    launchFirework(x, y, 1.8);
  });

  // Дождь салютов — каждые 400мс в случайном месте
  let rainTimer = null;
  document.getElementById('btn-rain').addEventListener('click', () => {
    if (rainTimer) return;
    rainTimer = setInterval(() => {
      const x = window.innerWidth * (0.15 + Math.random() * 0.7);
      const y = window.innerHeight * (0.15 + Math.random() * 0.5);
      launchFirework(x, y, 0.7 + Math.random() * 0.7);
    }, 400);
    launchFirework(window.innerWidth / 2, window.innerHeight * 0.4, 1.2);
  });

  document.getElementById('btn-stop').addEventListener('click', () => {
    if (rainTimer) {
      clearInterval(rainTimer);
      rainTimer = null;
    }
  });

  // Первый салют — через пол-секунды
  setTimeout(() => {
    launchFirework(window.innerWidth / 2, window.innerHeight / 2, 1.2);
  }, 500);
})();
</script>
</body>
</html>

отдельная версия, вырезано все лишнее!)))

Не, мне кажется логичнее на матрице светодиодной делать с светорассеивателем.

я все еще не купил матрицу больше чем 8 на 8)))) паять лень!)))
если памяти озу под каждый диод хватит, а лучше по 4 байта на диод, для цвета и яркости, однозначно будет смотреться лучше, так что наверное надо олед дисплей брать…

p.s. и матрица должна быть однозначно большая, а то может даже все таки и хуже будет, при маленькой пикселизации не то…

lilik может ваш код даже гораздо интереснее чем может показаться…

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Салют — искры (оптимизировано)</title>
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

  html, body {
    margin: 0; padding: 0;
    width: 100%; height: 100%;
    overflow: hidden;
    background: radial-gradient(ellipse at 50% 40%, #141a34 0%, #070a1a 55%, #02030a 100%);
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: #fff;
    user-select: none;
    cursor: crosshair;
  }

  #canvas {
    position: fixed;
    inset: 0;
    z-index: 1;
    display: block;
  }

  .info {
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
    pointer-events: none;
    z-index: 100;
  }
  .info h1 {
    margin: 0;
    font-size: 20px;
    letter-spacing: 4px;
    text-transform: uppercase;
    color: #fff;
    text-shadow: 0 0 20px rgba(255,214,0,0.7);
  }
  .info p {
    margin: 6px 0 0;
    font-size: 12px;
    color: #8a8aa0;
    letter-spacing: 1px;
  }
  .info .variant {
    margin-top: 8px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 3px;
    text-transform: uppercase;
    color: #ffd600;
    text-shadow: 0 0 15px rgba(255,214,0,0.8);
    min-height: 18px;
    transition: opacity 0.3s, color 0.3s;
  }

  .controls {
    position: fixed;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    flex-wrap: wrap;
    justify-content: center;
    z-index: 100;
    max-width: 95vw;
  }
  .controls button {
    padding: 12px 22px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 1px;
    text-transform: uppercase;
    border: 1px solid rgba(255,214,0,0.4);
    border-radius: 10px;
    background: rgba(30,20,10,0.7);
    color: #ffe680;
    cursor: pointer;
    backdrop-filter: blur(6px);
    transition: all 0.15s ease;
  }
  .controls button:hover {
    background: rgba(255,180,0,0.25);
    border-color: rgba(255,214,0,0.9);
    box-shadow: 0 0 20px rgba(255,214,0,0.5);
  }
  .controls button:active {
    transform: scale(0.96);
  }
</style>
</head>
<body>

<canvas id="canvas"></canvas>

<div class="info">
  <h1>Салют</h1>
  <p>Клик в любом месте — залп с полётом</p>
  <div class="variant" id="variant-name"></div>
</div>

<div class="controls">
  <button id="btn-fire">Залп</button>
  <button id="btn-big">Большой взрыв</button>
</div>

<script>
(() => {
  'use strict';

  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d', { alpha: true });

  let W = 0, H = 0, DPR = 1;

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth;
    H = window.innerHeight;
    canvas.width = W * DPR;
    canvas.height = H * DPR;
    canvas.style.width = W + 'px';
    canvas.style.height = H + 'px';
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  const COLORS = [
    '#ffd600', '#ff8a00', '#ff3060', '#4aa8ff',
    '#8affc8', '#c88aff', '#ffffff', '#ff66cc',
    '#66ff66', '#ff4444', '#00ffff', '#ffaa00',
  ];
  const COLORS_RGB = COLORS.map(hexToRgb);

  const rand = (min, max) => Math.random() * (max - min) + min;
  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

  function hexToRgb(hex) {
    const n = parseInt(hex.slice(1), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }

  // ============================================================
  // ЕДИНЫЙ ПУЛ ЧАСТИЦ
  // ============================================================
  const MAX_PARTICLES = 2200;
  const particles = [];   // активные искры
  const pool = [];        // переиспользуемые объекты

  function acquireParticle() {
    if (pool.length > 0) return pool.pop();
    return {
      x: 0, y: 0, vx: 0, vy: 0, startX: 0, startY: 0,
      color: '#fff', colorRgb: [255, 255, 255],
      size: 2, life: 1, age: 0,
      gravity: 200, friction: 0.985, trailLength: 4,
      flicker: false, flickerSeed: 0, alive: false,
      prevX: 0, prevY: 0,
    };
  }

  function releaseParticle(p) {
    p.alive = false;
    if (pool.length < MAX_PARTICLES) pool.push(p);
  }

  function spawnSpark(x, y, dx, dy, color, size, life, options = {}) {
    if (particles.length >= MAX_PARTICLES) return;
    const p = acquireParticle();
    p.x = x; p.y = y; p.startX = x; p.startY = y;
    p.prevX = x; p.prevY = y;
    p.vx = dx; p.vy = dy;
    p.color = color;
    p.colorRgb = hexToRgb(color);
    p.size = size;
    p.life = life / 1000; // в секундах
    p.age = 0;
    p.gravity = options.gravity !== undefined ? options.gravity : 220;
    p.friction = options.friction !== undefined ? options.friction : 0.985;
    p.trailLength = options.trailLength || 3.5;
    p.flicker = options.flicker || false;
    p.flickerSeed = Math.random() * 1000;
    p.alive = true;
    particles.push(p);
  }

  // ============================================================
  // РАКЕТЫ
  // ============================================================
  const rockets = [];

  function launchRocket(targetX, targetY, scale = 1, forceVariant = null) {
    const startX = targetX + rand(-100, 100) * scale;
    const startY = H + 20;
    const controlX = (startX + targetX) / 2 + rand(-150, 150) * scale;
    const controlY = Math.min(startY, targetY) - rand(100, 300) * scale;

    const variantIndex = forceVariant !== null
      ? forceVariant
      : Math.floor(Math.random() * VARIANTS.length);

    rockets.push({
      startX, startY, targetX, targetY,
      controlX, controlY,
      flightTime: rand(0.6, 1.1),
      age: 0,
      scale,
      color: pick(COLORS),
      colorRgb: hexToRgb(pick(COLORS)),
      variantIndex,
      lastTrail: 0,
    });

    // Показываем название варианта сразу
    const nameEl = document.getElementById('variant-name');
    nameEl.textContent = VARIANTS[variantIndex].name;
    nameEl.style.opacity = '1';
  }

  function updateRockets(dt, now) {
    for (let i = rockets.length - 1; i >= 0; i--) {
      const r = rockets[i];
      r.age += dt;
      const t = Math.min(r.age / r.flightTime, 1);
      const mt = 1 - t;
      r.x = mt * mt * r.startX + 2 * mt * t * r.controlX + t * t * r.targetX;
      r.y = mt * mt * r.startY + 2 * mt * t * r.controlY + t * t * r.targetY;

      // След ракеты — искра раз в ~25мс
      if (now - r.lastTrail > 25) {
        r.lastTrail = now;
        spawnSpark(
          r.x, r.y,
          rand(-25, 25), rand(30, 70),
          r.color, rand(2, 4) * r.scale, rand(200, 450),
          { gravity: 180, friction: 0.97, trailLength: 5, flicker: true }
        );
      }

      if (t >= 1) {
        // Взрыв
        const variant = VARIANTS[r.variantIndex];
        spawnFlash(r.targetX, r.targetY, r.scale);
        spawnShockwave(r.targetX, r.targetY, r.scale);
        variant.fn(r.targetX, r.targetY, r.scale);
        // Дым
        for (let j = 0; j < 5 * r.scale; j++) {
          spawnSmoke(r.targetX + rand(-30, 30), r.targetY + rand(-30, 30), r.scale);
        }
        rockets.splice(i, 1);
      }
    }
  }

  // ============================================================
  // ВСПЫШКИ, УДАРНЫЕ ВОЛНЫ, ДЫМ
  // ============================================================
  const flashes = [];
  const shockwaves = [];
  const smokes = [];

  function spawnFlash(x, y, scale, color = [255, 248, 192]) {
    flashes.push({ x, y, scale, age: 0, life: 0.5, color });
  }
  function spawnShockwave(x, y, scale) {
    shockwaves.push({ x, y, scale, age: 0, life: 0.65 });
  }
  function spawnSmoke(x, y, scale) {
    if (smokes.length > 80) return;
    smokes.push({
      x, y, scale, age: 0,
      life: rand(0.9, 1.6),
      driftX: rand(-40, 40), driftY: rand(-30, 30),
      size: rand(12, 28) * scale,
    });
  }

  // ============================================================
  // ВАРИАНТЫ САЛЮТА
  // ============================================================
  function variantPion(x, y, scale) {
    const baseColor = pick(COLORS);
    const count = Math.floor(70 * scale);
    for (let i = 0; i < count; i++) {
      const angle = (Math.PI * 2 * i) / count + rand(-0.08, 0.08);
      const speed = rand(180, 320) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        Math.random() < 0.7 ? baseColor : pick(COLORS),
        rand(2.5, 4.5) * scale, rand(1100, 1700),
        { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
    }
  }

  function variantIva(x, y, scale) {
    const color = pick(COLORS);
    const count = Math.floor(55 * scale);
    for (let i = 0; i < count; i++) {
      const angle = (Math.PI * 2 * i) / count + rand(-0.1, 0.1);
      const speed = rand(120, 220) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        Math.random() < 0.8 ? color : '#ffffff',
        rand(2, 3.5) * scale, rand(2000, 3000),
        { gravity: 120, friction: 0.975, trailLength: 6, flicker: true });
    }
  }

  function variantKolec(x, y, scale) {
    const color1 = pick(COLORS);
    const color2 = pick(COLORS);
    const count = Math.floor(60 * scale);
    const tilt = rand(0.3, 0.7);
    const rotation = rand(0, Math.PI);
    const cosR = Math.cos(rotation), sinR = Math.sin(rotation);
    for (let i = 0; i < count; i++) {
      const angle = (Math.PI * 2 * i) / count;
      const speed = rand(200, 280) * scale;
      let ex = Math.cos(angle) * speed;
      let ey = Math.sin(angle) * speed * tilt;
      const rx = ex * cosR - ey * sinR;
      const ry = ex * sinR + ey * cosR;
      spawnSpark(x, y, rx, ry, i % 2 === 0 ? color1 : color2,
        rand(2.5, 4) * scale, rand(1300, 1900),
        { gravity: 160, friction: 0.983, trailLength: 4, flicker: true });
    }
  }

  function variantPulsar(x, y, scale) {
    const color = pick(COLORS);
    const waves = 3; // уменьшили с 4
    for (let w = 0; w < waves; w++) {
      setTimeout(() => {
        const count = Math.floor((40 - w * 8) * scale);
        const speedMul = 1 + w * 0.35;
        for (let i = 0; i < count; i++) {
          const angle = (Math.PI * 2 * i) / count + rand(-0.1, 0.1);
          const speed = rand(140, 240) * scale * speedMul;
          spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
            w === 0 ? '#ffffff' : (Math.random() < 0.6 ? color : pick(COLORS)),
            rand(2, 4) * scale, rand(1000, 1600),
            { gravity: 200, friction: 0.984, trailLength: 4, flicker: true });
        }
      }, w * 200);
    }
  }

  function variantHrizantema(x, y, scale) {
    const color = pick(COLORS);
    const count = Math.floor(50 * scale);
    for (let i = 0; i < count; i++) {
      const angle = (Math.PI * 2 * i) / count + rand(-0.05, 0.05);
      const speed = rand(220, 360) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        Math.random() < 0.75 ? color : pick(COLORS),
        rand(2.5, 4) * scale, rand(1800, 2600),
        { gravity: 60, friction: 0.96, trailLength: 7, flicker: true });
    }
  }

  function variantDvoinoy(x, y, scale) {
    const color1 = pick(COLORS);
    const color2 = pick(COLORS);
    const outerCount = Math.floor(55 * scale);
    for (let i = 0; i < outerCount; i++) {
      const angle = (Math.PI * 2 * i) / outerCount + rand(-0.08, 0.08);
      const speed = rand(240, 340) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        color1, rand(2.5, 4) * scale, rand(1300, 1800),
        { gravity: 200, friction: 0.983, trailLength: 4, flicker: true });
    }
    setTimeout(() => {
      const innerCount = Math.floor(40 * scale);
      for (let i = 0; i < innerCount; i++) {
        const angle = (Math.PI * 2 * i) / innerCount + rand(-0.1, 0.1);
        const speed = rand(120, 200) * scale;
        spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
          color2, rand(3, 5) * scale, rand(1500, 2100),
          { gravity: 160, friction: 0.98, trailLength: 5, flicker: true });
      }
    }, 150);
  }

  function variantSpiral(x, y, scale) {
    const color = pick(COLORS);
    const count = Math.floor(65 * scale);
    const turns = 3;
    for (let i = 0; i < count; i++) {
      const progress = i / count;
      const angle = progress * Math.PI * 2 * turns;
      const speed = rand(180, 300) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        Math.random() < 0.7 ? color : pick(COLORS),
        rand(2.5, 4) * scale, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  function variantMolnija(x, y, scale) {
    const color = pick(COLORS);
    const count = Math.floor(80 * scale);
    for (let i = 0; i < count; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = rand(100, 400) * scale;
      spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
        Math.random() < 0.5 ? '#ffffff' : color,
        rand(2, 4) * scale, rand(800, 1800),
        { gravity: 280, friction: 0.978, trailLength: 6, flicker: true });
    }
  }

  const VARIANTS = [
    { name: 'Пион', fn: variantPion },
    { name: 'Ива', fn: variantIva },
    { name: 'Кольцо', fn: variantKolec },
    { name: 'Пульсар', fn: variantPulsar },
    { name: 'Хризантема', fn: variantHrizantema },
    { name: 'Двойной', fn: variantDvoinoy },
    { name: 'Спираль', fn: variantSpiral },
    { name: 'Шаровая молния', fn: variantMolnija },
  ];

  // ============================================================
  // БОЛЬШОЙ ВЗРЫВ — облегчённая версия
  // ============================================================
  function launchBigExplosion(x, y) {
    const nameEl = document.getElementById('variant-name');
    nameEl.textContent = '★ БОЛЬШОЙ ВЗРЫВ ★';
    nameEl.style.opacity = '1';
    nameEl.style.color = '#ff4400';

    // 5 ракет вокруг (было 6, меньше частиц)
    const count = 5;
    for (let i = 0; i < count; i++) {
      setTimeout(() => {
        const angle = (Math.PI * 2 * i) / count;
        const dist = rand(150, 260);
        const tx = x + Math.cos(angle) * dist;
        const ty = y + Math.sin(angle) * dist * 0.6;
        launchRocket(tx, ty, rand(0.9, 1.2), i % VARIANTS.length);
      }, i * 180);
    }

    // Центральный мега-взрыв — только 2 варианта + финальный залп
    setTimeout(() => {
      spawnFlash(x, y, 3.5);
      spawnShockwave(x, y, 3.5);

      variantPion(x, y, 2.0);
      setTimeout(() => variantHrizantema(x, y, 1.8), 150);
      setTimeout(() => variantMolnija(x, y, 1.6), 300);

      // Финальный залп — 120 искр (было 200)
      setTimeout(() => {
        const finalCount = 120;
        for (let i = 0; i < finalCount; i++) {
          const angle = (Math.PI * 2 * i) / finalCount + rand(-0.05, 0.05);
          const speed = rand(300, 550);
          spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
            pick(COLORS), rand(2, 4), rand(1800, 2600),
            { gravity: 150, friction: 0.98, trailLength: 5, flicker: true });
        }
      }, 550);

      for (let i = 0; i < 12; i++) {
        spawnSmoke(x + rand(-80, 80), y + rand(-80, 80), 2.2);
      }
    }, 1100);

    setTimeout(() => {
      nameEl.style.color = '#ffd600';
    }, 4000);
  }

  // ============================================================
  // ОТРИСОВКА
  // ============================================================
  function drawParticle(p) {
    const t = p.age / p.life;
    if (t >= 1) return;

    const opacity = 1 - t;
    const [r, g, b] = p.colorRgb;

    // Мерцание
    let alpha = opacity;
    if (p.flicker) {
      alpha *= 0.55 + 0.45 * Math.abs(Math.sin(p.flickerSeed + p.age * 40));
    }

    const dx = p.x - p.prevX;
    const dy = p.y - p.prevY;
    const dist = Math.sqrt(dx * dx + dy * dy);

    // Ядро искры (круг)
    const coreSize = p.size * (1 - t * 0.6);
    if (coreSize > 0.3) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, coreSize, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
      ctx.fill();
    }

    // Хвост (линия)
    if (dist > 0.5) {
      const stretch = Math.min(1 + dist * p.trailLength * 0.35, 10);
      const tailX = p.x - dx * stretch;
      const tailY = p.y - dy * stretch;

      const gradient = ctx.createLinearGradient(p.x, p.y, tailX, tailY);
      gradient.addColorStop(0, `rgba(${r},${g},${b},${alpha})`);
      gradient.addColorStop(0.4, `rgba(${r},${g},${b},${alpha * 0.5})`);
      gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);

      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(tailX, tailY);
      ctx.strokeStyle = gradient;
      ctx.lineWidth = coreSize * 1.2;
      ctx.lineCap = 'round';
      ctx.stroke();
    }

    // Лёгкое свечение (только для ярких искр, через globalCompositeOperation)
    if (coreSize > 1.5) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, coreSize * 2.5, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${r},${g},${b},${alpha * 0.15})`;
      ctx.fill();
    }
  }

  function drawFlash(f) {
    const t = f.age / f.life;
    if (t >= 1) return;
    const alpha = 1 - t;
    const size = 160 * f.scale * (0.3 + t * 1.8);
    const [r, g, b] = f.color;
    const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, size);
    grad.addColorStop(0, `rgba(255,255,220,${alpha * 0.95})`);
    grad.addColorStop(0.3, `rgba(${r},${g},${b},${alpha * 0.6})`);
    grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
    ctx.beginPath();
    ctx.arc(f.x, f.y, size, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  function drawShockwave(s) {
    const t = s.age / s.life;
    if (t >= 1) return;
    const alpha = 1 - t;
    const radius = 5 * s.scale * (0.5 + t * 10);
    ctx.beginPath();
    ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(255,255,200,${alpha * 0.7})`;
    ctx.lineWidth = Math.max(0.5, 3 * (1 - t));
    ctx.stroke();
  }

  function drawSmoke(sm) {
    const t = sm.age / sm.life;
    if (t >= 1) return;
    const alpha = (1 - t) * 0.35;
    const size = sm.size * (1 + t * 1.8);
    const x = sm.x + sm.driftX * t;
    const y = sm.y + sm.driftY * t;
    const grad = ctx.createRadialGradient(x, y, 0, x, y, size);
    grad.addColorStop(0, `rgba(120,120,140,${alpha})`);
    grad.addColorStop(0.5, `rgba(80,80,100,${alpha * 0.5})`);
    grad.addColorStop(1, 'rgba(80,80,100,0)');
    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  function drawRocket(r) {
    if (r.x === undefined) return;
    // Ядро ракеты
    ctx.beginPath();
    ctx.arc(r.x, r.y, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();

    // Свечение
    const [cr, cg, cb] = r.colorRgb;
    const grad = ctx.createRadialGradient(r.x, r.y, 0, r.x, r.y, 20);
    grad.addColorStop(0, `rgba(255,255,200,0.9)`);
    grad.addColorStop(0.3, `rgba(${cr},${cg},${cb},0.5)`);
    grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
    ctx.beginPath();
    ctx.arc(r.x, r.y, 20, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  // ============================================================
  // ГЛАВНЫЙ ЦИКЛ
  // ============================================================
  let lastTime = performance.now();

  function loop(now) {
    const dt = Math.min((now - lastTime) / 1000, 0.05);
    lastTime = now;

    // Очистка
    ctx.clearRect(0, 0, W, H);

    // Обновление ракет
    updateRockets(dt, now);

    // Обновление частиц
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.prevX = p.x;
      p.prevY = p.y;
      p.age += dt;
      if (p.age >= p.life) {
        releaseParticle(p);
        particles.splice(i, 1);
        continue;
      }
      p.vy += p.gravity * dt;
      const fr = Math.pow(p.friction, dt * 60);
      p.vx *= fr;
      p.vy *= fr;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
    }

    // Обновление вспышек
    for (let i = flashes.length - 1; i >= 0; i--) {
      flashes[i].age += dt;
      if (flashes[i].age >= flashes[i].life) flashes.splice(i, 1);
    }
    // Обновление ударных волн
    for (let i = shockwaves.length - 1; i >= 0; i--) {
      shockwaves[i].age += dt;
      if (shockwaves[i].age >= shockwaves[i].life) shockwaves.splice(i, 1);
    }
    // Обновление дыма
    for (let i = smokes.length - 1; i >= 0; i--) {
      smokes[i].age += dt;
      if (smokes[i].age >= smokes[i].life) smokes.splice(i, 1);
    }

    // -------- Рисование --------

    // Дым — снизу
    for (let i = 0; i < smokes.length; i++) drawSmoke(smokes[i]);

    // Свечение: используем 'lighter' для искр
    ctx.globalCompositeOperation = 'lighter';

    // Ударные волны
    for (let i = 0; i < shockwaves.length; i++) drawShockwave(shockwaves[i]);

    // Вспышки
    for (let i = 0; i < flashes.length; i++) drawFlash(flashes[i]);

    // Ракеты
    for (let i = 0; i < rockets.length; i++) drawRocket(rockets[i]);

    // Частицы
    for (let i = 0; i < particles.length; i++) drawParticle(particles[i]);

    ctx.globalCompositeOperation = 'source-over';

    requestAnimationFrame(loop);
  }

  // ============================================================
  // УПРАВЛЕНИЕ
  // ============================================================
  document.addEventListener('click', (e) => {
    if (e.target.closest('button')) return;
    launchRocket(e.clientX, e.clientY, 1);
  });

  document.getElementById('btn-fire').addEventListener('click', () => {
    const x = W * rand(0.25, 0.75);
    const y = H * rand(0.2, 0.5);
    launchRocket(x, y, rand(1.0, 1.4));
  });

  document.getElementById('btn-big').addEventListener('click', () => {
    launchBigExplosion(W / 2, H * 0.4);
  });

  // Первый салют
  setTimeout(() => {
    launchRocket(W / 2, H * 0.45, 1.2);
  }, 600);

  // Запуск главного цикла
  requestAnimationFrame(loop);

})();
</script>
</body>
</html>

вот это посмотрите ? естественно через strl и колесико масштаб надо подобрать…
яркость ведь у вас тоже работает ? а версия оптимизирована ? не лагает ? что то толковое по итогу появилось желание сделать ?))) с салютами, но в итоге с запуском наверное на телефоне, все таки esp такое наверное не осилит…

добавление белого цвета может даже частично заменить эффекты управление яркостью олед экранов, времени нет, но кажется так салюты начинают смотреться норм даже на маленьком тфт, найти бы того кто версию до ума доведет…)))

p.s. ну и бонусом игра! кликайте по экрану, и щелкайте мышкой, туда прилетит салют, без очков… все времени нет, lilik на вас вся надежда, что найдете время, продумайте все, может даже правки внесете, или варианты другие, я в вас верю!)))

Салют красиво, но через 10 секунд уже надоедает. Концепт не придумывается. Процесс должен быть переменчивым - течение-падение струй воды, горение пламени, генерация узоров, попытки остановить шарик на секторе :slight_smile:

для этого надо найти время на проект, не меньше 10 часов!)))
а еще салют может передавать время))))
эээх такая красота не увидит завершения…

Ну вот кстати сделать приложение салют-часы хорошая идея…и в плане восприятия времени тоже.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { margin:0; background:#000; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; font-family:monospace; }
canvas {
    border-radius:50%;
    box-shadow:0 0 40px #0ff;
    filter: blur(1.2px) saturate(1.6) brightness(1.15);
}
button { margin-top:15px; padding:10px 25px; background:cyan; border:none; border-radius:20px; font-weight:bold; cursor:pointer; }
</style>
</head>
<body>
<canvas id="c" width="240" height="240"></canvas>
<button onclick="restart()">🔄 ПЕРЕЗАПУСК</button>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 240, H = 240;
let particles = [], raf;

function rnd(a, b) { return a + Math.random() * (b - a); }

// Поле углов на основе синусоидального шума
function fieldAngle(x, y, t) {
    return Math.sin(x * 0.04 + t * 0.5) * Math.cos(y * 0.04 - t * 0.3) * Math.PI
         + Math.sin((x + y) * 0.02 + t * 0.2) * Math.PI * 0.7;
}

function initParticles() {
    particles = [];
    for (let i = 0; i < 1200; i++) {
        particles.push({
            x: rnd(0, W),
            y: rnd(0, H),
            hue: rnd(0, 360),
            speed: rnd(1.0, 2.2)
        });
    }
}

function draw() {
    // Лёгкое затемнение вместо полной очистки — накопление свечения
    ctx.fillStyle = 'rgba(0,0,0,0.045)';
    ctx.fillRect(0, 0, W, H);

    const t = performance.now() * 0.001;
    for (let p of particles) {
        let a = fieldAngle(p.x, p.y, t);
        let nx = p.x + Math.cos(a) * p.speed;
        let ny = p.y + Math.sin(a) * p.speed;

        // Перенос через границы
        if (nx < 0) nx += W; if (nx > W) nx -= W;
        if (ny < 0) ny += H; if (ny > H) ny -= H;

        ctx.strokeStyle = `hsla(${p.hue}, 100%, 60%, 0.18)`;
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(nx, ny);
        ctx.stroke();

        // Медленная эволюция цвета
        p.hue = (p.hue + 0.15) % 360;
        p.x = nx;
        p.y = ny;
    }
    raf = requestAnimationFrame(draw);
}

function restart() {
    cancelAnimationFrame(raf);
    ctx.fillStyle = 'black'; ctx.fillRect(0, 0, W, H);
    initParticles();
    draw();
}

restart();
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { margin:0; background:#000; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; font-family:monospace; }
canvas {
    border-radius:50%;
    box-shadow:0 0 40px #0ff;
    filter: blur(1.1px) saturate(1.55) brightness(1.1);
}
button { margin-top:15px; padding:10px 25px; background:cyan; border:none; border-radius:20px; font-weight:bold; cursor:pointer; }
</style>
</head>
<body>
<canvas id="c" width="240" height="240"></canvas>
<button onclick="restart()">🔄 ПЕРЕЗАПУСК</button>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 240, H = 240, NUM = 12, K = 3;
let points = [], particles = [], raf;

function rnd(a, b) { return a + Math.random() * (b - a); }

function initPoints() {
    points = [];
    for (let i = 0; i < NUM; i++) {
        points.push({
            x: rnd(40, W - 40),
            y: rnd(40, H - 40),
            hue: rnd(0, 360)
        });
    }
}

function initParticles() {
    particles = [];
    for (let i = 0; i < 1200; i++) {
        particles.push({ x: rnd(0, W), y: rnd(0, H), speed: rnd(0.8, 1.8) });
    }
}

// Находим владельца пикселя по k-NN (как в предыдущем файле),
// но теперь это задаёт "цвет" и "центр притяжения"
function findKnnOwner(x, y) {
    let bestSum = Infinity, bestIdx = 0;
    for (let i = 0; i < NUM; i++) {
        let dists = [];
        for (let j = 0; j < NUM; j++) {
            if (i === j) continue;
            dists.push(Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y));
        }
        dists.sort((a, b) => a - b);
        let sum = 0;
        for (let k = 0; k < Math.min(K, dists.length); k++) sum += dists[k];
        sum += Math.hypot(x - points[i].x, y - points[i].y) * 0.5;
        if (sum < bestSum) { bestSum = sum; bestIdx = i; }
    }
    return bestIdx;
}

function draw() {
    ctx.fillStyle = 'rgba(0,0,0,0.045)';
    ctx.fillRect(0, 0, W, H);

    for (let p of particles) {
        // Владелец определяет цвет и точку притяжения
        let idx = findKnnOwner(p.x, p.y);
        let target = points[idx];

        // Плавное притяжение к владельцу + завихрение вокруг него
        let dx = target.x - p.x;
        let dy = target.y - p.y;
        let d = Math.hypot(dx, dy) + 0.001;
        // Тангенциальная составляющая создаёт закручивание
        let tx = -dy / d, ty = dx / d;
        let pull = 0.05;
        let swirl = 0.35;

        let nx = p.x + dx * pull + tx * swirl * p.speed;
        let ny = p.y + dy * pull + ty * swirl * p.speed;

        if (nx < 0) nx += W; if (nx > W) nx -= W;
        if (ny < 0) ny += H; if (ny > H) ny -= H;

        ctx.strokeStyle = `hsla(${target.hue}, 100%, 62%, 0.16)`;
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(nx, ny);
        ctx.stroke();

        p.x = nx;
        p.y = ny;
        // Медленная эволюция точки-владельца
        target.hue = (target.hue + 0.1) % 360;
    }
    raf = requestAnimationFrame(draw);
}

function restart() {
    cancelAnimationFrame(raf);
    ctx.fillStyle = 'black'; ctx.fillRect(0, 0, W, H);
    initPoints();
    initParticles();
    draw();
}

restart();
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { margin:0; background:#000; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; font-family:monospace; }
canvas {
    border-radius:50%;
    box-shadow:0 0 45px #f0f;
    filter: blur(1.0px) saturate(1.7) brightness(1.15);
}
button { margin-top:15px; padding:10px 25px; background:magenta; border:none; border-radius:20px; font-weight:bold; cursor:pointer; }
</style>
</head>
<body>
<canvas id="c" width="240" height="240"></canvas>
<button onclick="restart()">🔄 ПЕРЕЗАПУСК</button>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 240, H = 240, NUM = 12;
let points = [], particles = [], raf;

function rnd(a, b) { return a + Math.random() * (b - a); }

function initPoints() {
    points = [];
    for (let i = 0; i < NUM; i++) {
        points.push({
            x: rnd(40, W - 40),
            y: rnd(40, H - 40),
            weight: Math.random(),      // вес 0..1
            hue: rnd(0, 360)
        });
    }
}

function initParticles() {
    particles = [];
    for (let i = 0; i < 1200; i++) {
        particles.push({ x: rnd(0, W), y: rnd(0, H), speed: rnd(0.6, 2.0) });
    }
}

// Взвешенное расстояние: чем больше вес, тем сильнее притягивает
function weightedDist(x, y, p) {
    let d = Math.hypot(x - p.x, y - p.y);
    return d - p.weight * 40;
}

function findWeightedOwner(x, y) {
    let best = 0, bestD = weightedDist(x, y, points[0]);
    for (let i = 1; i < NUM; i++) {
        let d = weightedDist(x, y, points[i]);
        if (d < bestD) { bestD = d; best = i; }
    }
    return best;
}

function draw() {
    ctx.fillStyle = 'rgba(0,0,0,0.045)';
    ctx.fillRect(0, 0, W, H);

    for (let p of particles) {
        let idx = findWeightedOwner(p.x, p.y);
        let target = points[idx];

        let dx = target.x - p.x;
        let dy = target.y - p.y;
        let d = Math.hypot(dx, dy) + 0.001;
        let tx = -dy / d, ty = dx / d;

        // Чем больше вес, тем сильнее притяжение и закрутка
        let pull = 0.02 + target.weight * 0.08;
        let swirl = 0.2 + target.weight * 0.6;

        let nx = p.x + dx * pull + tx * swirl * p.speed;
        let ny = p.y + dy * pull + ty * swirl * p.speed;

        if (nx < 0) nx += W; if (nx > W) nx -= W;
        if (ny < 0) ny += H; if (ny > H) ny -= H;

        // Яркость зависит от веса
        let light = 45 + target.weight * 30;
        ctx.strokeStyle = `hsla(${target.hue}, 100%, ${light}%, 0.17)`;
        ctx.lineWidth = 1.0 + target.weight * 1.5;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(nx, ny);
        ctx.stroke();

        p.x = nx;
        p.y = ny;
        target.hue = (target.hue + 0.12) % 360;
    }
    raf = requestAnimationFrame(draw);
}

function restart() {
    cancelAnimationFrame(raf);
    ctx.fillStyle = 'black'; ctx.fillRect(0, 0, W, H);
    initPoints();
    initParticles();
    draw();
}

restart();
</script>
</body>
</html>

визуализация! надо разноцветное поле, что бы брать области вот, только не забывайте перезапускать анимацию!

насколько я понял там именно так, скрытая анимация вращается и берутся цвета, ну а если не то, то я предупреждал, не понимаю я работу того кода)))

Речь не о том коде, а о диаграмме Вороного.

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

при чем я не могу понять где этот рисунок!))) такое ощущение что он прячется в 4d пространстве… понятно что не отображается… но вот как выглядит и зачем он…

переходите на салюты!))) ну в крайнем случае можно поисковик данных на ней попытаться сделать, чисто теоретически…! но как не знаю!)))

Нет, речь идёт просто о получении других по аналогии с этой. Статичная картинка по множеству случайно выбранных точек.


Точки задают области , которые можно раскрашивать. Суть получить другие по форме границы областей.

<!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">
<style>
body {
    margin: 0; padding: 10px; background: black;
    display: flex; flex-direction: column; align-items: center;
    min-height: 100vh; font-family: monospace; box-sizing: border-box;
}
.center-block { display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 600px; }
canvas {
    display: block;
    width: min(90vw, 90vh);
    height: min(90vw, 90vh);
    border-radius: 50%;
    box-shadow: 0 0 30px cyan;
    margin: 10px 0;
}
.button-row { display: flex; gap: 10px; margin: 15px 0; width: 100%; }
.action-button {
    flex: 1; background: #00ffff; color: black; padding: 12px; border: none;
    border-radius: 30px; cursor: pointer; font-size: 14px; font-weight: bold;
    transition: all 0.3s ease; text-align: center; white-space: nowrap;
}
.action-button:hover { background-color: #00cccc; transform: scale(1.02); }
.action-button.reset { background: #ff4444; color: white; }
.action-button.pause { background: #ffaa00; color: black; }
.action-button.pause.active { background: #ff5500; box-shadow: 0 0 20px #ff5500; }

.info-panel {
    width: 100%; max-width: 600px; background: #111; border: 2px solid cyan;
    border-radius: 20px; padding: 15px; box-sizing: border-box;
    box-shadow: 0 0 30px rgba(0,255,255,0.3);
    display: flex; flex-direction: column; gap: 12px;
}
.legend { display: flex; flex-direction: column; gap: 6px; color: #ccc; font-size: 12px; }
.legend-row { display: flex; align-items: center; gap: 8px; }
.legend-dot { width: 14px; height: 14px; border-radius: 50%; }
#status { color: cyan; font-size: 12px; text-align: center; padding: 8px; background: #1a1a1a; border-radius: 15px; }
</style>
</head>
<body>
    <div class="center-block">
        <canvas id="c" width="240" height="240"></canvas>

        <div class="button-row">
            <button id="restartButton" class="action-button reset">🔄 ПЕРЕЗАПУСК</button>
            <button id="pauseButton" class="action-button pause">⏸️ ПАУЗА</button>
        </div>

        <div class="info-panel">
            <div class="legend">
                <div class="legend-row"><div class="legend-dot" style="background:#fff; box-shadow:0 0 8px #fff;"></div> Точки массива (центры ячеек)</div>
                <div class="legend-row"><div class="legend-dot" style="background:#00ff88; box-shadow:0 0 8px #00ff88;"></div> Соединения между «соседями» (рёбра Делоне)</div>
                <div class="legend-row"><div class="legend-dot" style="background:#ff3355; box-shadow:0 0 8px #ff3355;"></div> Границы ячеек Вороного</div>
                <div class="legend-row"><div class="legend-dot" style="background:linear-gradient(90deg,#f0f,#0ff,#ff0);"></div> Заливка ячеек — цвет своей точки</div>
            </div>
            <div id="status">Загрузка...</div>
        </div>
    </div>

<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const restartButton = document.getElementById('restartButton');
const pauseButton = document.getElementById('pauseButton');

const W = 240, H = 240;
let points = [], basePositions = [];
let NUM_POINTS = 12;
let breathAmp = 12;   // амплитуда дыхания
let isPaused = false;
let raf;

function rnd(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }

function initPoints() {
    points = [];
    for (let i = 0; i < NUM_POINTS; i++) {
        points.push({
            x: rnd(40, W - 40),
            y: rnd(40, H - 40),
            R: rnd(60, 255),
            G: rnd(60, 255),
            B: rnd(60, 255)
        });
    }
    basePositions = points.map(p => ({ x: p.x, y: p.y }));
}

function dist(x1, y1, x2, y2) {
    return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
}

// Находим владельца пикселя (для заливки)
function owner(x, y) {
    let best = 0, bestD = dist(x, y, points[0].x, points[0].y);
    for (let i = 1; i < NUM_POINTS; i++) {
        let d = dist(x, y, points[i].x, points[i].y);
        if (d < bestD) { bestD = d; best = i; }
    }
    return best;
}

// Рисуем всё: заливку, границы, рёбра Делоне, точки
function draw() {
    if (isPaused) return;

    // Дыхание точек
    let time = performance.now() * 0.0008;
    for (let i = 0; i < NUM_POINTS; i++) {
        let phase = i * 1.7;
        points[i].x = basePositions[i].x + Math.cos(time + phase) * breathAmp;
        points[i].y = basePositions[i].y + Math.sin(time * 1.3 + phase) * breathAmp;
    }

    // 1) Заливка ячеек Вороного
    const img = ctx.createImageData(W, H);
    const data = img.data;
    const owners = new Int16Array(W * H);
    for (let y = 0; y < H; y++) {
        for (let x = 0; x < W; x++) {
            let k = owner(x, y);
            owners[y * W + x] = k;
            let idx = (y * W + x) * 4;
            let p = points[k];
            data[idx] = p.R * 0.35;     // приглушаем заливку,
            data[idx + 1] = p.G * 0.35; // чтобы графика поверх была видна
            data[idx + 2] = p.B * 0.35;
            data[idx + 3] = 255;
        }
    }
    ctx.putImageData(img, 0, 0);

    // 2) Границы ячеек — красные
    ctx.fillStyle = '#ff3355';
    for (let y = 1; y < H - 1; y++) {
        for (let x = 1; x < W - 1; x++) {
            let k = owners[y * W + x];
            if (owners[y * W + x - 1] !== k ||
                owners[y * W + x + 1] !== k ||
                owners[(y - 1) * W + x] !== k ||
                owners[(y + 1) * W + x] !== k) {
                ctx.fillRect(x, y, 1, 1);
            }
        }
    }

    // 3) Рёбра Делоне: соединяем точки, у которых есть общая граница.
    //    Проверяем среднюю точку между парой: если она равноудалена
    //    (в пределах допуска) — значит, эти две точки соседи.
    ctx.strokeStyle = '#00ff88';
    ctx.lineWidth = 1;
    for (let i = 0; i < NUM_POINTS; i++) {
        for (let j = i + 1; j < NUM_POINTS; j++) {
            let mx = (points[i].x + points[j].x) / 2;
            let my = (points[i].y + points[j].y) / 2;
            let dI = dist(mx, my, points[i].x, points[i].y);
            let dJ = dist(mx, my, points[j].x, points[j].y);

            // Проверяем, нет ли третьей точки ближе к середине
            let hasCloser = false;
            for (let k = 0; k < NUM_POINTS; k++) {
                if (k === i || k === j) continue;
                if (dist(mx, my, points[k].x, points[k].y) < dI - 0.5) {
                    hasCloser = true; break;
                }
            }
            if (!hasCloser) {
                ctx.beginPath();
                ctx.moveTo(points[i].x, points[i].y);
                ctx.lineTo(points[j].x, points[j].y);
                ctx.stroke();
            }
        }
    }

    // 4) Сами точки — белые кружки с цветным ореолом
    for (let i = 0; i < NUM_POINTS; i++) {
        let p = points[i];
        // Ореол цвета точки
        ctx.beginPath();
        ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${p.R},${p.G},${p.B},0.6)`;
        ctx.fill();
        // Белая сердцевина
        ctx.beginPath();
        ctx.arc(p.x, p.y, 2.2, 0, Math.PI * 2);
        ctx.fillStyle = 'white';
        ctx.fill();
    }

    // Статус
    statusEl.textContent = `Точек: ${NUM_POINTS} | Дыхание: ${breathAmp}px | Время: ${(time).toFixed(1)}`;

    raf = requestAnimationFrame(draw);
}

function togglePause() {
    isPaused = !isPaused;
    if (isPaused) {
        pauseButton.classList.add('active');
        pauseButton.textContent = '▶️ ПУСК';
        cancelAnimationFrame(raf);
    } else {
        pauseButton.classList.remove('active');
        pauseButton.textContent = '⏸️ ПАУЗА';
        draw();
    }
}

function restart() {
    cancelAnimationFrame(raf);
    isPaused = false;
    pauseButton.classList.remove('active');
    pauseButton.textContent = '⏸️ ПАУЗА';
    initPoints();
    draw();
}

restartButton.addEventListener('click', restart);
pauseButton.addEventListener('click', togglePause);

restart();
</script>
</body>
</html>

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

Красивая иллюстрация к Вороному. Но вопрос не в этом. А в аналогах, если таковые возможны.

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

ии такие варианты накидывает)))


Триангуляция Делоне	Двойственна Вороному: соединяет соседние точки	Меши, интерполяция
Диаграмма Юнга (Young diagram)	Разбиение на «клетки» по другому правилу	Комбинаторика
Диаграмма Аррениуса	Разбиение по энергии активации	Химия
k-means / k-medoids	Кластеризация по ближайшему центру	ML, сжатие
Разбиение по MST	Дерево минимальной длины, каждая точка «тянет» свою ветку	Кластеризация, сети
Гексагональная сетка (Honeycomb)	Регулярный аналог Вороного	Игры, физика
Разбиение Уорда (Ward)	Кластеризация с минимизацией дисперсии	Статистика
Тримеш (Trimmed Voronoi)	Вороной с обрезанными ячейками	Архитектура, 3D-печать

Генерация внешнего вида узоров зависит от многого. Сектор берётся из картинки-диаграммы Вороного и тип узора им определяем. То есть подобной картинкой


А мог бы, к примеру, такого типа:

Вопрос в математики разбиений аналогичных Вороного, если они есть и красивые :slight_smile:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
    margin: 0; padding: 20px; background: #000; color: #fff;
    font-family: monospace; display: flex; flex-direction: column;
    align-items: center; min-height: 100vh; box-sizing: border-box;
}
h2 { color: cyan; margin: 5px 0 15px; font-size: 16px; }
canvas {
    border-radius: 50%;
    box-shadow: 0 0 40px rgba(0, 255, 255, 0.4);
    display: block;
}
button {
    margin-top: 15px; padding: 12px 30px; background: cyan;
    border: none; border-radius: 25px; font-weight: bold;
    font-size: 14px; cursor: pointer; color: black;
}
button:hover { background: #0cc; }
.info {
    font-size: 11px; color: #888; text-align: center;
    max-width: 500px; margin-top: 15px; line-height: 1.5;
}
</style>
</head>
<body>
    <h2>12 НЕСМЕШИВАЮЩИХСЯ ЖИДКОСТЕЙ</h2>
    <canvas id="c" width="320" height="320"></canvas>
    <button onclick="restart()">🔄 ПЕРЕМЕШАТЬ</button>
    <div class="info">
        12 цветных «жидкостей» плавают в вихревом поле.<br>
        Цвета не смешиваются — границы только изгибаются.<br>
        Каждый пиксель принадлежит одному из 12 якорей.
    </div>

<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 320, H = 320;
const NUM = 12;

let anchors = [];      // якоря жидкостей
let raf;
let time = 0;

// Цвета: равномерно по кругу HSV, чтобы 12 жидкостей были хорошо различимы
function makeColors(n) {
    let colors = [];
    for (let i = 0; i < n; i++) {
        let hue = (i / n) * 360;
        // HSL -> RGB
        let h = hue / 360, s = 0.85, l = 0.55;
        let r, g, b;
        if (s === 0) { r = g = b = l; }
        else {
            let hue2rgb = (p, q, t) => {
                if (t < 0) t += 1;
                if (t > 1) t -= 1;
                if (t < 1/6) return p + (q - p) * 6 * t;
                if (t < 1/2) return q;
                if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
                return p;
            };
            let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
            let p = 2 * l - q;
            r = hue2rgb(p, q, h + 1/3);
            g = hue2rgb(p, q, h);
            b = hue2rgb(p, q, h - 1/3);
        }
        colors.push({
            R: Math.round(r * 255),
            G: Math.round(g * 255),
            B: Math.round(b * 255),
            // «Тёмная» версия для границ
            Rd: Math.round(r * 90),
            Gd: Math.round(g * 90),
            Bd: Math.round(b * 90)
        });
    }
    return colors;
}

let colors = makeColors(NUM);

function initAnchors() {
    anchors = [];
    for (let i = 0; i < NUM; i++) {
        // Располагаем якоря по кругу + небольшой случайный сдвиг
        let angle = (i / NUM) * Math.PI * 2;
        let radius = 60 + Math.random() * 40;
        anchors.push({
            x: W / 2 + Math.cos(angle) * radius,
            y: H / 2 + Math.sin(angle) * radius,
            // Уникальные параметры вихря для каждого якоря
            phase: Math.random() * Math.PI * 2,
            orbitR: 30 + Math.random() * 50,
            orbitSpeed: 0.3 + Math.random() * 0.5,
            colorIndex: i
        });
    }
}

// Поле потока: сумма нескольких вращающихся вихрей.
// Возвращает вектор скорости для точки (x, y) в момент t.
function flowAt(x, y, t) {
    let vx = 0, vy = 0;

    // 3 глобальных вихря, которые закручивают всю «жидкость»
    const vortices = [
        { cx: W * 0.3, cy: H * 0.3, strength: 1.2, spin: 1.0 },
        { cx: W * 0.7, cy: H * 0.4, strength: -1.0, spin: 0.8 },
        { cx: W * 0.5, cy: H * 0.75, strength: 1.5, spin: 1.2 }
    ];

    for (let v of vortices) {
        let dx = x - v.cx;
        let dy = y - v.cy;
        let d2 = dx * dx + dy * dy + 500;
        // Вращательное поле: перпендикуляр к радиусу, делённый на расстояние
        let s = v.strength * v.spin / d2;
        vx += -dy * s;
        vy += dx * s;
    }

    // Медленное глобальное течение, чтобы жидкость не застаивалась
    vx += Math.sin(y * 0.02 + t * 0.5) * 0.3;
    vy += Math.cos(x * 0.02 + t * 0.7) * 0.3;

    return { vx, vy };
}

// Двигаем якоря по полю потока: каждый якорь плывёт вместе с жидкостью
function updateAnchors(t) {
    for (let a of anchors) {
        // Собственное орбитальное движение (чтобы жидкости не слипались)
        let ox = Math.cos(t * a.orbitSpeed + a.phase) * a.orbitR * 0.02;
        let oy = Math.sin(t * a.orbitSpeed * 1.3 + a.phase) * a.orbitR * 0.02;

        // Основное течение
        let { vx, vy } = flowAt(a.x, a.y, t);
        a.x += vx * 0.8 + ox;
        a.y += vy * 0.8 + oy;

        // Мягкое удержание в пределах canvas (отражение у краёв)
        let margin = 40;
        if (a.x < margin) { a.x = margin; }
        if (a.x > W - margin) { a.x = W - margin; }
        if (a.y < margin) { a.y = margin; }
        if (a.y > H - margin) { a.y = H - margin; }
    }
}

// Для каждого пикселя — какой якорь ближе?
// Используем не «чистое» расстояние, а расстояние + небольшое искажение
// от поля потока, чтобы границы были волнистыми.
function ownerAt(x, y, t) {
    let bestIdx = 0;
    let bestD = Infinity;
    for (let i = 0; i < NUM; i++) {
        let a = anchors[i];
        let dx = x - a.x;
        let dy = y - a.y;
        let d = Math.sqrt(dx * dx + dy * dy);

        // Искажение: добавляем синусоидальную «рябь»,
        // зависящую от координат и фазы якоря.
        // Это делает границы не прямыми, а изогнутыми.
        let ripple = Math.sin(x * 0.05 + t * 2 + a.phase) *
                     Math.cos(y * 0.05 - t * 1.5 + a.phase * 1.7) * 8;
        d += ripple;

        if (d < bestD) { bestD = d; bestIdx = i; }
    }
    return bestIdx;
}

function draw() {
    time += 0.016;
    updateAnchors(time);

    // 1) Заливка: каждый пиксель получает цвет ближайшего якоря
    const img = ctx.createImageData(W, H);
    const data = img.data;
    const owners = new Int16Array(W * H);

    for (let y = 0; y < H; y++) {
        for (let x = 0; x < W; x++) {
            let k = ownerAt(x, y, time);
            owners[y * W + x] = k;
            let idx = (y * W + x) * 4;
            let c = colors[k];
            data[idx] = c.R;
            data[idx + 1] = c.G;
            data[idx + 2] = c.B;
            data[idx + 3] = 255;
        }
    }

    // 2) Границы: там, где соседние пиксели принадлежат разным якорям,
    //    рисуем тёмную линию. Она «склеивает» жидкости, но не смешивает цвета.
    for (let y = 1; y < H - 1; y++) {
        for (let x = 1; x < W - 1; x++) {
            let k = owners[y * W + x];
            let isEdge =
                owners[y * W + x - 1] !== k ||
                owners[y * W + x + 1] !== k ||
                owners[(y - 1) * W + x] !== k ||
                owners[(y + 1) * W + x] !== k;
            if (isEdge) {
                let idx = (y * W + x) * 4;
                let c = colors[k];
                // Тёмный оттенок цвета самой жидкости — эффект «мокрой границы»
                data[idx] = c.Rd;
                data[idx + 1] = c.Gd;
                data[idx + 2] = c.Bd;
            }
        }
    }

    ctx.putImageData(img, 0, 0);

    // 3) Рисуем сами якоря — маленькие светящиеся точки,
    //    чтобы было видно, где «сердце» каждой жидкости.
    for (let i = 0; i < NUM; i++) {
        let a = anchors[i];
        let c = colors[i];
        ctx.beginPath();
        ctx.arc(a.x, a.y, 5, 0, Math.PI * 2);
        ctx.fillStyle = `rgb(${c.R},${c.G},${c.B})`;
        ctx.fill();
        ctx.strokeStyle = 'white';
        ctx.lineWidth = 1.5;
        ctx.stroke();
    }

    raf = requestAnimationFrame(draw);
}

function restart() {
    cancelAnimationFrame(raf);
    time = 0;
    initAnchors();
    draw();
}

restart();
</script>
</body>
</html>

но если еще больше границ добавить будет лагать…

(сообщение удалено автором)

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Салют — 43 эффекта (canvas)</title>
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

  html, body {
    margin: 0; padding: 0;
    width: 100%; height: 100%;
    overflow: hidden;
    background: radial-gradient(ellipse at 50% 40%, #141a34 0%, #070a1a 55%, #02030a 100%);
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: #fff;
    user-select: none;
    cursor: crosshair;
  }

  #canvas {
    position: fixed;
    inset: 0;
    z-index: 1;
    display: block;
  }

  .info {
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
    pointer-events: none;
    z-index: 100;
  }
  .info h1 {
    margin: 0;
    font-size: 20px;
    letter-spacing: 4px;
    text-transform: uppercase;
    color: #fff;
    text-shadow: 0 0 20px rgba(255,214,0,0.7);
  }
  .info p {
    margin: 6px 0 0;
    font-size: 12px;
    color: #8a8aa0;
    letter-spacing: 1px;
  }
  .info .variant {
    margin-top: 8px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 3px;
    text-transform: uppercase;
    color: #ffd600;
    text-shadow: 0 0 15px rgba(255,214,0,0.8);
    min-height: 18px;
    transition: opacity 0.3s, color 0.3s;
  }
  .info .counter {
    margin-top: 4px;
    font-size: 11px;
    color: #666a80;
    letter-spacing: 1px;
  }

  .controls {
    position: fixed;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    flex-wrap: wrap;
    justify-content: center;
    z-index: 100;
    max-width: 95vw;
  }
  .controls button {
    padding: 12px 22px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 1px;
    text-transform: uppercase;
    border: 1px solid rgba(255,214,0,0.4);
    border-radius: 10px;
    background: rgba(30,20,10,0.7);
    color: #ffe680;
    cursor: pointer;
    backdrop-filter: blur(6px);
    transition: all 0.15s ease;
  }
  .controls button:hover {
    background: rgba(255,180,0,0.25);
    border-color: rgba(255,214,0,0.9);
    box-shadow: 0 0 20px rgba(255,214,0,0.5);
  }
  .controls button:active {
    transform: scale(0.96);
  }
  .controls select {
    padding: 12px 16px;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 1px;
    text-transform: uppercase;
    border: 1px solid rgba(255,214,0,0.4);
    border-radius: 10px;
    background: rgba(30,20,10,0.85);
    color: #ffe680;
    cursor: pointer;
    backdrop-filter: blur(6px);
    max-width: 240px;
  }
  .controls select option {
    background: #1a1408;
    color: #ffe680;
  }
</style>
</head>
<body>

<canvas id="canvas"></canvas>

<div class="info">
  <h1>Салют</h1>
  <p>Клик в любом месте — залп с полётом</p>
  <div class="variant" id="variant-name"></div>
  <div class="counter" id="counter"></div>
</div>

<div class="controls">
  <button id="btn-fire">Залп</button>
  <button id="btn-big">Большой взрыв</button>
  <button id="btn-random">Случайный</button>
  <select id="variant-select"></select>
</div>

<script>
(() => {
  'use strict';

  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d', { alpha: true });

  let W = 0, H = 0, DPR = 1;

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth;
    H = window.innerHeight;
    canvas.width = W * DPR;
    canvas.height = H * DPR;
    canvas.style.width = W + 'px';
    canvas.style.height = H + 'px';
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  // ============================================================
  // ПАЛИТРА И УТИЛИТЫ
  // ============================================================
  const COLORS = [
    '#ffd600', '#ff8a00', '#ff3060', '#4aa8ff',
    '#8affc8', '#c88aff', '#ffffff', '#ff66cc',
    '#66ff66', '#ff4444', '#00ffff', '#ffaa00',
    '#ff0099', '#9900ff', '#00ff99', '#ff5500',
    '#ffe680', '#e0e8ff', '#fff2b0',
  ];

  const rand = (min, max) => Math.random() * (max - min) + min;
  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

  function hexToRgb(hex) {
    const n = parseInt(hex.slice(1), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }

  // ============================================================
  // БЕЛЫЕ АКЦЕНТЫ — с индивидуальным шансом
  // ============================================================
  const WHITE_CHANCE = 0.3;
  const flashyColor = (c, chance = WHITE_CHANCE) =>
    Math.random() < chance ? '#ffffff' : c;

  // ============================================================
  // ЕДИНЫЙ ПУЛ ЧАСТИЦ
  // ============================================================
  const MAX_PARTICLES = 1800;
  const particles = [];
  const pool = [];

  function acquireParticle() {
    if (pool.length > 0) return pool.pop();
    return {
      x: 0, y: 0, vx: 0, vy: 0,
      color: '#fff', colorRgb: [255, 255, 255],
      size: 2, life: 1, age: 0,
      gravity: 200, friction: 0.985, trailLength: 4,
      flicker: false, flickerSeed: 0,
      prevX: 0, prevY: 0,
    };
  }

  function releaseParticle(p) {
    if (pool.length < MAX_PARTICLES) pool.push(p);
  }

  function spawnSpark(x, y, dx, dy, color, size, life, options = {}) {
    if (particles.length >= MAX_PARTICLES) return;
    const p = acquireParticle();
    p.x = x; p.y = y;
    p.prevX = x; p.prevY = y;
    p.vx = dx; p.vy = dy;
    p.color = color;
    p.colorRgb = hexToRgb(color);
    p.size = size;
    p.life = life / 1000;
    p.age = 0;
    p.gravity = options.gravity !== undefined ? options.gravity : 220;
    p.friction = options.friction !== undefined ? options.friction : 0.985;
    p.trailLength = options.trailLength || 3.5;
    p.flicker = options.flicker || false;
    p.flickerSeed = Math.random() * 1000;
    particles.push(p);
  }

  // ============================================================
  // РАКЕТЫ
  // ============================================================
  const rockets = [];

  function launchRocket(targetX, targetY, scale = 1, forceVariant = null) {
    const startX = targetX + rand(-100, 100) * scale;
    const startY = H + 20;
    const controlX = (startX + targetX) / 2 + rand(-150, 150) * scale;
    const controlY = Math.min(startY, targetY) - rand(100, 300) * scale;

    const variantIndex = forceVariant !== null
      ? forceVariant
      : Math.floor(Math.random() * VARIANTS.length);

    rockets.push({
      startX, startY, targetX, targetY,
      controlX, controlY,
      flightTime: rand(0.6, 1.1),
      age: 0,
      scale,
      color: pick(COLORS),
      colorRgb: hexToRgb(pick(COLORS)),
      variantIndex,
      lastTrail: 0,
      x: startX, y: startY,
    });

    const nameEl = document.getElementById('variant-name');
    nameEl.textContent = `${variantIndex + 1}. ${VARIANTS[variantIndex].name}`;
    nameEl.style.opacity = '1';
    nameEl.style.color = '#ffd600';
  }

  function updateRockets(dt, now) {
    for (let i = rockets.length - 1; i >= 0; i--) {
      const r = rockets[i];
      r.age += dt;
      const t = Math.min(r.age / r.flightTime, 1);
      const mt = 1 - t;
      r.x = mt * mt * r.startX + 2 * mt * t * r.controlX + t * t * r.targetX;
      r.y = mt * mt * r.startY + 2 * mt * t * r.controlY + t * t * r.targetY;

      if (now - r.lastTrail > 25) {
        r.lastTrail = now;
        spawnSpark(
          r.x, r.y,
          rand(-25, 25), rand(30, 70),
          flashyColor(r.color, 0.5),
          rand(2, 4) * r.scale, rand(200, 450),
          { gravity: 180, friction: 0.97, trailLength: 5, flicker: true }
        );
      }

      if (t >= 1) {
        const variant = VARIANTS[r.variantIndex];
        spawnFlash(r.targetX, r.targetY, r.scale);
        variant.fn(r.targetX, r.targetY, r.scale);
        for (let j = 0; j < 5 * r.scale; j++) {
          spawnSmoke(r.targetX + rand(-30, 30), r.targetY + rand(-30, 30), r.scale);
        }
        rockets.splice(i, 1);
      }
    }
  }

  // ============================================================
  // ВСПЫШКИ И ДЫМ
  // ============================================================
  const flashes = [];
  const smokes = [];

  function spawnFlash(x, y, scale, color = [255, 248, 192]) {
    flashes.push({ x, y, scale, age: 0, life: 0.5, color });
  }
  function spawnSmoke(x, y, scale) {
    if (smokes.length > 80) return;
    smokes.push({
      x, y, scale, age: 0,
      life: rand(0.9, 1.6),
      driftX: rand(-40, 40), driftY: rand(-30, 30),
      size: rand(12, 28) * scale,
    });
  }

  // ============================================================
  // 43 ЭФФЕКТА
  // ============================================================

  // 1. ПИОН — 60% белых (плотный, яркий)
  function fxPion(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(80 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n + rand(-0.08, 0.08);
      const sp = rand(180, 320) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.6),
        rand(2.5, 4.5) * s, rand(1100, 1700),
        { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
    }
  }

  // 2. ИВА — 30% белых (изящная, ниспадающая)
  function fxIva(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(65 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n + rand(-0.1, 0.1);
      const sp = rand(120, 220) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.3),
        rand(2, 3.5) * s, rand(2000, 3000),
        { gravity: 120, friction: 0.975, trailLength: 6, flicker: true });
    }
  }

  // 3. ХРИЗАНТЕМА — 45% белых
  function fxHrizantema(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(60 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n + rand(-0.05, 0.05);
      const sp = rand(220, 360) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.75 ? c : pick(COLORS), 0.45),
        rand(2.5, 4) * s, rand(1800, 2600),
        { gravity: 60, friction: 0.96, trailLength: 7, flicker: true });
    }
  }

  // 4. ГЕОРГИН — по слоям: горячий центр (0.7), средний (0.5), внешний (0.2)
  function fxGeorgin(x, y, s) {
    const c = pick(COLORS);
    const layers = 3;
    const layerChance = [0.2, 0.5, 0.7]; // внешний → внутренний
    for (let l = 0; l < layers; l++) {
      const n = Math.floor((50 - l * 10) * s);
      const speedMul = 1 + l * 0.4;
      const col = l === 0 ? c : (l === 1 ? pick(COLORS) : '#ffffff');
      const chanceL = layerChance[l];
      for (let i = 0; i < n; i++) {
        const a = (Math.PI * 2 * i) / n + l * 0.3 + rand(-0.05, 0.05);
        const sp = rand(140, 240) * s * speedMul;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(col, chanceL), rand(2.5, 4) * s, rand(1400, 2000),
          { gravity: 130, friction: 0.972, trailLength: 5, flicker: true });
      }
    }
  }

  // 5. ПАЛЬМА — 35% белых (ветки преимущественно цветные)
  function fxPalma(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(14 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(200, 300) * s;
      for (let j = 0; j < 8; j++) {
        const jitter = rand(-0.08, 0.08);
        spawnSpark(x, y, Math.cos(a + jitter) * sp, Math.sin(a + jitter) * sp,
          flashyColor(Math.random() < 0.6 ? c : pick(COLORS), 0.35),
          rand(2.5, 4) * s, rand(1800, 2600),
          { gravity: 100, friction: 0.965, trailLength: 8, flicker: true });
      }
    }
  }

  // 6. КОЛЬЦО — 55% белых (контрастное)
  function fxKolec(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(80 * s);
    const tilt = rand(0.3, 0.7);
    const rot = rand(0, Math.PI);
    const cosR = Math.cos(rot), sinR = Math.sin(rot);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(200, 280) * s;
      const ex = Math.cos(a) * sp, ey = Math.sin(a) * sp * tilt;
      spawnSpark(x, y, ex * cosR - ey * sinR, ex * sinR + ey * cosR,
        flashyColor(i % 2 === 0 ? c1 : c2, 0.55),
        rand(2.5, 4) * s, rand(1300, 1900),
        { gravity: 160, friction: 0.983, trailLength: 4, flicker: true });
    }
  }

  // 7. ПУЛЬСАР — 50% белых
  function fxPulsar(x, y, s) {
    const c = pick(COLORS);
    for (let w = 0; w < 4; w++) {
      setTimeout(() => {
        const n = Math.floor((45 - w * 8) * s);
        const speedMul = 1 + w * 0.35;
        for (let i = 0; i < n; i++) {
          const a = (Math.PI * 2 * i) / n + rand(-0.1, 0.1);
          const sp = rand(140, 240) * s * speedMul;
          spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
            flashyColor(Math.random() < 0.6 ? c : pick(COLORS), 0.5),
            rand(2, 4) * s, rand(1000, 1600),
            { gravity: 200, friction: 0.984, trailLength: 4, flicker: true });
        }
      }, w * 180);
    }
  }

  // 8. ДВОЙНОЙ — по слоям: внешний (0.4), внутренний (0.6)
  function fxDvoinoy(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n1 = Math.floor(60 * s);
    for (let i = 0; i < n1; i++) {
      const a = (Math.PI * 2 * i) / n1 + rand(-0.08, 0.08);
      const sp = rand(240, 340) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c1, 0.4), rand(2.5, 4) * s, rand(1300, 1800),
        { gravity: 200, friction: 0.983, trailLength: 4, flicker: true });
    }
    setTimeout(() => {
      const n2 = Math.floor(45 * s);
      for (let i = 0; i < n2; i++) {
        const a = (Math.PI * 2 * i) / n2 + rand(-0.1, 0.1);
        const sp = rand(120, 200) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(c2, 0.6), rand(3, 5) * s, rand(1500, 2100),
          { gravity: 160, friction: 0.98, trailLength: 5, flicker: true });
      }
    }, 150);
  }

  // 9. ТРОЙНОЙ — по слоям: 0.3, 0.5, 0.7
  function fxTroynoy(x, y, s) {
    const colors = [pick(COLORS), pick(COLORS), pick(COLORS)];
    const chances = [0.3, 0.5, 0.7];
    for (let w = 0; w < 3; w++) {
      setTimeout(() => {
        const n = Math.floor((55 - w * 10) * s);
        for (let i = 0; i < n; i++) {
          const a = (Math.PI * 2 * i) / n + rand(-0.1, 0.1);
          const sp = rand(180, 280) * s * (1 - w * 0.15);
          spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
            flashyColor(colors[w], chances[w]),
            rand(2.5, 4) * s, rand(1400, 2000),
            { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
        }
      }, w * 200);
    }
  }

  // 10. СПИРАЛЬ — 45% белых
  function fxSpiral(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(75 * s);
    const turns = 3;
    for (let i = 0; i < n; i++) {
      const progress = i / n;
      const a = progress * Math.PI * 2 * turns;
      const sp = rand(180, 300) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.45),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 11. ДВОЙНАЯ СПИРАЛЬ — 50% белых
  function fxDvoynayaSpiral(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(75 * s);
    const turns = 2.5;
    for (let i = 0; i < n; i++) {
      const progress = i / n;
      const a = progress * Math.PI * 2 * turns;
      const sp = rand(180, 300) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c1, 0.5), rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
      spawnSpark(x, y, Math.cos(a + Math.PI) * sp, Math.sin(a + Math.PI) * sp,
        flashyColor(c2, 0.5), rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 12. ШАРОВАЯ МОЛНИЯ — 65% белых (искрит)
  function fxMolnija(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(90 * s);
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = rand(100, 400) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.65),
        rand(2, 4) * s, rand(800, 1800),
        { gravity: 280, friction: 0.978, trailLength: 6, flicker: true });
    }
  }

  // 13. КОМЕТА — 40% белых (хвосты цветные)
  function fxKometa(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(40 * s);
    for (let i = 0; i < n; i++) {
      const a = rand(0, Math.PI * 2);
      const sp = rand(200, 400) * s;
      const vx = Math.cos(a) * sp, vy = Math.sin(a) * sp;
      for (let j = 0; j < 5; j++) {
        const f = 1 - j * 0.08;
        spawnSpark(x, y, vx * f, vy * f,
          flashyColor(c, 0.4),
          rand(2, 3.5) * s, rand(1600, 2400),
          { gravity: 120, friction: 0.975, trailLength: 9, flicker: true });
      }
    }
  }

  // 14. ЗВЕЗДА — 50% белых
  function fxZvezda(x, y, s) {
    const c = pick(COLORS);
    const points = 5;
    const n = Math.floor(75 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const spikes = points * 2;
      const localA = a * spikes / (Math.PI * 2);
      const frac = localA - Math.floor(localA);
      const radiusMul = 0.5 + 0.5 * Math.abs(Math.cos(frac * Math.PI));
      const sp = rand(200, 320) * s * radiusMul;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.5),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 170, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 15. РОМБ — 45% белых
  function fxRomb(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(70 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const k = Math.abs(Math.cos(a)) + Math.abs(Math.sin(a));
      const sp = rand(200, 300) * s * k;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.45),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
    }
  }

  // 16. КВАДРАТ — 45% белых
  function fxKvadrat(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(72 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const cosA = Math.cos(a), sinA = Math.sin(a);
      const maxV = Math.max(Math.abs(cosA), Math.abs(sinA));
      const sp = rand(200, 300) * s / maxV;
      spawnSpark(x, y, cosA * sp, sinA * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.45),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
    }
  }

  // 17. ВОСЬМЁРКА — 50% белых
  function fxVosmerka(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(80 * s);
    for (let i = 0; i < n; i++) {
      const t = (Math.PI * 2 * i) / n;
      const sp = rand(180, 300) * s;
      const vx = Math.cos(t) * sp;
      const vy = Math.sin(t * 2) * sp * 0.6;
      spawnSpark(x, y, vx, vy,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.5),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 18. КОНУС — 40% белых
  function fxKonus(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(65 * s);
    const baseA = -Math.PI / 2;
    for (let i = 0; i < n; i++) {
      const a = baseA + rand(-1.0, 1.0);
      const sp = rand(200, 340) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.4),
        rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 200, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 19. ВЕЕР — 50% белых
  function fxVeer(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(55 * s);
    const baseA = -Math.PI / 2;
    for (let i = 0; i < n; i++) {
      const a = baseA + (i / n - 0.5) * 2.4;
      const sp = rand(220, 340) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(i % 2 === 0 ? c1 : c2, 0.5),
        rand(2.5, 4.5) * s, rand(1500, 2100),
        { gravity: 220, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 20. ФОНТАН — 45% белых
  function fxFontan(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(50 * s);
    for (let i = 0; i < n; i++) {
      const a = -Math.PI / 2 + rand(-0.6, 0.6);
      const sp = rand(250, 420) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.45),
        rand(2.5, 4) * s, rand(1800, 2600),
        { gravity: 350, friction: 0.985, trailLength: 5, flicker: true });
    }
  }

  // 21. ХВОСТ ПАВЛИНА — 40% белых
  function fxPavlin(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(45 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(180, 260) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.4), rand(3, 4.5) * s, rand(1600, 2200),
        { gravity: 140, friction: 0.975, trailLength: 6, flicker: true });
      for (let j = 0; j < 4; j++) {
        const a2 = a + rand(-0.25, 0.25);
        const sp2 = sp * rand(0.85, 1.05);
        spawnSpark(x, y, Math.cos(a2) * sp2, Math.sin(a2) * sp2,
          flashyColor(pick(COLORS), 0.4), rand(2.5, 4) * s, rand(1800, 2400),
          { gravity: 130, friction: 0.972, trailLength: 7, flicker: true });
      }
    }
  }

  // 22. ПАУК — 55% белых
  function fxPauk(x, y, s) {
    const c = pick(COLORS);
    const legs = 12;
    for (let i = 0; i < legs; i++) {
      const a = (Math.PI * 2 * i) / legs;
      const sp = rand(200, 300) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.55), rand(3, 5) * s, rand(1500, 2200),
        { gravity: 160, friction: 0.975, trailLength: 7, flicker: true });
      setTimeout(() => {
        spawnSpark(x, y, Math.cos(a + 0.1) * sp, Math.sin(a + 0.1) * sp,
          flashyColor(c, 0.55), rand(3, 5) * s, rand(1500, 2200),
          { gravity: 160, friction: 0.975, trailLength: 7, flicker: true });
      }, 30);
    }
  }

  // 23. КОРОНА — по слоям: верхние лучи (0.5), нижняя дуга (0.35)
  function fxKorona(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(75 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(200, 300) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp * 0.5,
        flashyColor(i % 2 === 0 ? c1 : c2, 0.5),
        rand(2.5, 4) * s, rand(1800, 2400),
        { gravity: 100, friction: 0.97, trailLength: 8, flicker: true });
    }
    for (let i = 0; i < 28 * s; i++) {
      const a = rand(0, Math.PI);
      const sp = rand(150, 250) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c2, 0.35), rand(2, 3.5) * s, rand(1400, 2000),
        { gravity: 200, friction: 0.98, trailLength: 5 });
    }
  }

  // 24. ГАЛАКТИКА — 45% белых
  function fxGalaktika(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(110 * s);
    const turns = 4;
    for (let i = 0; i < n; i++) {
      const progress = i / n;
      const a = progress * Math.PI * 2 * turns;
      const sp = rand(60, 300) * s * (0.3 + progress);
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.6 ? c : pick(COLORS), 0.45),
        rand(2, 4) * s, rand(1600, 2600),
        { gravity: 140, friction: 0.976, trailLength: 6, flicker: true });
    }
  }

  // 25. КОЛЬЦО САТУРНА — кольцо (0.55), сфера (0.6)
  function fxSaturn(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const tilt = rand(0.15, 0.3);
    const rot = rand(0, Math.PI);
    const cosR = Math.cos(rot), sinR = Math.sin(rot);
    const n = Math.floor(90 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(200, 320) * s;
      const ex = Math.cos(a) * sp, ey = Math.sin(a) * sp * tilt;
      spawnSpark(x, y, ex * cosR - ey * sinR, ex * sinR + ey * cosR,
        flashyColor(i % 2 === 0 ? c1 : c2, 0.55),
        rand(2.5, 4) * s, rand(1500, 2200),
        { gravity: 150, friction: 0.981, trailLength: 5, flicker: true });
    }
    for (let i = 0; i < 28 * s; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = rand(30, 80) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(pick(COLORS), 0.6), rand(2, 3.5) * s, rand(1200, 1800),
        { gravity: 180, friction: 0.98, trailLength: 4 });
    }
  }

  // 26. ДВОЙНОЕ КОЛЬЦО — внешнее (0.5), внутреннее (0.7)
  function fxDvoynoeKolec(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(55 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(240, 320) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c1, 0.5), rand(2.5, 4) * s, rand(1300, 1800),
        { gravity: 170, friction: 0.983, trailLength: 4, flicker: true });
    }
    setTimeout(() => {
      const n2 = Math.floor(45 * s);
      const rot = rand(0, Math.PI);
      const tilt = rand(0.3, 0.7);
      const cosR = Math.cos(rot), sinR = Math.sin(rot);
      for (let i = 0; i < n2; i++) {
        const a = (Math.PI * 2 * i) / n2;
        const sp = rand(180, 260) * s;
        const ex = Math.cos(a) * sp, ey = Math.sin(a) * sp * tilt;
        spawnSpark(x, y, ex * cosR - ey * sinR, ex * sinR + ey * cosR,
          flashyColor(c2, 0.7), rand(2.5, 4) * s, rand(1400, 2000),
          { gravity: 160, friction: 0.982, trailLength: 5, flicker: true });
      }
    }, 250);
  }

  // 27. РАДУГА — 30% белых (цвета важны)
  function fxRaduga(x, y, s) {
    const rainbow = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#00ccff', '#4400ff', '#cc00ff'];
    const bands = rainbow.length;
    for (let b = 0; b < bands; b++) {
      const c = rainbow[b];
      const n = Math.floor(14 * s);
      const angleOffset = b * 0.08;
      for (let i = 0; i < n; i++) {
        const a = (Math.PI * 2 * i) / n + angleOffset;
        const sp = (200 + b * 15) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(c, 0.3), rand(2.5, 4) * s, rand(1400, 2000),
          { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
      }
    }
  }

  // 28. ЦВЕТОК — лепестки (0.4), сердцевина (0.7)
  function fxCvetok(x, y, s) {
    const petals = 6;
    const baseC = pick(COLORS);
    for (let p = 0; p < petals; p++) {
      const baseA = (Math.PI * 2 * p) / petals;
      const n = Math.floor(12 * s);
      for (let i = 0; i < n; i++) {
        const spread = (i / n - 0.5) * 0.7;
        const a = baseA + spread;
        const sp = rand(150, 280) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(p % 2 === 0 ? baseC : pick(COLORS), 0.4),
          rand(2.5, 4.5) * s, rand(1500, 2200),
          { gravity: 180, friction: 0.98, trailLength: 6, flicker: true });
      }
    }
    // Сердцевина — яркая
    for (let i = 0; i < 14 * s; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = rand(40, 90) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor('#ffff88', 0.7), rand(2, 3.5) * s, rand(1200, 1800),
        { gravity: 150, friction: 0.98, trailLength: 4, flicker: true });
    }
  }

  // 29. ВОЛНА — по рядам: 0.4, 0.5, 0.6, 0.7
  function fxVolna(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const rows = 4;
    const chances = [0.4, 0.5, 0.6, 0.7];
    for (let r = 0; r < rows; r++) {
      setTimeout(() => {
        const n = Math.floor(55 * s);
        const offset = r * 0.3;
        for (let i = 0; i < n; i++) {
          const a = (Math.PI * 2 * i) / n;
          const waveA = a + Math.sin(a * 4 + offset) * 0.3;
          const sp = rand(180, 280) * s;
          spawnSpark(x, y, Math.cos(waveA) * sp, Math.sin(waveA) * sp,
            flashyColor(r % 2 === 0 ? c1 : c2, chances[r]),
            rand(2.5, 4) * s, rand(1400, 2000),
            { gravity: 170, friction: 0.982, trailLength: 5, flicker: true });
        }
      }, r * 100);
    }
  }

  // 30. КАСКАД — 45% белых
  function fxKaskad(x, y, s) {
    const c = pick(COLORS);
    const waves = 5;
    for (let w = 0; w < waves; w++) {
      setTimeout(() => {
        const n = Math.floor((38 - w * 5) * s);
        const sp0 = (300 - w * 30) * s;
        for (let i = 0; i < n; i++) {
          const a = -Math.PI / 2 + rand(-0.6, 0.6);
          const sp = sp0 * rand(0.8, 1.2);
          spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
            flashyColor(Math.random() < 0.6 ? c : pick(COLORS), 0.45),
            rand(2.5, 4) * s, rand(1500, 2200),
            { gravity: 320, friction: 0.982, trailLength: 5, flicker: true });
        }
      }, w * 180);
    }
  }

  // 31. МЕТЕОР — 50% белых (головы ярче)
  function fxMeteor(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(45 * s);
    for (let i = 0; i < n; i++) {
      const a = rand(0, Math.PI * 2);
      const sp = rand(250, 450) * s;
      const vx = Math.cos(a) * sp, vy = Math.sin(a) * sp;
      for (let j = 0; j < 6; j++) {
        const f = 1 - j * 0.1;
        spawnSpark(x, y, vx * f, vy * f,
          flashyColor(c, j === 0 ? 0.7 : 0.4),
          rand(2, 3.5) * s, rand(1200, 2000),
          { gravity: 100, friction: 0.98, trailLength: 10, flicker: true });
      }
    }
  }

  // 32. СВЕРХНОВАЯ — 70% белых
  function fxSverhnova(x, y, s) {
    const c = pick(COLORS);
    for (let i = 0; i < 55 * s; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = rand(300, 500) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor('#ffffff', 0.7), rand(2, 4) * s, rand(600, 1200),
        { gravity: 150, friction: 0.99, trailLength: 8, flicker: true });
    }
    setTimeout(() => {
      const n = Math.floor(75 * s);
      for (let i = 0; i < n; i++) {
        const a = (Math.PI * 2 * i) / n + rand(-0.1, 0.1);
        const sp = rand(100, 200) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(Math.random() < 0.5 ? c : pick(COLORS), 0.55),
          rand(3, 5) * s, rand(1800, 2600),
          { gravity: 200, friction: 0.978, trailLength: 6, flicker: true });
      }
    }, 100);
  }

  // 33. ГИПЕРНОВАЯ — 75% белых (вспышка!)
  function fxGipernova(x, y, s) {
    spawnFlash(x, y, s * 1.4);
    const c1 = pick(COLORS), c2 = pick(COLORS), c3 = pick(COLORS);
    for (let i = 0; i < 55 * s; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = rand(350, 550) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor('#ffffff', 0.75), rand(2, 4) * s, rand(500, 1000),
        { gravity: 180, friction: 0.99, trailLength: 8, flicker: true });
    }
    setTimeout(() => {
      const n = Math.floor(90 * s);
      for (let i = 0; i < n; i++) {
        const a = (Math.PI * 2 * i) / n + rand(-0.1, 0.1);
        const sp = rand(150, 320) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(i % 3 === 0 ? c1 : (i % 3 === 1 ? c2 : c3), 0.5),
          rand(2.5, 4.5) * s, rand(1800, 2800),
          { gravity: 160, friction: 0.978, trailLength: 6, flicker: true });
      }
    }, 120);
    setTimeout(() => {
      for (let i = 0; i < 45 * s; i++) {
        const a = (Math.PI * 2 * i) / (45 * s) + rand(-0.2, 0.2);
        const sp = rand(80, 180) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(pick(COLORS), 0.4),
          rand(2, 4) * s, rand(2200, 3000),
          { gravity: 100, friction: 0.972, trailLength: 8, flicker: true });
      }
    }, 280);
  }

  // 34. ЦИКЛОН — 45% белых
  function fxCiklon(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(90 * s);
    const turns = 5;
    for (let i = 0; i < n; i++) {
      const progress = i / n;
      const a = progress * Math.PI * 2 * turns;
      const sp = rand(100, 300) * s * progress;
      const perpA = a + Math.PI / 2;
      spawnSpark(x, y, Math.cos(a) * sp + Math.cos(perpA) * 30 * s,
        Math.sin(a) * sp + Math.sin(perpA) * 30 * s,
        flashyColor(Math.random() < 0.6 ? c : pick(COLORS), 0.45),
        rand(2, 4) * s, rand(1600, 2400),
        { gravity: 150, friction: 0.976, trailLength: 6, flicker: true });
    }
  }

  // 35. ПЕСОЧНЫЕ ЧАСЫ — 50% белых
  function fxPesochnyeChasy(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(65 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const sp = rand(180, 280) * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp * 0.8 - 40 * s,
        flashyColor(c1, 0.5), rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 220, friction: 0.982, trailLength: 4, flicker: true });
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp * 0.8 + 40 * s,
        flashyColor(c2, 0.5), rand(2.5, 4) * s, rand(1400, 2000),
        { gravity: 220, friction: 0.982, trailLength: 4, flicker: true });
    }
  }

  // 36. БАБОЧКА — 50% белых
  function fxBabochka(x, y, s) {
    const c1 = pick(COLORS), c2 = pick(COLORS);
    const n = Math.floor(75 * s);
    for (let i = 0; i < n; i++) {
      const t = (i / n) * Math.PI * 2;
      const r = Math.abs(Math.sin(t)) * 200 * s;
      const vx = Math.cos(t) * r;
      const vy = Math.sin(t) * r * 0.7;
      spawnSpark(x, y, vx, vy,
        flashyColor(i % 2 === 0 ? c1 : c2, 0.5),
        rand(2.5, 4) * s, rand(1600, 2200),
        { gravity: 170, friction: 0.98, trailLength: 5, flicker: true });
    }
  }

  // 37. СЕРДЦЕ — 55% белых (романтичное)
  function fxSerdce(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(85 * s);
    for (let i = 0; i < n; i++) {
      const t = (i / n) * Math.PI * 2;
      const hx = 16 * Math.pow(Math.sin(t), 3);
      const hy = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
      const sp = 12 * s;
      spawnSpark(x, y, hx * sp, hy * sp,
        flashyColor(Math.random() < 0.75 ? c : pick(COLORS), 0.55),
        rand(2.5, 4) * s, rand(1600, 2200),
        { gravity: 200, friction: 0.98, trailLength: 4, flicker: true });
    }
  }

  // 38. ЗВЕЗДА ДАВИДА — 50% белых
  function fxZvezdaDavida(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(75 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const k = Math.abs(Math.cos(a * 3)) * 0.6 + 0.4;
      const sp = rand(200, 300) * s * k;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.75 ? c : pick(COLORS), 0.5),
        rand(2.5, 4) * s, rand(1500, 2100),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 39. ПЕНТАГРАММА — 40% белых (линии чётче)
  function fxPentagramma(x, y, s) {
    const c = pick(COLORS);
    const step = Math.PI * 2 / 5;
    const seq = [0, 2, 4, 1, 3, 0];
    for (let i = 0; i < seq.length - 1; i++) {
      const a1 = seq[i] * step - Math.PI / 2;
      const a2 = seq[i + 1] * step - Math.PI / 2;
      const steps = Math.floor(14 * s);
      for (let j = 0; j < steps; j++) {
        const t = j / steps;
        const a = a1 + (a2 - a1) * t;
        const sp = rand(220, 300) * s;
        spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
          flashyColor(c, 0.4), rand(2.5, 4) * s, rand(1600, 2200),
          { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
      }
    }
  }

  // 40. ГЕКСАГОН — 45% белых
  function fxHexagon(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(70 * s);
    for (let i = 0; i < n; i++) {
      const a = (Math.PI * 2 * i) / n;
      const k = Math.cos((a % (Math.PI / 3)) - Math.PI / 6);
      const sp = rand(200, 300) * s / k;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.75 ? c : pick(COLORS), 0.45),
        rand(2.5, 4) * s, rand(1500, 2100),
        { gravity: 180, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  // 41. ЦИФЕРБЛАТ — деления (0.5), стрелки (0.7)
  function fxCiferblat(x, y, s) {
    const c = pick(COLORS);
    for (let h = 0; h < 12; h++) {
      const a = (Math.PI * 2 * h) / 12 - Math.PI / 2;
      const sp = (h % 3 === 0 ? 300 : 240) * s;
      const size = h % 3 === 0 ? 4.5 : 3;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(c, 0.5), size * s, rand(1600, 2200),
        { gravity: 180, friction: 0.982, trailLength: 4, flicker: true });
    }
    const hourA = rand(0, Math.PI * 2);
    const minA = rand(0, Math.PI * 2);
    for (let i = 0; i < 18; i++) {
      const t = i / 18;
      spawnSpark(x, y, Math.cos(hourA) * 150 * s * t, Math.sin(hourA) * 150 * s * t,
        flashyColor('#ffffff', 0.7), 3 * s, rand(1400, 2000),
        { gravity: 160, friction: 0.98, trailLength: 5, flicker: true });
      spawnSpark(x, y, Math.cos(minA) * 220 * s * t, Math.sin(minA) * 220 * s * t,
        flashyColor('#ffff88', 0.5), 3 * s, rand(1400, 2000),
        { gravity: 160, friction: 0.98, trailLength: 5, flicker: true });
    }
  }

  // 42. ЛАБИРИНТ — 40% белых
  function fxLabirint(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(75 * s);
    for (let i = 0; i < n; i++) {
      const t = i / n;
      const a = t * Math.PI * 2 * 6;
      const sp = 50 + t * 250 * s;
      spawnSpark(x, y, Math.cos(a) * sp, Math.sin(a) * sp,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.4),
        rand(2, 3.5) * s, rand(1800, 2600),
        { gravity: 130, friction: 0.975, trailLength: 6, flicker: true });
    }
  }

  // 43. ПАРАБОЛА — 45% белых
  function fxParabola(x, y, s) {
    const c = pick(COLORS);
    const n = Math.floor(55 * s);
    const angle = rand(0, Math.PI * 2);
    for (let i = 0; i < n; i++) {
      const t = (i / n - 0.5) * 2;
      const sp = rand(220, 340) * s;
      const local = t * 0.8;
      const a = angle + local;
      const speedMul = 1 - Math.abs(t) * 0.3;
      spawnSpark(x, y, Math.cos(a) * sp * speedMul, Math.sin(a) * sp * speedMul,
        flashyColor(Math.random() < 0.7 ? c : pick(COLORS), 0.45),
        rand(2.5, 4.5) * s, rand(1600, 2200),
        { gravity: 240, friction: 0.982, trailLength: 5, flicker: true });
    }
  }

  const VARIANTS = [
    { name: 'Пион', fn: fxPion },
    { name: 'Ива', fn: fxIva },
    { name: 'Хризантема', fn: fxHrizantema },
    { name: 'Георгин', fn: fxGeorgin },
    { name: 'Пальма', fn: fxPalma },
    { name: 'Кольцо', fn: fxKolec },
    { name: 'Пульсар', fn: fxPulsar },
    { name: 'Двойной', fn: fxDvoinoy },
    { name: 'Тройной', fn: fxTroynoy },
    { name: 'Спираль', fn: fxSpiral },
    { name: 'Двойная спираль', fn: fxDvoynayaSpiral },
    { name: 'Шаровая молния', fn: fxMolnija },
    { name: 'Комета', fn: fxKometa },
    { name: 'Звезда', fn: fxZvezda },
    { name: 'Ромб', fn: fxRomb },
    { name: 'Квадрат', fn: fxKvadrat },
    { name: 'Восьмёрка', fn: fxVosmerka },
    { name: 'Конус', fn: fxKonus },
    { name: 'Веер', fn: fxVeer },
    { name: 'Фонтан', fn: fxFontan },
    { name: 'Хвост павлина', fn: fxPavlin },
    { name: 'Паук', fn: fxPauk },
    { name: 'Корона', fn: fxKorona },
    { name: 'Галактика', fn: fxGalaktika },
    { name: 'Кольцо Сатурна', fn: fxSaturn },
    { name: 'Двойное кольцо', fn: fxDvoynoeKolec },
    { name: 'Радуга', fn: fxRaduga },
    { name: 'Цветок', fn: fxCvetok },
    { name: 'Волна', fn: fxVolna },
    { name: 'Каскад', fn: fxKaskad },
    { name: 'Метеор', fn: fxMeteor },
    { name: 'Сверхновая', fn: fxSverhnova },
    { name: 'Гиперновая', fn: fxGipernova },
    { name: 'Циклон', fn: fxCiklon },
    { name: 'Песочные часы', fn: fxPesochnyeChasy },
    { name: 'Бабочка', fn: fxBabochka },
    { name: 'Сердце', fn: fxSerdce },
    { name: 'Звезда Давида', fn: fxZvezdaDavida },
    { name: 'Пентаграмма', fn: fxPentagramma },
    { name: 'Гексагон', fn: fxHexagon },
    { name: 'Циферблат', fn: fxCiferblat },
    { name: 'Лабиринт', fn: fxLabirint },
    { name: 'Парабола', fn: fxParabola },
  ];

  // ============================================================
  // БОЛЬШОЙ ВЗРЫВ — случайные эффекты каждый раз
  // ============================================================
  function launchBigExplosion(x, y) {
    const nameEl = document.getElementById('variant-name');
    nameEl.textContent = '★ БОЛЬШОЙ ВЗРЫВ ★';
    nameEl.style.opacity = '1';
    nameEl.style.color = '#ff4400';

    // 5 ракет вокруг — случайные эффекты
    const count = 5;
    for (let i = 0; i < count; i++) {
      setTimeout(() => {
        const angle = (Math.PI * 2 * i) / count;
        const dist = rand(150, 260);
        const tx = x + Math.cos(angle) * dist;
        const ty = y + Math.sin(angle) * dist * 0.6;
        launchRocket(tx, ty, rand(0.9, 1.2), Math.floor(Math.random() * VARIANTS.length));
      }, i * 180);
    }

    // Центральный мега-взрыв — 3 случайных эффекта
    setTimeout(() => {
      spawnFlash(x, y, 3.5);

      const shuffled = [...VARIANTS].sort(() => Math.random() - 0.5);
      const bigVariants = shuffled.slice(0, 3);

      bigVariants[0].fn(x, y, 2.0);
      setTimeout(() => bigVariants[1].fn(x, y, 1.8), 150);
      setTimeout(() => bigVariants[2].fn(x, y, 1.6), 300);

      // Финальный залп
      setTimeout(() => {
        const finalCount = 120;
        for (let i = 0; i < finalCount; i++) {
          const angle = (Math.PI * 2 * i) / finalCount + rand(-0.05, 0.05);
          const speed = rand(300, 550);
          spawnSpark(x, y, Math.cos(angle) * speed, Math.sin(angle) * speed,
            flashyColor(pick(COLORS), 0.6), rand(2, 4), rand(1800, 2600),
            { gravity: 150, friction: 0.98, trailLength: 5, flicker: true });
        }
      }, 550);

      for (let i = 0; i < 12; i++) {
        spawnSmoke(x + rand(-80, 80), y + rand(-80, 80), 2.2);
      }
    }, 1100);

    setTimeout(() => {
      nameEl.style.color = '#ffd600';
    }, 4000);
  }

  // ============================================================
  // ОТРИСОВКА
  // ============================================================
  function drawParticle(p) {
    const t = p.age / p.life;
    if (t >= 1) return;

    const opacity = 1 - t;
    const [r, g, b] = p.colorRgb;

    let alpha = opacity;
    if (p.flicker) {
      alpha *= 0.55 + 0.45 * Math.abs(Math.sin(p.flickerSeed + p.age * 40));
    }

    const dx = p.x - p.prevX;
    const dy = p.y - p.prevY;
    const dist = Math.sqrt(dx * dx + dy * dy);

    const coreSize = p.size * (1 - t * 0.6);
    if (coreSize > 0.3) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, coreSize, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${r},${g},${b},${alpha * 0.9})`;
      ctx.fill();
    }

    if (dist > 0.5) {
      const stretch = Math.min(1 + dist * p.trailLength * 0.35, 10);
      const tailX = p.x - dx * stretch;
      const tailY = p.y - dy * stretch;

      const gradient = ctx.createLinearGradient(p.x, p.y, tailX, tailY);
      gradient.addColorStop(0, `rgba(${r},${g},${b},${alpha})`);
      gradient.addColorStop(0.4, `rgba(${r},${g},${b},${alpha * 0.5})`);
      gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);

      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(tailX, tailY);
      ctx.strokeStyle = gradient;
      ctx.lineWidth = coreSize * 1.2;
      ctx.lineCap = 'round';
      ctx.stroke();
    }

    if (coreSize > 1.5) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, coreSize * 2.5, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${r},${g},${b},${alpha * 0.15})`;
      ctx.fill();
    }
  }

  function drawFlash(f) {
    const t = f.age / f.life;
    if (t >= 1) return;
    const alpha = 1 - t;
    const size = 160 * f.scale * (0.3 + t * 1.8);
    const [r, g, b] = f.color;
    const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, size);
    grad.addColorStop(0, `rgba(255,255,220,${alpha * 0.95})`);
    grad.addColorStop(0.3, `rgba(${r},${g},${b},${alpha * 0.6})`);
    grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
    ctx.beginPath();
    ctx.arc(f.x, f.y, size, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  function drawSmoke(sm) {
    const t = sm.age / sm.life;
    if (t >= 1) return;
    const alpha = (1 - t) * 0.35;
    const size = sm.size * (1 + t * 1.8);
    const x = sm.x + sm.driftX * t;
    const y = sm.y + sm.driftY * t;
    const grad = ctx.createRadialGradient(x, y, 0, x, y, size);
    grad.addColorStop(0, `rgba(120,120,140,${alpha})`);
    grad.addColorStop(0.5, `rgba(80,80,100,${alpha * 0.5})`);
    grad.addColorStop(1, 'rgba(80,80,100,0)');
    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  function drawRocket(r) {
    if (r.x === undefined) return;
    ctx.beginPath();
    ctx.arc(r.x, r.y, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();

    const [cr, cg, cb] = r.colorRgb;
    const grad = ctx.createRadialGradient(r.x, r.y, 0, r.x, r.y, 20);
    grad.addColorStop(0, `rgba(255,255,200,0.9)`);
    grad.addColorStop(0.3, `rgba(${cr},${cg},${cb},0.5)`);
    grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
    ctx.beginPath();
    ctx.arc(r.x, r.y, 20, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
  }

  // ============================================================
  // ГЛАВНЫЙ ЦИКЛ
  // ============================================================
  let lastTime = performance.now();

  function loop(now) {
    const dt = Math.min((now - lastTime) / 1000, 0.05);
    lastTime = now;

    ctx.clearRect(0, 0, W, H);
    updateRockets(dt, now);

    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.prevX = p.x;
      p.prevY = p.y;
      p.age += dt;
      if (p.age >= p.life) {
        releaseParticle(p);
        particles.splice(i, 1);
        continue;
      }
      p.vy += p.gravity * dt;
      const fr = Math.pow(p.friction, dt * 60);
      p.vx *= fr;
      p.vy *= fr;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
    }

    for (let i = flashes.length - 1; i >= 0; i--) {
      flashes[i].age += dt;
      if (flashes[i].age >= flashes[i].life) flashes.splice(i, 1);
    }
    for (let i = smokes.length - 1; i >= 0; i--) {
      smokes[i].age += dt;
      if (smokes[i].age >= smokes[i].life) smokes.splice(i, 1);
    }

    for (let i = 0; i < smokes.length; i++) drawSmoke(smokes[i]);

    ctx.globalCompositeOperation = 'lighter';
    for (let i = 0; i < flashes.length; i++) drawFlash(flashes[i]);
    for (let i = 0; i < rockets.length; i++) drawRocket(rockets[i]);
    for (let i = 0; i < particles.length; i++) drawParticle(particles[i]);
    ctx.globalCompositeOperation = 'source-over';

    requestAnimationFrame(loop);
  }

  // ============================================================
  // UI
  // ============================================================
  const nameEl = document.getElementById('variant-name');
  const counterEl = document.getElementById('counter');
  const selectEl = document.getElementById('variant-select');

  VARIANTS.forEach((v, i) => {
    const opt = document.createElement('option');
    opt.value = i;
    opt.textContent = `${i + 1}. ${v.name}`;
    selectEl.appendChild(opt);
  });
  const randomOpt = document.createElement('option');
  randomOpt.value = '-1';
  randomOpt.textContent = '🎲 Случайный';
  selectEl.insertBefore(randomOpt, selectEl.firstChild);
  selectEl.value = '-1';

  counterEl.textContent = `Всего эффектов: ${VARIANTS.length} • Белых искр: до ${Math.round(WHITE_CHANCE * 100)}%`;

  // ============================================================
  // УПРАВЛЕНИЕ
  // ============================================================
  function getSelectedIndex() {
    const sel = selectEl.value;
    return sel === '-1' ? null : parseInt(sel, 10);
  }

  document.addEventListener('click', (e) => {
    if (e.target.closest('button') || e.target.closest('select')) return;
    launchRocket(e.clientX, e.clientY, rand(0.9, 1.3), getSelectedIndex());
  });

  document.getElementById('btn-fire').addEventListener('click', () => {
    launchRocket(W / 2, H * 0.45, rand(1.0, 1.4), getSelectedIndex());
  });

  document.getElementById('btn-big').addEventListener('click', () => {
    launchBigExplosion(W / 2, H * 0.4);
  });

  document.getElementById('btn-random').addEventListener('click', () => {
    launchRocket(W * rand(0.25, 0.75), H * rand(0.2, 0.5), rand(1.0, 1.4), null);
  });

  setTimeout(() => {
    launchRocket(W / 2, H * 0.45, 1.2, 0);
  }, 600);

  requestAnimationFrame(loop);

})();
</script>
</body>
</html>

Ну вот, в 338 посте уже ход правильный у мыслей:

// Для каждого пикселя — какой якорь ближе?
// Используем не «чистое» расстояние, а расстояние + небольшое искажение
// от поля потока, чтобы границы были волнистыми.
function ownerAt(x, y, t) {
    let bestIdx = 0;
    let bestD = Infinity;
    for (let i = 0; i < NUM; i++) {
        let a = anchors[i];
        let dx = x - a.x;
        let dy = y - a.y;
        let d = Math.sqrt(dx * dx + dy * dy);

        // Искажение: добавляем синусоидальную «рябь»,
        // зависящую от координат и фазы якоря.
        // Это делает границы не прямыми, а изогнутыми.