Я лид команды в Pixonic, где работаю уже год. О старте и развитии одного из наших новых проектов я ранее написал статью на Хабре. По ходу дальнейшего производства, спустя еще полгода, у меня накопилось большое количество интересного опыта, которым хотел опять поделиться. На этот раз речь пойдет о процессе наращивания функционала в мобильном клиенте и поддержании кода в гибком состоянии.

Уверен, подавляющее большинство хотя бы раз запускали какую-нибудь многопользовательскую игру. На старте клиент, как правило, пишет несколько магических сообщений и через несколько секунд (хотя в случае с одним известным десктопным шутером — несколько минут) игрок попадает в главное меню, где есть заветная кнопка «В бой» или типа того. Но процесс запуска состоит из огромного количества этапов, которые происходят очень быстро и без вмешательства игрока:

  • подбор лучшего региона для игрока;
  • проверка изменений и скачивание новых настроек для игры;
  • установка соединения и успешная авторизация;
  • получение актуальных данных о профиле игрока;
  • множество других действий без погружения игрока в технические детали.

Причем этот функционал не пишется сразу полностью, а постепенно расширяется и постоянно улучшается всё время жизни проекта. Одна из непростых задач проектировщика — не допустить такого развития кода, при котором он теряет такие свои хорошие свойства, как слабая связанность (loose coupling) и переиспользуемость.

Но часто бывает, что модуль кода, который выглядит лаконичным и универсальным, превращается в монстра из-за какого-нибудь одного маленького нюанса в ТЗ. Одним словом: ни в коем случае нельзя допускать макарон в коде — их не распутать быстро, если нагрянут изменения.

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

Для кого-то этот материал может показаться лишь еще одной трактовкой SOLID принципов, но примеры из реальной и масштабной практики только помогают закрепить и улучшить их понимание.

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

Но прежде чем продолжить, должен снова оговориться:

  • я все еще не могу уточнять особенности геймдизайна или показывать кадры из игры до официального релиза;
  • приведенный код не является точной копией кода в проекте (это сделано для упрощения примеров);
  • данные практики, советы и код могут быть неприменимы в других проектах (но они эффективно работают у нас — с нашими требованиями и стеком технологий).

Итак, начнем с самого нижнего уровня, непосредственно взаимодействия игрового сервера и мобильного клиента.

Слой транспорта



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

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

Мы используем Low Level Photon Network для передачи данных от игрового сервера к клиенту и назад непосредственно в процессе игры с высокой частотой. Создание абстракции в коде выглядело так:

public interface INetworkPeer
{
    PeerStatus PeerStatus { get; } //состояние пира
    int Ping { get; }
    IQueue<byte[]> ReciveQueue { get; } //очередь пришедших сообщений
		
    void Connect(); 
    void Send(byte[], bool sendReliable);
    void Update(); //необходимо для синхронной работы пира, как Service у PhotonPeer
    void Disconnect();
}

public enum PeerStatus
{
    Disconnected = 0,
    Connecting = 1,
    Connected = 2,
}

«Потокобезопасность» или, если угодно — «потокоочевидность», должна читаться по интерфейсу. Как вы могли заметить, API интерфейса INetworkPeer является синхронным, метод Update намекает, что часть работ будет выполнена в контексте выполнения вызывающего.

Что это нам дало

Во время работы над кодом симуляции самый быстрый способ работы с новым функционалом — совсем не развертывание локального сервера с измененным кодом у себя на рабочем компьютере. У нас появилась возможность написать вторую реализацию для данного интерфейса, внутри которой уже использовался код из общего субмодуля — так клиент сам становится себе сервером.

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

Мы ведем эксперименты и с другими реализациями транспортов и меняем их при необходимости. Основные поводы — оптимизация работы с памятью и системными вызовами для увеличения емкости и производительности серверов.

Поток десериализации



Дальнейшая задача — преобразование массивов пришедших байт в объекты передачи данных (тип GameClientMessage). Я скрыл эти обязанности за таким интерфейсом (заметьте, этот интерфейс не привязан к реализации INetworkPeer):

public interface INetworkPeerService
{
    float Ping { get; }
    NetworkServiceStatus Status { get; } //состояние сервиса для определения дисконнектов

    void Connect(INetworkPeer peer);
    void SendMessage(GameClientMessage message, bool sendReliable); //отправка сообщения как DTO объекта, сериализация происходит внутри.
    void Disconnect();
    bool HasMessage(); //флаг указывающий, что есть входящие сообщения.
    GameClientMessage DequeueMessage(); //получение следующего пришедшего сообщения
}

public enum NetworkServiceStatus
{
    Disconnected = 0,
    Connecting = 1,
    Connected = 2,
}

Обратите внимание, INetworkPeerService знает о типе INetworkPeer и использует его в методе Connect, а реализации INetworkPeer, в то же время, ничего не знают о INetworkPeerService.

Что это нам дало

