👋 Всем привет

Часто в моем окружении среди разработчиков много холивара на тему MVx-паттернов. Что обозначают все эти буквы? Почему в разных командах называют по-разному? Чем один отличается от другого? И зачем оно вообще все?

Поэтому решил сделать несколько статей на тему MVX паттернов с примерами на Unity. Хочется прояснить его для создания единого контекста в gamedev о концепции как самого паттерна MVC, так и их различные реализации.

Итак. Поехали!

Рассмотрим следующую ситуацию. Игра находится на стадии разработки. Нам необходимо запрограммировать логику следующего UI окна. Причем окно и игрок должны скрываться, когда HP заканчиваться

И вот мы делаем это просто в одном классе Health

public class Health : MonoBehaviour
{
    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;

    [SerializeField] private TMP_Text _healthText;

    private void Awake()
    {
        _health = _maxHealth;
        UpdateUI();
    }

    [Button]
    public void TakeDamage(float damage)
    {
        _health -= damage;

        if (_health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }

    private void DisableUI()
    {
        _healthText.gameObject.SetActive(false);
    }

    private void UpdateUI()
    {
        _healthText.SetText($"{_health}/{_maxHealth}");
    }
}

И приходит геймдизайнер... И спрашивает

  1. А что если добавить вывода HP и под героем. Причем в стиле progress bar?

  2. А что если мы хотим выводить HP в отдельно окно статистики по герою с другим текстом?

Как вы понимаете. Это и становиться новой нашей задачей. И давайте попробуем это сделать в текущем классе. По пути наименьшего сопротивления, так сказать. Получиться класс

public class HealthV2 : MonoBehaviour
{
    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;

    [SerializeField] private TMP_Text _healthText;
    [SerializeField] private Image _healthBar;
    [SerializeField] private TMP_Text _statisticsText;

    private void Awake()
    {
        _health = _maxHealth;
        UpdateUI();
        CloseStatistics();
    }

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

        if (_health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }

    private void DisableUI()
    {
        _healthText.gameObject.SetActive(false);
        CloseStatistics();
    }

    private void UpdateUI()
    {
        _healthText.SetText($"{_health}/{_maxHealth}");
        _healthBar.fillAmount = _health / _maxHealth;
        _statisticsText.SetText($"Current health: {_health}/{_maxHealth}");
    }

    public void CloseStatistics()
    {
        _statisticsText.gameObject.SetActive(false);
    }

    public void OpenStatistics()
    {
        _statisticsText.gameObject.SetActive(true);
    }
}

И вот у нас есть наш функционал вывода HP. И при 0 хп возникают новые проблемы �� контролем панели хп и панели статистики. Давайте это также исправим

public class HealthV3 : MonoBehaviour
{
    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;

    [SerializeField] private GameObject _healthPanel;
    [SerializeField] private TMP_Text _healthText;

    [SerializeField] private Image _healthBar;

    [SerializeField] private GameObject _statisticsPanel;
    [SerializeField] private TMP_Text _statisticsText;

    private void Awake() { ... }

    public void TakeDamage(float damage) { ... }

    private void DisableUI()
    {
        _healthText.gameObject.SetActive(false);
        _healthPanel.SetActive(false);
        CloseStatistics();
    }

    private void UpdateUI() { ... }

    public void CloseStatistics()
    {
        _statisticsPanel.SetActive(false);
    }

    public void OpenStatistics()
    {
        _statisticsPanel.SetActive(true);
    }
}

Теперь это работает как надо с точки зрения логики. А теперь взглянем на инспектор. Компонент заметно вырос. Зайдем в код. Для использования компонента хп нам теперь нужно сразу 3 панели: HealthPanel, HealthBar, StatisticPanel. 

  • В игре наверняка будут другие персонажи с механикой здоровья. Для каждого из них надо делать сразу 3 панели. А что если им не нужны панели? Тогда писать костыли с if конструкциями для конкретно их случаев

