Как решить проблему зависания шариков друг в друге?

Шарики можно создавать кликом на поле, но для иллюстрации проблемы уже заранее создал 2 шарика.

При движении шарики могут войти друг в друга, сработает механизм отталкивания, который поменяет векторы движения шариков, но шарики могут не успеть выйти и тогда будет зависание. Либо шарики может натыкать пользователь и они тоже окажутся друг-в-друге.

Зависание:

введите сюда описание изображения

Минимальный пример:

import math
from random import randint

from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtGui import QPainter, QColor
from PyQt5.QtCore import Qt, QPointF, QTimer


class Ball:
    def __init__(self, pos, r=25):
        self.x = pos.x()
        self.y = pos.y()
        self.r = r

        self.dx = self.dy = 0
        while self.dx == 0 and self.dy == 0:
            self.dx = randint(-3, 3)
            self.dy = randint(-3, 3)

        self.color = QColor(randint(0, 255), randint(0, 255), randint(0, 255))

    @property
    def center(self):
        return self.x, self.y

    @property
    def top(self):
        return self.y - self.r

    @property
    def bottom(self):
        return self.y + self.r

    @property
    def left(self):
        return self.x - self.r

    @property
    def right(self):
        return self.x + self.r

    def move(self):
        self.x += self.dx
        self.y += self.dy

    def is_collide(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        return math.sqrt(dx * dx + dy * dy) < self.r + other.r

    def draw(self, painter):
        painter.setPen(Qt.black)
        painter.setBrush(self.color)

        painter.drawEllipse(QPointF(self.x, self.y), self.r, self.r)


class Window(QWidget):
    def __init__(self):
        super().__init__()

        self.timer = QTimer()
        self.timer.timeout.connect(self._on_timeout)
        self.timer.setInterval(30)
        self.timer.start()

        self.balls = [
            Ball(QPointF(200, 200)),
            Ball(QPointF(220, 220))
        ]

    def _on_timeout(self):
        for ball in self.balls:
            ball.move()

            # Отталкивание от стены
            if ball.left <= 0 or ball.right >= self.width():
                # Возврат шарика, если тот в стене
                if ball.left < 0:
                    ball.x = 0 + ball.r
                if ball.right > self.width():
                    ball.x = self.width() - ball.r

                ball.dx *= -1

            # Отталкивание от стены
            if ball.top <= 0 or ball.bottom >= self.height():
                # Возврат шарика, если тот в стене
                if ball.top < 0:
                    ball.y = 0 + ball.r
                if ball.bottom > self.height():
                    ball.y = self.height() - ball.r

                ball.dy *= -1

        num = len(self.balls)
        # Проверка столкнования шариков
        for i in range(num):
            for j in range(i + 1, num):
                if self.balls[i].is_collide(self.balls[j]):
                    self.balls[i].dx, self.balls[j].dx = self.balls[j].dx, self.balls[i].dx
                    self.balls[i].dy, self.balls[j].dy = self.balls[j].dy, self.balls[i].dy

        self.update()

    def mouseReleaseEvent(self, event):
        ball = Ball(event.pos())
        self.balls.append(ball)
        self.update()
    
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        for ball in self.balls:
            ball.draw(painter)


if __name__ == '__main__':
    app = QApplication([])

    w = Window()
    w.show()

    app.exec()

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

Накидал, примерно как это представляю (красная линия, это то, насколько шарики вошли в друг друга, стрелки это направление их векторов):

введите сюда описание изображения


UPD. Пробовал сделать шаг назад при столкновении шариков перед изменением векторов, но это не помогло:

    def move_back(self):
        self.x -= self.dx
        self.y -= self.dy
#    ^^^^^^^^^^^^^^^^^^^^^^^
...

        for i in range(num):
            for j in range(i + 1, num):
                if self.balls[i].is_collide(self.balls[j]):
                    self.balls[i].move_back()
                    self.balls[j].move_back()
#                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^

                    self.balls[i].dx, self.balls[j].dx = self.balls[j].dx, self.balls[i].dx
                    self.balls[i].dy, self.balls[j].dy = self.balls[j].dy, self.balls[i].dy

UPD2. Либо шаг дальше после изменения векторов, тоже не помогло:

                    self.balls[i].dx, self.balls[j].dx = self.balls[j].dx, self.balls[i].dx
                    self.balls[i].dy, self.balls[j].dy = self.balls[j].dy, self.balls[i].dy

                    self.balls[i].move()
                    self.balls[j].move()

UPD3. Удалось частично решить проблему, но это дикий костыль и шарики, при вхождении друг в друга далеко перемещаются и это может вызвать цепную реакцию. Тут я попробовал узнать насколько большое расстояние между шариками и если оно меньшей значения одного из радиусов, то обоим шарикам давалось перемещение:

        # Проверка столкнования шариков
        for i in range(num):
            for j in range(i + 1, num):
                if self.balls[i].is_collide(self.balls[j]):
                    dx = self.balls[i].x - self.balls[j].x
                    dy = self.balls[i].y - self.balls[j].y
                    d = math.sqrt(dx * dx + dy * dy)
                    
                    self.balls[i].dx, self.balls[j].dx = self.balls[j].dx, self.balls[i].dx
                    self.balls[i].dy, self.balls[j].dy = self.balls[j].dy, self.balls[i].dy

                    if d < self.balls[i].r * 1.8:
                        self.balls[i].x += -d * (1 if self.balls[i].dx < 0 else -1)
                        self.balls[i].y += -d * (1 if self.balls[i].dy < 0 else -1)

                        self.balls[j].x += -d * (1 if self.balls[j].dx < 0 else -1)
                        self.balls[j].y += -d * (1 if self.balls[j].dy < 0 else -1)

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

    def mouseReleaseEvent(self, event):
        ball = Ball(event.pos())
        if any(x.has_collide(ball) for x in self.balls):
            return

        self.balls.append(ball)
        self.update()

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

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

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

# Убрал библиотеку math так как считаю нецелосообразным её использование
from random import randint

from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtGui import QPainter, QColor
from PyQt5.QtCore import Qt, QPointF, QTimer


class Ball:
    def __init__(self, pos, r=25):
        self.x = pos.x()
        self.y = pos.y()
        self.r = r

        self.dx = self.dy = 0
        while self.dx == 0 and self.dy == 0:
            self.dx = randint(-3, 3)
            self.dy = randint(-3, 3)

        self.color = QColor(randint(0, 255), randint(0, 255), randint(0, 255))

    @property
    def center(self):
        return self.x, self.y

    @property
    def top(self):
        return self.y - self.r

    @property
    def bottom(self):
        return self.y + self.r

    @property
    def left(self):
        return self.x - self.r

    @property
    def right(self):
        return self.x + self.r

    def move(self):
        self.x += self.dx
        self.y += self.dy

    def is_collide(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        # Переписано выражение return (убрана библиотека math, несколько ускорилось вычесление)
        return (dx ** 2 + dy ** 2) ** .5 <= self.r + other.r

    def crash_move(self, other):
        # Метод обменивающий ускорения при столкновении
        # Почситал его перенос в класс шаров здравой идеей
        self.dx, other.dx = other.dx, self.dx
        self.dy, other.dy = other.dy, self.dy

    def crash_corrector(self, other, speed_drop=1):
        """
        :param other: ball class
        :param speed_drop: Коэффициент уменьшения силы отталкивания
        :return: добавочные x и y
        """
        x = self.x - other.x
        x /= speed_drop
        y = self.y - other.y
        y /= speed_drop
        return {'x': x, 'y': y}

    def draw(self, painter):
        painter.setPen(Qt.black)
        painter.setBrush(self.color)

        painter.drawEllipse(QPointF(self.x, self.y), self.r, self.r)


class Window(QWidget):
    def __init__(self):
        super().__init__()

        self.timer = QTimer()
        self.timer.timeout.connect(self._on_timeout)
        self.timer.setInterval(30)
        self.timer.start()

        self.balls = [
            Ball(QPointF(200, 200)),
            Ball(QPointF(200, 210))
        ]

    def _on_timeout(self):
        for ball in self.balls:
            ball.move()

            # Отталкивание от стены
            if ball.left <= 0 or ball.right >= self.width():
                # Возврат шарика, если тот в стене
                if ball.left < 0:
                    ball.x = 0 + ball.r
                elif ball.right > self.width():  
                    # if замена на elif [ради оптимизации скорости выполнения]
                    ball.x = self.width() - ball.r

                ball.dx *= -1

            # Отталкивание от стены
            if ball.top <= 0 or ball.bottom >= self.height():
                # Возврат шарика, если тот в стене
                if ball.top < 0:
                    ball.y = 0 + ball.r
                elif ball.bottom > self.height(): 
                    # if замена на elif [ради оптимизации скорости выполнения]
                    ball.y = self.height() - ball.r

                ball.dy *= -1
            # Новый метод для обработки столкновений
            self.ball_collide(ball=ball)
            """
            Целиком убран дополнительный for с проверкой 
            в силу того что аналогичную пробежку 
            по шарикам выполняем выше [опять оптимизация]
            """

        self.update()

    def mouseReleaseEvent(self, event):
        ball = Ball(event.pos())
        self.balls.append(ball)
        self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        for ball in self.balls:
            ball.draw(painter)

    def ball_collide(self, ball):
        # Проверяем коллизию с каждым шаром от текущего присланного из главного цикла
        for check_ball in self.balls:
            # Проверяем колизию и проверяем что шар не отношению к самому себе
            if check_ball.is_collide(ball) and check_ball != ball:
                check_ball.crash_move(ball) # производим обмен "силами"
                k = check_ball.crash_corrector(ball, speed_drop=10)  # Считаем поправки
                # Двигаем шарик и перерисовываем (Условно защита от диких разгонов)
                check_ball.move()
                self.update()
                if check_ball.is_collide(ball):
                    check_ball.dx += k['x'] # Применяем поправки и опять перерисовываем
                    check_ball.dy += k['y']
                    ball.dx -= k['x']
                    ball.dy -= k['y']
                    ball.move()
                    check_ball.move()
                    self.update()


if __name__ == '__main__':
    app = QApplication([])
    w = Window()
    w.show()
    app.exec()

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

Ваш код поправить не сложно: надо менять скорости шаров только если они сближаются. Это касается и столкновений между шарами и столкновений шара со стенкой.

Если пользователь создаст пересекающиеся шары, ничего делать не надо. Они самостоятельно выйдут из пересечения через некоторое время.

В коде ниже исправлены удары о стены и взаимные удары между шарами:

import math
from random import randint

from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtGui import QPainter, QColor
from PyQt5.QtCore import Qt, QPointF, QTimer


class Ball:
    def __init__(self, pos, r=25):
        self.x = pos.x()
        self.y = pos.y()
        self.r = r

        self.dx = self.dy = 0
        while self.dx == 0 and self.dy == 0:
            self.dx = randint(-3, 3)
            self.dy = randint(-3, 3)

        self.color = QColor(randint(0, 255), randint(0, 255), randint(0, 255))

    @property
    def center(self):
        return self.x, self.y

    @property
    def top(self):
        return self.y - self.r

    @property
    def bottom(self):
        return self.y + self.r

    @property
    def left(self):
        return self.x - self.r

    @property
    def right(self):
        return self.x + self.r

    def move(self):
        self.x += self.dx
        self.y += self.dy

    def is_collide(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        return math.sqrt(dx * dx + dy * dy) < self.r + other.r

    def draw(self, painter):
        painter.setPen(Qt.black)
        painter.setBrush(self.color)

        painter.drawEllipse(QPointF(self.x, self.y), self.r, self.r)


class Window(QWidget):
    def __init__(self):
        super().__init__()

        self.timer = QTimer()
        self.timer.timeout.connect(self._on_timeout)
        self.timer.setInterval(30)
        self.timer.start()

        self.balls = [
            Ball(QPointF(200, 200)),
            Ball(QPointF(220, 220))
        ]

    def _on_timeout(self):
        for ball in self.balls:
            ball.move()

            # Отталкивание от левой стены
            if ball.left <= 0:
                if ball.dx < 0:
                    ball.dx = -ball.dx

            # Отталкивание от правой стены
            if ball.right >= self.width():
                if ball.dx > 0:
                    ball.dx = -ball.dx

            # Отталкивание от верхней стены
            if ball.top <= 0:
                if ball.dy < 0:
                    ball.dy = -ball.dy

            # Отталкивание от нижней стены
            if ball.bottom >= self.height():
                if ball.dy > 0:
                    ball.dy = -ball.dy

        num = len(self.balls)
        # Проверка столкнования шариков
        for i in range(num):
            for j in range(i + 1, num):
                if self.balls[i].is_collide(self.balls[j]):
                    # вектор из центра шара i в центр шара j
                    pij = [self.balls[j].x - self.balls[i].x, self.balls[j].y - self.balls[i].y]
                    # скорость шара j относительно шара i
                    vij = [self.balls[j].dx - self.balls[i].dx, self.balls[j].dy - self.balls[i].dy]
                    # скалярное произведение векторов
                    # если оно отрицательно, шары сближаются
                    # только в этом случае надо менять скорости
                    if pij[0] * vij[0] + pij[1] * vij[1] < 0:
                        self.balls[i].dx, self.balls[j].dx = self.balls[j].dx, self.balls[i].dx
                        self.balls[i].dy, self.balls[j].dy = self.balls[j].dy, self.balls[i].dy

        self.update()

    def mouseReleaseEvent(self, event):
        ball = Ball(event.pos())
        self.balls.append(ball)
        self.update()
    
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        for ball in self.balls:
            ball.draw(painter)


if __name__ == '__main__':
    app = QApplication([])

    w = Window()
    w.show()

    app.exec()
→ Ссылка