Мне повезло на работе заниматься любимым делом в сильной команде с хорошими людьми. Мы строили и рушили воздушные замки, сражались с ветряными мельницами, внедряли, поддерживали и не волновались. Однажды мне захотелось построить свой замок. Рассудив с разных сторон я решил, что он будет небольшой, и я построю его сам, это будет хобби-проект. Идей было несколько, я выбрал одну и приступил к разработке, это была игра.
Идея и реализация
Вдохновением послужили Кубик Рубика и R-функции В.Л.Рвачева. В чем идея: на игровом поле расположены элементы, которые связаны в списки. Цепочки связанных элементов можно перемещать внутри этих списков. Важная часть реализации и геймплея — геометрическое расположение элементов. В случае Кубика Рубика они расположены в трехмерной матрице
Теперь внимание. Узлы списков не обязательно располагать в прямоугольной матрице. Их можно расположить произвольно, можно сделать цепочки разной длины, наложить разные условия на этот граф и перемещения по нему. Звучит просто до тех пор, пока мы не попытаемся визуализировать такую модель. Здесь перед нами встает первая интересная задача.
Мы привыкли, что игровые объекты имеют понятный и вполне определенный размер. Однако, если мы задаем произвольные траектории движения объектов, то при визуализации элементов головоломки будут неизбежны коллизии, они будут накладываться друг на друга. Вариант решения — вычислять размеры и форму элементов в зависимости от их взаимного расположения.
Выглядит неплохо. R-функциями такой результат не получишь, но вдохновение я почерпнул в них. Теперь усложняем модель, чтобы посмотреть, как это может выглядеть целиком. Капли пришлось сделать более «жесткими», иначе все изображение ходило ходуном.
И опять выглядит неплохо, и даже хорошо. С проверкой стартовой модели закончили, теперь нужно извлечь из нее полезные артефакты. После экспериментов остановился на таком варианте.
- Для уменьшения потребляемой памяти графические элементы (теперь я стал называть их blob-ами) будем хранить в векторном формате в виде многоугольников.
- Для придания им округлой формы, многоугольники будут рендериться с интерполяцией сплайнами, как в векторных шрифтах.
- Все вершины и индексы отдельного игрового поля будут лежать в одном непрерывном буфере, что бы сократить количество проходов отрисовки.
Еще небольшой объем памяти на служебные структуры для описания фреймов. Итого получилась структура данных размером от 0.5 до 1 Мб на одно игровое поле. Размер зависит от желаемой точности геометрического описания blob-ов и плавности их движения. Модель есть, данные есть, осталось их оживить. После некоторых усилий начальная идея материализовалась в такой вот геймплей:
В статье от идеи до реализации уместилась пара абзацев. На практике пройденный путь был длиннее, и, конечно, он был уже много раз исхожен до меня. Поэтому остановлюсь лишь на нескольких эпизодах проделанной работы, которые мне кажутся интересными.
Выбор платформы и SDK
К выбору я подошел очень издалека. Решил, что это должно быть что-то кросс-платформенное (или легко-портируемое) и первая реализация непременно должна быть на мобильной платформе, например, на Android.
Моя профессиональная деятельность связана с enterprise разработкой преимущественно в стеке .net, а слова “мобильная” и “игра” были для меня чем то далеким. Исходя из сказанного в качестве основного инструмента разработки логично было бы выбрать Xamarin или mono-based реализацию других, распространенных библиотек. Поэтому после некоторых раздумий я решил писать на c++ и в качестве framework-а использовать его же. В итоге на нем было написано 99.9% программного кода. Разработка и отладка велась в Visual Studio под нативную ОС Windows. Для мобильной платформы выполнялись только финальная сборка и минорные тесты.
Главное — не сдаваться
Плавность движения blob-ов — важная часть геймплея, мне был нужен FPS 60 и оптимизация достаточная, что бы держать этот frame rate на максимальном количестве устройств. Что получилось при реализации: использован API OpenGL ES2, при минимальных настройках графики в приложении (отключены: anti aliasing, подсветка выбранных цепочек blob-ов и пр.) основной экран отрисовывается за 3 вызова glDrawElements, при максимальных настройках — от 6 до 9 вызовов. В целом получилось довольно экономно, можно было бы еще поработать над оптимизацией, но здесь меня подстерегало разочарование. Ни при каких условиях мне не удавалось добиться 60 FPS от моего старого Galaxy Ace (думал, будет работать на нем — будет работать везде). После многих экспериментов и значительного потраченного времени до меня наконец дошло. Я все время пытался оптимизировать шейдеры и количество draw-колов, а решение проблемы с Galaxy Ace находилось совсем в другой плоскости. Я исключил это устройство из моего чек-листа, после этого необходимость в продолжении работ по оптимизации быстродействия отпала. Главное не сдаваться, решение обязательно найдется!
Пара кнопок на интерфейсе
Другая интересная задача — интерфейс пользователя. Приступая к ней, я совсем не ожидал, что реализация “пары кнопок” займет столько времени. Это был очень-очень большой сюрприз. Чтобы “кнопки” заработали, были реализованы несколько render-ов: для текста, иконок, спрайтов, прямоугольников, добавлено выравнивание элементов относительно экрана и друг-друга. Из примитивов собраны объекты посложнее, к ним добавлено поведение: текстовые метки, кнопки, диалоговые и всплывающие окна и пр. Разумеется я был озабочен производительностью и не мог просто нарисовать “кнопки”, это надо было сделать быстро. А если нужно быстро, то вариант один — минимизируем draw-колы. В результате элементы UI были объединены в группы так, что все объекты одной группы отрисовываются за один вызов. Велосипед получился похожим на микро-библиотеку для UI. Например, кнопка сейчас создается так:
std::unique_ptr<IControl> button(new Icon(
"render_group_for_all_controls_on_the_screen",
IconType::MenuSolid, // вид иконки
SDFStyle::Default, // способ вывода SDF изображения иконки
vec2(0, 0.5f), // смещение при относительном выравнивании
size, // размер
rect::up, // выравниваем верх
rect::down, // относительно низа
parent_button->GetRenderObject(), // вот этого объекта
[](IControl*)->void {Locator::Resolve<IActivityManager>(true)->OpenActivity("Levels"); }
));
Обратная связь для разработчика
По мере приближения релиза задумался о журналировании. Приложение было портировано под Android и протестировано на паре физических устройств и эмуляторах. Явно недостаточно, чтобы со спокойной совестью нажать кнопку “опубликовать”. Нужен централизованный журнал ошибок, который поможет в первые дни после релиза (или очередного обновления) поправить что-нибудь критичное. Поднимать свои сервера — совсем не то, чего бы мне хотелось. Готовые решения для мобильных приложений не понравились, по крайней мере бесплатные. Пошел в облака. В целом у всех все одинаково. По стечению обстоятельств, которые совсем не принципиальны, я остановился на Azure с его blob хранилищем. По моим ожиданиям, решение с удаленным журналом в облаке, должно получиться дешевым, т.к. в журнал пишется минимум и при нормальной работе приложения логи должны быть пустыми. На момент написания статьи проверить еще не успел.
В заключении о главном
Оглядываясь на проделанную работу и принятые решения, совершенно точно можно сказать, ресурсы, и без того ограниченные, расходовались хаотично и ужасно неэффективно, но с другой стороны очень правильно. Бизнес модель, планирование, продуктовые практики с гипотезами и пивотами — это очень хорошие инструменты, когда твой проект = работа. Но если у тебя вдруг обнаруживается желание что-то “сочинить” сверх обычного, метрики перестают работать, целью становится не результат, а процесс. И самая сложная задача в нем — удержание этой странной и зыбкой мотивации. Только с ней можно залезть в самые труднодоступные места. Это был мой главный вызов в течении проекта. Наверное, как у всех, прошедших по этой дороге.
Все это время я работал над продуктом, и это гораздо больше, чем код или идея, работа над ним не может прекратиться никогда, но сегодня я ставлю еще одну галочку: «Замок готов».
Автор: ethero