Привет, Хабр ????
Меня зовут Игорь, и я Unity Developer. В этой статье хотел бы рассказать, как можно сделать миссии в игре на Unity. Статья будет состоять из трех частей. В первой части сделали менеджер для миссий. В этой части попытаюсь схематично показать, как можно реализовать экран с миссиями, и на какие моменты нужно обратить при разработке.
Техническое задание
В первой части мы рассматривали абстрактную игру, в которой нужно выполнять различные задания. Когда квест выполняется, то игрок может получить награду в виде монет.
Итак, начнем с верстки экрана, затем перейдем к коду...
Верстка интерфейса
Если говорить вкратце, то 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 для миссий...
Программирование интерфейса
Давайте теперь немного поговорим о том, как можно запрограммировать интерфейс миссий, чтобы он не только работал, но и его можно было поддерживать и переиспользовать в будущем. С точки зрения программирования и архитектуры можно выделить следующие задачи:
Отображать актуальную информацию о миссиях на экране
Обрабатывать нажатие пользователя на кнопки интерфейса
Обрабатывать события, которые происходят в системе миссий под капотом
Для выполнения следующих задач, целесообразно будет использовать паттерн Model-View-Presenter. Это позволит распределить ответственности в коде таким образом, чтобы поддержка и повторное использование UI было максимально удобным в будущем.
Итак, 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. С нуля соберем игровой уровень и создадим персонажа.
Комментарии (2)
Sagrell
13.06.2023 05:24Для того, чтоб решить задачу по отображению текста + иконки по центру не нужен ContentSizeFitter, более того, он создает приличный оверхед, тк в подобном виджете он может пересчитываться очень часто, не в этом случае, но что если мы захотим его использовать с иконкой часов и отображать там время. Текст умеет автоматически выставлять свой размер если на паренте весит лейаут группа.
Также интересно, что в MissionPresenter был относительно неплохо соблюден подход MVP, который сразу после куда-то исчез и превратился в монобех вьюхи которые ссылаются на все и вся и делают все, то есть тут вся суть паттерна и в целом архитектурные принципы нарушены.
С одной стороны хочется сказать, что подобных статей не хватает и было бы круто, чтобы люди шарили знания по вопросам проектирования и архитектуры, этого очень недостает. За что автору хочется выразить благодарность.
Но с другой стороны конкретно в этой серии "уроков" автор не только пытается обучать, но и курсы рекламирует, что уже сильно смущает, т.к. качество решений в предыдущем и даже, что удивительно, текущем уроке находится на очень слабом уровне и я считаю вредно обучать людей таким решениям.
pauldyatlov
Почему модификатор доступа public, если есть атрибут SerializeField?
То есть, в методе Hide еще раз происходит подписка на OnCloseClicked?
Зачем такое дублирование кода?
В этом коде много проблемных мест, я бы порекомендовал автору самому почитать о лучших практиках, прежде чем учить этому других