Внутри этой абстракции можно инкапсулировано и безопасно развивать функционал, связанный с сериализацией сообщений. В нашем случае под капотом находится композиция из таких ответственностей:

  • Сжатие алгоритмом LZ4.
  • Сериализация с помощью Protocol Buffers.
  • Пуллинг массивов и DTO для оптимизации использования памяти.
  • Выполнение кода в отдельном потоке.

Последний пункт очень важен, так как ваша частота кадров не должна зависеть от количества сообщений, пришедш��х за кадр. Также мы защищены от спонтанной трудоёмкости операции по расширению пула объектов.

Сетевая модель, её состояния и процедура хендшейка



При подключении к игре недостаточно просто установить соединение. Игровой сервер должен понять: кто вы, зачем подключились и что с вами делать. А на клиенте должна смениться последовательность состояний:

  1. Инициировать подключение к серверу.
  2. Дождаться успешного подключения или выдать ошибку.
  3. Отправить данные о намерениях (кто я и в какую игру меня отправила служба подбора игроков).
  4. Дождаться от сервера положительного ответа и получить от него идентификатор сессии.
  5. Начать работу, связанную с игрой, отправлять инпут и предоставлять доступ к полученным данным.
  6. В случае отключения от сервера принять необходимое состояние.

По моему мнению, тут явно напрашивается паттерн проектирования State. Как видно из примера ниже, этот автомат закрыт от пользователя и сам способен принимать решения в своей зоне ответственности:

public interface IGameplayNetworkModel
{
    NetworkState NetworkState { get; } //сообщает о готовности к работе
    int SessionId { get; } //при успешном хендшейке покажет текущий идентификатор сессии
    IQueue<GameState> GameStates { get; } //очередь пришедших состояний симуляции
    float Ping { get; }
    void ProcessNetwork(TimeData timeData); //Update или, если угодно, Service
    void ConnectToServer(INetworkPeer peer, string roomId, string playerId); //INetworPeer передаётся дальше в INetworkPeerService.Connect(peer).
    void SendInput(IEnumerable<InputFrame> input);
    void ExitGameplay();
}

У реализации интерфейса IGameplayNetworkModel конструктор выглядит так:

public GameplayNetworkModel(INetworkPeerService networkPeerService)

Это классическая инъекция через конструктор сущности нижнего уровня в сущность верхнего уровня. INetworkPeerService ничего не знает о GameplayNetworkModel или даже о IGameplayNetworkModel. Как NetworkPeerService, так и GameplayNetworkModel создаются для приложения один раз и существуют все время работы клиента. Пользователь более высокого уровня, который будет использовать для работы IGameplayNetworkModel, ничего не должен знать о сущностях, скрытых от него — таких как INetworkPeerService и еще ниже.

Что это нам дало

Самое важное это то, что пользователь этого интерфейса будет огражден от всех деталей работы с сетевыми состояниями. Какая разница, почему вы не можете отправлять инпут, получать свежие данные о игре и должны показа��ь окно потери соединения? Главное лишь доверять реализации.

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

Модель игрового матча. Инкапсуляция интерполяции и хранения игровых данных



Когда в Unity через вызов Update() ваш код получает управление выполнением, в сетевых играх обычно нужно сделать 3 вещи (упрощенно):

  1. Собрать ввод для отправки на сервер (если он есть и если позволяет состояние сети).
  2. Обновить сетевое состояние и принять то, что пришло и готово к обработке за этот кадр.
  3. Забрать данные и начать их визуализацию.

Но борясь за плавность картинки в условиях плохого мобильного соединения и негарантированной доставки, нужно дополнительно реализовать следующие функциональности:

  • Сбор и хранение ввода игрока (так как частота кадров отрисовки у нас не равна частоте отправки).
  • Дублирование данных о вводе при отправке для повышения надежности доставки.
  • Хранение пришедших состояний мира и их упорядочивание.
  • Интерполирование данных, необходимых для построения текущего кадра, на основе пришедших состояний.
  • Абстрагирование от DTO типов.
  • Ведение статистики о частоте передач.
  • Работа со временем: анализ времени на сервере, адаптация данных к временным проблемам (ускорение времени для уменьшения инпут лага и кратковременное замедление/смещение времени в случае ухудшения соединения или отсутствия части данных).

В нашем случае это инкапсулируется за интерфейсом модели геймплея:

public interface IGameplayModel : IDisposable
{
    int PlayerSessionId { get; } //идентификатор сессии выданный нам при хендшейке
    ICurrentState CurrentState { get; } //содержит уже интерполированные данные обо всем мире, берем и рисуем.
    void SetCurrentInputTo(InputData input); //Передача ввода игрока для отправки.
    void ConnectToGame(string roomId, string playerName, string playerId, INetworkPeer networkPeer); //для старта новой игры
    void ExitGamePlay(); //прерывание игры
    void UpdateFrame(TimeData timeData); //Вызов обновления из более высокого уровня.         
}

В реализации метода UpdateFrame происходит вызов IGameplayNetworkModel.ProcessNetwork(timeData) в необходимый момент. Конструктор реализации выглядит так:

public GameplayModel(IGameplayNetworkModel gameplayNetworkModel)

Что это нам дало

Это уже полноценная модель сетевого клиента для нашей игры. Теоретически, ничего больше не нужно для того, чтобы играть. Хорошая практика — написать отдельную реализацию пользователя этой абстракции как консольное приложение. Нам на помощь пришли инструменты dotTrace и dotMemory, они намного нагляднее, чем профилировщик Unity, и могут дополнительно рассказать, какие есть проблемы.

В процессе работы мы написали несколько реализаций этого интерфейса, что очень дешево дало нам полезный функционал:

  • Запись и воспроизведение реплеев. При записи реализация сохраняет пришедшие данные в отдельный буфер. А при воспроизведении, реализация игнорирует ввод пользователя и просто проигрывает из буфер��, вообще не требуя экземпляра IGameplayNetworkModel.
  • Подключение для выполнения технических задач и тестирования. Все что можно абстрагировать, зачастую можно и автоматизировать: интеграционные тесты выглядят лаконично и не тянут за собой кучу инструментария более высоких уровней. Также мы используем эту модель для создания тестовых комнат и передачи модифицированной конфигурации от геймдизайнеров в рамках конкретной партии.

Артефакты
Начиная с определенного момента у нас стали проявляться графические артефакты. Персонажи и объекты стали двигаться с незначительными рывками и это воспроизводилось только на сборках Android. Нам пришлось перебрать всё — от синхронизации времен с сервером до формул интерполятора. Но в итоге оказалось, что баг стал воспроизводиться после перехода с Unity версии 2017.1.1p1 на 2017.4.1f1. После исследований и общения с саппортом оказалось, что существует баг в расчете Time.deltaTime движка — дельты времени не соответствуют физическому течению времени (обещали исправить в Unity 2018.2). Благодаря тому, что мы не используем Time.deltaTime непосредственно в коде, а пробрасываем TimeData через Update дерево, мы легко сделали правку следующим образом: завели Stopwatch в самом начале дерева кода и использовали Stopwatch.Elapsed и считали дельты вручную, корректируя лишь на Time.timeScale.

Общая модель приложения. Инкапсуляция старта приложения и переподключения к игре



В один прекрасный момент нашей команде пришла задача переподключать игрока в игру в случае, если он каким-либо образом пропал из боя. Если с выключенным интернетом все понятно, то в случае падения выключения приложения и его последующего перезапуска — не сразу стало очевидно, как должна работать данная фича. Было недостаточно расширить коллекцию состояний IGameplayModel, так как её интерфейс явно указывает на контроль за работой извне.

Решение было таким: создать стейт машину более высокого уровня, которая бы наблюдала за состоянием модели геймплея и выполняла бы заново подключение в необходимых случаях. Вдобавок, на старте начальные состояния этой машины должны проверять записи о неоконченных играх и в случае наличия таких — пытаться подключиться к игре для продолжения. И в самом конечном кейсе, если такой игры уже не существует на сервере, то возвращаться в стандартное состояние готовности.

Список состояний:

  • Старт приложения с проверкой записей об играх.
  • Состояние готовности к работе.
  • Состояние «в процессе игры».
  • Состояние переподключения.

Интерфейс этой модели самого высокого уровня на тот момент выглядел так:

public interface IAppModel : IDisposable
{
    ApplicationState ApplicationState { get; } //информация о текущем состоянии приложения. В случае дисконнекта на основе этого свойства вид будет показывать экран потери соединения.
    GameplayData GamePlayData { get; } //данные для отрисовки для текущего времени
	
    void StartGamePlay(GameplayStartParameters parameters);
    void PlayReplay(Replay replay);
    void RefreshRoomsList(string serverAddress); //дебаг функционал, для работы с тестовыми к��мнатами
    void ExitGamePlay();

    void SetLastGamePlayInput(Vector2 input, ISafeList<SkillInputData> skillButtonStates); //передача ввода от игрока на этот момент времени.

    void SelectHero(int dropTeamMemberId, bool isConfirmed); //выбор персонажа в конкретном матче
    void Update(TimeData timeData); //обязательное обновление, пробрасываемое ниже по дереву. 
}

Что это нам дало

Это дало нам не только элегантное решение переподключений между сессиями, но и инструмент расширения стадий инициализации. Как-нибудь я покажу, как мы использовали этот инструмент на всю катушку.

Предварительные итоги


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

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

Кстати, вы возможно заметили, что я совершенно не затронул тему взаимодействия со вторым сервером:

  • как пользователь авторизуется;
  • как клиент получает данные о текущем игроке;
  • как происходит процедура попадания в бой;
  • как мы получаем результат боя;
  • как мы восстанавливаем соединение с учетом уже двух подключений к двум серверам.

Этот набор обязанностей клиента тоже встраивается в дерево зависимостей на уровне модели приложения и образует отдельную большую ветку типов и связей. Но об этом уже в следующий раз.