Ранее я уже писал о своем опыте разработки мобильной словесной игры на Android и iOS, которая пользуется определенной популярностью, и я решил прикрутить к ней режим мультиплеера, когда два игрока соревнуются между собой, составляя слова по очереди, как заключительном раунде телепередачи Сергея Супонева «Звездный час».



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

Немного истории


Приложение было написано на языке С++ c использованием Marmalade SDK. С тех пор вендор прекратил поддержку этой платформы, продав сорцы японцам, и будущее этой среды разработки стало очень туманным.



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

Почему не cocos2d-x




Cocos2d-x — один из самых распространенных движков разработки кроссплатформенных мобильных игр на C++. Видимо, засчет своей бесплатности и открытого исходного кода. Движок плохо документирован. Описание покрывает скудную часть движка и большая часть материала давно устарела.

По результатам какого-то периода мне все-таки удалось создать прототип своего приложения. Но впечатления были очень нехорошие: такое ощущение, что cocos2d-x собран на коленке. Уровни абстракции Scene, Sprite, Application Delegate показались мне очень неудобными, а необходимость искать ответы на вопросы на кокосовом форуме все чаще приводят тебя к мысли, что ты занимаешься чем-то не тем. Наверное руки у меня не из того места растут.

Мой выбор пал на SDL2




SDL, так же как и Marmalade SDL, это не движок, это платформа. Он предоставляет низкоуровневое API, из которого я потом выстраиваю удобные мне уровни абстракции. Написано все это на языке C, исходный код открыт.

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

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

Архитектура многопоточного приложения


Логика приложения и вся работа с графикой реализована в основном потоке, который представляет из себя цикл обработки сообщений и стартует в функции main. Назовем этот поток SDL Thread. В свою о��ередь другие потоки, бросают ему события (SDL_PushEvent) для обработки в очередь, а тот читает их из нее с помощью SDL_WaitEvent и SDL_PollEvent. Это либо системные события, бросаемые системой и поддержка которых уже реализована в SDL, либо вызовы Callback-ов и Listener-ов, которые мы реализуем уже сверх функционала SDL.



Вся логика игры написана на C++. Каталог проекта содержит набор *.cpp файлов, которые можно разделить на три группы:

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

Соответственно есть три отдельных каталога под проект каждой платформы:

  • proj.win32 — проект VS2017 Community Edition;
  • proj.android — проект Android с использованием Gradle;
  • proj.ios — XCode проект для iOS.

Интеграция с сервисами мультиплеера


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

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

Обе платформы iOS и Android поддерживают Real-time Multiplayer (RTMP). В случае с Android мы интегрируемся с Google Play Services (GPS), в случае с iOS — Game Center. Ранее Google поддерживал и интеграцию с iOS, но в этом году решил от нее отказаться.

В данной статье я не буду описывать те действия, которые нужно выполнить в Google Play Console и AppStoreConnect для настройки мультиплеера, не буду описывать спецификацию классов и методов интеграции — все это описано на сайтах вендоров.

Далее я кратко опишу, какие изменения нужно сделать в проекте для каждой из платформ.

Android


Как? Я об этом еще не сказал? Для компиляции C++ кода используется Android NDK. Хотя, если Вы Android-разработчик, то и так знаете.

Общая инструкция для интеграции Google Play Services в Android-проект описана на сайте для Android-разработчиков. В своем проекте я использую следующие зависимости:

implementation 'com.google.android.gms:play-services-games:16.0.0'
implementation 'com.google.android.gms:play-services-nearby:16.0.0'
implementation 'com.google.android.gms:play-services-auth:16.0.1'

Изначально была мысль заюзать C++ api, которое поставляется в виде скомпилированных статических библиотек без исходников. Из-за того, что в списке библиотек отсутствует сборка под платформу x86_64, я решил, что ребята из Google не очень-то и следят за актуальностью этого SDK и решил изобрести свой велосипед написать этот слой на Java, обернув его JNI-врапперами. Да и потом, зачем мне лишняя зависимость в виде либ без исходников, которые внутри себя все равно дергают Java? Помимо актуальности Java-классов нужно будет следить еще и за актуальностью этих либ.

В качестве пособия использовал наглядный пример из Google Samples. Спасибо Google за это. Apple, бери пример с Google!

iOS


Для интеграции с Game Center необходимо подключить GameKit framework. Весь слой интеграции с Game Center опишем в одном *.m-файле и интерфейс к нему предоставим через отдельный *.h файл. Так как C++ является подмножеством языка objective-C, то и со сборкой *.cpp и *.m файлов в одном проекте проблем не возникнет.

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

Принцип работы с Multiplayer-слоем


Единая точка входа


Изучив особенности работы с мультиплеерам на обоих платформах, для своего приложения я создал единую C++ асбстракцию и в момент компиляции под нее «подкладывается» соответствующая реализация в зависимости от конкретной платформы. То есть, мое приложение не знает ни о каких Google Play Services, Game Center и их особенностях. Оно знает только предоставленное ей C++ api, где например, есть такие методы, как:

SignIn()           // войти в игровой сервис
SignOut()          // выйти из игрового сервиса
LeaveRoom()        // покинуть комнату игры
SendMessage(...)   // отправить сообщение оппоненту
ShowLeaderboards() // показать доску лидеров
SubmitScore(...)   // зафиксировать набранные очки
...

Поиск соперника


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

Подключение к игровой комнате


Когда два игрока подключились к виртуальной игровой комнате, они получают соответствующие Callback-и. Теперь надо выбрать, кто будет хостом.

Выбора хоста


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



Поскольку у меня всегда в игре только два игрока, то, выходит, у меня частный случай peer-to-peer соединения. И поэтому на роль хоста выпадает только определение начального состояния, а именно — выбор слова, из которого будут составляться слова.

Итак, после того, как произошло подключение игроков к игровой комнате, каждому из игроков известен список идентификаторов участников начавшейся игры. Будем называть его список of participantID. participantID — это некий уникальный строковый идентификатор участника игры, который присваивается сервисом. Нужно выбрать, кто из них будет хостом, донести это до самого хоста и сообщить другому, что в качестве хоста выбран его соперник. Как это сделать?

Выбора хоста на Android

В гугловой доке я не нашел советов по выбору хоста. Молчат, партизаны. Но добрые люди на stackoverflow.com кинули ссылку на видео, где подробно объясняется следующий принцип:

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

Выбора хоста на iOS

Для iOS есть метод chooseBestHostPlayerWithCompletionHandler, который значительно упрощает сценарий выбора хоста по сравнению с тем, что я описал для Android. Но, судя по заметным задержкам во время вызова этого метода, он оценивает параметры отклика сети, меряет ping-и и на основе этой статистики решает, кому быть хостом. Это скорее подходит для приведенной выше клиент-серверной архитектуры, где хост выполняет роль маршрутизатора. В моем же варианте частного peer-to-peer соединения это не имеет смысла и в целях экономии времени я юзаю принцип, аналогичный тому, что я сделал для Android.

Обмен сообщениями между игроками


Что представляет собой сообщение? Сообщение — это массив байт.

  • в языке java это тип:
    byte[]
  • в objective-C это:
    NSData *
  • в C++ я маплю все вышеуказанное на
    std::vector<Uint8>

Существует 2 типа отправки сообщений:

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

Как правило Unreliable доставляется быстрее, чем Reliable. Подробнее можно прочитать на сайте вендоров:


Как мы будем использовать это массив? Очень просто:

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

Итак, определяем enum с типами сообщений, которыми игроки будут обмениваться между собой в процессе игры:

  • Я выбран хостом. Передаю начальное состояние. Сейчас мой ход (параметры: номер версии протокола обмена сообщениями, исходное слово);
  • Ты выбран хостом. Жду от тебя начальное состояние;
  • Я открываю (называю) слово. Теперь твоя очередь (параметр: названное слово);
  • Я сдаюсь. Ты победил;
  • Я не смог составить слово за время хода. Ты победил;
  • Я согласен на реванш;
  • Я выхожу из игры;
  • Ошибка разбора сообщения. Отключаюсь;
  • Твоя версия протокола обмена сообщениями устарела. Проверь обновление приложения. Отключаюсь;
  • Моя версия протокола обмена сообщениями устарела. Нужно проверить обновление. Отключаюсь;
  • Пинг (системное сообщение);

Когда приложение получает входящее сообщение от соперника, вызывается соответствующий Callback, который в свою очередь передает его основному потоку SDL Thread для обработки.

Мониторинг соединения


Игровые сервисы (что у Google, что у Apple) имеют функционал listener-ов, которые в том или ином виде призваны оповещать нас о разрыве связи с соперником. Но, я заметил, что если одного из игрока отключить от интернета, то второй далеко не сразу узнает, что первый отключился и играть-то не с кем. Callback-и в таких случаях не вызываются, либо вызываются по прошествии достаточно долгого времени. Чтобы в этом случае второму игроку не ждать, когда рак на горе свистнет, мне пришлось сделать собственный мониторинг соединения, работающий по принципу:

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

Результат


В результате проделанной работы я получил игру, в которую играю сам со своими друзьями и семьей. Играю как на iOS, так и на Android-е.

Правда на iOS есть нюанс — почему-то не фиксируются очки в Leaderboards, о чем в настоящий момент я веду переписку со службой поддержки Apple.

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