Опыт разработки аркады под Android на С++ и Qt
Космос сам себя не наложит
Предпосылки
Я, как и многие программисты, выбрал эту профессию, потому что в детстве играл в компьютерные игры и мечтал их разрабатывать. Как только я научился более-менее писать код, который может компилироваться без синтаксических ошибок, я, конечно, стал делать всякие глупые игры, которые показывал всем друзьям и знакомым. Но время шло, и оно заставило заниматься совершенно другими вещами, работать над проектами, которые, мягко говоря, серьезней, чем игры. И так продолжалось последние несколько лет. А изначальные желания никуда не делись, исчезло только свободное время.
Давно хотел сделать какой-нибудь проект под Android, а, как известно, основная масса проектов разрабатывается на Android SDK и Java, а NDK рекомендуют использовать только в «критичных по скорости» местах и не делать на нем все целиком.
Но кому нужны все эти рекомендации и правила, когда есть Qt? Java я не знаю в той степени, которую считаю достаточной для качественной разработки игры, и изучать мне ее не хотелось, зато у меня имеются в запасе знания C++. После нескольких тестовых проектов на Qt под Android я понял, что на нем вполне можно разработать полноценное приложение, да еще и перенести его на другие платформы. Так же, посмотрев видео Shia LaBeouf — Just Do it, стало понятно, что я обречен это сделать.
Итак, я хочу рассказать про опыт разработки игры под Android на Qt 5.5.1 и С++.
Идея
Как ни странно, идея игры пришла довольно быстро и очень необычным образом. Мы экспериментировали с датчиками телефона в попытках сделать игрушку «стукач»(название временное), смыслом которой было — чем сильнее и чаще ударяешь телефон, тем больше очков получаешь. Для этого необходимо было отличить удар телефоном от взмаха, и, к счастью, нам это не удалось, но при выводе графиков на экран рисовались интересные картины, что и натолкнуло нас на идею игры, о которой и пойдет речь.
Внезапно, захотелось сделать игру про нечто, падающее в пещеру, контуры которой меняются со временем. Первоначально предполагалось сделать двухмерную игру с графикой в рисованном стиле. В дальнейшем все перетекло в трехмерную игру с видом сверху. Был написан генератор пещеры, который позволял создавать слои с контурами. Как рассчитывать столкновения с стенами пещеры и как превратить их в трехмерную модель? Можно было долго думать и выдумать что-нибудь невероятно классное, но я пошел обходным путем и сделал воксельную геометрию мира. Расчет столкновений и вывод на экран стали заметно проще, но ценой того, что игра стала похожа на майнкрафт. Я не растерялся и сделал её еще больше на него похожей, добавив модель персонажа из него же. Так и сформировался вид и концепция игры.
Первая версия генератора пещеры (архивное фото)
Приступая к разработке игры, я не ставил себе цель заработать на ней, как многие, а хотел просто начать свой проект и довести его до конца. Поэтому игра должна быть бесплатная (кто у меня ее купит?), но все-таки с минимальным количеством рекламы.
Графика
Средства
В Qt для трехмерной графики можно использовать OpenGL ES 2.0, в этом нам могут помочь следующие классы:
- QGLWidget — устаревший класс, наследник QWidget. Использовался до Qt 5.0
- QOpenGLWidget — новый класс, выполняет те же функции, что и QGLWidget, но в своей реализации использует промежуточный фреймбуфер
- QOpenGLWindow — предназначен для вывода графики OpenGL, не является виджетом и не тянет зависимостями модуль widget
Экспериментируя с этими виджетами, я сделал несколько выводов:
- QGLWidget работает намного быстрее QOpenGLWidget на слабых смартфонах
- QOpenGLWidget не меняет размер внутреннего фреймбуфера при изменении размеров окна, вероятно, это баг или фича, или я просто идиот, но тем не менее, при использовании этого виджета разрешение окна не совпадало с размером вьюпорта, что приводило либо к растянутому изображению, либо к поехавшей матрице проекции. При всем при этом производительность с использованием этого виджета была крайне мала
- QOpenGLWindow — самый подходящий кандидат. Его нельзя использовать как viewport для графической сцены и нельзя добавлять дочерние виджеты, что лишает возможности создать пользовательский интерфейс средствами Qt. Но зато он самый производительный из всех трех
Для реализации игры я выбрал QOpenGLWindow, а пользовательский интерфейс пришлось велосипедить самому.
Обновление кадров было реализовано по сигналу frameSwapped (), который генерируется после swapBuffers. Использование этого сигнала позволяет достичь большей плавности смены кадров, чем при использовании таймеров.
Для расчета анимации мне требовалось посчитать время кадра. Для этого я использовал сначала QTime, что было плохой идеей, так как этот класс считает немонотонное время, а на мобильном устройстве часто происходит корректировка времени, что приводило к попаданию в прошлое или будущее, в зависимости от ухода часов на устройстве. Также разрешение изменения этого класса ограничивается миллисекундами, что недостаточно для плавной анимации при нестабильном фпс.
Поразмыслив и обратившись к документации, я решил использовать класс QElapsedTimer, который старается использовать монотонное время и имеет разрешение до наносекунд.
Текстуры и интерфейс
Для первых версий и отладки текстуры были позаимствованы временно из майнкрафта, как и какой-то скин персонажа. Первая версия интерфейса представляла собой серые квадратные кнопки и была сделана за вечер, а переработка заняла целый месяц.
В качестве дизайнера был выбрал мой друг. Плюсами такого выбора является то, что этому дизайнеру можно доверять, а минусами — то, что он не является дизайнером — он автомеханик.
В дальнейшем мы нарисовали оригинальные текстуры для нескольких уровней и сделали текстуры персонажей.
Процесс рисования «красивого» интерфейса и персонажей оказался довольно длительным. Пока дизайнер пытался выдавить из себя нового персонажа или кнопку, мне ничего не оставалось делать, кроме как продолжать оптимизировать код.
Текстура уровня представляет собой большой атлас всех блоков и вмещает в себя 32 куба со стороной 16 пикселей. При таком подходе при рисовании слоев не требуется постоянно переустанавливать текущую текстуру, а можно рисовать весь уровень, используя вершинные буферы за несколько вызовов.
Проблема возникла в крайних пикселях блоков, которые являются смежными с другими блоками. При округлении текстурных координат стали появляться артефакты в виде полосок на краях блоков. Эту проблему я решил путем добавления каждому блоку окантовки шириной в один пиксель, дублирующую пиксель края блока. Такой своеобразный CLAMP_TO_EDGE.
К сожалению, при таком подходе я не смог использовать mipmap для уровня, т.к. текстурные координаты в вершинном буфере должны учитывать это смещение на один пиксель, а при создании текстуры уменьшенной детализации это смещение становится меньше, и мне бы пришлось использовать различную геометрию уровня в зависимости от степени детализации. Я решил с этим не заморачиваться, т.к. сама текстура уровня всего 256×256 пикселей, и особого выигрыша в производительности билинейная фильтрация не принесла бы, а принесла бы лишь дополнительные сложности в реализации.
Еще хотел заметить, что хотя блоков и всего 32, но в свойствах уровня я задал возможность расставлять блоки повернутыми на 90, 180 и 270 градусов, а также анимацию переключения между текстурой блока, что позволило разнообразить визуальную составляющую игры. Хотя, анимацию я применил только на одном из уровней для создания эффекта вращения вентиляторов.
Шейдеры
В Qt имеются удобные классы для работы с шейдерами. Я использовал QOpenGLShaderProgram. Этот класс позволяет добавлять вершинные и фрагментные шейдеры, компилировать их, линковать, устанавливать uniform и attrubute. Сам по себе класс является просто оберткой над множеством вызовов OpenGL и, соответственно, не является полноценным объектом, в том понимании, что работу класса можно нарушить, используя вызовы GL напрямую между вызовами этого класса.
Удобно то, что класс автоматически добавляет define таким образом, что шейдер ES компилируется нормально и на десктопе и на мобильном устройстве. Это относится в первую очередь к спецификаторам точности, которые на десктопе превращаются в ничто.
Мне пришлось написать отдельные шейдеры для игрового мира, включающие освещение и анимацию некоторых блоков, шейдер для персонажа и шейдеры интерфейса.
Шейдеры интерфейса включают в себя
- простой шейдер, выводящий текстуру
- шейдер бара хп\щит
- шейдер круга
В первой версии я реализовал прогресс-бар набором прямоугольников, формирующих колбу показателя и сам показатель, спрятанный в колбе. Но, когда я увидел макет нового бара, от такого способа пришлось отказаться.
Бар хп\щит
Этот бар я решил реализовать шейдером, сначала мне это показалось очень странной идеей, но в конце концов я ее принял как неизбежное и реализовал таким способом:
#ifdef GL_ES
precision highp int;
#endif
varying highp vec4 v_position;
varying highp vec2 v_dim;
uniform lowp vec4 u_color;
uniform highp float u_tg;
uniform highp float u_value_a;
uniform highp float u_value_b;
uniform highp int u_step;
uniform lowp vec3 u_pallete[3];
void main()
{
int hstep = u_step/2;
int w = int(v_dim.x);
int h = int(v_dim.y);
int wd = u_step*3;
int pos_bl = int(v_position.x - u_tg*v_position.y + v_dim.y);
int pos_br = int(v_position.x + u_tg*v_position.y);
int pos_l = (pos_bl - wd*(pos_bl/wd))/u_step;
lowp float b0 = float(pos_l == 0) * float(pos_bl <= int(u_value_a*v_dim.x));
lowp float b1 = float(pos_l == 1) * float(pos_bl >= int((1.0-u_value_b)*v_dim.x));
lowp float b2 = clamp(float(pos_l == 2) + 1.0-(b0+b1), 0.0, 1.0);
highp float p = abs(2.0*(v_position.w - 0.5));
highp float out_p = (1.0 - 0.25*p);
lowp float i = float(int(v_position.y) > hstep) * float(int(v_position.y) < h - hstep);
lowp float o = (1.0-i)*float(pos_br >= h) * float(pos_bl <=w);
lowp float a = i*float(pos_br >= h+u_step) * float(pos_bl <=w - u_step);
lowp float b = i*float(pos_br < h+u_step) * float(pos_br >= h);
lowp float c = i*float(pos_bl > w - u_step) * float(pos_bl <= w);
highp float pr = (1.0 - p)*a;
gl_FragColor = vec4( u_pallete[0]*pr*b0
+ u_pallete[1]*pr*b1
+ u_pallete[2]*pr*b2
+ u_pallete[0]*out_p * b
+ u_pallete[1]*out_p * c
+ mix(u_pallete[0], u_pallete[1], v_position.z)*out_p*o,
clamp(a+b+c+o, 0.0, 1.0)*u_color.a);
}
Отдельно хотелось бы сказать про разнообразие android устройств и их графических ускорителей. Я столкнулся с проблемами:
- Код шейдера компилируется без проблем на одном устройстве, а на другом возникают ошибки
- Шейдер нормально компилируется, но на некоторых устройствах неправильно работает. Например, на PowerVR, если локальная переменная называется так же, как и юниформ или атрибут — это не приводит к ошибкам, но сам шейдер перестает работать корректно
- На некоторых устройствах возникают ошибки, если переменная или функция называется так же как и встроенная функция. Например, переменная mix или clamp
- Возникали проблемы с точностью float на некоторых устройствах
Итог всего этого — если вы планируете использовать шейдеры в мобильной игре, проверяйте их на самых распространенных моделях графических ускорителей. Если шейдер компилируется и работает у вас на компьютере, это еще не означает, что он будет работать на телефоне вашего соседа. Новый Vulkan API должен решить проблему с различными компиляторами шейдеров и привнеси порядок в этот безумный мир, но это дело будущего, а сегодня имеем то, что имеем.
Звуки
Поиск
Звуки — это вообще отдельная песня. Их можно записать самому, что довольно трудоемко и требует наличия нормального микрофона и слуха (не наш случай). А можно найти желанные звуки в интернете.
Искать звуки желательно с лицензией Creative Commons 0, дабы не указывать авторство для каждого звука, коих может быть несколько десятков. Может показаться, что найти нормальный free звук довольно тяжело, и оно так и есть. Проблема не в том, что их мало, напротив, — их очень много, большинство из которых ужасны. Поиск звука — процесс, в котором надо переслушать очень-очень много звуков и выбрать из них самые подходящие.Средства
В Qt для вывода звуков существуют классы QSound, QSoundEffect, QAudioOutput и QMediaPlayer. В первых версиях я использовал QSoundEffect для вывода эффектов и QMediaPlayer для вывода звуков. Но, как оказалось, все они не подходят.
Со сжатыми звуковыми файлами умеет работать только QMediaPlayer, но у этого класса, точнее, у его реализации под Android есть несколько неприятных моментов.
- Фризы при запуске звука. Когда музыка играет в зацикленном состоянии, при повторении возникает ощутимая задержка всего приложения.
- При чтении медиафайла из ресурсов он создает временный файл в папке с данными приложения и не всегда удаляет его за собой, что приводит к росту размера приложения. Это было обнаружено случайно и хорошо, что вовремя.
QSound и QSoundEffect умеют работать только с несжатыми wav файлами. QSoundEffect предназначен для вывода звуков без задержек и умеет зацикливать звуки сам, но при его использовании на Android в логах часто появляется сообщение AUDIO_OUTPUT_FLAG_FAST denied by client», что означает — формат звукового файла не может быть выведен медиасервером без задержек. Это связано с неправильной частотой дискретизации, которая различна на разных устройствах. Некоторые устройства проглатывают 44KHz, некоторым надо 48KHz, а сам обьект передает звук так, как он записан в файле wav. Размер звуковых ресурсов составлял значительный процент от размера приложения.
Все эти недостатки привели к тому, что после добавления звуков в игру появились ощутимые проседания FPS при воспроизведении звуков и музыки.
Решением стал отказ от этих классов и использованием для звуков библиотеки SFML. Очень простая и легковесная библиотека, похожая на SDL. Удобные классы для работы с графикой, звуком, устройствами ввода. Эта библиотека не умеет работать с mp3 (лицензия, все дела), но зато умеет многое другое. Я использовал для эффектов и музыки формат ogg.
Вывод по звукам
Для использовании в приложении, где звуки являются чем-то необязательным, Qt-шные родные классы подходят. Для разработки игр — совсем нет. Лучше и проще использовать SFML.
Реклама в приложении
Для рекламы я использовал уже готовую реализацию AdMob под Qt — QtAdMob.
Сперва был добавлен только один маленький баннер в меню, но в дальнейшем образовалось межэкранное объявление.
Занятно, что межэкранное объявление, находясь даже в загруженном состоянии, появляется с некоторой задержкой. То есть появилась необходимость блокирования пользовательского интерфейса в момент появления рекламы и восстановление после ее закрытия. При этом, библиотека не позволяла отловить моменты, когда объявление показано и закрыто. Данный функционал библиотеки я допилил в версию под Android. Версию под ios трогать пока не стал, за неимением возможности проверить работоспособность.
Аналитика и статистика
Выкладывая первую версию на Play Market, я понадеялся на статистику в консоли разработчика. Но, как оказалось, статистика активных пользователей завязана на Google Analytics и не работает, пока в приложении не включишь трекер аналитики. А та статистика, что доступна, приходит с задержкой более суток и рассчитывается по тихоокеанскому времени. Такое положение дел не позволяет понять, как же твои действия влияют на скачивания. Поэтому я дополнил класс activity из QtAdMob, который наследуется от QtActivity функциями инициализации аналитики и функциями отправки событий игры. Примеры кода не привожу, т.к. все прекрасно описано в документации.
В события я вынес нажатие всех кнопок интерфейса, возникновение некоторых игровых ситуаций, открытия и разблокировки персонажей с уровнями.
Благодаря сбору всей этой статистики я могу сидеть за ноутбуком и наблюдать в реальном времени, как в Бразилии кто-то запустил игру, не смог пройти первую миссию, вышел и, вероятно, удалил игру.
Еще, если верить статистике,- мы сейчас чемпионы по нашей игре.
Про Google Play
Консоль разработчика сама по себе довольно удобный инструмент, но функционал статистики и рекламы завязан на другие аккаунты.
Чтобы полноценно разрабатывать приложение самому, необходимо как минимум иметь аккаунты AdWords, AdSense, AdMob, Google Analytics. При этом между ними устанавливается связь. Все эти аккаунты — отдельные продукты Google и имеют различную техническую поддержку и настройки. Также стоит заметить, что аккаунт AdMob требует наличия аккаунтов AdWords и AdSense. При этом все эти аккаунты могут быть привязаны в единственном экземпляре к основному аккаунту Gmail. Но, как показала практика, во всем этом можно запутаться с самого начала, потому что ты открываешь один сервис, он тебе предлагает создать новый аккаунт в другом, тот в третьем и так далее.
Я каким-то магическим образом так, что сотрудник техподдержки не смог объяснить произошедшее, создал 2 аккаунта AdWords и привязал их к одной почте, при этом привязав один аккаунт к консоли разработчика, а другой к AdMob (об этом я не знал).
На один аккаунт я закинул 500р, чтобы проверить рекламную кампанию. В попытках разобраться с этим и следуя советам техподдержки, я перенес один из аккаунтов на «левую» почту и закрыл сам себе доступ к нему. Все это привело сначала к неработоспособности обоих аккаунтов с моей почты, затем, при повторных отключениях и подключениях самого себя, работоспособность вернулась. Но, как выяснилось, перестал работать AdMob. Так как AdMob мне был важнее тех 500р, пришлось провести всю эту процедуру заново, попутно молясь о том, чтобы я не потерял доступ вообще ко всему, вернуть работу AdMob. И конечно, те 500р остались висеть на не подключенном аккаунте.
Так что, аккуратнее с этим.
Перевод
В игре
Игровое меню мы перевели на 2 языка — русский и английский. Выбор языка игры осуществляется по системной локали. Если в системе русский язык — то игры на русском, во всех остальных случаях — на английском.
Для осуществления переводов текстовой информации в Qt существует встроенный механизм, приводящийся в исполнения классом
QTranslator myTranslator;
myTranslator.load(":/translations/neverfall_" + QLocale::system().name());
a.installTranslator(&myTranslator);
Все строки, которые необходимо перевести, передаются в функцию QObject: tr (), для классов, не являющихся наследниками QObject можно использовать функцию QApplication: translate и для строк, объявленных в массивах макросы QT_TRANSLATE_NOOP, QT_TR_NOOP.
Но это только полдела. Необходимо создать сами переводы, что производится программами lupdate и lrelease. Первая собирает информацию из исходного кода, содержащего эти функции и макросы, и создает Xml файл с информацией для перевода.
Вторая собирает из xml файла бинарный файл qm, который загружается непосредственно в QTranslator.
Мы использовали в качестве переводимых строк что-то вроде тегов, которые затем переводили на английский и русский. Например,»#GameOverText» переводится в «Игра закончена». Сделано было так, чтобы не было необходимости менять исходный код, чтобы что-то по-другому написать, а затем еще и менять все переводы, т.к. для lupdate это уже другая строчка.
На маркете
В Google Play мы пошли по самому простому пути: написали текст на русском — закинули в гугл-переводчик — перевели на английский, подкорректировали. А затем английский текст перевели на самые распространенные языки тем же самым гугл-переводчиком. Забавно, что один из вариантов описания содержал фразу «Окунись в подземелье с головой», которая, пройдя весь этот пусть переводов на китайском, означала «Прыжки в пещере на голове». Мы так и оставили, потому что раз они нам на АлиЭксперессе перлы выдают, так и мы не будем отставать.
Заключение и планы
В заключение хочу сказать, что процесс разработки этой игры был и является одним из самых интересных занятий, принес нам огромное удовольствие и опыт, которым я скромно делюсь в этой статье. Разработка велась в свободное время, после работы и ночью, с августа этого года. Потрачены деньги были только на аккаунт разработчика и пару тысяч на рекламу, так что я не расстроюсь, если нам эта игра принесет чуть менее, чем ничего.
Дальнейшие планы зависят от того, как люди будут реагировать на наше творение. Судя по отзывам, довольно неплохо, но судя по скачиваниям и удалениям, хочется сходить за веревкой в ближайший магазин хозтоваров. Вероятно, игра получилась довольно сложная, и у нас явный недостаток рекламной деятельности
Мы планируем перенести игру под ios, но этому мешает отсутствие яблочной техники и 100$ в год, а так же пугает перспектива общения на эльфийском Objective C.
Я надеюсь, что это не последняя наша игра, есть множество новых идей, которые я буду стараться воплотить в жизнь, учитывая полученный опыт. Эта игра — первый блин, комом он или нет — судить не мне.
Предвосхищаю комментарии по поводу того, что это можно было сделать проще и быстрее, используя готовые движки, привлекая дизайнеров и издателей. Отвечу на это, что мы желали пройти путь джедаев-разработчиков с начала и до конца, своими силами, дабы окунуться в это с головой.