Створення редактора сценаріїв у Unity

До створення кастомних інструментів рано чи пізно приходять всі, хто працює з Unity. Можна довго чинити опір і боятися, але в якийсь момент без редакторів та інспекторів, заточених під потреби команди, просуватися буде неможливо.

Я беру участь у проекті одного дуже талановитого художника, де допомагаю у розробці гри-квесту в ретро піксель-арт стилі. Ми використовуємо Unity, так як обидва маємо тривалий досвід розробки в цьому середовищі. Практично відразу виникла необхідність створення поставлених подій, кат-сцен і головоломок, під час яких низка дій строго визначена. Спочатку я спробував відбутися якомога меншою кров'ю і запропонував використовувати стандартний Animator Controller і клас StateMachineBehaviour з Unity 5 для кастомізації подій, але як виявилося цей підхід не працює: кінцевий автомат аніматора хоч і універсальний, але зажадав би надмірної кількості зайвих дій для абсолютно лінійних речей, а нам необхідно було схоже візуальне рішення, але дозволяє легко і просто вибудовувати події як в таймлайні відео-редакторів.


Картинка з документації Unity, яка надихнула на створення власного редактора

Таким чином написання свого власного повноцінного редактора виявилося неминучим.


До цього моменту я писав тільки свої інспектори для класів MonoBehaviour. На мій погляд підхід, використовуваний Unity для інтерфейсів редактора дещо громіздкий, тому я дуже боявся того, що може вийти при написанні цілого вікна з таймлайном. У результаті що вийшло: так, громіздко, але ні, нічого страшного, очі і свідомість звикають.

Отже, завдання відразу легко розбивається на дві: основа системи сценаріїв і сам інтерфейс.

Система сценаріїв

Логіка роботи проста: для кожного сценарію повинен визначатися список подій, які будуть стартувати і закінчуватися в строго певний час. Якщо ми визначимо ці дії, яким чином їх зберігати? Вбудований клас Unity MonoBehaviour автоматично серіалізует підтримувані поля, але для того щоб він працював, скрипт повинен бути призначений активного об'єкту в сцені. Це підходить для класу сценарію, але не підходить для наших дій — на кожну абстрактну сутність довелося б створити по реальному об'єкту в ієрархії. Для нашої мети в Unity існує клас ScriptableObject, життєвий цикл якого схожий на MonoBehaviour але з деякими обмеженнями, а головне, при цьому не вимагає об'єкта в сцені для існування і виконання коду.

Все що робить клас сценаріїв — це запускає корутину, яка на кожному кадрі перевіряє скільки часу пройшло і кого зараз треба запустити, оновити або зупинити. Тут наведу основний метод (посилання на повні вихідні коди в кінці):

Scenario.cs
private IEnumerator ExecuteScenario()
{
Debug.Log("[EventSystem] Started execution of " + gameObject.name);
_time = 0f;

var totalDuration = _actions.Any () ? _actions.Max (action => action.EndTime) : 0f;

var isPlaying = true;

while (isPlaying)
{
for (var i = 0; i < _actions.Count; i++)
{
var action = _actions.ElementAt(i);
if (_time >= action.StartTime && _time < action.EndTime)
{
if (action.NowPlaying)
action.ActionUpdate(ref _time); // дії можуть керувати плином часу сценарію
// ризиково, але необхідно для універсального способу "пропуску" подій
else
action.ActionStart(_time); 
}
else if (_time >= action.EndTime)
{
if (!action.NowPlaying) continue;
action.Stop();
}
}
if(_time >= totalDuration)
isPlaying = false;

_time += Time.deltaTime;
yield return null;
}

foreach (var eventAction in _actions.Where(eventAction => eventAction.NowPlaying))
eventAction.Stop(); // так як дії можуть управляти часом - нам потрібен захист від них

_coroutine = null;
if(_callback != null) // якщо користувач хоче - повідомимо йому про завершення сценарію
_callback();
Debug.Log("[EventSystem] Finished executing " + gameObject.name);
_canPlay = !PlayOnce;
}



Для EventAction я визначив три значущі події: «Початок життя», «Момент» (викликається кожен кадр) і «Кінець». В залежності від самої дії може знадобиться те чи інше, наприклад «соориентировать камеру на самому початку», «оновити положення, поки дія відбувається», «повернути управління гравцеві в кінці». Для створення власного дії досить в класі-спадкоємці перевизначити відповідні методи.

