Пишем игру змейка на чистом JavaScript
В этой статье мы разработаем классическую игру змейка на чистом JavaScript. Змейка это простая игра, где змея перемещаясь внутри доски ест яблоки. Поедая яблоки змея растет. Мы добавим к функционалу игры еще и бомбы, при поедании которых змея будет умерать.
Этап 1 - Подготавливаем пустой макет(Skeleton):
Начнем разработку игры с подготовки нашего макета:
- Скачайте все ассеты игры от сюда () и поместите их в папку images.
- Создайте файл index.html и заполните его стандартной версткой с подключением файла style.css и app.js
Ваш файл index.html должен выглядеть так:
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Snake in Pure JavaScript</title>
<link rel="stylesheet" href="style.css">
<meta charset="utf-8">
</head>
<body>
<canvas></canvas>
<script src="app.js" type="module"></script>
</body>
</html>
Обратите особое внимание на то, как мы подключили файл app.js
<script src="app.js" type="module"></script>
Ма подключаем наш главный JavaScript файл(app.js) с аттрибутом type="module". Это даст нам возможность пользоваться импортом других JavaScript файлов в наш app.js, тоесть остальные JavaScript файлы будут импортированы сюда и нам не прийдется прописывать их в index.html
Теперь создадим файл style.css и добавим немного стилей:
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: #E7DDA9;
text-align: center;
}
canvas {
width: 100%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
-o-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
-moz-transform: translate(-50%, -50%);
-webkit-transform: translate(-50%, -50%);
}
С помощью CSS мы отцентровали наш canvas и добавили бекграунд цвет #E7DDA9. Вы можете выбрать любой другой цвет, но этот подходит под цвет нашего бекграунд-спрайта.
Нам осталось только создать папку js, добавить в него файл game.js и подключить его в нашем главном app.js файле:
import Game from './js/game.js';
В файле game.js, который находится в папке js мы пока вставим пустой класс с экспортом
export default class Game {
constructor() {
}
}
В дальнейшем мы будем модифицировать только файлы в папке js.Файлы внутри главной папки(index.html, style.css, app.js) мы больше трогать не будем.
Конечная структура нашего начального шаблона должна выглядеть как на картинке.

Этап 2 - Инициализация канваса и вывод бекграунда
В классе Game создадим 2 метода init и create, и вызовем их в конструкторе:
export default class Game {
constructor() {
this.init();
this.create();
}
init() {
}
create() {
}
}
В методе init как вы уже догадались мы будем инициализировать все необходимые в классе переменные, а в методе create - создавать наши сущности. Для начала инициализируем canvas.
init() {
// Подключаем канвас
this.canvas = document.querySelector('canvas');
// Получаем контекст
this.context = this.canvas.getContext('2d');
// Устанавливаем ширину и длину
this.context.canvas.width = 640;
this.context.canvas.height = 360;
// Определяем центр экрана
this.centerX = this.context.canvas.width / 2;
this.centerY = this.context.canvas.height / 2;
}
В коде выше мы проинициализировали Canvas, получили Canvas context, выставили высоту и ширину, а также сохранили центральные координаты в переменных centerX и centerY.
Теперь выведем наш бекграунд. Для этого создадим новый метод createBg и вызовем его из функции create
create() {
this.createBg();
}
createBg() {
}
Для того чтобы вывести на Canvas бекграунд нам нужно:
- Создать новую картинку с помощью JavaScript метода
new Image() - Задать картинке свойство src с путем до нашей картинки
- Повесить слушатель на событие load и убедиться что загрузка завершена
- Вывести бекграунд на Canvas
Вот как это выглядит в коде:
createBg() {
// Создаем новую картинку и задаем ей путь
this.bg = new Image();
this.bg.src = '../images/background.png';
// Слушаем загрузку
this.bg.addEventListener('load', () => {
// Перересовываем страницу
window.requestAnimationFrame(() => {
// Отрисовываем бекграунд
this.context.drawImage(this.bg, 0, 0);
});
});
}
После загрузки картинки мы вызываем функцию requestAnimationFrame, чтобы сообщить браузеру о том, что необходимо сделать перерисовку страницы, прежде чем выполнить код, находящийся в callback функции. В колбек мы как раз и отрисовываем наш бекграунд на контексте канваса с помощью метода drawImage, которому мы передаем картинку и координаты X и Y. В данном примере мы выводим картинку по координатам 0, 0 - это означает, что она выведется в левом верхнем углу. Поскольку мы разместили наш canvas в центре страницы(в файле style.css) - наша картинка выведется в центре экрана.
Результат нашей работы в браузере выглядит так:

