[Из песочницы] Unity3d: эксперименты с Social Interface

Современную мобильную игру трудно представить без социальной интеграции, общих таблиц рекордов (leaderboards) и достижений (achievements). Дабы не отставать от тенденций, решил интегрировать Game Center и Play Services для iOS и Android версий моей игры.Так как я разрабатываю игру в свободное время в качестве хобби, то мысли о покупке плагинов, например, prime31, были отброшены сразу. Выбор пал на интерфейс Social, который входит в состав Unity. Вокруг этого пакета чувствуется интрига: практическое отсутствие справочной информации наталкивает на две мысли: либо интерфейс очень прост, либо не пригоден к использованию. Итак, пришло время в этом разобраться.Прежде всего оказалось, что интерфейс этот имеет реализацию только под iOS, а для Android — это, действительно, интерфейс в чистом виде.

Нежелание покупать плагины и желание добавить таблицу рекордов привели меня сюда: https://github.com/playgameservices/play-games-plugin-for-unity. Это бесплатный плагин под Android от Google, который наполняет интерфейс Social живительной реализацией и сохраняет толщину кошелька на прежнем уровне. Плагин имеет пугающую версию 0.9, однако на его работоспособности это не сказывается, но отсутствует часть функционала, о которой речь пойдет дальше.

Полный решимости и веры в успех я начал подготавливать проекты в iTunes Connect и Google Developer Console — на этом этапе никаких проблем не возникает, обе платформы имеют практически идентичные настройки таблиц рекордов и достижений, а обилие справочной информации не дает сбиться с пути.

Есть пара моментов, на которые стоит обратить внимание:

Google Developer Console генерирует идентификаторы достижений и лидербордов сам, а в iTunes Connect их нужно задавать самостоятельно, поэтому для большей совместимости будущего кода удобно начать с Google, а затем по образу и подобию настроить проект под iOS, копируя те же идентификаторы.

При работе с Play Services в Google Developer Console, а также при добавлении альфа/бета версий игры, Google настойчиво предлагает сделать «паблишинг» достижений и лидербордов — на это не стоит соглашаться до самого релиза, т.к. после «паблишинга» вы лишаетесь возможности удалять достижения и таблицы рекордов, а также редактировать такие важные параметры, как кол-во шагов, необходимых для выполнения итеративных достижений.

Я создал лидерборды «High Scores» и минимальный набор достижений (для Google — это пять позиций) так что, даже если вы не собираетесь их использовать — придется из себя что-то выжать. У Apple такого ограничения нет, но раз уж достижения созданы — нет ничего сложного в том, чтобы их скопировать.

Далее устанавливаем плагин для Android. В меню Unity выбираем Assets/Import Package/Custom Package и разворачиваем плагин в свой проект. После успешного импорта в меню появляется пункт Google Play Games, выбираем подпункт Android Setup…, вводим идентификатор приложения, который можно найти в разделе Game Services Google Developer Console и получаем плагин, готовый к использованию.

