Creating a script editor in Unity

    Sooner or later, everyone who works with Unity comes to the creation of custom tools. You can resist and be afraid for a long time, but at some point without editors and inspectors, tailored to the needs of the team, it will be impossible to advance.

    I participate in the project of one very talented artist, where I help in the development of the quest game in retro pixel art style. We use Unity, as we both have a long history of development in this environment. Almost immediately, it became necessary to create set events, cut scenes and puzzles, during which the sequence of actions is strictly defined. At first I tried to get off as little blood as possible and suggested using the standard Animator Controller and the StateMachineBehaviour class from Unity 5 to customize events, but as it turned out this approach does not work: the animator finite state machine, although universal, would require an excessive amount of unnecessary actions for absolutely linear things , and we needed a similar visual solution, but allowing us to easily and simply arrange events as in the timeline of video editors.


    A picture from the Unity documentation that inspired the creation of its own editor.

    Thus, writing your own full-fledged editor turned out to be inevitable.


    Until now, I only wrote my inspectors for MonoBehaviour classes. In my opinion, the approach used by Unity for editor interfaces is somewhat cumbersome, so I was very afraid of what might come out when writing an entire window with a timeline. In the end, what happened: yes, it is cumbersome, but no, it's okay, eyes and consciousness get used to it.

    So, the task immediately easily breaks into two: the basis of the scripting system and the interface itself.

    Scripting system



    The logic of the work is simple: for each scenario, a list of events that will start and end at a strictly defined time should be determined. If we define these actions, how to store them? The built-in Unity MonoBehaviour class automatically serializes supported fields, but in order for it to work, the script must be assigned to the active object in the scene. This is suitable for the script class, but not suitable for our actions - for each abstract entity we would have to create a real object in the hierarchy. For our purpose, Unity has a ScriptableObject class , whose life cycle is similar to MonoBehaviour but with some restrictions, and most importantly it does not require an object in the scene to exist and execute the code.

    All the scripting class does is runcoroutine , which on each frame checks how much time has passed and who needs to be started, updated or stopped now. Here is the main method (link to the full source code at the end):

    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;
    }
    



    For EventAction, I defined three significant events: “The Beginning of Life”, “The Moment Between” (each frame is called) and “The End”. Depending on the action itself, this or that may be necessary, for example, “orient the camera at the very beginning”, “update the position while the action is taking place”, “return control to the player at the end”. To create your own action, it is enough to override the corresponding methods in the successor class.

    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.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() { }
    	}
    }
    



    With simple over, now the fun part.

    Script editor interface



    The old Unity interface system continues to exist for the gui editor (custom inspectors and windows) and works as follows: when certain events occur (mouse click, data update, explicit call Repaint ()) calls a special method of the user class, which in turn makes calls drawing interface elements. Standard elements can be automatically located in the window, they are all in the GUILayout and EditorGUILayout classes, I used them for simple script properties and visual settings:

    Basic parameters

    To create your own editor window, you must inherit from EditorWindow and define the OnGUI () method:

    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.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");
    	}
    {
    



    But in the base library of elements there is no necessary for me, namely draggable boxes that can exist on several tracks and change their size (there is a GUI.Window, but this is not quite right). Therefore, I had to do it manually, namely: draw the rectangles corresponding to the actions myself, for example:

    // Проверка на попадание события в область видимости
    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);
    


    This code will draw such a box:

    The event of moving a

    Unity object allows you to determine the pressed button (Event.current.type == EventType.MouseDown && Event.current.button == 0), to find out if the cursor falls into the rectangle (Rect.Contains (Event. current.mousePosition)) or even prohibit button click processing in this frame further down the code (Event.current.Use ()). Using these standard tools, I implemented the interaction: you can drag and drop events, select several at once, change their length. When the user clicks or moves the box, the parameters of the corresponding action actually change, and the interface is redrawn in the wake of them. By right-clicking an action, you can add or remove, and when you double-click the editing window opens:

    Where does the interface for each action come from? In EventAction, I added two more virtual methods related only to the editor: OnEditorGui () and OnDrawTimelineGui () - they allow you to define the interface when editing an action and even to display it in the editor's timeline.

    For the project, I already wrote some of my own actions that are applicable exclusively to it: an action that displays character dialogs, an action that sets a goal for the main character or triggers its special animation, or for example, EventAction, which allows you to control the camera’s behavior: follow the player, center on the object , turn off centering.

    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
    	}
    }
    



    What happened in the end?




    Known Issues


    Scenario and EventAction are independent entities, so if we duplicate a script and compile its serialized properties, then links to already existing actions will fall into the new script. I plan to remedy this situation by storing scenario-action relationships, but for now I'm thinking about it.

    Conclusion


    I believe that the main goal has been achieved. The project is still ahead, polishing and fixing bugs remains, but already at this stage it successfully performs its function. Before starting it, I searched the Internet for a long time in the hope of finding something ready, but I could not. Now I am putting it out for everyone and I hope that this work can be useful to someone other than us.

    The project is available on github under the MIT License github.com/marcellus00/visc

    Also popular now: