Unity3D система подій і відгуків чи задатки Visual Scripting

Введення

У минулій моїй статті були наведені способи забезпечення «м'якої» зв'язку між компонентами ігровий логіки, засновані на повідомленнях і підписки на них. У загальному сенсі подібні повідомлення можна відправляти на будь-яке, яке нам захочеться, дія або подія, що виникає в роботі компонента: від зміни змінної, до більш складних речей. Однак найчастіше певні події вимагають від нас виконати ряд дій, які недоцільно делегувати. Найпростішим прикладом є звукове оформлення — в компоненті виникла подія, що потребує звукового супроводу. У найпростішому варіанті ми викличемо функцію 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);
}
}


Не складно помітити, що основа системи, абсолютно не являє собою ніякої складності і в загальному-то примітивна. Всі проблеми народжує реалізація візуальної частини системи, пов'язана з інспектором. Детальніше про це читайте нижче.

Редактор

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

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

Перевизначення інспектора для компонента.
Оскільки нам потрібна гнучкість, щоб уніфікувати перевизначення редактора в інспекторі для точок подій, існує два способи:
  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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.