Aims:
- To have ints / floats / strings manage saving & loading their own state.
- To have ints / floats / strings be reference-able in the inspector
- To separate data (e.g. currency, health, score) from components (displays, calculators, shopfronts, managers).
Let’s remind ourselves of the problem. You’re storing “coins” as an int and you stick it on a GameManager singleton:
1 2 3 4 5 |
public class GameManager{ public static GameManager Instance; public int coins; } |
All the other things that need to know how many coins there are will reference it with GameManager.Instance.coins .
Ok, but now the GameManager also has to persist that data so it starts to look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class GameManager{ public static GameManager Instance; public int coins; void Awake(){ Load(); } void Load(){ // load from disk / playerprefs / cloud } void Save(){ // save to disk / playerprefs / cloud } } |
Suddenly our GameManager is not only managing the game but also looking after data. Also, now your Shop UI or coin display can’t work on its own because it needs the GameManager to know how many coins you have. Things have become coupled (one cannot work without the other). If we made a scene just for the shop, our team would have to remember to drag the GameManager into it as well. That might not sound like a big deal but it quickly gets out of control as more managers are introduced.
Like in real life, managers quickly become a dump for all the leftover tasks.
Since we are striving for clean, decoupled, single-responsibility code – we can do better than this! Let’s start by creating a scriptableobject that just stores an int:
1 2 3 4 5 |
[CreateAssetMenu(fileName = "newInt", menuName = "Variable/Int")] public class IntVar:ScriptableObject{ public int Value; } |
This step on its own already gets rid of the need for a manager & singleton. Once you create an instance of this class through the right click menu, some shop UI can reference and use it directly:
1 2 3 4 5 6 7 8 |
public class ShopUI: MonoBehaviour{ public IntVar Coins; public Text CoinDisplay; void UpdateUI(){ CoinDisplay.text = Coins.Value.ToString(); } } |
Let’s go a step further and get our ints to look after their own state. There are many options to storing data, but for demo, let’s store this int in Playerprefs.
1 2 3 4 5 6 7 8 |
[CreateAssetMenu(fileName = "newInt", menuName = "Variable/Int")] public class IntVar:ScriptableObject{ public int Value{ get{return PlayerPrefs.GetInt(name, 0);} set{PlayerPrefs.SetInt(value);} }; } |
Alright, now the int’s are managing their own persistence. How about an event notifying listeners of a value change?
1 2 3 4 5 6 7 8 9 10 11 12 |
[CreateAssetMenu(fileName = "newInt", menuName = "Variable/Int")] public class IntVar:ScriptableObject{ public Action OnChange = delegate {}; public int Value{ get{return PlayerPrefs.GetInt(name, 0);} set{ PlayerPrefs.SetInt(value); OnChange(); } }; } |
Registering a callback to OnChange lets us write a counter as small as this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class IntDisplay: MonoBehaviour{ public IntVar TargetInt; public Text DisplayText; void OnEnable(){ TargetInt.OnChange += UpdateText; } void OnDisable(){ TargetInt.OnChange -= UpdateText; } void UpdateText(){ DisplayText.text = TargetInt.Value.ToString(); } } |
Now let’s cache the value from Playerprefs so we’re not going in and out all the time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
[CreateAssetMenu(fileName = "newInt", menuName = "Variable/Int")] public class IntVar:ScriptableObject{ public Action OnChange = delegate {}; int cachedValue; public int Value{ get{return cachedValue;} set{ if(cachedValue == value) return; cachedValue = value; PlayerPrefs.SetInt(cachedValue); OnChange(); } }; void OnEnable(){ cachedValue = PlayerPrefs.GetInt(name); } } |
And maybe a few Odin buttons for resetting the stored value and testing:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
[CreateAssetMenu(fileName = "newInt", menuName = "Variable/Int")] public class IntVar:ScriptableObject{ public Action OnChange = delegate {}; int cachedValue; public int Value{ get{return cachedValue;} set{ if(cachedValue == value) return; cachedValue = value; PlayerPrefs.SetInt(name, cachedValue); OnChange(); } }; void OnEnable(){ cachedValue = PlayerPrefs.GetInt(name); } [Button("Reset")] void ResetValue(){ Value = 0; } [Button("Increment")] void Increment(){ Value++; } } |
And there we have it. From here, you can add other features like saving to the cloud instead of PlayerPrefs, string formatting, and so on. Most importantly – data and behaviour is separate and scenes are easier to reason about and test.
Happy scripting!