Pull to refresh

Unity3d: эксперименты с Social Interface

Reading time 10 min
Views 24K
Современную мобильную игру трудно представить без социальной интеграции, общих таблиц рекордов (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 достижений:



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

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

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

Итак, в случае с 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<string, IAchievement> _achievementDict = new Dictionary<string, IAchievement>();
Следующий код нужен только для 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

Вспомогательная функция, которая может пригодится во время тестирования достижений для iOS, возвращает весь список достижений (полученные от сервера + созданные на клиенте):
override public string ToString()
{
	string result = "";

	foreach (KeyValuePair<string, IAchievement> pair in _achievementDict)
	{
		IAchievement achievement = pair.Value;
		result += achievement.id + " " +
			achievement.percentCompleted + " " +
			achievement.completed + " " +
			achievement.lastReportedDate + "\n";
	}
	return result;
}

Подводя итог, работа с достижениями под 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 без собственного или стороннего плагина не обойтись, а интеграция занимает определенное время, львиная часть которого уходит на «борьбу с ветряными мельницами» и не является такой простой, как хотелось бы.
Tags:
Hubs:
+13
Comments 2
Comments Comments 2

Articles