Я из тех программистов, кому нравится всё реализовать самостоятельно. Нет, я не имею в виду, что не доверяю работе, сделанной другими. Скорее, я полагаю, что, если сделаю что-нибудь сам, то из этого будет гораздо больше толку, чем если просто взять чужую реализацию. Например, я написал на С мой собственный парсер регулярных выражений, при этом воспользовался моей собственной библиотекой структур данных на C. Надеюсь, когда-нибудь потом напишу об этом подробнее.
Вне всяких сомнений, я наработал массу опыта и знаний, выполняя все эти вещи самостоятельно. Поэтому, на мой взгляд, вполне целесообразно и далее так делать, то есть, попробовать выполнить какой-то проект, уже реализованный ранее. Всё это — именно с целью самообучения. На этот раз я взялся самостоятельно перепроектировать «Тетрис». Чтобы было ещё интереснее, я решил всё сделать на C.
На самом деле, это не первая моя игра на C – немного ранее я уже успел написать на С клон сапёра. В блоге также об этом не писал, но, может быть, напишу в будущем, так как проект получился очень интересным. Пожалуй, этот опыт пригодился мне в работе над «Тетрисом».
❯ Создаём GUI на C
В разработке игр особенно важно, что приходится уделять особое внимание разработке интерфейса. У меня большой опыт написания библиотек (то есть, кода, который другие разработчики могут использовать в своих программах). Библиотеки — это хорошо. Вы как программист продумываете, как на месте другого человека вы использовали бы тот сервис, который предоставляет ваша библиотека. Вы придумываете названия для всех функций, а также описываете, как эти функции должны работать. Пишете примеры с кодом, в котором используется ваша гипотетическая библиотека, чтобы можно было проверить, насколько удобен этот код. Затем приступаете к реализации ваших функций. «Пользовательский интерфейс» как таковой не существует. Всё ваше время вы тратите на то, чтобы «думать как программист», с интеллектуальной точки зрения это очень интересно. Но софт, как правило, пишется для пользователей, а игры – наиболее ориентированный на пользователя класс софта.
В C действительно не так много возможностей для создания пользовательских интерфейсов. В этом языке используется классический интерфейс командной строки (читаем из консоли, выводим в консоль). Интерфейс такого рода очень удобен, так как и на вход, и на выход вам приходится работать исключительно с текстом. Но, к сожалению, он не только не подходит для большинства игр, но и пугает пользователей — как правило, они с криком отшатываются от экрана с командной строкой (то есть, от той строки, в которую нужно вводить команды).
Другая крайность программирования GUI на С – это мир оконных инструментариев. При помощи таких библиотек можно создавать оконные приложения, подобные тем, с которыми вы привыкли работать на ПК. Таких инструментариев не один и не два. Разумеется, свой подобный инструментарий есть в Microsoft Windows. Обширная коллекция таких инструментов имеется в Linux, прежде всего, на ум приходит GTK. К сожалению, любой инструментарий для создания оконных приложений на C требует в огромном количестве писать скучный и сложный для понимания код. Это вполне логично: язык C достаточно низкоуровневый. В целом GUI вообще сложно описывать на языках программирования. Чем ближе к машинному коду находится язык, тем больше приходится потрудиться, чтобы выразить в коде, как должен выглядеть ваш интерфейс.
К счастью, в данном случае есть отличная золотая середина. Именно на С можно быстро написать игру класса «Тетрис». Давайте не будем забивать в командную строку код и считывать вывод, а попытаемся прямо в ней нарисовать пользовательский интерфейс? Поскольку консольный экран — это просто символьная решётка, на ней будет легко запрограммировать тетрис-подобную игру, ориентированную на передвижение по клеточкам. Оказывается, интерфейсы в таком стиле довольно распространены (особенно в программах, написанных под Linux/Unix). Кроме того, существует одна универсальная библиотека для построения таких интерфейсов, она называется ncurses
.
Вооружившись библиотекой ncurses
, можно реализовать в окне командной строки некоторые очень крутые штуки. Притом, что в типичной программе на C в поле командной строки можно добавлять только текст (набирать его), в программах с применением ncurses
можно переставлять в окне командной строки курсор, а также ставить на экране отдельные символы там, где угодно. Таким образом, при помощи библиотеки ncurses
можно прямо в окне командной строки собрать пользовательский интерфейс с множеством интерактивных функций.
❯ Делаем Tetris
Итак, я сразу не сомневался, что хочу написать Тетрис при помощи ncurses
. Требовалось реализовать игровую логику. Я стремился, чтобы логика геймплея была полностью отделена от логики пользовательского интерфейса. Для этого я подразделил код на два файла: tetris.c
и main.c
. Файл tetris.c
не представляет о существовании пользовательского интерфейса, так как всей обработкой пользовательского интерфейса занимается main.c. Аналогично, main.c
ничего не знает о том, как именно реализован Тетрис, то есть, какая именно информация предоставляется в заголовочном файле tetris.h
. Всё это легко объяснимо. Следует писать код, который делает ровно одну вещь, и делает её хорошо. Если подойти к этому небрежно и делать в одном и том же коде две вещи (например, реализовать правила игры Тетрис в пользовательском интерфейсе), то, скорее всего, вы просто перепутаете оба функционала. Кроме того, важный бонус при том подходе, которого я придерживаюсь — я могу написать для моей игры в Тетрис совершенно новый интерфейс, даже не прикасаясь к файлу tetris.c
.
Игровая логика
Поначалу я думал, что написать Тетрис не составит никакого труда. Но, немного исследовав проблему, я убедился, что она гораздо сложнее, чем кажется. Например, мы принимаем как должное, что при поворачивании блока возле стенки он от неё отскакивает (а не застревает около неё). Такое особое поведение (и многие другие) необходимо держать в уме, занимаясь разработкой этой игры.
Я начал с простого игрового цикла и постепенно его дополнял. Функция tg_tick()
(здесь tg
— это tetris_game
) за один игровой цикл успевает выполнить одну итерацию. Она выглядит так:
/*
Выполняем один шаг игры: обрабатываем тяготение, пользовательский ввод и обновляем счёт. Возвращаем true, если игра продолжается, и
False, если игра проиграна.
*/
bool tg_tick(tetris_game *obj, tetris_move move)
{
int lines_cleared;
// Обрабатываем тяготение.
tg_do_gravity_tick(obj);
// Обрабатываем ввод.
tg_handle_move(obj, move);
// Проверяем, удалились ли какие-нибудь линии
lines_cleared = tg_check_lines(obj);
tg_adjust_score(obj, lines_cleared);
// Возвращаем данные о том, продолжится ли игра после данного хода (NOT, если она проиграна)
return !tg_game_over(obj);
}
Давайте построчно разберём этот код. Начнём с tg_do_gravity_tick()
. В игре «Тетрис» падающий блок движется вниз под действием тяготения. Чем сложнее уровень, тем быстрее движется блок. Таким образом, функция такта тяготения будет отсчитывать задержку, после которой вновь подействует (потянет блок вниз). Как только наступит время сдвинуть блок вниз, функция это сделает, а затем сбросит таймер. При этом время до следующего акта тяготения будет рассчитываться в зависимости от того, на каком уровне сложности вы играете.
После такта гравитации игра обрабатывает пользовательский ввод, вызывая функцию tg_handle_move()
. Данная функция принимает значение tetris_move
, которое может соответствовать любому из ходов, возможных в тетрисе: ход вправо, ход влево, бросить блок, придержать блок. Функция выполняет этот ход, а затем возвращается.
Теперь, когда обработаны и акт тяготения, и пользовательский ввод, возникает возможность, что какие-то линии оказались заполнены от края до края. Поэтому вызываем функцию tg_check_lines(obj)
, чтобы подсчитать такие линии и, если таковые найдутся — удаляем их. Затем мы обновляем счёт в зависимости от того, сколько линий удалось очистить. Начисление очков зависит и от уровня игры, и от того, сколько линий на данном ходу вам удалось убрать.
Наконец, в коде пользовательского интерфейса, вызывающем эту функцию tg_tick()
, требуется возможность узнать, когда игра будет закончена.Таким образом, tg_tick()
возвращает true
, пока игра продолжается, и false
, когда она окончена.
Для описания игровой логики тетриса требуется ещё немало кода — общий объём файла tetris.c
составляет почти 500 строк. В одном посте весь его не разобрать. Он достаточно интересный, так как этому коду требуется распознавать не только все виды фигур-тетромино, но и ориентацию каждой из них. Именно в этом коде также требуется обнаруживать столкновения и обрабатывать случаи «утыкания в стену», когда вы вращаете фигуру. Если вам интересно, как именно всё это делается, можете изучить его подробнее, он выложен в репозитории на GitHub.
❯ Пользовательский интерфейс
Разумеется, весь вышеприведённый код описывает игровую логику, но ничего не делает для того, чтобы отобразить игру пользователю. Он просто изменяет структуру игры в памяти. Отображение игрового процесса и обработка пользовательского ввода заключены в файле main.c
.
Я хотел бы подробно разобрать главную функцию из файла main.c, но, кажется, она великовата для этого поста. Она не так сложна для понимания, просто в ней много строк, и большая часть её специфических деталей в нашем случае не важна. Но я могу вполне доступно объяснить, как она работает, воспользовавшись одним лишь псевдокодом.
int main(int argc, char **argv)
{
// Если пользователь указал имя файла, загрузить игру, сохранённую под таким именем
// В противном случае начать новую игру.
// Инициализировать библиотеку ncurses.
// Выполнить главный игровой цикл:
while (running) {
// Вызвать функцию tg_tick(), чтобы игра двигалась вперёд.
// Отобразить новое состояние игры.
// Ненадолго заснуть (иначе игра пойдёт слишком быстро).
// Получить пользовательский ввод для следующего цикла.
}
}
Если хотите подробнее изучить код пользовательского интерфейса, посмотрите файл main.c
в репозитории GitHub.
❯ Готовый продукт
Вот, наконец, моя простая реализация тетриса почти готова. Поработав всего один день, я самостоятельно запрограммировал большинство возможностей тетриса:
- Основы (т.e., тяготение, движения, повороты и стирание линий).
- Сохранение блоков с возможностью их последующей замены.
- Система начисления очков, скопированная из более ранних версий Tetris.
- Прогрессивное усложнение уровней по мере того, чем дольше вы играете.
- Меню постановки на паузу (в том числе, “босс-версия” такого меню, при переходе в которое на месте игры оказывается имитация командной строки, чтобы создавалось впечатление, будто вы работаете.
- Возможность загрузки и сохранения игры, чтобы вы могли возвращаться к ранее начатыми играм.
Единственное, что мне не удалось сделать — воспроизводить тематический саундтрек Tetris на фоне игры. Может быть, когда-нибудь и к этому верну, но на языке С не предусмотрено удобных способов просто воспроизводить мелодию.
Если хотите попробовать сами, то лучше всего делайте это под Linux. Вам понадобится установить библиотеку ncurses
(в Ubuntu для этого понадобится выполнить sudo apt-get install libncurses5-dev
). Затем перейдите в репозиторий GitHub, скомпилируйте код при помощи make и выполните при помощи bin/release/main.
❯ Заключение
В этом посте подробно описано, как реализовать клон тетрис. И, честно говоря, я считаю, что эта тема заслуживает обсуждения. Думаю, программу я спроектировал достойно, мне приятно смотреть на такой код как tg_tick()
. Более того, эта программа по-настоящему играбельна, а это уже само по себе хорошо. Правда, завершить статью я хотел бы большим философским отступлением о важности повторных реализаций.
Когда я сказал моей девушке, что пишу собственную версию Tetris, она сразу ответила мне в духе «а разве это ещё не сделано»? Совершенно нормальная реакция, ведь, в самом деле — уже сделано. Если бы этот критерий был определяющим при программировании, то большей части кода, которую я написал в жизни, просто не существовало бы. Разумеется, можно много рассказывать, как важно делать что-то новое и уникальное, а ещё больше можно сказать о переиспользовании кода. Но сделать что-то старое и банальное — и близко не столь скучно, как кажется на первый взгляд. Практика — путь к мастерству, а упражнения по клонированию известных, важных и даже великих программ (например, оболочек, регулярных выражений, веб-серверов, брандмауэров и других игр) — наилучший способ прокачать навыки программирования, в то же время расширяя предметно-ориентированные знания семимильными шагами.
Для программиста нормально знать, что такое циклы, условные конструкции, функции и классы. Таких выпекают в университетах как горячие пирожки. Зная только эти концепции, уже можно немалого добиться, но, на мой взгляд, с этого только начинается путь к гораздо более увлекательному самообразованию. Когда научишься решать реальные прикладные задачи, только тогда у тебя появится шанс браться за такие вещи, о которых тебе, возможно, рассказывали в университете, а ты их так и не усвоил, поскольку не реализовал сам. Кроме того, решая такие задачи, приходится разбираться в отраслевых частностях, например, в Linux, HTTP, TCP, ncurses, GTK, …), которые вам точно никто никогда не преподавал, но рано или поздно вам придётся ежедневно пользоваться такими вещами при работе. Причём, даже если вы не будете обращаться к конкретному подмножеству знаний, то всё равно выиграете, так как расширите кругозор и освоите новые инструменты и подходы, которыми иначе никогда ранее не воспользовались бы.
Короче говоря, подобные проекты «реализаций заново» стали важнейшей частью моего образования, дополнив ту теорию информатики, которую я выучил в университете. Без этих проектов я бы определённо не состоялся таким программистом, каким являюсь сейчас. Сейчас я буквально думаю на C. Указатели, массивы, структуры, биты, байты, системные вызовы теперь неотделимы от моей личности. Я понимаю, как в программах реализуются те вещи, которые мы воспринимаем как данность: например, как создаются процессы, порождаются и обмениваются информацией потоки. Я могу вам часами рассказывать, как пакеты маршрутизируются по Интернету, как их просеивает брандмауэр (особенно в Linux). Я обожаю иерархию Хомского и люблю рассказывать тем, кто готов слушать, о чистой теории регулярных выражений и конечных автоматов, как она привела к реализации регулярных выражений — одно из самых распространённых и востребованных инструментов программиста сегодня.
Благодаря тому опыту и пониманию, который я наработал на проектах по повторной реализации, я научился эффективнее мыслить, видеть связи между старыми и новыми задачами. Я всегда думаю о том, как взять на вооружение наилучшие идеи. Лучше понимаю, почему при проектировании были приняты те или иные решения. Я умею подходить к решению задач в той же дисциплинированной манере, в которой были сделаны другие реализации, которые мне довелось изучить. Те разрозненные знания, которые я приобрёл, мне удалось объединить в совершенно новый набор обширных знаний и более качественных подходов, а также осознать, насколько мало я знаю.
Возможно, захочется почитать и это:
Разрабатывайте и развивайте свою игру (и не только) с помощью облачного хостинга для GameDev ↩