Наивно. Супер: код и архитектура простой игры

К осеннему 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-алхимии в открытый доступ.
А ещё заглядывайте в телеграм-канал, где я пишу всякое про разработку, жизнь, математику и философию.
На съемках «Народа против Гориллы Гродда» заметили намеки на появление Короля Соломона
Premium Collectibles Studio открыла предзаказ на статую ксеноморфа из фильма Чужой высотой 124 сантиметра
Серии Crash Bandicoot исполнилось 30 лет, но Xbox практически никак не отметила юбилей
Градостроительный симулятор XIRI Oasis Cities посвящен выживанию города будущего
Разработчики Moss закрыли студию после релиза игры на всех платформах
Авторы Fable пообещали сделать боевую систему глубокой вместо копирования старых RPG
По данным СМИ, Sony забрала у студии Kojima Productions движок Decima
Российская инди-студия анонсировала градостроительный симулятор XIRI Oasis Cities