Привет, уважаемые участники Хабр!

Сегодня продолжим создание мини-игры с движущимися кружочками и интересным капельным эффектом.

В первой части было сделано базовое перемещение кружочков по странице. А в сегодняшнем уроке мы сделаем анимацию “взрыва” и поглощения кружочков.

Финальное демо второй части урока:

Давайте начинать!

Генерация кружочков

Для того, чтобы сделать “деление” одного кружка на несколько в класс Substance добавим новую функцию splitting, которая в цикле будет создавать заданное количество новых кружков. Также определим в качестве данных два новых поля:

  • minSize - минимальный размер кружочка;

  • countPieces - количество генерируемых кружочков.

А в конце конструктора вызовем созданную функцию.

class Substance extends Base {
    constructor(...) {
        this.data = {
            minSize: 60,
            countPieces: 5,
            ...
        }
        ...

        this.splitting();
    }

    splitting() {
        for (let i = 0; i < this.data.countPieces; i++) {
            this.data.pieces.push(new Piece(this));
        }
    }
}

Чтобы кружочки не летали слишком быстро по экрану, вы можете отрегулировать по своему усмотрению значения скорости MIN_SPEED и MAX_SPEED класса Piece. В примере я изменю скорость перемещения, чтобы эффект “слияния” кружочков был более нагляден, когда они пролетают мимо друг друга.

class Piece extends Base {
    MIN_SPEED = 1;
    MAX_SPEED = 3;
    
    ...
}

В результате при клике по странице появится заданное в поле countPieces количество кружочков, которые будут хаотично перемещаться по странице.

Результат - появление множества кружков
Результат - появление множества кружков

Анимация “взрыва” кружочка

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

Функция animate позволяет описать порядок анимации для любого HTML-элемента, а также задать другие характеристики анимации (длительность, задержка старта и так далее). Похожим образом работают CSS-анимации с использованием @keyframes. 

Создадим функцию animateCollapse в классе Piece.

В качестве первого параметра в функцию animate передаём объект, в котором содержится название каждого CSS свойства, которое мы собираемся анимировать, а в качестве массива - последовательность кадров анимации.

В качестве второго параметра зададим длительность анимации, а также задержку начала анимации.

class Piece extends Base {
    ...

    animateCollapse() {
        const maxSize = this.parent.data.maxSize;
        const minSize = this.parent.data.minSize;

        this.animate = this.el.animate({
            width: [`${maxSize}px`, `${minSize}px`],
            height: [`${maxSize}px`, `${minSize}px`]
        }, {
            duration: 300,
            delay: 300
        });

        this.animate.onfinish = () => {}
    }
}

В данном случае используем именно реализацию через JavaScript. Нам необходимо определить момент окончания анимации, и сделать мы это можем через метод onfinish. Пока оставим его пустым, но скоро к нему вернёмся.

Функцию animateCollapse необходимо вызвать, чтобы запустить цепочку анимаций. Удалим вызов функции splitting, так как немного позднее мы перенесём его в метод onfinish. А при создании первого элемента Piece вызовем для него же анимацию “взрыва”.

class Substance extends Base {
    constructor(parent, params) {
        this.data = {...}

        this.data.pieces.push(new Piece(this));
        this.data.pieces[0].animateCollapse();
    }
    
    ...
}

У нас появилась анимация “взрыва” элемента. Теперь мы можем добавить генерацию кружков.

Появление кружков после окончания анимации

Для того чтобы сразу же после окончания анимации у нас происходила генерация кружков, вызовем функцию splitting в onfinish.

class Piece extends Base {
    ...
    
    animateCollapse() {
        ...

        this.animate.onfinish = () => {
            this.parent.splitting();
        }
    }
}

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

Но есть несколько недочётов:

  • во время анимации элемент двигается;

  • из-за того, что элемент двигается, генерация кружков происходит именно в том месте, где мы ранее кликнули.

Будем исправлять!

Остановка перемещения кружка при анимации

Для класса Substance добавим новое поле canMove, которое будет хранить состояние перемещения "разрешено/запрещено". Именно его мы будем менять на “движение разрешено”, когда анимация заканчивается, и, наоборот, когда анимация начинается. А по умолчанию выставляем значение false, так как наш кружок при появлении на странице сразу же начнёт свою анимацию.

class Substance extends Base {
    constructor(parent, params) {
        ...
        this.data = {...}
        
        this.canMove = false;

        ...
    }
}