  • А что если попросят добавить модификаторы для урона? Разные типы урона будут?

  • А что если еще добавить звуки при нанесении урона?

И эти вопросы вызывают сильное чувство дискомфорта при обдумывании их реализации. Это становится проблемой

И для решения такой ситуации существует концепция MVC. Она была сформулирована Трюгве Реенскаугом (Trygve Reenskaug) в результате его работы в Xerox PARC в 1978/79 годах. Важно уточнить, что в своих статьях буду рассматривать MVC прежде всего как набор архитектурных идей/принципов/подходов, которые могут быть реализованы различными способами.
Более подробно об этом можно прочитать в этой статье

При его рассмотрении у нас сразу появляется 3 новых понятия.

  • M - model

  • V - view

  • C - controller

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

Model - логика, которая отвечает за хранение и обработку данных. 

Давайте попробуем определить, что будет являться моделью в нашем примере.
Какие у нас используются данные?
Здоровье и макс. здоровье. Хранение данных как раз подходит под определение модели. 
А какая логика обрабатывает это здоровье?
Инициализация здоровья в Awake() и метод TakeDamage(float damage). В остальных методах мы только выводим в интерфейс эти данные, но не обрабатываем. 

public class HealthV3 : MonoBehaviour
{
    [SerializeField] private float _health = 100f; //Model logic
    [SerializeField] private float _maxHealth = 100f; //Model logic

    [SerializeField] private GameObject _healthPanel;
    [SerializeField] private TMP_Text _healthText;

    [SerializeField] private Image _healthBar;

    [SerializeField] private GameObject _statisticsPanel;
    [SerializeField] private TMP_Text _statisticsText;

    private void Awake()
    {
        _health = _maxHealth; //Model logic

        UpdateUI();
        CloseStatistics();
    }

    public void TakeDamage(float damage)
    {
        _health -= damage; //Model logic

        if (_health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }
    }

    ...
}

View - это логика, отвечающая за визуализацию и отображение данных. Обычно это компоненты, которые отвечают за рендер на экране игрока. Они отображают те данные, которые им дают. За обр��ботку данных View не отвечает

Давайте попробуем выделить View у нас. За визуализацию у нас отвечают Unity компоненты:

  • healthPanel

  • healthText

  • healthBar

  • statisticsPanel

  • statisticsText

  • А также методы, которые их открывают и скрывают

//HealthV3

[SerializeField] private float _health = 100f; //Model logic
[SerializeField] private float _maxHealth = 100f; //Model logic

[SerializeField] private GameObject _healthPanel; //View logic
[SerializeField] private TMP_Text _healthText; //View logic

[SerializeField] private Image _healthBar; //View logic

[SerializeField] private GameObject _statisticsPanel; //View logic
[SerializeField] private TMP_Text _statisticsText; //View logic

public void CloseStatistics() //View logic { ... }

public void OpenStatistics() //View logic { ... }

...

Controller – логика, которая связывает Model и View, отвечает за обработку изменений их данных. Теперь обратимся к коду и попробуем найти логику Controller.

При нанесении урона у нас есть обработка данных модели. Если HP <= 0, то мы выключаем отображение. А если HP > 0, то мы обновляем данные в UI. Также у нас есть логика включения и выключения панели статистики. Это тоже логика контроллера. О ней мы поговорим подробнее в следующей статье

public class HealthV3 : MonoBehaviour
{
    private void Awake()
    {
        _health = _maxHealth; //Model logic

        //Controller logic
        UpdateUI();
        CloseStatistics();
    }

    public void TakeDamage(float damage)
    {
        _health -= damage; //Model logic

        //Controller logic
        if (_health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }

    private void DisableUI() //Controller logic
    {
        _healthText.gameObject.SetActive(false);
        _healthPanel.SetActive(false);
        CloseStatistics();
    }

    private void UpdateUI() //Controller logic
    {
        _healthText.SetText($"{_health}/{_maxHealth}");
        _healthBar.fillAmount = _health / _maxHealth;
        _statisticsText.SetText($"Current health: {_health}/{_maxHealth}");
    }
}

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

public class HealthV3 : MonoBehaviour
{
    [SerializeField] private float _health = 100f; //Model logic
    [SerializeField] private float _maxHealth = 100f; //Model logic

    [SerializeField] private GameObject _healthPanel; //View logic
    [SerializeField] private TMP_Text _healthText; //View logic

    [SerializeField] private Image _healthBar; //View logic

    [SerializeField] private GameObject _statisticsPanel; //View logic
    [SerializeField] private TMP_Text _statisticsText; //View logic

    private void Awake()
    {
        _health = _maxHealth; //Model logic

        //Controller logic
        UpdateUI();
        CloseStatistics();
    }

    public void TakeDamage(float damage)
    {
        _health -= damage; //Model logic

        //Controller logic
        if (_health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }

    private void DisableUI() //Controller logic { ... }

    private void UpdateUI() //Controller logic { ... }

    public void CloseStatistics() //View logic { ... }

    public void OpenStatistics() //View logic { ... }
}

По полученному разделению я вынесу классы:

  • HealthModel

  • HealthViewController

  • HealthView

public class HealthModel : MonoBehaviour
{
    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;

    public float Health => _health;
    public float MaxHealth => _maxHealth;

    public void TakeDamage(float damage)
    {
        _health -= damage;
    }
}
public class HealthView : MonoBehaviour
{
    [SerializeField] private GameObject _healthPanel; //View logic
    [SerializeField] private TMP_Text _healthText; //View logic

    [SerializeField] private Image _healthBar; //View logic

    [SerializeField] private GameObject _statisticsPanel; //View logic
    [SerializeField] private TMP_Text _statisticsText; //View logic

    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 class HealthController : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private HealthView _healthView;

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

    private void UpdateUI() //Controller logic
    {
        _healthView.SetHealthText($"{_healthModel.Health}/{_healthModel.MaxHealth}");
        _healthView.SetHealthBarFillAmount(_healthModel.Health / _healthModel.MaxHealth);
        _healthView.SetStatisticsText($"Current health: {_healthModel.Health}/{_healthModel.MaxHealth}");
    }

    public void CloseStatistics() //Controller logic
    {
        _healthView.CloseStatisticsPanel();
    }

    public void OpenStatistics() //Controller logic
    {
        _healthView.OpenStatisticsPanel();
    }
}

Но логика все еще не работает, так как HealthViewController не знает об изменении данных Model. И тут вступают связи между MVC. (Спойлер, эти связи разрушат не одну жизнь программиста)

В канонической концепции MVC связи следующие:

  1. Модель ничего не знает ни о View, ни о Controller. Это делает возможным ее разработку и тестирование как независимого компонента. Может быть Активной и Пассивной. (Дальше на нашем примере разберем разницу)

  2. View отображает Model. Есть 2 способа, как ему отобразить данные Model. Есть 2 способа. Активный - View знает о Model и берет нужные данные. Пассивный - View получает данные через посредника в виде Controller.

  3. Controller всегда знает о Model и может ее изменять. Как правило в результате действий пользователя. И получать данные о действиях пользователя он также может 2 способами. Активный - Controller знает о View и берет нужные данные. Пассивный - View дает данные в Controller. Последний не знает о View

Слева - концепция MVC через Active View. Справа - через Passive View
Слева - концепция MVC через Active View. Справа - через Passive View

Таким образом есть разные комбинации получения данных между этими объектами. Какая-то логика может быть активной, какая-то пассивной

За счет этих комбинаций и родились различные MVx паттерны: MVP, PresentationModel, MVVM и др. Их мы рассмотрим в следующих статьях 

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

Теперь пробежимся по связям между MVC.
У нас нет необходимости обрабатывать пользовательский ввод, поэтому связь в виде пунктирной стрелки UserAction уходит.

Раз у нас нет обработка от пользователя, то на данные мы также не влияем. То есть мы их не изменяем. Поэтому стрелка с Change state/Get data также уходит. Давайте взглянем на обновленную схему

Сделаю более приятный вид:

Как я уже сказал Model может быть Активной и Пассивной. Давайте разберем.