Нам осталось только оранизовать метод Preload, в котором мы будем загружать все картинки. Мы ведь не хотим для каждого спрайта писать new Image(), затем добавлять src и отслеживать эту загрузку...Вместо этого, мы вынесем этот код в отдельный метод и загрузим все необходимые картинки для игры.
Сделаем метод preload и добавим его в конструктор:
constructor() {
this.init();
this.preload();
this.create();
}
preload() {}
Прелоадер мы тоже разделим на методы, пока он буден один - preloadImages, но в будущем мы добавим туда еще методы.
preload() {
this.preloadImages();
}
preloadImages(){
}
Теперь нам нужно сделать метод preloadImages. Мы будем принимать в него путь и координаты X и Y, затем создавать наш спрайт и возвращать эту картинку. Так мы сможем сохранять нужные спрайты в глобальные переменные класса с контекстом this
preloadImages() {
this.background = this.preloadImage('../images/background.png', 0, 0);
}
preloadImage(path, x, y) {
let image = new Image();
image.src = path;
image.addEventListener('load', () => {
window.requestAnimationFrame(() => {
this.context.drawImage(image, x, y);
});
});
return image;
}
Методы create() и createBg() мы можем удалить.
Функцию preloadImage нам нужно еще доработать, а именно добавить асинхронность. Самый простой способ дождаться 100% загрузки ассетов - это использовать Promise. Вот как наш код будет выглядеть с промисом:
async preloadImage(path, x, y) {
let image = new Image();
await new Promise((resolve, reject) => {
image.src = path;
image.addEventListener('load', () => {
// Если ассет загрузился - отрисовываем его
window.requestAnimationFrame(() => {
this.context.drawImage(image, x, y);
});
// возвращаем картинку
resolve(image);
});
image.addEventListener('error', () => {
// Если ошибка - пробрасываем ошибку в reject
reject(new Error("Couldn't load image"));
});
});
// возвращаем картинку
return image;
}
Мы добавили абсолютно стандарный Promise и обработали в нем ошибку, добавим отслеживание 'error'. Также мы добавили ключевое слово async перед названием функции. Теперь нам нужно немного переделать функцию preloadImages, и добавить также async, что бы мы могли возпользоваться оператором await, который позволит нам дождаться выполнения асинхронного кода. Таким образом мы получим в нашу переменную строку с путем картинки, как мы получали раньше. Если же мы уберем await - метод preloadImage вернет нам Promise.
async preloadImages() {
this.background = await this.preloadImage(
'../images/background.png',
0,
0,
);
}
Чтобы сделать функции preloadImage более универсальной - добавим значания X и Y по умолчанию
async preloadImage(path, x = -100, y = -100) {
...
}
Это позволит нам загружать ассеты не указывав абсолютные координаты. Мы передали в аргументах -100 для того, что бы спрайты не были видны на канвасе.
Давайте протестируем наш новый метод и добавим еще одну картинку cell.png, выведя ее в координатах centerX и centerY.
async preloadImages() {
this.background = await this.preloadImage(
'../images/background.png',
0,
0,
);
this.cell = await this.preloadImage(
'../images/cell.png',
this.centerX,
this.centerY,
);
}
Если вы видите 2 картинки на экране - значит все в порядке! Если нет - сверьтесь с кодом. Весь наш класс Game теперь выглядит так:
export default class Game {
constructor() {
this.init();
this.preload();
}
init() {
this.canvas = document.querySelector('canvas');
this.context = this.canvas.getContext('2d');
this.context.canvas.width = 640;
this.context.canvas.height = 360;
this.centerX = this.context.canvas.width / 2;
this.centerY = this.context.canvas.height / 2;
}
preload() {
this.preloadImages();
}
async preloadImages() {
this.background = await this.preloadImage(
'../images/background.png',
0,
0,
);
this.cell = await this.preloadImage(
'../images/cell.png',
);
}
async preloadImage(path, x, y) {
let image = new Image();
await new Promise((resolve, reject) => {
image.src = path;
image.addEventListener('load', () => {
window.requestAnimationFrame(() => {
this.context.drawImage(image, x, y);
});
resolve(image);
});
image.addEventListener('error', () => {
reject(new Error("Couldn't load image"));
});
});
return image;
}
}
Этап 3 - Создаем матрицу с доской
Наша доска будет состоять из двух сущностей: controller и model. В папке js создадим 2 новые папки: models и controllers. В папке models создадим файл board.js и опишем в нем нашу доску:
export default class Board {
constructor() {
this.init();
this.create();
}
init() {
// Инициализируем пустой массив, который позже заполним
this.cells = [];
// Ширина доски
this.boadWidth = 15;
// Длина доски
this.boadHeight = 15;
}
create() {
// Проходим первым циклом по ширине
for (let x = 0; x < this.boadWidth; x++) {
// Проходим вторым циклом по высоте
for (let y = 0; y < this.boadHeight; y++) {
// Добавляем координаты ячейки в массив
this.cells.push({x, y});
}
}
}
}
Мы создали уже знакомые нам методы init и create, которые вызываем в конструкторе. В методе init мы инициализировали наш массив клеток, а также ширину и длину матрицы (можете поиграть с размерами). В функции create мы добавили нашему массиву координаты X, Y. Это не совсем конечные координаты, а просто номера ячеек. Всю логику нашей доски мы вынесем в конроллер. Там же и посчитаем точные координаты. Таким образом мы полность изолируем логигу от модели.
Создаем Board Controller
import Board from './../models/board.js';
export default class BoardController {
constructor(context, cell) {
this.init();
this.render(context, cell);
}
init() {
this.board = new Board();
}
render(context, cell) {
}
}
В файле boardController.js, который мы создали в папке controllers мы импортируем нашу доску и рендерим ее. Для рендеринга нам нужны будут контекст канваса и спрайт ячейки, который мы передадим в конструктор из файла game.js. Дальше мы перебросим эти аргументы в метод render.
Теперь отрисовываем все ячейки:
render(context, cell) {
// Добаляем один пиксель к ширине и высоте спрайта, для отступа
const cellWidth = cell.width + 1;
const cellHeight = cell.height + 1;
// Проходим по массиву ячеек
this.board.cells.forEach((cellCoords) => {
window.requestAnimationFrame(() => {
context.drawImage(
cell,
// Умножаем номер ячейки на ширину
cellCoords.x * cellWidth,
// Умножаем номер ячейки на длину
cellCoords.y * cellHeight,
);
});
});
}
Мы создали доску с ячейками и осталось подключить ее в классе Game. Сделать это нужно после загрузки всех спрайтов. Чтобы не оборачивать код загрузки в еще один Promise и не усложнять его, мы просто вызовем метод create в самом низу функции preloadImages. А в методе create создадим наш Board Controller и запишем его в переменную.
this.boardController = new BoardController(this.context, this.cell);
Так нам нужно передать аргументы(контекст и ячейку) и импортировать файл boardController.js из папки controllers.
В браузере наша доска выглядит так:

Для того чтобы отрисовать нашу доску по центру - мы должны высчитать offsetX и ofsetY.
const offsetX = (context.canvas.width - cellWidth * this.board.boadWidth) / 2;
Для этого мы отнимем от ширины канваса ширину матрицы (ширина ячейки * кол-во ячеек) и поделим на два.
const offsetY = (context.canvas.height - cellheight * this.board.boadHeight) / 2;
Точно также высчитываем высоту. Отнимаем от длины канваса длину доски и делим на два.
Теперь нам нужно прибавить оффсеты к обеим сторонам.
render(context, cell) {
const cellWidth = cell.width + 1;
const cellheight = cell.height + 1;
const offsetX =
(context.canvas.width - cellWidth * this.board.boadWidth) / 2;
const offsetY =
(context.canvas.height - cellheight * this.board.boadHeight) / 2;
this.board.cells.forEach((cellCoords) => {
window.requestAnimationFrame(() => {
context.drawImage(
cell,
cellCoords.x * cellWidth + offsetX,
cellCoords.y * cellheight + offsetY,
);
});
});
}
И наша доска будет отцентрована.

Вот как сейчас выглядит весь файл boardController.js
import Board from './../models/board.js';
export default class BoardController {
constructor(context, cell) {
this.init();
this.render(context, cell);
}
init() {
this.board = new Board();
}
render(context, cell) {
const cellWidth = cell.width + 1;
const cellheight = cell.height + 1;
const offsetX =
(context.canvas.width - cellWidth * this.board.boadWidth) / 2;
const offsetY =
(context.canvas.height - cellheight * this.board.boadHeight) / 2;
this.board.cells.forEach((cellCoords) => {
window.requestAnimationFrame(() => {
context.drawImage(
cell,
cellCoords.x * cellWidth + offsetX,
cellCoords.y * cellheight + offsetY,
);
});
});
}
}
Этап 4 - Ограничиваем размер канваса
После создания нашей матрицы мы должны ограничить максимальный и минимальный размер нашего канваса. Это нужно для того, что бы наша доска всегда отрисовывалась полностью, а не обрезалась. Для того в классе Game в методе init переименуем переменные width и height в maxWidth и maxHeight; В методе create после создания boardController вывозовем новую функцию resizeCanvas.
create() {
this.boardController = new BoardController(this.context, this.cell);
this.resizeCanvas();
}
resizeCanvas() {
...
}
Именно в методе resizeCanvas мы и будем высчитывать новую высоту и ширину. Для начала определим минимальные значения. Мы хотим что бы наша доска всегда отрисовывалась полностью, а значит минимальной шириной будет ширина доски, а минимальной высотой - высота доски. В коде это выглядит так:
this.minWidth = (this.boardController.board.boadWidth + 1) * (this.cell.width + 1);
this.minHeight = (this.boardController.board.boadHeight + 1) * (this.cell.height + 1);
Как и раньше мы добавляем по одному пикселю для отступов. Теперь расчитаем ширыну, ведь длина доски будет зависить от этого значения.
this.width = Math.floor(
(window.innerWidth * this.maxHeight) / window.innerHeight,
);
this.width = Math.min(this.width, this.maxWidth);
this.width = Math.max(this.width, this.minWidth);
В коде выше мы 3 раза высчитываем ширину. Первый раз мы считаем соотношение между текущей шириной * максимальную высоту и текущей высотой. Второй раз с помощь метода Math.min мы сверяем, что ширина не привысила максимальную. И в третий раз ма проверяем, что ширина не меньше минимальной, а если меньше - устанавливаем минимальное значение.
Теперь высчитаем высоту:
this.height = Math.floor((this.width * window.innerHeight) / window.innerWidth);
После того как мы высчитали новую высоту и ширину мы можем их присвоить нашему канвасу:
this.context.canvas.width = this.width;
this.context.canvas.height = this.height;
Из метода init эти строки уже можно удалить.
Теперь нам нужно разделить наш метод высчита ширины и высоты. В зависимости от того, наш экран уже или шире мы будем растягивать канвас по ширине или высоте. Для этого мы создадим 2 новых метода fitWidth и fitHeight.
resizeCanvas() {
this.minWidth =
(this.boardController.board.boadWidth + 1) * (this.cell.width + 1);
this.minHeight =
(this.boardController.board.boadHeight + 1) *
(this.cell.height + 1);
// Определяем экран шире или уже
if (
window.innerWidth / window.innerHeight >
this.maxWidth / this.maxHeight
) {
this.fitWidth();
} else {
this.fitHeight();
}
this.context.canvas.width = this.width;
this.context.canvas.height = this.height;
this.drawBackground();
this.boardController &&
this.boardController.render(this.context, this.cell);
}
fitWidth() {
this.height = Math.round(
(this.width * window.innerHeight) / window.innerWidth,
);
this.height = Math.min(this.height, this.maxHeight);
this.height = Math.max(this.height, this.minHeight);
this.width = Math.round(
(window.innerWidth * this.height) / window.innerHeight,
);
this.canvas.style.width = '100%';
}
fitHeight() {
this.width = Math.round(
(window.innerWidth * this.maxHeight) / window.innerHeight,
);
this.width = Math.min(this.width, this.maxWidth);
this.width = Math.max(this.width, this.minWidth);
this.height = Math.round(
(this.width * window.innerHeight) / window.innerWidth,
);
this.canvas.style.height = '100%';
}
Нам осталось только перерисавать наш бекграунд и перерендерить нашу матрицу. Создадим метод drawBackground и поместим его над методом resizeCanvas
drawBackground() {
this.context.drawImage(
this.background,
(this.width - this.background.width) / 2,
(this.height - this.background.height) / 2,
);
}
Раньше мы отрисовывали бекграунд в координатах 0, 0. Теперь мы динамически высчитываем центр с оффсетом и таким образом отрисовываем бекграунд в центре канваса.
В методе resizeCanvas осталось только вызвать эту функции и сообщить классу BoardController, что нужно отрендерить матрицу с новыми данными.
this.drawBackground();
// Убеждаемся что boardController уже создан
this.boardController &&
this.boardController.render(this.context, this.cell);
Раньше в конструкторе класса BoardController мы вызывали метод render, теперь этот вызов можно удалить.
Нам еще нужно немного подправить наш CSS. А именно:
- Удалить свойство width: 100%;
- Довавить свойство image-rendering: pixelated;
Файл style.css сейчас выглядит так:
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: #E7DDA9;
text-align: center;
}
canvas {
position: absolute;
top: 50%;
left: 50%;
image-rendering: pixelated;
transform: translate(-50%, -50%);
-o-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
-moz-transform: translate(-50%, -50%);
-webkit-transform: translate(-50%, -50%);
}
Больше мы его менять не будем.
Этап 5 - Создаем змейку
Начнем с того, что подключим изображения через метод preloadImages в классе Game.
this.snakeBody = await this.preloadImage('../images/body.png');
this.snakeHead = await this.preloadImage('../images/head.png');
Затем создадим модель змейки - snake.js и контроллер - snakeController.js в папках models и controllers. В классе Snake мы вызовем пока пустые методы init и create, а в классе SnakeController вызовем init и render. Точно также мы создавали наш модель и контроллер для доски.
В моделе нашей змейки мы инициализируем два массива. В одном массиве мы запишим начальные коодринаты, а другой оставим пустым:
export default class Snake {
constructor() {
this.init();
this.create();
}
init() {
this.snakeCoords = [];
this.snakeStartCoords = [
{ x: 3, y: 12 },
{ x: 3, y: 13 },
];
}
create() {}
}
Массив snakeCoords мы заполним позже. Метод create пока остаеется пустой, возможно он нам вовсе не пригодится.
Переходим к классу SnakeController. Нам нужно передать сюда контекст канваса, boardController, а также спрайты головы и тела змейки. В методе init сразу создадим модель змейки.
// Импортируем модель змейки
import Snake from '../models/snake.js';
export default class SnakeController {
constructor(context, boardController, snakeBody, snakeHead) {
this.init(boardController);
// Прокидываем сразу все аргументы в метод рендер
this.render(context, boardController, snakeBody, snakeHead);
}
init(boardController) {
// Инициализируем модель змейки
this.snake = new Snake();
}
render(context, boardController, snakeBody, snakeHead) {
...
}
}
В методе init нам необходимо найти координаты нашей змеи. Для этого нам нужно немного модернизировать наш BoardController. Именно там нам лучше всего высчитывать наши координаты, ведь отрисовывать все обьекты мы будем поверх нашей мартицы. Создадим метод getCell(x, y), в котором будем возвращать ячейку на нашей доске. Таким образом все расчеты оффсетов и ширины/длины будут изолированы в одном классе.
getCell(x, y) {
return this.board.cells.find((c) => c.x === x && c.y === y);
}
Вернемся в наш SnakeController в метод init. Теперь найдем нужные координаты змейки и положим их в пустой(пока) массив snakeCoords, который мы создали в модели Snake. Вот как это выглядит в коде:
init(boardController) {
this.snake = new Snake();
for (let coord of this.snake.snakeStartCoords) {
let cell = boardController.getCell(coord.x, coord.y);
this.snake.snakeCoords.push(cell);
}
}
В коде выше мы проходим по стартовым координатам, затем идентифицируем их на доке и добавляем в массив координат. Сейчас в массиве snakeCoords должны находится два объекта с коодринатами змеи. Мы можем проверить правильно ли заполняется массив
console.log(this.snake.snakeCoords);
Переходим в медод render. Здесь нам нужно пройтись по всем координатам нашей змеи и отрисовать. Для этого мы и передаем сюда как тело змеи, так и ее голову. В ячейку массива под номером 0 мы положим голову, а в остальных отрисуем тело.
render(context, boardController, snakeBody, snakeHead) {
this.snake.snakeCoords.forEach((cell, i) => {
window.requestAnimationFrame(() => {
context.drawImage(
// Определяем номер массива
i === 0 ? snakeHead : snakeBody,
// Высчитываем ширину с оффсетом
cell.x * boardController.cellWidth +
boardController.offsetX,
// Высчитываем длину с оффсетом
cell.y * boardController.cellheight +
boardController.offsetY,
);
});
});
}
Весь класс SnakeController
import Snake from '../models/snake.js';
export default class SnakeController {
constructor(context, boardController, snakeBody, snakeHead) {
this.init(boardController);
this.render(context, boardController, snakeBody, snakeHead);
}
init(boardController) {
this.snake = new Snake();
for (let coord of this.snake.snakeStartCoords) {
let cell = boardController.getCell(coord.x, coord.y);
this.snake.snakeCoords.push(cell);
}
}
render(context, boardController, snakeBody, snakeHead) {
this.snake.snakeCoords.forEach((cell, i) => {
window.requestAnimationFrame(() => {
context.drawImage(
i === 0 ? snakeHead : snakeBody,
cell.x * boardController.cellWidth +
boardController.offsetX,
cell.y * boardController.cellheight +
boardController.offsetY,
);
});
});
}
}
Осталось только вызвать его в файле game.js и передать все аргументы. Сделаем это в методе create:
create() {
this.boardController = new BoardController(this.context, this.cell);
this.resizeCanvas();
this.snake = new SnakeController(
this.context,
this.boardController,
this.snakeBody,
this.snakeHead,
);
}
В браузере мы должны увидеть змею.

Этап 6 - Движение змейки
Для того, чтобы заставить нашу змейку двигать - нам нужно доработать наш класс SnakeController. Добавим методы move и getNextCell, но прежде сохраним наш BoardController в меторе render
this.boardController = boardController;
Мы воспользуемся этой переменной в ниже, а поскольку метод render будут вызываться при каждой отрисовке - наш boardController тоже будет обновлять свои данные.
Создаем метод move
move() {
// Находим следующую ячейку
let cell = this.getNextCell();
if (cell) {
// Добавляем ячейку в начало
this.snake.snakeCoords.unshift(cell);
// Удаляем последнию ячейку
this.snake.snakeCoords.pop();
}
}
Теперь в методе getNextCell нам осталось получить новую ячейку. Пока что мы сделаем это просто убавим одно значение у Y, а X оставим как есть. Воспользуемся для этого уже существующим методом getCell, который мы сделали для BoardController
getNextCell() {
let head = this.snake.snakeCoords[0];
return this.boardController.getCell(head.x, head.y - 1);
}
Метод move мы будем вызывать из нашего класса Game с интервалом 150 миллисекунды. Но также нам нужно перед каждой отрисовкой канваса очищать его, и затем снова рисовать бекграунд, на нем доску, и только после этого отрисовывать змейку в новых координатах. Для этого мы сделаем 2 новых метода в классе Game. Функцию start и update. Start мы вызовем в методе create, после создания всех необходимых сущностей
this.start();
В нем будет функция setInterval, в колбеке которой мы будем вызывать наш update.
start() {
setInterval(() => {
this.update();
}, 150);
}
В методе update мы будем перерисовывать весь наш конвас, с каждым ходом змеи.
update() {
// Делаем ход змеей
this.snakeController.move();
// Очищаем канвас
this.context.clearRect(
0,
0,
this.context.canvas.width,
this.context.canvas.height,
);
// Рисуем бекграунд
this.drawBackground();
// Перересовываем доску
this.boardController.render(this.context, this.cell);
// Перересовываем змею
this.snakeController.render(
this.context,
this.boardController,
this.snakeBody,
this.snakeHead,
);
}
Наша змейка теперь умеет двигаться.

