Keeping Stat Logic and UI Logic seperate

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.

1 Like

Your solution looks great and was exactly what I was looking for! Now for a ramble:

I jumped straight to this section of the course as I’ve been juggling the four horsemen of: Scene Management, ‘global’ Game Object lifecycles, Singletons and Tight Coupling. Obviously this course isn’t a deep dive on ‘best’ practices to handle those four doomsters, but I was interested to see how we’d approach UI updates.

I have a mishmash of Events (not using Scriptable Objects… yet!) and less-than-clean hacks using Singletons. As a long time developer of a certain vintage, a lot of this stuff makes me scream internally, though I’ve talked myself down from trying to do heavy IoC type stuff by realising that Unity’s C# implementation isn’t really anything to do with .NET (except e.g. the libraries you can leverage), it’s just its chosen scripting language (this was a sobering read: https://forum.unity.com/threads/unity-is-not-net.544795/ )

Anyway, I’m going to look at implementing your solution and see if I can clean my project up a bit. Thank you for posting your classes!!

PS. I also had a quite a few issues re: unloading of scenes and waiting for scenes to load. I have a bootstrapper class which instantiates a prefab containing all the building blocks (e.g. Singletons) to run my game. This allows me to test the game from any Scene. My game scenes are loaded additively using the following logic. The main issues I had were trying to unload the current scene before the new one had been fully loaded. Some code I made (definitely need cleaning up) that solved this problem - basically hooking into the LoadSceneAsync → operation.completed event to do my unloading there:


        public void LoadLevel(string level)
        {
            StartCoroutine(StartLoadLevel(level));
        }

        IEnumerator StartLoadLevel(string level)
        {
            var ao = SceneManager.LoadSceneAsync(level, LoadSceneMode.Additive);

            ao.allowSceneActivation = false;
            while (ao.progress < 0.9f)
            {
                yield return null;
            }
            ao.allowSceneActivation = true;

            while (!ao.isDone)
            {
                yield return null;
            }
            
            UnloadCurrentLevel();
        }


        public void UnloadCurrentLevel()
        {
            //string level = SceneManager.GetActiveScene().name;

            var operation = SceneManager.UnloadSceneAsync(SceneManager.GetActiveScene());

            if (operation == null)
            {
                Debug.LogError($"[GameManager] unable to unload level {SceneManager.GetActiveScene().name}");
                return;
            }

            operation.completed += FinaliseLoadingNewLevel();
        }
        private Action<AsyncOperation> FinaliseLoadingNewLevel()
        {
            Debug.Log("Unload complete.");

            UpdateState(GameState.RUNNING);

            SceneManager.SetActiveScene(SceneManager.GetSceneByName(currentLevelName));
            levelLoadInProgress = false;
            PlayLevelMusic(GetCurrentGameLevel());
            Debug.Log("New scene load complete." + currentLevelName);

            return null;
        }
1 Like

I am glad it was some help. I have a more detailed solution at https://github.com/JamesLaFritz/CoreFramework2022

Privacy & Terms