  • Активная Model - логика, когда Model напрямую рассылает информацию об изменении подписчикам через интерфейс.

  • Passive Model - логика, когда есть некий ивент, через который происходит уведомление остальных. В данном случа�� мы даже не знаем о подписчиках.

Разберем и тот, и тот сценарий. Начнем с Активной Model. Для этого мы создадим интерфейс IHealthListener, в котором будет один метод, который и будет вызываться при изменении здоровья или максимального здоровья. Также вызываем первый ивент при старте игры для уведомления других

using System.Collections.Generic;
using UnityEngine;

public class HealthModel : MonoBehaviour
{
    private readonly List<IHealthListener> _listeners = new();

    [SerializeField] private float _health = 100f;
    [SerializeField] private float _maxHealth = 100f;

    public float Health => _health;
    public float MaxHealth => _maxHealth;

    public void AddListener(IHealthListener listener)
    {
        _listeners.Add(listener);
    }

    public void RemoveListener(IHealthListener listener)
    {
        _listeners.Remove(listener);
    }

    private void Start()
    {
        OnHealthChanged();
    }

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

        // Если здоровье не изменилось, то ничего не делаем
        if (Mathf.Approximately(newHealth, _health))
            return;

        _health = newHealth;
        OnHealthChanged();
    }

    private void OnHealthChanged()
    {
        for (var i = _listeners.Count - 1; i >= 0; i--)
            _listeners[i].OnHealthChanged();
    }
}

Такой подход является реализацией паттерна Observer. Поэтому я Controller переименую в Observer. Это более подходящее название для него

public class HealthViewObserver : MonoBehaviour, IHealthListener
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private HealthView _healthView;

    void IHealthListener.OnHealthChanged()
    {
        if (_healthModel.Health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }
...
}

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

public class SceneInstaller : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private HealthViewObserver _healthViewObserver;

    private void Awake()
    {
        _healthModel.AddListener(_healthViewObserver);
    }

    private void OnDestroy()
    {
        _healthModel.RemoveListener(_healthViewObserver);
    }
}

Теперь вывод хп персонажа реализован через MVO: Model-View-Observer!

Теперь сделаем Пассивную реализацию Модели. На самом деле идея делать логику подписок и отписок в самой модели имеет проблемы: 

  • Нужен отдельный интерфейс под обработку

  • Об этих подписках нужно думать самой модели

  • Сложнее динамически отписываться/подписываться 

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

Давайте используем ивент, на который подпишемся. В таком случае нам не нужен SceneInstaller и интерфейс IHealthListener. Мы будем подписываться на OnEnable/OnDisable. И вот какая реализация получилась:

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;

    private void Start()
    {
        OnHealthChanged?.Invoke();
    }

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

        // Если здоровье не изменилось, то ничего не делаем
        if (Mathf.Approximately(newHealth, _health))
            return;

        _health = newHealth;
        OnHealthChanged?.Invoke();
    }
}
public class HealthViewObserver : MonoBehaviour
{
    [SerializeField] private HealthModel _healthModel;
    [SerializeField] private HealthView _healthView;

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

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

    private void OnHealthChanged()
    {
        if (_healthModel.Health <= 0f)
        {
            gameObject.SetActive(false);
            DisableUI();
        }

        UpdateUI();
    }

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

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

Итого получились следующая "схема" реализации MVO

В следующей статье продолжим рассматривать MVx паттерны. Рассмотрим, как обрабатывать пользовательский ввод с помощью этого паттерна на примере Statistic Panel

P.S. Для HealthBar, HealthPanel и StatisticPanel также надо реализовать свои обсерверы и вьюхи, так как это разные логики. Решил материал не раздувать