Я не могу настроить столкновение и гравитацию в canvas

   
   //Функция для ограничения количества кадров в секунду
   var limitLoop = function (fn, fps) {
 
            // Use var then = Date.now(); if you
            // don't care about targetting < IE9
            var then = new Date().getTime();
        
            // custom fps, otherwise fallback to 60
            fps = fps || 60;
            var interval = 1000 / fps;
         
            return (function loop(time){
                requestAnimationFrame(loop);
         
                // again, Date.now() if it's available
                var now = new Date().getTime();
                var delta = now - then;
         
                if (delta > interval) {
                    // Update time
                    // now - (delta % interval) is an improvement over just 
                    // using then = now, which can end up lowering overall fps
                    then = now - (delta % interval);
         
                    // call the fn
                    fn();
                }
            }(0));
        };



        //Полезные функции

        function randomIntFromRange(min,max) {
            return Math.floor(Math.random() * (max - min + 1) + min);
        }

        function frameCheck(ball){
          if(ball.y + ball.radius  + ball.velocity.y > canvas.height){
            ball.velocity.y = -ball.velocity.y * 0.6;
          } else {
            if(!ball.colideState){
            ball.velocity.y += gravity;
        }
          }
          
          if(ball.x + ball.radius + ball.velocity.x >= canvas.width || ball.x - ball.radius + ball.velocity.x<= 0){
            ball.velocity.x = -ball.velocity.x;
          }
        }


        function getDistance(x1, y1, x2, y2){
          let xDistance = x2 - x1;
          let yDistance = y2 - y1;

          return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2))
        }
        
        //Утилита для рассчета коллизии

        /**
 * Rotates coordinate system for velocities
 *
 * Takes velocities and alters them as if the coordinate system they're on was rotated
 *
 * @param  Object | velocity | The velocity of an individual particle
 * @param  Float  | angle    | The angle of collision between two objects in radians
 * @return Object | The altered x and y velocities after the coordinate system has been rotated
 */

function rotate(velocity, angle) {
  const rotatedVelocities = {
      x: velocity.x * Math.cos(angle) - velocity.y * Math.sin(angle),
      y: velocity.x * Math.sin(angle) + velocity.y * Math.cos(angle)
  };

  return rotatedVelocities;
}

/**
* Swaps out two colliding particles' x and y velocities after running through
* an elastic collision reaction equation
*
* @param  Object | particle      | A particle object with x and y coordinates, plus velocity
* @param  Object | otherParticle | A particle object with x and y coordinates, plus velocity
* @return Null | Does not return a value
*/

