Пишем игру змейка с помощью JavaScript + Canvas

Доброго времени суток, друзья. Сейчас я постараюсь вам показать как можно написать игру Змейка. Конечно, не самым быстрым способом и не самым маленьким в плане количества строк кода, но по-моему самым понятным для начинающих разработчиков, как я. Статья написана для людей, желающих чуть-чуть познакомиться с элементом canvas и его простыми методами для работы с 2D графикой.
image
Напишем змейку в «старом» виде, без особо красивой графики — в виде кубиков. Но это только упростит понимание разработки. Ну что же, поехали!

Подготовка


Пожалуй, стоит вообще начать с подготовки к созданию игры и написанию кода. Мы будем использовать простой редактор Sublime Text. Впрочем, это не важно. Все делаем в одном документе, чтобы было быстрее.

Первым делом, напишем сам код для встраивания canvas в документ. Напомню, что canvas поддерживается только в HTML5.

<!-- Надпись по середине появиться в случае, если у вас старый браузер. -->
<canvas id="gP">HTML5 не поддерживается</canvas>

Подготовка завершена, теперь мы можем приступать к созданию самой игры.

Начинаем


Для начала, я хотел бы вам вообще объяснить как будет работать змейка, так будет гораздо понятнее. Наша змейка — это массив. Массив элементов, элементы — это ее части, на которые она делиться. Это всего лишь квадратики, которые имеют координаты X и Y. Как вы знаете, X — горизонталь, Y — вертикаль. В обычном виде мы представляем себе координатную плоскость вот так:

image

Она абсолютно правильная, в этом нет сомнения, но на мониторе компьютера (в частности, canvas) она выглядит по-другому, вот так:

image

Это нужно знать, если вы вдруг в первый раз столкнулись с canvas. Я, когда столкнулся с этим, сначала вообще не понял где точка (0,0), благо я быстро разобрался. Надеюсь и у вас проблем не возникло.

Вернемся к элементам змейки, ее частям. Представим, что каждый элемент имеет свои координаты, но одинаковую высоту и одинаковую ширину. Это квадратики, не более. А теперь представим, что мы вот нарисовали змейку, все квадратики, они идут друг за другом, одинаковые такие. И тут мы решили, что нам нужно подвинуть змейку вправо. Как бы вы поступили?

Некоторые люди ответили бы, что нам нужно первый элемент подвинуть вправо, затем второй, затем третий и так далее. Но для меня этот вариант не является правильным, так как в случае, если вдруг змейка огромная, а компьютер слабый, мы можем заметить, что змейка иногда разрывается, что вообще не должно быть. Да и вообще, данный способ требует слишком много команд, когда можно обойтись гораздо меньшим количеством, не потеряв качество. А теперь мой способ: Мы берем последний элемент змейки, и ставим его в начало, изменяя его координаты так, чтобы он был после головы. Теперь этот элемент — голова. Всего-то! И да, эффект движения будет присутствовать, а на компьютере вообще не будет заметно, как мы спрятали хвост, а потом его поставили в начало. именно так мы и будем поступать во время создания движения змейки.

Вот тут точно начинаем


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

//Возвращает случайное число.
function rand (min, max) {k = Math.floor(Math.random() * (max - min) + min); return (Math.round( k / s) * s);}
//Функция для создания нового яблока.
function newA () {a = [rand(0, innerWidth),rand(0, innerHeight)];}
//Функция для создания тела змейки из одного элемента.
function newB () {sBody = [{x: 0,y: 0}];}

var gP = document.getElementById('gP'), //Достаем canvas.
	//Получаем "контекст" (методы для рисования в canvas).
        g = gP.getContext('2d'), 
	sBody = null, //Тело змейки, мы потом его создадим.
	d = 1, //Направление змейки 1 - dправо, 2 - вниз 3 - влево, 4 - вверх.
	a = null, //Яблоко, массив, 0 элемент - x, 1 элемент - y.
	s = 30; newB(); newA(); //Создаем змейку.

И так, вообще, нужно чуть-чуть пояснить зачем нам нужно в функции возвращения случайного числа, умножать и делить на переменную s, которая хранит в себе ни что иное, как ширину, по совместительству и высоту элементов змейки. На самом деле, это нужно, чтобы не было смещений во время движения, так как у нас ширина элемента — 30, то если мы хотим двигать ее без разрывов, то все координаты должны делиться на 30 без остатка. Именно поэтому я делю на число, округляю, а потом умножаю. Таким образом, число возвращается таким, что его можно разделить без остатка на 30.

Вы могли бы возразить, сказав, что ты мог бы просто холсту сделать ширину и высоту, кратную 30. Но на самом деле, это не лучший вариант. Так как я лично привык использовать всю ширину экрана. И в случае, если ширина = 320, то мне пришлось бы аж целых 20 пикселей забирать у пользователя, что могло бы доставить дискомфорт. Именно поэтому в нашей змейки все координаты объектов делятся на 30, чтобы не было никаких неожиданных моментов. Было бы даже правильнее вынести это как отдельную функцию, так как она достаточно часто используется в коде. Но к этому выводу я пришел поздно. (Но возможно это даже не нужно).

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

gP.width = innerWidth; //Сохраняем четкость изображения, выставив полную ширину экрана.
gP.height = innerHeight; //То же самое, но только с высотой.

Если что, то переменные innerWidth и innerHeight хранятся в глобальном пространстве имен.
Поэтому к ним можно обратиться именно так. Правда, я не знаю правильно ли так делать.


Ну что же, теперь начинаем писать код змейки.

Чтобы было движение, нам нужна анимация, мы будем использовать функцию setInterval, вторым параметром которой будет число 60. Можно чуть больше, 75 на пример, но мне нравится 60. Функция всего на всего каждые 60 мс. рисует змейку «заново». Дальнейшее написание кода — это только этот интервал.

Покажу вообще простую отрисовку нашей змейки, пока что без движения.

setInterval(function(){
	g.clearRect(0,0,gP.width,gP.height); //Очищаем старое.
	g.fillStyle = "red"; //Даем красный цвет для рисования яблока.
	g.fillRect(...a, s, s); //Рисуем яблоко на холсте 30x30 с координатами a[0] и a[1].
	g.fillStyle = "#000"; //А теперь черный цвет для змейки.
}, 60);

Чтобы проверить, что наша змейка не сталкивается сама с собой, нам нужно сделать некоторую проверку для каждого элемента, кроме последнего. Мы будем проверять, не равны ли координаты последнего элемента (головы) змейки любым из… То есть проще говоря: не произошло ли столкновение. Эта строчка кода была единой строкой, но вам сделал ее понятной. Напоминаю, что все это добавляется в функцию интервала.


sBody.forEach(function(el, i){
   
    //Проверка на то, что яблоко ушло за границы окна, мы его не можем увидеть.
    if (a[0] + s >= gP.width || a[1] + s >= gP.height) newA();

    //Проверка на столкновение.
    var last = sBody.length - 1;
    if ( el.x == sBody[last].x && el.y == sBody[last].y && i < last) { 
        sBody.splice(0,last); //Стираем тело змейки.
        sBody = [{x:0,y:0}]; //Создаем его заново.
        d = 1;  //Меняем направление на правую сторону.
    }

});

//+
// Сохраняем хвост и голову змейки.
var m = sBody[0], f = {x: m.x,y: m.y}, l = sBody[sBody.length - 1];

/*

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

    Делается это путем проверки направления змейки (изначально -  это 1, - право), 
    а затем уже изменяем координаты. Соответственно, комментарии все описывают.

*/


//Если направление вправо, то тогда сохраняем Y, но меняем X на + s.
if (d == 1)  f.x = l.x + s, f.y = Math.round(l.y / s) * s;
// Если направление вниз, то сохраняем X, но меняем Y на + s.
if (d == 2) f.y = l.y + s, f.x = Math.round(l.x / s) * s;
//Если направление влево, то сохраняем Y, но меняем X на -s.
if (d == 3) f.x = l.x - s, f.y = Math.round(l.y / s) * s;
//Если направление вверх, то сохраняем X, Но меняем Y на -s.
if (d == 4) f.y = l.y - s, f.x = Math.round(l.x / s) * s;
 

