Изучение современного JavaScript с помощью тетриса

Сегодня я отправлю вас в путешествие по разработке игр с классической игрой Тетрис. Мы собираемся коснуться таких понятий, как графика, игровые циклы и обнаружение столкновений. В итоге у нас есть полноценная игра с точками и уровнями. Часть пути - использование концепций современного JavaScript, то есть функций, представленных в ECMAScript 2015 (ES6), таких как:

Надеюсь, вы подберете что-то новое, что сможете добавить в свой арсенал трюков с JavaScript!

Если вы создаете проект и получаете ошибку из фрагментов кода, то проверьте код в репозитории на GitHub. Пожалуйста, отправьте мне сообщение, если вы обнаружите, что что-то не работает. Дальнейшие разработки игры войдут в главную ветку, а ветка блога останется без изменений.

Готовая игра выглядит так:

Переводы:

  • "Корейский язык"

Тетрис

Тетрис был создан в 1984 году Алексеем Пажитновым. Игра требует, чтобы игроки вращали и перемещали падающие части тетриса. Игроки очищают линии, заполняя горизонтальные ряды блоков без пустых ячеек. Но, если фигуры достигают вершины, игра окончена!

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

Обновление: если этой статьи недостаточно и вы хотите получить более подробную и интерактивную информацию, я создал целый курс по этой теме:



Структура проекта

Хорошо разделить код в проекте, даже если он не такой уж большой. Код JavaScript находится в четырех разных файлах:

  • constants.js - это то место, где мы помещаем конфигурации и правила игры.
  • board.js предназначен для логики платы.
  • piece.js - для фигурной логики.
  • main.js содержит код для инициализации игры и общей игровой логики.
  • index.html порядок скриптов, которые мы добавляем в конце, очень важен.
  • styles.css здесь собраны все стили для украшения.
  • README.md информационный файл разметки, который является первой страницей в репозитории.

Размер и стиль

Игровое поле состоит из 10 столбцов и 20 рядов. Мы часто используем эти значения для циклического просмотра платы, чтобы мы могли добавить их к constants.js вместе с размером блоков:

Я предпочитаю использовать элемент холст для графики.

Мы можем получить элемент холста и его 2-мерный контекст в main.js и использовать константы для установки размера:

Используя масштаб, мы всегда можем указать размер блоков как один (1) вместо того, чтобы вычислять везде с BLOCK_SIZE, что упрощает наш код.

Укладка

Приятно ощущать атмосферу 80-х в нашей игре. Press Start 2P - это растровый шрифт, основанный на дизайне шрифтов из аркадных игр Namco 1980-х годов. Мы можем сделать ссылку на него в <head> и добавить в наши стили:

Первый раздел в styles.css предназначен для шрифта в аркадном стиле. Обратите внимание на использование CSS Grid и Flexbox для макета:

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

Доска

Доска в Тетрисе состоит из ячеек, которые либо заняты, либо нет. Моей первой мыслью было представить ячейку с логическими значениями. Но мы можем добиться большего, используя числа. Мы можем представить пустую ячейку цифрой 0, а цвета - цифрами 1–7.

Следующая концепция представляет собой строки и столбцы игрового поля. Мы можем использовать массив чисел для представления строки. А доска представляет собой массив строк. Другими словами, двумерный (2D) массив или то, что мы называем матрицей.

Доска - хороший кандидат на class. Вероятно, мы не хотим создавать new Board при запуске новой игры. Если вы не хотите больше узнать о курсах, я написал о них небольшую статью:

Давайте создадим функцию в board.js, которая возвращает пустую доску со всеми ячейками, установленными в ноль. Здесь пригодится метод fill ():

Мы можем вызвать эту функцию в main.js, когда нажмем кнопку воспроизведения:

Используя console.table, мы видим представление доски в числах:

Координаты X и Y представляют ячейки доски. Теперь, когда у нас есть доска, давайте посмотрим на движущиеся части.

Тетроминос

Фишка в Тетрисе - это фигура, состоящая из четырех блоков, которые движутся как единое целое. Их часто называют тетромино, и они бывают семи разных узоров и цветов. Имена I, J, L, O, S, T и Z взяты из-за сходства в их форме.

Мы представляем J-тетромино в виде матрицы, в которой число два представляет цветные клетки. Мы добавляем ряд нулей, чтобы центр вращался вокруг:

[2, 0, 0],
[2, 2, 2],
[0, 0, 0];