function resolveCollision(particle, otherParticle) {
  const xVelocityDiff = particle.velocity.x - otherParticle.velocity.x;
  const yVelocityDiff = particle.velocity.y - otherParticle.velocity.y;

  const xDist = otherParticle.x - particle.x;
  const yDist = otherParticle.y - particle.y;

  // Prevent accidental overlap of particles
  if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {

      // Grab angle between the two colliding particles
      const angle = -Math.atan2(otherParticle.y - particle.y, otherParticle.x - particle.x);

      // Store mass in var for better readability in collision equation
      const m1 = particle.mass;
      const m2 = otherParticle.mass;

      // Velocity before equation
      const u1 = rotate(particle.velocity, angle);
      const u2 = rotate(otherParticle.velocity, angle);

      // Velocity after 1d collision equation
      const v1 = { x: u1.x * (m1 - m2) / (m1 + m2) + u2.x * 2 * m2 / (m1 + m2), y: u1.y };
      const v2 = { x: u2.x * (m1 - m2) / (m1 + m2) + u1.x * 2 * m2 / (m1 + m2), y: u2.y };

      // Final velocity after rotating axis back to original location
      const vFinal1 = rotate(v1, -angle);
      const vFinal2 = rotate(v2, -angle);
      if(particle.y != particle.pastY){
      // Swap particle velocities for realistic bounce effect
      if(particle.y + particle.radius  + particle.velocity.y < canvas.height){
        particle.velocity.y = vFinal2.y * 0.8;
      }
      
      if(otherParticle.y + otherParticle.radius  + otherParticle.velocity.y < canvas.height){
        otherParticle.velocity.y = vFinal2.y * 0.8;
      }
    }
      if(particle.x + particle.radius + particle.velocity.x < canvas.width && particle.x - particle.radius + particle.velocity.x > 0){
        particle.velocity.x = vFinal1.x ;
      } else {}

     if(otherParticle.x + otherParticle.radius + otherParticle.velocity.x < canvas.width && otherParticle.x - otherParticle.radius + otherParticle.velocity.x > 0){
       otherParticle.velocity.x = vFinal2.x ;
     } else {}





  }
}




        //game
        const canvas = document.querySelector('canvas')
        const c = canvas.getContext('2d');
        canvas.height = window.screen.height - window.screen.height * 0.6; // window heigh - 20 percents
        canvas.width = document.querySelector('.container').clientWidth * 0.5; // container width
        let objects = []
   //Массив шариков
        let  ballsTemplates = {
          1:{
            y: 50,
            direction: 0,
            radius: 20,
            color: "blue",
            level: 1
          },
          2:{
            y: 50,
            direction: 0,
            radius: 25,
            color: "green",
            level: 2
          },
          3:{
            y: 50,
            direction: 0,
            radius: 30,
            color: "black",
            level: 3
          },
          4:{
            y: 50,
            direction: 0,
            radius: 35,
            color: "red",
            level: 4
          },
        }


        const gravity = 0.5;
        //objects
        class Ball {
            constructor(x, y, dx, dy, bp, radius, color, level) {
              this.x = x
              this.y = y
              this.pastX = 0
              this.pastY = 0
              this.colideState = false;
              this.velocity = {
                x: dx,
                y: dy
              }
              this.level = level
              this.bp = bp;
              this.radius = radius
              this.color = color
              this.mass = 1;
            }
          
            draw() {
              c.beginPath()
              c.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false)
              c.fillStyle = this.color
              c.fill()
              c.closePath()
            }
          
            update = objects => {

              this.pastX = this.x;
                  this.pastY = this.y

              this.y += this.velocity.y
              this.velocity.x = this.velocity.x * 0.99;
              this.x += this.velocity.x;
              //Не даём шарику возможности упать за границы карты
              frameCheck(this);

              for (let i=0; i < objects.length; i++){
                if(objects[i]){
                if(this === objects[i]) continue;
                if(getDistance(this.x, this.y, objects[i].x, objects[i].y) - (this.radius + objects[i].radius) < -0.8){
                  
                  //Вызов функции коллизии
                  resolveCollision(this, objects[i]);
                  

                 
                  let lvl = this.level;
                  let lvl2 = objects[i].level;
                  if(lvl === lvl2){
                    if(lvl < 4){
                    let x = (this.x + objects[i].x) / 2
                    let y = (this.y + objects[i].y) / 2

                    this.x = x;
                    this.y = y;
                    this.direction = ballsTemplates[lvl + 1].direction;
                    this.dy = 0.5;
                    this.bp =  0.9;
                    this.radius =  ballsTemplates[lvl + 1].radius;
                    this.color =  ballsTemplates[lvl + 1].color;
                    this.level =  ballsTemplates[lvl + 1].level;
                    
                  delete objects[i];
                }
                  }
                  
                    if(this.y == this.pastY){
                  this.colideState = true;
                }
                  } else {
                  this.colideState = false;
                  }

            }
        }

          this.draw()
        
            }
          }


  //функция для создания шарика по клику
          canvas.addEventListener('mouseup', function (event) {
           // Implementation
           const rect = canvas.getBoundingClientRect();
           const computedStyle = getComputedStyle(canvas);
           const borderLeftWidth = parseInt(computedStyle.borderLeftWidth, 10);
           const borderTopWidth = parseInt(computedStyle.borderTopWidth, 10);
           let numb = randomIntFromRange(1,4);
           let x = event.clientX - rect.left - borderLeftWidth
           let y = event.clientY - rect.top - borderTopWidth

           let radius = 55;

            let dxWerid = 5;

           if(x + radius * 2 > canvas.width){
            x = canvas.width - radius;
           } else if (x - radius * 2 < canvas.width){
            x = x + radius;
           }

          let ball = new Ball( x , y, ballsTemplates[numb].direction , 1 , 0.9,ballsTemplates[numb].radius, ballsTemplates[numb].color, ballsTemplates[numb].level)
        
           objects.push(ball)

            
          });



        const animate = async () => {
            c.fillStyle="white"
          
            c.fillRect(0, 0, canvas.width, canvas.height) //background :/
             objects.forEach(object => {
              object.update(objects)
             })
            
        }
    
        
        limitLoop(animate, 60);
