Добавление рекордов с OAuth 2: Laravel Passport + Unity. Часть 2

  • Tutorial


Продолжение статьи про добавление рекордов из игры на сайт от конкретного пользователя. В первой части мы сделали страничку рекордов на Laravel и подготовили API для их добавления — как анонимным, так и авторизированным пользователем. В этой части будем дорабатывать готовую игру на Unity про Крысу на Стене, заходить за свой аккаунт и отправлять рекорды на сайт на Laravel с использованием токена авторизации.

Подготовка


В качестве примера предлагаю воспользоваться моим раннером про крысу с простейшим функционалом — крыса ползёт по стене, а сверху падают сковородки. Скачать проект для Unity 2017.1 можно с гитхаба. При желании можно использовать и любой другой проект, здесь рассматривается только принцип и один из вариантов его реализации.

Также в туториале используется готовый сайт на Laravel из первой части. Скачать его можно здесь. Чтобы сайт был доступен по адресу http://127.0.0.1:8000/, нужно воспользоваться командой:

php artisan serve

Откроем проект в Unity. Базовый игровой процесс выглядит следующим образом.



При нажатии на Play мы сможем управлять крысой, перемещаясь по стене в определенных границах и уклоняясь от падающих сковородок. Слева вверху идёт счётчик очков, внизу — остаток жизней. При нажатии на Esc отображается меню паузы — пустая панелька, на которую нам предстоит добавить форму авторизации. После окончания игру можно перезапустить кнопкой R.

Первым делом займемся добавлением анонимных рекордов.

Анонимные рекорды


Создадим новый скрипт в папке Scripts при помощи команды Create -> C# Script на панели Project. Назовем его WWWScore и откроем получившийся файл WWWScore.cs в используемом вами редакторе для Unity (Visual Studio, MonoDevelop).

Первым делом добавим поле для хранения адреса сервера. Укажем [SerializeField] для того, чтобы можно было изменять эту приватную переменную через панель Inspector в Unity.

[SerializeField]
private string serverURL = "http://127.0.0.1:8000/";

По-умолчанию адрес зададим тем же, что и у нашего сайта на Laravel. При желании его можно будет изменить.

Теперь перейдём к функции добавления рекорда от анонимного пользователя. Эта функция будет отправлять POST-запрос на сервер и дожидаться ответа. Как вариант обработки таких запросов, воспользуемся сопрограммой (Coroutine) для запуска функции параллельно. Функция для использования в сопрограмме будет выглядеть следующим образом:

public IEnumerator AddRecord(int score)
{
	WWWForm form = new WWWForm();
	form.AddField("score", score);

	WWW w = new WWW(serverURL + "api/anonymrecord", form);

	yield return w;

	if(!string.IsNullOrEmpty(w.error)) {
		Debug.Log(w.error);
	} else {
		Debug.Log("Рекорд добавлен!");
	}
}

Мы добавляем данные для POST-запроса (значение переменной score, которую мы будем передавать при вызове сопрограммы из класса GameController), формируем запрос по адресу http://127.0.0.1:8000/api/anonymrecord и ждем результата. Как только приходит ответ от сервера (или заканчивает срок ожидания запроса), в консоли будет выведено сообщение Рекорд добавлен!, или же информация об ошибке в случае неудачи.

Добавим скрипт WWWScore.cs объекту Game Controller через кнопку Add Component на панели Inspector, или же просто перетащив скрипт мышкой на объект.



Теперь отредактируем скрипт GameController.cs, добавив туда вызов сопрограммы.

void Update () {
	if (gameover){
		// Действия, выполняемые только один раз после конца игры до рестарта
		if (!gameoverStarted) {
			gameoverStarted = true; // Существующий код
			restartText.SetActive(true); // Существующий код
			// Отправляем рекорд
			StartCoroutine(GetComponent<WWWScore>().AddRecord(score));
		}
		// ...
	} else {
		// ...
	}
	// ...
}

Сопрограмма вызывается один раз в тот момент, когда игра была закончена — сразу после включения интерфейса рестарта игры. При нажатии на R сцена будет перезапущена, и можно будет опять дойти до конца игры, вызвав добавление рекорда.