В классе Piece сделаем несколько дополнений:

  • при старте функции animateCollapse изменяем значение canMove на “движение запрещено”, так как в дальнейшем цикл “взрыва” и “слияния” элементов будет бесконечным;

  • при завершении анимации в методе onfinish изменяем значение canMove на “движение разрешено”;

  • при старте функции update проверяем, что “движение разрешено”, а иначе - завершаем функцию и не обновляем позицию кружочка.

class Piece extends Base {
    ...

    update() {
        if (!this.parent.canMove) return;
        ...
    }
    
    animateCollapse() {
        this.parent.canMove = false;
        ...
        
        this.animate.onfinish = () => {
            this.parent.canMove = true;
            ...
        }
    }
}

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

Уменьшение размера кружочков

В класс Base добавим функцию updateSize для обновления размеров HTML-элементов.

class Base {
    ...

    updateSize() {
        this.el.style.width = `${this.data.size}px`;
        this.el.style.height = `${this.data.size}px`;
    }
}

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

class Piece extends Base {
    constructor(parent) {
        ...
        
        this.data = {
            size: this.parent.data.minSize,
            ...
        }
        ...
    }
}

А для того чтобы в качестве размера кружочка применялось динамическое значение, в функции createElement заменим установленное вручную значение на поле из data.size.

class Piece extends Base {
    ...

    createElement() {
        ...
        this.el.style.width = `${this.data.size}px`;
        this.el.style.height = `${this.data.size}px`;
        ...
    }

    ...
}

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

class Piece extends Base {
    ...

    animateCollapse() {
        ...

        this.data.size = this.parent.data.maxSize;
        this.updateSize();
        
        ...
        
        this.animate.onfinish = () => {
            this.data.size = this.parent.data.minSize;
            this.updateSize();
            ...
        }
    }
}

Теперь анимации “взрыва” и изменения размеров выглядят корректно.

Определение пересечения окружностей

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

Добавим в класс Base статическую функцию checkCollision, в которой используем математическую формулу соприкосновения окружностей и будем возвращать результат true/false.

class Base {
    ...

    static checkCollision(piece1, piece2) {
        const a = piece1.r + piece2.r;
        const x = piece1.x - piece2.x;
        const y = piece1.y - piece2.y;
        
        return a > Math.sqrt((x * x) + (y * y));
    }
}

В классе Piece создадим две новые функции:

  • checkCollision - в ней мы будем вызывать статический метод Base.checkCollision и передавать аргументы двух кружков. Если метод вернёт true, значит, окружности соприкасаются, и один из кружков мы удаляем;

  • removeElement - будет удалять из DOM элемент кружочка, в экземпляре класса которого была вызвана функция, а также удалять из массива pieces тот самый кружок.

class Piece extends Base {
    ...

    checkCollision() {
        this.parent.data.pieces.forEach((piece) => {
            // Выходим из цикла, если мы сравниваем один и тот же кружок
            if (this === piece) return;

            if (Base.checkCollision({
                r: this.data.size / 2,
                x: this.data.position.x,
                y: this.data.position.y
            }, {
                r: piece.data.size / 2,
                x: piece.data.position.x,
                y: piece.data.position.y
            })) {
                this.removeElement();
            }
        });
    }

    removeElement() {
        this.parent.removePieceFromArray(this);
        this.el.remove();
    }
}

В классе Substance создадим функцию removePieceFromArray. Данная функция необходима даже несмотря на то, что мы удалили объект из DOM, но в памяти JavaScript он остался и до сих пор перемещается.

class Substance extends Base {
    ...

    removePieceFromArray(piece) {
        this.data.pieces.splice(this.data.pieces.indexOf(piece), 1);
    }
}

В завершение функции update добавим вызов функции checkCollision.

class Piece extends Base {
    ...
    
    update() {
        ...

        this.checkCollision();
    }
}

Проверив результат, увидим странное поведение. Происходит это по той причине, что элементы не успевают отлететь подальше друг от друга и моментально “съедают” все сами себя.

Для того чтобы "выключить" функционал “поедания” на некоторое время после того, как происходит “взрыв”, добавим новое поле canCheckCollision в классе Substance.

class Substance extends Base {
    constructor(parent, params) {
        this.data = {...}
        ...

        this.canCheckCollision = false;

        ... 
    }
    
    ...
}

При старте animateCollapse присвоим значение false для поля canCheckCollision, а в конце метода onfinish добавим таймер. Так как после окончания анимации “взрыва” и до разлёта кружков по странице проходит некоторое время, то таймер позволит изменить значение по прошествии времени.

