LazyValue implementation on BaseStats breaking something

Hi,

I’ve just implemented the lazy values and it worked fine for everything except for base stats - I get this NullReferenceException:

NullReferenceException: Object reference not set to an instance of an object
RPG.Stats.BaseStats.GetLevel () (at Assets/Scripts/Stats/BaseStats.cs:81)
RPG.Stats.BaseStats.GetBaseStat (RPG.Stats.Stat stat) (at Assets/Scripts/Stats/BaseStats.cs:76)
RPG.Stats.BaseStats.GetModifiedStat (RPG.Stats.Stat stat) (at Assets/Scripts/Stats/BaseStats.cs:70)
RPG.Attributes.Health.GetInitialHealth () (at Assets/Scripts/Attributes/Health.cs:30)
GameDevTV.Utils.LazyValue`1[T].ForceInit () (at Assets/Asset Packs/GameDev.tv Assets/Scripts/Utils/LazyValue.cs:56)
GameDevTV.Utils.LazyValue`1[T].get_value () (at Assets/Asset Packs/GameDev.tv Assets/Scripts/Utils/LazyValue.cs:38)
RPG.Attributes.Health.Awake () (at Assets/Scripts/Attributes/Health.cs:25)

I have checked by rolling back just base stats and everything works fine. I am guessing it’s related to current level not being set for some reason… Any idea what might be causing it?

Thanks,
Anthony.

Paste in your BaseStats (with the LazyValue implemented) and we’ll take a look.

I couldn’t upload the file so here it is, hopefully it renders okay.

using System;
using System.Collections.Generic;
using GameDevTV.Utils;
using UnityEngine;

namespace RPG.Stats
{
    
    public class BaseStats : MonoBehaviour
    {
        [SerializeField][Range(1,100)] int startingLevel = 1;
        [SerializeField] CharacterClass characterClass;
        [SerializeField] Progression progression = null;
        [SerializeField] GameObject levelUpFX = null;
        [SerializeField] bool shouldUseModifiers = false;

        public event Action onLevelUp;
        
        LazyValue<int> currentLevel;
    

        Experience experience;

        void Awake()
        {
            experience = GetComponent<Experience>();
            currentLevel = new LazyValue<int>(CalculateLevel);
        }

        void Start()
        {
            currentLevel.ForceInit();
        }

        void OnEnable()
        {
            if (experience != null)
            {
                experience.onExperienceGained += UpdateLevel;
            }
        }

        void OnDisable()
        {
            if (experience != null)
            {
                experience.onExperienceGained -= UpdateLevel;
            }
        }

        void UpdateLevel()
        {
            int newLevel = CalculateLevel();
            if (newLevel > currentLevel.value)
            {
                currentLevel.value = newLevel;
                onLevelUp();
                LevelUpEffect();
            }
        }

        void LevelUpEffect()
        {
            Instantiate(levelUpFX, transform);
        }

        // gets the base stat and applies additive and multiplicative modifiers
        public float GetModifiedStat(Stat stat)
        {
            return (GetBaseStat(stat) + GetAdditiveModifier(stat)) * (1 + GetPercentageModifier(stat)/100);
        }

        // gets the underlying stat before any modifiers are applied
        float GetBaseStat(Stat stat)
        {
            return progression.GetStat(stat, characterClass, GetLevel());
        }

        public int GetLevel()
        {
            return currentLevel.value;
        }

        float GetAdditiveModifier(Stat stat)
        {
            if (!shouldUseModifiers) return 0f;
            
            var modifierProviders = GetComponents<IModifierProvider>();
            float totalModifierValue = 0f;
            
            foreach (IModifierProvider provider in modifierProviders)
            {
                foreach (float value in provider.GetAdditiveModifiers(stat))
                {
                    totalModifierValue += value;
                }
            }
            return totalModifierValue;
        }

        float GetPercentageModifier(Stat stat)
        {
            if (!shouldUseModifiers) return 0f;

            var modifierProviders = GetComponents<IModifierProvider>();
            float totalPercentageValue = 0f;

            foreach (IModifierProvider provider in modifierProviders)
            {
                foreach (float value in provider.GetPercentageModifiers(stat))
                {
                    totalPercentageValue += value;
                }
            }

            return totalPercentageValue;
        }
        
        public int CalculateLevel()
        {
            if (experience == null) return startingLevel; 

            float currentEXP = experience.GetCurrentExperience();

            int penultimateLevel = progression.GetLevels(Stat.ExperienceToLevelUp, characterClass);
            for (int level = 1; level <= penultimateLevel; level++)
            {
                float levelUpThreshold = progression.GetStat(Stat.ExperienceToLevelUp, characterClass, level);
                if (currentEXP < levelUpThreshold)
                {
                    return level;
                }

            }
            return penultimateLevel + 1;
        }
    }
}

That’s actually perfect.

So it looks like GetLevel() is getting called -=before=- Awake sets the LazyValue.Init method.
Looking at the error chain, I see that it starts in Health.Awake()…

