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

Про апдейтер, мессенджер и вопрос, оставшийся нерешённым.

Я где-то в предыдущих постах упоминал апдейтер. Если кто не в курсе, что это, то тут всё просто - этот класс каждый Update() э-э-э апдейтит другие классы. Для этого мы удаляем Update() во всех монобехэвиорах проекта, а если кому-то апдейтиться всё-таки нужно, подписываем этого кого-то на апдейтер. Зачем это вообще надо? Ну, для такого маленького проекта - низачем, но для более крупного может и пригодиться - см. известную статью. Как вариант - реализовать по той же схеме, что и с внедрением зависимостей из предыдущего поста. Т.е. делаем интерфейс с одним публичным методом и делаем класс, хранящий список объектов, реализующих данный интерфейс, и вызывающий этот метод каждый Update() во всех элементах списка:

public interface IUpdatable { void Tick(); }
public class Updater : MonoBehaviour { //список объектов, которые надо апдейтить private List<IUpdatable> _activeObjects = new List<IUpdatable>(); private void Update() { foreach (var item in _activeObjects) { item.Tick(); } } //подписка public void Subscribe(IUpdatable obj) { _activeObjects.Add(obj); } //отписка public void Unsubscribe(IUpdatable obj) { _activeObjects.Remove(obj); } }

Updater раздаём - правильно - через Главный Синглтон. Можно его повесить на тот же геймобжект, а можно создать в синглтоне в Awake().

Если какому-то классу необходимо воспользоваться функционалом Update(), ему надо просто реализовать интерфейс IUpdatable и “подписаться” в Updater-е. И неважно, что это за класс, монобехэвиор или нет, возможность что-то делать каждый кадр теперь доступна всем :).

Главное - не забыть отписаться. Т.е. если мы пишем IUpdatable монобехэвиор, то в нём либо подписываемся в Start, отписываемся в OnDestroy, либо подписываемся в OnEnable и отписываемся в OnDisable, в зависимости от. Точно так же, как и с обычной подпиской на C#-ивенты через оператор +=. Дело в том, что монобехэвиор - это всё-таки класс в C#, и сборщик мусора за его экземпляром придёт только в том случае, если на него отсутствуют ссылки из других объектов. А тут ссылка лежит в списке в другом объекте. И если мы уничтожили геймобжект, на котором висит IUpdatable-скрипт, но при этом не отписались, получается странное - в иерархии сцены геймобжект исчез, а скрипт знай себе апдейтится, в каких-то переменных хранит старые значения, в каких-то - нуллы, но продолжает исправно работать.

Смотрите, какая штука с этим “типа MVC” получается. Когда у нас контроллеры и монобехэвиоры, скажем так, относятся к одной категории и передают друг другу объекты одной категории, всё нормально. Как, например, со здоровьем игрока - вот контроллер здоровья, вот монобехэвиор, выводящий здоровье, всё ровно, вопросов и нестыковок не возникает.

Но что если мы берём монобехэвиоры, которые работают с сущностями немного иного порядка, нежели контроллеры? Вот, скажем, какой-нибудь скрипт, который проигрывает аудио. Или выводит текст. Он должен, по идее, просто получить текст/аудио, вывести его с нужными параметрами и, может, сообщить, когда текст уже полностью на экране (если мы делаем побуквенный вывод, например) или когда аудио закончило проигрываться (в Ten-Hut это как раз надо). Не будет же скрипт-аудиоплеер сам стучаться в, скажем, контроллер состояний игры? Аудиоплееру вообще не нужно знать, что в проекте существуют какие-то там состояния, а тут ему придётся чуть ли не самолично в их контроллере методы вызывать. Чтобы не делать прямой вызов, монобехэвиор мог бы запустить внутри себя какой-то ивент, на который другие смогли бы подписаться, но, в отличие от контроллеров, он-то через синглтон не раздаётся. Т.е. ивент он, конечно, запустит, но подписаться на него никто не сможет.

Выход - сделать эдакий класс-”прокладку”, базовый C#-класс, который будет заниматься только запуском ивентов для данного монобехэвиора:

public class AudioFXController { public event Action<AudioClip, float> OnAudioClipPlayRequested = delegate { }; public event Action OnAudioClipEnded = delegate { }; public void InvokeOnAudioClipEnded() { OnAudioClipEnded.Invoke(); } public void PlayAudioClip(AudioClip audioClip, float pitch) { OnAudioClipPlayRequested.Invoke(audioClip, pitch); } }