А теперь, вы наверное заметили, что во время того, как мы изменяем координаты, мы вечно что-то «сохраняем», сначала поделив, а потом округлив и умножив на число s. Это все тот же самый способ выравнивания змейки относительно яблока. Движение в данном случае строгое, простое, поэтому и есть змейка яблоко может строго по определенным правилам, которые задан в самом начале интервала. И если бы координаты головы змейки хоть на 1px сместились бы, то яблоко нельзя было бы съесть. И да, это простой вариант, поэтому все так сильно ограничено.

Ну а нам же осталось что сделать? Правильно, удалить из массива хвост (первый элемент), добавить новый элемент в самый конец и отрисовать всю змейку. Сделаем это, добавив в конец интервала вот такие строчки кода.


sBody.push(f); //Добавляем хвост после головы с новыми координатами.
sBody.splice(0,1); //Удаляем хвост.

//Отрисовываем каждый элемент змейки.
sBody.forEach(function(pob, i){
    //Если мы двигаемся вправо, то если позиция элемента по X больше, чем ширина экрана, то ее надо обнулить
    if (d == 1) if (pob.x > Math.round(gP.width / s) * s) pob.x = 0;
    //Если мы двигаемся вниз, то если позиция элемента по X больше, чем высота экрана, то ее надо обнулить.
    if (d == 2) if (pob.y > Math.round(gP.height / s) * s) pob.y = 0;
   //Если мы двигаемся влево, и позиция по X меньше нуля, то мы ставим элемент в самый конец экрана (его ширина).
    if (d == 3) if (pob.x < 0) pob.x = Math.round(gP.width / s) * s;
    //Если мы двигаемся вверх, и позиция по Y меньше нуля, то мы ставим элемент в самый низ экрана (его высоту).
    if (d == 4) if (pob.y < 0) pob.y = Math.round(gP.height / s) * s;
   
    //И тут же проверка на то, что змейка съела яблоко.
    if (pob.x == a[0] && pob.y == a[1]) newA(), sBody.unshift({x: f.x - s, y:l.y})
    
    //А теперь рисуем элемент змейки.
    g.fillRect(pob.x, pob.y, s, s);		
});

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

//setInerval(...);

onkeydown = function (e) {
	var k = e.keyCode;
	if ([38,39,40,37].indexOf(k) >= 0) e.preventDefault();
	if (k == 39 && d != 3) d = 1; //Вправо
	if (k == 40 && d != 4) d = 2; //Вниз
	if (k == 37 && d != 1) d = 3; //Влево
	if (k == 38 && d != 2) d = 4; //Вверх
};

Тут мы сделали так, что если змейка двигается вправо, то она не может изменить направление налево. Это логично, на самом деле.

Вот и все, друзья. Моя первая статья, написанная новичком для новичков. Надеюсь, все было понятно и кому-то это пригодилось. Змейку можно усовершенствовать, добавив на пример, счетчик очков, рекорды, дополнительные фишки, но это все уже дополнения, которые вы можете сделать сами. На этом все, всем удачи!

Посмотреть демонстрацию (CodePen):

Snake game.
Share post