Нам осталось только научиться ее останавливать. Мы хотим что бы змейка начинала движение только после нажатия на одну из кнопок. Поэтому мы добавим в модель змейки флаг isMoving. Изначально он будет равен false. Добавим его в метод init
this.isMoving = false;
В классе Snake у нас также есть пустой метод create. Переименуем его в startMoving и изменим в нем наш булеан.
startMoving() {
this.isMoving = true;
}
В контроллере змейки отменим движение, пока булеан isMoving стоит в значение false. Для этого добавим код в самый верх метода move:
if (!this.snake.isMoving) {
return;
}
Вернемся в класс Game. Нам нужно отследить нажатие любой клавиши и изменить флаг на isMoving = true. Сделаем это в методе create. Перед запуском игры создадим наши слушатели в методе createListeners и вызовем его.
this.createListeners();
createListeners() {
window.addEventListener('keydown', () => {
this.snakeController.snake.startMoving();
});
this.start();
}
В функции createListeners мы меняем флаг isMoving, а также запускаем игру.
Такой подход позволит нам в будущем останавливать и запускать различные обьекты нашей сцены. Возможно в этой, маленькой игре можно было обойтись одним общим флагом для всей игры. Но мы сделаем такие флаги для всех динамических обьектов. Это хорошая практика.
Прежде чем мы начнем отслеживать кнопки управления - нам нужно научить змейку двигаться в разные стороны. В классе SnakeController заведем новые переменные deltaX и deltaY. По умолчению они будут равны нулю. Инициализируем их в методе init
init(boardController) {
this.deltaX = 0;
this.deltaY = 0;
...
}
В функции getNextCell теперь просто прибавим эти значения к X и Y головы змеи:
getNextCell() {
let head = this.snake.snakeCoords[0];
return this.boardController.getCell(
head.x + this.deltaX,
head.y + this.deltaY,
);
}
Вот как это работает:
Если мы передадим в deltaX значение 1 - змейка побежит вправо. При значении -1, влево.
Точно также это работает и с deltaY. Значение 1 и змейка побежит вниз, а -1 заставит ее двигаться вверх, как у нас стояло до этого. Поэтому поставим изначально в положие вверх, тоесть:
this.deltaX = 0;
this.deltaY = -1;
Вернемся в класс Game, метод createListeners. Нам нужно определять нажатую кнопку и изменять значания направления. Вот как это выглядит в коде:
window.addEventListener('keydown', (e) => {
const { key } = e;
if (key === 'ArrowUp') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = -1;
} else if (key === 'ArrowDown') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = 1;
} else if (key === 'ArrowLeft') {
this.snakeController.deltaX = -1;
this.snakeController.deltaY = 0;
} else if (key === 'ArrowRight') {
this.snakeController.deltaX = 1;
this.snakeController.deltaY = 0;
}
this.snakeController.snake.startMoving();
});
Каждый раз когда мы устанавливаем deltaY, deltaX мы обнуляем, и наоборот. Это нужно чтобы наша змейка могла двигаться только в одном направалении.
Теперь наша змейка умеет двигаться по доске во всех направлениях.
Этап 7 - Добавляем Еду Для Змеи
Начнем с добавления спрайта в методе preloadImages, класса Game.
this.food = await this.preloadImage('../images/food.png');
Дальше нам нужно пробросить спрайт еды в метод render, класса BoardController. В методах update и resizeCanvas добавим спрайт в качестве аргумента:
this.boardController.render(this.context, this.cell, this.food);
В методе render примем агрумент food, а также нам уже можно очистить наш конструктор и удалить от туда все агрументы. Теперь мы создаем пустой BoardController:
this.boardController = new BoardController();
Строчкой ниже вызовем еще не созданную функцию addFood:
this.boardController.addFood();
Создаем функцию addFood
addFood() {
let cell = this.board.cells[0];
cell.hasFood = true;
}
В коде выше мы получаем первую ячейку нашей доски и ставим метку hasFood. Нам осталось только проверять в методе render, если есть метка hasFood - отрисовывать еду.
Код отрисовки:
if (cellCoords.hasFood) {
context.drawImage(
food,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
}
Изменился только один аргумент - спрайт. Весь код метод render сейчас выглядит так:
render(context, cell, food) {
this.cellWidth = cell.width + 1;
this.cellheight = cell.height + 1;
this.offsetX =
(context.canvas.width - this.cellWidth * this.board.boadWidth) / 2;
this.offsetY =
(context.canvas.height - this.cellheight * this.board.boadHeight) /
2;
this.board.cells.forEach((cellCoords) => {
window.requestAnimationFrame(() => {
context.drawImage(
cell,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
if (cellCoords.hasFood) {
context.drawImage(
food,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
}
});
});
}
Если вы все сделали правильно - вы должны увидеть яблоко на самой верхней левой ячейке.

Естественно мы хотим отрисовывать еду в случайных, свободных координатах. Для этого заменим строку:
let cell = this.board.cells[0];
на
getAvailableCell(){
return this.board.cells[0];
}
addFood() {
let cell = this.getAvailableCell();
cell.hasFood = true;
}
В методе getAvailableCell и будет вся логика получения свободной ячейки. Но на самом деле мы создадим еще одну функцию getRandomCell, в которую мы запишем формулу получения рандомного числа. Вот как это выглядим в коде:
getRandomCell(min, max) {
return Math.floor(Math.random() * (max + 1 - min) + min);
}
Теперь мы можем получать рандомную ячейку с нашей доски.
getAvailableCell() {
let idx = this.getRandomCell(0, this.board.cells.length - 1);
return this.board.cells[idx];
}
В выше созданный метод getRandomCell мы передаем 0 как минимальное число, и this.board.cells.length - 1 как максимальное.
Результат:

Нам осталось только проверять не находится ли змея в данный момент на нашей ячейке. Для этого нам конечное нужно пробросить SnakeController в метод addFood, а дальше мы передадим его в getAvailableCell.
getAvailableCell(snakeController) {
...
}
addFood(snakeController) {
let cell = this.getAvailableCell(snakeController);
cell.hasFood = true;
}
Напомню вам, что в классе SnakeController мы создаем змею и храним ее координаты в массиве snakeCoords. С помощью метода filter мы можем проверить является ли ячейка свободна от змеи. Вот как это выглядит в коде:
getAvailableCell(snakeController) {
// Получаем массив свободных ячеек
const availableCells = this.board.cells.filter((cell) => {
return !snakeController.snake.snakeCoords.includes(cell);
});
// Передаем аргументы нового массива
let idx = this.getRandomCell(0, availableCells.length - 1);
return availableCells[idx];
}
Сейчас наша доска умеет рендерить еду, сверяя координаты с змеей. Таким образом мы гарантировано отрендерим еду в свободной ячейке.
Этап 8 - Поедание Еды Змейкой
Логика поедания будет очень простая. Каждый раз при движении змеи в методе move класса SnakeController, мы "отрезаем" змее хвост, методом pop
move() {
if (!this.snake.isMoving) {
return;
}
let cell = this.getNextCell();
if (cell) {
this.snake.snakeCoords.unshift(cell);
// Отрезаем змее хвост
this.snake.snakeCoords.pop();
}
}
Все что нам нужно - это отменить это действие, если змея сьела еду.
if (cell) {
this.snake.snakeCoords.unshift(cell);
if (cell.hasFood) {
// Если ячейка содержит еду - прерываем метод
return;
}
this.snake.snakeCoords.pop();
}
Немного усложним этот метод. А именно: нам необходимо сначала стереть старое яблоко, а затем отрисовать новое.
if (cell) {
this.snake.snakeCoords.unshift(cell);
if (cell.hasFood) {
// Стираем сьеденное яблоко
this.boardController.removeFood(cell);
// Отрисовываем новое яблоко
this.boardController.addFood(this);
return;
}
this.snake.snakeCoords.pop();
}
Теперь создадим функцию removeFood в классе BoardController.
removeFood(cell) {
cell.hasFood = false;
}
Наша змейка уже умеет поедать яблоки.

Этап 9 - Вращаем Голову Змеи

Вращение головы мы можем реализовать двумя методами:
- Сделать 4 изображения, смотрящие в разные стороны и менять их в зависимости от направления
- Динамически разворачивать голову змеи

В этой, простой игре - мы используем второй способ. Для этого нам нужно доработать метод render класса SnakeController. Начнем с того, что добавим переменную degree в методе init:
this.degree = 180;
Мы задали значение по умолчанию 180 - чтобы наша змейка изначально смотрела вверх. В дальнейшем мы будем регулировать поворот головы звеи просто меняя это число. Для этого нам нужно, сохранить контекст канваса, развернуть его, отрисовать голову, и затем вернуть сохраненный контекст.
Теперь наш метод render выглядит так:
render(context, boardController, snakeBody, snakeHead) {
this.boardController = boardController;
// сохраняем половину ширины(она же длина)
const halfHeadSize = snakeHead.width / 2;
this.snake.snakeCoords.forEach((cell, i) => {
window.requestAnimationFrame(() => {
// Находим голову
if (i === 0) {
// Сохраняем контекст
context.save();
// Переходим к клекте головы
context.translate(
cell.x * boardController.cellWidth +
boardController.offsetX,
cell.y * boardController.cellheight +
boardController.offsetY,
);
// Переходим к центру клетки
context.translate(halfHeadSize, halfHeadSize);
// Разворачиваем контекст
context.rotate((this.degree * Math.PI) / 180);
// Отрисовываем голову
context.drawImage(snakeHead, -halfHeadSize, -halfHeadSize);
// Восстанавливаем контекст без ротаций
context.restore();
} else {
context.drawImage(
snakeBody,
cell.x * boardController.cellWidth +
boardController.offsetX,
cell.y * boardController.cellheight +
boardController.offsetY,
);
}
});
});
}
Изменять переменную degree мы будет в методе createListeners класса Game. Для того, чтобы змея смотрела вниз нужно установить значение 0, вверх - 180, 90 градусов - влево, и 270 вправо. Весь код метода выглядит теперь так:
createListeners() {
window.addEventListener('keydown', (e) => {
const { key } = e;
if (key === 'ArrowUp') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = -1;
this.snakeController.degree = 0;
} else if (key === 'ArrowDown') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = 1;
this.snakeController.degree = 180;
} else if (key === 'ArrowLeft') {
this.snakeController.deltaX = -1;
this.snakeController.deltaY = 0;
this.snakeController.degree = 270;
} else if (key === 'ArrowRight') {
this.snakeController.deltaX = 1;
this.snakeController.deltaY = 0;
this.snakeController.degree = 90;
}
this.snakeController.snake.startMoving();
});
this.start();
}
Голова нашей змейки поворачивается во все стороны.

Этап 10 - Отрисовываем Бомбы
Как всегда начнем с подключения нашего спрайта. В классе Game, в методе preloadImages добавим строчку:
this.bomb = await this.preloadImage('../images/bomb.png');
Затем в методе create вызовем пока не созданную нами функцию addBomb
this.boardController.addBomb(this.snakeController);
Метод addBomb полностью идентичен функции addFood
this.boardController.addFood(this.snakeController);
this.boardController.addBomb(this.snakeController);
Переходим в класс BoardController, именно здесь мы реализуем метод addBomb, по аналогии с addFood.
addBomb(snakeController) {
let cell = this.getAvailableCell(snakeController);
cell.hasBomb = true;
}
Нам нужно немного доработать функцию getAvailableCell, в который проверять, нет ли в ячейке бомбы или еды.
Код доработанного метода выглядит так:
getAvailableCell(snakeController) {
const availableCells = this.board.cells.filter((cell) => {
// Отсекаем клетки с едой и бомбами
if (cell.hasFood || cell.hasBomb) {
return;
}
return !snakeController.snake.snakeCoords.includes(cell);
});
let idx = this.getRandomCell(0, availableCells.length - 1);
return availableCells[idx];
}
Добавление бомб почти готово. Благодаря универсальности нашего кода - мы можем легко добавлять новые объекты. Осталось только отрендерить наши бомбы в методе render. Но в начале нужно пробросить в эту функцию спрайт бомбы. В методах resizeCanvas и update нужно добавить наш аргумент:
this.boardController.render(this.context, this.cell, this.food, this.bomb);
В методе render, класса BoardController нужно принять аргумент бомбы:
render(context, cell, food, bomb) { }
После отрисовки еды, напишем идентичный код для отрисовки бомб
if (cellCoords.hasBomb) {
context.drawImage(
bomb,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
}
В браузере мы должны увидеть бомбу

Если вы не видете бомбу, сверьтесь с кодом:
render(context, cell, food, bomb) {
this.cellWidth = cell.width + 1;
this.cellheight = cell.height + 1;
this.offsetX =
(context.canvas.width - this.cellWidth * this.board.boadWidth) / 2;
this.offsetY =
(context.canvas.height - this.cellheight * this.board.boadHeight) /
2;
this.board.cells.forEach((cellCoords) => {
window.requestAnimationFrame(() => {
context.drawImage(
cell,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
if (cellCoords.hasFood) {
context.drawImage(
food,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
}
if (cellCoords.hasBomb) {
context.drawImage(
bomb,
cellCoords.x * this.cellWidth + this.offsetX,
cellCoords.y * this.cellheight + this.offsetY,
);
}
});
});
}
Поскольку методы addBomb и addFood абсолютно идентичны - мы можем их обьеденить в новую функцию addObject. А в качестве аргументов мы будем передавать SnakeController и тип обьекта. В коде это выглядит так:
addObject(snakeController, type) {
let cell = this.getAvailableCell(snakeController);
if (type === 'food') {
cell.hasFood = true;
}
if (type === 'bomb') {
cell.hasBomb = true;
}
}
В методе create, класса Game изменим вызов старых методов. Теперь это происходит так:
this.boardController.addObject(this.snakeController, 'food');
this.boardController.addObject(this.snakeController, 'bomb');
Функцию removeFood тоже модифицируем и переименуем в removeObject
removeObject(cell, type) {
if (type === 'food') {
cell.hasFood = false;
}
if (type === 'bomb') {
cell.hasBomb = false;
}
}
В методе move класса SnakeController сейчас удаляем еду так:
this.boardController.removeObject(cell, 'food');
Из класса BoardController можно уже удалить методы addBomb и addFood.
Логика появления бомб будет отличаться от логики появления еды. Ведь еду мы сьедаем, а затем генерируем новое яблоко. Бомбы мы будем добавлять в setTimeout, каждые 5 секунд. При добавлении новой бомбы - старую будем стерать. Только вот теперь у нас нет точной координаты бомбы, ведь мы ее не сьедаем. Значит нам нужно сделать новый метод, который будет пробегаться по массиву ячеек и затерать бомбы. Назовем этот метод ```removeBombs````:
removeBombs() {
this.board.cells.forEach((cell) => (cell.hasBomb = false));
}
В методе addObject перед добавлением новый бомбы мы будем вызывать функцию removeBombs
if (type === 'bomb') {
this.removeBombs();
cell.hasBomb = true;
}
Теперь все работает как и должно. Бомбы появляются с переодичностью в 5 секунд. Нам остается только обработать коллизии при столкновении с ними.
Этап 11 - Завершение Игры (Game Over)
Для начала давайте перечислим все условия, при которых наша игра должна завершаться:
- Змея покинула пределы доски.
- Змея наехала на саму себя.
- Змея сьела бомбу.
Тригером к поражению в игре всегда будет наша змейка. А значит проверять все условия нам нужно в классе SnakeController. В методе move после получения новой ячейки мы сделаем проверки:
// Получаем новую клекту
let cell = this.getNextCell();
// Проверяем есть ли клетки и не пренадлежит ли она змее.
if (!cell || this.snake.snakeCoords.includes(cell)) {
console.log('Game Over');
return;
}
При попытке выехать за пределы доски или наехать на саму себя мы выводим в консоле Game Over. Вместо этого сделаем отдельный метод, который будет останавливать игру. В файле game.js добавим статический метод gameOver
static gameOver() {
console.log('game over');
}
Мы сделали этот метод статическим для того, чтобы вызывать его без создания инстанса класса Game в SnakeController
if (!cell || this.snake.snakeCoords.includes(cell)) {
Game.gameOver();
return;
}
Естесвенно, сам класс Game необходимо импортировать.
import Game from '../game.js';
Пока что мы проверяем только не выехала ли наша змея за доску или не наехала ли она на саму себя. Но нам еще нужно проверить на наличие бомбы в клекте. Для этого после того как мы убедились, что ячейка существует - добавим проверку на бомбу, в методе move класса SnakeController
if (cell.hasBomb) {
Game.gameOver();
}
Весь метод move сейчас выглядит так:
move() {
if (!this.snake.isMoving) {
return;
}
let cell = this.getNextCell();
if (!cell || this.snake.snakeCoords.includes(cell)) {
Game.gameOver();
return;
}
if (cell) {
this.snake.snakeCoords.unshift(cell);
if (cell.hasFood) {
this.boardController.removeObject(cell, 'food');
this.boardController.addFood(this);
return;
}
if (cell.hasBomb) {
Game.gameOver();
}
this.snake.snakeCoords.pop();
}
}
Сейчас мы должны получать сообщение в консоле при любом из условий проигрыша.

Для того, чтобы остановить нашу игру - нам нужно остановить наши интревалы, которые мы создаем в методе start. Для начала сохраним их в переменные.
start() {
this.updateInterval = setInterval(() => {
this.update();
}, 150);
this.bombInterval = setInterval(() => {
this.boardController.addObject(this.snakeController, 'bomb');
}, 5000);
}
Выше мы сделали метод gameOver статическим, но для того чтобы очистить интервалы нам нужен обычный метод. Статические методы создаются в первую очередь. В момент создания метода gameOver, наши интервалы еще не проинициализированы, а значит мы не может их остановить. Уберем ключевое слово static из метода gameOver и очистим в нем наши интервалы.
gameOver() {
clearInterval(this.updateInterval);
clearInterval(this.bombInterval);
}
Еще нам нужно изменить логику вызова функции gameOver, а именно...нужно сообщить из класса SnakeController, что игра окончена. Сделаем это самым простым способом, добавим нашему контроллеру свойство gameOver.
if (!cell || this.snake.snakeCoords.includes(cell)) {
this.gameOver = true;
return;
}
тоже самое сделаем при поедании бомбы
if (cell.hasBomb) {
this.gameOver = true;
}
Теперь в методе update будем проверять не появилась ли переменная gameOver у SnakeController
if (this.snakeController.gameOver) {
this.gameOver();
}
Сейчас игра просто останавливается и не как не сообщает нам о произошедшем. Мы исправим это выведя сообщение методом alert и перезагрузив страницу. Методу gameOver добавим 2 строчки кода:
gameOver() {
clearInterval(this.updateInterval);
clearInterval(this.bombInterval);
alert('Game Over');
window.location.reload();
}
Вот теперь у нас есть завершение игры.

Этап 12 - Загружаем Звуки
Вы можете найти 4 файла звуков в папке sounds в github репоситории. Файлы называются snakecharmer.wav, bomb.wav, food.wav и game-over.wav

Начнем естественно с загрузки звуков в классе Game. По аналогии с методами загрузки ассетов создадим методы preloadSound и preloadSounds. Обьеденить все метода в один мы не сможем. Когда мы загружаем картинки, нам нужно добавить их на канвас по окончанию. Со звуками дело абстоит немного по другому. Вот как будет выглядеть наша функция preloadSound
async preloadSound(path) {
let sound = new Audio();
await new Promise((resolve, reject) => {
sound.src = path;
sound.load();
sound.addEventListener(
'canplaythrough',
() => {
resolve(sound);
},
{ once: true },
);
sound.addEventListener('error', () => {
reject(new Error("Couldn't load sound"));
});
});
return sound;
}
Как вы заметили мы не чего не загружаем на канвас. Вместо этого мы дожидаемся события canplaythrough и возвращаем обьект из промиса.
Теперь в методе preloadSounds загружаем все звуки.
async preloadSounds() {
this.bombSound = await this.preloadSound('../sounds/bomb.wav');
this.foodSound = await this.preloadSound('../sounds/food.wav');
this.gameOverSound = await this.preloadSound('../sounds/game-over.wav');
this.snakeSound = await this.preloadSound('../sounds/snakecharmer.wav');
this.snakeSound.loop = true;
}
Нашему главному звуку, который будет играть постоянно выставляем свойство loop = true.
Также немного переделаем наш метод preload. Дождемся все ассетов в асинхронном режиме и затем запустить функцию create
async preload() {
await this.preloadImages();
await this.preloadSounds();
this.create();
}
Исправим также ошибку в методе createListeners. В самом низу метода мы вызываем функцию start и запускаем наши интервалы. Нам нужно это делать только после того как игра началась. Для того, чтобы отследить начало игры сделаем новый флаг gameisStarted и будем менять его значение после первого нажатия. Весь метод createListeners сейчас выглядит так:
createListeners() {
let gameisStarted = false;
window.addEventListener('keydown', (e) => {
if (!gameisStarted) {
gameisStarted = true;
this.start();
}
const { key } = e;
if (key === 'ArrowUp') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = -1;
this.snakeController.degree = 0;
} else if (key === 'ArrowDown') {
this.snakeController.deltaX = 0;
this.snakeController.deltaY = 1;
this.snakeController.degree = 180;
} else if (key === 'ArrowLeft') {
this.snakeController.deltaX = -1;
this.snakeController.deltaY = 0;
this.snakeController.degree = 270;
} else if (key === 'ArrowRight') {
this.snakeController.deltaX = 1;
this.snakeController.deltaY = 0;
this.snakeController.degree = 90;
}
this.snakeController.snake.startMoving();
});
}
В методе start мы и запустим наш главный звук, который мы назвали snakeSound
this.snakeSound.play();
Теперь у нас есть музыка на бекграунде, которая играет постоянно в цикле. Давайте добавим звук оканчания игры в методе gameOver
gameOver() {
// Останавливаем главный звук
this.snakeSound.pause();
// Запускаем звук окончания игры
this.gameOverSound.play();
clearInterval(this.updateInterval);
clearInterval(this.bombInterval);
alert('Game Over');
window.location.reload();
}
Теперь у нас есть звуки бекграунда и окончания игры. Нам нужно добавить звук еды и звук бомбы. Столкновения с обьектами мы обрабатывает в классе SnakeController. Именно там там нужно добавить переменные playFood и playBomb.
В методе move класса SnakeController добавим эти строки кода:
if (cell.hasFood) {
this.playFood = true;
this.boardController.removeObject(cell, 'food');
this.boardController.addFood(this);
return;
}
if (cell.hasBomb) {
this.playBomb = true;
this.gameOver = true;
}
Возвращемся в метод update класса Game и добавим проверки на наши новые поля.
if (this.snakeController.playBomb) {
this.bombSound.play();
this.snakeController.playBomb = false;
}
if (this.snakeController.playFood) {
this.foodSound.play();
this.snakeController.playFood = false;
}
Как вы заметили после проигрывания звука мы ставим флаги проигрывания в false. Это нужно для того, чтобы наши звуки проигрывались только один раз.
Теперь у нас есть звуки взрыва и поедания яблок. Для того, чтобы лучше их слышать - нам нужно немного приубавить громкость главного звука. Вернемся в метод preloadSounds и добавим в самом конце строку:
this.snakeSound.volume = 0.1;
Мы реализовали звуки. В следующих туториалах мы сделаем полноценный Audio Manager, но для маленькой игры, то что мы сделали - в самый раз.
Этап 12 - Вывод Количества Очков
Для вывода кол-ва очков нам нужно создать два метода. В первом методе createFont мы создадим наш фонт. Эта функция будет вызвана один раз при инициализации класса Game. А вот функцию createScore мы будем постоянно отрисовывать с каждым ходом змеи. Вначале создадим фонт.
createFont() {
this.context.font = '20px Roboto';
this.context.fillStyle = '#747474';
}
И вызовем этот метод из функции create
this.createFont();
В методе init создадим переменную score и дадим ей значений 0.
this.score = 0;
Инкрементируя эту переменную мы будем увеличивать наши очки. Но сперва нужно создать метод createScore
createScore() {
this.context.fillText(`Score: ${this.score}`, 25, 25);
}
Также добавим вызов этого метода в update, чтобы очки отрисовывались каждые 150 миллисекунд.
this.createScore();
Где же нам инкрементировать переменную score? Все очень просто. Там же где мы отслеживаем проигрывание звуков, в методе update
if (this.snakeController.playFood) {
this.score++;
this.foodSound.play();
this.snakeController.playFood = false;
}
Вот и все...наши очки инкрементируются. Игра полностью работает.
Нам осталось только дождаться полной загрузки HTML документа перед запуском игры. Для этого откроем наш файл app.js и дождемся события DOMContentLoaded перед запуском игры.
import Game from './js/game.js';
window.addEventListener('DOMContentLoaded', () => {
const game = new Game();
});
Теперь точно все :). Поздравляю вас с созданием первой игры на чистом JavaScript.