Ten-Hut. Про архитектуру кода #1
Таки дошли руки. Сегодня выкладываю общее описание того, как это задумывалось, с примерами. В ближайшие пару дней напишу, с какими трудностями столкнулся, к чему пришёл, залью проект на гит и запаблю ссылку.
Собственно, игра небольшая, и, казалось бы, её вполне можно сделать за пару дней (а в расслабленном темпе - дней за пять), просто написав десяток монобехэвиоров, напрямую ссылающихся друг на друга, и запихав всё внутрь одной сцены. Но у меня задача не в том, чтобы сделать конкретную крохотную игру для конкретного джема, а в том, чтобы в конечном итоге научиться делать игры нормального размера. Создание маленького проекта описанным выше способом нисколько решение этой задачи не приближает. Скорее наоборот, прививает привычку быстро-быстро печатать нечитаемый говнокод и выстраивать всё из костылей. Поэтому я попробовал заюзать некоторые из известных мне паттернов и схем, потренироваться в их применении, посмотреть, что работает, что - не очень. Согласитесь, лучше выявить слабые места, подводные камни и прочее на вот таком маленьком проекте, чем потом, работая над более серьёзным, через полгода кодинга внезапно обнаружить, что для дальнейшей работы придётся переписывать более половины уже имеющегося.
Итак, за основу я взял упрощённую модификацию MVC под Юнити. В этой схеме скрипты делятся на три подвида - model, view и controller. Насколько я знаю, в оригинальном MVC (не в Юнити-версии) это работает так - пользователь обращается к программе через вью, вью обращается к контроллеру, контроллер смотрит, что там такое пользователю понадобилось, берёт у соответствующей модели соответствующие данные и отдаёт их пользователю снова через вью. Сразу замечу, что в Юнити-версии есть разительные отличия от изначального MVC, и я бы эту схему вообще переименовал. Во что-то в духе Controller-Singleton-Monobehavior. Но пока оставим как есть. Итак.
Модель - для нашего случая считаем, что это либо некая штука, которая может выдать или принять некоторые данные, либо, что чаще всего, сами данные. И отдельно может не выделяться, а просто храниться в контроллере в виде переменных.
В качестве Вью в Юнити выступает монобехэвиор в сцене: какой-нибудь триггер с OnTriggerEnter, обработчик нажатия кнопки на канвасе с публичным методом, привязывающимся к OnClick кнопки, скрипт, выводящий текст на GUI-панельку и т.п.
Контроллер - всё остальное, базовый C#-класс, не наследующий от монобехэвиора. В них - весь основной функционал.
Чтобы всё это в Юнити связать вместе, создаётся Главный Синглтон (противникам синглтонов должен сказать, что в данной схеме достаточно этого одного синглтона, больше тут не нужно), который служит эдакой “точкой входа в программу”. Далее, экземпляры контроллеров создаются в главном синглтоне первым делом при запуске игры и раздаются через публичные проперти. Все необходимые настройки, конкретные числа для тех или иных полей и т.д. выносятся в Scriptable Objects, передаются в главный синглтон через [SerializeField] и из него раздаются через публичные проперти. Монобехэвиоры накидываются на гейм-обжекты.
Теперь, если какому-нибудь монобехэвиору или контроллеру нужно обратиться к какому-то другому контроллеру, то обращающийся класс стучится в главный синглтон, берёт нужный контроллер через проперти и выбирает там нужный публичный метод или свойство. Однако связь от контроллера к монобехэвиору мы так не сделаем. Монобехэвиоры никаким синглтоном не раздаются, ведь, в отличие от контроллеров, которые уже созданы в синглтоне и поэтому существуют от самого старта приложения до его закрытия, набор экземпляров монобехэвиоров в разных сценах может быть совершенно разным, да и внутри одной сцены они могут то появляться, то исчезать. Поэтому здесь связь через ивенты. Т.е. если мы хотим из контроллера как-то повлиять на монобехэвиор, мы создаём в контроллере публичный ивент, а в монобехэвиоре, достучавшись до данного контроллера через главный синглтон, на этот ивент подписываемся. Всё.
Чтобы проиллюстрировать схему, давайте накодим что-нибудь небольшое. Например, нам нужно поставить в сцене триггер, который будет отнимать у игрока здоровье при попадании в этот триггер.
Поехали.
Как видно, в 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.