
Я лид команды в 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 для оптимизации использования памяти.
- Выполнение кода в отдельном потоке.
Последний пункт очень важен, так как ваша частота кадров не должна зависеть от количества сообщений, пришедш��х за кадр. Также мы защищены от спонтанной трудоёмкости операции по расширению пула объектов.
Сетевая модель, её состояния и процедура хендшейка

При подключении к игре недостаточно просто установить соединение. Игровой сервер должен понять: кто вы, зачем подключились и что с вами делать. А на клиенте должна смениться последовательность состояний:
- Инициировать подключение к серверу.
- Дождаться успешного подключения или выдать ошибку.
- Отправить данные о намерениях (кто я и в какую игру меня отправила служба подбора игроков).
- Дождаться от сервера положительного ответа и получить от него идентификатор сессии.
- Начать работу, связанную с игрой, отправлять инпут и предоставлять доступ к полученным данным.
- В случае отключения от сервера принять необходимое состояние.
По моему мнению, тут явно напрашивается паттерн проектирования 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 вещи (упрощенно):
- Собрать ввод для отправки на сервер (если он есть и если позволяет состояние сети).
- Обновить сетевое состояние и принять то, что пришло и готово к обработке за этот кадр.
- Забрать данные и начать их визуализацию.
Но борясь за плавность картинки в условиях плохого мобильного соединения и негарантированной доставки, нужно дополнительно реализовать следующие функциональности:
- Сбор и хранение ввода игрока (так как частота кадров отрисовки у нас не равна частоте отправки).
- Дублирование данных о вводе при отправке для повышения надежности доставки.
- Хранение пришедших состояний мира и их упорядочивание.
- Интерполирование данных, необходимых для построения текущего кадра, на основе пришедших состояний.
- Абстрагирование от 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.
Общая модель приложения. Инкапсуляция старта приложения и переподключения к игре

В один прекрасный момент нашей команде пришла задача переподключать игрока в игру в случае, если он каким-либо образом пропал из боя. Если с выключенным интернетом все понятно, то в случае
Решение было таким: создать стейт машину более высокого уровня, которая бы наблюдала за состоянием модели геймплея и выполняла бы заново подключение в необходимых случаях. Вдобавок, на старте начальные состояния этой машины должны проверять записи о неоконченных играх и в случае наличия таких — пытаться подключиться к игре для продолжения. И в самом конечном кейсе, если такой игры уже не существует на сервере, то возвращаться в стандартное состояние готовности.
Список состояний:
- Старт приложения с проверкой записей об играх.
- Состояние готовности к работе.
- Состояние «в процессе игры».
- Состояние переподключения.
Интерфейс этой модели самого высокого уровня на тот момент выглядел так:
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); //обязательное обновление, пробрасываемое ниже по дереву.
}Что это нам дало
Это дало нам не только элегантное решение переподключений между сессиями, но и инструмент расширения стадий инициализации. Как-нибудь я покажу, как мы использовали этот инструмент на всю катушку.
Предварительные итоги
Путем разделения обязанностей и инкапсулирования ответственностей наше приложение объединило в себе множество функций. Все компоненты взаимозаменяемы и одни изменения слабо влияют на другие. Зависимости можно отобразить на графике как цепочку связей от более широких элементов к более специализированным.
На практике такое проектирование дает очень хорошие показатели поддержки и изменяемости кода. Для нас (с учётом всех дедлайнов, сжатых сроков и обычного будничного изготовления/изменения фичей) изменения кода являются легковесными, а задачи на рефакторинг не исчисляются неделями.
Кстати, вы возможно заметили, что я совершенно не затронул тему взаимодействия со вторым сервером:
- как пользователь авторизуется;
- как клиент получает данные о текущем игроке;
- как происходит процедура попадания в бой;
- как мы получаем результат боя;
- как мы восстанавливаем соединение с учетом уже двух подключений к двум серверам.
Этот набор обязанностей клиента тоже встраивается в дерево зависимостей на уровне модели приложения и образует отдельную большую ветку типов и связей. Но об этом уже в следующий раз.
