Анимация на JavaScript с помощью Canvas и ES6 классов

Анимация на JavaScript с помощью Canvas и ES6 классов
Содержание:
  1. Что мы будем создавать?
  2. Подготовка к разработке
  3. Создание объекта, который будет отвечать за всё наше приложение
  4. Создание класса Circle
  5. Создание нужного количества шаров и запуск приложения
  6. Комментарии

В этой статье, на примере создания анимации шаров на <canvas>, мы разберём как написать ES6 класс для этой задачи и его использовать. Сам класс будет включать в себя достаточное большое количество различных свойств и методов, включая статические.

Что мы будем создавать?

В этой статье мы на реальном примере попрактикуемся в создании ES6 классов на JavaScript. В качестве задачи мы выберем создание анимации на <canvas>. А если быть более точным, то создадим движущиеся шары, отталкивающиеся друг друга и от границ холста.

Демо

Выполненное задание находится на GitHub: 01-html5-canvas-moving-balls.

Подготовка к разработке

Перед тем как начать писать код на JavaScript, создадим HTML-документ и подключим к нему js-файл посредством тега <script>:

HTML
<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Практика по ES6 классам. Создание анимации в canvas</title>
  <style>
    *,
    *::before,
    *::after {
      box-sizing: border-box;
    }
    body {
      margin: 0;
    }
    canvas {
      display: block;
      background-color: #f5f5f5;
    }
  </style>
  <script defer src="canvas.js"></script>
</head>
<body>
  <canvas></canvas>
</body>
</html>

<canvas> – это элемент HTML5, представляющий собой холст, на котором мы можем рисовать с помощью JavaScript.

Создание объекта, который будет отвечать за всё наше приложение

Начнём написание JavaScript кода с создания объекта посредством литерального синтаксиса:

JavaScript
const app = {
  canvas: document.querySelector('canvas'),
  width: document.documentElement.clientWidth,
  height: document.documentElement.clientHeight
}

Созданный объект в этом коде мы присвоили переменной app. Состоит объект в текущем виде из 3 свойств:

  • canvas – сюда мы положили элемент <canvas>, который получили с помощью метода document.querySelector;
  • width и height – в эти ключи мы поместили доступную ширину и высоту HTML- документа, которые соответственно узнали через свойства clientWidth и clientHeight.

Теперь добавим в объект app метод draw. Этот метод мы будем вызывать постоянно, как только браузер будет готов обновить анимацию на экране. Чтобы это сделать нам потребуется метод requestAnimationFrame, который имеется в объекте window. Этот метод сообщает браузеру то, что вы хотите выполнить анимацию. И как только браузер будет готов это сделать, она сразу выполнится. То есть по факту нам нужно в draw с помощью метода window.requestAnimationFrame снова вызвать метод draw():

JavaScript
draw() {

  window.requestAnimationFrame(this.draw.bind(this));
}

Теперь метод draw будет вызываться постоянно. Но перед тем как снова вызвать draw() нам необходимо сначала стереть весь холст. То есть очистить на нём все шары в их текущих положениях. После этого сделать новый кадр, то есть нарисовать шары, но уже в их новых положениях.

В итоге, метод draw будет иметь следующий код:

JavaScript
draw() {
  // стираем весь холст
  this.ctx.clearRect(0, 0, this.width, this.height)
  // рисуем новое положение каждого шара
  Circle.contains.forEach((item) => {
    item.draw()
  });
  // опять запускаем draw()
  window.requestAnimationFrame(this.draw.bind(this))
}

Пока мы ещё не создали класс Circle и конечно эту часть кода мы написали бы в реальности потом, но, чтобы не возвращаться к этому моменту разберём сейчас, что здесь происходит:

JavaScript
Circle.contains.forEach((item) => {
  item.draw();
});

У класса Circle имеется статическое свойство contains. Это свойство будет содержать массив объектов, которые мы создадим посредством этого класса. Другими словами, в этом свойстве будут находиться все созданные нами шары, которые нужно рисовать на <canvas>. Далее мы их перебираем посредством метода forEach и для каждого из них вызываем метод draw(). draw() в этом контексте – это метод шаров. Он выполняет рисование конкретного шара на холсте. Вот так всё просто.

Создадим в app ещё один метод. Назовём его init. С помощью него мы будем инициализировать всё наше приложение или, другими словами, запускать его:

JavaScript
init() {
  // получаем контекст 2d
  this.ctx = this.canvas.getContext('2d');
  // чтобы canvas занимал всю ширину и высоту viewport
  [this.canvas.width, this.canvas.height] = [this.width, this.height]
  // запускаем анимацию
  window.requestAnimationFrame(this.draw.bind(this))
}

В init мы сначала посредством специального метода getContext('2d') получим инструменты с помощью которых будет рисовать на холсте. Один из его инструментов – это метод clearRect, который мы использовали ранее в draw() для стирания всего холста. Объект, который нам возвращает getContext('2d') сохраним в свойство ctx объекта app.

После этого мы элементу <canvas> установим в качестве ширины и высоты всю доступную ширину и высоту HTML-документа. Ну и на последней строчке запустим анимацию, то есть вызовем метод draw объекта app, но не посредственно, а используя window.requestAnimationFrame. В этом случае он запустится, как только браузер будет готов это сделать.

Создание класса Circle

Объявим ES6 класс Circle. Этот класс будет отвечать за всё, что касается шаров. Начнём создание класса с добавлением в него статического свойства contains и конструктора:

JavaScript
class Circle {
static contains = [];
constructor(x, y, dx, dy, r, color) {
  this.x = x;
  this.y = y;
  this.dx = dx;
  this.dy = dy;
  this.r = r;
  this.color = color;
  this.constructor.contains.push(this);
}

Статическое свойство contains будем использовать для хранения всех созданных объектов типа Circle. Помещать эти объекты в данное свойство будем автоматически при вызове constructor(). Для этого в конце конструктора мы написали эту строчку:

JavaScript
this.constructor.contains.push(this);

Статические свойства и методы класса находятся не в самих объектах или их прототипах, а в конструкторе класса Circle. Поэтому мы сначала получаем его с помощью this.constructor. Затем мы обращаемся к contains и добавляем в это свойство посредством метода push созданный экземпляр класса.

Код конструктора очень простой. Здесь на вход мы принимаем аргументы, которые будем получать посредством следующих параметров: x, y, dx, dy, r и color. Их будем использовать для установки значений соответствующим свойствам:

  • x и y – это координаты центра окружности;
  • dx и dy – определяют направление движения шара и его скорость, другими словами, на сколько сместится шар по x и y при следующей отрисовке кадра;
  • r – это радиус окружности;
  • color – задаёт цвет заливки окружности.

Для удобства создания шара со случайными значениями свойств напишем статический метод create:

JavaScript
static create() {
  const r = 15
  const x = Math.random() * (app.width - r * 2) + r
  const y = Math.random() * (app.height - r * 2) + r
  const dx = (Math.random() - 0.5) * 5
  const dy = (Math.random() - 0.5) * 5
  const color = `#${Math.floor(Math.random() * 16777215).toString(16)}`
  new this(x, y, dx, dy, r, color)
}

Этот метод очень простой. Он будет устанавливать в качестве радиуса постоянное значение, в данном случае 15px, а всем остальным параметрам случайные числа в определённом диапазоне с использованием Math.random().

После этого с помощью new this() мы можем создать новый экземпляр класса Circle. this в статическом методе указывает на сам класс, то есть в данном случае на Circle.

Чтобы сделать анимацию шаров более интересной добавим в Circle два метода: processBorderCollision и processCollision.

Первый метод будет ограничивать выход шара за пределы <canvas>, то есть проверять коллизию шара и границ. В случае её обнаружения изменять направление шара на противоположное:

JavaScript
processBorderCollision() {
  if (this.x + this.r >= app.width || this.x - this.r <= 0) {
    this.dx = -this.dx
  }
  if (this.y + this.r >= app.height || this.y - this.r <= 0) {
    this.dy = -this.dy
  }
}

Вызов этого метод будем выполнять в update(), который добавим Circle немного позже.

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

JavaScript
static processCollision(circle1, circle2) {
  const d1x = circle1.x - circle2.x;
  const d1y = circle1.y - circle2.y;
  const dc = Math.sqrt(d1x * d1x + d1y * d1y);
  const v1c = Math.sqrt(circle1.dx * circle1.dx + circle1.dy * circle1.dy);
  const v2c = Math.sqrt(circle2.dx * circle2.dx + circle2.dy * circle2.dy);
  const vcmax = Math.max(v1c, v2c);
  const v1x = circle1.dx / vcmax;
  const v1y = circle1.dy / vcmax;
  const v2x = circle2.dx / vcmax;
  const v2y = circle2.dy / vcmax;
  const min = circle1.r + circle2.r;
  if (dc <= min) {
    const v1xNew = d1x / dc + v1x;
    const v1yNew = d1y / dc + v1y;
    const v2xNew = -d1x / dc + v2x;
    const v2yNew = -d1y / dc + v2y;
    const v1cNew = Math.sqrt(v1xNew * v1xNew + v1yNew * v1yNew);
    const v2cNew = Math.sqrt(v2xNew * v2xNew + v2yNew * v2yNew);
    const ratio = (v1c + v2c) / (v1cNew + v2cNew);
    circle1.dx = v1xNew * ratio;
    circle1.dy = v1yNew * ratio;
    circle2.dx = v2xNew * ratio;
    circle2.dy = v2yNew * ratio;
  }
}

Здесь много математики и вычислений, если интересно, то можете самостоятельно разобраться как это всё работает. Вызывать processCollision будем также в методе update().

Метод update:

JavaScript
update() {
  for (let i = 0; i < Circle.contains.length - 1; i++) {
    for (let j = i + 1; j < Circle.contains.length; j++) {
      Circle.processCollision(Circle.contains[i], Circle.contains[j]);
    }
  }
  this.processBorderCollision();
  this.x += this.dx;
  this.y += this.dy;
}

Этот метод будет выполнять одну очень простую вещь: обновлять значение свойств x и y шаров, а также вызывать два предыдущих метода для решения коллизий.

И наконец метод draw, который будет непосредственно рисовать окружность в соответствии со значениями свойств:

JavaScript
draw() {
  app.ctx.beginPath();
  app.ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI, false);
  app.ctx.fillStyle = this.color;
  app.ctx.fill();
  this.update();
}

