Мы живём в сложном мире и, кажется, стали забывать о простых вещах. Например, о бритве Оккама, принцип которой гласит: «Что может быть сделано на основе меньшего числа, не следует делать, исходя из большего». В этой статье я расскажу про простые и не самые надёжные решения, которые можно применять в разработке простых игр.



К осеннему 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<float> 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). При этом он никак не привязан к устройству. Имя игрока вместе с токеном отправляются на сервер. Все остальные запросы от клиента к бэку содержат в себе этот токен.



Очевидные минусы этого решения:

  1. Если пользователь снесёт приложение и поставит его заново, он будет считаться новым игроком, и весь его прогресс потеряется.
  2. Нельзя продолжить игру на другом устройстве.

Это были сознательные допущения, потому что мы понимали, что на конфе люди будут играть точно только с одного устройства. И у них не будет времени переключиться на другое устройство.

Таким образом, в запланированном сценарии клиент вызывал метод сервера AddNewUser только один раз.

При загрузке экрана игры также один раз вызывался метод GetBaseElements, который возвращал id, имя спрайта и описание для четырёх базовых элементов. Клиент находил нужные спрайты у себя в ресурсах, создавал объекты элементов, записывал их к себе локально и отрисовывал на экране.

При повторных запусках клиент уже не регистрировался на сервере и не запрашивал стартовые элементы, а забирал их из локального хранилища. В результате сразу открывался экран игры.

Слияние элементов


Когда игрок пытается соединить два элемента, вызывается метод MergeElements, который либо возвращает информацию о новом элементе, либо сообщает, что эти два элемента не собираются. Если игрок собрал новый элемент, информация об этом записывается в БД.



Мы применили очевидное решение: чтобы уменьшить нагрузку на сервер, после того как игрок пытается сложить два элемента, результат кэшируется на клиенте (в памяти и в csv). Если игрок пытается повторно сложить элементы, сперва проверяется кэш. И только если результата там нет, запрос отправляется на сервер.

Таким образом мы знаем, что каждый игрок может совершить ограниченное количество взаимодействий с бэком, а количество записей в базу не превышает числа доступных элементов (которых у нас было 128). Мы смогли избежать проблем многих других приложений для конференций, у которых после большого и одновременного наплыва участников частенько отказывал бэк.

Таблица рекордов


Участники играли в нашу «Алхимию» не просто так, а ради призов. Поэтому нам необходима была таблица рекордов, которую мы выводили на экране на нашем стенде, а ещё в отдельном окошке в нашей игре.



Чтобы сформировать таблицу рекордов, используются последние два метода GetCurrentLadder и GetUser. Здесь тоже есть любопытный нюанс. В случае, если игрок попадает в топ-20 результатов, его имя в таблице выделяется. Проблема была в том, что информация этих двух методов не была связана напрямую.

Метод GetCurrentLadder обращается к коллекции Stats, получает 20 результатов и делает это быстро. Метод GetUser обращается к коллекции Users по UserId и делает это тоже быстро. Слияние результатов происходит уже на стороне клиента. Вот только вот мы не хотели светить UserId в результатах, поэтому их там и нет. Сопоставление происходило по имени игрока и количеству набранных очков. В случае с тысячами игроков неизбежно были бы коллизии. Но мы рассчитывали на то, что среди всех играющих вряд ли будут игроки с одинаковыми именами и количеством очков. В нашем случае этот подход полностью себя оправдал.

Game Over


Наша исходная задача была за месяц собрать игру для двухдневной конференции. Все те решения, что мы воплотили в архитектуре и коде, полностью оправдали себя. Сервер не лёг, все запросы обрабатывались корректно, а игроки не жаловались на баги в приложении.

К следующему московскому DotNext'у мы, скорее всего, замутим очередную игру, потому что это теперь стало нашей доброй традицией (CMAN-2018, IT-алхимия-2019). Напишите в комментариях, на какую убивалку времени вы готовы променять хардкорные доклады от звёзд разработки. :)
Для таких же наивных и интересующихся мы выложили код клиента IT-алхимии в открытый доступ.

А ещё заглядывайте в телеграм-канал, где я пишу всякое про разработку, жизнь, математику и философию.