Сохраним скрипт и проверим работу игры. Через некоторое время после окончания игры в консоли появится сообщение Рекорд добавлен!



Можно открыть табличку рекордов на сайте и убедиться в том, что запрос действительно был отправлен.



Анонимное добавление рекордов работает. Перейдём к авторизации.

Код авторизации


Добавим функцию авторизации Login(string email, string password) в WWWScore.cs, которую потом будем передавать сопрограмме. Аналогично функции добавления рекордов, она формирует POST-запрос к нашему сайту на Laravel, передавая в нём набор данных по адресу http://127.0.0.1:8000/oauth/token. Необходимый набор данных для авторизации мы рассматривали в первой части статьи.

WWWForm form = new WWWForm();
form.AddField("grant_type", "password");
form.AddField("client_id", "<Client ID>");
form.AddField("client_secret", "<Client Secret>");
form.AddField("username", email); // Параметр функции
form.AddField("password", password); // Параметр функции
form.AddField("scope", "*");

После получения результата запроса необходимо преобразовать данные из json. Это можно сделать с помощью JsonUtility, преобразовав json в объект. Опишем класс объекта в том же файле WWWScore.cs до описания класса WWWScore.

[Serializable]
public class TokenResponse
{
    public string access_token;
}

Как мы помним, в получаемом объекте json будут 4 поля, но нам нужно только поле access_token, его мы и описываем в классе. Теперь можно добавить само конвертирование json в объект.

TokenResponse tokenResponse = JsonUtility.FromJson<TokenResponse>(w.text);

После получения токена авторизации нам нужно сохранить его. Для простоты воспользуемся классом PlayerPrefs, предназначенном как раз для сохранения пользовательских настроек.

PlayerPrefs.SetString("Token", tokenResponse.access_token);

После того, как мы сохранили токен, можно воспользоваться им для добавления рекорда от этого пользователя. Но перед этим мы можем также запросить информацию о текущем пользователе, чтобы отобразить в игре, за какого пользователя осуществлен вход. Для этого вызываем сопрограмму с соответствующей функцией, которой пока ещё нет.

StartCoroutine(GetUserInfo());

Напишем и эту функцию.

Полный код функции Login
[Serializable]
public class TokenResponse
{
    public string access_token;
}

public class WWWScore : MonoBehaviour {
	// ...

    public IEnumerator Login(string email, string password)
    {
        WWWForm form = new WWWForm();
        form.AddField("grant_type", "password");
        form.AddField("client_id", "3");
        form.AddField("client_secret", "W82LfjDg4DpN2gWlg8Y7eNIUrxkOcyPpA3BM0g3s");
        form.AddField("username", email);
        form.AddField("password", password);
        form.AddField("scope", "*");

        WWW w = new WWW(serverURL + "oauth/token", form);

        yield return w;

        if (!string.IsNullOrEmpty(w.error))
        {
            Debug.Log(w.error);
        }
        else
        {
            TokenResponse tokenResponse = JsonUtility.FromJson<TokenResponse>(w.text);

            if (tokenResponse == null)
            {
                Debug.Log("Конвертирование не удалось!");
            }
            else
            {
                // Сохраняем токен в настройках
                PlayerPrefs.SetString("Token", tokenResponse.access_token);
                Debug.Log("Токен установлен!");
                // Запрашиваем имя пользователя
                StartCoroutine(GetUserInfo());
            }
        }
    }
}


Получение информации о пользователе


Нам нужно выполнить GET-запрос по адресу http://127.0.0.1:8000/api/user, прописав в Headers запроса данные авторизации и не передавая при этом никаких других данных в запросе (null).

Dictionary<string, string> headers = new Dictionary<string, string>();
headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
WWW w = new WWW(serverURL + "api/user", null, headers);

Аналогично прошлой функции, в качестве ответа мы получаем json, для разбора которого нужно создать отдельный класс с единственным нужным нам полем из всей структуры json — именем.

[Serializable]
public class UserInfo
{
    public string name;
}

Конвертируем json в объект этого класса.

UserInfo userInfo = JsonUtility.FromJson<UserInfo>(w.text);

Сохраняем имя пользователя в настройках.

