A tale of despair and finding one's path again
by
(aka Kieran Lord)
Unity is a small team, small game engine that has (mostly) grown into one supporting larger projects.
This leads to issues with larger projects and larger team sizes that need workarounds.
I take a lot of inspiration from old doom/quake editors. The game should be able to drop objects in and have them "just work".
Unity is a tool for building content. Artists & designers should be able to focus on that rather than trying to second guess if their scene is set up right.
Most unity games require one or more system objects in every scene.
Unity's systems have project wide assets that are easily accessible in code. These mostly store project settings
Unity doesn't provide an easy way to set up your own settings in a similar way.
This may have been an early design decision. But on larger projects having ways to store "global config" is pretty crucial.
Actual unity tutorial sreencap
We've all made a project that looked like this.
Solving the kinds of problems this talk is about wont generate more money for Unity (at this time)
Competition might drive this to change in future, but at present that seems a ways off.
So for the time being... we need to find our own workarounds to this.
void Update()
{
gameObject.ಠ_ಠ();
}
public partial class ScoreSettings : ResourceSingleton<ScoreSettings>
{
#if UNITY_EDITOR
[UnityEditor.MenuItem("Edit/Game Settings/Score Settings")]
public static void SelectSettings()
{
UnityEditor.Selection.activeObject = instance;
}
#endif
}
We also want our settings assets to behave and appear just like the unity ones.
public partial class ScoringSettings : ResourceSingleton<ScoringSettings>
{
[SerializeField] CoinPickup.Settings m_coinPickup;
public static CoinPickup.Settings coinPickup { get { return instance.m_coinPickup; } }
}
public class CoinPickup : MonoBehaviour
{
[System.Serializable]
public class Settings
{
int numPoints;
}
void OnTriggerEnter(Collider other)
{
var player = other.GetComponent<ActorPlayer>();
if(player!=null)
{
player.GivePoints(ScoringSettings.coinPickup.numPoints);
}
}
}
Using mario coins as an example. Each coin prefab really doesn't need to store the number of points. It's project wide.
So we want to add our coin related settings to a partial class that stores these kinds of things.
public abstract class ResourceSingleton<T>
: ScriptableObject where T:ScriptableObject
{
static T s_instance;
protected static T instance
{
get
{
LoadAsset();
if(!s_instance)
{
throw new System.ArgumentNullException(
"Couldn't load asset for ResourceSingleton "+typeof(T).Name);
}
return s_instance;
}
}
static void LoadAsset()
{
if(s_instance!=null) return;
if(Application.isPlaying)
{
s_instance = Resources.Load(typeof(T).Name) as T;
}
else
{
#if UNITY_EDITOR
var temp = ScriptableObject.CreateInstance<T>();
var monoscript = MonoScript.FromScriptableObject(temp);
ScriptableObject.DestroyImmediate(temp);
var scriptPath = AssetDatabase.GetAssetPath(monoscript);
var assetDir = Path.GetDirectoryName(scriptPath)+"/Resources/";
var assetPath = Path.GetFileNameWithoutExtension(scriptPath)+".asset";
s_instance = AssetDatabase.LoadAssetAtPath<T>(assetDir+assetPath);
#endif
}
}
}
So now we can store default settings. But quite often we have a case where prefabs will need to override those settings.
We create a ScriptableObject asset type that also stores our settings class
On our behaviour we now add a parameter that accepts that asset type. If it's set we get our values from there. Otherwise they come from the defaults.
[CreateAssetMenu(menuName="Settings/ActorPlayer")]
public class ActorPlayerSettings : ScriptableObject
{
public ActorPlayer.Settings settings;
}
public partial class GlobalDefaults : ResourceSingleton<GlobalDefaults>
{
[FormerlySerializedAs("m_playerSettings")]
[SerializeField] ActorPlayer.Settings m_defaultPlayerSettings;
public static ActorPlayer.Settings defaultPlayerSettings {
get {
return instance.m_defaultPlayerSettings;
}
}
}
[RequireComponent(typeof(PreviewModel))]
public class ActorPlayer : BaseMonoBehaviour, IPreviewModelSource
{
#region settings
[System.Serializable]
public class Settings
{
public GameObject playerModelPrefab;
public float moveSpeed = 1f;
}
[SerializeField] ActorPlayerSettings m_settingsOverride;
public Settings settings {
get {
return m_settingsOverride!=null
? m_settingsOverride.settings
:GlobalDefaults.defaultPlayerSettings;
}
}
#endregion
}
There's a few benefits to them that aren't immediately apparent:
[CreateAssetMenu(menuName="Settings/ActorPlayer")]
public class ActorPlayerSettings : ScriptableObject
{
public ActorPlayer.Settings settings;
}
#if UNITY_EDITOR
[UnityEditor.CustomPropertyDrawer(typeof(ActorPlayerSettings))]
public class SettingsDrawer : PopupEditorDrawer {}
#endif
Setting up the propertydrawer is very simple but has to be done for each asset type.
Wont go into source for propertydrawer in the talk but it's provided in the downloadable examples.
If you're building your own implementation.
Singletons are usually used to represent a manager that should either be global or belong to a particular scene.
They're used because static access makes them easy to use. Not because a "single instance only" rule fits the task at hand.
Lets make a replacement
using UnityEngine;
using System.Collections;
public abstract class Manager : ScriptableObject
{
// called by container
public virtual void OnAwake() { }
public virtual void OnStart() { }
public virtual void OnUpdate() { }
public virtual void OnFixedUpdate() { }
public virtual void OnLateUpdate() { }
// OnDestroy called by unity
public virtual void OnDestroy() { }
public ManagerContainer container { get; set; }
public bool enabled { get; set; }
}
We'll design our managers to be ScriptableObjects. This allows us to save their configuration in assets
using UnityEngine;
using System.Collections;
[CreateAssetMenu(fileName="PlayerManager.asset", menuName="Managers/Player Manager")]
public class PlayerManager : Manager
{
public ActorPlayer playerPrefab;
public ActorPlayer currentPlayer {get;set;}
}
Here's an example manager.
So far we can create assets that store settings. But we don't have a way to use it at runtime yet.
We need something to manage the managers. And ManagerManager is a terrible name.
using UnityEngine;
using System.Collections;
public class ManagerContainer : MonoBehaviour
{
[SerializeField] Manager[] m_managerPrefabs = new Manager[0];
List<Manager> m_managerInstances = new List<Manager>;
protected void Awake() {
for(int i=0;i<m_managerPrefabs.Length; ++i)
{
if(m_managerPrefabs[i]==null) continue;
m_managerInstances.Add(Instantiate(m_managerPrefabs[i]));
}
for(int i=0;i<m_managerInstances.Count; ++i)
{
var manager = m_managerInstances[i];
manager.enabled = true;
manager.OnAwake();
}
}
protected void OnDestroy() {
for(int i=0;i<m_managerInstances.Count; ++i) {
Destroy(m_managerInstances[i]);
}
}
protected void Update() {
for(int i=0;i<m_managerInstances.Count; ++i) {
m_managerInstances[i].Update();
}
}
protected void FixedUpdate() {
for(int i=0;i<m_managerInstances.Count; ++i) {
m_managerInstances[i].FixedUpdate();
}
}
protected void LateUpdate() {
for(int i=0;i<m_managerInstances.Count; ++i) {
m_managerInstances[i].LateUpdate();
}
}
}
^ it's the best name I could think of
But we still need a way to access them.
Handles the full lifecycle for our managers
A basic Managercontainer for example
public class ActorPlayer : BaseMonoBehaviour
{
ActorPlayerManager m_playerManager;
void Awake()
{
m_playerManager = GetManager<PlayerManager>();
}
}
public abstract class BaseMonoBehaviour : MonoBehaviour
{
public T GetManager<T>() where T:Manager
{
var scene = gameObject.scene;
return ManagerContainer.GetManager<T>(scene);
}
}
A monobehaviour caching a manager
We'll explain the scene parameter later.
[CreateAssetMenu(fileName="UnlocksManager.asset", menuName="Managers/Unlocks Manager")]
public class UnlocksManager : Manager
{
ActorPlayerManager m_playerManager;
public override void OnAwake()
{
m_playerManager = GetManager<PlayerManager>();
}
}
Calling GetManager on a Manager will fail with a "Dependency required" error.
Unless you specify that the manager depends on it using the IManagerDependency interface.
[CreateAssetMenu(fileName="UnlocksManager.asset", menuName="Managers/Unlocks Manager")]
public class UnlocksManager : Manager, IManagerDependency<PlayerManager>
{
ActorPlayerManager m_playerManager;
public override void OnAwake()
{
m_playerManager = GetManager<PlayerManager>();
}
}