Создание редактора сценариев в Unity
К созданию кастомных инструментов рано или поздно приходят все, кто работает с Unity. Можно долго сопротивляться и бояться, но в какой-то момент без редакторов и инспекторов, заточенных под нужды команды, продвигаться будет невозможно.
Я участвую в проекте одного очень талантливого художника, где помогаю в разработке игры-квеста в ретро пиксель-арт стиле. Мы используем Unity, так как оба имеем длительный опыт разработки в этой среде. Практически сразу возникла необходимость создания поставленных событий, кат-сцен и головоломок, во время которых череда действий строго определена. Сначала я попытался отделаться как можно меньшей кровью и предложил использовать стандартный Animator Controller и класс StateMachineBehaviour из Unity 5 для кастомизации событий, но как оказалось этот подход не работает: конечный автомат аниматора хоть и универсален, но потребовал бы чрезмерного количества лишних действий для абсолютно линейных вещей, а нам было необходимо похожее визуальное решение, но позволяющее легко и просто выстраивать события как в таймлайне видео-редакторов.
Картинка из документации Unity, вдохновившая на создание собственного редактора
Таким образом написание своего собственного полноценного редактора оказалось неминуемым.
До этого момента я писал только свои инспекторы для классов MonoBehaviour. На мой взгляд подход, используемый Unity для интерфейсов редактора несколько громоздкий, поэтому я очень боялся того, что может выйти при написании целого окна с таймлайном. В итоге что получилось: да, громоздко, но нет, ничего страшного, глаза и сознание привыкают.
Итак, задача сразу легко разбивается на две: основа системы сценариев и сам интерфейс.
Логика работы простая: для каждого сценария должен определяться список событий, которые будут стартовать и заканчиваться в строго определенное время. Если мы определим эти действия, каким образом их хранить? Встроенный класс Unity MonoBehaviour автоматически сериализует поддерживаемые поля, но для того чтобы он работал, скрипт должен быть назначен активному объекту в сцене. Это подходит для класса сценария, но не подходит для наших действий — на каждую абстрактную сущность пришлось бы создать по реальному объекту в иерархии. Для нашей цели в Unity существует класс ScriptableObject, жизненный цикл которого похож на MonoBehaviour, но с некоторыми ограничениями, а главное при этом не требует объекта в сцене для существования и выполнения кода.
Все что делает класс сценариев — это запускает корутину, которая на каждом кадре проверяет сколько времени прошло и кого сейчас надо запустить, обновить или остановить. Здесь приведу основной метод (ссылка на полные исходники в конце):
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 я определил три значимых события: «Начало жизни», «Момент между» (вызывается каждый кадр) и «Конец». В зависимости от самого действия может понадобится то или иное, например «соориентировать камеру в самом начале», «обновить положение, пока действие происходит», «вернуть управление игроку в конце». Для создания собственного действия достаточно в классе-наследнике переопределить соответствующие методы.
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.1f ? value : 0.1f; } }
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 ():
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.1f, 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, позволяющее управлять поведением камеры: следовать за игроком, центрироваться на объекте, отключить центрирование.
#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