PlayerPrefs.SetString("UserName", userInfo.name);

Полный код функции GetUserInfo
// Класс TokenResponse
// ...

[Serializable]
public class UserInfo
{
    public string name;
}

public class WWWScore : MonoBehaviour {
	// ...
	
	// Функция Login
	// ...

    public IEnumerator GetUserInfo()
    {
        Dictionary<string, string> headers = new Dictionary<string, string>();
        headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
        WWW w = new WWW(serverURL + "api/user", null, headers);

        yield return w;

        if (!string.IsNullOrEmpty(w.error))
        {
            Debug.Log(w.error);
        }
        else
        {
            UserInfo userInfo = JsonUtility.FromJson<UserInfo>(w.text);

            if (userInfo == null)
            {
                Debug.Log("Конвертирование не удалось!");
            }
            else
            {
                // Сохраняем токен в настройках
                PlayerPrefs.SetString("UserName", userInfo.name);
                Debug.Log("Имя пользователя установлено!");
            }
        }
    }
}


Изменения в коде добавления рекордов


Чтобы добавлять рекорды от авторизированного пользователя, мы немного изменим код функции AddRecord(int score). Добавим проверку, заполнен ли токен авторизации в настройках, и если да — будем добавлять его в Headers аналогично тому, как это было при получении информации о пользователе, с тем лишь отличием, что мы всё ещё передаём рекорд в данных POST-запроса.

WWW w;
if (PlayerPrefs.HasKey("Token"))
{
	Dictionary<string, string> headers = new Dictionary<string, string>();
	byte[] rawData = form.data;
	headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
	w = new WWW(serverURL + "api/record", rawData, headers);
} else {
	w = new WWW(serverURL + "api/anonymrecord", form);
}

Полный код изменённой функции AddRecord
public IEnumerator AddRecord(int score)
{
	WWWForm form = new WWWForm();
	form.AddField("score", score);

	WWW w;
	if (PlayerPrefs.HasKey("Token"))
	{
		Dictionary<string, string> headers = new Dictionary<string, string>();
		byte[] rawData = form.data;
		headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
		w = new WWW(serverURL + "api/record", rawData, headers);
	} else {
		w = new WWW(serverURL + "api/anonymrecord", form);
	}

	yield return w;

	if(!string.IsNullOrEmpty(w.error)) {
		Debug.Log(w.error);
	} else {
		Debug.Log("Рекорд добавлен!");
	}
}


Код выхода


Чтобы выйти за пользователя из игры, необходимо удалить все данные о нем в настройках. В нашем случае у нас нет никаких других настроек, поэтому мы просто очищаем все настройки. Будьте аккуратнее с этим в своих проектах.

public void Logout()
{
	PlayerPrefs.DeleteAll();
}


Основной контроллер


Теперь подготовим основной контроллер игры (GameController.cs) для работы с авторизацией пользователя. Нам будут нужны объекты с панелью авторизации loginObj и панелью выхода logoutObj, чтобы можно было переключать их. На панели авторизации будут поля ввода для электронного адреса (inputFieldEmail) и для пароля (inputFieldPassword). Также нам будет нужна надпись userNameText для отображения имени пользователя, который зашел за свой аккаунт.

// Объект авторизации
public GameObject loginObj;
// Объект выхода
public GameObject logoutObj;
// Поле E-mail
public GameObject inputFieldEmail;
// Поле Пароль
public GameObject inputFieldPassword;
// Надпись с именем пользователя
public GameObject userNameText;

Для авторизации мы создадим функцию Login(), которая будет вызываться по клику на кнопке Войти, считывать адрес электронной почты с паролем и вызывать сопрограмму с одноименной функцией из WWWScore.cs.

public void Login()
{
	var email = inputFieldEmail.GetComponent<InputField>().text;
	var password = inputFieldPassword.GetComponent<InputField>().text;
	StartCoroutine(GetComponent<WWWScore>().Login(email, password));
}

Функция выхода очень проста — она будет вызываться по клику на кнопке Выйти и вызывать одноименную функцию из WWWScore.cs без каких-либо параметров.

public void Logout()
{
	GetComponent<WWWScore>().Logout();
}