.gameScreen {
    border: 10px solid white;
    background-color: black;
}
<body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"><div class="container" height="200" width="332"><div><div><canvas class="gameScreen"></canvas> <- С попощью левой кнопки мыши заспавни много шариков. Чтобы было несколько рядок</div></div></div></div>
  

</body>

Я пытаюсь реализовать с помощью canvas довольно простую игру merge fruit, в которой шарики должны падать в стакан, и если шарики одинакового размера, соприкасаются друг с другом. Они превращаются в один большой шар. Но есть проблема с гравитацией и коллизией. Со временем они просто начинают продавливаться друг в друга. Скорее всего, это из-за силы тяжести, которая притягивает их, пока они не коснутся пола. Но я не знаю, как манипулировать ею. И не терять силу тяжести при столкновении. Потому что нужно чтобы, шарики имели по прежнему возможность кататься друг по другу. Пожалуйста, помогите мне!

Извините, если код выглядит неуклюже. Я просто перепробовал все, что мог, и со временем начал путаться в своем собственном коде


Ответы (2 шт):

Автор решения: EzioMercer

Тут такое крайне не приветствуется, но к сожалению по другому у меня не получилось. Я не смог полностью разобраться в вашем коде, потому написал сам с нуля :)

Пытался назвать переменные максимально понятно и оставлял комментарии в некоторых местах и везде пытался оставлять JSDoc. Но всё же если будут вопросы, то обязательно пишите, постараюсь ответить

Вот сам код:

const CANVAS_WIDTH = window.innerWidth;
const CANVAS_HEIGHT = window.innerHeight;
// FRAME PER SECOND
const FPS = 60;
// PHYSIC PER SECOND
const PPS = FPS;
const Gravity = 9.8;
const G = Gravity / PPS;

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

canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;

// CLASSES (START)
class Dot {
    /**
     * @type {number}
     */
    x;
    
    /**
     * @type {number}
     */
    y;
    
    /**
     * @param {number} x
     * @param {number} y
     */
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}

class Vector extends Dot {
}

class CircleLevel {
    /**
     * @type {number}
     */
    #level = 0;
    
    /**
     * @type {string}
     */
    #color = '';
    
    /**
     * @type {number}
     */
    #radius = 0;
    
    /**
     * @type {number}
     */
    #mass = 0;
    
    /**
     * @param {number} level
     * @param {string} color
     * @param {number} radius
     * @param {number} mass
     */
    constructor(level, color, radius, mass) {
        this.#level = level;
        this.#color = color;
        this.#radius = radius;
        this.#mass = mass
    }
    
    /**
     * @returns {number}
     */
    get level() {
        return this.#level;
    }
    
    /**
     * @returns {string}
     */
    get color() {
        return this.#color;
    }
    
    /**
     * @returns {number}
     */
    get radius() {
        return this.#radius;
    }
    
    /**
     * @returns {number}
     */
    get mass() {
        return this.#mass;
    }
}

class CircleLevelsManager {
    /**
     * @type {CircleLevel[]}
     */
    static #levels = [
        new CircleLevel(1, 'red', 20, 1),
        new CircleLevel(2, 'green', 30, 2),
        new CircleLevel(3, 'blue', 40, 3),
        new CircleLevel(4, 'yellow', 50, 4)
    ];
    
    /**
     * @param {number} levelIndex
     * @returns {CircleLevel}
     */
    static getLevel(levelIndex) {
        return CircleLevelsManager.#levels[levelIndex];
    }
    
    /**
     * @param {CircleLevel} prevLevel
     * @returns {CircleLevel}
     */
    static getNextLevel(prevLevel) {
        return CircleLevelsManager.#levels[prevLevel.level];
    }
    
    /**
     * @param {CircleLevel} prevLevel
     * @returns {boolean}
     */
    static hasNextLevel(prevLevel) {
        return prevLevel.level < CircleLevelsManager.#levels.length;
    }
}

class CirclesManager {
    /**
     * @type {Set<Circle>}
     */
    static #circles = new Set();
    
    /**
     * @param {Circle} circle
     */
    static add(circle) {
        CirclesManager.#circles.add(circle);
    }
    
    /**
     * @param {Circle} circle
     */
    static delete(circle) {
        CirclesManager.#circles.delete(circle);
    }
    
    /**
     * @returns {Circle[]}
     */
    static getCircles() {
        return [...CirclesManager.#circles.values()];
    }
}

class Circle {
    /**
     * @type {Dot}
     */
    #center = new Dot(0, 0);
    
    /**
     * @type {{
     *     horizontal: number,
     *     vertical: number
     * }}
     */
    #friction = {
        horizontal: 0.98,
        vertical: 0.5,
    };
    
    /**
     * @type {Vector}
     */
    velocity = new Vector(0, 0);
    
    /**
     * @type {CircleLevel}
     */
    level = CircleLevelsManager.getLevel(0);
    
    /**
     * @param {number} x
     * @param {number} y
     * @param {CircleLevel} [level]
     */
    constructor(x, y, level) {
        CirclesManager.add(this);
        
        this.#center.x = x;
        this.#center.y = y;
        
        if (level !== undefined) this.level = level;
    }
    
    /**
     * @returns {number}
     */
    get radius() {
        return this.level.radius;
    }
    
    /**
     * @returns {number}
     */
    get mass() {
        return this.level.mass;
    }
    
    /**
     * @returns {Dot}
     */
    get center() {
        return this.#center;
    }
    
    /**
     * @param {Dot} newCenter
     */
    set center(newCenter) {
        return this.#center = newCenter;
    }
    
    /**
     * @returns {number}
     */
    get x() {
        return this.#center.x;
    }
    
    /**
     * @returns {number}
     */
    get y() {
        return this.#center.y;
    }
    