Т.е. вот, скажем, кто-то хочет проиграть такой-то аудиоклип с такими-то параметрами, он стучится в AudioFXController и вызывает метод PlayAudioClip(). Есть, например, контроллер, который после завершения проигрывания аудио переключает состояние игры, он подписывается на OnAudioClipEnded. А монобехэвиор-класс AudioFXPlayerMono подписывается на OnAudioClipPlayRequested, при его срабатывании запускает аудио и по завершении воспроизведения звука снова обращается к AudioFXController и вызывает в нём InvokeOnAudioClipEnded(). Всё, монобехэвиор-плеер у нас ни про каких персонажей и состояния игры не знает.

Но тогда что ж получается, под каждый такой “как бы generic”-монобехэвиор нужно будет писать по такому вот контроллеру-прокладке? Не проще ли объединить все эти, по сути, раздатчики ивентов в один класс? Который будет хранить в себе кучу ивентов типа OnAudioClipPlayRequested, OnTextOutputRequested, OnAnimationStarted и т.п. И все, кому надо, будут в этот класс стучаться и какие-то ивенты запускать, а на какие-то подписываться.

И-и-и таки да (оркестр, туш!), я изобрёл велосипед под названием Event Aggregator / Messenger / “Радиоэфир”. В Ten-Hut я обошёлся отдельными контроллерами-”прокладками”, но мессенджер, насколько я знаю, - удобная и довольно часто используемая вещь. Более того, если вас не смущает хаос базового Unity Way, о котором я писал в предыдущем посте, то вроде вполне себе нормальный подход - делать отдельные системы на монобехэвиорах, а если нужно достучаться до другой системы, кидать ивенты в мессенджер. И проблемы с аддитивной загрузкой сцен тогда тоже возникнуть не должно, потому что мессенджер вполне можно сделать синглтоном и раздавать его инстанс через паблик-статик проперти.

Реализаций мессенджера много, в зависимости от задач и того, как вы ведёте разработку. И, естественно, ивенты там - это уже не базовые event из C#, они реализуются в каждом варианте по-своему. Если вы девелопер-одиночка, можно сделать енум событий и всё реализовать в пределах одного класса. Если у вас групповая разработка или не хочется по каждому поводу лезть править мессенджер, то каждый ивент выносится в отдельный класс, со списком подписчиков, и подписка производится пробрасыванием нужного метода через делегат Action. Я знаю ещё один вариант - сделать каждый ивент страктом Т, в нём сделать публичные поля с передаваемыми параметрами, а подписывающийся класс сделать с интерфейсом IEventSubscriber и методом ResolveEvent(T eventStruct), и дальше подписывать-отписывать-вызывать это всё примерно так же, как тут уже было с апдейтером и инъекцией зависимостей. При каждом срабатывании ивента будет создаваться новый экземпляр стракта Т. Тот, кто запустил ивент, будет в этот экземпляр стракта закидывать нужные параметры для передачи, а подписчик будет их оттуда забирать. Наверняка есть и ещё способы.

Однако проблему с переводом сущностей из одной категории в другую до конца-то это всё не решает.

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

Т.е. неважно, использовать мессенджер или нет, некоторые классы-”прокладки” всё равно придётся писать. Например, эдакий э-э-э AudioDispatcher. Пусть он ловит в мессенджере все события, которые должны запускать проигрывание аудио (OnButtonClicked, OnPlayerReceivedDamage, OnEnemyDied), по каким-нибудь Dictionary находит нужный аудиоклип и отправляет обратно в мессенджер событие OnAudioPlayRequested, передавая в качестве параметра уже аудиоклип. А монобехэвиор AudioFXPlayerMono будет ловить в мессенджере этот ивент OnAudioPlayRequested. Мне кажется, что для максимальной отвязанности всего от всего это наилучший вариант, хотя, с другой стороны, цепочка вызовов для одного простого действия получается уже какой-то больно длинной, и не скажется ли это на производительности… Плюс, я не уверен, что это нормально увяжется с изначальной схемой "типа MVC".

В общем, не знаю... Может, есть какой-то способ проще, но я не в курсе.

В Ten-Hut я в итоге сделал контроллер AudioFXDispatcher, который подписывается на события из других контроллеров (анимации, кнопок, здоровья игрока) и при их срабатывании стучится в описанный выше AudioFXController, запуская там метод PlayAudioClip и передавая туда нужный аудиоклип. Аудиоклип берётся из Dictionary в соответствующих скриптабл-обжект-настройках.

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

2626 показов
171171 открытие
Начать дискуссию