Ten-Hut. Про архитектуру кода #1

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

Собственно, игра небольшая, и, казалось бы, её вполне можно сделать за пару дней (а в расслабленном темпе - дней за пять), просто написав десяток монобехэвиоров, напрямую ссылающихся друг на друга, и запихав всё внутрь одной сцены. Но у меня задача не в том, чтобы сделать конкретную крохотную игру для конкретного джема, а в том, чтобы в конечном итоге научиться делать игры нормального размера. Создание маленького проекта описанным выше способом нисколько решение этой задачи не приближает. Скорее наоборот, прививает привычку быстро-быстро печатать нечитаемый говнокод и выстраивать всё из костылей. Поэтому я попробовал заюзать некоторые из известных мне паттернов и схем, потренироваться в их применении, посмотреть, что работает, что - не очень. Согласитесь, лучше выявить слабые места, подводные камни и прочее на вот таком маленьком проекте, чем потом, работая над более серьёзным, через полгода кодинга внезапно обнаружить, что для дальнейшей работы придётся переписывать более половины уже имеющегося.

Итак, за основу я взял упрощённую модификацию MVC под Юнити. В этой схеме скрипты делятся на три подвида - model, view и controller. Насколько я знаю, в оригинальном MVC (не в Юнити-версии) это работает так - пользователь обращается к программе через вью, вью обращается к контроллеру, контроллер смотрит, что там такое пользователю понадобилось, берёт у соответствующей модели соответствующие данные и отдаёт их пользователю снова через вью. Сразу замечу, что в Юнити-версии есть разительные отличия от изначального MVC, и я бы эту схему вообще переименовал. Во что-то в духе Controller-Singleton-Monobehavior. Но пока оставим как есть. Итак.

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

В качестве Вью в Юнити выступает монобехэвиор в сцене: какой-нибудь триггер с OnTriggerEnter, обработчик нажатия кнопки на канвасе с публичным методом, привязывающимся к OnClick кнопки, скрипт, выводящий текст на GUI-панельку и т.п.

Контроллер - всё остальное, базовый C#-класс, не наследующий от монобехэвиора. В них - весь основной функционал.

Чтобы всё это в Юнити связать вместе, создаётся Главный Синглтон (противникам синглтонов должен сказать, что в данной схеме достаточно этого одного синглтона, больше тут не нужно), который служит эдакой “точкой входа в программу”. Далее, экземпляры контроллеров создаются в главном синглтоне первым делом при запуске игры и раздаются через публичные проперти. Все необходимые настройки, конкретные числа для тех или иных полей и т.д. выносятся в Scriptable Objects, передаются в главный синглтон через [SerializeField] и из него раздаются через публичные проперти. Монобехэвиоры накидываются на гейм-обжекты.

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

Чтобы проиллюстрировать схему, давайте накодим что-нибудь небольшое. Например, нам нужно поставить в сцене триггер, который будет отнимать у игрока здоровье при попадании в этот триггер.

Поехали.

