An Introduction to Editor Scripting
Vérifié avec version: 2018.1
-
Difficulté: Débutant
You can use editor scripting inside Unity to make life easier for your game designers, or even yourself. With a small amount of code, you can automate some of the more tedious aspects of using the inspector to configure your behaviours, and provide visual feedback on configuration changes. We will create a simple projectile and launcher system to demonstrate some of the basics that can be achieved by scripting the editor.
In this tutorial:
- How to expose methods in the Inspector
- How to use Handles to create custom Gizmos
- How to use field attributes to customise the Inspector
Get Started with Simple Techniques
We start with a basic projectile class, which lets the user assign to the rigidbody field, which provides the Physics behaviour. We will then extend this class to make it easier to use.
Code snippet
public class Projectile : MonoBehaviour
{
public Rigidbody rigidbody;
}
When you add the above component to a GameObject, you will need to also add a Rigidbody component. We can make this happen automatically by using a RequireComponent attribute which will automatically add the Rigidbody component (if it doesn’t already exist) when the Projectile component is first added to a GameObject.
Code snippet
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
public Rigidbody rigidbody;
}
Let’s make it even better, by auto-assigning the Rigidbody component to the rigidbody field at the same time. We do this using the Reset method, which is called when you first add the component to a GameObject. You can also call the Reset method manually by right clicking the component header in the inspector, and choosing the ‘Reset’ menu item.
Code snippet
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
public Rigidbody rigidbody;
void Reset()
{
rigidbody = GetComponent<Rigidbody>();
}
}
Finally, we can minimise the valuable screen space taken up the inspector GUI, by hiding the rigidbody field using a HideInInspector attribute. We can also remove editor warnings by using the ‘new’ keyword on the field declaration.
Code snippet
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
[HideInInspector] new public Rigidbody rigidbody;
void Reset()
{
rigidbody = GetComponent<Rigidbody>();
}
}
These are very simple techniques you can use on all your components to keep things clean and tidy, and minimise configuration mistakes.
Simple Inspector Customisation
The next class we look at is the Launcher class. It instantiates a new Projectile and modifies the velocity so that it shoots forward at a specified velocity. (It will actually launch any prefab with a RigidBody component.)
Code snippet
public class Launcher : MonoBehaviour
{
public Rigidbody projectile;
public Vector3 offset = Vector3.forward;
public float velocity = 10;
public void Fire()
{
var body = Instantiate(
projectile,
transform.TransformPoint(offset),
transform.rotation);
body.velocity = Vector3.forward * velocity;
}
}
The first thing we can add is a Range attribute to the ‘velocity’ field which creates a slider in the inspector GUI. The designer can then quickly slide this value around to experiment with different velocities, or type in an exact figure. We also add a ContextMenu attribute to the ‘Fire’ method, which allows us to run the method by right clicking the component header in the inspector. You can do this with any method (as long as it has zero arguments) to add editor functionality to your component.
Code snippet
public class Launcher : MonoBehaviour
{
public Rigidbody projectile;
public Vector3 offset = Vector3.forward;
[Range(0, 100)] public float velocity = 10;
[ContextMenu("Fire")]
public void Fire()
{
var body = Instantiate(
projectile,
transform.TransformPoint(offset),
transform.rotation);
body.velocity = Vector3.forward * velocity;
}
}
To take this example further, we need to write a Editor class to extend the editor functionality of the Launcher component. The class has a CustomEditor attribute which tells Unity which component this custom editor is used for. The OnSceneGUI method is called when the scene view is rendered, allowing us to draw widgets inside the scene view. As this is an Editor class, it must be inside a folder named ‘Editor’ somewhere in your project.
Code snippet
using UnityEditor;
[CustomEditor(typeof(Launcher))]
public class LauncherEditor : Editor
{
void OnSceneGUI()
{
var launcher = target as Launcher;
}
}
Lets add to the OnSceneGUI method so that we can have a widget which allows us to display and adjust the offset position inside the scene view. Because the offset is stored relative to the parent transform, we need to use the Transform.InverseTransformPoint and Transform.TransformPoint methods to convert the offset position into world space for use by the Handles.PositionHandle method, and back to local space for storing in the offset field.
Code snippet
using UnityEditor;
[CustomEditor(typeof(Launcher))]
public class LauncherEditor : Editor
{
void OnSceneGUI()
{
var launcher = target as Launcher;
var transform = launcher.transform;
launcher.offset = transform.InverseTransformPoint(
Handles.PositionHandle(
transform.TransformPoint(launcher.offset),
transform.rotation));
}
}
We can also create a custom Projectile editor class. Let's add a damageRadius field to the Projectile class, which could be used in the game code to calculate which other GameObjects might be affected by the projectile.
Code snippet
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
[HideInInspector] new public Rigidbody rigidbody;
public float damageRadius = 1;
void Reset()
{
rigidbody = GetComponent<Rigidbody>();
}
}
We might be tempted to add a simple Range attribute to the damageRadius field, however we can do better by visualising this field in the scene view. We create another Editor class for the Projectile component, and use a Handles.RadiusHandle to visualise the field, and allow it to be adjusted in the scene view.
Code snippet
using UnityEditor;
[CustomEditor(typeof(Projectile))]
public class ProjectileEditor : Editor
{
void OnSceneGUI()
{
var projectile = target as Projectile;
var transform = projectile.transform;
projectile.damageRadius = Handles.RadiusHandle(
transform.rotation,
transform.position,
projectile.damageRadius);
}
}
We should also add a Gizmo so we can see the Projectile in the scene view when it has no renderable geometry. Here we have used a DrawGizmo attribute to specify a method which is used to draw the gizmo for the Projectile class. This can also be done by implementing OnDrawGizmos and OnDrawGizmosSelected in the Projectile class itself, however it is better practice to keep editor functionality separated from game functionality when possible, so we use the DrawGizmo attribute instead.
Code snippet
using UnityEditor;
[CustomEditor(typeof(Projectile))]
public class ProjectileEditor : Editor
{
[DrawGizmo(GizmoType.Selected | GizmoType.NonSelected)]
static void DrawGizmosSelected(Projectile projectile, GizmoType gizmoType)
{
Gizmos.DrawSphere(projectile.transform.position, 0.125f);
}
void OnSceneGUI()
{
var projectile = target as Projectile;
var transform = projectile.transform;
projectile.damageRadius = Handles.RadiusHandle(
transform.rotation,
transform.position,
projectile.damageRadius);
}
}
Widgets in the Scene View
We can also use Editor IMGUI methods inside OnSceneGUI, to create any kind of scene view editor control. We are going to expose the Fire method of the Launcher component using a button inside the scene view. We calculate a screen space rect right next to the offset world position where we want to draw the GUI. Also, we don’t want to call Fire during edit mode, only when the game is playing, so we wrap Fire method call in a EditorGUI.DisabledGroupScope which will only enable the button when we are in Play mode.
Code snippet
using UnityEditor;
[CustomEditor(typeof(Launcher))]
public class LauncherEditor : Editor
{
void OnSceneGUI()
{
var launcher = target as Launcher;
var transform = launcher.transform;
launcher.offset = transform.InverseTransformPoint(
Handles.PositionHandle(
transform.TransformPoint(launcher.offset),
transform.rotation));
Handles.BeginGUI();
var rectMin = Camera.current.WorldToScreenPoint(
launcher.transform.position +
launcher.offset);
var rect = new Rect();
rect.xMin = rectMin.x;
rect.yMin = SceneView.currentDrawingSceneView.position.height -
rectMin.y;
rect.width = 64;
rect.height = 18;
GUILayout.BeginArea(rect);
using (new EditorGUI.DisabledGroupScope(!Application.isPlaying))
{
if (GUILayout.Button("Fire"))
launcher.Fire();
}
GUILayout.EndArea();
Handles.EndGUI();
}
}
Physics in game design can be hard to debug, so let’s add a helper for the designer which displays an estimate of where the projectile will be after 1 second of flight time. We need the mass of the projectile to calculate this position, therefore we check that the rigidbody field is not null before attempting the calculation. We also draw a dotted line from the launcher object to the offset position for clarity (using Handles.DrawDottedLine), letting the designer know that this position handle is modifying the offset field, not the transform position. Let’s also add a label to the offset handle using Handles.Label.
This is done using a method with a DrawGizmo attribute, in the same way as the ProjectileEditor. We also add an Undo.RecordObject call, which, with the help of EditorGUI.ChangeCheckScope allows us to record an undo operation when the offset is changed. (If you haven’t seen the using statement before, you can read up on it at MSDN.)
Code snippet
using UnityEditor;
[CustomEditor(typeof(Launcher))]
public class LauncherEditor : Editor
{
[DrawGizmo(GizmoType.Pickable | GizmoType.Selected)]
static void DrawGizmosSelected(Launcher launcher, GizmoType gizmoType)
{
var offsetPosition = launcher.transform.position + launcher.offset;
Handles.DrawDottedLine(launcher.transform.position, offsetPosition, 3);
Handles.Label(offsetPosition, "Offset");
if (launcher.projectile != null)
{
var endPosition = offsetPosition +
(launcher.transform.forward *
launcher.velocity /
launcher.projectile.mass);
using (new Handles.DrawingScope(Color.yellow))
{
Handles.DrawDottedLine(offsetPosition, endPosition, 3);
Gizmos.DrawWireSphere(endPosition, 0.125f);
Handles.Label(endPosition, "Estimated Position");
}
}
}
void OnSceneGUI()
{
var launcher = target as Launcher;
var transform = launcher.transform;
using (var cc = new EditorGUI.ChangeCheckScope())
{
var newOffset = transform.InverseTransformPoint(
Handles.PositionHandle(
transform.TransformPoint(launcher.offset),
transform.rotation));
if(cc.changed)
{
Undo.RecordObject(launcher, "Offset Change");
launcher.offset = newOffset;
}
}
Handles.BeginGUI();
var rectMin = Camera.current.WorldToScreenPoint(
launcher.transform.position +
launcher.offset);
var rect = new Rect();
rect.xMin = rectMin.x;
rect.yMin = SceneView.currentDrawingSceneView.position.height -
rectMin.y;
rect.width = 64;
rect.height = 18;
GUILayout.BeginArea(rect);
using (new EditorGUI.DisabledGroupScope(!Application.isPlaying))
{
if (GUILayout.Button("Fire"))
launcher.Fire();
}
GUILayout.EndArea();
Handles.EndGUI();
}
}
}
If you try this out in your editor, you will notice that the position estimate is not very accurate! Let's change the calculation to take gravity into account, and draw a curved path with Handles.DrawAAPolyLine and Gizmos.DrawWireSphere through the one second flight time trajectory. If we use Handles.DrawingScope to change the colour of the widgets, we don’t need to worry about setting it back to the previous handle colour when the method finishes.
Code snippet
[DrawGizmo(GizmoType.Pickable | GizmoType.Selected)]
static void DrawGizmosSelected(Launcher launcher, GizmoType gizmoType)
{
{
var offsetPosition = launcher.transform.TransformPoint(launcher.offset);
Handles.DrawDottedLine(launcher.transform.position, offsetPosition, 3);
Handles.Label(offsetPosition, "Offset");
if (launcher.projectile != null)
{
var positions = new List<Vector3>();
var velocity = launcher.transform.forward *
launcher.velocity /
launcher.projectile.mass;
var position = offsetPosition;
var physicsStep = 0.1f;
for (var i = 0f; i <= 1f; i += physicsStep)
{
positions.Add(position);
position += velocity * physicsStep;
velocity += Physics.gravity * physicsStep;
}
using (new Handles.DrawingScope(Color.yellow))
{
Handles.DrawAAPolyLine(positions.ToArray());
Gizmos.DrawWireSphere(positions[positions.Count - 1], 0.125f);
Handles.Label(positions[positions.Count - 1], "Estimated Position (1 sec)");
}
}
}
}
In Conclusion
These are some very simple ways you can improve the editor experience for other game designers and yourself. Using Editor.OnSceneGUI, you have the ability to create any kind of editor tool, right inside the scene view. It is definitely worthwhile becoming familiar with the Handles class and all the functionality it can provide you, helping you smooth out the game design and development process for yourself and your team.