👋 Всем привет
Часто в моем окружении среди разработчиков много холивара на тему 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}");
}
}
И приходит геймдизайнер... И спрашивает
А что если добавить вывода HP и под героем. Причем в стиле progress bar?
А что если мы хотим выводить 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 связи следующие:
Модель ничего не знает ни о View, ни о Controller. Это делает возможным ее разработку и тестирование как независимого компонента. Может быть Активной и Пассивной. (Дальше на нашем примере разберем разницу)
View отображает Model. Есть 2 способа, как ему отобразить данные Model. Есть 2 способа. Активный - View знает о Model и берет нужные данные. Пассивный - View получает данные через посредника в виде Controller.
Controller всегда знает о Model и может ее изменять. Как правило в результате действий пользователя. И получать данные о действиях пользователя он также может 2 способами. Активный - Controller знает о View и берет нужные данные. Пассивный - View дает данные в Controller. Последний не знает о 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 также надо реализовать свои обсерверы и вьюхи, так как это разные логики. Решил материал не раздувать