Для переключения видимости панелей авторизации и выхода мы будем проверять, сохранена ли соответствующая настройка в PlayerPrefs и в зависимости от этого отображать нужную панель.

public void SetLoginVisible()
{
	if (PlayerPrefs.HasKey("Token"))
	{
		loginObj.SetActive(false);
		logoutObj.SetActive(true);
	}
	else
	{
		loginObj.SetActive(true);
		logoutObj.SetActive(false);
	}
}

Аналогично, для отображения имени пользователя проверяем настройку имени и если её нет, пишем Аноним.

public void SetUserName()
{
	if (PlayerPrefs.HasKey("UserName"))
	{
		userNameText.GetComponent<Text>().text = PlayerPrefs.GetString("UserName");
	} else
	{
		userNameText.GetComponent<Text>().text = "Аноним";
	}
}

Последние две функции следует вызывать только при изменении соответствующих настроек (и в процессе инициализации), но в рамках этого туториала можно делать это и в функции Update():

void Update () {
	// ...

	// Подсчет результата
	// ...
	
	SetUserName();
	SetLoginVisible();
}

Теперь переходим к визуальной составляющей.

Интерфейс авторизации


Добавим интерфейс авторизации. Поставим галочку Enable панельке Pause, вложенной в объект Canvas. Создадим новый пустой объект (Create Empty), назовём его Login и поместим внутрь панели Pause, на одном уровне с Title (надпись Пауза). Добавим ему компонент Graphic Raycaster (для корректной работы со вложенными элементами).



В этот объект Login добавим два поля ввода, InputFieldEmail и InputFieldPassword (UI -> Input Field), поменяв им текст плейсхолдера для наглядности. Компоненту Input Field у объекта InputFieldEmail сменим тип данных в поле Content Type на Email Address, а у объекта InputFieldPassword — на Password. Добавим кнопку ButtonLogin (UI -> Button) в этот же объект Login. Интерфейс будет выглядеть примерно так (если поиграться со шрифтами и размером компонентов).



Привяжем созданную ранее функцию к событию клика по кнопке ButtonLogin. У компонента Button на панели Inspector нажмём на плюсик у события On Click (), выберем Editor and Runtime из списка (для корректной работы в процессе отладки) и перетянем туда объект Game Controller (мышкой или же выбрав его при клике на кружок выбора у поля объекта). В появившемся после этого выпадающем меню выберем компонент GameController и функцию Login() в нём.



Снимем галочку Enable у объекта Login — его отображение регулируется в GameController.cs.

Интерфейс выхода


Создадим новый объект Logout аналогично объекту Login (не забыв про компонент Graphic Raycaster) вложенным в Pause. Добавим объекту Logout только кнопку ButtonLogout. Аналогично прошлой кнопке, привяжем к событию клика функцию Logout() компонента GameController одноименного объекта.



Снимем галочку Enable у объекта Logout и у самой панели Pause.

Отображение имени пользователя


Добавим текстовый элемент User (UI -> Text) в главный Canvas до элемента Pause, написав в нём Аноним (либо оставив пустым, т.к. надпись будет назначаться в GameController.cs) и поместив в верхний правый угол. Здесь будет отображаться имя авторизированного пользователя.



Назначение объектов контроллеру


Выберем объект GameController. На панели Inspector у компонента Game Controller есть несколько пустых полей, которые мы добавляли в коде ранее. Назначьте им соответствующие объекты, перетащив мышкой из панели Hierarchy или выбрав из списка после нажатия на кружок выбора у поля.



Тестирование


Мы подошли к заключительной части — проверки, что всё работает так, как надо. Запустим игру и нажмём на Esc. Перед нами откроется панель авторизации. Наберём данные зарегистрированного на сайте пользователя (в прошлой статье мы использовали habr@habrahabr.ru / habrahabr).



Нажмём на кнопку Войти. В случае успеха через некоторое время панель авторизации пользователя сменится на панель выхода, оставив только соответствующую кнопку, а вместо Аноним справа вверху будет написано Habr — имя пользователя с сайта.



Теперь, если снова нажать на Esc и поставить рекорд, он будет отправляться от авторизированного пользователя, а не от анонимного.



