- PVSM.RU - https://www.pvsm.ru -

Простой лидерборд на Unity3D с facebook-ом

После участия в Ludum Dare 31 у нас появилась игра, в которой можно соревноваться с друзьями и мы решили добавить к ней лидерборд, с авторизацией через Facebook. Какие сложности могут возникнуть и как сделать подобный в своей игре читайте под катом.

image

Facebook

Первое что мы сделали — подключили Facebook SDK. Его можно скачать бесплатно с Asset Store [1]. Стоит отметить, что SDK написан достаточно давно и не слишком активно обновляется. В частности для совместимости с Unity 4.6 необходимо кое что поправить. Открываем файл FB.cs и там меняем в 411 строке UNITY_4_5 на UNITY_4_6. Будет работать теперь. Ну или перепишите этот дефайн на правильный, чтобы работал и на 4.5, и на 4.6 и на всех последующих тоже.

Далее нужно создать и настроить приложение на https://developers.facebook.com [2]. После этого вы получаете App ID, который вписываете в настройки через инспектор.

Простой лидерборд на Unity3D с facebook-ом - 2

Далее необходимо инициализировать Facebook в Unity. Для этого необходимо вызвать функцию FB.Init().В документации сказано, что ее нужно вызвать один и только один раз при первом запуске игры. И вот тут могут возникнуть первые сложности. Дело в том, что в нее нужно передать 2 колбека. На окончание инициализации и на сворачивание игры библиотекой. Возникает вопрос где эту функцию вызвать. Если у вас вся игра на одной сцене и эта сцена никогда не перезагружается, то проблем, нет. Просто вызываете в Awake() какого-нибудь GameController-а.

В противном случае лучше делать либо статические функции, либо сразу синглтон. Мы использовали вот эту [3] его реализацию. Получилось достаточно просто.

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 [4]. Создано оно специально для лидербордов в играх, причем позиционируется как нечто очень простое. И по началу оно таким и показалось. Мы можем легко получить как свои очки, так и очки своих друзей, причем сразу же отсортированный список. Однако при дальнейшем изучении все оказалось не так просто. Во первых хранить там можно лишь 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() взята из официального примера и доступна вот здесь [5]. В итоге в переменной _userName у нас будет имя игрока, а в friends — список его друзей.

Parse

Следующий шаг — сохранение очков и списка игроков. Так как от Facebook Score API мы отказались, то нам потребуется сервер. Самый простой способ его получить — использовать Parse [6]. У него есть библиотека специально для Unity и доступна здесь [7]. Впрочем, библиотека явно создавалась не специально для Unity, а была взята просто .NET версия, что еще вызовет определенные трудности.

Настройка Parse больших сложностей не вызывает, благо официальный гайд [8] написан достаточно хорошо. Отмечу лишь, что 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 [9]-а из андроид-программирования) — буду благодарен.

Осталось лишь получить список лучших игроков среди всех и среди друзей. Для этого достаточно лишь составить правильный запрос к 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
        );
    }

Спасибо, что дочитали до конца. Делайте хорошие игры и подталкивайте игроков к соревнованию с лидербордами.

Кому интересно как это в итоге работает — итоговый результат [10]. А вот здесь [11] можно поиграть в изначальную версию.

Автор: Gasparfx

Источник [12]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/facebook/77314

Ссылки в тексте:

[1] Asset Store: https://www.assetstore.unity3d.com/en/#!/content/10989

[2] https://developers.facebook.com: https://developers.facebook.com

[3] эту: http://wiki.unity3d.com/index.php/Singleton

[4] Facebook Scores API: https://developers.facebook.com/docs/games/scores?locale=ru_RU

[5] здесь: https://github.com/fbsamples/friendsmash-unity/blob/master/friendsmash_advanced/Assets/Scripts/Util.cs

[6] Parse: https://www.parse.com/

[7] здесь: https://www.parse.com/docs/downloads

[8] гайд: https://www.parse.com/apps/quickstart#parse_data/unity

[9] Looper: http://developer.android.com/reference/android/os/Looper.html

[10] итоговый результат: https://apps.facebook.com/306586236197672

[11] здесь: http://ludumdare.com/compo/ludum-dare-31/?action=preview&uid=46923

[12] Источник: http://habrahabr.ru/post/245985/