? Всем привет

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

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


  1. ioleynikov
    17.10.2025 17:25

    К сожалению я не знаком с реализацией MVC в Unity но хочу рассказать как это делается в React.js. Эта система внесла очень существенное улучшение концепции MVC. Классическая схема предполагает, что модель передает изменения данных в представление и тем самым полностью нарушает принцип независимости компонент. В React представление автоматически отслеживает изменение состояний данных модели при помощи механизма событий и обновляет UI. Аналогично контроллер React следит за событиями UI и представлению нет дела до обработки действий пользователя. Это намного более строго обеспечивает независимость и позволяет разработчику модели полостью абстрагироваться от представлений. Если Unity MVC реализует подобный механизм, то это здорово.


    1. PLoveGames Автор
      17.10.2025 17:25

      Да, это действительно так. В этой статье как раз такая ситуация. В следующей рассмотрю механизм взаимодействия с пользователем


  1. ioleynikov
    17.10.2025 17:25

    Я понял. Спасибо. Я сейчас думаю о некоей системе, которая реализует MVC в чистом виде. Разработчик модели данных знает только SQL и понятия не имеет о вьюверах, контроллерах, C#, JavaScript. Мыслит только в терминах таблиц реляций и бизнес логики. Дизайнер UI знает только HTML, CSS ну и готовые визуальные реализации таблиц, списков, деревьев. Вся забота о сращивании ежа с ужом ложится на контроллер, но у программиста есть компилятор логики с описания на английском, русском языках в код JavaScript React, Angular или C# Unity, .NET Core. Думаю, что такую систему можно будет построить с привлечением средств генеративных нейросетей ИИ. Надо просто правильно определить все интерфейсы и обучить нейросети как использовать API фреймворков . Ещё раз Спасибо, хороших выходных и Удачи!


  1. leni8ec
    17.10.2025 17:25

    Довольно хорошо написано, как для статей про MVC, где обычно любят излишне усложнять.

    Также понравилось что показали какую именно проблему это решает.

    Интересно будет почитать продолжение, спасибо.


    1. PLoveGames Автор
      17.10.2025 17:25

      Спасибо. Следующая статья будет в таком же формате


  1. Bardakan
    17.10.2025 17:25

    много работал в MVC (objective-c, swift). Возможно я чего-то не понимаю, но вы сами выделили сущность View:

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

    Если другие компоненты напрямую не связаны с отображением в том же визуальном редакторе, то зачем им наследоваться от MonoBehaviour, добавлять SerializeField и т.п.? Или это связано с какими-то особенностями Unity?


    1. PLoveGames Автор
      17.10.2025 17:25

      Да, это связано с особенностями Unity. Так как нам необходимы другие компоненты, например текст, то нам нужен объект, который сможет эти компоненты получить. В Unity проще всего это сделать через MonoBehaviour и SerializeField. И так как у нас идет взаимодействие с View(например, выключать и включать view) наследование от MonoBehaviour нам также подходит


      1. Bardakan
        17.10.2025 17:25

        не, подождите. Вопрос не про view. Почему у вас SerializeField в модели и контроллере?
        Почему нельзя их оставить внутри view как входные параметры, которые задаются в UI редакторе, а потом передаются внутрь контроллера и модели?
        И можете выложить пример проекта? Потому что то, что вы озвучили, выглядит так, как будто в UI редакторе будет каша из классов (для каждого mvc модуля в UI объект добавляется минимум 3 скрипта, которые еще и нужно связать между собой (тоже через UI), хотя это можно было сделать целиком в коде.


        1. PLoveGames Автор
          17.10.2025 17:25

          Да, это действительно делается в коде. Можно в одном месте забиндить нужные данные. А потом их использовать. В такой системе и ваш вариант можно сделать. Это другая проблема - внедрение зависимостей. Ее в статье не рассматриваю


  1. Grave18
    17.10.2025 17:25

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

    Для этого случая что мы конкретно должны делать, вынести view в интерфейс или использовать что-то еще?


  1. Wolfdp
    17.10.2025 17:25

    Я не сильно разбираюсь в Unity, поэтому уточнил данный подход у знакомого игродела. С его слов:

    1. примеры больше похоже на MVP, чем на MVC

    2. вью и контроллер ещё норм наследовать от MonoBehaviour, а вот модель -- уже не очень

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

    В целом глядя на код, много вопросов к подходу...