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