EventAction.cs
using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Visc
{
public abstract class EventAction : ScriptableObject
{
// універсальні для кожного дії параметри
[SerializeField] protected string _description;
[SerializeField] protected GameObject _actor;
[SerializeField] protected float _startTime;
[SerializeField] protected float _duration = 1f;

public GameObject Actor { get { return _actor; } }
public string Description { get { return _description; } }
public float StartTime { get { return _startTime; } set { _startTime = value >= 0f ? value : 0f; } }
public float Duration { get { return _duration; } set { _duration = value >= 0.1 f ? value : 0.1 f; } }
public float EndTime { get { return _startTime + _duration; } }

public bool NowPlaying { get; protected set; }

public void ActionStart(float starTime)
{
Debug.Log("[EventSystem] Started event " + _description);
NowPlaying = true;
OnStart(starTime);
}

public void ActionUpdate(ref float timeSinceActionStart) { OnUpdate(ref timeSinceActionStart); }

public void Stop()
{
Debug.Log("[EventSystem] Finished event " + _description);
NowPlaying = false;
OnStop();
}

// для кастомізації необхідно перевизначити ці методи
// не кожному дії необхідний кожен з цих методів
protected virtual void OnEditorGui() { }
protected virtual void OnStart(float startTime) { }
protected virtual void OnUpdate(ref float currentTime) { }
protected virtual void OnStop() { }
}
}



З простим покінчено, тепер найцікавіше.

Інтерфейс редактора сценаріїв

Стара система інтерфейсів Unity продовжує своє існування для gui редактора (кастомних інспекторів і вікон) і працює наступним чином: при виникненні певних подій (клік мишею, оновлення даних, явний виклик Repaint()) викликає спеціальний метод інтерфейсу класу, який у свою чергу робить виклики, які малюють елементи інтерфейсу. Стандартні елементи можуть бути автоматично розташовані у вікні, всі вони знаходяться в класах GUILayout і EditorGUILayout, я використовував їх для простих властивостей сценарію і візуальної настройки:

Базові параметри

Для створення власного вікна редактора необхідно успадковуватися від EditorWindow і визначити метод OnGUI():

