Привет, Хабр ????

Меня зовут Игорь, и я Unity Developer. В этой статье хотел бы рассказать, как можно сделать миссии в игре на Unity. Статья будет состоять из трех частей. В первой части сделали менеджер для миссий. В этой части попытаюсь схематично показать, как можно реализовать экран с миссиями, и на какие моменты нужно обратить при разработке.

Техническое задание

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

Пример из моего демонстрационного RPG проекта
Пример из моего демонстрационного RPG проекта

Итак, начнем с верстки экрана, затем перейдем к коду...

Верстка интерфейса

Если говорить вкратце, то UI миссий будет состоять из двух префабов: попап миссий и графический элемент миссии.

Структура интерфейса для миссий
Структура интерфейса для миссий

В целом реализация верстки графического элемента довольно проста: Верстаем иконку, добавляем компонент Image, Верстаем заголовок, текст для сложности, добавляем компоненты Text и бла-бла-бла...

А вот с кнопкой придется немного попотеть. Нужно сделать так, чтобы иконка монет и текст с кол-вом монет были всегда выровнены по центру.

Для этого нам потребуется сделать родительский контейнер ContainerPrice и добавить на него компонент Content Size Fitter, который будет растягивать его ширину в зависимости от ширины дочерних элементов, а на иконку и текст с наградой повесить компоненты LayoutElement:

Структура и настройки кнопки награды
Структура и настройки кнопки награды

Теперь давайте поговорим о структуре попапа миссий. В целом тоже ничего сложного в верстке нет, но есть пара нюансов, которые я хотел бы подсветить:

Структура попапа миссий
Структура попапа миссий

Во-первых, нужно сделать так, чтобы задний план экрана не прокликивался. Для этого можно сделать отдельный объект и назвать его, например, Anticlicker с компонентом Image, который будет растянут на весь канвас. В компоненте Image поставим галочку Raycast Target = true, и тогда за пределы окна нельзя будет кликать.

Во-вторых, если вы хотите, чтобы список миссий был скролящимся вертикально, то нужно будет добавить компонент ScrollRect, который будет двигать объект Content в рамках объекта Viewport.

В-третьих, на объекте Viewport должна обязательно маска (компонент Mask). Благодаря нему, область контента миссий, которая выходит за рамки Viewport не будет видна.

В-четвертых, необходимо расположить элементы миссий в вертикальном порядке. За это будет отвечать компонент Vertical Layout Group. В инспекторе компонента можно задать отступ между элементами списка (параметр Spacing) и выравнивание элементов по определенному краю (параметр Child Alignment).

В результате должно получиться два префаба: префаб попапа и префаб карточки миссии. Надеюсь, получилось схематично объяснить, как можно сверстать GUI для миссий...

Программирование интерфейса

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

  1. Отображать актуальную информацию о миссиях на экране

  2. Обрабатывать нажатие пользователя на кнопки интерфейса

  3. Обрабатывать события, которые происходят в системе миссий под капотом

Для выполнения следующих задач, целесообразно будет использовать паттерн Model-View-Presenter. Это позволит распределить ответственности в коде таким образом, чтобы поддержка и повторное использование UI было максимально удобным в будущем.

Применение MVP-паттерна
Применение MVP-паттерна

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

Начнем с отображения одной миссии на экране:

Графический элемент одной миссии будет состоять из следующих классов:

  1. MissionView — хранит структуру одной миссии;

  2. MissionRewardButton — хранит структуру кнопки награды;

  3. MissionProgressBar — хранит структуру прогресса миссии

//Графический элемент одной миссии:
public sealed class MissionView : MonoBehaviour
{
    [SerializeField]
    public Image iconImage; //Иконка
    
    [SerializeField]
    public Text titleText; //Заголовок
    
    [SerializeField]
    public Text difficultyText; //Сложность миссии
    
    [SerializeField]
    public MissionProgressBar progressBar; //Прогресс миссии
    
    [SerializeField]
    public MissionRewardButton rewardButton; //Кнопка с наградой
}


//Структура кнопки с получением награды:
public sealed class MissionRewardButton : MonoBehaviour
{
    [SerializeField]
    private Button button;

    [Space]
    [SerializeField]
    private Image buttonBackground;

    [SerializeField]
    private Sprite availableBackground;

    [SerializeField]
    private Sprite unavailableBackground;

    [SerializeField]
    private GameObject processingText;

    [SerializeField]
    private GameObject getText;

    [SerializeField]
    private Text rewardText;

    public void AddListener(UnityAction action) {
        this.button.onClick.AddListener(action);
    }

    public void RemoveListener(UnityAction action) {
        this.button.onClick.RemoveListener(action);
    }

    public void SetReward(string reward) {
        this.rewardText.text = reward;
    }

