Как стать автором
Обновить

Прикручиваем мультиплеер к мобильной игре «Составь слова из слова» на iOS и Android, написанной на C++

Время на прочтение8 мин
Количество просмотров4.5K
Ранее я уже писал о своем опыте разработки мобильной словесной игры на 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.

Надеюсь, эта статья будем полезна как и участникам моей команды, так и тем, кто интересуется разработкой мобильных приложений. Спасибо за внимание.
Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+10
Комментарии6

Публикации

Истории

Работа

Программист C++
133 вакансии
Swift разработчик
27 вакансий
QT разработчик
8 вакансий
iOS разработчик
20 вакансий

Ближайшие события