    draw() {
        ctx.beginPath();
        
        ctx.fillStyle = this.level.color;
        ctx.arc(this.#center.x, this.#center.y, this.level.radius, 0, 2 * Math.PI);
        ctx.fill();
        
        ctx.closePath();
    }
    
    update() {
        const radius = this.level.radius;
        
        // IF TOUCHES HORIZONTAL WALLS
        if (this.#center.y + radius >= CANVAS_HEIGHT || this.center.y - radius <= 0) {
            this.velocity.y = Math.abs(this.velocity.y) < 1 ? 0 : -this.velocity.y * this.#friction.vertical;
            
            if (this.velocity.y === 0 && this.center.y - radius === 0) this.velocity.y += 1;
        } else {
            // IF FALLING DOWN
            this.velocity.y += G * this.level.mass;
        }
        
        // IF TOUCHES VERTICAL WALLS
        if (this.#center.x + radius >= CANVAS_WIDTH || this.center.x - radius <= 0) {
            this.velocity.x = Math.abs(this.velocity.x) < 1 ? 0 : -this.velocity.x * this.#friction.horizontal;
        }
        
        // PUT INSIDE FRAME
        this.#center.y = Math.max(radius, Math.min(this.#center.y + this.velocity.y, CANVAS_HEIGHT - radius));
        
        // IF ON GROUND
        if (this.center.y + radius === CANVAS_HEIGHT) {
            this.velocity.x *= this.#friction.horizontal;
        }
        
        // PUT INSIDE FRAME
        this.#center.x = Math.max(radius, Math.min(this.#center.x + this.velocity.x, CANVAS_WIDTH - radius));
    }
    
    /**
     * @param {Circle} circle
     * @returns {boolean}
     */
    hasCollision(circle) {
        return distanceSquare(circle.center, this.center) <= (circle.radius + this.radius) ** 2;
    }
}

// CLASSES (END)

// UTILS (START)
/**
 * @param {Dot} dot1
 * @param {Dot} dot2
 * @returns {number}
 */
const distanceSquare = (dot1, dot2) => (dot1.x - dot2.x) ** 2 + (dot1.y - dot2.y) ** 2;

/**
 * @param {Dot} dot1
 * @param {Dot} dot2
 * @returns {number}
 */
const distance = (dot1, dot2) => Math.sqrt(distanceSquare(dot1, dot2));

/**
 * @param {Dot} vector
 * @returns {number}
 */
const lengthOfVector = vector => Math.sqrt(vector.x ** 2 + vector.y ** 2);

/**
 * @param {Dot} dot1
 * @param {Dot} dot2
 * @returns {Dot}
 */
const dotsSub = (dot1, dot2) => new Dot(dot1.x - dot2.x, dot1.y - dot2.y);

/**
 * @param {Dot} dot1
 * @param {Dot} dot2
 * @returns {Dot}
 */
const dotsSum = (dot1, dot2) => new Dot(dot1.x + dot2.x, dot1.y + dot2.y);

/**
 * @param {Dot} dot
 * @param {number} coefficient
 * @returns {Dot}
 */
const multiplyDot = (dot, coefficient) => new Dot(dot.x * coefficient, dot.y * coefficient);

/**
 * @param {Dot} dot1
 * @param {Dot} dot2
 * @returns {number}
 */
const dotsProduct = (dot1, dot2) => dot1.x * dot2.x + dot1.y * dot2.y;

/**
 * @param {Circle} circle1
 * @param {Circle} circle2
 */
function elasticCollision(circle1, circle2) {
    const massSum = circle1.mass + circle2.mass;
    let dist = distance(circle1.center, circle2.center);
    
    const correctionLength = (circle1.radius + circle2.radius - dist) / 2;
    const impact = dotsSub(circle1.center, circle2.center);
    const dir = multiplyDot(impact, correctionLength / lengthOfVector(impact));
    circle1.center = dotsSum(circle1.center, dir);
    circle2.center = dotsSub(circle2.center, dir);
    
    dist = circle1.radius + circle2.radius;
    
    const distSquare = dist ** 2;
    
    const oldVelocity1 = circle1.velocity;
    const oldVelocity2 = circle2.velocity;
    
    circle1.velocity = dotsSub(
        oldVelocity1,
        multiplyDot(
            dotsSub(circle1.center, circle2.center),
            (2 * circle2.mass / massSum) * dotsProduct(
                dotsSub(oldVelocity1, oldVelocity2),
                dotsSub(circle1.center, circle2.center),
            ) / distSquare
        )
    );
    
    circle2.velocity = dotsSub(
        oldVelocity2,
        multiplyDot(
            dotsSub(circle2.center, circle1.center),
            (2 * circle1.mass / massSum) * dotsProduct(
                dotsSub(oldVelocity2, oldVelocity1),
                dotsSub(circle2.center, circle1.center),
            ) / distSquare
        )
    );
}

function upgradeLevel(circle1, circle2) {
    let primaryCircle = circle1;
    let secondaryCircle = circle2;
    
    if (circle2.y < circle1.y) {
        primaryCircle = circle2;
        secondaryCircle = circle1;
    }
    
    CirclesManager.delete(secondaryCircle);
    
    primaryCircle.level = CircleLevelsManager.getNextLevel(primaryCircle.level);
}

// UTILS (END)

canvas.addEventListener('mousemove', e => {
    new Circle(e.clientX, e.clientY);
})

// UPDATE GAME (START)

let prevPhysicsTimeStamp = performance.now();

/**
 * @param {DOMHighResTimeStamp} timeStamp
 */
const updatePhysics = (timeStamp) => {
    requestAnimationFrame(updatePhysics);
    
    if (timeStamp - prevPhysicsTimeStamp < (1000 / PPS)) return;
    
    prevPhysicsTimeStamp = timeStamp;
    
    const circles = CirclesManager.getCircles();
    
    for (let i = 0; i < circles.length; ++i) {
        circles[i].update();
    }
    
    // CHECK COLLISIONS
    for (let i = 0; i < circles.length - 1; ++i) {
        const circle1 = circles[i];
        
        for (let j = i + 1; j < circles.length; ++j) {
            const circle2 = circles[j];
            
            if (!circle1.hasCollision(circle2)) continue;
            
            if (circle1.level === circle2.level && CircleLevelsManager.hasNextLevel(circle1.level)) {
                upgradeLevel(circle1, circle2);
            } else {
                elasticCollision(circle1, circle2);
            }
        }
    }
}

requestAnimationFrame(updatePhysics);

let prevPictureTimeStamp = performance.now();

/**
 * @param {DOMHighResTimeStamp} timeStamp
 */
const updatePicture = (timeStamp) => {
    requestAnimationFrame(updatePicture);
    
    if (timeStamp - prevPictureTimeStamp < (1000 / FPS)) return;
    
    prevPictureTimeStamp = timeStamp;
    
    // ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.fillStyle = `rgba(0, 0, 0, 0.5)`;
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    
    const circles = CirclesManager.getCircles();
    
    for (let i = 0; i < circles.length; ++i) {
        circles[i].draw();
    }
}

requestAnimationFrame(updatePicture);

// UPDATE GAME (END)
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html {
    overflow: hidden;
}

canvas {
    background-color: black;
}
<canvas></canvas>

Основные моменты:

  1. Формулы я взял отсюда: Two-dimensional collision with two moving objects

  2. Были пару багов, с которыми разобрался благодаря этому видео: Collisions Without a Physics Library! (Coding Challenge 184). В видел автор исползьует другие формулы, чем в википедии, насколько я понял, потому что не разобрался в них (хотя они крайне похожи)

    Вообще очень советую этого автора, очень много чему учит. Правда испоользует библиотеку p5, но он настолько подробно и просто объясняет, что закодить самому - не проблема

  3. Разделил обновление физики и картинки на две отдельные, чтобы они не мешали друг другу

  4. Формулы, я по сути просто скопипастил, но автор на похожих формулах показал как можно их оптимизировать

  5. Добавил след для кружков :)

Надеюсь мой ответ вам поможет!

→ Ссылка
Автор решения: CodeNameCoder

Я решил проблему отредактировав функцию resolveCollision

Добавил константу overlap которая рассчитывает перекрытие (пересечение) между двумя частицами, когда они сталкиваются.

Вот как это работает:

overlap вычисляется как разница между суммой радиусов двух частиц (particle.radius + otherParticle.radius) и расстоянием между их центрами (Math.hypot(xDist, yDist)). Если overlap больше нуля, это означает, что частицы перекрываются, т.е. находятся так близко друг к другу, что пересекаются.

Переменная используется для корректировки позиций частиц после столкновения, чтобы они не оставались в состоянии пересечения. Это помогает избежать проблемы, когда частицы визуально "залипают" друг на друге после столкновения.

   
   //Функция для ограничения количества кадров в секунду
   var limitLoop = function (fn, fps) {
 
            // Use var then = Date.now(); if you
            // don't care about targetting < IE9
            var then = new Date().getTime();
        
            // custom fps, otherwise fallback to 60
            fps = fps || 60;
            var interval = 1000 / fps;
         
            return (function loop(time){
                requestAnimationFrame(loop);
         
                // again, Date.now() if it's available
                var now = new Date().getTime();
                var delta = now - then;
         
                if (delta > interval) {
                    // Update time
                    // now - (delta % interval) is an improvement over just 
                    // using then = now, which can end up lowering overall fps
                    then = now - (delta % interval);
         
                    // call the fn
                    fn();
                }
            }(0));
        };



        //Полезные функции

        function randomIntFromRange(min,max) {
            return Math.floor(Math.random() * (max - min + 1) + min);
        }

        function frameCheck(ball){
          if(ball.y + ball.radius  + ball.velocity.y > canvas.height){
            ball.velocity.y = -ball.velocity.y * 0.6;
          } else {
            if(!ball.colideState){
            ball.velocity.y += gravity;
        }
          }
          
          if(ball.x + ball.radius + ball.velocity.x >= canvas.width || ball.x - ball.radius + ball.velocity.x<= 0){
            ball.velocity.x = -ball.velocity.x;
          }
        }


        function getDistance(x1, y1, x2, y2){
          let xDistance = x2 - x1;
          let yDistance = y2 - y1;

          return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2))
        }
        
        //Утилита для рассчета коллизии

        /**
 * Rotates coordinate system for velocities
 *
 * Takes velocities and alters them as if the coordinate system they're on was rotated
 *
 * @param  Object | velocity | The velocity of an individual particle
 * @param  Float  | angle    | The angle of collision between two objects in radians
 * @return Object | The altered x and y velocities after the coordinate system has been rotated
 */

function rotate(velocity, angle) {
  const rotatedVelocities = {
      x: velocity.x * Math.cos(angle) - velocity.y * Math.sin(angle),
      y: velocity.x * Math.sin(angle) + velocity.y * Math.cos(angle)
  };

  return rotatedVelocities;
}

/**
* Swaps out two colliding particles' x and y velocities after running through
* an elastic collision reaction equation
*
* @param  Object | particle      | A particle object with x and y coordinates, plus velocity
* @param  Object | otherParticle | A particle object with x and y coordinates, plus velocity
* @return Null | Does not return a value
*/

function resolveCollision(particle, otherParticle) {
  const xVelocityDiff = particle.velocity.x - otherParticle.velocity.x;
  const yVelocityDiff = particle.velocity.y - otherParticle.velocity.y;

  const xDist = otherParticle.x - particle.x;
  const yDist = otherParticle.y - particle.y;

  if (xVelocityDiff * xDist + yVelocityDiff * yDist > 0) {

const angle = -Math.atan2(otherParticle.y - particle.y, otherParticle.x - particle.x);
const m1 = particle.mass;
const m2 = otherParticle.mass;

const u1 = rotate(particle.velocity, angle);
const u2 = rotate(otherParticle.velocity, angle);

const v1 = { x: u1.x * (m1 - m2) / (m1 + m2) + u2.x * 2 * m2 / (m1 + m2), y: u1.y };
const v2 = { x: u2.x * (m2 - m1) / (m1 + m2) + u1.x * 2 * m1 / (m1 + m2), y: u2.y };

const vFinal1 = rotate(v1, -angle);
const vFinal2 = rotate(v2, -angle);

particle.velocity.x = vFinal1.x * 0.8;
particle.velocity.y = vFinal1.y * 0.8;

otherParticle.velocity.x = vFinal2.x * 0.8;
otherParticle.velocity.y = vFinal2.y * 0.8;

const overlap = particle.radius + otherParticle.radius - Math.hypot(xDist, yDist);
if (overlap > 0) {
  const correctionFactor = overlap / 2;
  const correctionX = (xDist / Math.hypot(xDist, yDist)) * correctionFactor;
  const correctionY = (yDist / Math.hypot(xDist, yDist)) * correctionFactor;

  particle.x -= correctionX;
  particle.y -= correctionY;
  otherParticle.x += correctionX;
  otherParticle.y += correctionY;
}

function handleBoundaryCollision(p, canvas) {
  if (p.x - p.radius < 0) {
    p.x = p.radius;
    p.velocity.x = Math.abs(p.velocity.x) < 0.01 ? 0 : -p.velocity.x * 0.8;
  } else if (p.x + p.radius > canvas.width) {
    p.x = canvas.width - p.radius;
    p.velocity.x = Math.abs(p.velocity.x) < 0.01 ? 0 : -p.velocity.x * 0.8;
  }

  if (p.y - p.radius < 0) {
    p.y = p.radius;
    p.velocity.y = Math.abs(p.velocity.y) < 0.01 ? 0 : -p.velocity.y * 0.8;
  } else if (p.y + p.radius > canvas.height) {
    p.y = canvas.height - p.radius;
    p.velocity.y = Math.abs(p.velocity.y) < 0.01 ? 0 : -p.velocity.y * 0.8;
  }
}

handleBoundaryCollision(particle, canvas);
handleBoundaryCollision(otherParticle, canvas);
  }
}



        //game
        const canvas = document.querySelector('canvas')
        const c = canvas.getContext('2d');
        canvas.height = window.screen.height - window.screen.height * 0.6; // window heigh - 20 percents
        canvas.width = document.querySelector('.container').clientWidth * 0.5; // container width
        let objects = []
   //Массив шариков
        let  ballsTemplates = {
          1:{
            y: 50,
            direction: 0,
            radius: 20,
            color: "blue",
            level: 1
          },
          2:{
            y: 50,
            direction: 0,
            radius: 25,
            color: "green",
            level: 2
          },
          3:{
            y: 50,
            direction: 0,
            radius: 30,
            color: "black",
            level: 3
          },
          4:{
            y: 50,
            direction: 0,
            radius: 35,
            color: "red",
            level: 4
          },
        }


        const gravity = 0.5;
        //objects
        class Ball {
            constructor(x, y, dx, dy, bp, radius, color, level) {
              this.x = x
              this.y = y
              this.pastX = 0
              this.pastY = 0
              this.colideState = false;
              this.velocity = {
                x: dx,
                y: dy
              }
              this.level = level
              this.bp = bp;
              this.radius = radius
              this.color = color
              this.mass = 1;
            }
          
            draw() {
              c.beginPath()
              c.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false)
              c.fillStyle = this.color
              c.fill()
              c.closePath()
            }
          
            update = objects => {

              this.pastX = this.x;
                  this.pastY = this.y

              this.y += this.velocity.y
              this.velocity.x = this.velocity.x * 0.99;
              this.x += this.velocity.x;
              //Не даём шарику возможности упать за границы карты
              frameCheck(this);

              for (let i=0; i < objects.length; i++){
                if(objects[i]){
                if(this === objects[i]) continue;
                if(getDistance(this.x, this.y, objects[i].x, objects[i].y) - (this.radius + objects[i].radius) < -0.8){
                  
                  //Вызов функции коллизии
                  resolveCollision(this, objects[i]);
                  

                 
                  let lvl = this.level;
                  let lvl2 = objects[i].level;
                  if(lvl === lvl2){
                    if(lvl < 4){
                    let x = (this.x + objects[i].x) / 2
                    let y = (this.y + objects[i].y) / 2

                    this.x = x;
                    this.y = y;
                    this.direction = ballsTemplates[lvl + 1].direction;
                    this.dy = 0.5;
                    this.bp =  0.9;
                    this.radius =  ballsTemplates[lvl + 1].radius;
                    this.color =  ballsTemplates[lvl + 1].color;
                    this.level =  ballsTemplates[lvl + 1].level;
                    
                  delete objects[i];
                }
                  }
                  
                    if(this.y == this.pastY){
                  this.colideState = true;
                }
                  } else {
                  this.colideState = false;
                  }

            }
        }

          this.draw()
        
            }
          }


  //функция для создания шарика по клику
          canvas.addEventListener('mouseup', function (event) {
           // Implementation
           const rect = canvas.getBoundingClientRect();
           const computedStyle = getComputedStyle(canvas);
           const borderLeftWidth = parseInt(computedStyle.borderLeftWidth, 10);
           const borderTopWidth = parseInt(computedStyle.borderTopWidth, 10);
           let numb = randomIntFromRange(1,4);
           let x = event.clientX - rect.left - borderLeftWidth
           let y = event.clientY - rect.top - borderTopWidth

           let radius = 55;

            let dxWerid = 5;

           if(x + radius * 2 > canvas.width){
            x = canvas.width - radius;
           } else if (x - radius * 2 < canvas.width){
            x = x + radius;
           }

          let ball = new Ball( x , y, ballsTemplates[numb].direction , 1 , 0.9,ballsTemplates[numb].radius, ballsTemplates[numb].color, ballsTemplates[numb].level)
        
           objects.push(ball)

            
          });



        const animate = async () => {
            c.fillStyle="white"
          
            c.fillRect(0, 0, canvas.width, canvas.height) //background :/
             objects.forEach(object => {
              object.update(objects)
             })
            
        }
    
        
        limitLoop(animate, 60);
.gameScreen {
    border: 10px solid white;
    background-color: black;
}
<body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"><div class="container" height="200" width="332"><div><div><canvas class="gameScreen"></canvas> <- С попощью левой кнопки мыши заспавни много шариков. Чтобы было несколько рядок</div></div></div></div>
  

</body>

→ Ссылка