    public void SetActive(bool isActive) {
        if (isActive) {
            this.button.interactable = true;
            this.buttonBackground.sprite = this.availableBackground;
            this.getText.SetActive(true);
            this.processingText.SetActive(false);
        } else {
            this.button.interactable = false;
            this.buttonBackground.sprite = this.unavailableBackground;
            this.getText.SetActive(false);
            this.processingText.SetActive(true);
        }
    }
}


//Структура прогресс бара:
public sealed class MissionProgressBar : MonoBehaviour
{
    [SerializeField]
    private Text text;

    [SerializeField]
    private Color completeTextColor;

    [SerializeField]
    private Color processingTextColor;

    [Space]
    [SerializeField]
    private Image fill;

    [SerializeField]
    private Color completeFillColor;

    [SerializeField]
    private Color progressFillColor;
    
    public void SetProgress(float progress, string text)
    {
        this.fill.fillAmount = progress;
        this.text.text = text;

        if (progress >= 1)
        {
            this.text.color = this.completeTextColor;
            this.fill.color = this.completeFillColor;
        }
        else
        {
            this.text.color = this.processingTextColor;
            this.fill.color = this.progressFillColor;
        }
    }
}

Таким образом, класс MissionView вместе с вспомогательными классами отвечает за отрисовку информации о миссии и обрабатывает нажатие на кнопку награды.

Структура интерфейса одной миссии
Структура интерфейса одной миссии

Теперь напишем класс MissionPresenter, который будет взаимодействовать с графическим элементом на экране и моделью миссии под капотом:

public sealed class MissionPresenter
{
    private Mission mission; //Объект миссии под капотом
    private MissionView view; //Объект миссии на экране

    private MissionsManager missionsManager;

    public MissionPresenter(Mission mission, MissionView view) 
    {
        this.mission = mission;
        this.view = view;
        this.missionsManager = MissionsManager.Instance; //Лучше использовать DI
    } 

    //Метод активации презентера:
    public void Start()
    {
        this.view.titleText.text = this.mission.Title;
        this.view.difficultyText.text = this.mission.Difficulty.ToString();
        this.view.iconImage.icon = this.mission.Icon;
        
        this.view.rewardButton.SetReward(this.mission.MoneyReward.ToString());
        this.view.rewardButton.SetActive(this.mission.IsCompleted);  
        this.view.rewardButton.AddListener(this.OnButtonClicked);

        this.view.gameObject.SetActive(true);
        
        this.mission.OnProgressChanged += this.OnMissionProgressChanged;
        this.mission.OnCompleted += this.OnMissionCompleted;

        this.UpdateProgressbar();
    }

    //Метод деактивации презентера:
    public void Stop()
    {
        this.view.rewardButton.RemoveListener(this.OnButtonClicked);
        this.view.gameObject.SetActive(false);

        this.mission.OnProgressChanged -= this.OnMissionProgressChanged;
        this.mission.OnCompleted -= this.OnMissionCompleted;
    }

    //Обработка нажатия кнопки с наградой:
    private void OnButtonClicked()
    {
        if (this.missionsManager.CanReceiveReward(this.mission))
        {
            this.missionsManager.ReceiveReward(this.mission);
        }
    }

    //Обработка изменения прогресса миссии:
    private void OnMissionProgressChanged(Mission mission)
    {
        this.UpdateProgressBar();
    }

    //Обработка завершения миссии:
    private void OnMissionCompleted(Mission mission)
    {
        this.view.rewardButton.SetActive(true);
    }

    private void UpdateProgressBar()
    {
        var progress = this.mission.GetProgress();
        var text = this.mission.GetTextProgress();
        this.view.ProgressBar.SetProgress(progress, text);
    }
}

Теперь давайте поговорим о том, как можно отобразить список миссий на экране, имея классы MissionView & MissionPresenter. Поскольку информация и кол-во миссий храниться в менеджере миссий, то мы можем сделать адаптер MissionListAdapter, который будет обращаться туда и отрисовывать миссии в виде списка на экране:

public sealed class MissionListAdapter : MonoBehaviour
{
    [SerializeField]
    private Item[] missionItems; //Графические элементы для миссий

    //Метод "Показать список миссиий"
    public void Show()
    {
        MissionsManager.Instance.OnMissionChanged += this.OnMissionChanged;

        //Отрисовка миссий на UI:
        var missions = MissionsManager.Instance.GetMissions();
        for (int i = 0, count = missions.Length; i < count; i++)
        {
            var mission = missions[i];
            var item = this.GetItem(mission.Difficulty);
            var presenter = new MissionPresenter(mission, item.view);
            presenter.Start(mission);

            item.presenter = presenter;
        }
    }

    //Метод "Скрыть список миссиий"
    public void Hide()
    {
        MissionsManager.Instance.OnMissionChanged -= this.OnMissionChanged;

        for (int i = 0, count = this.missionItems.Length; i < count; i++)
        {
            var item = this.missionItems[i];
            var presenter = item.presenter;
            presenter.Stop();
            
            item.presenter = null;
        }
    }

