Как сделать тетрис в блокноте
Тетрис на JavaScript
Отменяйте все дела, переносите встречи. Сегодня мы делаем тетрис, в который можно играть в браузере и на который можно потратить весь день.
В чём идея
Правила игры все знают: сверху в двумерный игровой стакан падают фигуры разной формы, составленные из модульных блоков. Внизу блоки соединяются. Если собрать целую горизонтальную линию из блоков, она исчезает, все остальные блоки сдвигаются на ряд ниже.
Наша задача — как можно дольше продержаться, чтобы экран не заполнился и было место, куда падать новым фигурам.
Если вдруг не знаете, как это работает, вот фрагмент с чемпионата мира по тетрису:
Код не мой
Код, который мы разбираем в этом проекте, написал американский разработчик Стивен Ламберт:
В этой статье мы объясним, как этот код работает.
Неожиданная сложность
Самое главное при программировании такой игры — это как-то хранить содержимое игрового экрана и учитывать движение фигур.
Если бы мы писали эту игру на Unreal Engine или Unity, первым интуитивным решением было бы сделать для блоков какую-то сущность типа объекта. У него были бы свойства — например, конфигурация. Может быть, мы бы захотели потом сделать взрывающиеся объекты или объекты с заморозкой, объекты с повышенной скоростью, отравленные объекты или что-то ещё в таком духе.
Но есть нюанс: смысл объекта в том, что он неделимый. А в «Тетрисе» все объекты запросто делятся, когда мы «закрываем линию». У какой-нибудь Т-образной фигуры может запросто пропасть хвостик, а у Z-образной фигуры — нижняя перекладина.
Получается, что фигура в тетрисе выглядит как объект, иногда ведёт себя как объект, но не обладает свойствами объекта. Поэтому объектный подход нам здесь не подходит.
Решение — представить игровое поле в виде двумерного массива нулей и единиц. Ноль означает, что клетка свободна, а единица — что занята какой-то частью фигуры. Хранить и обрабатывать двумерный массив довольно просто, поэтому решение кажется логичным.
Сами фигуры тоже представим в виде двумерного массива из нолей и единиц, но особым образом — в виде квадрата, где единицы отвечают за части фигуры, а ноли — за пустое место:
Если вместо квадрата просто взять фактические размеры фигуры и загнать их в массив, то при вращении они не влезут в исходный массив. А внутри квадрата их можно вращать как угодно — размер массива от этого не изменится:
Получается, что если мы добавим в общий массив с игровым цветом параметр, который отвечает за цвет, то можем рисовать каждую фигуру своим цветом. Так и сделаем.
Подготовка страницы
Игра будет работать на HTML-странице с помощью элемента Canvas — это холст, на котором мы можем рисовать произвольные фигуры через JavaScript.
Возьмём пустую страницу и сразу нарисуем на ней игровое поле. Сразу сделаем чёрный фон, игровое поле поставим по центру, а его рамки сделаем белыми:
Всё остальное сделаем скриптом. Добавим тэг сразу после того, как нарисовали холст, и начнём писать содержимое скрипта.
Заводим переменные и константы
Пока что всё просто:
Генерируем выпадающие фигуры
Первое, что нам понадобится для этого, — функция, которая выдаёт случайное число в заданном диапазоне. По этому числу мы будем выбирать фигуры.
Теперь мы можем создать последовательность из выпадающих фигур. Логика будет такая:
Последний этап в этом блоке — получить из игровой последовательности, которую мы только что сделали, следующую фигуру, которая у нас появится. Мы должны знать, что это за фигура; как она рисуется; откуда она начинает движение. Обратите внимание: на выходе мы получаем не только двумерный массив с фигурой, а ещё и название и её координаты. Название нам нужно для того, чтобы знать, каким цветом рисовать фигуру.
Движение, вращение и установка фигуры на место
В тетрисе мы можем вращать каждую фигуру на 90 градусов по часовой стрелке сколько угодно раз. А так как у нас фигура — это двумерный массив из чисел, то быстро найдём в интернете готовый код для поворота числовой матрицы:
После каждого поворота и при каждой смене позиции нам нужно проверить, а может ли в принципе фигура так двигаться? Если движению или вращению мешают стенки поля или другие фигуры, то нужно сообщить программе, что такое движение делать нельзя. Дальше мы будем делать эту проверку перед тем, как что-то отрисовывать на экране.
Если проверка не прошла, то мы не делаем последнее движение, и фигура просто продолжает падать вниз. Если ей некуда падать и она упёрлась в другие, то нам нужно зафиксировать это в игровом поле. Это значит, что мы записываем в массив, который отвечает за поле, нашу матрицу фигуры, пропуская ноли и записывая только единицы.
Как только фигура встала, нам нужно проверить, получился целый ряд или нет. Если получился — сдвигаем на один ряд вниз всё, что сверху. Такую проверку делаем каждый раз при установке фигуры и начинаем с нижнего ряда, поднимаясь наверх.
Что будет, когда мы проиграем
Когда фигура при окончательной установке вылезает за границы игрового поля, это значит, что мы проиграли. За это у нас отвечает флаг gameOver, и его задача — остановить анимацию игры.
Чтобы было понятно, что игра закончена, выведем надпись GAME OVER! прямо поверх игрового поля:
Обрабатываем нажатия на клавиши
Всё как в обычном тетрисе: стрелки влево и вправо двигают фигуру, стрелка вверх поворачивает её на 90 градусов, а стрелка вниз ускоряет падение.
Единственное, о чём нужно не забыть — после каждого нажатия вызвать проверку, можно ли так двигать фигуру или нет.
Запускаем движения и анимацию
Смысл главного цикла игры такой:
Так как кадры меняются быстро, мы не заметим постоянного очищения и отрисовки. Нам будет казаться, что фигура просто движется вниз и реагирует на наши действия.
Последнее, что нам осталось сделать, — запустить игру:
// старт игры rAF = requestAnimationFrame(loop);
Готовый результат можно посмотреть на странице с игрой.
[Перевод] Как я сделал игру для Блокнота
Пока читал про необычные решения от инди-разработчиков, наткнулся на золото. Вот вам статья про игру в текстовом редакторе. Арт, анимация, сюжет — все как положено.
Я создал игру And yet it hurt (вероятно, автор хотел сказать it hurts, но мог использовать неправильный вариант намеренно, — прим.).
Все началось в 2017 году с вопроса: «Реально ли сделать игру в Блокноте?» Тогда я только усмехнулся. Прошло три года. Обдумав, как все будет работать, и убедившись, что это реально, я решил сделать эту игру.
Обычно вы жмете на кнопку, и в игре что-то происходит. Жмете А, и Марио прыгает. Все завязано на получении информации и отклике. Игра получает входные данные и выводит свои.
В игре для Блокнота на входе будут изменения, которые вносит пользователь в файл, а на выходе изменения, которые вносит в файл сама игра. Для этого приложение отслеживает время последнего сохранения файла. Если оно изменилось, игра считывает содержимое файла и вносит в него новые данные.
Возникает проблема: Блокнот от Microsoft не проверяет, был ли файл изменен. Пришлось бы сохранять файл, закрывать и открывать его заново. Создать такую игру возможно, но звучит не очень весело. Пришлось искать альтернативу.
Могу понять ваше разочарование из-за того, что игра в итоге сделана не в самом обычном Блокноте. Мой тайтл можно запустить в нем — просто процесс немного замороченный. Я решил пожертвовать крутостью проекта, чтобы сделать игру более приятной.
Альтернатива
Пришлось искать другой текстовый редактор. Единственным требованием было — автоматическое обновление файла. Хотя чуть позже вы увидите, что я использовал еще одну фичу.
Сначала на ум пришли Notepad++ и Sublime Text. Но они совсем не похожи на Блокнот внешне, очарование проекта развеялось бы окончательно. Плюс, они спрашивают игрока, хотел бы он обновить файл. Это куда лучше, чем закрывать и открывать файл, но все равно отвлекает от геймплея. Я хотел, чтобы файл обновлялся автоматически. Тогда мне на глаза попался Notepad2. Он был почти идеален.
Редактор можно настроить, чтобы он был похож на MS Блокнот, а главное — он проверяет изменения, внесенные в файл. Но также как Notepad++ и Sublime Text, Notepad2 спрашивает игрока, нужно ли изменить файл. К счастью, у редактора открытый код, и я мог отполировать его до совершенства.
Notepad2 написан на C. Я немного знаком с этим языком, пусть меня и нельзя назвать экспертом. Опытный программист Javascript сможет прочитать и уловить общую суть кода, но понять исходный код Notepad2, чтобы внести необходимые изменения, оказалось не так просто.
Для начала я решил поискать текст из диалогового окна: «Файл был изменен внешней программой. Перезагрузить файл?». Это значение переменной, которая используется в качестве аргумента в функции диалогового окна. И я ее нашел.
Этот код проверяет, не изменилось ли содержимое файла. Если оно изменилось, открывается окно, и программа проверяет, выбрал ли пользователь ответ «Да». Мне нужно было лишь заменить кусок
на TRUE, и программа начала автоматически обновлять файл. Таким образом, я создал рендер на базе ASCII. Осталось создать подходящий движок.
Отрисовка
Загрузка арта — это просто чтение файла.
Дом используется в качестве фона, поэтому я начал с прорисовки этого изображения на «экране». Экран в данном случае — это home.txt.
Я хотел, чтобы с птичкой можно было работать в таком ключе:
х — номер столбца, y — номер строки. Поэтому разбил экран и птицу на списки строк.
С птицей сделал то же самое. Теперь код, описывающий птицу, должен был перекрывать код про дом. Вот, что мне было нужно:
В коде это выглядит так:
Наверное, вы заметили, что птица представляет собой прямоугольник — в ее арте используются пробелы. Чтобы исправить ситуацию, я посчитал количество пробелов в начале каждой строки и добавил это число к координатам, чтобы отрисовывался только арт.
Стало намного лучше:
Анимация
Я начал добавлять больше фишек, например, анимацию:
Все кадры расположены в одном файле и разделены тегом <
Также я реализовал вывод печатаемого текста и отобразил отдельно экран, инвентарь и окно для ввода решения. Оставалась одна проблема. Как игра узнает, что был открыт файл? Это и есть вторая фича, о которой я говорил ранее.
В исходном коде Notepad2 я прописал, что файл должен сохраняться сразу после открытия. Затем игра проверяет, не изменилось ли время последнего сохранения. Так она узнает, что файл был открыт, и может его менять.
В итоге я получил фреймворк, в котором можно работать. Самое время создавать игру.
За девять дней разработки (судя по дате создания gif-файлов) я сделал это:
Если вы запускали игру, то знаете, что в ней нет печатаемого текста и анимации. На то было несколько причин:
Программа по умолчанию
Может, возвращать исходные настройки при закрытии игры? Это возможно, но возникнет проблема, если игра вылетит или неожиданно закроется.
Программу по умолчанию можно назначить только от имени администратора. Если вы открываете игру под другой учетной записью, будут использоваться txt-файлы. Если вы открываете файл в обычном Блокноте, игра сообщит, что нужно перетащить файл в открытое окно Блокнота. Либо запустить ее от имени администратора, чтобы она открылась по дабл-клику.
Мотивация
На самом деле всё было сделано три года назад. Что я делал все остальное время? Классический пример отсутствия мотивации.
Но я все время держал его в голове. Я отладил целый фреймворк, который позволял создать игру в Блокноте, а проект не двигался с мертвой точки. Нужно было доделать его. В 2019 году я не завершил почти ни одного проекта. Разочарование подтолкнуло меня к решению: закончить незаконченное в 2020-м.
И вот она. Я сократил сюжет, дал себе месяц на все (получилось на неделю дольше) и бросился в бой. Еще подал заявку на A MAZE. Awards, соответственно, дедлайн был назначен на 2 февраля. Так появилась мотивация.
Заключение
Я рад, что доделал игру. Удивительно, сколько времени проект просто собирал цифровую пыль, а в итоге хватило месяца. Игру не стоило делать настолько объемной, как я хотел сначала — такой нестандартный проект должен лишь показывать особенности, которые можно в нем реализовать.
Что дальше? Игра в Paint? Игра в Калькуляторе? Вряд ли я их сделаю. Но мне нравится думать об играх, которые используют нетрадиционные платформы.
Как написать свой Тетрис на Java за полчаса
В предыдущих статьях этой серии мы уже успели написать сапёра, змейку и десктопный клон игры 2048. Попробуем теперь написать свой Тетрис.
Нам, как обычно, понадобятся:
Прежде чем задавать вопрос в комментариях, не забудьте заглянуть в предыдущие статьи, возможно там на него уже давался ответ. Исходный код готового проекта традиционно можно найти на GitHub.
С чего начать?
Получение данных от пользователя
Код, честно говоря, достаточно капитанский:
Интерфейсы для клавиатурного и графического модулей
Так как многим не нравится, что я пишу эти модули на LWJGL, я решил в статье уделить время только интерфейсам этих классов. Каждый может написать их с помощью той GUI-библиотеки, которая ему нравится (или вообще сделать консольный вариант). Я же по старинке реализовал их на LWJGL, код можно посмотреть здесь в папках graphics/lwjglmodule и keyboard/lwjglmodule.
Интерфейсы же, после добавления в них всех упомянутых выше методов, будут выглядеть следующим образом:
Отлично, мы получили от пользователя данные. Что дальше?
А дальше мы должны эти данные обработать и что-то сделать с игровым полем. Если пользователь сказал сдвинуть фигуру куда-то, то передаём полю, что нужно сдвинуть фигуру в таком-то направлении. Если пользователь сказал, что нужно фигуру повернуть, поворачиваем, и так далее. Кроме этого нельзя забывать, что 1 раз в FRAMES_PER_MOVE (вы же открывали файл с константами?) итераций нам необходимо сдвигать падающую фигурку вниз.
Сюда же добавим проверку на переполнение поля (в Тетрисе игра завершается, когда фигурам некуда падать):
Так, а теперь мы напишем класс для того магического gameField, в который мы всё это передаём, да?
А инициализировать мы их будем так:
А вот теперь мы переходим к классу, который отвечает за хранение информации об игровом поле и её обновление.
Класс GameField
Этот класс должен, во-первых, хранить информацию о поле и о падающей фигуре, а во-вторых, содержать методы для их обновления, и получения о них информации – кроме тех, которые мы уже использовали, необходимо написать метод, возвращающий цвет ячейки по координатам, чтобы графический модуль мог отрисовать поле.
Хранить информацию о поле…
…и о падающей фигуре
TpReadableColor — простой enum, содержащий элементы с говорящими названиями (RED, ORANGE и т.п.) и метод, позволяющий получить случайным образом один из этих элементов. Ничего особенного в нём нет, код можно посмотреть тут.
Это все поля, которые нам понадобятся. Как известно, поля любят быть инициализированными.
Сделать это следует в конструкторе.
Конструктор и инициализация полей
А что это там за spawnNewFigure()? Почему инициализация фигуры вынесена в отдельный метод?
На этом с хранением данных мы закончили. Переходим к методам, которые отдают информацию о поле другим классам.
Методы, передающие информацию об игровом поле
Таких метода всего два. Первый возвращает цвет ячейки (для графического модуля):
А второй сообщает, переполнено ли поле (как это происходит, мы разобрали выше):
Методы, обновляющие фигуру и игровое поле
Сдвиг фигуры
Что мы сделали в этом методе? Мы запросили у фигуры ячейки, которые бы она заняла в случае сдвига. А затем для каждой из этих ячеек мы проверяем, не выходит ли она за пределы поля, и не находится ли по её координатам в сетке статичный блок. Если хоть одна ячейка фигуры выходит за пределы или пытается встать на место блока – сдвига не происходит. Coord здесь – класс-оболочка с двумя публичными числовыми полями (x и y координаты).
Поворот фигуры
Логика аналогична сдвигу:
Падение фигуры
Сначала код в точности повторяет предыдущие два метода:
Так как в результате переноса ячеек какая-то линия может заполниться полностью, после каждого добавления ячейки мы проверяем линию, в которую мы её добавили, на полноту:
Этот метод возвращает истину, если линию удалось уничтожить. После добавления всех кирпичиков фигуры в сетку (и удаления всех заполненных линий), мы, при необходимости, запускаем метод, который сдвигает на место пустых линий непустые:
Теперь GameField реализован почти полностью — за исключением геттера для фигуры. Её ведь графическому модулю тоже придётся отрисовывать:
Теперь нам нужно написать алгоритмы, по которым фигура определяет свои координаты в разных состояниях. Да и вообще весь класс фигуры.
Класс фигуры
Реализовать это всё я предлагаю следующим образом – хранить для фигуры (1) «мнимую» координату, такую, что все реальные блоки находятся ниже и правее неё, (2) состояние поворота (их всего 4, после 4-х поворотов фигура всегда возвращается в начальное положение) и (3) маску, которая по первым двум параметрам будет определять положение реальных блоков:
Rotation мод здесь будет выглядеть таким образом:
Соответственно, от самого класса Figure нам нужен только конструктор, инициализирующий поля:
И методы, которыми мы пользовались в GameField следующего вида:
Вдобавок, у фигуры должен быть цвет, чтобы графический модуль мог её отобразить. В тетрисе каждой фигуре соответствует свой цвет, поэтому цвет мы будем запрашивать у формы:
Форма фигуры и маски координат
Чтобы не занимать лишнее место, здесь я приведу реализацию только для двух форм: I-образной и J-образной. Код для остальных фигур принципиально не отличается и выложен на GitHub.
Храним для каждой фигуры маску координат (которая определяет, насколько каждый реальный блок должен отстоять от «мнимой» координаты фигуры) и цвет:
Реализуем методы, которые использовали выше:
Ну а сами маски координат я предлагаю просто захардкодить следующим образом:
Т.е. для каждого объекта enum‘а мы передаём с помощью импровизированных (других в Java нет) делегатов метод, в котором в зависимости от переданного состояния поворота возвращаем разные реальные координаты блоков. В общем-то, можно обойтись и без делегатов, если хранить в каждом элементе отсупы для каждого из режимов поворота.