[DefaultExecutionOrder(-2000)] public class MainSingleton : MonoBehaviour { //скриптабл обжект с настройками [SerializeField] private SettingsSO _settings; public SettingsSO Settings { get { return _settings; } } //инстанс синглтона public static MainSingleton Instance { get; private set; } //контроллер здоровья игрока public PlayerHealthController PlayerHealthController { get; private set; } private void Awake() { //инициализируем синглтон if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } //создаём контроллер здоровья PlayerHealthController = new PlayerHealthController(); } }
[CreateAssetMenu(fileName = "Settings", menuName = "GameSettingsAsset")] public class SettingsSO : ScriptableObject { [SerializeField] private int _damagerDamageAmount;//количество здоровья, которое будет отнимать триггер [SerializeField] private int _maxHealth;//максимальное и изначальное здоровье игрока public int DamagerDamageAmount { get { return _damagerDamageAmount; } } public int MaxHealth { get { return _maxHealth; } } }
public class PlayerHealthController { private int _currentHealth; public event Action<int> OnHealthChanged; public PlayerHealthController() { //берём начальный уровень здоровья из скриптабл обжекта с настройками _currentHealth = MainSingleton.Instance.Settings.MaxHealth; } public void DealDamage(int amount) { _currentHealth -= amount;//отнимаем здоровье OnHealthChanged.Invoke(_currentHealth);//вызываем ивент, передаём оставшееся здоровье } }
public class DealDamageOnContactMono : MonoBehaviour { private PlayerHealthController _playerHealthController; private void Start() { //ищем контроллер здоровья игрока в синглтоне _playerHealthController = MainSingleton.Instance.PlayerHealthController; } private void OnTriggerEnter2D(Collider2D collision) { if (collision.tag == "Player") { //вызываем в контроллере метод, изменяющий здоровье, //передаём количество повреждений из скриптабл обжекта с настройками _playerHealthController.DealDamage(MainSingleton.Instance.Settings.DamagerDamageAmount); } } }
public class HealthBarMono : MonoBehaviour { [SerializeField] TextMeshProUGUI _healthText;//панель вывода здоровья на экран private PlayerHealthController _healthController; private void Start() { //выводим начальное количество здоровья на экран ChageHealthPointsInHealthBar(MainSingleton.Instance.Settings.MaxHealth); //подписываемся на ивент в контроллере здоровья игрока _healthController = MainSingleton.Instance.PlayerHealthController; _healthController.OnHealthChanged += ChageHealthPointsInHealthBar; } private void ChageHealthPointsInHealthBar(int currentHealth) { //меняем отображение здоровья на экране _healthText.text = currentHealth.ToString(); } private void OnDestroy() { //отписываемся от ивента при уничтожении геймобжекта _healthController.OnHealthChanged -= ChageHealthPointsInHealthBar; } }

Как видно, в DealDamageOnContactMono закэширован контроллер PlayerHealthController, ссылка на который получена из соответствующего свойства главного синглтона. В DealDamageOnContactMono в методе OnTriggerEnter проверяется, кто попал в триггер, и если это игрок, то DealDamageOnContactMono обращается к PlayerHealthController и там вызывает метод DealDamage, передавая параметром объём наносимого урона. В PlayerHealthController имеется ивент OnHealthChanged, который запускается в методе DealDamage и передаёт итоговое количество здоровья. Где-то в сцене (причём, не важно, в какой) есть канвас с индикатором здоровья, на котором висит монобехэвиор HealthBarMono. Этот монобехэвиор через синглтон стучится в PlayerHealthController, подписывается в нём на OnHealthChanged методом ChageHealthPointsInHealthBar. И - вуаля - игрок наступает в триггер, здоровье теряется и тут же отображается на панели здоровья. Сколько именно повреждений наносится? Данные об этом хранятся в scriptable object с настройками, который передаётся через атрибут SerializeField в главный синглтон и раздаётся через публичное свойство. В нашем случае его берёт DealDamageOnContactMono. Все остальные настройки, вроде максимального количества здоровья игрока (которое может понадобиться как PlayerHealthController, так и HealthBarMono), делаются точно так же через скриптаблобжекты.

Вся эта система расширяема и модифицируема. Настройки лучше не валить в один скриптабл обжект, как в примере, а вынести в отдельные, и их уже класть в основной скриптабл обжект, висящий на главном синглтоне. Остальное - по желанию. Можно на каждый контроллер сразу писать интерфейсы и раздавать уже их. Можно контроллеры сделать монобехэвиорами с интерфейсами и накидывать их в главный синглтон через SerializeField - тогда для замены одного контроллера на другой вам не нужно будет лезть в код главного синглтона каждый раз. Можно поделить контроллеры на разные блоки для разных мест игры и создавать/уничтожать их по мере необходимости. Можно воспользоваться фреймворком инъекции зависимостей, например, ZenJect или Adic, и раздавать ссылки на контроллеры посредством как раз инъекций. Про последнее, кстати, будет в следующем посте, потому что без минимального простенького dependency injection мне таки не удалось обойтись в своём проекте.

А на этом сегодняшнее полотно, пожалуй, пора стопануть. See you next time.

33
Начать дискуссию