This means that it’s likely that the value of the LazyValue in Health is being checked in the Awake method instead of waiting until start. Paste in your Health.cs and we’ll take a look.

Glad to hear my basestats is good! I have done something slightly different with health and so it’s most likely an issue there, here is my health.cs

using System;
using System.Collections.Generic;
using GameDevTV.Utils;
using RPG.Core;
using RPG.Saving;
using RPG.Stats;
using UnityEngine;

namespace RPG.Attributes
{
    public class Health : MonoBehaviour, ISaveable
    {
        BaseStats baseStats;

        bool isDead = false;
        public bool IsDead() { return isDead; }

        LazyValue<float> maxHealth;
        float currentHealth;

        void Awake()
        {
            baseStats = GetComponent<BaseStats>();
            maxHealth = new LazyValue<float>(GetInitialHealth);
            currentHealth = maxHealth.value;
        }

        float GetInitialHealth()
        {
            return baseStats.GetModifiedStat(Stat.Health);
        }

        void Start()
        {
            maxHealth.ForceInit();
        }

        void OnEnable()
        {
            baseStats.onLevelUp += GetUpdatedHealth;
        }

        void OnDisable()
        {
            baseStats.onLevelUp -= GetUpdatedHealth;
        }

        public void TakeDamage(GameObject instigator, float damage)
        {
            print (gameObject.name + " took damage " + damage);
            
            currentHealth = Mathf.Max(currentHealth - damage, 0);

            if (currentHealth == 0)
            {
                Death();
                AwardExperience(instigator);
            }
        }

        void Death()
        {
            if (isDead) { return; }

            if (this.gameObject.tag != "Player")
            {
                transform.GetChild(1).GetComponent<ParticleSystem>().Stop();
            }

            isDead = true;
            GetComponent<Animator>().SetBool("die", true);
            GetComponent<ActionScheduler>().CancelCurrentAction();
        }

        void AwardExperience(GameObject instigator)
        {
            Experience experience = instigator.GetComponent<Experience>();
            if (experience == null) return;

            float expReward = baseStats.GetModifiedStat(Stat.ExperienceRewards);
            experience.GainExperience(expReward);
        }

        void GetUpdatedHealth()
        {
            // work out difference between new max hp and current max hp, then set max hp to new cap and heal the difference - this is different to course method
            float newMaxHealth = baseStats.GetModifiedStat(Stat.Health);
            float levelUpHeal = newMaxHealth - maxHealth.value;
            maxHealth.value = newMaxHealth;
            currentHealth += levelUpHeal;
        }

        public float GetHealthPercentage()
        {
            return 100 * (currentHealth / maxHealth.value);
        }

        public float GetCurrentHealth()
        {
            return currentHealth;
        }
        public float GetMaximumHealth()
        {
            return maxHealth.value;
        }
        
        public object CaptureState()
        {
            Dictionary<string, float> healthValues = new Dictionary<string, float>();
            healthValues.Add("currenthealth", currentHealth);
            healthValues.Add("maxhealth", maxHealth.value);

            return healthValues;
        }
        
        public void RestoreState(object state)
        {
            Dictionary<string, float> storedHealthValues = (Dictionary<string, float>)state;
            
            currentHealth = storedHealthValues["currenthealth"];
            maxHealth.value = storedHealthValues["maxhealth"];

            if (currentHealth <= 0)
            {
                Death();
            }
        }
    }
}


image

There’s your culprit. You’re setting currentHealth to maxHealth.value in Awake. This automatically triggers GetInitialHealth, which calls BaseStats and asks for the current health… but… there’s no guarantee that BaseStats will run it’s Awake before Health, and this sets up the race condition.

In this case, BaseStats is losing the race.

Try putting that last line (currentHealth = maxHealth.value) in Start()

Thank you for your help Brian, in hindsight that makes total sense.

The initial intention with having currentHealth set to maxhealth.value was to make sure I am storing both current and maxhealth as I feel it’s a bit more robust (and allowed me to keep levelup health values consistent rather than percentage increase) - moving the line to start means that it’s always resetting to max value on reload which is obviously not good. In order to fix this, I have put in some protection:

            if(currentHealth == -1)
            {
                currentHealth = maxHealth.value;
            }

So I set the default value for currentHealth to -1 and it seems to be working for dead, alive, soft and hard reloads - is there anything I am not considering which might cause me issues later?

There actually isn’t really a need to cache maxHealth, as it’s always available from BaseStats (well… it’s always available from BaseStats everywhere but Awake)

The problem with caching maxHealth is that it introduces the risk that your cached maxHealth won’t be in sync with the actual maxHealth, which is BaseStats.GetStat(Stat.Health);

What I tend to do is to make MaxHealth a property (so you don’t have to keep typing BaseStats.GetStat:

public float MaxHealth => baseStats.GetModifiedStat(Stat.Health);

Then you can just use MaxHealth where needed and it will always be up to date, even if the gear changes and gives the character more or less MaxHealth.

2 Likes

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms