How To Code The Snake Game In Pure JavaScript
В этой статье мы разработаем классическую игру змейка на чистом JavaScript
Stage 1 - Preparing a clear template:
Начнем разработку игры с подготовки нашего макета:
- Скачайте все ассеты игры от сюда () и поместите их в папку 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>
:heart: remark-emoji
Ма подключаем наш главный 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,
);
}
});
});
}
Если вы все сделали правильно - вы должны увидеть яблоко на самой верхней левой ячейке.
