В продолжении совместного проекта dev.by и Playtika Григорий Перепечко, Mobile Client Architect в минском офисе компании, рассказывает о технической стороне создания одного из крупнейших в мире мобильных приложений для социальных слотов.
Начало: стакан мобильных движков наполовину отсутствовал
На дворе был 2012 год, и компания Playtika приняла решение о разработке новой мобильной игрушки, разумеется, на слот-тематику.
Пока продакт-менеджеры окучивали «джиры», рисовали диаграммы Ганта и творили прочую бесценную бюрократию, мы мучились вопросом «На чём писать?!». Минский офис был только-только сформирован, и готовых технологий для разработки игр мы не имели.
Был проверенный десятилетиями C++, компилирующийся под все мыслимые и немыслимые платформы, уйма движков и даже редакторов под него. Была возможность писать нативно, от которой сразу же отказались из-за банальной дороговизны и сложностей с синхронизацией команд. Имелся также модный и современный C#, который Мигель ди Иказа с товарищами из Xamarin отчаянно продвигали как философский камень для мобильной разработки под Android, iOS и Windows Phone.
Мудрые менеджеры взвесили простоту C# на одной руке и зарплаты хороших C++-разработчиков на другой, посмотрели из широкого окна на стены монументально стоящего БГУИР и сделали выбор в сторону более мейнстримового C#.
Но игра — хитрая штука, которая тысячи раз в секунду сообщает GPU, что хочет взять шейдер Ш, текстуру Т и отрисовать оными пару сотен треугольников. А также декодирует по чуточку бэкграунд-трек, чтобы ласкать слух игрока, и не повредит обсчитывать пару десятков анимаций, дабы картинка не казалась слишком унылой.
И конечно же для всего этого необходим игровой движок. А стакан мобильных игровых движков в 2012 году был не просто наполовину пуст, а скорее, наполовину отсутствовал. Выбрать можно было из Unity, Axiom, DeltaEngine, MonoGame — пожалуй, и всё. И каждый из них содержал по ложке сочного дёгтя.
Изобилующий #ifdef MonoGame, медленный и не умеющий работать с масками и видео Unity были не лучшим выбором для очень простой, но требовательной к мелочам игры. И как бы невзначай все пришли к неизбежному — нужно писать своё.
Свой-то уж точно будет лучше, быстрее, стабильнее, чем все чужие. Так зародился в наших недрах Monosyne, в основу которого лёг архитектурный подход XNA.
В команде были и остаются люди, умеющие писать игровые движки, и это отчасти спасло ситуацию, однако никто из бизнеса не понимал, что нужно довести технологию до некой логической завершённости, и таким образом после пары месяцев работы над движком, при полном отсутствии какого-либо инструментария, ещё одна команда начала работать над игрой.
Движок только утром научился рисовать текстурированные треугольники, а команда, гонимая Скрамом, уже к обеду хочет класть «чекбокс» на «попап», да ещё и с удобным API.
Хочется отметить удачное решение — поддержка десктопной Windows с ранних этапов как третьей платформы наряду с Android и iOS, что сэкономило километры нервов во время отладки.
Xamarin времён 2012 года, к сожалению, был далёк от идеала. Временами «отваливался» отладчик. Сама по себе Xamarin Studio не содержала всех инструментов, к которым мы привыкли. В библиотеке классов BCL были баги и отличия в поведении от имплементации Microsoft. Да и просто заливка сборки на устройство занимала много времени. Поэтому возможность написать и проверить код под Windows в удобной Visual Studio с R#, залить это дело в git и довериться команде тестирования была глотком свежего воздуха.
Для бизнес-приложений такой подход видится малореальным из-за сильной зависимости от платформенных UI-контролов, а для игры c 95% кроссплатформенного кода — вполне приемлемым.
Из забавных проблем, с которыми пришлось столкнуться, — отсутствие поддержки Portable Class Libraries в Xamarin. Представьте, вы пишете под три платформы, но вынуждены иметь три копии одного и того же проекта. На тот момент выкрутились генерацией проектов. В шапку проекта без списка файлов оный генерируется и дописывается по некоторым правилам. Таким образом, если вы добавили какой-нибудь класс, для его появления в проектах для других платформ нужно было перезапустить генератор. Звучит жутко.
Не кодом единым...
Но не кодом единым жив проект. Ведь чтобы порадовать глаз игрока, нужно что-то показывать и желательно анимировать.
Вернёмся к началу проекта. Движку пара месяцев, он может открыть PVR и PNG, есть «арты» от художников в виде PSD и чёткое понимание, что контентно-ориентированные игры так не делаются. Необходим оптимизированный для телефонов формат игровых сцен, с анимациями и прочими радостями геймдева. А главное — нужен способ подготовки этих сцен специально обученными людьми. Тут опытный разработчик вспомнит заветные «тулсет», «редактор сцен», но игру хотят уже вчера, и несколько месяцев на инструментарий никто не даст.
Посему появился набор правил, по которым специально обученные ребята называли слои в «фотошопе», и скриптик, который умел разбирать эту PSD в сцену в нашем формате. Назвать это неудобным — ничего не сказать. Поэтому после нескольких месяцев мучений взгляд упал на Adobe Flash и экспорт из его формата, а там — и анимации, и bitmap-маски, и разного рода эффекты. Технология для игр, подобных нашей, оказалась довольно удобной, а главное, что найти специалиста по flash вполне реально.
Эволюционировал движок, код и процессы разработки.
Хочу рассказать о паре вещей, без которых было бы сложно организовать процесс работы на проекте в 60 человек.
1. Continuous integration
Разработчик заливает код в репозиторий, Teamcity запускает билд из его ветки на все платформы. Через 5-7 минут билды под WP8, iOS, Android, Win10 готовы. Причём в них зашивается информация о номере билда, ветке, конфигурации и номере коммита, из которого они собраны. Это экономит время на выяснение деталей. Когда разработчиков стало много, мы отключили автобилды для всего, кроме мастера, но QA могут запускать нужные им билды в один клик.
Для того чтобы QA мог поставить билд с устройства, мы написали простенькую страничку на PHP, которая ходит в Teamcity API и получает список билдов.
Для iOS потребовались некоторые приседания с info.plist’ом, для WP8 пришлось купить сертификат от Symantec для подписи билдов. Экономия времени — колоссальная.
2. Log Storage
Приложение в процессе работы пишет подробные текстовые логи в файл. И в приложении есть удобная возможность отослать логи в хранилище. Причем отсылается как лог текущей сессии, так и предыдущей, чтобы можно было увидеть краш-лог. Ссылку на лог удобно присоединять к тикету в Jira. Также по всем логам можно организовать поиск, например, чтобы найти редкий паттерн, на который QA иногда наталкиваются, но не сообщают о нём. А главное — QA экономят время на подключение устройства к компьютеру и снятию логов нативным способом (adb, xcode). Кроме того, логи можно отослать в релизе, потапав по скрытой кнопке. Также они автоматически отправляются, когда игрок делает запрос в Customer Support. Так проще разобраться, что же именно у него пошло не так.
3. Git flow/Pull requests
Читая reddit, можно внезапно подумать, что сейчас в тренде Trunk-Based Development. Вероятно, так и есть для компаний, где в один репозиторий коммитят сотни разработчиков. Для 10-15 человек мы пришли к почти классическому Git-Flow. Есть мастер, от него feature-бранчи, от мастера же релизные ветки на пару дней. Для тегов применения не нашлось, а релизные ветки никогда не удаляются. Все ветки «мержатся» исключительно туда, откуда были созданы. Никакие перекрестные merge’ы не разрешены, force push и rebase опубликованных веток запрещён.
Pull Request’ы помогают делиться знаниями и работают как экран от явных косяков. Если верить Стиву Макконнеллу, ревью снижает цену исправления ошибок в сто и более раз в случае, если ошибка дошла до юзеров.
В нашей команде из 12 разработчиков действует простое правило: в мастер можно «мержить» только PR’ы, а прямые коммиты запрещены (за этим следит Atlassian Bitbucket Server). Для мержа требуется два одобрения от коллег, билд на Teamcity (он информирует Bitbucket о результатах билдов через интеграционный плагин) и одобрение от QA. Есть правило релевантных аппруверов, что означает обязательные аппрувы от людей, хорошо разбирающихся в области изменений.
4. SDD
Перед разработкой каждой фичи составляется документ, описывающий общие соображения. Плюсы: разработчики стали больше задумываться над тем, что же они будут писать, как оно будет работать у миллионов юзеров и как в целом одна фича влияет на всё приложение. Здесь присутствует лёгкий аромат бюрократии, но всё-таки это хороший способ уговорить разработчика подумать о том, что он напишет, а не сразу бросаться в объятия IDE. После разработки этот документ остаётся на корпоративной вики, и всегда можно глянуть техническую информацию по каждой фиче.
Ниже — контрольный список для составления SDD:
- Configuration keys (dynamic config, segmentation approach, persistent application settings).
- Assets (embedded/external, approximate size, lifetime in memory, aspect ratios).
- Network calls (role, estimated response size, frequency, custom timeout, specific error handling, retry/fallback strategy, websocket application possibility, response caching possibility).
- Device storage usage (estimated footprint, corruption fallback, cleanup on expiration).
- Forward compatibility (foresee future changes that should be backward compatible).
- Logic modularity(excludability) (how easy can the logic be removed/disabled from project, critical for promotional/temp features).
- Ability to reuse existing common infrastructure (Payments, Remote Resources/Assets, Facebook).
- Ability to be reused in multiple projects (CS, SM, SR, VDS, WLC).
- Application performance implications (app loading time, feature loading time, heavy operations in runtime, memory/graphics memory usage, overdraw, draw call count).
- UML diagrams (sequence with requests/user actions, flow diagram with business logic, incoming/outgoing logical dependencies).
- Incident monitoring/hotfixing possibilities (event streaming, dynamic config, text logging).
- Development/Testing helpers (integration with Service popup, Windows hotkeys, hardcoded test data/api stubs).
- Device/Hardware limitations (memory, sensors).
Об архитектуре проекта
А теперь вкратце расскажу о некоторых архитектурных моментах проекта.
Так вышло, что мой предыдущий профессиональный опыт был сформирован на enterprise-проектах с соответствующей архитектурой и подходами. И, как мне кажется, для такого плана игр он оказался довольно полезным.
В целом, игра достаточно проста — это социальные слот-машины.
Не вдаваясь в детали, можно выделить 6 базовых частей приложения:
- Мультилогин (через Facebook либо просто привязка к устройству).
- Загрузка, в процессе которой мы активно коммуницируем с сервером, разбираем и загружаем сцен-граф из файлов, декодируем картинки — в общем, готовимся радовать юзера.
- Лобби, в котором пользователь может собрать разнообразные бонусы, послать друзьям подарок, купить монет или мультипликаторов, ну и, конечно, выбрать одну из 130 слот-машин, на которой он хочет испытать удачу.
- Собственно слот-машины.
- Бонусные мини-игры, уникальные для каждого слота, но все со схожей механикой.
- Несколько случайных типов бонусов, наподобие колеса удачи, лототрона или рулетки.
Каркас всего приложения построен на паттерне, схожем с MVP.
Очень много идей было позаимствовано из WPF/WinForms.
- Похожая система визуальных иерархических контролов. К примеру, возможность вложить текстуру и звук в кнопку, и звук будет проигрываться при зажатой кнопке.
- Кодогенерация на уровне форм/окон с помощью T4. К примеру, интегратор (дизайнер сцен) нарисовал вам красивое всплывающее окно, на входе у вас файлик сцены, но, чтобы не писать код в стиле Button btn = Scene.FindById<Button>(“RedButton”), мы приняли решение на предкомпиляции генерировать код аналогично WPF/WinForms и обращаться к переменным, отражающим объекты в сцене. И аналогично методы запускают анимации в сцене.
- Разделение ответственности в коде. Никакой невизуальной логики в pop-up/окнах, как завещал Фаулер.
Controller -> Screen -> SceneObject
Логика в Controller, визуальная логика в Screen, а SceneObject и его наследники — контролы, из которых собирается картинка.
Отдельного разговора заслуживают слот-машины, ведь в них ни о каком MVP и речи идти не может. Там используется классическая Entity Component System на уровне механики вращения барабанов, которая позволяет инкрементально добавлять логику, не рискуя что-либо сломать, и ещё одна компонентная система на уровне логики фич.
В приложении 130 слот-машин, некоторые из которых отличаются только ресурсами (графика, видео, анимация уникальные), но логика и код идентичны. Тут отлично подошёл подход со стейт-машиной, отражающей состояние слота, и подключаемыми блоками, которые подписываются на переходы между состояниями и выполняют некоторую логику. В итоге сейчас определение большинства простых слотов выглядит как указание, какие базовые модули добавить.
Одна из важных частей архитектуры — Event Aggregator для развязывания частей проекта, которым друг о друге знать необязательно. Тут хочу добавить, что важно не переусердствовать. События уровня UserLoggedIn или LevelUp — это нормально, но что-либо более специализированное, возможно, стоит получать непосредственно у продюсера этого события. Изначально мы использовали ReactiveExtensions, но из-за его тяжеловесности и влияния на время старта написали свою простейшую реализацию.
Также важной составляющей является IoC, в нашем случае это Autofac. О плюсах IoC/DI можно говорить долго, я очень рекомендую книжку Dependency Injection in .NET. Мне кажется, что без него разделение платформенного и кроссплатформенного кода не было бы столь элегантным. Сейчас код на 90% кроссплатформенный, а платформы по большому счёту отличаются только реализациями двух десятков интерфейсов и регистрацией оных.
Также DI дал возможность писать легко тестируемый код за счёт Constructor Injection. К сожалению, в игровых проектах, где за производительностью — приоритет, сделать чистую тестируемую архитектуру крайне сложно. Некоторые вещи придётся слишком дорого прятать за интерфейс, некоторые просто архитектурно не хочется развязывать ради тестов. Поэтому мы для тестирования вынуждены использовать платный Unconstrained Isolation Framework. Их для .NET всего три: Telerik JustMock, Microsoft Moles и TypeMock Isolator. Мы остановились на последнем, позволяющем подменять реализации даже закрытых и статических методов, методов классов BCL, и делать прочие странные вещи. Из свежих примеров: у меня был код, базирующийся на Stopwatch, и очень не хотелось менять его структуру ради тестов. С TypeMock подменить ответ Stopwatch было проще простого.
IoC дал возможность не тратить время на размышления о том, как прокинуть какие-либо зависимости. Вы просто пишете класс, определяете все необходимые зависимости в конструкторе, регистрируете класс в контейнере и — вуаля. В то же время он предоставил мощную валидацию корректности зависимостей.
Небольшая ложка дёгтя — производительность контейнера. Для нас Autofac оказался медленным. На телефонах 3-летней давности регистрация всех типов (сейчас ~500) может занимать 1.5-2 секунды, что, очевидно, очень неприятно.
Я активно поглядываю на DryIoC, самый быстрый IoC-контейнер под .NET — проект нашего соотечественника Максима Волкова.
C# как язык для геймдева
Ну и напоследок — пара субъективных выводов о C# как о языке разработки игр.
- Простота C# и .NET — миф. Большинство разработчиков очень плохо представляют себе, как в деталях работает await или GC, или чем, например, struct на практике лучше class. А это очень важно для игр. И, как следствие, найти хороших C#-разработчиков даже сложнее, чем для C++.
- Производительность — это в первую очередь аллокации, а во вторую — Data Locality. Большинство проектов на C# страдают избыточной аллокацией и просто захлёбываются в GC. Для игры остановка мира даже на 5 мс — риск пропуска кадра. Локальность данных уменьшает количество cache miss и мусора в кэше. Пример из игры: мы уменьшили объём базовой игровой ноды, на основе которой строятся все сцены в два раза — и количество рисуемых объектов при том же FPS удвоилось (речь только о CPU, GPU нагрузка была околонулевая для чистоты замера).
- JIT — это зло. Если у вас много кода на старте приложения, телефоны без AOT потратят огромное время на компиляцию. Порой хочется кое-где отказаться от Reflection, используя кодогенерацию. Но взамен мы получаем увеличение JIT time. Можно получить логи mono, найти хотспоты, и немного подвигать код, но это в целом очень грустно. На слабых Android-устройствах мы тратим до 7 секунд чисто на JIT-компиляцию.
Microsoft уже сделала шаг в сторону CoreClr + NativeCompiler для W10, будем ждать для Android.
- LINQ — медленно, dynamic — невозможно, await — круто, unsafe — необходимо.
- Не нужен скриптинг. C# — реально удобный язык для игровой логики.
- Для проектов, не нацеленных на хороший FPS, я бы его порекомендовал. Но если вы хотите сделать технически отличный продукт, достойный уровня мобильного AAA, многие вещи сделать либо очень сложно, либо невозможно.
Главный вывод
Ну и, конечно же, главный вывод, который неизменен на протяжении веков.
Самое важное — с какими людьми вы работаете. Если это профессионалы с горящими глазами, открытые всему новому, готовые прийти в воскресенье на работу, чтобы написать библиотеку, которая заменит готовое, но неподходящее решение, у вас всё получится. В лучшем случае у вас выйдет первоклассная игра, в худшем же вы получите бесценный опыт и удовольствие от пройденных километров бурелома.
Я горжусь тем, что в нашей компании сложилась именно такая атмосфера, и рад пригласить всех красноглазых дотнетчиков в гости пообщаться, а может, и на совместный бой с .NET за FPS.
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.