Игра Змейка на JavaScript
Первая версия змейки появилась в 1976 году, а последняя - сегодня.
Пока проходил курс по JS, стало немного скучновато и я решил найти какой-нибудь гайд на ютубе, чтобы попрактиковаться.
У автора этой змейки есть две версии: в первом видео он делает змейку одним JS файлом, а во-втором видео уже на модулях с ООП (Объектно ориентированное программирование). Я сделал оба варианта, но именно второй мне захотелось разобрать по частям, чтоб лучше понять что к чему, т.к. он был намного менее понятен чем первый, там много классов и сложная модульная структура.
Гитхаб автора со змейкой: https://github.com/EpicLegend/snake2d-opp
Все что будет ниже, я писал для того, чтоб просто разобрать непонятные мне части кода, но в процессе начал вносить в змейку и свои доработки. Поэтому код немного отличается от кода автора.
Мой Гитхаб с моей змейкой: https://github.com/Buninman/Snake
Буду рад на указание ошибок, даже орфографических🙂
Основные изменения моей змейки:
- Поменял дизайн игрового поля, добавил вниз блок с подсказкой;
- Сделал счетчик рекорда, он запоминает наибольший накопленный результат;
- Добавил возможность менять скорость змейки клавишами PgUp, PgDwn;
- Теперь есть режим бога, в котором змейка не может себя съесть. Кнопка G;
- Убрал возможность змейке разворачиваться на 180º и кусать себя. Больше она не умирает от случайного нажатия.
Начало
Набросал схему устройства приложения, чтоб было понятнее что куда идет в плане импорта объектов. По такому порядку и пойдем:
supportFunction.js
В модуле всего одна функция, подробнее о ней можно посмотреть в документации, она целиком оттуда.
export const getRandomInt = (min, max) => Math.floor( Math.random() * (max - min) + min ) // Math.random() возвращает псевдослучайное число // с плавающей запятой в диапазоне от 0 до менее 1 // (включая 0, но не 1) // Math.floor() возвращает наибольшее целое число, // меньшее или равное заданному числу. Округляет
Эта функция возвращает случайное число между указанными значениями. Возвращаемое значение не меньше (и, возможно, равно) min
и меньше (и не равно)max
.
getRandomInt()
вызывается в файле berry.js и имеет такой вид:
Для x: getRandomInt(0, this.canvas.element.width / this.config.sizeCell) * this.config.sizeCell Для y: getRandomInt(0, this.canvas.element.height / this.config.sizeCell) * this.config.sizeCell
Соответственно на min
передается 0
, а на max
передается:
Для x: ширинаПоля (688) / размерЯчейки (16) - это 43 Для y: высотаПоля (480) / размерЯчейки (16) - это 30
Таким образом, когда функция срабатывает, мы получаем число в диапазоне от 1 до 42 для x
и от 1 до 49 для y
.
config.js
Этот модуль претерпел самые большие изменения, вместо 8 строк кода, теперь тут 38.
Значения config.js используются в berry.js, snake.js и gameloop.js
export default class Config { constructor() { this.speedBlock = document.querySelector('#speed') this.godModeColor = document.querySelector('.game-header') this.textBlock = document.querySelector('.keys-conf') // Ищем значения в HTML коде, по названиям классов и id // Тут мы ищем индикатор скорости, цвет плашки под ним // и блок текста с инструкцией this.step = 0 this.maxStep = 6 // step и maxStep нужны чтоб пропускать игровой цикл, // их использует функция в файле gameLoop.js. // Также maxStep регулирует скорость змейки, // чем выше значение тем реже перерисовывается змейка this.sizeCell = 16 this.sizeBerry = this.sizeCell / 4 // sizeCell это размер ячейки, а sizeBerry это размер ягодки this.godMode = false // Переменная отвечает за режим бога. Изначально он выключен this.colorLight = '#A55E00' this.colorDark = '#5E3908' // Переменные с цветами змейки. Есть темный оттенок // и светлый, для головы змейки this.drawSpeed() // Метод отрисовывает значение скорости } speedLevel() { return 10 - this.maxStep // Чтоб значения скорости были понятнее я сделал // функцию которая меняет порядок чисел, теперь 1 // это самая медленная скорость, а 8 - самая быстрая } drawSpeed() { this.speedBlock.innerHTML = this.speedLevel() // innerHTML обращается к значению в HTML // и меняет на текущую скорость змейки } drawGodMode() { if (this.godMode) { // Если режим бога сейчас true то: this.colorLight = '#FA0556' this.colorDark = '#A00034' // Меняем цвета в переменных на красные. // Они также подхватываются змейкой this.textBlock.innerHTML = 'God Mode: ON' // Вместо инструкции пишем о включенном режиме this.godModeColor.style.backgroundColor = this.colorDark // style.backgroundColor получает значение цвета // параметра background в css-файле. // Тут мы меняем цвет плашки под счетом } else { // Если режим бога сейчас false: this.colorLight = '#A55E00' this.colorDark = '#5E3908' // Меняем цвета в переменных на оранжевые this.textBlock.innerHTML ='Use the Arrows and PageUp/PageDown' // Пишем стандартную инструкцию внизу поля this.godModeColor.style.backgroundColor = this.colorDark // Меняем цвет плашки } } }
🙁Не смог сделать так, чтоб ягодка меняла цвет вместе со змейкой. Сделал ягодку белой.
Свойства step
и maxStep
используются в модуле gameLoop.js, внутри функции animate()
:
animate() { requestAnimationFrame(this.animate) // Вызывает повторно функцию animate() // так образуется непрерывный цикл if (++this.config.step < this.config.maxStep) { return // Если ++step меньше maxStep, то ничего не делать. // Таким образом, надо 6 раз вызвать функцию, // чтоб змейка сделала 1 шаг } this.config.step = 0 // После движения змейки, счетчик возвращается на 0 this.update() this.draw() }
++
увеличивает значение на единицу и возвращает значение.
Оператор ++
будет прибавлять 1 к step до тех пор, пока на шестом вызове функции она не пройдет дальше. После функция обнуляет значение step.
score.js
Модуль отвечает за отрисовку очков за съеденные ягодки. Сюда же я добавил функцию drawRecord()
, которая вызывается в случае смерти змейки сохраняет наш прогресс в виде рекорда.
Значение score.js используются в game.js
export default class Score { constructor(scoreBlock, recordBlock, score = 0) { this.scoreBlock = document.querySelector(scoreBlock) this.recordBlock = document.querySelector(recordBlock) // Ищем значения в HTML коде, по id. Тут мы ищем // счетчики очков и рекорда this.score = score // Создает свойство со значением очков this.draw() // Вызывает метод объекта draw() при создании экземпляра } incScore() { this.score++ // Добавляет 1 к счету за съеденную ягодку this.draw() // Запускает метод draw() который поменяет значение счетчика } setToZero() { this.score = 0 // Обнуляет счетчик очков. Это нужно в случае сметри this.draw() // Запускает метод draw() } drawRecord() { if (this.score > this.record) { // Если значение очков больше чем текущий рекорд, то this.record = this.score // Меняем значение рекорда на текущее значение очков this.recordBlock.innerHTML = this.record // Меняем значение рекорда в HTML } } draw() { this.scoreBlock.innerHTML = this.score // innerHTML обращается к значению в HTML // и меняет значение счета в HTML } }
canvas.js
Модуль отвечает за создание и размеры игрового поля. Я менял только размер игрового поля.
Значение canvas.js используются в game.js
export default class Canvas { constructor(container) { this.element = document.createElement('canvas') // .createElement создает HTML-элемент <canvas> в HTML-файле this.context = this.element.getContext('2d') // .getContext('2d') указывает JS что мы хотим рисовать в 2д. // Нам становится доступен набор свойст и методов // таких как beginPath(), которым нарисована ягодка this.element.width = 688 this.element.height = 800 // Это высота и ширина 2д контекста, т.к. одна ячейка // у нас 16рх, то значения должны быть кратны 16рх container.appendChild(this.element) // Как я понял, .appendChild создает дочерний элемент. // Он помещает наш созданный 2д контекст (игровое поле) // в <canvas> внутри HTML-файла } }
berry.js
Модуль отвечает за все что происходит с ягодкой.
Значение berry.js используются в game.js
import Config from './config.js' import {getRandomInt} from './supportFunction.js' export default class Berry { constructor(canvas) { this.x = 0 this.y = 0 // Это координаты ягодки this.canvas = canvas // в Berry передаются параметры игрового поля, // нам нужны оттуда только высота и ширина this.config = new Config() // Передаем данные с Config, нам оттуда нужны размеры ячейки // и размер ягодки this.randomPosition() // Запускаем функцию получения рандомных кординат для ягодки } draw(context) { // Функция рисования ягодки. Сюда передается наш 2д контекст // из класса Canvas context.beginPath() // Создает новый векторый контур. Начинаем рисование context.fillStyle = '#EBEBEB' // Заливка этого контура белым цветом. Если поменять ее // на динамичную переменную this.config.colorLight, то // цвет ягодки все равно не будет меняться // Я не понимаю почему🙁 context.arc(this.x + (this.config.sizeCell / 2), // arc() создает векторную дугу. Замкнутая дуга это круг // В arc() нужно передать координаты x и y, // радиус, начальный угол и конечный угол // Берем рандомную координату x, прибавляем // к ней половину размера ячейки. this.y + (this.config.sizeCell / 2), // Тоже самое для координаты y, // Это дает нам положение центра ячейки // Центр нашей ягодки this.config.sizeBerry, 0, 2 * Math.PI) // Берем размер ягодки из Config, это радиус // 0 - это начальный угол // 2 * Math.PI - это конечный угол, такой // параметр указан в учебнике по JS context.fill() // Метод fill() создает заливку для нашей ягодки, } randomPosition() { this.x = getRandomInt(0, this.canvas.element.width / this.config.sizeCell) * this.config.sizeCell this.y = getRandomInt(0, this.canvas.element.height / this.config.sizeCell) * this.config.sizeCell // Функция получения рандомных координат, два раза // вызывает функцию генерации случайных чисел // и присваивает их x и y координатам } }
Функция getRandomInt()
расписана в модуле supportFunction.js
snake.js
Модуль за все что связано со змейкой. Я добавил функцию сохранение рекорда после смерти змейки, поменял параметры старта и изменил назначение клавиш.
Значение snake.js используются в game.js
import Config from './config.js' export default class Snake { constructor() { this.config = new Config() // Передаем данные с Config, нам нужен размер ячейки this.x = 336 this.y = 480 // Начальные координаты змейки, // я сделал чтоб она появлялась внизу посередине this.dx = 0 this.dy = -this.config.sizeCell // Начальное направление движения змейки, // по x - 0, а по y на -16рх, т.е. вверх. // Ось кординат начинается сверху слева // Ось y идет сверху вниз this.tails = [] // Пустой массив для записи данных о хвосте змейки this.maxTails = 1 // Параметр длины змейки. Я сделал по умолчанию 1, // т.е. только голова this.control() // Запускаем функцию отвечающую за упарвление змейкой } update(berry, score, canvas) { // Функция обновления змейки. Сюда передаются данные ягодки, // счета и игрового поля this.x += this.dx this.y += this.dy // Прибавляем к координатам змейки размер движения. // Змейка перемещается путем прибавления или вычитания // размера ячейки от следующих координат положения головы if (this.x < 0) { // Если координата x меньше 0 то this.x = canvas.element.width - this.config.sizeCell // Присваиваем ей значение ширины игрового поля // минус одну ячейку. // Т.е. если змейка доходит до края, то // она появится с другой стороны } else if (this.x >= canvas.element.width) { // то, при условии, что координата x больше или равна // ширине игрового поля this.x = 0 // Кордината x = 0, начало поля. // Т.е. если змейка доходит до края, то // она появится с другой стороны } if (this.y < 0) { this.y = canvas.element.height - this.config.sizeCell } else if (this.y >= canvas.element.height) { this.y = 0 // Логика с вертикальной осью такая же, но в расчет берем // высоту поля, а не ширину } this.tails.unshift({ x: this.x, y: this.y}) // Метод .unshift() добавляет элемент в начало массива // с хостом. Добавляем туда объект с координатами x и y. // Это секция хвоста if (this.tails.length > this.maxTails) { // Если длина массива с хвостом больше чем параметр длинны // хвоста, то this.tails.pop() // Удаляем последний элемент массива // Метод .pop() удаляет элемент в конце массива } this.tails.forEach((el, index) => { // Метод .forEach() перебирает все элементы массива, // все элементы змейки, и применяет к ним функцию. // В функцию передаются все элементы массива и их индексы if (el.x === berry.x && el.y === berry.y) { // Если координаты x и y в какой-то из секций змейки // совпадают с координатами ягоды, то this.maxTails++ // Добавляем 1 к параметру длины змейки score.incScore() // Вызываем метод incScore(). Это метод нашего // объекта Score, он прибавляем 1 единичку к счету berry.randomPosition() // Вызываем метод randomPosition(). Это метод нашего // объекта Berry, он переносит ягодку на новые // случайные координаты } for (let i = index + 1; i < this.tails.length; i++) { // Цикл for запускает функцию пока условие верно. // Переменная i это индекс элементов массива, прибавляем // к ней 1, чтоб игнорировать элемент 0, это голова. // Цикл будет продолжаться пока i меньше длинны массива // Каждый цикл увеличиваем i на 1 if ( el.x == this.tails[i].x && el.y == this.tails[i].y && !this.config.godMode) { // Если координаты x и y какого элемента равны // каким-то другим координатам из массива, кроме // головы, и если режим бога НЕ включен, то this.death() // Метод death() возвращает все параметры // на начальные, перезапускает игру score.drawRecord() // Метод объекта Score, записывает наш счет // в рекорд, если текущий рекорд меньше score.setToZero() // Обнуляет счетчик очков berry.randomPosition() // Вызываем метод randomPosition(). Это метод // объекта Berry, он переносим ягодку на новые // случайные координаты } } }) } draw(context) { // Функция рисования змейки. Сюда передается наш 2д контекст // из класса Canvas this.tails.forEach((el, index) => { // Метод .forEach() перебирает все элементы массива if (index == 0) { // Если индекс элемента массива равен 0, context.fillStyle = this.config.colorLight // то красим в яркий цвет. Это голова } else { context.fillStyle = this.config.colorDark // Иначе красим в темный цвет, это тело змейки } context.fillRect( el.x, el.y, this.config.sizeCell, this.config.sizeCell) // Метод fillRect() рисует прямоугольник по двум // координатам и двум величинам, это размер ячейки }) } death() { // Метод death() возвращает все параметры // на начальные, перезапускает игру this.x = 336 this.y = 480 // Ставит змейку на нужные координаты this.dx = 0 this.dy = -this.config.sizeCell // Выставляет напрвление движения this.tails = [] // Обнуляем массив хвостов this.maxTails = 1 // Делает параметр колическва хвостов равным 1 } control() { document.addEventListener("keydown", e => { // Метод addEventListener() отслеживает события // Первый аргумен указывает на тип события - кнопка нажата. // Второй агрумент указывает на "слушателя", тот раздражитель // который должен это событие вызвать и ту функцию которое // это событие произведет. В нашем случае это будут кнопки // клавиатуры if (e.code == 'ArrowUp') { // Если код слушателя равен клавише "Стрелка вверх" то if (this.dx == 0 && this.dy == this.config.sizeCell) { return // Если сейчас прибавление координат идет по // оси y, то ничего не делаем - return // Эта проверка запрещает змейке начать // двигаться в противоположную от текущей } else { this.dx = 0 this.dy = -this.config.sizeCell // Иначе начинаем отнимать координаты // от текущего положения головы змейки // по оси y. Это заставит змейку двигаться // вверх от текущих координат y } } else if (e.code == 'ArrowLeft') { if (this.dx == this.config.sizeCell && this.dy == 0) { return } else { this.dx = -this.config.sizeCell this.dy = 0 // Для движения влево все тоже самое, // но начинаем отнимать размер ячейки от // оси x, не трогая значение y } } else if (e.code == 'ArrowDown') { if (this.dx == 0 && this.dy == -this.config.sizeCell) { return } else { this.dx = 0 this.dy = this.config.sizeCell } } else if (e.code == 'ArrowRight') { if (this.dx == -this.config.sizeCell && this.dy == 0) { return } else { this.dx = this.config.sizeCell this.dy = 0 } } if (e.code == 'KeyG') { // Если код слушателя равен клавише "G", то if (this.config.godMode) { // Если параметр godMode равен true (включен) this.config.godMode = false // То выключаем его, делая false this.config.drawGodMode() // Запускаем функцию смены цветов и надписей // Интерфейс и змейка становятся оранжевыми } else { // Если параметр godMode равен false this.config.godMode = true // Включаем его, делая true this.config.drawGodMode() // Запускаем функцию смены цветов и надписей // Интерфейс и змейка становятся красными } } }) } }
Занятные детали компьютерной логики:
- Змейка никуда не движется и постоянно растет, каждый ход к ней прирастает новая голова и отваливаться одна клетка от хвоста.
gameLoop.js
Модуль отвечает за обновление игрового пространства. Сюда я добавил контроль нажатия клавиш, чтоб регулировать скорость движения змейки.
Значение gameLoop.js используются в game.js
import Config from './config.js' export default class GameLoop { constructor(update, draw) { // В конструктор передаются методы из класса Game this.update = update // update() отвечает за изменения в игре this.draw = draw // а draw() за отрисовку на экране this.config = new Config() // Передаем данные Config this.animate = this.animate.bind(this) // animate будет содержать метод animate(). // Чтоб сохранить значение this, используем // метод bind() this.animate() // Запускаем метод animate() она отвечает // за непрерывное обновление игрового поля this.control() // Запускаем метод control() он отвечает // за нажатие клавиш } animate() { // Функция отвечает за непрерывный цикл внесения изменений // в игру requestAnimationFrame(this.animate) // Вызывает повторно функцию animate() // так образуется непрерывный цикл if (++this.config.step < this.config.maxStep) { return // Если ++step меньше maxStep, то ничего не делать // таким образом, надо 6 раз вызвать функцию, // чтоб змейка сделала 1 шаг } this.config.step = 0 // После движения змейки, счетчик возвращается на 0 this.update() // Вызывается функция update(), которая обновляет // данные по змейке, ягодке и очкам this.draw() // Вызывается функция, которая запускает draw() у ягодки // и змейки, предварительно отчистив игровое поле } control() { // Функция отвечает за изменение скорости змейки. // Чем выше значение maxStep, тем медленнее ползает змейка, // я выбрал диапазон от 2 до 9 document.addEventListener('keydown', e => { // Метод addEventListener() отслеживает события // также как в snake.js if (e.code == 'PageUp') { if (this.config.maxStep > 2) { // Если maxStep больше 2, то мы можем позволить // себе уменьшить его значение this.config.maxStep-- // Отнимаем 1 от maxStep this.config.drawSpeed() // Рисуем новое значение скорости } } else if (e.code == 'PageDown') { if (this.config.maxStep < 9) { // Если maxStep меньше 9, то мы можем позволить // себе увеличить его значение this.config.maxStep++ // Прибавляем 1 к maxStep this.config.drawSpeed() // Рисуем новое значение скорости } } }) } }
game.js
import Canvas from './canvas.js' import Snake from './snake.js' import Berry from './berry.js' import Score from './score.js' import GameLoop from './gameLoop.js' class Game { constructor(container) { this.canvas = new Canvas(container) // Создается объект игрового поля, // со значением ссылки на класс в HTML-файле this.snake = new Snake() // Создается объект змейки this.berry = new Berry(this.canvas) // Объект ягодки this.score = new Score('#score', '#record', 0) new GameLoop(this.update.bind(this), this.draw.bind(this)) // Создается объект GameLoop в аргументах которого передаютя // container.update где container это значение в HTML-файле, // а update это метод объекта. // .bind(this) это метод, который передает методу update() // нужное значение this, опять же значение в HTML-файле } update() { this.snake.update(this.berry, this.score, this.canvas) // Вызываем медод объекта Snake, передавая в него данные // ягодки, счета и игрового поля } draw() { // Функция отвечает за отрисовку всего на игровом поле this.canvas.context.clearRect(0, 0, this.canvas.element.width, this.canvas.element.height) // Метод .clearRect() очищает прямоугольную область пикселей. // аргументы 0, 0, это начальные координаты, а ширина и высота // игрового поля размер отчищаемой области this.snake.draw(this.canvas.context) this.berry.draw(this.canvas.context) // Вызываем методы draw() у змейки и у ягодки, передавая // туда свойства и функции 2D-контекста из Канваса } } new Game(document.querySelector('.canvas-wrapper')) // Это старт игры, создается объект Game. // querySelector ищет значение в HTML. // У нас это пустой <div> с классом .canvas-wrapper
HTML & CSS
snake.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>My Snake</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="css/style.css"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png"> </head> <body> <div id="game"> <div class="game-header"> <div class="game-score"> <span class="score-text">Speed:</span> <span class="score-count" id="speed" >0</span> </div> <div class="game-score"> <span class="score-text">Score:</span> <span class="score-count" id="score" >0</span> </div> <div class="game-score"> <span class="score-text">Record:</span> <span class="score-count" id="record" >0</span> </div> <!-- Блоков game-score стало три, поэтому добавил уникальные id, по которым теперь ориенируется JavaScript вместо классов ---> </div> <div class="canvas-wrapper"></div> <div class="game-footer"> <div class="keys-conf"> <span>Use the WASD keys</span> </div> </div> <!-- Новый блок game-footer отвечает за текст под игровым полем. Научил JS его менять в режиме бога ---> </div> <script src="js/game.js" type="module"></script> </body> </html>
style.css
body { margin: 0px; font-family: sans-serif; } #game { background: #353336; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; } #game-canvas { display: block; border-radius: 5px; } .game-header { margin-bottom: 15px; width: 708px; height: 70px; background: #5E3908; display: flex; align-items: center; justify-content: space-between; border-radius: 10px; } .game-score { padding: 10px 15px; min-width: 160px; height: 30px; background: #121214; border-radius: 5px; display: flex; justify-content: center; align-items: center; } .score-text { font-size: 2vmin; color: #EBEBEB; } .score-count { font-size: 3vmin; padding-left: 6px; color: #EBEBEB; font-weight: bold; } .canvas-wrapper { background-color: #161618; border-radius: 5px; border: 10px solid #161618; background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg opacity='0.2'%3E%3Ccircle cx='8' cy='8' r='4' fill='%23525053'/%3E%3C/g%3E%3C/svg%3E%0A"); } .game-footer { padding-top: 1vmin; padding-bottom: 1vmin; display: flex; justify-content: space-between; } .keys-conf { width: 708px; height: 30px; display: flex; justify-content: center; align-items: center; font-size: 2vmin; color: #797979; font-weight: regular; }
Основные изменения коснулись .game-header
и .game-score
, добавились классы .game-footer
и .keys-conf
.
Уверен как-то можно заменить уродскую url
с svg-картинкой на нарисованную через css, если кто знает, напишите.
Наверх