    //Перерисовка GUI миссии, если миссия в системе поменялась:
    private void OnMissionChanged(Mission mission)
    {
        var item = this.GetItem(mission.Difficulty);
        if (item.presenter != null)
        {
            item.presenter.Stop();
        }

        var presenter = new MissionPresenter(mission, item.view);
        presenter.Start(mission);

        item.presenter = presenter;
    }

    //Поиск GUI элемента миссии по типу сложности:
    private Item GetItem(MissionDifficulty difficulty)
    {
        for (int i = 0, count = this.missionItems.Length; i < count; i++)
        {
            var item = this.missionItems[i];
            if (item.difficulty == difficulty)
            {
                return item;
            }
        }

        throw new Exception($"Mission with difficulty {difficulty} is not found"!);
    }

     //Вспомогательная структура, которая сопоставляет View и Presenter
    [Serializable]
    private sealed class Item
    {    
        [SerializeField]
        public MissionDifficulty difficulty;
        
        [SerializeField]
        public MissionView view;
  
        public MissionPresenter presenter;
    }    
}

Затем напишем код для попапа, класс MissionPopup, который будет хранить в себе адаптер для списка миссий и кнопку "Закрыть":

public sealed class MissionsPopup : MonoBehaviour 
{
    [SerializeField]
    private MissionListAdapter missionsAdapter;  

    [SerializeField]
    private Button closeButton;

    //Метод "Показать попап"
    public void Show()
    {
        this.gameObject.SetActive(true);
        this.missionsAdapter.Show();
        this.closeButton.onClick.AddListener(this.OnCloseClicked);
    }

    //Метод "Скрыть попап"
    public void Hide()
    {
        this.gameObject.SetActive(false);
        this.missionsAdapter.Hide();
        this.closeButton.onClick.AddListener(this.OnCloseClicked);
    }

    private void OnCloseClicked()
    {
        this.Hide();
    }
}

В завершение подключаем скрипты MissionsPopup & MissionListAdapter к верстке

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

Надеюсь, у меня получилось схематично описать, как можно реализовать GUI для миссий. В следующей части поговорим о том, как можно сохранять данные. Спасибо за внимание :)

Напоследок хочу пригласить вас на бесплатный урок, на котором начнём создание Top Down игры с нуля на Unity. Посмотрим на Unity Asset Store и другие сайты с графикой, определим дизайн будущей игры. Рассмотрим ассет Top Down Engine. С нуля соберем игровой уровень и создадим персонажа.

Комментарии (2)


  1. pauldyatlov
    13.06.2023 05:24
    +1

        [SerializeField]
        public Image iconImage; //Иконка

    Почему модификатор доступа public, если есть атрибут SerializeField?

        //Метод "Скрыть попап"
        public void Hide()
        {
            this.closeButton.onClick.AddListener(this.OnCloseClicked);
        }

    То есть, в методе Hide еще раз происходит подписка на OnCloseClicked?

        public void SetActive(bool isActive) {
            if (isActive) {
                this.button.interactable = true;
                this.buttonBackground.sprite = this.availableBackground;
                this.getText.SetActive(true);
                this.processingText.SetActive(false);
            } else {
                this.button.interactable = false;
                this.buttonBackground.sprite = this.unavailableBackground;
                this.getText.SetActive(false);
                this.processingText.SetActive(true);
            }
        }

    Зачем такое дублирование кода?

    button.interactible = isActive;
    getText.SetActive(isAcitve);
    ...

    В этом коде много проблемных мест, я бы порекомендовал автору самому почитать о лучших практиках, прежде чем учить этому других


  1. Sagrell
    13.06.2023 05:24

    Для того, чтоб решить задачу по отображению текста + иконки по центру не нужен ContentSizeFitter, более того, он создает приличный оверхед, тк в подобном виджете он может пересчитываться очень часто, не в этом случае, но что если мы захотим его использовать с иконкой часов и отображать там время. Текст умеет автоматически выставлять свой размер если на паренте весит лейаут группа.
    Также интересно, что в MissionPresenter был относительно неплохо соблюден подход MVP, который сразу после куда-то исчез и превратился в монобех вьюхи которые ссылаются на все и вся и делают все, то есть тут вся суть паттерна и в целом архитектурные принципы нарушены.

    С одной стороны хочется сказать, что подобных статей не хватает и было бы круто, чтобы люди шарили знания по вопросам проектирования и архитектуры, этого очень недостает. За что автору хочется выразить благодарность.
    Но с другой стороны конкретно в этой серии "уроков" автор не только пытается обучать, но и курсы рекламирует, что уже сильно смущает, т.к. качество решений в предыдущем и даже, что удивительно, текущем уроке находится на очень слабом уровне и я считаю вредно обучать людей таким решениям.