Продолжаю цикл статей, посвящённый концепции MVC.

В прошлой статье я рассмотрел одну из реализаций MVC – MVO. Если ещё не читали, рекомендую сначала ознакомиться с ней, чтобы иметь контекст происходящего 

Продолжим решать задачи с предыдущей статьи. У нас есть панель статистики, в которой указаны наши HP. Мы не реализовали функционал показа и скрытия этого окна через UI. 

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

Сначала добавим их на сцену. Получается следующая иерархия

Возьмем «схему» с прошлой статьи.

Полная "схема" MVC
Полная "схема" MVC

В прошлый раз мы убрали некоторые связи из-за их отсутствия в нашей логике.

"Схема" нашей логики
"Схема" нашей логики

Сейчас появился пользовательский ввод. Давайте восстановим связи. У нас появилась связь UserAction между View и Controller (в нашем случае Observer).

Теперь в View мы добавим кнопки и выведем ивенты соответствующих нажатий для других классов.

//HealthView

[SerializeField] private Button _openStatisticsButton; 
[SerializeField] private Button _closeStatisticsButton;

public Button.ButtonClickedEvent OnOpenClicked => _openStatisticsButton.onClick; 
public Button.ButtonClickedEvent OnCloseClicked => _closeStatisticsButton.onClick;

public void SetHealthText(string text) => _healthText.SetText(text);
public void SetHealthBarFillAmount(float fillAmount) => _healthBar.fillAmount = fillAmount;

public void SetStatisticsText(string text) => _statisticsText.SetText(text);
public void CloseStatisticsPanel() => _statisticsPanel.SetActive(false);
public void OpenStatisticsPanel() => _statisticsPanel.SetActive(true);

public void CloseHealthPanel() => _healthPanel.SetActive(false);
public void OpenHealthPanel() => _healthPanel.SetActive(true);

public void SetActiveOpenStatisticsButton(bool isActive) =>
    _openStatisticsButton.gameObject.SetActive(isActive);

Пока просто в HealthViewObserver обработаем этот ввод.

//HealthViewObserver
private void OnEnable()
{
    _healthModel.OnHealthChanged += OnHealthChanged;
    _healthView.OnOpenClicked.AddListener(OnOpenStatisticClicked);
    _healthView.OnCloseClicked.AddListener(OnCloseStatisticClicked);
}

private void OnDisable()
{
    _healthModel.OnHealthChanged -= OnHealthChanged;
    _healthView.OnOpenClicked.RemoveListener(OnOpenStatisticClicked);
    _healthView.OnCloseClicked.RemoveListener(OnCloseStatisticClicked);
}

private void OnCloseStatisticClicked() => _healthView.CloseStatisticsPanel();

private void OnOpenStatisticClicked() => _healthView.OpenStatisticsPanel();

Протестируем. Логика работает. Только кнопка для открытия не исчезает при нажатии. Давайте также поправим. Добавим соответствующие методы для этой кнопки в HealthView. Изменения сделаем и в HealthViewObserver.

//HealthView

public void SetActiveOpenStatisticsButton(bool isActive) =>
    _openStatisticsButton.gameObject.SetActive(isActive);
//HealthViewObserver

private void OnCloseStatisticClicked()
{
    _healthView.CloseStatisticsPanel();
    _healthView.SetActiveOpenStatisticsButton(true);
}

private void OnOpenStatisticClicked()
{
    _healthView.OpenStatisticsPanel();
    _healthView.SetActiveOpenStatisticsButton(false);
}

Теперь всё работает как нужно. Тестовые методы CloseStatistics и OpenStatistics можно убрать

//HealthViewObserver

[Button]
private void CloseStatistics()
{
    _healthView.CloseStatisticsPanel();
}

[Button]
private void OpenStatistics()
{
    _healthView.OpenStatisticsPanel();
}

Давайте теперь взглянем на наши связи. Теперь у нас есть связь между View и Observer.

