К осеннему DotNext’у в Москве мы решили разработать игру. Это была IT-вариация популярной «Алхимии». Игрокам нужно было собрать из доступных 4-х элементов 128 понятий, связанных с IT, пиццей и Додо. А нам нужно было реализовать это от идеи до работающей игры чуть больше, чем за месяц.
В предыдущей статье я писал о проектной составляющей работы: планирование, факапы и эмоции. А эта статья про техническую часть. Будет даже немного кода!
Disclaimer: подходы, код и архитектура, о которых я пишу ниже, не являются чем-то сложным, оригинальным или надёжным. Скорее наоборот, они очень простые, местами наивные и не предназначены для больших нагрузок by design. Однако, если вы никогда не делали игру или приложение, использующее логику на сервере, то эта статья может послужить стартовым толчком.
Код на клиенте
В двух словах про архитектуру проекта: у нас были мобильные клиенты для Android и iOS на Unity и бэкенд-сервер на ASP.NET с CosmosDB в качестве хранилища.
Клиент на Unity представляет собой только взаимодействие с UI. Игрок кликает на элементы, они перемещаются по экрану фиксированным образом. Когда создается новый элемент, появляется окошко с его описанием.
Процесс игры
Этот процесс можно описать достаточно простой стейт-машиной. Главное в этой стейт-машине — дождаться анимации перехода в следующее состояние, блокируя UI для игрока.
Я воспользовался очень классной библиотекой UnitRx, чтобы писать код на Unity в полностью асинхронном стиле. Сперва я попробовал использовать родные Task’и, но они вели себя нестабильно на сборках для iOS. А вот UniRx.Async отработал как часы.
Любое действие, которое требует анимации, вызывается через класс AnimationRunner:
public static class AnimationRunner
{
private const int MinimumIntervalMs = 20;
public static async UniTask Run(Action action, float durationInSeconds)
{
float t = 0;
var delta = MinimumIntervalMs / durationInSeconds / 1000;
while (t <= 1)
{
action(t);
t += delta;
await UniTask.Delay(TimeSpan.FromMilliseconds(MinimumIntervalMs));
}
}
}
Это фактически замена классических корутин на UnitTask’и. Дополнительно любой вызов, который должен блокировать UI, вызывается через метод HandleUiOperation
глобального класса GameManager
:
public async UniTask HandleUiOperation(UniTask uiOperation)
{
_inputLocked = true;
await uiOperation;
_inputLocked = false;
}
Соответственно, во всех элементах управления сперва проверяется значение InputLocked, и только если он равен false, элемент управления реагирует.
Это позволило достаточно легко воплотить стейт-машину, изображенную выше, включая сетевые вызовы и I/O, применяя async/await подход с вложенными вызовами, как в матрёшке.
Второй важной особенностью клиента было то, что все провайдеры, получавшие данные по элементам, были сделаны в виде интерфейсов. После конференции, когда мы выключили наш бэкенд, это позволило буквально за один вечер переписать код клиента так, чтобы игра стала полностью офлайновой. Именно эту версию можно скачать сейчас с Google Play.
Взаимодействие клиента и бэка
Теперь поговорим про то, какие решения мы принимали, разрабатывая архитектуру клиент-сервер.
На клиенте хранились картинки, а за всю логику отвечал сервер. При старте сервер считывал csv-файл с id и описаниями всех элементов и сохранял их у себя в памяти. После этого он был готов к работе.
Методов API был необходимый минимум — всего пять. Они реализовывали всю логику игры. Всё достаточно просто, но расскажу про пару интересных моментов.
Аутентификация и стартовые элементы
Мы отказались от сколько-нибудь сложной системы аутентификации и вообще любых паролей. Когда игрок вводит имя на стартовом экране и жмёт на кнопку «Старт», ему в клиенте создается уникальный случайный токен (ID). При этом он никак не привязан к устройству. Имя игрока вместе с токеном отправляются на сервер. Все остальные запросы от клиента к бэку содержат в себе этот токен.
Очевидные минусы этого решения:
- Если пользователь снесёт приложение и поставит его заново, он будет считаться новым игроком, и весь его прогресс потеряется.
- Нельзя продолжить игру на другом устройстве.
Это были сознательные допущения, потому что мы понимали, что на конфе люди будут играть точно только с одного устройства. И у них не будет времени переключиться на другое устройство.
Таким образом, в запланированном сценарии клиент вызывал метод сервера AddNewUser
только один раз.
При загрузке экрана игры также один раз вызывался метод GetBaseElements
, который возвращал id, имя спрайта и описание для четырёх базовых элементов. Клиент находил нужные спрайты у себя в ресурсах, создавал объекты элементов, записывал их к себе локально и отрисовывал на экране.
При повторных запусках клиент уже не регистрировался на сервере и не запрашивал стартовые элементы, а забирал их из локального хранилища. В результате сразу открывался экран игры.
Слияние элементов
Когда игрок пытается соединить два элемента, вызывается метод MergeElements
, который либо возвращает информацию о новом элементе, либо сообщает, что эти два элемента не собираются. Если игрок собрал новый элемент, информация об этом записывается в БД.
Мы применили очевидное решение: чтобы уменьшить нагрузку на сервер, после того как игрок пытается сложить два элемента, результат кэшируется на клиенте (в памяти и в csv). Если игрок пытается повторно сложить элементы, сперва проверяется кэш. И только если результата там нет, запрос отправляется на сервер.
Таким образом мы знаем, что каждый игрок может совершить ограниченное количество взаимодействий с бэком, а количество записей в базу не превышает числа доступных элементов (которых у нас было 128). Мы смогли избежать проблем многих других приложений для конференций, у которых после большого и одновременного наплыва участников частенько отказывал бэк.
Таблица рекордов
Участники играли в нашу «Алхимию» не просто так, а ради призов. Поэтому нам необходима была таблица рекордов, которую мы выводили на экране на нашем стенде, а ещё в отдельном окошке в нашей игре.
Чтобы сформировать таблицу рекордов, используются последние два метода GetCurrentLadder
и GetUser.
Здесь тоже есть любопытный нюанс. В случае, если игрок попадает в топ-20 результатов, его имя в таблице выделяется. Проблема была в том, что информация этих двух методов не была связана напрямую.
Метод GetCurrentLadder
обращается к коллекции Stats
, получает 20 результатов и делает это быстро. Метод GetUser
обращается к коллекции Users
по UserId и делает это тоже быстро. Слияние результатов происходит уже на стороне клиента. Вот только вот мы не хотели светить UserId в результатах, поэтому их там и нет. Сопоставление происходило по имени игрока и количеству набранных очков. В случае с тысячами игроков неизбежно были бы коллизии. Но мы рассчитывали на то, что среди всех играющих вряд ли будут игроки с одинаковыми именами и количеством очков. В нашем случае этот подход полностью себя оправдал.
Game Over
Наша исходная задача была за месяц собрать игру для двухдневной конференции. Все те решения, что мы воплотили в архитектуре и коде полностью оправдали себя. Сервер не лёг, все запросы обрабатывались корректно, а игроки не жаловались на баги в приложении.
К следующему московскому DotNext'у мы, скорее всего, замутим очередную игру, потому что это теперь стало нашей доброй традицией (CMAN-2018, IT-алхимия-2019). Напишите в комментариях, на какую убивалку времени вы готовы променять хардкорные доклады от звёзд разработки. 🙂
Для таких же наивных и интересующихся мы выложили код клиента IT-алхимии в открытый доступ.
А ещё заглядывайте в телеграм-канал, где я пишу всякое про разработку, жизнь, математику и философию.