Pull to refresh

Система событий и откликов или задатки Visual Scripting для Unity3D

Reading time 10 min
Views 12K

Введение


В прошлой моей статье были приведены способы обеспечения “мягкой” связи между компонентами игровой логики, основанные на уведомлениях и подписки на них. В общем смысле подобные уведомления можно посылать на любое, какое нам захочется, действие или событие, возникающее в работе компонента: от изменения переменной, до более сложных вещей. Однако зачастую определенные события требуют от нас выполнить ряд действий, которые нецелесообразно делегировать. Самым простым примером является звуковое оформление — в компоненте возникло событие, которое требует звукового сопровождения. В простейшем варианте мы вызовем функцию AudioSource.Play(), в чуть более сложном, функцию обертки над звуковой системой. В принципе ничего страшного в этом нет, если проект небольшой и в команде мало людей, которые совмещают в себе множество ролей, но если это проект крупный, где есть несколько программистов и саунд-дизайнер, то, в частности, настройка звуков превратится для программиста в кошмар. Не секрет же, что мы стараемся абстрагироваться от контента и поменьше с ним работать в плане настройки, ибо правильнее, если этим будут заниматься ответственные специалисты, а не мы.

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

События и отклики


Немного вступления

Я думаю ни для кого не секрет, что из себя представляет игровой процесс с точки зрения программиста. Однако все же я поясню, чтобы читателям была понятна моя точка зрения.

Итак, весь код, который мы пишем для реализации игрового процесса изначально представляет из себя набор компонентов, каждый из которых является генератором ряда событий, происходящих в игровом мире и влияющих на него, либо на другие компоненты. В простейшем варианте разбиение логики на компоненты происходит по событиям, которые группируются по критерию источника: персонаж, противник, интерфейс, окно интерфейса и т.д и т.п. Однако, сами по себе события еще не формируют игровой процесс, нам нужные действия (отклики), совершаемые в ответ на события. Например, персонаж сделал шаг и сгенерировал соответствующее события, в ответ на которое нам надо проиграть звук, проиграть эффект пыли из-под ног и возможно еще потрясти камеру. В более сложном варианте действия, совершаемые в ответ на события, могут также генерировать новые события, я это называю “эффектом звуковой волны”. Таким образом формируются “поверхность” нашего игрового процесса, как цепь событий и откликов.

В большинстве случаев описанный выше механизм формируется непосредственно в коде, но как было сказано ранее, ряд событий может (и будет) связан с контентом, обеспечивающим оживление и восприятие игры — ту часть, за которую отвечают художники и дизайнеры. При не правильном процессе управления проектом и неверном подходе к производству игры большинство подобных задач решается за счет программиста. Художник заленился – программист напишет “хак” и все будет хорошо. Для времен зарождения “russian-геймдев” это было хорошо, однако сейчас нужны качество, скорость и гибкость, поэтому я решил пойти другим путем и путь этот связан с Unity Editor
Extensions
.

Пара слов об архитектуре

Ниже представлена небольшая блок-схема, показывающая основные элементы, необходимые для реализации механизма событий и откликов, которая позволит настраивать дизайнерам определенные элементы игровой логики самостоятельно.
image
Итак, базовыми элементами системы являются EventPoint (точка события в компоненте) и Action (отклик на событие). Каждый из этих элементов требует для настройки специализированного редактора, который будет реализован через Editor Extensions, в нашем случае через custom inspector (подробнее об этом будет написано в отдельном разделе).

В базовом сценарии каждая точка события может порождать не один отклик, а множество, при этом каждый отклик, может также быть генератором событий и содержать в себе точки входа в них. Помимо этого, отклик должен уметь оперировать базовыми сущностями движка Unity3D.
На основе выше написанного, я решил, что наиболее простым вариантом будет сделать Action наследником от MonoBehaviour, а EventPoint сделать частью логики компонентов в виде публичного поля, которое должно сериализоваться стандартным механизмом Unity3D.

Остановимся подробнее на этих элементах.

EventPoint
[Serializable]
public class EventPoint  
{
    public List<ActionBase> ActionsList = new List<ActionBase>();

    public void Occurrence()
    {
        foreach (var action in ActionsList)
        {
            action.Execute();
        }
    }
}


Как видно из кода все достаточно просто. EventPoint по сути — это список, в котором хранится набор Action’ов, которые привязаны к нему. Вход в точку события в коде происходит через вызов функции Occurence. ActionsList сделан публичным полем, чтобы он мог быть сериализован средствами Unity3D.

Использование в коде
public class CustomComponent : MonoBehaviour 
{
	[HideInInspector]
	public EventPoint OnStart;	
	
	void Start () 
	{
		OnStart.Occurrence();
	}		
}


Как было сказано выше Action’ы должен быть наследником от MonoBehaviour, а также поскольку точке события должно быть все равно, что за отклик она вызывает, сделаем обертку над этим классом в виде абстракции.

ActionBase
public abstract class ActionBase : MonoBehaviour
{
    public abstract void Execute();
}


Банально и примитивно. Функция Execute необходима для запуска логики отклика при вхождении в точку события.

Теперь можно реализовать отклик (как пример - включение и выключение объекта сцены)
public class ActionSetActive : ActionBase
{
	public GameObject Target;
	public bool Value;

	public override void Execute()
	{
		Target.SetActive(Value);
	}
}


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

Редактор


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

Начнем по порядку:

Переопределение инспектора для компонента.

Поскольку нам нужна гибкость, то чтобы унифицировать переопределение редактора в инспекторе для точек событий, существует два способа:
  1. Переопределение инспектора для конкретного типа свойства (в нашем случае EventPoint)
  2. Переопределение инспектора для компонента целиком (в нашем случае наследника MonoBehaviour)

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

Пользовательский редактор для всех наследников MonoBehaviour
[CustomEditor(typeof(MonoBehaviour), true)]
public class CustomMonoBehaviour_Editor : Editor 
{
         //тело класса рассмотрим ниже
}


Получение и вывод списка откликов

Для того, чтобы система была максимально гибкой, нам необходимо в инспекторе выводить все реализованные нами отклики (классы наследники от ActionBase) и путем простого выбора из списка добавлять их к точке события.

Реализуется это следующим образом
void OnEnable()
{
     var runtimeAssembly = Assembly.GetAssembly(typeof(ActionBase));

     m_actionList.Clear();

     foreach (var checkedType in runtimeAssembly.GetTypes())
     {
         if (typeof(ActionBase).IsAssignableFrom(checkedType) && checkedType !=  typeof(ActionBase))
         {
             m_actionList.Add(checkedType.FullName, checkedType);
         }
     }
}


Как видно, мы получаем список всех наследников от ActionBase, а затем исключаем саму асбтракцию из списка. Имя отклика берется из имени класса. Здесь можно пойти более сложным путем и забирать имя через атрибут класса и также через атрибут можно задавать и короткое описание, что делает отклик. Поскольку я именую классы детально то, я решил пойти по более простому пути.

Вывод списка точек события и публичных полей компонента

Поскольку нам надо переопределить способ вывода только точек событий, то функция рисования инспектора примет следующий вид.

Функция отрисовки GUI инспектора
public override void OnInspectorGUI()
{
        serializedObject.Update();

        base.OnInspectorGUI();  //вызов функции отрисовки базового класса для показа публичных полей компонента   

        FindAndEditEventPoint(target); //функция поиска точек событий

        serializedObject.ApplyModifiedProperties(); 
}


Функция для поиска точек событий (EventPoints)
void FindAndEditEventPoint(UnityEngine.Object editedObject)
{
    var fields = editedObject.GetType().GetFields();

    foreach (var fieldInfo in fields)
    {
        if (fieldInfo.FieldType == typeof(EventPoint))
        {
            EventPointInspector(fieldInfo, editedObject); //функция отрисовки инспектора
        }
    }   
}


Главный вопрос, который возникает при взгляде на данный код – это почему используется рефлексия, а не SerializedProperty. Причина проста, поскольку мы переопределяем инспектор для всех наследников MonoBehaviour, то мы понятия не имеем об именовании полей компонентов и их связи с типом, следовательно, мы не можем воспользоваться функцией serializedObject.FindProperty().

Теперь, что касается непосредственной отрисовки инспектора для точек события. Я не буду приводить код целиком, поскольку там достаточно все просто: используется FoldOut стиль для сворачивания и разворачивания информации об откликах. Остановимся на ключевых моментах.

Получение данных об экземпляре класса EventPoint
var eventPoint = (EventPoint)eventPointField.GetValue(editedObject);


Вывод списка откликов и добавление их к точке события
var selectIndex = EditorGUILayout.Popup(-1, actionNames.ToArray());

if (selectIndex >= 0)
{                
    var actionType = m_actionList[actionNames [selectIndex]];                
    var actionObject = (ActionBase)((target as Component).gameObject.AddComponent(actionType));
                
    eventPoint.ActionsList.Add(actionObject);               
}


Инспектор для откликов (Actions)

Как видно из кода выше, отклик добавляется как компонент к объекту, как итог такого действия, этот класс будет отображаться в окне инспектора и будет обработан соответствующим образом (с отображением всех полей и т.п.). Для нас это не является удобным, поскольку мы ходим все манипуляции по настройке параметров производить в одном месте. Первым делом запретим Unity выводить параметры классов откликов в инспекторе.

Делается это путем переопределения редактора для всех наследников ActionBase
[CustomEditor(typeof(ActionBase), true)]
public class ActionBase_Editor : Editor 
{
    public override void OnInspectorGUI()    {}
}


Теперь можно показывать поля классов отклика в инспекторе, который мы переопределили для точки события (привожу базовый код, как добавить стили (FoldOut), думаю пояснять не нужно)
var destroyingComponent = new List<ActionBase>();            

for (var i = 0; i < eventPoint.ActionsList.Count; i++)
{
    EditorGUILayout.BeginHorizontal();

    ActionBaseInspector(eventPoint.ActionsList[i]); //функция отрисовки инспектора для конкретного отклика, рассмотрим ее ниже
                    
    if (GUILayout.Button("-", GUILayout.Width(25)))
    {
        destroyingComponent.Add(eventPoint.ActionsList[i]);                          
    }

    EditorGUILayout.EndHorizontal();
}


Удаление отклика (Action) происходит следующим образом
foreach (ActionBase action in destroyingComponent)
{
    RecursiveFindAndDeleteAction(action);

    eventPoint.ActionsList.Remove(action);
    GameObject.DestroyImmediate(action);          
}
void RecursiveFindAndDeleteAction(object obj)
{
    var fields = obj.GetType().GetFields();
    var destroyingComponent = new List<ActionBase>();  

    foreach (var fieldInfo in fields)
    {
        if (fieldInfo.FieldType == typeof(EventPoint))
        {
            var eventPoint = (EventPoint)fieldInfo.GetValue(obj);

            destroyingComponent.AddRange(eventPoint.ActionsList);

            eventPoint.ActionsList.Clear();
        }
    }

    foreach (ActionBase action in destroyingComponent)
    {
        RecursiveFindAndDeleteAction(action);           		

        GameObject.DestroyImmediate(action);
    }
}


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

Примечание:
Здесь кроется самый странный момент, который я до сих пор не могу побороть: несмотря на то, что Unity рекомендует для удаления компонентов с объекта через редактор использовать DestroyImmediate, оный приводит к появлению ошибки MissingReferenceException в редакторе. При этом она не влияет правильность работы кода. Баг это мой или Unity я так до сих пор и пытаюсь понять. Если кто знает в чем дело, пишите в комментариях.