Давайте заметим, что при реализации данной связи мы никак не задействовали нашу модель с Health. В прошлой статье мы уже видели это преимущество, когда не затрагивая модель, мы реализовали различные представления этой модели. В данном случае мы реализовали логику, связанную только с Statistics. Давайте ее выделим в коде HealthView

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class HealthView : MonoBehaviour
{
//Health logic
    [SerializeField] private GameObject _healthPanel;   
    [SerializeField] private TMP_Text _healthText;      
    [SerializeField] private Image _healthBar;          

    public void CloseHealthPanel() => _healthPanel.SetActive(false);
    public void OpenHealthPanel() => _healthPanel.SetActive(true);

    public void SetHealthText(string text) => _healthText.SetText(text);
    public void SetHealthBarFillAmount(float fillAmount) => _healthBar.fillAmount = fillAmount;

    
//Statistics logic
    [SerializeField] private GameObject _statisticsPanel;     
    [SerializeField] private TMP_Text _statisticsText;        
    [SerializeField] private Button _openStatisticsButton;    
    [SerializeField] private Button _closeStatisticsButton;  

    public Button.ButtonClickedEvent OnOpenClicked => _openStatisticsButton.onClick;
    public Button.ButtonClickedEvent OnCloseClicked => _closeStatisticsButton.onClick;

    public void SetStatisticsText(string text) => _statisticsText.SetText(text);
    public void CloseStatisticsPanel() => _statisticsPanel.SetActive(false);
    public void OpenStatisticsPanel() => _statisticsPanel.SetActive(true);
    
    public void SetActiveOpenStatisticsButton(bool isActive) =>
        _openStatisticsButton.gameObject.SetActive(isActive);   
}

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

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class StatisticsView : MonoBehaviour
{
    [SerializeField] private GameObject _statisticsPanel;
    [SerializeField] private TMP_Text _statisticsText;
    [SerializeField] private Button _openStatisticsButton;
    [SerializeField] private Button _closeStatisticsButton;

    public Button.ButtonClickedEvent OnOpenClicked => _openStatisticsButton.onClick;
    public Button.ButtonClickedEvent OnCloseClicked => _closeStatisticsButton.onClick;

    public void SetStatisticsText(string text) => _statisticsText.SetText(text);
    public void CloseStatisticsPanel() => _statisticsPanel.SetActive(false);
    public void OpenStatisticsPanel() => _statisticsPanel.SetActive(true);
    public void SetActiveOpenStatisticsButton(bool isActive) =>
        _openStatisticsButton.gameObject.SetActive(isActive);
}

Теперь сделаем то же самое с HealthViewObserver. Выделим логику

//HealthViewObserver

private void DisableUI()
{
    _healthView.CloseHealthPanel();
    //Statistics logic
    _healthView.CloseStatisticsPanel();
}

private void UpdateUI()
{
    _healthView.SetHealthText($"{_healthModel.Health}/{_healthModel.MaxHealth}");
    _healthView.SetHealthBarFillAmount(_healthModel.Health / _healthModel.MaxHealth);

    //Statistics logic
    _healthView.SetStatisticsText($"Current health: {_healthModel.Health}/{_healthModel.MaxHealth}");
}

private void OnEnable()
{
    _healthModel.OnHealthChanged += OnHealthChanged;
    
    //Statistics logic
    _healthView.OnOpenClicked.AddListener(OnOpenStatisticClicked);
    _healthView.OnCloseClicked.AddListener(OnCloseStatisticClicked);
}

private void OnDisable()
{
    _healthModel.OnHealthChanged -= OnHealthChanged;

    //Statistics logic
    _healthView.OnOpenClicked.RemoveListener(OnOpenStatisticClicked);
    _healthView.OnCloseClicked.RemoveListener(OnCloseStatisticClicked);
}

//Statistics logic
private void OnCloseStatisticClicked()
{
    _healthView.CloseStatisticsPanel();
    _healthView.SetActiveOpenStatisticsButton(true);
}

//Statistics logic
private void OnOpenStatisticClicked()
{
    _healthView.OpenStatisticsPanel();
    _healthView.SetActiveOpenStatisticsButton(false);
}

И вынесем в отдельный класс, используя уже вынесенную в StatisticsView логику

public class StatisticViewObserver : MonoBehaviour
{
    [SerializeField] private StatisticsView _statisticsView;

