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

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



    К осеннему 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-алхимии в открытый доступ.

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

    Комментарии 7

      0
      Здравствуйте. А почему решили писать асинхронный код с UnitRx? Разве для такого приложения в этом был выигрыш по сравнению с теми же корутинами?
        0
        Да, было два выигрыша с точки зрения написания кода:
        1. Удобно было писать «вложенные» асинхронные действия с помощью стандартного механизма async/await.
        2. Внутри некоторых вызовов были сетевые запросы. Я вообще не думал, можно ли их описать корутинами, но хотелось их встроить в общий «пайплайн».

        Изначально я вообще всё написал на «голом» async/await, но у него внезапно вылезли проблемы на iOS-устройствах. Скорее всего, там некорректно отрабатывает await Task.Delay(...), когда задержка занимает небольшое время.

        В своих предыдущих проектах я использовал корутины, в этот раз попробовал UnitRx, и с ним реально как-то проще.
          +1
          Понял, спасибо и за ответ, и за статью
        0
        А сервак на asp.net core? А его не выложили?) Я сейчас искал чтобы поковырять такое)
          0

          Да, на ASP.NET Core. Пока не выложили, потому что там секреты и доступы к БД прописаны. Наверное, можем создать отдельный репозиторий и залить туда финальный код без конфиденциальной инфы и истории коммитов) Ceridan, что думаешь?

            0
            Теоретически можно, но там нет же ничего интересного. Да и код на коленке написан. Если хочется что-то поковырять, то я рекомендую, например, Polly, но уж точно не нашу Алхимию :)
          0
           

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое