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

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

Выполненное задание находится на GitHub: 01-html5-canvas-moving-balls.
Подготовка к разработке
Перед тем как начать писать код на JavaScript, создадим HTML-документ и подключим к нему js-файл посредством тега <script>
:
<!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 кода с создания объекта посредством литерального синтаксиса:
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()
:
draw() {
window.requestAnimationFrame(this.draw.bind(this));
}
Теперь метод draw
будет вызываться постоянно. Но перед тем как снова вызвать draw()
нам необходимо сначала стереть весь холст. То есть очистить на нём все шары в их текущих положениях. После этого сделать новый кадр, то есть нарисовать шары, но уже в их новых положениях.
В итоге, метод draw
будет иметь следующий код:
draw() {
// стираем весь холст
this.ctx.clearRect(0, 0, this.width, this.height)
// рисуем новое положение каждого шара
Circle.contains.forEach((item) => {
item.draw()
});
// опять запускаем draw()
window.requestAnimationFrame(this.draw.bind(this))
}
Пока мы ещё не создали класс Circle
и конечно эту часть кода мы написали бы в реальности потом, но, чтобы не возвращаться к этому моменту разберём сейчас, что здесь происходит:
Circle.contains.forEach((item) => {
item.draw();
});
У класса Circle
имеется статическое свойство contains
. Это свойство будет содержать массив объектов, которые мы создадим посредством этого класса. Другими словами, в этом свойстве будут находиться все созданные нами шары, которые нужно рисовать на <canvas>
. Далее мы их перебираем посредством метода forEach
и для каждого из них вызываем метод draw()
. draw()
в этом контексте – это метод шаров. Он выполняет рисование конкретного шара на холсте. Вот так всё просто.
Создадим в app
ещё один метод. Назовём его init
. С помощью него мы будем инициализировать всё наше приложение или, другими словами, запускать его:
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
и конструктора:
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()
. Для этого в конце конструктора мы написали эту строчку:
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
:
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>
, то есть проверять коллизию шара и границ. В случае её обнаружения изменять направление шара на противоположное:
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
немного позже.
Второй метод будет
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
:
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
, который будет непосредственно рисовать окружность в соответствии со значениями свойств:
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
:
for (let i = 0; i < 25; i++) {
Circle.create();
}
Для создания объекта мы использовали здесь статический метод create
. Сохранять все созданные шары нам никуда не нужно, так как вы уже знаете, они автоматически помещаются в статическое свойство contains
.
И последнее действие, которое нам необходимо сделать – это инициализировать наше приложение или, иными словами, запустить всё, что мы с вами написали:
app.init();
На этом всё, наслаждаемся созданной анимацией:
А можете написать пример на WebGL? на 2D браузер виснет, и код выполняется миллион лет.
1. например, как понять в какой последовательности все идет? Я бы такое если бы и делал то на функциях, а классами все получилось классно, не подумал бы что такое делается классами
2. Если шариков сделать раз в 10 больше то все начинает тормозить, это из-за плохой оптимизации? Просто игра агарио, так там таких шариков очень много и не тормозит…
Не совсем понятно, про какую последовательность. вы спрашиваете.
Отрисовка у нас осуществляется только в области просмотра браузера. То есть мы рисуем только видимые объекты. Если карта большая, то за пределами области просмотра мы ничего не рисуем, а выполняем только расчёты. А сколько в этой игре видимых объектов?
Также здесь мы используем контекст 2D, в этом случае всё это выполняет CPU. Но, можно в качестве контекста использовать WebGL, тогда отрисовкой графики будет заниматься GPU и производительность будет намного лучше.