ScenarioEditorWindow.cs
private void OnGUI()
{
if (CurrentScenario != null)
{
// початок горизантольного блоку - необхідно для автоматичного позиціонування
GUILayout.BeginHorizontal();

if(Application.isPlaying)
if(GUILayout.Button("PLAY"))
_currentScenario.Execute();

GUILayout.BeginHorizontal(); 
// саме так і виглядає імплементація інтерфейсу
CurrentScenario.VisibleScale = EditorGUILayout.Slider("Scale", CurrentScenario.VisibleScale, 0.1 f, 100f);
CurrentScenario.MaximumDuration = EditorGUILayout.FloatField("Max duration (seconds)",
CurrentScenario.MaximumDuration);
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
CurrentScenario.MaximumTracks = EditorGUILayout.IntField("Max tracks", CurrentScenario.MaximumTracks);
BoxHeight = EditorGUILayout.IntSlider("Track height", BoxHeight, 20, 50);

if (_draggedAction == null)
{
var newVisibleDuration = CurrentScenario.MaximumDuration/CurrentScenario.VisibleScale;
var newScale = newVisibleDuration*CurrentScenario.VisibleScale/_visibleDuration;
_visibleDuration = newVisibleDuration;
CurrentScenario.VisibleScale = newScale;
}

GUILayout.EndHorizontal();

GUILayout.BeginHorizontal();
CurrentScenario.PlayOnce = EditorGUILayout.Toggle("Play once", CurrentScenario.PlayOnce);
GUILayout.EndHorizontal();

if (GUILayout.Button("Save"))
EditorSceneManager.MarkAllScenesDirty();

GUILayout.EndHorizontal();
}
else
{
_eventActionTypes = null;
GUILayout.Label("Select scenario");
}
{



Але в базовій бібліотеці елементів немає необхідного мені, а саме перетягуємо боксів, які можуть існувати на кількох доріжках і змінювати свій розмір (є GUI.Window, але це не зовсім те). Тому довелося робити вручну, а саме: самостійно малювати прямокутники, відповідних дій, наприклад:

// Перевірка на потрапляння події в область видимості
if(action.EditingTrack < _trackOffset || action.EditingTrack >= _trackOffset + maxVisibleTracks) continue;

var horizontalPosStart = position.width * (action.StartTime / duration) - hOffset;
var horizontalPosEnd = position.width * (action.EndTime / duration) - hOffset;
var width = horizontalPosEnd - horizontalPosStart;

// Центральний прямокутник
var boxRect = new Rect (horizontalPosStart + HandleWidth, offset + BoxHeight * (action.EditingTrack - _trackOffset), width - HandleWidth * 2, BoxHeight);
EditorGUIUtility.AddCursorRect (boxRect, MouseCursor.Pan);

// Крайній лівий прямокутник, за який можна "вхопитися"
var boxStartHandleRect = new Rect (horizontalPosStart, offset + BoxHeight * (action.EditingTrack - _trackOffset), HandleWidth, BoxHeight);
EditorGUIUtility.AddCursorRect (boxStartHandleRect, MouseCursor.ResizeHorizontal);
GUI.Box (boxStartHandleRect, "<");

// Правий прямокутник
var boxEndHandleRect = new Rect (horizontalPosEnd - HandleWidth, offset + BoxHeight * (action.EditingTrack - _trackOffset), HandleWidth, BoxHeight);
EditorGUIUtility.AddCursorRect (boxEndHandleRect, MouseCursor.ResizeHorizontal);
GUI.Box (boxEndHandleRect, ">");

// Виклик методу, який, якщо скасоване, може намалювати свій інтерфейс поверх
action.DrawTimelineGui (boxRect);


Цей код намалює такий бокс:

Подія переміщення об'єкта

Unity дозволяє визначити натиснуту кнопку (Event.current.type == EventType.MouseDown && Event.current.button == 0), дізнатися, чи потрапляє курсор в прямокутник (Rect.Contains(Event.current.mousePosition)) або навіть заборонити обробку натискання кнопки в цьому кадрі далі за кодом (Event.current.Use()). З допомогою цих стандартних засобів я реалізував взаємодія: події можна перетягувати, виділяти відразу кілька, змінювати їх довжину. Коли користувач клікає або рухає бокс, то насправді змінюються параметри відповідної дії, а інтерфейс перефарбували в слід за ними. По правому кліку дію можна додати або видалити, а при подвійному кліці відкривається вікно редагування:

Звідки береться інтерфейс для кожної дії? У EventAction я додав ще два віртуальних методів, що відносяться тільки до редактора: OnEditorGui() і OnDrawTimelineGui() — вони дозволяють визначити інтерфейс при редагуванні дії і навіть для відображення в таймлайні редактора.

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

CameraTargetControl.cs
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;

namespace Platformer
{
public class CameraTargetControl : EventAction
{
[SerializeField] private bool _turnOffTargetingAtStart;
[SerializeField] private bool _turnOnTargetingAtEnd;
[SerializeField] private bool _targetActorInstedOfPlayerAtStart;
[SerializeField] private bool _targetPlayerInTheEnd;

protected override void OnStart(float startTime)
{
if(_turnOffTargetingAtStart) GameManager.CameraController.SetTarget(null);
else if (_targetActorInstedOfPlayerAtStart) GameManager.CameraController.SetTarget(_actor.transform);
}

protected override void OnStop()
{
if(_turnOnTargetingAtEnd || _targetPlayerInTheEnd) GameManager.CameraController.SetTarget(GameManager.PlayerController.transform);
}


#if UNITY_EDITOR
protected override void OnEditorGui()
{
_turnOffTargetingAtStart = EditorGUILayout.Toggle("Camera targeting off", _turnOffTargetingAtStart);

if (_turnOffTargetingAtStart)
_turnOnTargetingAtEnd = EditorGUILayout.Toggle("Targeting on in the end", _turnOnTargetingAtEnd);
else
{
_turnOnTargetingAtEnd = false;
_targetActorInstedOfPlayerAtStart = EditorGUILayout.Toggle("Target actor", _targetActorInstedOfPlayerAtStart);
if (_targetActorInstedOfPlayerAtStart)
_targetPlayerInTheEnd = EditorGUILayout.Toggle("Target player in the end", _targetPlayerInTheEnd);
}
}
#endif
}
}



Що в підсумку вийшло?




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

Висновок
Я вважаю, що основна мета була досягнута. У проекту все ще попереду, належить полірування і фікс багів, але вже на даному етапі він успішно виконує свою функцію. Перш ніж приступити до нього, я довго прочісував інтернет в надії знайти щось готове, але не зміг. Тепер я викладаю його для всіх і сподіваюся, що ця робота може виявитися корисною комусь крім нас.

Проект доступний на гітхабі під MIT License github.com/marcellus00/visc

Джерело: Хабрахабр

0 коментарів

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