Это можно проверить, зайдя на страницу рекордов на сайте.



На этом мой первый туториал завершается. Буду рад ответить на вопросы по нему!

Полный код WWWScore.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class TokenResponse
{
    public string access_token;
}

[Serializable]
public class UserInfo
{
    public string name;
}

public class WWWScore : MonoBehaviour {

    [SerializeField]
    private string serverURL = "http://127.0.0.1:8000/";

	public IEnumerator AddRecord(int score)
    {
        WWWForm form = new WWWForm();
        form.AddField("score", score);

        WWW w;
		if (PlayerPrefs.HasKey("Token"))
        {
        	Dictionary<string, string> headers = new Dictionary<string, string>();
        	byte[] rawData = form.data;
        	headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
        	w = new WWW(serverURL + "api/record", rawData, headers);
        } else {
        	w = new WWW(serverURL + "api/anonymrecord", form);
        }

        yield return w;

        if(!string.IsNullOrEmpty(w.error)) {
        	Debug.Log(w.error);
        } else {
        	Debug.Log("Рекорд добавлен!");
        }
    }

    public IEnumerator Login(string email, string password)
    {
        WWWForm form = new WWWForm();
        form.AddField("grant_type", "password");
        form.AddField("client_id", "3"); // Пример заполнения
        form.AddField("client_secret", "W82LfjDg4DpN2gWlg8Y7eNIUrxkOcyPpA3BM0g3s"); // Пример заполнения
        form.AddField("username", email);
        form.AddField("password", password);
        form.AddField("scope", "*");

        WWW w = new WWW(serverURL + "oauth/token", form);

        yield return w;

        if (!string.IsNullOrEmpty(w.error))
        {
            Debug.Log(w.error);
        }
        else
        {
            TokenResponse tokenResponse = JsonUtility.FromJson<TokenResponse>(w.text);

            if (tokenResponse == null)
            {
                Debug.Log("Конвертирование не удалось!");
            }
            else
            {
                // Сохраняем токен в настройках
                PlayerPrefs.SetString("Token", tokenResponse.access_token);
                Debug.Log("Токен установлен!");
                // Запрашиваем имя пользователя
                StartCoroutine(GetUserInfo());
            }
        }
    }

    public IEnumerator GetUserInfo()
    {
        Dictionary<string, string> headers = new Dictionary<string, string>();
        headers.Add("Authorization", "Bearer " + PlayerPrefs.GetString("Token"));
        WWW w = new WWW(serverURL + "api/user", null, headers);

        yield return w;

        if (!string.IsNullOrEmpty(w.error))
        {
            Debug.Log(w.error);
        }
        else
        {
            UserInfo userInfo = JsonUtility.FromJson<UserInfo>(w.text);

            if (userInfo == null)
            {
                Debug.Log("Конвертирование не удалось!");
            }
            else
            {
                // Сохраняем токен в настройках
                PlayerPrefs.SetString("UserName", userInfo.name);
                Debug.Log("Имя пользователя установлено!");
            }
        }
    }

    public void Logout()
    {
        PlayerPrefs.DeleteAll();
    }
}

Полный код GameController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

// Класс сковородки
[System.Serializable]
public class PanClass
{
    // Префаб сковородки
    public GameObject panObj;   
    // Пауза до начала падения сковородок
    public float start;       
    // Пауза между сковородками  
    public float pause; 		
}

public class GameController : MonoBehaviour {

    // Объект сковородки
    public PanClass pan;
    // Точка спавна
    public Vector2 spawnValues;
    // Объект с интерфейсом результата
    public GameObject scoreText;
    // Объект с интерфейсом рестарта игры
    public GameObject restartText;
    // Объект с интерфейсом панели паузы
    public GameObject pausePanel;
    // Время между повышениями результата
    public float scoreRate = 1.0F;
    // Значение, на которое повышается результат
    public int scoreAdd = 10;   
    // Результат
    public static int score;
    // Признак завершения игры
    public static bool gameover;
    // Время до следующего результата
    private float nextScore = 0.0F;
    // Признак того, что единоразовые действия после конца игры были выполнены
    private bool gameoverStarted;