Теперь все готово к тому, чтобы написать пару строк кода (C#) в Unity. Прежде всего нужно сделать предварительные настройки для iOS и Android, а также авторизироваться:

#if UNITY_ANDROID // активируем плагин Google Play Games, если приложение собирается под Android, // таким образом интерфейс Social получает его реализацию GooglePlayGames.PlayGamesPlatform.Activate (); #endif

#if UNITY_IPHONE // по умолчании при получении достижения под iOS ничего не происходит, чтобы игрок видел стандартное сообщение о получении достижения нужно вызвать эту функцию UnityEngine.SocialPlatforms.GameCenter.GameCenterPlatform.ShowDefaultAchievementCompletionBanner (true); #endif

Social.localUser.Authenticate (onProcessAuthentication); // функция вызывается, когда завершается авторизация // если операция проходит успешно, Social.localUser будет содержать данные сервера private void onProcessAuthentication (bool success) { Debug.Log («onProcessAuthentication:» + success); } После успешной авторизации мы можем работать с лидербордами и достижениями.При работе с лидербордами я решил, что мне нужно прежде всего получить текущий рекорд игрока — это нужно, чтобы можно было сравнивать старый рекорд с новым и если игрок достигает нового топа, выводить об этом сообщение «Congratulations! New Top: XXX». Для этого я написал следующий код, который создает таблицу, устанавливает фильтр игроков, по которым нам нужны данные (только наш игрок), и получает текущий рекорд игрока в случае успеха:

string[] userIds = new string[] { Social.localUser.id }; highScoresBoard = Social.CreateLeaderboard (); highScoresBoard.id = «LEADERBOARD_ID»; highScoresBoard.SetUserFilter (userIds); highScoresBoard.LoadScores (onLeaderboardLoadComplete);

private void onLeaderboardLoadComplete (bool success) { Debug.Log («onLeaderboardLoadComplete:» + success); if (success) { long score = highScoresBoard.localUserScore.value; } } Отправка текущего прогресса выглядит следующим образом (при чем, нам не обязательно заботится о том, что новый результат может быть меньше старого — в этом случае данные будут отброшены сервером): public void reportScore (long score) { if (Social.localUser.authenticated) { Social.ReportScore (score, «LEADERBOARD_ID», onReportScore); } } private void onReportScore (bool result) { Debug.Log («onReportScore:» + success); } После тестирования этого кода появилась проблема — он не работает под Android, т.к. в плагине нет реализации этой функции — вот она, прелесть версии 0.9.Но это не повод для расстройств, поразмыслив, я пришел к выводу, что нет необходимости получать текущий рекорд игрока, достаточно хранить его локально. Дело в том, что если игрок поменяет устройство, или просто удалит вашу игру и вернется в нее спустя какое-то время, ему будет приятнее заново раз за разом бить свой собственный локальный рекорд. Рекорд же в лидербордах будет всегда содержать максимальный прогресс игрока, и чтобы его побить у игрока может уйти много времени, что в конечном счете может снизить мотивацию игрока. Так что я решил отказаться от глобального рекорда, и дописал следующий код к авторизации:

public long highScore = 0;

private void onProcessAuthentication (bool success) { Debug.Log («onProcessAuthentication:» + success);

if (success) { if (PlayerPrefs.HasKey («high_score»)) highScore = (long)PlayerPrefs.GetInt («high_score»); } } Отправка прогресса на сервер приняла вид: public void reportScore (long score) { if (Social.localUser.authenticated) { if (score > highScore) { highScore = score; PlayerPrefs.SetInt («high_score», (int)score);

Social.ReportScore (score, «LEADERBOARD_ID», onReportScore); } } } Таким образом мы запоминаем локальный рекорд игрока, который затем можно использовать для проверки достижения нового рекорда.Осталось вывести стандартный диалог лидербордов, что можно сделать с помощью функции Social.ShowLeaderboardUI (). По умолчанию для Android отображается список всех лидербордов, даже если он у вас один (таблица «High Scores»), это не очень красиво и требует лишнего выбора от игрока, поэтому пришлось дописать такой код:

#if UNITY_ANDROID (Social.Active as GooglePlayGames.PlayGamesPlatform).SetDefaultLeaderboardForUI («LEADERBOARD_ID»); #endif Social.ShowLeaderboardUI (); Разобравшись с таблицами рекордов и довольный результатом я приступил к реализации достижений, и тут меня ждал большой и неприятный сюрприз, но давайте по порядку.Достижения есть двух типов: «одноходовые» (achievement) и инкрементируемые (incremental achievement). Первые подразумевают достижение с одного раза, например «запустить ракету» — как только игрок нашел и запустил одну ракету, мы считаем, что достижение выполнено на 100% и открываем его игроку. Инкрементируемые достижения подразумевают пошаговое выполнение в несколько этапов, например, достижение «Охотник за вишенками» подразумевает сбор 15 вишенок, в процессе чего игроку будет постепенно открываться достижение, а после сбора всех 15 вишенок он получит его полностью. Такие достижения мне показались более уместными в моей игре; для начала, я добавил 5 достижений:

d2892bc47a434d839af544bf03f3ea9e.jpg

Приступив к реализации инкрементируемых достижений я столкнулся с двумя проблемами: — Разница во взаимодействии с Android и iOS серверами; — Нужно хранить текущий прогресс по достижению, чтобы каждый раз слать увеличенное значение, иначе достижение не будет расти.

Разница во взаимодействии состоит в том, что Google Play рассчитывает процентное приращение достижения сам, указав в Google Developer Console кол-во шагов 15, мы можем каждый раз отправлять на сервер значение 1, и серверная логика будет складывать единицы до тех пор, пока не наберется 15 и достижение не будет открыто.

Apple Game Center перекладывает заботу о приращении прогресса по достижению на логику клиента, и ждет от нас постепенного увеличение прогресса в пределах от 0 до 100 единиц (процентов). Поэтому если мы будем слать ему постоянно 1, то прогресс постоянно будет 0,01%.

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

public class AchievementData { public string id; public int steps; public AchievementData (string id, int steps) { this.id = id; this.steps = steps; } } И подготовил данные по своим достижениям (фактически это копия данных, которые я ввел в Google Developer Console для Android): // описываем все возможные достижения — их идентификаторы и кол-во итераций для достижения public static readonly AchievementData cherryHunter = new AchievementData («ACHIEVEMENT_ID», 15); public static readonly AchievementData bananaHunter = new AchievementData («ACHIEVEMENT_ID», 25); public static readonly AchievementData strawberryHunter = new AchievementData («ACHIEVEMENT_ID», 50); public static readonly AchievementData rocketRider = new AchievementData («ACHIEVEMENT_ID», 15); public static readonly AchievementData climberHero = new AchievementData («ACHIEVEMENT_ID», 250);

// массив всех возможных достижений private readonly AchievementData[] _achievements = { cherryHunter, bananaHunter, strawberryHunter, rocketRider, climberHero };

// таблица достижений игрока, заполняется основываясь на результатах от сервера private Dictionary _achievementDict = new Dictionary(); Следующий код нужен только для iOS:

if (Application.platform == RuntimePlatform.IPhonePlayer) { Social.LoadAchievements (onAchievementsLoadComplete); }

private void onAchievementsLoadComplete (IAchievement[] achievements) { // заносим в таблицу достижения, по которым у игрока уже есть прогресс foreach (IAchievement achievement in achievements) { _achievementDict.Add (achievement.id, achievement); } // создаем остальные достижения, по которым у игрока еще нет прогресса for (int i = 0; i < _achievements.Length; i++) { AchievementData achievementData = _achievements[i];

if (_achievementDict.ContainsKey (achievementData.id) == false) { IAchievement achievement = Social.CreateAchievement (); achievement.id = achievementData.id;

_achievementDict.Add (achievement.id, achievement); } } } Важно обратить внимание, что пока по достижениям нет прогресса, будет приходить пустой список — это не баг, в этом массиве приходят только достижения, по которым у игрока уже есть прогресс больше 0, поэтому после получения списка имеющихся достижений «заполняем пробелы» по остальным достижениям (с прогрессом 0), чтобы в дальнейшем работать со всеми достижениями по одному принципу.Отправка прогресса по достижению отличается для обоих платформ:

public void reportProgress (string id) { if (Social.localUser.authenticated) { #if UNITY_ANDROID (Social.Active as GooglePlayGames.PlayGamesPlatform).IncrementAchievement (id, 1, onReportProgressComplete); #elif UNITY_IPHONE IAchievement achievement = getAchievement (id); // нормализуем значение в рамках 0 — 100 achievement.percentCompleted += 100.0 / getAchievementData (id).steps; achievement.ReportProgress (onReportProgressComplete); #endif } } В нем используются две вспомогательные функции: // возможность получить данные по достижению за пределами класса public IAchievement getAchievement (string id) { return _achievementDict[id]; } // возможность получить вспомогательные данные по достижению, которые нам нужны при расчете прогресса для iOS и которые мы специально храним на клиенте (массив всех возможных достижений) public AchievementData getAchievementData (string id) { for (int i = 0; i < _achievements.Length; i++) { AchievementData achievementData = _achievements[i]; if (achievementData.id == id) return achievementData; }

return null; } Чтобы отобразить стандартный диалог достижений, воспользуемся функцией: #if UNITY_ANDROID || UNITY_IPHONE Social.ShowAchievementsUI (); #endif Подводя итог, работа с достижениями под Android проще. В случае с iOS нужно больше всего контролировать на стороне клиента. В этом есть только один плюс — большая гибкость под iOS, за что приходится платить временными затратами.

Так как под Android пришлось использовать сторонний плагин, то я начал проверять написанную логику именно с него. Убедившись, что все работает окей, я решил быстренько проверить логику на iPad и подготовить релизы игры. И тут меня ждал тот самый неприятный сюрприз, который всплыл, когда его меньше всего ожидаешь: функция отправки прогресса для iOS постоянно возвращала false и загадочную строку:

Looking for «ACHIEVEMENT_ID», cache count is 1.

Почитав форумы и вдоволь наэкспериментировавшись, я понял, что достижения под iOS мне не светят, и что это какой-то баг Unity или Game Center. Следующим утром, пребывая в прескверном настроении, я запустил игру на iPad и с удивлением обнаружил, что достижения корректно обрабатываются. Вечером же ситуация повторилась снова. Поразмыслив, я пришел к выводу, что проблема может быть связана с этим: транзакции песочницы имеют намного меньший приоритет, чем игр в сторе, поэтому в «час пик», когда в Америке день, практически ни один прогресс по достижению не выполняется, но если попробовать обновить прогресс достижения, когда в Америке глубокая ночь, и сервера Apple «отдыхают» в ночной прохладе калифорнийской ночи, то практически все достижения обрабатываются. А сообщение «Looking for «ACHIEVEMENT_ID», cache count is 1.» означает, что в настоящее время отправить прогресс не удается, и Unity кэширует прогресс по достижению локально. Этот прогресс не будет потерян, и отправится на сервер, когда будет возможность установить с ним связь.

Против этой теории выступает тот факт, что разработчики, использующие prime31-плагин для этих целей таких «задержек» не испытывают, и что вероятнее всего проблема именно в Unity. Я решил рискнуть и выдать игру с достижениями в таком «подвешенном» состояли, чтобы проверить свою теорию.

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

Подводя итог: для интеграции достижений и лидербордов в Unity без собственного или стороннего плагина не обойтись, а интеграция занимает определенное время, львиная часть которого уходит на «борьбу с ветряными мельницами» и не является такой простой, как хотелось бы.

© Habrahabr.ru