I am doing my health / stamina / coin a little different then in the course. I have created a Stat class that can be added to things that need the stat. I am using Scriptable Object Game Events to communicate when these stats change. I created the Stat class when I got to the Stamina section, this gave me a place to handle the stat changes instead of doing it in the Damageable(Player Health in the video), Economy Manager, and PlayerController classes. If I need to change want to add a second game event type (Like to keep track of the amount that the value changes) I just have to edit the Stat and Stat Game Event Classes. This also keeps my UI logic separate from the game play logic. I am a strong believer that the Game Play Logic should not contain UI logic as well, i believe that It breaks the S.O.L.I.D principles to combine them.
Stat.cs
using System;
using System.Collections;
using Pickups;
using Unity.Mathematics;
using UnityEngine;
namespace Misc
{
[Serializable]
public class Stat
{
[Header("Stat Value")]
[SerializeField] private int _startingValue = 1;
[SerializeField] private int _maxValue = 1;
[Header("Refresh")]
[SerializeField] private bool _autoRefresh;
[SerializeField] private float _refreshTime;
[Header("Game Events")]
[SerializeField] private StatGameEvent _onStatChanged;
private bool _hasOnStatChanged;
[SerializeField] private PickupGameEvent _pickupGameEvent;
[SerializeField] private PickupGameEvent _maxPickupGameEvent;
private int _currentValue;
private MonoBehaviour _monoBehaviour;
private bool _hasMonoBehavior;
private Coroutine _refreshCoroutine;
private int Value
{
get => _currentValue;
set
{
if (value == int.MinValue) value = int.MaxValue;
value = math.clamp(value, 0, _maxValue);
if (value == _currentValue) return;
_currentValue = value;
if (_hasOnStatChanged) _onStatChanged.OnValueChanged(_currentValue);
}
}
/// <summary>
/// The Max Value of the Stat
/// </summary>
public int Max
{
get => _maxValue;
set
{
if (value == int.MinValue) value = int.MaxValue;
value = math.max(1, value);
if (value == _maxValue) return;
_maxValue = value;
if (_hasOnStatChanged) _onStatChanged.OnValueChanged(_currentValue);
if (_currentValue > _maxValue)
Value = _maxValue;
}
}
/// <summary>
/// Call this method from a MonoBehaviour's Awake method.
/// </summary>
/// <param name="monoBehaviour"></param>
public void Awake(MonoBehaviour monoBehaviour)
{
_monoBehaviour = monoBehaviour;
_hasMonoBehavior = _monoBehaviour != null;
_hasOnStatChanged = _onStatChanged != null;
_currentValue = _startingValue;
if (_maxValue < _startingValue) _maxValue = _startingValue;
}
/// <summary>
/// Call this from a MonoBehaviour's OnEnable Method
/// </summary>
public void OnEnabled()
{
if (_pickupGameEvent != null) _pickupGameEvent.Register(OnPickup);
if (_maxPickupGameEvent != null) _maxPickupGameEvent.Register(OnMaxPickup);
if (!_hasOnStatChanged) return;
_onStatChanged.OnMaxValueChanged(_maxValue);
_onStatChanged.OnValueChanged(_currentValue);
}
/// <summary>
/// Call this from a MonoBehaviour's OnDisable Method
/// </summary>
public void OnDisable()
{
if (_pickupGameEvent != null) _pickupGameEvent.UnRegister(OnPickup);
if (_maxPickupGameEvent != null) _maxPickupGameEvent.UnRegister(OnMaxPickup);
}
private void OnPickup(int amount)
{
Value += amount;
}
private void OnMaxPickup(int amount)
{
Max += amount;
}
private void StartRefresh()
{
if (!_autoRefresh) return;
if (!_hasMonoBehavior)
{
Debug.LogError("No monoBehavior assigned to this stat. Did you forget to call the Awake method?");
}
if (_refreshCoroutine != null) _monoBehaviour.StopCoroutine(_refreshCoroutine);
_refreshCoroutine = _monoBehaviour.StartCoroutine(RefreshRoutine());
}
private IEnumerator RefreshRoutine()
{
while (_currentValue < _maxValue)
{
yield return new WaitForSeconds(_refreshTime);
Value++;
}
yield return null;
_refreshCoroutine = null;
}
#region Equality members
protected bool Equals(Stat other)
{
return _startingValue == other._startingValue && _maxValue == other._maxValue && _currentValue == other._currentValue && Equals(_onStatChanged, other._onStatChanged) && _hasOnStatChanged == other._hasOnStatChanged;
}
/// <inheritdoc />
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
return obj.GetType() == GetType() && Equals((Stat)obj);
}
/// <inheritdoc />
public override int GetHashCode()
{
// ReSharper disable NonReadonlyMemberInGetHashCode
return HashCode.Combine(_startingValue, _maxValue, _currentValue, _onStatChanged, _hasOnStatChanged);
// ReSharper restore NonReadonlyMemberInGetHashCode
}
#endregion
#region Implicit Conversion operators
/// <summary>
/// Returns the value of the stat.
/// </summary>
/// <param name="s">The Stat to get the value of.</param>
/// <returns>The value of the Stat</returns>
/// <example>int value = myStat;</example>
public static implicit operator int(Stat s)
{
return s.Value;
}
#region == !=
/// <summary>
/// Determines if to stats are equal to each other
/// </summary>
/// <param name="s1">The First Stat</param>
/// <param name="s2">The Second Stat</param>
/// <returns>true if the values of s1 and s2 are the same.</returns>
/// <example>if (myStat1 == myStat2) { // Do stuff if the stats have the same value. }</example>
public static bool operator ==(Stat s1, Stat s2)
{
return s1 != null && s2 != null && s1.Value == s2.Value && s1.Max == s2.Max;
}
/// <summary>
/// Determines if to stats are equal to each other
/// </summary>
/// <param name="s1">The First Stat</param>
/// <param name="s2">The Second Stat</param>
/// <returns>true if the values of s1 and s2 are different.</returns>
/// <example>if (myStat1 != myStat2) { // Do stuff if the stats have the same value. }</example>
public static bool operator !=(Stat s1, Stat s2)
{
return !(s1 == s2);
}
#endregion
#region +
/// <summary>
/// Adds an int Value to the stat
/// </summary>
/// <param name="s">The Stat to add to</param>
/// <param name="amount">The amount to add</param>
/// <returns>The stat</returns>
/// <example>myStat = myStat + 3</example>
public static Stat operator +(Stat s, int amount)
{
s.Value += amount;
return s;
}
/// <summary>
/// Adds stat values together
/// </summary>
/// <param name="s1">The Stat to add to</param>
/// <param name="s2">The Stat to add</param>
/// <returns>The value of the stats added together.</returns>
/// <example>myStat = myStat + myStat2</example>
public static int operator +(Stat s1, Stat s2)
{
s1.Value += s2.Value;
return s1;
}
#endregion
#region -
/// <summary>
/// Subtracts an int Value from the stat
/// </summary>
/// <param name="s">The Stat to subtract from</param>
/// <param name="amount">The amount to subtract</param>
/// <returns>The stat</returns>
/// <example>myStat = myStat - 3</example>
public static Stat operator -(Stat s, int amount)
{
s.Value -= amount;
s.StartRefresh();
return s;
}
/// <summary>
/// Subtracts a stat Value from the stat
/// </summary>
/// <param name="s1">The Stat to subtract from</param>
/// <param name="s2">The Stat to subtract</param>
/// <returns>The value of the</returns>
/// <example>int value = myStat + myStat2</example>
public static int operator -(Stat s1, Stat s2)
{
s1 -= s2.Value;
return s1;
}
#endregion
#region ++ --
/// <summary>
/// Increments the value of the stat by 1.
/// </summary>
/// <param name="s">The stat to increment.</param>
/// <returns>The stat</returns>
/// <example>myStat++;</example>
public static Stat operator ++(Stat s)
{
s.Value ++;
return s;
}
/// <summary>
/// Decrements the value of the stat by 1.
/// </summary>
/// <param name="s">The stat to decrement.</param>
/// <returns>The stat</returns>
/// <example>myStat--;</example>
public static Stat operator --(Stat s)
{
s -= 1;
return s;
}
#endregion
#endregion
}
}
StatGameEvent.cs
using System;
using UnityEngine;
namespace Misc
{
[CreateAssetMenu(menuName = "New Stat Game Event")]
public class StatGameEvent : ScriptableObject
{
private Action<int> _valueChangedEvent;
private Action<int> _maxValueChangedEvent;
/// <summary>
/// Register a Method to the value change event that is invoked when the value changes.
/// Passes an int representing the current value of the Stat.
/// </summary>
/// <param name="action">The Method to handle when the value changed.</param>
public void RegisterValueChanged(Action<int> action)
{
_valueChangedEvent += action;
}
/// <summary>
/// Un-register a Method from the value change event.
/// </summary>
/// <param name="action">The Method to un-register.</param>
public void UnRegisterValueChanged(Action<int> action)
{
_valueChangedEvent -= action;
}
/// <summary>
/// Register a Method to the max value change event that is invoked when the max value changes.
/// Passes an int representing the current max value of the Stat.
/// </summary>
/// <param name="action">The Method to handle when the max value changed.</param>
public void RegisterMaxValueChanged(Action<int> action)
{
_maxValueChangedEvent += action;
}
/// <summary>
/// Un-register a Method from the max value change event.
/// </summary>
/// <param name="action">The Method to un-register.</param>
public void UnRegisterMaxValueChanged(Action<int> action)
{
_maxValueChangedEvent -= action;
}
/// <summary>
/// Call this method to fire the value changed event.
/// </summary>
/// <param name="value">The current value of the stat.</param>
public void OnValueChanged(int value)
{
_valueChangedEvent?.Invoke(value);
}
/// <summary>
/// Call this method to fire max value changed event.
/// </summary>
/// <param name="value"></param>
public void OnMaxValueChanged(int value)
{
_maxValueChangedEvent?.Invoke(value);
}
}
}
StatSlider.cs Added to the Heart container
using Misc;
using Scene_Management;
using UnityEngine;
using UnityEngine.UI;
namespace UI
{
public class StatSlider : MonoBehaviour
{
[SerializeField] private Slider _slider; // Can get this in awake instead.
[SerializeField] private StatGameEvent _statGameEvent;
private int _currentValue, _maxValue;
private void OnEnable()
{
_statGameEvent.RegisterValueChanged(OnStatValueChanged);
_statGameEvent.RegisterMaxValueChanged(OnMaxValueChanged);
}
private void OnDisable()
{
_statGameEvent.UnRegisterValueChanged(OnStatValueChanged);
_statGameEvent.UnRegisterValueChanged(OnMaxValueChanged);
}
private void OnStatValueChanged(int value)
{
UpdateSlider(value, _maxValue);
}
private void OnMaxValueChanged(int max)
{
UpdateSlider(_currentValue, max);
}
private void UpdateSlider(int value, int max)
{
//if (SceneManagement.Instance.IsSceneChanging) return;
_currentValue = value;
_maxValue = max;
_slider.maxValue = _maxValue;
_slider.value = _currentValue;
}
}
}
StatString.cs added to the Gold Coin Container
using Misc;
using Scene_Management;
using TMPro;
using UnityEngine;
namespace UI
{
public class StatString : MonoBehaviour
{
[SerializeField] private TMP_Text _text;
[SerializeField] private StatGameEvent _statGameEvent;
[SerializeField] private bool _displayMax;
[SerializeField] private int _numPlaces = 3;
private int _currentValue, _maxValue;
private void OnEnable()
{
_statGameEvent.RegisterValueChanged(OnStatValueChanged);
_statGameEvent.RegisterMaxValueChanged(OnMaxValueChanged);
}
private void OnDisable()
{
_statGameEvent.UnRegisterValueChanged(OnStatValueChanged);
_statGameEvent.UnRegisterValueChanged(OnMaxValueChanged);
}
private void OnStatValueChanged(int value)
{
UpdateText(value, _maxValue);
}
private void OnMaxValueChanged(int max)
{
UpdateText(_currentValue, max);
}
private void UpdateText(int value, int max)
{
//if (SceneManagement.Instance.IsSceneChanging) return;
_currentValue = value;
_maxValue = max;
var text = _currentValue.ToString($"D{_numPlaces}");
if (_displayMax)
text = $"{text} / {_maxValue.ToString($"D{_numPlaces}")}";
_text.text = text;
}
}
}
StatImage.cs Added to
I need to modify this class latter to take into account Max being more then 3.
using Misc;
using Scene_Management;
using UnityEngine;
using UnityEngine.UI;
namespace UI
{
public class StatImage : MonoBehaviour
{
[SerializeField] private StatGameEvent _statGameEvent;
[SerializeField] private GameObject _imagePrefab;
[SerializeField] private Sprite _empty;
[SerializeField] private Sprite _full;
private int _currentValue, _maxValue;
private void OnEnable()
{
_statGameEvent.RegisterValueChanged(OnStatValueChanged);
_statGameEvent.RegisterMaxValueChanged(OnMaxValueChanged);
}
private void OnDisable()
{
_statGameEvent.UnRegisterValueChanged(OnStatValueChanged);
_statGameEvent.UnRegisterValueChanged(OnMaxValueChanged);
}
private void OnStatValueChanged(int value)
{
UpdateSprite(value, _maxValue);
}
private void OnMaxValueChanged(int max)
{
UpdateSprite(_currentValue, max);
}
private void UpdateSprite(int value, int max)
{
//if (SceneManagement.Instance.IsSceneChanging) return;
_currentValue = value;
_maxValue = max;
// ToDo: Delete all child objects
// ToDo: Use instantiate an image prefab up to max
// ToDo: Instead of full or empty have a number of sprites that represent this stat
// i.e. A heart might have 4 pieces so a full heart would be 4.
for (var i = 0; i < _maxValue; i++)
{
transform.GetChild(i).GetComponent<Image>().sprite = i <= _currentValue - 1 ? _full : _empty;
}
}
}
}
Damageable.cs
using System.Collections;
using System.Linq;
using Effects;
using Misc;
using UnityEngine;
namespace Damageables
{
/// <summary>
/// A damageable Game Object.
/// </summary>
public class Damageable : MonoBehaviour
{
[Header("Hit points")]
[SerializeField] private Stat _hitPoints;
// Other Damageable code ....
#region Unity Methods
private void Awake()
{
_// Other Awake Code...
_hitPoints.Awake(this);
}
private void OnEnable()
{
_hitPoints.OnEnabled();
}
private void OnDisable()
{
_hitPoints.OnDisable();
}
#endregion
private void IncreaseHealth(int amount)
{
_hitPoints += amount;
}
// Other Damageable code ....
}
}
PlayerController.cs
using System.Collections;
using System.Linq;
using Effects;
using Misc;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
namespace Player
{
/// <summary>
/// Controls the player in the game world.
/// Can only be one game object in the scene that has this controller.
/// <p>Requires the following Components:
/// <ul>
/// <li><see cref="Rigidbody2D"/></li>
/// <li><see cref="Animator"/></li>
/// <li><see cref="SpriteRenderer"/></li>
/// </ul></p>
/// </summary>
[RequireComponent(typeof(Rigidbody2D),
typeof(Animator),
typeof(SpriteRenderer))]
public class PlayerController : Singleton<PlayerController>
{
[Header("Dash Ability")]
[SerializeField] private Stat _stamina;
// Other code .....
#region Unity Methods
protected override void Awake()
{
base.Awake();
// Other code ....
_stamina.Awake(this);
}
private void OnEnable()
{
// other code ....
_stamina.OnEnabled();
}
private void OnDisable()
{
_stamina.OnDisable();
// other code ....
}
// Other code ....
private IEnumerator DashRoutine()
{
_isDashing = true;
_stamina--;
// Other code....
}
}
}
The real nice thing about using this set up is I do not have to modify any code to change the way my UI looks. I can easily change the Stamina to be a slider instead, or the Health to be Heart images. Or add a text overlay that has the current value / max value.
Update: I had an issue when adding in the Scene Reloading when the player dies, For some reason I had a check in all of the UI code
if (SceneManagement.Instance.IsSceneChanging) return;
I think originally I had a null reference error and I used this as a bandied.
This was causing my UI not to update when the player died and the game reloaded.