    private void OnEnable()
    {
        _statisticsView.OnOpenClicked.AddListener(OnOpenStatisticClicked);
        _statisticsView.OnCloseClicked.AddListener(OnCloseStatisticClicked);
    }

    private void OnDisable()
    {
        _statisticsView.OnOpenClicked.RemoveListener(OnOpenStatisticClicked);
        _statisticsView.OnCloseClicked.RemoveListener(OnCloseStatisticClicked);
    }

    private void OnCloseStatisticClicked()
    {
        _statisticsView.CloseStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(true);
    }

    private void OnOpenStatisticClicked()
    {
        _statisticsView.OpenStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(false);
    }

    public void DisableUI() => _statisticsView.CloseStatisticsPanel();

    public void UpdateUI(float health, float maxHealth) =>
        _statisticsView.SetStatisticsText($"Current health: {health}/{maxHealth}");
}

Чтобы не дублировать код, события по обновлению и скрытию UI я передам через HealthViewObserver. Когда будет необходимо, тогда добавим обработку в StatisticsViewObserver

Также выключать игрока я буду через Model, чтобы можно было вынести HealthViewObserver. Это стоило сделать в прошлый раз. Лучше поздно, чем никогда

public class HealthViewObserver : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private HealthView _healthView;
    [SerializeField] private StatisticViewObserver _statisticViewObserver;

    private void OnEnable()
    {
        _healthModel.OnHealthChanged += OnHealthChanged;
    }
    
    private void OnDisable()
    {
        _healthModel.OnHealthChanged -= OnHealthChanged;
    }

    private void OnHealthChanged()
    {
        if (_healthModel.Health <= 0f)
        {
            //Выключение игрока через model
            _healthModel.gameObject.SetActive(false);
            DisableUI();
        }
        
        UpdateUI();
    }
    
    private void DisableUI() 
    {
        _healthView.CloseHealthPanel();
        //Пока не выносим
        _statisticViewObserver.DisableUI();
    }

    private void UpdateUI() 
    {
        _healthView.SetHealthText($"{_healthModel.Health}/{_healthModel.MaxHealth}");
        _healthView.SetHealthBarFillAmount(_healthModel.Health / _healthModel.MaxHealth);
        //Пока не выносим
_statisticViewObserver.UpdateUI(_healthModel.Health, _healthModel.MaxHealth);
    }
}

Проверим в Unity — логика задачу выполняет. Теперь посмотрим на связи, которые у нас получились 

Приходит геймдизайнер… Панель статистики мы теперь можем открывать, когда у нас закончились HP

Сейчас уже необходимо добавить свою обработку HP в StatisticsViewObserver, а из HealthViewObserver логику с другим observer можно убрать

Получился следующий код

public class StatisticViewObserver : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private StatisticsView _statisticsView;
    
    private void OnEnable()
    {
        //Вынесли логику обновления UI сюда
        _healthModel.OnHealthChanged += OnHealthChanged;
        _statisticsView.OnOpenClicked.AddListener(OnOpenStatisticClicked); 
        _statisticsView.OnCloseClicked.AddListener(OnCloseStatisticClicked); 
    }

    private void OnDisable()
    {
        _healthModel.OnHealthChanged -= OnHealthChanged;
        _statisticsView.OnOpenClicked.RemoveListener(OnOpenStatisticClicked); 
        _statisticsView.OnCloseClicked.RemoveListener(OnCloseStatisticClicked); 
    }

    private void OnHealthChanged()
    {
        UpdateUI(_healthModel.Health, _healthModel.MaxHealth);
    }

    private void OnCloseStatisticClicked()
    {
        _statisticsView.CloseStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(true);
    }

    private void OnOpenStatisticClicked()
    {
        _statisticsView.OpenStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(false);
    }

    private void UpdateUI(float health, float maxHealth) => 
        _statisticsView.SetStatisticsText($"Current health: {health}/{maxHealth}");

    public void DisableUI() => _statisticsView.CloseStatisticsPanel();
}

И следующие связи 

