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

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

В общем, настройка макета графического элемента достаточно проста: Выкладываем иконку, добавляем компонент Изображение, выкладываем заголовок, текст для сложности, добавляем компоненты Текст и бла-бла-бла…
А вот с кнопкой придется немного попотеть. Вы должны убедиться, что значок монеты и текст с количеством монет всегда выровнены по центру.

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

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

Во-первых, вам нужно убедиться, что фон экрана не кликабельный. Для этого можно создать отдельный объект и назвать его, например, Антикликер с компонентом Изображение, которое будет растянуто на весь холст. В компоненте Image поставьте галочку Raycast Target = true, и тогда кликать вне окна будет нельзя.
Во-вторых, если вы хотите, чтобы список миссий мог прокручиваться по вертикали, вам нужно добавить компонент ScrollRect, который будет перемещать объект Content в объект Viewport.
В-третьих, объект Viewport должен иметь маску (компонент Mask). Благодаря ему область содержимого миссии, выходящая за пределы окна, не будет видна.
В-четвертых, вам нужно расположить предметы миссии в вертикальном порядке. За это будет отвечать компонент Vertical Layout Group. В Инспекторе компонентов можно задать интервал между элементами списка (параметр Spacing) и выравнивание элементов по определенному краю (параметр Child Alignment).
В результате должно получиться два префаба: контекстный префаб и префаб карты миссии. Надеюсь, получилось схематически объяснить, как можно настроить графический интерфейс для миссий…
Программирование интерфейса
Теперь давайте немного поговорим о том, как можно запрограммировать интерфейс миссии, чтобы он не только работал, но также мог поддерживаться и повторно использоваться в будущем. С точки зрения программирования и архитектуры можно выделить следующие задачи:
-
Отображение актуальной информации о миссии на экране
-
Управление нажатиями пользователей на кнопки интерфейса
-
Управляйте событиями, которые происходят в системе миссий под капотом
Для выполнения следующих задач рекомендуется использовать шаблон Model-View-Presenter. Это позволит вам распределить обязанности в коде таким образом, чтобы поддерживать и повторно использовать пользовательский интерфейс в будущем было максимально удобно.

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

Графический элемент миссии будет включать в себя следующие классы:
-
MissionView — хранит структуру миссии;
-
MissionRewardButton — хранит структуру кнопки вознаграждения;
-
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. С нуля соберем игровой уровень и создадим персонажа.