    // Объект авторизации
    public GameObject loginObj;
    // Объект выхода
    public GameObject logoutObj;
    // Поле E-mail
    public GameObject inputFieldEmail;
    // Поле Пароль
    public GameObject inputFieldPassword;
    // Надпись с именем пользователя
    public GameObject userNameText;


    void Start () {
        // Инициализация значений (для рестарта)
        gameover = false;
        score = 0;
        gameoverStarted = false;
        // Запустить падение сковородок
        StartCoroutine(PanSpawn());
    }

    void FixedUpdate()
    {
        if (!gameover)
        {
            // Обновить результат
            scoreText.GetComponent<Text>().text = score.ToString();
        }
    }

    void Update () {
        if (gameover){
            // Действия, выполняемые только один раз после конца игры до рестарта
            if (!gameoverStarted) {
                gameoverStarted = true;
                // Отобразить интерфейс рестарта
                restartText.SetActive(true);
                // Отправляем рекорд
                StartCoroutine(GetComponent<WWWScore>().AddRecord(score));
            }

            // Рестарт по R
            if (Input.GetKey(KeyCode.R))
            {
                // Перезапуск сцены
                SceneManager.LoadScene(0);
            }         
        } else {
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                if (Time.timeScale != 0) {
                    // Поставить на паузу
                    Time.timeScale = 0;
                    pausePanel.SetActive(true);
                } else {
                    // Снять с паузы
                    Time.timeScale = 1;
                    pausePanel.SetActive(false);
                }
            }
        }

        // Подсчет результата
        if (!gameover && (Time.time > nextScore))
        {
            nextScore = Time.time + scoreRate;
            score = score + scoreAdd;
        }

        SetUserName();
        SetLoginVisible();
    }

    // Падение сковородки
    IEnumerator PanSpawn()
    {
        // Пауза до начала падения сковородок
        yield return new WaitForSeconds(pan.start);

        // Бесконечный цикл, до конца игры
        while (!gameover)
        {
            // Генерировать крутящуюся сковородку в случайном месте на определенной высоте
            Vector2 spawnPosition = new Vector2(Random.Range(-spawnValues.x, spawnValues.x), spawnValues.y);
            Quaternion spawnRotation = Quaternion.identity;
            Instantiate(pan.panObj, spawnPosition, spawnRotation);
            yield return new WaitForSeconds(pan.pause);
        }
    }

    // Авторизация
    public void Login()
    {
        var email = inputFieldEmail.GetComponent<InputField>().text;
        var password = inputFieldPassword.GetComponent<InputField>().text;
        StartCoroutine(GetComponent<WWWScore>().Login(email, password));
    }

    // Выход
    public void Logout()
    {
        GetComponent<WWWScore>().Logout();
    }

    // Поменять видимость формы авторизации
    public void SetLoginVisible()
    {
        if (PlayerPrefs.HasKey("Token"))
        {
            loginObj.SetActive(false);
            logoutObj.SetActive(true);
        }
        else
        {
            loginObj.SetActive(true);
            logoutObj.SetActive(false);
        }
    }

    // Установить имя пользователя из настроек
    public void SetUserName()
    {
        if (PlayerPrefs.HasKey("UserName"))
        {
            userNameText.GetComponent<Text>().text = PlayerPrefs.GetString("UserName");
        } else
        {
            userNameText.GetComponent<Text>().text = "Аноним";
        }
    }
}

Первая часть
Готовый проект на Laravel
Базовый проект на Unity (ветка master)
Готовый проект на Unity (ветка final)
  • +10
  • 4,4k
  • 3
Поделиться публикацией

Комментарии 3

    0
    1) Нельзя ли вместо проверки if(game over) в каждом фрейме бросить евент?
    2) По конвенции имена функций в С# начинаются с большой буквы.
      0
      1.) Да, в реальном проекте с ивентами было бы правильнее (как и с добавлением рекордов, я про это писал в одном из пунктов), просто в этом туториале я не стал усложнять.
      2.) Спасибо! Когда скачешь по языкам, некоторые тонкости теряются (да и не такой большой опыт у меня пока с C#). Поправил в статье, буду внимательнее)
      0
      классная статья, спасибо.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое