👋 Всем привет
Часто в моем окружении среди разработчиков много холивара на тему 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 прежде всего как набор архитектурных идей/принципов/подходов, которые могут быть реализованы различными способами.
Более подробно об истории 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.

Таким образом есть разные комбинации получения данных между этими объектами. Какая-то логика может быть активной, какая-то пассивной
За счет этих комбинаций и родились различные MVx паттерны: MVP, PresentationModel, MVVM и др. Их мы рассмотрим в следующих статьях
Вернемся к концепции MVC и их связям. Сначала выберем активную или пассивную реализацию View. Я выберу первую реализацию. Зная геймдизайнера, я хочу View использовать с разными моделями и не привязывать даже к интерфейсу. Поэтому будем использовать посредник

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

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

Теперь реализуем логику уведомлений в модели. Для этого мы создадим интерфейс 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 также надо реализовать свои обсерверы и вьюхи, так как это разные логики. Решил материал не раздувать