Тетромино нерестятся горизонтально, при этом J, L и T. нерестятся сначала плоской стороной.

Мы хотим, чтобы класс Piece знал свое положение на доске, какой цвет он имеет и свою форму. Поэтому, чтобы рисовать на доске, ему нужна ссылка на контекст холста.

Для начала, мы можем жестко запрограммировать значения нашей части в constructor класса Piece:

Чтобы нарисовать тетромино на доске, мы перебираем все ячейки фигуры. Если значение в ячейке больше нуля, мы раскрашиваем этот блок.

Доска отслеживает тетромино на доске, поэтому мы можем создавать и раскрашивать его, нажимая кнопку воспроизведения:

Появляется синий J-тетромино!

Теперь давайте сделаем волшебство с помощью клавиатуры.

Ввод с клавиатуры

Нам нужно связать события клавиатуры, чтобы переместить фигуру на доске. Функция move изменяет переменную x или y текущей фигуры, чтобы изменить ее положение на доске.

move(p) {
  this.x = p.x;
  this.y = p.y;
}

Перечисления

Затем мы сопоставляем ключи с кодами клавиш в constants.js. Для этого было бы неплохо иметь enum.

Enum (перечисление) - это специальный тип, используемый для определения коллекций констант.

В JavaScript нет встроенных перечислений, поэтому давайте создадим их, создав объект со значениями:

Константа может вводить в заблуждение при работе с объектами и массивами и фактически не делает их неизменяемыми. Для этого мы можем использовать Object.freeze (). Вот пара подводных камней:

  • Чтобы это работало правильно, нам нужно использовать строгий режим.
  • Это работает только на один уровень ниже. Другими словами, если у нас есть массив или объект внутри нашего объекта, это не замораживает их.

Объектные литералы

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

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

Скобки нужны нам для получения имен вычисленных свойств, чтобы мы могли использовать наши константы. Это упрощенный пример того, как это работает:

const X = 'x';
const a = { [X]: 5 };
console.log(a.x); // 5

Мы хотим отправить текущее тетромино и вернуть его копию вместе с изменением координат. Для этого мы можем использовать оператор распространения, чтобы получить неглубокую копию, а затем изменить координаты на желаемую позицию.

В JavaScript мы можем использовать поверхностное копирование для копирования примитивных типов данных, таких как числа и строки. В нашем случае координаты - числа. ES6 предлагает два механизма неглубокого копирования: Object.assign () и оператор распространения.

Другими словами, в этом фрагменте кода много чего происходит:

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

const p = this.moves[event.key](this.piece);

Затем мы добавляем прослушиватель событий, который прослушивает события нажатия клавиш:

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

У нас движение! Однако призрачные части, проходящие сквозь стены, - это не то, что нам нужно.

Обнаружение столкновений

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

Столкновение происходит, когда тетромино:

  • падает на пол
  • перемещается влево или вправо в стену
  • попадает в блок на доске
  • вращается, и новое вращение ударяется о стену или блок

Мы уже определили возможное новое положение фигуры. Теперь мы можем добавить проверку, действительна ли эта позиция, прежде чем мы перейдем к ней. Чтобы проверить наличие столкновений, мы перебираем все промежутки в сетке, которые тетромино займет в своем потенциально новом положении.

Лучше всего для этого подходит метод массива every (). С его помощью мы можем проверить, все ли элементы в массиве проходят тесты, которые мы предоставляем. Мы вычисляем координаты каждого блока детали и проверяем правильность его положения:

Используя этот метод перед переездом, мы убеждаемся, что никуда не переезжаем:

if (this.valid(p)) {
  this.piece.move(p);
}

Попробуем снова выйти за пределы сетки.

Больше никаких ореолов!

Теперь, когда пол останавливает тетромино, мы можем добавить еще одно движение, называемое жестким падением. При нажатии на пробел тетромино падает до тех пор, пока он с чем-то не столкнется. Это называется жестким сбросом. Нам также нужно добавить новое сопоставление клавиш и переместить:

Что дальше?

Вращение

Теперь мы можем двигаться, но было бы совсем не весело, если бы мы не могли вращать этот кусок. Нам нужно повернуть тетромино вокруг их центра.

Я давно не изучал линейную алгебру в школе. Но вращение по часовой стрелке происходит примерно так:

Два отражения могут привести к повороту на 90 градусов на угол 45 градусов, поэтому вы можете транспонировать матрицу, а затем умножить ее на матрицу перестановки, которая меняет порядок столбцов на обратный.