Так как мы теперь обрабатываем ввод, а не только отслеживаем изменения данных в HealthModel, это уже не просто observer. Такой класс принято называть Presenter (презентер)

Понятие MVP обобщил Mike Potel в своей статье "MVP: Model-View-Presenter – The Taligent Programming Model for C++ and Java" 

Краткая суть презентера из этой статьи заключается в следующем: Presenter выступает посредником: он обрабатывает ввод пользователя через View, вызывает модели-команды, затем, по результатам, обновляет View

Таким образом, за счет новой логики у нас меняется название с StatisticViewObserver в StatisticsViewPresenter

public class StatisticViewPresenter : MonoBehaviour

И приходит геймдизайнер…

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

Сейчас мы не будем реализовывать логику нового уровня. Сначала добавим изменения в HealthModel, так как вместо HP у нас увеличивается Max HP

public class HealthModel : MonoBehaviour
{
    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;
    
    public float Health => _health;
    public float MaxHealth => _maxHealth;
    
    public event Action OnHealthChanged;
    public event Action OnMaxHealthChanged;

    private void Start()
    {
        SetMaxHealth(_maxHealth);
    }

    public void TakeDamage(float damage)
    {
        var newHealth = _health - damage;

        if (Mathf.Approximately(newHealth, _health)) 
            return;
        
        _health = newHealth;
        OnHealthChanged?.Invoke();
    }
    
    public void SetMaxHealth(float maxHealth)
    {
        _maxHealth = maxHealth;
        _health = maxHealth;
        OnHealthChanged?.Invoke();
        OnMaxHealthChanged?.Invoke();
    }
}

Далее сверстаем и добавим кнопку для увеличения Max HP в StatisticsView

//StatisticsView

[SerializeField] private Button _upStatButton;

public Button.ButtonClickedEvent OnUpStatClicked => _upStatButton.onClick;        

Далее реализуем обработку в нашем StatisticViewPresenter, который уже работает с пользовательским вводом.

public class StatisticViewPresenter : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private StatisticsView _statisticsView;
    
    private void OnEnable()
    {
        _healthModel.OnMaxHealthChanged += OnMaxHealthChanged;
        //Новая подписка на кнопку улучшения статов
        _statisticsView.OnUpStatClicked.AddListener(OnUpStatClicked);
        
        _statisticsView.OnOpenClicked.AddListener(OnOpenStatisticClicked);
        _statisticsView.OnCloseClicked.AddListener(OnCloseStatisticClicked);
    }

    private void OnDisable()
    {
        _healthModel.OnMaxHealthChanged -= OnMaxHealthChanged;
        //Новая отписка на кнопку улучшения статов
        _statisticsView.OnUpStatClicked.RemoveListener(OnUpStatClicked);
        
        _statisticsView.OnOpenClicked.RemoveListener(OnOpenStatisticClicked);
        _statisticsView.OnCloseClicked.RemoveListener(OnCloseStatisticClicked);
    }

    //Увеличенние статов HP
    private void OnUpStatClicked()
    {
        _healthModel.SetMaxHealth(_healthModel.MaxHealth + 10f);
    }
    
    private void OnMaxHealthChanged()
    {
        UpdateUI(_healthModel.MaxHealth);
    }

    private void OnCloseStatisticClicked()
    {
        _statisticsView.CloseStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(true);
    }

    private void OnOpenStatisticClicked()
    {
        _statisticsView.OpenStatisticsPanel();
        _statisticsView.SetActiveOpenStatisticsButton(false);
    }

    private void UpdateUI(float maxHealth) => 
        _statisticsView.SetStatisticsText($"Max health: {maxHealth}");

    public void DisableUI() => _statisticsView.CloseStatisticsPanel();
}

Таким образом у нас получается следующие связи

Такая реализация MVC называется MVP. Здесь она представлена в виде HealthModel - StatisticsViewPresenter - StatisticsView

Но на самом деле это не совсем так… Почему это не так и какая разница между разными реализациями MVP я расскажу в следующей статье

Кстати, пока можно подумать, а как реализовать обработку кнопок для других статов с помощью текущего подхода

Спасибо за внимание. Буду рад обсудить эту тему в комментариях

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