class Piece extends Base {
    ...

    animateCollapse() {
        this.parent.canCheckCollision = false;
        ...

        this.animate.onfinish = () => {
            ...
            setTimeout(() => {
                this.parent.canCheckCollision = true;
            }, 1000);
        }
    }
    
    ...
}

“Поглощение” мелких кружков

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

В классе Substance создадим новое поле difSize, которое будет являться разностью значений максимального и минимального размеров кружков. Его мы будем использовать в формуле расчёта увеличения кружка-поглотителя.

class Substance extends Base {
    constructor(parent, params) {
        ...

        this.canCheckCollision = false;
        this.difSize = this.data.maxSize - this.data.minSize;

        ...
    }
    
    ...
}

А в класс Piece добавим функцию consume, описав формулу, по которой будет происходить расчёт числа, добавляемого к размеру при “поглощении” меньшего элемента.

class Piece extends Base {
    ...

    consume(piece) {
        this.data.size += piece.data.size - this.parent.data.minSize + this.parent.difSize / this.parent.data.countPieces;
        this.updateSize();
        piece.removeElement();
    }
}

Вызываем функцию consume при выполнении условия пересечения окружностей внутри checkCollision.

class Piece extends Base {
    ...

    checkCollision() {
        if (!this.parent.canCheckCollision) return;

        this.parent.data.pieces.forEach((piece) => {
            ...

            if (Base.checkCollision(...)) {
                if (this.data.size > piece.data.size) this.consume(piece);
                else piece.consume(this);
            }
        });
    }
}

А чтобы анимация увеличение кружка смотрелась более плавно, нам всё-таки понадобится добавить ещё одно дополнительное свойство в наш файл стилей style.css.

.spore {
    transition: width .2s, height .2s;
    ...
}

Повторный “взрыв” кружка

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

В функции checkSplitting проверим, что длина массива составляет ровно один элемент и перезапишем положение “генерации” кружков на текущее положение кружка.

class Substance extends Base {
    ...
    
    removePieceFromArray(piece) {
        this.data.pieces.splice(this.data.pieces.indexOf(piece), 1);
        this.checkSplitting();
    }
    
    checkSplitting() {
        if (this.data.pieces.length === 1) {
            const piece = this.data.pieces[0];
            
            this.data.position.x = piece.data.position.x;
            this.data.position.y = piece.data.position.y;

            piece.animateCollapse();
        }
    }
}

Добавление разных цветов

В качестве константы в начало файла добавим массив разных цветов в переменную COLORS.

const SCREEN_WIDTH = ...
const SCREEN_HEIGHT = ...

const COLORS = ['red', 'cyan', 'yellow', 'green', 'blue', 'black'];

Чтобы при клике по странице кружки появились разных цветов, используем уже реализованную функцию, которая позволяет получить случайное число в заданном диапазоне. В качестве поля данных для класса Substance добавим ещё одну строку.

class Substance extends Base {
    constructor(parent, params) {
        ...

        this.data = {
            ...
            countPieces: 5,
            color: COLORS[Game.random(0, COLORS.length - 1)]
        }
        ...
    }
    
    ...
}

А для применения цвета всех кружков - добавим задний фон элементу в функции createElement.

class Piece extends Base {
    createElement() {
        ...
        this.el.style.backgroundColor = this.parent.data.color;

        ZONE.appendChild(this.el);
    }
    
    ...
}

Заключение

В результате работы у нас получилась небольшая интерактивная страничка с интересным эффектом, которую можно продолжать дорабатывать, внедряя свои идеи!

Финальный результат урока
Финальный результат урока

Репозиторий на GitHub

Всем спасибо за внимание и до встречи на Хабр!

Комментарии (3)


  1. shtaty
    20.09.2023 08:17

    о, чувак. сделай следующую статью о том как сделать физику как в bonk.io.

    бонк просто имеет свои сложные механики и интересно было бы узнать как они написаны


  1. acsent1
    20.09.2023 08:17

    Очень непривычный способ писать стрелочные функции без параметров как
    _ => {} вместо () => {}


    1. varlab Автор
      20.09.2023 08:17

      Спасибо за замечание! Да, согласен. Использование пустых скобок вместо подчеркивания - используется почти всегда. Синтаксис с подчеркиванием в таком случае становится неиспользуемым параметром, чего, конечно, лучше избегать