На хабрахабре периодически предпринимаются попытки описания процесса игроделания с самых разных сторон — от воплощения 3D-графики до создания сетевых протоколов. Эти темы, безусловно, важны, однако довольно узкие. В данной статье я попробую использовать более широкий подход — рассмотрю принцип создания игрового движка для т.н. казуальных игр. Описываемая механика вполне подойдет для создания всяческих пакманов, арканоидов, платформеров и пр. Описание процесса будет на примере примитивного scrolldown шутера (из ностальгических чувств к Zybex и Xevious) — летаем по полю, сбиваем метеориты. Инструмент — Qt. Сразу оговорюсь, что никаких красот и законченности в коде нет. Классы примитивны и повторяют код, функции неоптимальны, графика некрасивая никакая, зато это всё пыхтит и ворочается. Это — база, с которой можно работать дальше. Опытным программистам — пролистать за чашечкой чего-нибудь горячего, начинающим или вливающимся в тему возможно даст пищу для размышления. Начинаем.
Цикл приложения
Для того, чтобы правильно выбрать способ организации программы, нужно определиться с участниками главного цикла. В любой казуальной (и большинстве неказуальных) игре их минимум три:
Тактовый генератор игрового процесса
Симулятор непрерывности
Рендеринг сцены
Возможны и другие пункты — менеджер анимации, искусственный интеллект и пр. Для примера нам вполне хватит этих трех.
Что это за такие участники?
Тактовый генератор игрового процесса — это привязанный к таймеру… эм… тактовый генератор игрового процесса. В нем контролируются перемещения объектов по игровому полю. Основное его назначение — обеспечение целостности игрового процесса и его одинаковость. Это очень важно — не только для того, чтобы скорость игры не зависела от производительности компьютера, но еще и для обеспечения синхронизации при игре по сети.
Симулятор непрерывности — это вспомогательные функции, основное назначение которых — следить, чтобы между вызовами генератора игрового процесса не произошло что-нибудь важное. Например, рассмотрим такой игровой момент:
Слева и справа изображены два последовательных вызова тактового генератора игрового процесса. Предположим, что скорость желтого круга = 3. Расстояние между кругом и прямоугольником, как видно из рисунка, = 2. Получается, что круг и прямоугольник так и не столкнутся, если им не помочь. Эту помощь и оказывает симулятор непрерывности.
Рендеринг сцены — тут вроде бы всё понятно. Он отдельным пунктом, так как:
не должен зависеть от скорости игрового процесса;
должен обеспечить плавность изображения (при скорости объекта = 10 точкам на экране и частоте игры = 30 будут видны рывки движущегося объекта, если рендерить кадры только в момент вызова генератора игрового процесса)
Возможные способы организации циклов
Сразу в голову приходит мысль сделать по отдельному потоку для каждого из участников. Однако такой подход не является оптимальным, так как:
не обеспечивает по умолчанию синхронизацию между участниками цикла. Вдруг придется жертвовать рендерингом и анимацией ради поддержания синхронности сетевой баталии?
значительно усложняет разработку и как следствие повышает количество ошибок. Разные потоки = разные ресурсы, вопросы синхронизации и совместного доступа, и прочие прелести многопоточных программ.
Поэтому потоки делать будем, но несколько иначе — отдельный поток на обработку сообщений от ОС (отрисовка, опрос клавиатуры), и отдельный поток на игровой процесс, в котором вызываются все три участника. С отрисовкой и опросом клавиатуры всё понятно — это просто поток главной формы приложения. Разберемся с потоком игрового процесса.
Поток игрового процесса
Структура потока показана на рисунке:
Теперь немного кода с пояснениями. Для начала устанавливаем частоты. Грубо говоря, сколько мс должно пройти между вызовами обработки логики, рендеринга:
if ( time_tmp - time_lastrender >= MAX_FPS && w->paint_mx.tryLock() )
{
time_lastrender = time_tmp;
float freq_bit = 0;
if ( time_tmp != last_freq )
freq_bit = (float)( time_tmp - last_freq )/FREQ ;
emit signalGUI( freq_bit );
w->paint_mx.unlock();
}
}
(прим. — если будете смотреть исходный код — там всё несколько сложнее. Идет подсчет кадров в секунду, вывод дебажной информации и прочее)
Наверняка возник вопрос — зачем функциям рендеринга и симулятора непрерывности знать время, которое прошло с момента последнего обновления игровой логики? Всё просто — для того, чтобы рассчитать моментальное состояние сцены, и верным образом его обработать и вывести на экран. Для экономии ресурсов вызывая симулятор непрерывности можно передавать также время его прошлого вызова.
Как всё это работает
В нашем примере три вида объектов:
корабль игрока
пули
метеориты
Для них сделаны соответствующие классы (CShip, CBullet, CMeteorite). Для пуль и метеоритов заданы контейнеры QVector для хранения. Для обработки пользовательского ввода создан массив «направлений движения» и переопределены функции keyReleaseEvent и keyPressEvent: keyReleaseEvent проверяет, есть ли в массиве нажатых клавиш отпускаемая клавиша, и удаляет ее при наличии. keyPressEvent соответственно заносит нажатую клавишу в массив нажатых клавиш (если ее там нет). Обработка этого массива происходит в функции тактового генератора игрового процесса. Там же происходят перемещения игровых объектов, обсчет инерции при движении корабля, создание метеоритов:
Функция CheckGameRules проверяет игровые правила — кто в кого врезался, кто за рамки чего вышел и прочее. Кстати, в 2D это всё очень удобно делается функциями классов QPolygon, QRect и иже с ними.
Рендеринг отрисовывает игровое поле и вызывает Draw() всех объектов с параметром текущего отступа от последнего вызова тактового генератора игрового процесса. Плюс вывод служебной информации:
Собственно, остальное — тривиальное программирование. Скелет приложения разобран, а детали реализации можно посмотреть в прилагаемых исходных кодах. В качестве итога — внешний вид того, что у меня получилось: Исходники тут. Летаем стрелками, стреляем пробелом.