🎮Тетрис на JavaScript: разбираем возможности языка через геймификацию
Чтобы провести разбор современных возможностей JavaScript, напишем собственную версию одной из самых популярных игр в мире - Тетриса.
Тетрис на JavaScript + изучение современных возможностей языка
Лучший способ узнавать новое и закреплять полученные знания – практика. Лучшая практика в программировании – создание игр. Лучшая игра в мире – Тетрис. Сегодня мы будем узнавать новое в процессе написания тетриса на javaScript.
В конце руководства у нас будет полностью функционирующая игра с уровнями сложности и системой очков. По ходу дела разберемся с важными игровыми концепциями, вроде графики и игрового цикла, а также научимся определять коллизии блоков и изучим возможности современного JavaScript (ES6):
Весь код проекта вы можете найти в github-репозитории.
Тетрис
Эта всемирно известная игра появилась в далеком 1984 году. Придумал ее русский программист Алексей Пажитнов. Правила очень просты и известны каждому. Сверху вниз падают фигурки разной формы, которые можно вращать и перемещать. Игрок должен складывать их внизу игрового поля. Если получается заполнить целый ряд, он пропадает. Игра заканчивается, когда башня из фигурок достигает верха игрового поля.
Тетрис – великолепный выбор для первого знакомства с гейм-разработкой. Он достаточно прост для программирования, но в то же время содержит все принципиальные игровые элементы. К тому же в нем максимально простая графика.
Структура проекта
Для удобства разобьем весь проект на отдельные файлы:
constants.js
– конфигурация и правила игры;board.js
- логика игрового поля:piece.js
– управление фрагментами-тетрамино;main.js
– инициализация и управление всей игрой;index.html
– веб-страница с html-кодом проекта и подключением ресурсов;styles.css
- стили для оформления игры;README.md
.
Сразу же подключим все нужное в index.html:
Создание каркаса
Игровое поле состоит из 10 колонок и 20 рядов. Эти значения будут часто использоваться, поэтому мы сделаем их константами и вынесем в отдельный файл constants.js
. В этом файле также укажем размер одного блока.
Для отрисовки графики будем использовать холст – элемент HTML5 canvas. Добавим его в html-файл с инфраструктурой будущей игры:
Теперь в главном скрипте проекта main.js
нужно найти элемент холста и получить контекст 2D для рисования:
Здесь мы используем установленные ранее константные значения.
Метод scale используется, чтобы избежать постоянного умножения всех значений на BLOCK_SIZE
и упростить код.
Оформление
Для оформления такой ретро-игры идеально подходит пиксельный стиль, поэтому мы будем использовать шрифт Press Start 2P. Подключите его в секции head
:
Теперь добавим основные стили в style.css
:
Для разметки используются системы CSS Grid и Flexbox.
Вот, что у нас получилось:
Игровое поле
Поле состоит из клеточек, у которых есть два состояния: занята и свободна. Можно было бы просто представить клетку булевым значением, но мы собираемся раскрасить каждую фигурку в свой цвет. Лучше использовать числа: пустая клетка – 0, а занятая – от 1 до 7, в зависимости от цвета.
Само поле будет представлено в виде двумерного массива (матрицы). Каждый ряд – массив клеток, а массив рядов – это, собственно, поле.
Создадим отдельный класс для представления игрового поля – Board
и разместим его в файле board.js
. Сразу же добавим пару важных методов:
Для создания пустой матрицы поля и заполнения ее нулями используются методы массивов: Array.from() и Array.fill().
Теперь создадим экземпляр класса Board
в основном файле игры.
Функция play
будет вызвана при нажатии на кнопку Play. Она очистит игровое поле с помощью метода reset
:
Для наглядного представления матрицы удобно использовать метод console.table:
Тетрамино
Каждая фигурка в тетрисе состоит из четырех блоков и называется тетрамино. Всего комбинаций семь – дадим каждой из них имя (I, J, L, O, S, T, Z) и свой цвет:
Для удобства вращения каждое тетрамино будет представлено в виде квадратной матрицы 3х3. Например, J-тетрамино выглядит так:
Для представления I-тетрамино потребуется матрица 4x4.
Заведем отдельный класс Piece
для фигурок, чтобы отслеживать их положение на доске, а также хранить цвет и форму. Чтобы фигурки могли отрисовывать себя на поле, нужно передать им контекст рисования:
Блок фигурки занимает несколько клеток на поле, но раскрашены должны быть только те, значение которых больше 0
. Для отрисовки нужно перебрать все ячейки блока в двойном цикле:
Итак, нарисуем первое тетрамино на поле:
Активная фигурка сохраняется в свойстве board.piece
для удобного доступа.
Управление с клавиатуры
Передвигать фигурки по полю (влево, вправо и вниз) можно с помощью клавиш-стрелок.
Добавим в класс Piece
метод move
, которые будет изменять текущие координаты тетрамино на поле.
Перечисления
Коды клавиш будут храниться в файле constants.js
. Для этого удобно применять специальный тип данных – перечисление (enum). В JavaScript нет встроенных перечислений, поэтому мы воспользуемся обычным объектом:
Ключевое слово const
не работает с полями объектов, а лишь запрещает переприсваивание данных в переменную KEY
. Чтобы сделать константы в объекте неизменяемыми, мы используем метод Object.freeze(). При этом важно помнить две вещи:
- Для правильной работы этого метода нужен строгий режим выполнения;
- Метод работает только на один уровень вложенности.
Вычисляемые имена свойств
Теперь нужно сопоставить коды клавиш и действия, которые следует выполнить при их нажатии.
ES6 позволяет добавлять в объекты свойства с вычисляемыми именами. Другими словами, в имени свойства можно использовать переменные и даже выражения.
Для установки такого свойства нужны квадратные скобки:
Для перемещения тетрамино мы будем стирать старое отображение и копировать его в новых координатах. Чтобы получить эти новые координаты, сначала скопируем текущие, а затем изменим нужную (x
или y
) на единицу.
Так как координаты являются примитивными значениями, мы можем использовать spread-оператор, чтобы перенести их в новый объект. В ES6 существует еще один механизм копирования: Object.assign().
В объекте moves
теперь хранятся функции вычисления новых координат для каждой клавиши. Получить их можно так:
Очень важно, что при этом не меняются текущие координаты самого тетрамино, так как нажатие клавиши не всегда будет приводить к реальному изменению положения.
Теперь добавим обработчик для события keydown:
Метод board.valid()
будет реализован в следующем разделе. Его задача – определять допустимость новых координат на игровом поле.
Обнаружение столкновений
Если бы фигурки тетриса могли проходить сквозь друг друга, а также сквозь пол и стены игрового поля, игра не имела бы смысла. Важно проверить возможные столкновения элементов перед изменением их положения.
Возможные столкновения одного тетрамино:
- с полом при движении вниз;
- со стенками игрового поля при движении вправо или влево;
- с другими тетрамино, уже размещенными на поле.
Фигурки можно будет вращать, поэтому при вращении тоже нужно учитывать возможные столкновения.
Мы уже умеем вычислять новую позицию фигурки на поле при нажатии клавиш-стрелок. Теперь нужно добавить проверку на ее допустимость. Для этого мы должны проверить все клетки, которые будет занимать тетрамино в новом положении.
Для такой проверки удобно использовать метод массива every(). Для каждой клетки в матрице тетрамино нужно определить абсолютные координаты на игровом поле, а затем проверить, свободно ли это место и не выходит ли оно за границы поля.
Пустые клетки матрицы тетрамино при этом не учитываются.
Если проверка прошла удачно, передвигаем фигурку в новое место.
Теперь мы можем добавить возможность ускоренного падения (hard drop) фигурок при нажатии на пробел. Тетрамино при этом будет падать пока не столкнется с чем-нибудь.
Вращение
Фигурки можно вращать относительно их «центра масс»:
Чтобы реализовать такую возможность, нам понадобятся базовые знания линейной алгебры. Мы должны транспонировать матрицу, а затем умножить ее на матрицу преобразования, которая изменит порядок столбцов.
На JavaScript это выглядит так:
Эту функцию можно использовать для вращения фигурок, но перед началом манипуляций с матрицей, ее нужно скопировать, чтобы не допускать мутаций. Вместо spread-оператора, который работает лишь на один уровень в глубину, мы используем трюк с сериализацией – превратим матрицу в JSON-строку, а затем распарсим ее.
Теперь при нажатии на клавишу Вверх, активная фигурка будет вращаться:
Случайный выбор фигурок
Чтобы каждый раз появлялись разные фигурки, придется реализовать рандомизацию, следуя стандарту SRS (Super Rotation System).
Добавим цвета и формы фигурок в файл constants.js:
Теперь нужно случайным образом выбрать порядковый номер тетрамино:
На этом этапе мы можем выбирать тип фигурки случайным образом при создании.
Добавим в класс Piece
метод spawn
:
Игровой цикл
Почти во всех играх есть одна главная функция, которая постоянно делает что-то, даже если игрок пассивен – это игровой цикл. Нам он тоже понадобится, чтобы фигурки постоянно генерировались и падали сверху вниз на игровом поле.
RequestAnimationFrame
Для совершения циклических действий удобно использовать метод requestAnimationFrame. Он сообщает браузеру о том, что нужно сделать, а браузер выполняет это во время следующей перерисовки экрана.
Мы создадим функцию animate
, которая будет производить все необходимые перерисовки для одного цикла анимации, а затем рекурсивно вызовет сама себя для выполнения перерисовок следующего цикла. Самый первый вызов функции animate
будет происходить внутри функции play
.
Таймер
Нам также потребуется таймер, чтобы в каждом фрейме анимации "ронять" активное тетрамино вниз. Возьмем готовый пример с MDN и немного модифицируем его.
Для начала создадим объект для хранения нужной информации:
В цикле мы будем обновлять это состояние и отрисовывать текущее отображение:
Заморозка состояния
При достижении активной фигуркой низа игрового поля, ее нужно «заморозить» в текущем положении и создать новое активное тетрамино.
Для этого добавим классу Board
метод freeze
. Он будет сохранять положение фигурки в матрице игрового поля:
Теперь при достижении фигуркой низа поля, мы увидим в консоли, что матрица самого поля изменилась:
Добавим метод для отрисовки целого поля (с уже «замороженными» тетрамино):
Обратите внимание, что теперь объекту игрового поля тоже нужен контекст рисования, не забудьте передать его:
Очистка линий
Главная задача игры – собирать из блоков целые ряды, которые должны пропадать с поля, освобождая место для новых фигурок.
Добавим в класс Board метод для проверки, не собрана ли целая линия, которую можно удалить, и удаления всех таких линий:
Его нужно вызывать каждый раз после «заморозки» активного тетрамино при достижении низа игрового поля:
Система баллов
Чтобы сделать игру еще интереснее, нужно добавить баллы за сбор целых рядов.
Чем больше рядов собрано за один цикл, тем больше будет начислено очков.
Для хранения информации создадим новый объект accountValues
.
При каждом изменении счета нужно обновлять данные на экране. Для этого мы обратимся к возможностям метапрограммирования в JavaScript – Proxy.
Прокси позволяет отслеживать обращение к свойствам объекта, например, для их чтения (get) или обновления (set) и реализовывать собственную логику:
Теперь при каждом изменении свойств объекта account
будет вызываться функция updateAccount
.
Добавим логику начисления очков в обработчик события keydown
:
и в метод очистки собранных рядов:
Уровни
Чем лучше вы играете в тетрис, тем быстрее должны падать фигурки, чтобы вам не стало скучно. Придется добавить уровни сложности в нашу игру, постепенно увеличивая частоту фреймов игрового цикла.
Информация на экране о количестве собранных линий и текущем уровне должна обновляться при каждом изменении. Для этого добавим два новых поля (lines
и levels
) в объект accountValues
.
Они будут обновляться по той же схеме, что и score
, с помощью прокси-объекта.
Напишем отдельную функцию resetGame, в которую поместим всю логику для начала новой игры:
Теперь нужно немного обновить логику начисления очков за собранные линии. С каждым уровнем очков должно быть больше.
При сборке каждых десяти рядов, уровень будет повышаться, а скорость – увеличиваться.
Завершение игры
Игра завершается, когда пирамида фигурок достигает самого верха игрового поля.
Мы должны добавить проверку внутри методаboard.drop
. Если y-координата тетрамино равна нулю, значит, ему уже некуда падать.
Теперь метод drop
возвращает false
, если игру пора останавливать, и true
– в остальных случаях.
Чтобы прервать игровой цикл, нужно отменить последний вызов requestAnimationFrame
. Также нужно показать пользователю сообщение.
Следующая фигура
Для удобства игрока мы можем добавить подсказку – какая фигурка будет следующей. Для этого используем еще один холст меньшего размера:
Получим его контекст для рисования и установим размеры:
Осталось внести изменения в метод board.drop
:
Теперь игрок знает, какое тетрамино будет следующим, и может выстраивать стратегию игры.