Отрисовка полей класса, реализующего отклик (Action)

Как было сказано ранее, мы не можем использовать serializedObject.FindProperty() в коде, ибо имена полей нам недоступны, помимо прочего для откликов нам не доступен и serializedObject, поэтому, как и для EventPoint, используется рефлексия, что в свою очередь является самым неудобным моментом во всей системе.

Рисуем инспектор для отклика
void ActionBaseInspector(ActionBase action)
{        
    var fields = action.GetType().GetFields();

    EditorGUILayout.BeginVertical();

    EditorGUILayout.Space();
    EditorGUILayout.Space();
    EditorGUILayout.Space();		

    foreach (FieldInfo fieldInfo in fields)
    {            
        if (fieldInfo.FieldType == typeof(int))
        {
            var value = (int)fieldInfo.GetValue(action);

            value = EditorGUILayout.IntField(fieldInfo.Name, value);

            fieldInfo.SetValue(action, value);
        }
        else if (fieldInfo.FieldType == typeof(float))
        {
            var value = (float)fieldInfo.GetValue(action);

            value = EditorGUILayout.FloatField(fieldInfo.Name, value);

            fieldInfo.SetValue(action, value);
        }
        //и т.д. по всем типам, которые используются в коде откликов           
    }

    FindAndEditEventPoint(action); // рекурсивно проходим по всей цепочке событий и откликов.           

    EditorGUILayout.EndVertical();
}


Как видно, нам придется определять вывод редактора для всех нужных типов поля ручками. С одной стороны, это жесть, с другой, есть возможность переопределить редактор поля, чем я пользуюсь, например, для звуков.

Итог


Если посмотреть на систему целиком, в ней нет ничего сложного, однако те преимущества, которые она дает, просто несопоставимы. Экономия времени и сил огромная. Фактически от программиста теперь требуется по запросу от дизайнеров создать в нужном компоненте логики точку события и вход в нее, а далее, что называется “умыть руки”, конечно помимо этого еще требуется написание кода откликов, но поскольку они являются по сути компонентами Unity, то здесь все ограничено лишь фантазией: можно делать крупные сложные вещи, можно сделать много мелких односложных.

В начале статьи я упомянул, что делегирование обработки событий другим компонентам бывает нецелесообразным, но на самом деле в этом есть свои преимущества с позиции пайплайна. Используя систему сообщений (смотри предыдущую статью), можно определить специализированные компоненты, для их обработки, чтобы иметь возможность создать входы в точки событий. Если поместить такие компоненты в заранее определенное место, то дизайнерам не придется рыскать по иерархии объектов. Этим подходом, например, можно пользоваться для настройки звукового окружения.

Как было сказано ранее система не ограничена длинной цепочки событий и откликов, по сути дизайнер может создавать сколько угодно большие и сложные логики поведения (отсыл к Visual Scripting), однако на практике это не удобно и не читаемо, поэтому лучше ограничивать цепочки на уровне хотя бы устных договоренностей. В ходе своей работы, мы с напарником использовали такие цепочки в расширенном варианте — там для редактирования использовалось отдельное окно (EdtiorWindow). Все было хорошо, пока не появлялась необходимость создавать сложные ветвления в логике. Разобраться спустя некоторое время в этой каше было просто нереально. Именно поэтому в своей системе я ушел в сторону простого решения на основе инспектора.

Что касается в целом Visual Scripting, то сам подход событий и откликов и реализации их через компоненты Unity имеет свои преимущества и, в конце концов, мы с напарником в ходе своей работы над несколькими проектами таки решились на разработку полноценного визуального редактора логики. Что из этого вышла это тема для отдельной статьи, а в завершении хочу привести небольшой ролик, демонстрирующий работу описанной системы, на примере uGUI.
https://youtu.be/sfZ9Tf_EVYE
Tags:
Hubs:
+7
Comments 2
Comments Comments 2

Articles