Comments 17

    +10
    1. Где полный код?
    2. Вместо setInterval нужно использовать requestAnimationFrame
    3. Код нужно разделить на небольшие отдельные логические блоки и вынести в отдельные функции, чтобы не было лапши.
      +8
      1.1. А еще лучше — демка на CodePen.
      4. Говорящие сами за себя имена переменных предпочтительнее, чем имена из одной буквы + куча комментариев, поясняющих то, что было бы очевидно при нормальном именовании.
        –7
        Вместо setInterval нужно использовать requestAnimationFrame

        Меня всегда забавлял этот совет от людей, которые повторяют эту мантру, не понимая основного недостатка rAF и искренне считающих, что rAF — это какой-то вид оптимизации и вообще серебрянная пуля.
          +4

          Ну так и расскажи. О чем комментарий-то?

            –5
            Несмотря на то, что по ссылке сказано: «ее частота может быть снижена для вкладок невидимых.» — обычно выполнение функции rAF в фоновой вкладке полностью отключается, что допустимо для всяких жквери-анимаций, но недопустимо для более менее серьезных игр.
            Понижение до 1 выполнения в секунду — значительно более удачное решение.

            Да и какую реальную выгоду вы ожидаете от rAF в сравнении с sT?
              +4
              но недопустимо для более менее серьезных игр

              Из-за инкрементальной отрисовки? Тут этого нет.


              Да и какую реальную выгоду вы ожидаете от rAF в сравнении с sT?

              Плавность анимации и разгрузка браузера за счет плановых перерисовок.


              И вот тут еще про это написано:


              The problem with using setTmeout/setInterval for executing code that changes something on the screen is twofold.
              
              What we specify as the delay (ie: 50 milliseconds) inside these functions are often times not honoured due to changes in user system resources at the time, leading to inconsistent delay intervals between animation frames.
              
              Even worse, using setTimeout() or setInterval() to continuously make changes to the user's screen often induces "layout thrashing", the browser version of cardiac arrest where it is forced to perform unnecessary reflows of the page before the user's screen is physically able to display the changes. This is bad -very bad- due to the taxing nature of page reflows, especially on mobile devices where the problem is most apparent, with janky page loads and battery drains

              Понижение до 1 выполнения в секунду — значительно более удачное решение.

              А мне нравится, когда оно плавно от клетки к клетке переходит.

                –4
                Из-за инкрементальной отрисовки? Тут этого нет.

                В цикле игры есть не только рендер, но и логика. Если же разбивать их не отдельные запускающие функции, то будет обратная проблема (логика запускается чаще или реже, чем рендер).

                Плавность анимации и разгрузка браузера за счет плановых перерисовок.

                Увы, практика показала, что это миф.

                unnecessary reflows

                Не про canvas.

                А мне нравится, когда оно плавно от клетки к клетке переходит.

                Я про неактивную вкладку.
                  –1
                  Играть в неактивной вкладке в серьезные игры это конечно нужно еще уметь =)
                    0
                    Жаль вас расстраивать, но все онлайн игры «играют» в момент неактивности, даже если вы не отдаете приказы. И если есть необходимость, к примеру, проиграть какой-то звук на определенный евент, то остановленная логика вкладки может этому помешать.
          0
          "Больше функций! Больше классов!" — стандартная мантра людей, после всяких школ/курсов/универов/<вписать своё>… людей, которые пришли к этому не из собственного опыта.

          Как по мне все достаточно логично, поделено на отдельные блоки и понятно.
          А самое главное — работает.
          Остальное — на усмотрение автора.

          На счёт именования переменных, тоже довольно спорный вопрос.
          Исходя из опыта — да, правильно именованные переменные помогают понять код, когда ты возвращаешься к нему через время.

          Но судя по всей этой затее — это просто «проба пера» и чрезмерное усложнение, ровно как и повторное использование кода, не про этот случай.
            0
            Так же, неплохо было бы дать переменным понятные имена.
            Пример

            if (d == 1)  f.x = l.x + s, f.y = Math.round(l.y / s) * s;
            

            или

              if (d == 1) if (pob.x > Math.round(gP.width / s) * s) pob.x = 0;
            


            Что такое pob, например? И как расшифровывается gP?
            Сделать понятные имена и раскидать код по отдельным функциям, как предложено выше — и все комменты из кода можно будет убрать.
            Сейчас комменты несут в себе расшифровку кода, а не пояснение действительно сложных алгоритмов/решений/паттернов и т.д.
            +1
            sBody.splice(0,1); //Удаляем хвост.

            почему не pop / unshift (в зависимости от того, где у змеи голова)?

            ps: можно же и не перерисовывать всю змейку (или весь экран) каждый раз, достаточно очистить хвост и дорисовать голову ;-)
              0

              Спасибо за совет.

              0
              С читаемостью кода надо бы поработать

              d = 1, //Направление змейки 1 — dправо, 2 — вниз 3 — влево, 4 — вверх.

              ИМХО, куда удобней тут использовать текстовое значение «right», «down», ''left", «up», да и d ничего не говорящая буква, когда ее встретишь в коде, в отличии от «direction», например.
                0

                Согласен, код давно писал, для себя. Конечно, надо бы исправить.

                0

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


                Ну т.е. я к чему это — я подобную штуку впервые писал кажется на ДВК-2, на бейсике, и даже тогда это не было нужно.

                  0
                  На бейсике у вас скорее всего появились бы тормоза, если бы вы попытались перерисовывать картинку на каждое движение змеи.

                  PS В глубоком детстве пытался делать динамичные игры на бейсике ZX Spectrum :) И до освоения ассемблера не очень клеилось.

                Only users with full accounts can post comments. Log in, please.