В конце этого метода мы вызываем this.update(). С помощью него как мы уже знаем, обновляются координаты окружности, а также свойства dx и dy. Таким образом при следующем вызове draw окружность будет нарисована уже в другом месте в соответствии с новыми значениями свойств x и y.

Полный код скрипта доступен на GitHub: canvas.js.

Создание нужного количества шаров и запуск приложения

Теперь, когда у нас всё готово, необходимо в конце файла создать нужное количество шаров с помощью цикла for. Допустим, 25:

JavaScript
for (let i = 0; i < 25; i++) {
  Circle.create();
}

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

И последнее действие, которое нам необходимо сделать – это инициализировать наше приложение или, иными словами, запустить всё, что мы с вами написали:

JavaScript
app.init();

На этом всё, наслаждаемся созданной анимацией:

Комментарии: 6

Анигиляторная пушка
Анигиляторная пушка
Добрый день,
А можете написать пример на WebGL? на 2D браузер виснет, и код выполняется миллион лет.
Александр Мальцев
Александр Мальцев
Здравствуйте! 100 штук не тормозит. Анимацию и игры на WebGL обычно пишут с использованием движков Phaser, Three.js, PixiJS и других.
Скандинавия Хаус
Скандинавия Хаус
Всё, беру в практику. Спасибо.
Александр Мальцев
Александр Мальцев
Пожалуйста!
ctac
ctac
Спасибо. Очень интересно было читать, но все же осталось много непонятного.
1. например, как понять в какой последовательности все идет? Я бы такое если бы и делал то на функциях, а классами все получилось классно, не подумал бы что такое делается классами
2. Если шариков сделать раз в 10 больше то все начинает тормозить, это из-за плохой оптимизации? Просто игра агарио, так там таких шариков очень много и не тормозит…
Александр Мальцев
Александр Мальцев
Пожалуйста!
Не совсем понятно, про какую последовательность. вы спрашиваете.
Отрисовка у нас осуществляется только в области просмотра браузера. То есть мы рисуем только видимые объекты. Если карта большая, то за пределами области просмотра мы ничего не рисуем, а выполняем только расчёты. А сколько в этой игре видимых объектов?
Также здесь мы используем контекст 2D, в этом случае всё это выполняет CPU. Но, можно в качестве контекста использовать WebGL, тогда отрисовкой графики будет заниматься GPU и производительность будет намного лучше.