И в JavaScript:

Мы можем добавить функцию, которая вращает фигуру. Ранее мы использовали оператор распространения для клонирования координат. В этом случае мы работаем с многоуровневым массивом, но оператор распространения копирует только на один уровень в глубину. Остальное скопировано по ссылке.

Вместо этого я использую JSON.parse и JSON.stringify. Метод stringify() преобразует матрицу в строку JSON. Метод parse() анализирует строку JSON, снова создавая нашу матрицу для клона.

Затем мы добавляем новое состояние для ArrowUp в board.js.

[KEY.UP]: (p) => this.rotate(p)

Теперь вращаемся!

Рандомизировать Тетромино

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

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

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

randomizeTetrominoType(noOfTypes) {
  return Math.floor(Math.random() * noOfTypes);
}

С помощью этого метода мы можем получить случайный тип тетромино при создании, а затем установить цвет и форму из него:

const typeId = this.randomizeTetrominoType(COLORS.length);
this.shape = SHAPES[typeId];
this.color = COLORS[typeId];

Если мы нажмем кнопку воспроизведения, на странице отобразятся элементы разной формы и цвета.

Игровой цикл

Почти во всех играх есть одна основная функция, которая поддерживает работу игры, даже когда пользователь ничего не делает. Этот цикл выполнения одной и той же основной функции снова и снова называется игровым циклом. В нашей игре нам нужен игровой цикл, который перемещает тетромино вниз по экрану.

RequestAnimationFrame

Чтобы создать наш игровой цикл, мы можем использовать requestAnimationFrame. Он сообщает браузеру, что мы хотим анимировать, и он должен вызвать функцию для обновления анимации перед следующей перерисовкой. Другими словами, мы говорим браузеру: В следующий раз, когда вы рисуете на экране, запустите эту функцию, потому что я тоже хочу что-то нарисовать.

«Анимация - это не искусство движущихся рисунков, а искусство нарисованных движений». - Норман Макларен

Способ анимации с window.requestAnimationFrame() - это создать функцию, которая рисует кадр, а затем перепланировывает себя. Если мы используем его внутри класса (в нашем случае это не так), нам нужно привязать вызов к this, или он имеет объект окна в качестве контекста. Поскольку он не содержит функции анимации, мы получаем ошибку.

animate() {
  this.piece.draw();
  requestAnimationFrame(this.animate.bind(this));
}

Мы можем удалить все наши предыдущие вызовы draw() и вместо этого вызвать animate() из функции play(), чтобы запустить анимацию. Если мы попробуем нашу игру, она все равно будет работать, как раньше.

Таймер

Далее нам понадобится таймер. Каждый раз мы отбрасываем тетромино. На странице MDN есть пример, который мы можем изменить в соответствии с нашими потребностями.

Начнем с создания объекта с необходимой нам информацией:

time = { start: 0, elapsed: 0, level: 1000 };

В игровом цикле мы обновляем состояние игры в зависимости от временного интервала, а затем рисуем результат.

У нас есть анимация!

Затем давайте посмотрим, что происходит, когда мы достигаем дна.

Заморозить

Когда мы больше не можем двигаться вниз, мы замораживаем кусок и создаем новый. Начнем с определения freeze(). Эта функция объединяет блоки тетромино с платой:

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

Давайте добавим функцию, которая рисует доску:

Теперь функция рисования выглядит так:

draw() {
  this.piece.draw();
  this.drawBoard();
}

Если мы запустим игру, мы увидим, что фигуры появляются.

Теперь, когда мы замораживаем части, нам нужно добавить новое обнаружение столкновений. На этот раз мы должны убедиться, что не столкнемся с застывшими тетромино на доске. Мы можем сделать это, проверив, что ячейка равна нулю. Добавьте это в метод valid и отправьте в доску в качестве аргумента:

board[p.y + y][p.x + x] === 0;

Теперь, когда мы добавляем фигуры на доску, она быстро становится переполненной. Мы должны что-то с этим делать.

Линия чистая

Чтобы продержаться дольше, нам нужно собрать тетромино в ряды блоков, которые охватывают весь ряд, в результате чего линия станет чистой. Когда вы это сделаете, ряд исчезнет, ​​в результате чего те, что над ним, осядут.

Обнаружить сформированные строки так же просто, как проверить, есть ли в них нули:

Мы можем добавить вызов этой clearLines() функции после freeze() вызова. Мы можем попробовать поиграть и, надеюсь, увидим, как очищаются строки.

Счет

Чтобы получить больше удовольствия, нам нужно вести счет. Из руководства по тетрису мы получаем следующие значения:

Чтобы отслеживать ход игры, мы добавляем объект accountValues со счетом и линиями. Когда любое из этих значений изменяется, мы хотим изменить его на экране. Мы добавляем универсальную функцию, которая получает элемент из HTML и изменяет его textContext на предоставленное значение.

Чтобы действовать в соответствии с изменениями объекта учетной записи, мы можем создать объект Прокси и запустить код для обновления экрана в методе установки. Если вы не хотите изучать основы Proxy, у меня есть небольшая статья об этом, которую вы можете прочитать:

Мы отправляем объект accountValues на прокси, потому что это объект, для которого мы хотим иметь настраиваемое поведение:

Теперь каждый раз, когда мы вызываем свойства прокси account, мы вызываем updateAccount() и обновляем DOM. Давайте добавим баллы за мягкое и жесткое падение в нашем обработчике событий:

Теперь о точках линии. В зависимости от количества линий мы получаем определенные точки:

Чтобы это сработало, нам нужно добавить немного логики, чтобы подсчитать, сколько строк мы очищаем:

Если мы попробуем сыграть сейчас, то увидим, что набираем больше очков. Что нам нужно иметь в виду, так это то, что всякий раз, когда мы хотим, чтобы что-то отображалось на экране, нам нужно пройти через прокси, а не напрямую к объекту учетной записи.

Уровни

Когда мы становимся лучше в тетрисе, скорость, на которой мы начинаем, становится слишком легкой. А слишком просто - значит скучно. Значит, нам нужно повысить уровень сложности. Мы делаем это, уменьшая интервальную скорость в нашем игровом цикле.

Мы также можем показать игроку, на каком уровне он сейчас находится. Логика отслеживания и отображения уровней и линий такая же, как и для точек. Мы инициализируем для них значение, и когда мы начинаем новую игру, мы должны их сбросить.

Мы можем добавить его в объект учетной записи:

let accountValues = {
  score: 0,
  lines: 0,
  level: 0
}

Инициализация игры может происходить с помощью функции, которую мы вызываем из play():

function resetGame() {
  account.score = 0;
  account.lines = 0;
  account.level = 0;
  board = this.getEmptyBoard();
}

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

(account.level + 1) * lineClearPoints;

Следующий уровень достигается, когда линии очищаются в соответствии с настройками. Также нам нужно обновить скорость уровня.

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

Игра закончена

Если вы немного поиграете, то заметите, что тетромино не перестают падать. Нам нужно знать, когда закончить игру.

После того, как мы выпали, мы можем проверить, находимся ли мы все еще в строке 0, и в этом случае мы останавливаем игру, выйдя из функции игрового цикла:

if (this.piece.y === 0) {
  this.gameOver();
  return;
}

Перед выходом мы отменяем ранее запланированный запрос кадра анимации с помощью cancelAnimationFrame. И мы показываем сообщение пользователю.

Следующее тетромино

Давайте добавим еще одно тетромино. Мы можем добавить для этого еще один холст:

<canvas id="next" class=”next”></canvas>

Затем мы делаем то же, что и для нашего первого холста:

const canvasNext = document.getElementById('next');
const ctxNext = canvasNext.getContext('2d');
// Size canvas for four blocks.
ctxNext.canvas.width = 4 * BLOCK_SIZE;
ctxNext.canvas.height = 4 * BLOCK_SIZE;
ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);

Мы должны немного изменить логику функции drop. Вместо создания новой части мы устанавливаем ее на следующую и вместо этого создаем новую следующую часть:

this.piece = this.next;
this.next = new Piece(this.ctx);
this.next.drawNext(this.ctxNext);

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

Заключение

Сегодня мы узнали об основах разработки игр и о том, как использовать Canvas для графики. Я также хотел, чтобы этот проект стал увлекательным способом изучения современного JavaScript. Надеюсь, вам понравилась статья и вы узнали что-то новое для своего инструментария JavaScript.

И теперь, когда мы сделали первые шаги в разработке игр, какой игрой мы будем делать дальше?

Узнайте, как добавлять высокие баллы:

Спасибо, Тим Дешрайвер, за то, что он руководил моим путешествием по тетрису.



Ресурсы