После участия в Ludum Dare 31 у нас появилась игра, в которой можно соревноваться с друзьями и мы решили добавить к ней лидерборд, с авторизацией через Facebook. Какие сложности могут возникнуть и как сделать подобный в своей игре читайте под катом.
Первое что мы сделали — подключили Facebook SDK. Его можно скачать бесплатно с Asset Store. Стоит отметить, что SDK написан достаточно давно и не слишком активно обновляется. В частности для совместимости с Unity 4.6 необходимо кое что поправить. Открываем файл FB.cs и там меняем в 411 строке UNITY_4_5 на UNITY_4_6. Будет работать теперь. Ну или перепишите этот дефайн на правильный, чтобы работал и на 4.5, и на 4.6 и на всех последующих тоже.
Далее нужно создать и настроить приложение на https://developers.facebook.com. После этого вы получаете App ID, который вписываете в настройки через инспектор.
Далее необходимо инициализировать Facebook в Unity. Для этого необходимо вызвать функцию FB.Init().В документации сказано, что ее нужно вызвать один и только один раз при первом запуске игры. И вот тут могут возникнуть первые сложности. Дело в том, что в нее нужно передать 2 колбека. На окончание инициализации и на сворачивание игры библиотекой. Возникает вопрос где эту функцию вызвать. Если у вас вся игра на одной сцене и эта сцена никогда не перезагружается, то проблем, нет. Просто вызываете в Awake() какого-нибудь GameController-а.
В противном случае лучше делать либо статические функции, либо сразу синглтон. Мы использовали вот эту его реализацию. Получилось достаточно просто.
public class SocialController : Singleton<SocialController>
{
public void Awake()
{
FB.Init(FacebookInited, OnHideUnity);
}
private void FacebookInited()
{
Debug.Log("FacebookInited");
if (FB.IsLoggedIn)
{
Debug.Log("Already logged in");
OnLoggedIn();
}
}
private void OnHideUnity(bool isGameShown)
{
Debug.Log("OnHideUnity");
if (!isGameShown)
{
GameController gameController = FindObjectOfType<GameController>();
if (gameController != null)
{
gameController.SetPause();
}
}
}
...
}
После этого можно вызвать какую-нибудь функцию SocialController-а и он автоматически создастся. На любой сцене и не будет уничтожен при переходе по сценам. В функции OnHideUnity() можно поставить игру на паузу, если сейчас идет активный геймплей, а в FacebookInited() можно проверить не залогинен ли пользователь уже(такое бывает при перезапусках игры). Если же пользователь еще не логинился, то эту возможность ему нужно дать. Для этого мы добавили кнопку, которая показана в игре, если пользователь не залогинен(это можно проверить с FB.IsLoggedIn).
public void LoginToFaceBook()
{
if (!FB.IsLoggedIn)
{
FB.Login("user_friends", LoginCallback);
}
}
В функцию FB.Login() передаются необходимые игре разрешения. И вот тут возникает вопрос, а какие разрешения нам нужны? И зависит это от того, что мы хотим от фейсбука. Изначально мы хотели для лидерборда использовать Facebook Scores API. Создано оно специально для лидербордов в играх, причем позиционируется как нечто очень простое. И по началу оно таким и показалось. Мы можем легко получить как свои очки, так и очки своих друзей, причем сразу же отсортированный список. Однако при дальнейшем изучении все оказалось не так просто. Во первых хранить там можно лишь 1 число для каждого пользователя. Так что лидерборд только 1 и только среди своих друзей.
Но хуже всего, что для обновления собственного Score приложение должно получить разрешение publish_actions. А это разрешение подразумевает возможность и пользователю в ленту писать и много чего еще. А еще ваше приложение должно пройти ревью, чтобы иметь возможность просить это разрешение у пользователя. А пользователь может вам его еще и не дать. В итоге получается очень сложно, а возможности минимальны. Так что от такого решения пришлось отказаться.
Что же нам нужно в таком случае от фейсбука:
- Id пользователя
- Имя пользователя
- Список друзей — для реализации лидерборда друзей
Исходя из этого и формируем список разрешений в функции FB.Login(). Нам сейчас нужно только user_friends.
Залогинившись, можно запросить необходимую нам информацию:
void OnLoggedIn()
{
Debug.Log("Logged in. ID: " + FB.UserId);
FB.API("/me?fields=name,friends", Facebook.HttpMethod.GET, FacebookCallback);
}
void FacebookCallback(FBResult result)
{
if (result.Error != null)
{
return;
}
string get_data = result.Text;
var dict = Json.Deserialize(get_data) as IDictionary;
_userName = dict["name"].ToString();
friends = Util.DeserializeJSONFriends(result.Text);
GotUser();
GetBestScoresFriends();
}
Функция Util.DeserializeJSONFriends() взята из официального примера и доступна вот здесь. В итоге в переменной _userName у нас будет имя игрока, а в friends — список его друзей.
Parse
Следующий шаг — сохранение очков и списка игроков. Так как от Facebook Score API мы отказались, то нам потребуется сервер. Самый простой способ его получить — использовать Parse. У него есть библиотека специально для Unity и доступна здесь. Впрочем, библиотека явно создавалась не специально для Unity, а была взята просто .NET версия, что еще вызовет определенные трудности.
Настройка Parse больших сложностей не вызывает, благо официальный гайд написан достаточно хорошо. Отмечу лишь, что Parse Initialize Behaviour стоит добавлять именно к новому объекту, а не к геймконтроллеру, так как с ним объект не будет уничтожаться при перезагрузке сцены.
Parse предоставляет разработчикам большие возможности, но что же может понадобиться нам? Первое что мы думали использовать — это ParseUser — пользователь в терминологии Parse. Его можно создать нового или обновлять существующего. Нужны они для того чтобы унифицировать пользователей разных типов и связывать разные профили одного пользователя. Скажем, если пользователь сначала логинился к вам через email, а потом решил указать еще и аккаунт Facebook. Тогда вы можете добавить информацию о FB аккаунте игрока в его профиль. Однако, повозившись немного с ParseUser мы поняли, что они нам не особо то и нужны, так что дальше мы их оспользовать не будем.
А вот что нам точно понадобится, так это ParseObject. Каждый такой объект по сути — это запись в таблице данных. В какой таблице задается названием при создании объекта. Соответственно пишите new ParseObject(«DataTable») — получите новую запись в таблице DataTable после того как вызовите метод Save(). Получается простой алгоритм для лидерборда. Ищем в таблице запись с текущим пользователем, если не нашли создаем новую. В любом случае у нас будет ParseObject с текущим пользователем. Записываем в него имя игрока, его рекорд и сохраняем.
private void GotUser()
{
var query = ParseObject.GetQuery("GameScore")
.WhereEqualTo("playerFacebookID", FB.UserId);
query.FindAsync().ContinueWith(t =>
{
IEnumerable<ParseObject> result = t.Result;
if (!result.Any())
{
Debug.Log("UserScoreParseObject not found. Create one!");
_userScoreParseObject = new ParseObject("GameScore");
_userScoreParseObject["score"] = 0;
if (string.IsNullOrEmpty(_userName))
_userScoreParseObject["playerName"] = "Player";
else
_userScoreParseObject["playerName"] = _userName;
_userScoreParseObject["playerFacebookID"] = FB.UserId;
_userScoreParseObject.SaveAsync();
}
else
{
Debug.Log("Found score on Parse!");
_userScoreParseObject = result.ElementAt(0);
int score = _userScoreParseObject.Get<int>("score");
if (score > GameController.BestScore)
{
GameController.BestScore = score;
GameController.UpdateBestScore = true;
}
}
});
}
public void SaveScore(int score)
{
if (_userScoreParseObject == null)
return;
Debug.Log("Save new score on Parse! " + score);
int oldScore = _userScoreParseObject.Get<int>("score");
if (score > oldScore)
{
_userScoreParseObject["score"] = score;
_userScoreParseObject.SaveAsync();
}
}
GameController.BestScore и GameController.UpdateBestScore — статические поля класса GameController. Как я уже говорил, Parse изначально не проектировался для Unity, поэтому и логика его работы несколько неудобна. Вместо привычных для Unity программистов корутин здесь используется системный класс Task. Работает он асинхронно и содержимое ContinueWith() у вас будет вызвано в другом потоке. При попытке вызвать какой-то метод MonoBehaviour — получите ошибку. Статические поля — не самый красивый способ обойти эту проблему, но в нашем случае это помогло. Если кто-то из хабра-пользователей расскажет в комментариях нормальный способ вернуться в главный поток приложения(может быть наподобие Looper-а из андроид-программирования) — буду благодарен.
Осталось лишь получить список лучших игроков среди всех и среди друзей. Для этого достаточно лишь составить правильный запрос к Parse.
public void GetBestScoresOverall()
{
var query = ParseObject.GetQuery("GameScore")
.OrderByDescending("score")
.Limit(5);
query.FindAsync().ContinueWith(t =>
{
IEnumerable<ParseObject> result = t.Result;
string leaderboardString = "";
foreach (ParseObject parseObject in result)
{
leaderboardString += parseObject.Get<string>("playerName");
leaderboardString += " - ";
leaderboardString += parseObject.Get<int>("score").ToString();
leaderboardString += "n";
}
GameController.overallLeaderboardString = leaderboardString;
});
}
public void GetBestScoresFriends()
{
if (friends != null && friends.Any())
{
List<string> friendIds = new List<string>();
foreach (Dictionary<string, object> friend in friends)
{
friendIds.Add((string)friend["id"]);
}
if (friendIds.Any())
{
string regexp = FB.UserId;
for (int i = 0; i < friendIds.Count; i++)
{
regexp += "|";
regexp += friendIds[i];
}
var queryFriends = ParseObject.GetQuery("GameScore")
.OrderByDescending("score")
.WhereMatches("playerFacebookID", regexp, "")
.Limit(5);
queryFriends.FindAsync().ContinueWith(t =>
{
IEnumerable<ParseObject> result = t.Result;
string leaderboardString = "";
foreach (ParseObject parseObject in result)
{
leaderboardString += parseObject.Get<string>("playerName");
leaderboardString += " - ";
leaderboardString += parseObject.Get<int>("score").ToString();
leaderboardString += "n";
}
GameController.friendsLeaderboardString = leaderboardString;
});
}
}
}
Если с функцией получения глобального лидерборда все более-менее понятно, то вот написание функции выбора друзей может быть не тривиально. В данном случае мы составляем регулярное выражение, которое выбирает из таблицы нас и наших друзей. Мало того, что функция WhereMatches() по документации может работать медленно, так еще и регулярное выражение может получиться достаточно длинным(зависит от количества друзей, тоже играющих в эту игру). Думаю, этот способ не подойдет для сколько-нибудь крупных проектов, но для небольшой игры пока работает замечательно и проблем не вызывало. Впрочем, буду благодарен, если кто то опишет как в этом случае надо поступать «по уму».
Бонус
После всего этого мы получили рабочий лидерборд с авторизацией через Facebook. А раз уж у нас уже есть авторизация, то почему бы не сделать возможность поделиться результатом с друзьями. Самое классное, что если не делать это автоматически, а предлагать пользователю стандартный диалог, то вам не потребуются специальные разрешения.
public void ShareResults()
{
string socialText = string.Format("I scored {0} in Sentinel. Can you beat it?", GameController.BestScore);
FB.Feed(
link: "https://apps.facebook.com/306586236197672",
linkName: "Sentinel",
linkCaption: "Sentinel @ LudumDare#31",
linkDescription: socialText,
picture: "https://www.dropbox.com/s/nmo2z079w90vnf0/icon.png?dl=1",
callback: LogCallback
);
}
Спасибо, что дочитали до конца. Делайте хорошие игры и подталкивайте игроков к соревнованию с лидербордами.
Кому интересно как это в итоге работает — итоговый результат. А вот здесь можно поиграть в изначальную версию.
Автор: Gasparfx