Unity Editor Integration with Pure Code
One apparent downside to the “pure code” approach to Unity app code design is that it makes less use of the Unity Editor. Because it only uses one main MonoBehaviour
, there aren’t a lot of MonoBehaviour
classes that can be modified by the Inspector panel- a mainstay in Unity design and debugging. Today’s article introduces another kind of auxiliary MonoBehaviour
to work around this issue enabling you to use the “pure code” approach without sacrificing the Inspector panel.
With the “normal”, non-“pure code” approach to programming a Unity app there are many MonoBehaviour
classes that each have part of the overall app’s logic in them. Each is meant to be attached to a GameObject
that its logic works on. This MonoBehaviour
and the GameObject
combine to represent an actor or entity in the game.
The “normal” approach then makes use of the Unity editor’s Inspector panel by declaring public variables on the MonoBehaviour
which allows the Inspector to read and write them. Their name and a type-specific control are automatically shown for programmers and app designers to view and modify as the app runs. Here’s a quick example of how the class would look:
using UnityEngine; class Enemy : MonoBehaviour { public int Health = 100; }
And here’s how it’d show up in the Unity editor’s Inspector panel:
When that Inspector panel GUI is used to change the health value, the Unity editor automatically sets a new value of the Health
field on the Enemy
class instance attached to the GameObject
. The new value can now be read and written to by anyone with a reference to that class instance since the variable is public
. If you’d rather hide the variable, you can take the less-common path and use the SerializeField attribute.
The “normal” approach would then add on a bunch of app logic using Health
in message functions like Start
and Update
as well as custom functions like TakeDamage
and Respawn
.
With the “normal” approach in mind, let’s look at how we can make use of the Inspector panel for our “pure code” apps. The first step is to create an auxiliary MonoBehaviour
that the app will use to read and write from the Inspector panel. This class will not have any app logic in it. Its sole purpose is to enable the Inspector panel GUI.
As with the “normal” approach, a field is provided for the Inspector panel to read and write. Using the SerializeField
attribute on a private field is recommended to disable access to any class except the Inspector panel, but a public field can be used as well. Similarly, using attributes like Range(min,max)
are recommended to provide a better GUI to the user and some basic error checking.
So far, the auxiliary MonoBehaviour
looks very much like the “normal” approach’s one:
using UnityEngine; class EnemyInspector : MonoBehaviour { [SerializeField] [Range(0,100)] private int Health = 100; }
The “pure code” approach does not add on logic in the EnemyInspector
class that uses Health
. Instead, it uses Health
only to allow access so other classes can read updated values. On-demand reading can be easily accommodated by simply providing a property with a get
:
using UnityEngine; class EnemyInspector : MonoBehaviour { [SerializeField] [Range(0,100)] private int health = 100; public int Health { get { return health; } } }
Changes can be exposed via C#’s event system by keeping around the previous health value and comparing the current value against it in Update
or other functions like FixedUpdate
:
using UnityEngine; class EnemyInspector : MonoBehaviour { [SerializeField] [Range(0,100)] private int health = 100; private int oldHealth; public int Health { get { return health; } } public delegate void HealthChangedHandler(int oldHealth, int newHealth); public event HealthChangedHandler OnHealthChanged = (oldHealth, newHealth) => {}; void Awake() { oldHealth = health; } void Update() { if (health != oldHealth) { OnHealthChanged(oldHealth, health); oldHealth = health; } } }
A centralized event also provides an opportunity for the main game logic—outside of this class—to validate the field. To do so, simply pass a ref
boolean to the event and check it when the event dispatch is complete:
using UnityEngine; using System.Xml; class EnemyInspector : MonoBehaviour { [SerializeField] [Range(0,100)] private int health = 100; private int oldHealth; public int Health { get { return health; } } public delegate void HealthChangedHandler( int oldHealth, int newHealth, ref bool valid ); public event HealthChangedHandler OnHealthChanged = ( int oldHealth, int newHealth, ref bool valid ) => {}; void Awake() { oldHealth = health; } void Update() { if (health != oldHealth) { var valid = true; if (OnHealthChanged != null) { OnHealthChanged(oldHealth, health, ref valid); } if (valid) { oldHealth = health; } else { health = oldHealth; } } } }
Here’s an example of an event that only allows increments of 10:
enemyInspector.OnHealthChanged += HandleEnemyHealthChanged; private void HandleEnemyHealthChanged(int oldHealth, int newHealth, ref bool valid) { if ((newHealth % 10) != 0) { valid = false; } }
In summary, what we’ve created here is a class dedicated to reading and writing from the Inspector panel in the Unity editor. It contains no logic about this data. Instead, it simply provides access to that data to the “pure code” part of the app: the set of classes used by the “main” MonoBehaviour
. It optionally adds on standard C# events and centralized validation.
The result achieves the basic goal of allowing the Unity editor’s Inspector panel to be used with the “pure code” approach, but arguably goes a step further. The “normal” approach mixes reading, writing, and validating data from the Inspector GUI with the app’s logic code all in one class. The “pure code” approach allows for separation of concerns, which is often viewed as “cleaner” or “better” code design.
Which approach do you think is better? Do you know a better way to make use of the Inspector GUI with the “pure code” approach? Let me know in the comments!
#1 by Phil on January 28th, 2015 ·
Where is the enemy game object created?
#2 by jackson on January 28th, 2015 ·
With the “pure code” approach, the
MonoBehaviour
attached to the mainGameObject
would create it since that’s the only place that app logic resides. It could create it in a variety of ways, though: asset bundle,Resources.Load
,new GameObject
,GameObject.CreatePrimitive
, etc. Once theGameObject
is created, an “inspector”MonoBehaviour
like theEnemyInspector
can be added to it.