Vampire Survivors Part 19

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 19: Upgrading the Level-up UI

This article is a part of the series:
Creating a Rogue-like Shoot 'Em Up (like Vampire Survivors) in Unity

This article will be free until the video for Part 19 has been released.

12 May 2024: The article has been updated with some new content. The updated sections are labelled in boxes like these below.

While working on the next part of the Vampire Survivors series, one of our Patrons reported a bug with our weapon upgrade UI system in this forum topic. Basically, if you already have the maximum number of weapons / passives in your inventory, the level-up UI will still continue to give you new weapons / passives as upgrade options. These upgrade options, however, are unselectable, because the function that is fired when they are clicked on will fail, as the PlayerInventory will fail to add the item.

What began as (what I thought was) an easy bug fix became an entire part in the series, because the bug fix turned out to require a bigger overhaul on our scripts than I expected.

Hence, we have this part of the tutorial series.

  1. Debugging the issue
  2. Planning the Level-Up UI Screen improvements
    1. Issues with the Level-Up UI Screen
    2. Let’s talk about modularity
    3. Modularity in Unity Components
    4. Applying the concept to our level-up screen
  3. Making the Level-up Screen adaptive
    1. Making a duplicate as backup
    2. Making our Upgrade window scalable
    3. Making the window adaptive
    4. Rearranging the button layout
    5. Limitations of the current configuration
  4. Streamlining access to the level data of an Item
    1. The problem
    2. The solution
  5. Creating the UIUpgradeWindow script
    1. Preparing your UI elements
    2. How the UIUpgradeWindow will work
    3. Explaining the UIUpgradeWindow component
    4. The SetUpgrades() function
    5. Hooking it up to PlayerInventory
    6. Assigning the new Upgrade Window property
  6. Bugfixes for experience gain
    1. Optimising the GameManager script
    2. Level-ups do not stack
    3. Supporting multiple level-ups in one go
    4. Configuring the level-up ranges properly
  7. Conclusion

1. Debugging the issue

This section goes through how this bug evolved into a part in the series. You don’t have to copy any of the codes here, as they will eventually get upgraded in a later section anyway.

The issue is essentially with one of the functions in PlayerInventory — the ApplyUpgradeOptions() function (shown below). If you’d like a thorough recap of what the function does, you can refer to this section in the Part 10 article. Otherwise, here’s a quick and dirty recap:

  1. The function creates a duplicated list of all available weapon upgrades (availableWeaponUpgrades) and available passive upgrades (availablePassiveItemUpgrades).
  2. Then, it loops through all the upgrade slots, randomly selects either a passive, or weapon as an upgrade, and puts the upgrade into the slot.
  3. After the upgrade is selected, it is removed from the list, so it never occurs again.
// Determines what upgrade options should appear.
void ApplyUpgradeOptions()
{
	// Make a duplicate of the available weapon / passive upgrade lists
	// so we can iterate through them in the function.
	List<WeaponData> availableWeaponUpgrades = new List<WeaponData>(availableWeapons);
	List<PassiveData> availablePassiveItemUpgrades = new List<PassiveData>(availablePassives);

	// Iterate through each slot in the upgrade UI.
	foreach (UpgradeUI upgradeOption in upgradeUIOptions)
	{
		// If there are no more avaiable upgrades, then we abort.
		if (availableWeaponUpgrades.Count == 0 && availablePassiveItemUpgrades.Count == 0)
			return;

		// Determine whether this upgrade should be for passive or active weapons.
		int upgradeType;
		if (availableWeaponUpgrades.Count == 0)
		{
			upgradeType = 2;
		}
		else if (availablePassiveItemUpgrades.Count == 0)
		{
			upgradeType = 1;
		}
		else
		{
			// Random generates a number between 1 and 2.
			upgradeType = UnityEngine.Random.Range(1, 3);
		}

		// Generates an active weapon upgrade.
		if (upgradeType == 1)
		{
			
			// Pick a weapon upgrade, then remove it so that we don't get it twice.
			WeaponData chosenWeaponUpgrade = availableWeaponUpgrades[UnityEngine.Random.Range(0, availableWeaponUpgrades.Count)];
			availableWeaponUpgrades.Remove(chosenWeaponUpgrade);

			// Ensure that the selected weapon data is valid.
			if (chosenWeaponUpgrade != null)
			{
				// Turns on the UI slot.
				EnableUpgradeUI(upgradeOption);

				// Loops through all our existing weapons. If we find a match, we will
				// hook an event listener to the button that will level up the weapon
				// when this upgrade option is clicked.
				bool isLevelUp = false;
				for (int i = 0; i < weaponSlots.Count; i++)
				{
					Weapon w = weaponSlots[i].item as Weapon;
					if (w != null && w.data == chosenWeaponUpgrade)
					{
						// If the weapon is already at the max level, do not allow upgrade.
						if (chosenWeaponUpgrade.maxLevel <= w.currentLevel)
						{
							DisableUpgradeUI(upgradeOption);
							isLevelUp = true;
							break;
						}

						// Set the Event Listener, item and level description to be that of the next level
						upgradeOption.upgradeButton.onClick.AddListener(() => LevelUpWeapon(i, i)); //Apply button functionality
						Weapon.Stats nextLevel = chosenWeaponUpgrade.GetLevelData(w.currentLevel + 1);
						upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
						upgradeOption.upgradeNameDisplay.text = nextLevel.name;
						upgradeOption.upgradeIcon.sprite = chosenWeaponUpgrade.icon;
						isLevelUp = true;
						break;
					}
				}

				// If the code gets here, it means that we will be adding a new weapon, instead of
				// upgrading an existing weapon.
				if (!isLevelUp)
				{
					upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenWeaponUpgrade)); //Apply button functionality
					upgradeOption.upgradeDescriptionDisplay.text = chosenWeaponUpgrade.baseStats.description;  //Apply initial description
					upgradeOption.upgradeNameDisplay.text = chosenWeaponUpgrade.baseStats.name;    //Apply initial name
					upgradeOption.upgradeIcon.sprite = chosenWeaponUpgrade.icon;
				}
			}
		}
		else if (upgradeType == 2)
		{
			// NOTE: We have to recode this system, as right now it disables an upgrade slot if
			// we hit a weapon that has already reached max level.
			PassiveData chosenPassiveUpgrade = availablePassiveItemUpgrades[UnityEngine.Random.Range(0, availablePassiveItemUpgrades.Count)];
			availablePassiveItemUpgrades.Remove(chosenPassiveUpgrade);

			if (chosenPassiveUpgrade != null)
			{
				// Turns on the UI slot.
				EnableUpgradeUI(upgradeOption);

				// Loops through all our existing passive. If we find a match, we will
				// hook an event listener to the button that will level up the weapon
				// when this upgrade option is clicked.
				bool isLevelUp = false;
				for (int i = 0; i < passiveSlots.Count; i++)
				{
					Passive p = passiveSlots[i].item as Passive;
					if (p != null && p.data == chosenPassiveUpgrade)
					{
						// If the passive is already at the max level, do not allow upgrade.
						if (chosenPassiveUpgrade.maxLevel <= p.currentLevel)
						{
							DisableUpgradeUI(upgradeOption);
							isLevelUp = true;
							break;
						}
						upgradeOption.upgradeButton.onClick.AddListener(() => LevelUpPassiveItem(i, i)); //Apply button functionality
						Passive.Modifier nextLevel = chosenPassiveUpgrade.GetLevelData(p.currentLevel + 1);
						upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
						upgradeOption.upgradeNameDisplay.text = nextLevel.name;
						upgradeOption.upgradeIcon.sprite = chosenPassiveUpgrade.icon;
						isLevelUp = true;
						break;
					}
				}

				if (!isLevelUp) //Spawn a new passive item
				{
					upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenPassiveUpgrade)); //Apply button functionality
					Passive.Modifier nextLevel = chosenPassiveUpgrade.baseStats;
					upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;  //Apply initial description
					upgradeOption.upgradeNameDisplay.text = nextLevel.name;  //Apply initial name
					upgradeOption.upgradeIcon.sprite = chosenPassiveUpgrade.icon;
				}
			}
		}
	}
}

There are, however, 2 issues that it does not account for. These are:

  1. The function does not check the remaining item slots that the player character has before recommending upgrades. This means that it is possible for the system to recommend a new item when your weapons or passive slots have already been maxed out.
  2. If you have an item that is already at max level, and the system decides to recommend a level up to the item, it will detect that the item is already at max level and turn off the recommendation. This means that, if you have max level items, you will sometimes get less than 4 options.

To account for and fix these issues, I offered a rewritten ApplyUpgradeOptions() function in the forum post:

// Determines what upgrade options should appear.
void ApplyUpgradeOptions()
{
	// <availableUpgrades> is an empty list that will be filtered from
	// <allUpgrades>, which is the list of ALL upgrades in PlayerInventory.
	// Not all upgrades can be applied, as some may have already been
	// maxed out the player, or the player may not have enough inventory slots.
	List<ItemData> availableUpgrades = new List<ItemData>();
	List<ItemData> allUpgrades = new List<ItemData>(availableWeapons);
	allUpgrades.AddRange(availablePassives);

	// We need to know how many weapon / passive slots are left.
	int weaponSlotsLeft = GetSlotsLeft(weaponSlots);
	int passiveSlotsLeft = GetSlotsLeft(passiveSlots);

	// Filters through the available weapons and passives and add those
	// that can possibly be an option.
	foreach(ItemData data in allUpgrades)
	{
		// If a weapon of this type exists, allow for the upgrade if the
		// level of the weapon is not already maxed out.
		Item obj = Get(data);
		if(obj)
		{
			if (obj.currentLevel < data.maxLevel) availableUpgrades.Add(data);
		}
		else
		{
			// If we don't have this item in the inventory yet, check if
			// we still have enough slots to take new items.
			if (data is WeaponData && weaponSlotsLeft > 0) availableUpgrades.Add(data);
			else if (data is PassiveData && passiveSlotsLeft > 0) availableUpgrades.Add(data);
		}
	}

	// Iterate through each slot in the upgrade UI and populate the options.
	foreach (UpgradeUI upgradeOption in upgradeUIOptions)
	{
		// If there are no more available upgrades, then we abort.
		if (availableUpgrades.Count <= 0) return;

		// Pick an upgrade, then remove it so that we don't get it twice.
		ItemData chosenUpgrade = availableUpgrades[UnityEngine.Random.Range(0, availableUpgrades.Count)];
		availableUpgrades.Remove(chosenUpgrade);

		// Ensure that the selected weapon data is valid.
		if (chosenUpgrade != null)
		{
			// Turns on the UI slot.
			EnableUpgradeUI(upgradeOption);

			// If our inventory already has the upgrade, we will make it a level up.
			Item item = Get(chosenUpgrade);
			if(item)
			{
				upgradeOption.upgradeButton.onClick.AddListener(() => LevelUp(item)); //Apply button functionality
				if (item is Weapon)
				{
					Weapon.Stats nextLevel = ((WeaponData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
					upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
					upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
					upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
				}
				else
				{
					Passive.Modifier nextLevel = ((PassiveData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
					upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
					upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
					upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
				}
			}
			else
			{
				if(chosenUpgrade is WeaponData)
				{
					WeaponData data = chosenUpgrade as WeaponData;
					upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
					upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
					upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
					upgradeOption.upgradeIcon.sprite = data.icon;
				}
				else
				{
					PassiveData data = chosenUpgrade as PassiveData;
					upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
					upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
					upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
					upgradeOption.upgradeIcon.sprite = data.icon;
				}
				
			}
		}
	}
}

The function is different from the original in a couple of ways:

  1. Instead of separating the available weapons and available passives into separate lists, it combines them into a single list (allUpgrades), since both WeaponData and PassiveData are child classes of ItemData (i.e. we can put both WeaponData and PassiveData objects into a List<ItemData> object). This allows us to shorten our code in a big way, since we no longer need to have independent code blocks for weapons and passives (they are both the same codes anyway).
  2. Instead of assuming all available upgrades can be considered, we first do a preliminary check to see if they are eligible upgrades before populating these into the availableUpgrades list. In this check, we see if:
    1. The player already has a copy of the item the upgrade offers. If it does, we add the item as a possible level-up upgrade if the item is not already at max level.
    2. If the player does not have a copy of the item yet, we add it as a possible upgrade only if the player still has remaining slots in their inventory.

This means that, for the function to work, we will need to add a function to PlayerInventory that counts how many slots the player’s weapon / passive inventory have left.

// Checks a list of slots to see if there are any empty item slots in this list.
int GetSlotsLeft(List<Slot> slots)
{
	int count = 0;
	foreach(Slot s in slots)
	{
		if (s.IsEmpty()) count++;
	}
	return count;
}

Also, because we are handling both weapons and passives as items in our new function, we can no longer separate the level-up functions of weapons and passives. Previously, we used LevelUpWeapon() to level up weapons, and LevelUpPassiveItem() to level up passives. Now, we will need to create a generic LevelUp() function that can handle both weapons and passives.

// Levels up a selected weapon in the player inventory.
public bool LevelUp(Item item)
{
	// Tries to level up the item.
	if(!item.DoLevelUp())
	{
		Debug.LogWarning(string.Format(
			"Failed to level up {0}.",
			 item.name
		));
		return false;
	}

	// Close the level up screen afterwards.
	if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
	{
		GameManager.instance.EndLevelUp();
	}

	// If it is a passive, recalculate player stats.
	if(item is Passive) player.RecalculateStats();
	return true;
}

Basically, this combines LevelUpWeapon() and LevelUpPassiveItem() into a single function. Since both Weapon and Passive are subclasses of Item, they can call the DoLevelUp() function in Item to increase their level. We then do a check to see if the levelled-up item is a passive, because when we level up passives, we need to calculate the player’s stats.

As a convenience, we have also overloaded the LevelUp() function to work with an ItemData argument. This has not been used anywhere in our code, but it might come in handy in future — it basically finds an Item in the player’s inventory represented by the ItemData type.

// Overload so that we can use both ItemData or Item to level up an
// item in the inventory.
public bool LevelUp(ItemData data)
{
	Item item = Get(data);
	if (item) return LevelUp(item);
	return false;
}

Also, we need to modify the Get() function in PlayerInventory that we implemented back in Part 15, as both versions below will throw NullReferenceException errors when they run into empty slots:

// Find a passive of a certain type in the inventory.
public Passive Get(PassiveData type)
{
	foreach (Slot s in passiveSlots)
	{
		Passive p = s.item as Passive;
		if (p && p.data == type)
			return p;
	}
	return null;
}

// Find a weapon of a certain type in the inventory.
public Weapon Get(WeaponData type)
{
	foreach (Slot s in weaponSlots)
	{
		Weapon w = s.item as Weapon;
		if (w && w.data == type)
			return w;
	}
	return null;
}

This is because, when a slot is empty, s.item will be null. Hence the passive p or weapon w will be null as well. If the p or w variables are null, then w.data / p.data will become null.data, which is what causes the NullReferenceException.

After applying all the changes we list above, this is what the PlayerInventory script should look like (we’re gonna be making a few more changes to it later in this part though):

PlayerInventory.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerInventory : MonoBehaviour
{
    [System.Serializable]
    public class Slot
    {
        public Item item;
        public Image image;

        public void Assign(Item assignedItem)
        {
            item = assignedItem;
            if(item is Weapon)
            {
                Weapon w = item as Weapon;
                image.enabled = true;
                image.sprite = w.data.icon;
            }
            else
            {
                Passive p = item as Passive;
                image.enabled = true;
                image.sprite = p.data.icon;
            }
            Debug.Log(string.Format("Assigned {0} to player.", item.name));
        }

        public void Clear()
        {
            item = null;
            image.enabled = false;
            image.sprite = null;
        }

        public bool IsEmpty() { return item == null; }
    }
    public List<Slot> weaponSlots = new List<Slot>(6);
    public List<Slot> passiveSlots = new List<Slot>(6);

    [System.Serializable]
    public class UpgradeUI
    {
        public TMP_Text upgradeNameDisplay;
        public TMP_Text upgradeDescriptionDisplay;
        public Image upgradeIcon;
        public Button upgradeButton;
    }

    [Header("UI Elements")]
    public List<WeaponData> availableWeapons = new List<WeaponData>();    //List of upgrade options for weapons
    public List<PassiveData> availablePassives = new List<PassiveData>(); //List of upgrade options for passive items
    public List<UpgradeUI> upgradeUIOptions = new List<UpgradeUI>();    //List of ui for upgrade options present in the scene

    PlayerStats player;

    void Start()
    {
        player = GetComponent<PlayerStats>();
    }

    // Checks if the inventory has an item of a certain type.
    public bool Has(ItemData type) { return Get(type); }

    public Item Get(ItemData type)
    {
        if (type is WeaponData) return Get(type as WeaponData);
        else if (type is PassiveData) return Get(type as PassiveData);
        return null;
    }

    // Find a passive of a certain type in the inventory.
    public Passive Get(PassiveData type)
    {
        foreach (Slot s in passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p && p.data == type)
                return p;
        }
        return null;
    }

    // Find a weapon of a certain type in the inventory.
    public Weapon Get(WeaponData type)
    {
        foreach (Slot s in weaponSlots)
        {
            Weapon w = s.item as Weapon;
            if (w && w.data == type)
                return w;
        }
        return null;
    }

    // Removes a weapon of a particular type, as specified by .
    public bool Remove(WeaponData data, bool removeUpgradeAvailability = false)
    {
        // Remove this weapon from the upgrade pool.
        if (removeUpgradeAvailability) availableWeapons.Remove(data);

        for(int i = 0; i < weaponSlots.Count; i++)
        {
            Weapon w = weaponSlots[i].item as Weapon;
            if (w.data == data)
            {
                weaponSlots[i].Clear();
                w.OnUnequip();
                Destroy(w.gameObject);
                return true;
            }
        }

        return false;
    }

    // Removes a passive of a particular type, as specified by .
    public bool Remove(PassiveData data, bool removeUpgradeAvailability = false)
    {
        // Remove this passive from the upgrade pool.
        if (removeUpgradeAvailability) availablePassives.Remove(data);

        for (int i = 0; i < weaponSlots.Count; i++)
        {
            Passive p = weaponSlots[i].item as Passive;
            if (p.data == data)
            {
                weaponSlots[i].Clear();
                p.OnUnequip();
                Destroy(p.gameObject);
                return true;
            }
        }

        return false;
    }

    // If an ItemData is passed, determine what type it is and call the respective overload.
    // We also have an optional boolean to remove this item from the upgrade list.
    public bool Remove(ItemData data, bool removeUpgradeAvailability = false)
    {
        if (data is PassiveData) return Remove(data as PassiveData, removeUpgradeAvailability);
        else if(data is WeaponData) return Remove(data as WeaponData, removeUpgradeAvailability);
        return false;
    }

    // Finds an empty slot and adds a weapon of a certain type, returns
    // the slot number that the item was put in.
    public int Add(WeaponData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for(int i = 0; i < weaponSlots.Capacity; i++)
        {
            if (weaponSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the weapon in the slot.
        // Get the type of the weapon we want to spawn.
        Type weaponType = Type.GetType(data.behaviour);

        if (weaponType != null)
        {
            // Spawn the weapon GameObject.
            GameObject go = new GameObject(data.baseStats.name + " Controller");
            Weapon spawnedWeapon = (Weapon)go.AddComponent(weaponType);
            spawnedWeapon.transform.SetParent(transform); //Set the weapon to be a child of the player
            spawnedWeapon.transform.localPosition = Vector2.zero;
            spawnedWeapon.Initialise(data);
            spawnedWeapon.OnEquip();

            // Assign the weapon to the slot.
            weaponSlots[slotNum].Assign(spawnedWeapon);

            // Close the level up UI if it is on.
            if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
                GameManager.instance.EndLevelUp();

            return slotNum;
        }
        else
        {
            Debug.LogWarning(string.Format(
                "Invalid weapon type specified for {0}.",
                data.name
            ));
        }

        return -1;
    }

    // Finds an empty slot and adds a passive of a certain type, returns
    // the slot number that the item was put in.
    public int Add(PassiveData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for (int i = 0; i < passiveSlots.Capacity; i++)
        {
            if (passiveSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the passive in the slot.
        // Get the type of the passive we want to spawn.
        GameObject go = new GameObject(data.baseStats.name + " Passive");
        Passive p = go.AddComponent<Passive>();
        p.Initialise(data);
        p.transform.SetParent(transform); //Set the weapon to be a child of the player
        p.transform.localPosition = Vector2.zero;

        // Assign the passive to the slot.
        passiveSlots[slotNum].Assign(p);

        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }
        player.RecalculateStats();

        return slotNum;
    }

    // If we don't know what item is being added, this function will determine that.
    public int Add(ItemData data)
    {
        if (data is WeaponData) return Add(data as WeaponData);
        else if (data is PassiveData) return Add(data as PassiveData);
        return -1;
    }

    public void LevelUpWeapon(int slotIndex, int upgradeIndex)
    {
        // Don't level up the weapon if it is already at max level.
        if (weaponSlots.Count > slotIndex)
        {
            Weapon weapon = weaponSlots[slotIndex].item as Weapon;
            if (!weapon.DoLevelUp())
            {
                Debug.LogWarning(string.Format(
                    "Failed to level up {0}.",
                    weapon.name
                ));
                return;
            }
        }
    }

    // Overload so that we can use both ItemData or Item to level up an
    // item in the inventory.
    public bool LevelUp(ItemData data)
    {
        Item item = Get(data);
        if (item) return LevelUp(item);
        return false;
    }

    // Levels up a selected weapon in the player inventory.
    public bool LevelUp(Item item)
    {
        // Tries to level up the item.
        if(!item.DoLevelUp())
        {
            Debug.LogWarning(string.Format(
                "Failed to level up {0}.",
                 item.name
            ));
            return false;
        }

        // Close the level up screen afterwards.
        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }

        // If it is a passive, recalculate player stats.
        if(item is Passive) player.RecalculateStats();
        return true;
    }

    public void LevelUpPassiveItem(int slotIndex, int upgradeIndex)
    {
        if (passiveSlots.Count > slotIndex)
        {
            Passive p = passiveSlots[slotIndex].item as Passive;
            if(!p.DoLevelUp())
            {
                Debug.LogWarning(string.Format(
                    "Failed to level up {0}.",
                    p.name
                ));
                return;
            }
        }

        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }
        player.RecalculateStats();
    }

    // Checks a list of slots to see if there are any slots left.
    int GetSlotsLeft(List<Slot> slots)
    {
        
        int count = 0;
        foreach(Slot s in slots)
        {
            if (s.IsEmpty()) count++;
        }
        return count;
    }

    // Determines what upgrade options should appear.
    void ApplyUpgradeOptions()
    {
        // Make a duplicate of the available weapon / passive upgrade lists
        // so we can iterate through them in the function.
        List<WeaponData> availableWeaponUpgrades = new List<WeaponData>(availableWeapons);
        List<PassiveData> availablePassiveItemUpgrades = new List<PassiveData>(availablePassives);

        // Iterate through each slot in the upgrade UI.
        foreach (UpgradeUI upgradeOption in upgradeUIOptions)
        {
            // If there are no more avaiable upgrades, then we abort.
            if (availableWeaponUpgrades.Count == 0 && availablePassiveItemUpgrades.Count == 0)
                return;

            // Determine whether this upgrade should be for passive or active weapons.
            int upgradeType;
            if (availableWeaponUpgrades.Count == 0)
            {
                upgradeType = 2;
            }
            else if (availablePassiveItemUpgrades.Count == 0)
            {
                upgradeType = 1;
            }
            else
            {
                // Random generates a number between 1 and 2.
                upgradeType = UnityEngine.Random.Range(1, 3);
            }

            // Generates an active weapon upgrade.
            if (upgradeType == 1)
            {
                
                // Pick a weapon upgrade, then remove it so that we don't get it twice.
                WeaponData chosenWeaponUpgrade = availableWeaponUpgrades[UnityEngine.Random.Range(0, availableWeaponUpgrades.Count)];
                availableWeaponUpgrades.Remove(chosenWeaponUpgrade);

                // Ensure that the selected weapon data is valid.
                if (chosenWeaponUpgrade != null)
                {
                    // Turns on the UI slot.
                    EnableUpgradeUI(upgradeOption);

                    // Loops through all our existing weapons. If we find a match, we will
                    // hook an event listener to the button that will level up the weapon
                    // when this upgrade option is clicked.
                    bool isLevelUp = false;
                    for (int i = 0; i < weaponSlots.Count; i++)
                    {
                        Weapon w = weaponSlots[i].item as Weapon;
                        if (w != null && w.data == chosenWeaponUpgrade)
                        {
                            // If the weapon is already at the max level, do not allow upgrade.
                            if (chosenWeaponUpgrade.maxLevel <= w.currentLevel)
                            {
                                DisableUpgradeUI(upgradeOption);
                                isLevelUp = true;
                                break;
                            }

                            // Set the Event Listener, item and level description to be that of the next level
                            upgradeOption.upgradeButton.onClick.AddListener(() => LevelUpWeapon(i, i)); //Apply button functionality
                            Weapon.Stats nextLevel = chosenWeaponUpgrade.GetLevelData(w.currentLevel + 1);
                            upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                            upgradeOption.upgradeNameDisplay.text = nextLevel.name;
                            upgradeOption.upgradeIcon.sprite = chosenWeaponUpgrade.icon;
                            isLevelUp = true;
                            break;
                        }
                    }

                    // If the code gets here, it means that we will be adding a new weapon, instead of
                    // upgrading an existing weapon.
                    if (!isLevelUp)
                    {
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenWeaponUpgrade)); //Apply button functionality
                        upgradeOption.upgradeDescriptionDisplay.text = chosenWeaponUpgrade.baseStats.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = chosenWeaponUpgrade.baseStats.name;    //Apply initial name
                        upgradeOption.upgradeIcon.sprite = chosenWeaponUpgrade.icon;
                    }
                }
            }
            else if (upgradeType == 2)
            {
                // NOTE: We have to recode this system, as right now it disables an upgrade slot if
                // we hit a weapon that has already reached max level.
                PassiveData chosenPassiveUpgrade = availablePassiveItemUpgrades[UnityEngine.Random.Range(0, availablePassiveItemUpgrades.Count)];
                availablePassiveItemUpgrades.Remove(chosenPassiveUpgrade);

                if (chosenPassiveUpgrade != null)
                {
                    // Turns on the UI slot.
                    EnableUpgradeUI(upgradeOption);

                    // Loops through all our existing passive. If we find a match, we will
                    // hook an event listener to the button that will level up the weapon
                    // when this upgrade option is clicked.
                    bool isLevelUp = false;
                    for (int i = 0; i < passiveSlots.Count; i++)
                    {
                        Passive p = passiveSlots[i].item as Passive;
                        if (p != null && p.data == chosenPassiveUpgrade)
                        {
                            // If the passive is already at the max level, do not allow upgrade.
                            if (chosenPassiveUpgrade.maxLevel <= p.currentLevel)
                            {
                                DisableUpgradeUI(upgradeOption);
                                isLevelUp = true;
                                break;
                            }
                            upgradeOption.upgradeButton.onClick.AddListener(() => LevelUpPassiveItem(i, i)); //Apply button functionality
                            Passive.Modifier nextLevel = chosenPassiveUpgrade.GetLevelData(p.currentLevel + 1);
                            upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                            upgradeOption.upgradeNameDisplay.text = nextLevel.name;
                            upgradeOption.upgradeIcon.sprite = chosenPassiveUpgrade.icon;
                            isLevelUp = true;
                            break;
                        }
                    }

                    if (!isLevelUp) //Spawn a new passive item
                    {
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenPassiveUpgrade)); //Apply button functionality
                        Passive.Modifier nextLevel = chosenPassiveUpgrade.baseStats;
                        upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = nextLevel.name;  //Apply initial name
                        upgradeOption.upgradeIcon.sprite = chosenPassiveUpgrade.icon;
                    }
                }
            }
        }
    }

    // Determines what upgrade options should appear.
    void ApplyUpgradeOptions()
    {
	// <availableUpgrades> is an empty list that will be filtered from
	// <allUpgrades>, which is the list of ALL upgrades in PlayerInventory.
	// Not all upgrades can be applied, as some may have already been
	// maxed out the player, or the player may not have enough inventory slots.
	List<ItemData> availableUpgrades = new List<ItemData>();
	List<ItemData> allUpgrades = new List<ItemData>(availableWeapons);
	allUpgrades.AddRange(availablePassives);

        // We need to know how many weapon / passive slots are left.
        int weaponSlotsLeft = GetSlotsLeft(weaponSlots);
        int passiveSlotsLeft = GetSlotsLeft(passiveSlots);

        // Filters through the available weapons and passives and add those
        // that can possibly be an option.
        foreach(ItemData data in allUpgrades)
        {
            // If a weapon of this type exists, allow for the upgrade if the
            // level of the weapon is not already maxed out.
            Item obj = Get(data);
            if(obj)
            {
                if (obj.currentLevel < data.maxLevel) availableUpgrades.Add(data);
            }
            else
            {
                // If we don't have this item in the inventory yet, check if
                // we still have enough slots to take new items.
                if (data is WeaponData && weaponSlotsLeft > 0) availableUpgrades.Add(data);
                else if (data is PassiveData && passiveSlotsLeft > 0) availableUpgrades.Add(data);
            }
        }

        // Iterate through each slot in the upgrade UI and populate the options.
        foreach (UpgradeUI upgradeOption in upgradeUIOptions)
        {
            // If there are no more available upgrades, then we abort.
            if (availableUpgrades.Count <= 0) return;

            // Pick an upgrade, then remove it so that we don't get it twice.
            ItemData chosenUpgrade = availableUpgrades[UnityEngine.Random.Range(0, availableUpgrades.Count)];
            availableUpgrades.Remove(chosenUpgrade);

            // Ensure that the selected weapon data is valid.
            if (chosenUpgrade != null)
            {
                // Turns on the UI slot.
                EnableUpgradeUI(upgradeOption);

                // If our inventory already has the upgrade, we will make it a level up.
                Item item = Get(chosenUpgrade);
                if(item)
                {
                    upgradeOption.upgradeButton.onClick.AddListener(() => LevelUp(item)); //Apply button functionality
                    if (item is Weapon)
                    {
                        Weapon.Stats nextLevel = ((WeaponData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
                        upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                        upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
                        upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
                    }
                    else
                    {
                        Passive.Modifier nextLevel = ((PassiveData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
                        upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                        upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
                        upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
                    }
                }
                else
                {
                    if(chosenUpgrade is WeaponData)
                    {
                        WeaponData data = chosenUpgrade as WeaponData;
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
                        upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
                        upgradeOption.upgradeIcon.sprite = data.icon;
                    }
                    else
                    {
                        PassiveData data = chosenUpgrade as PassiveData;
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
                        upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
                        upgradeOption.upgradeIcon.sprite = data.icon;
                    }
                    
                }
            }
        }
    }

    void RemoveUpgradeOptions()
    {
        foreach (UpgradeUI upgradeOption in upgradeUIOptions)
        {
            upgradeOption.upgradeButton.onClick.RemoveAllListeners();
            DisableUpgradeUI(upgradeOption);    //Call the DisableUpgradeUI method here to disable all UI options before applying upgrades to them
        }
    }

    public void RemoveAndApplyUpgrades()
    {
        RemoveUpgradeOptions();
        ApplyUpgradeOptions();
    }

    void DisableUpgradeUI(UpgradeUI ui)
    {
        ui.upgradeNameDisplay.transform.parent.gameObject.SetActive(false);
    }

    void EnableUpgradeUI(UpgradeUI ui)
    {
        ui.upgradeNameDisplay.transform.parent.gameObject.SetActive(true);
    }

}

2. Planning the Level-Up UI Screen improvements

After fixing the level-up issue with the UI screen, I also noticed that there are a few other issues that bothered me:

a. Issues with the Level-Up UI Screen

  1. Firstly, it is not adaptive, so at certain screen sizes, the elements in the corner of the screen get cut off because they are too large.
Level Up UI is not adaptive
If the screen is too small, certain elements get cut off.
  1. Secondly, the level-up screen only gives allows players to show 4 upgrades — it does not accommodate for a lesser number of upgrades, and it does not factor in the Luck stat, which gives players the chance to get an extra item when the stat has any kind of boost.
Luck stat in Vampire Survivors
You get 3 items, and a chance to get 4 items whenever you level up.
  1. Thirdly, our level-up interface does not show the level of the item, does not inform us if the item is new or if we already have it, and does not tell us if the item helps to evolve any of our other weapons.
Vampire Survivors level and evolution
The in-game level-up UI shows you what level your item is in, and whether it helps to evolve any of your existing items.
  1. Finally, the code that makes it function is tightly-coupled with the PlayerInventory script — it is completely controlled by PlayerInventory, which makes it very non-modular. This means that modifying it and improving upon it quite difficult.

Hence, I set out to improve on these 4 aspects of the level-up UI screen.

b. Let’s talk about modularity

When we create any kind of programming application, one of the most important things that you want to do when writing your code is to make the different parts of it as modular as possible. What does this mean?

If we say something is modular, we are saying that it is a part of a bigger thing that is also capable of working independently. Take for example a computer mouse — we say that it is modular because, no matter which computer it is attached to, it is able to perform its function. On the contrary, if we had a mouse that could not be detached from the computer it is made for, or only works with a specific computer, it would be a non-modular piece of technology.

In the programming world, whenever a large-scale application is built, you can almost always be sure that the thing that has been built will consist of many constituent parts that are modular in nature. There are several reasons for this:

  1. Modular components are easily-reusable, since they are designed to work in a variety of environments (like how a mouse can work with different operating systems). This means that, once it is built, it can be reused in many places, and this saves time and effort in development and maintenance.
  2. Creating applications modularly also has the side of making it extremely well-organised. This means that it will be easier to scale (i.e. adding many new features), and easier to debug and maintain (each part of the application is more walled off from the others, so that errors in one module will be less likely to affect the others).
  3. Coding modularly also makes it much easier for developers to collaborate, as it is easier to split development tasks among multiple members, and different team members can work on different modules simultaneously without interfering with each other’s work.

c. Modularity in Unity Components

In Unity, the components that we attach to GameObjects are also highly-modular in nature. Take the Rigidbody2D component for example — we can easily add it to any GameObject in our Scene to add physics behaviours to it.

How the moving platform works.
The Rigidbody2D component manages collisions between GameObjects, and resolves the forces that are acting on them.

Because it is coded with modularity in mind, it is designed to work in a wide range of situations. There are a variety of attributes that you can tweak on the Rigidbody component to modify how it functions.

Freeze positions in Rigidbody
This is a Rigidbody, not a Rigidbody2D, but my point still stands.

d. Applying the concept to our level-up screen

We want to apply this design concept to our level-up UI screen as well. This means that:

  1. Instead of being controlled by the PlayerInventory script entirely, we want it to be managed by its own script (which we will call UIUpgradeScreen). Whenever the player levels up, the PlayerInventory script will call the SetUpgrades() function on it to tell it which upgrades should be made available. This new script will handle the display of the upgrade options on the UI.
  2. By coding things this way, it will make it very easy for us to activate the level-up screen by other means in future as well. For example, if you wanted to create an item that will gift an upgrade, all you have to do is make the item call SetUpgrades() when picked up.

3. Making the Level-up Screen adaptive

Let’s tackle the easiest part of this overhaul first — making the level-up screen adaptive. What does it mean when a screen is adaptive? That means that it can adapt its layout to different screen widths, screen heights, and aspect ratios.

Adaptive display example
Adaptive display is something that every web developer has to consider. Today, with the number of games on mobiles, it is becoming something game developers have to be aware of as well.

a. Making a duplicate as backup

Before we start modifying our level-up UI though, let’s create a duplicate of the level-up box on our Game scene, and disable the original one, so we have a back up when things go wrong.

Level up UI box
Duplicate the Level-up UI box

b. Making our Upgrade window scalable

The first thing we need to do, is make the window scalable. Currently, because of how our elements are constructed, if we change the size of our window, all the elements within will become distorted.

UI Upgrade Layout Issue
If we are making this adaptive, this element will change its size based on the screen it is on, so we will need to fix this.

To begin, set the following properties in the Vertical Layout Group on the Upgrade Options Holder (New) object that we just created:

  • Check the Width and Height boxes of both Control Child Size and Child Force Expand. This makes the children of our Vertical Layout Group (our buttons) automatically size themselves to fit within the space of the parent object, and fill up the entire space of the container.
  • Unroll the Padding variable in the group and key in a suitable padding for all directions, so that there is space between our buttons and the edges of the window. I’m going to be using 18 pixels for all directions for padding.
  • Set a Spacing of 10 pixels. This controls the space between items in the vertical layout.
  • Finally, set the Child Alignment attribute to Upper Center.
Vertical Layout Group
My settings for the Vertical Layout Group on Upgrade Options Holder.

This should fix the buttons expanding beyond our window borders when we shrink the window — they should now scale according to the amount of space they have. Now, we have to make the background scale together with our window as well, so parent Upgrade Options Background to Upgrade Options Holder (New). This turns the background into one of the items on the vertical layout, however…

Vertical Layout with background
Worry not. There’s an easy fix to this.

This can be easily fixed by adding a Layout Element to the background, and then checking the Ignore Layout property. This allows children of layout groups like our Vertical Layout Group to ignore the layout, so that we can stretch the background to cover the entire element.

Use Layout Element to make the GameObject ignore the Vertical Layout.
Use a Layout Element component with the Ignore Layout property checked to make a GameObject ignore the Vertical Layout Group.
stretch to fit screen
To stretch the background, hold Alt and click on the highlighted button above.

Upon stretching the background to cover the entire element though, we see that it covers all our buttons, and the edges of the window provided by our parent GameObject. To fix this, we will need to swap the sprites of the parent window and the background element on the Image element.

Background GameObject covers everything
=You will need to swap the Image sprites of both GameObject elements, so the border and the buttons appear above the blue background.

Finally, make sure that the Upgrade Options Background GameObject has got the Raycast Target option on its Image component unchecked. Otherwise, it will block clicks to the buttons and cause the buttons to not work.

Unity UI Raycast Target
Disabling Raycast Target will prevent the UI element from blocking Buttons and other clickables.

If everything is set up correctly, try resizing the element. You should see the entire element resize itself proportionately, including the buttons within.

Level up UI window resizable
The window and its elements should scale uniformly now.

So now, we can move on to…

c. Making the window adaptive

The first step to making any UI element adaptive is to split its anchor. Hence, let’s split the Anchor of the level-up box (named Upgrade Options Holder (New) above) by pulling on one of its “petals”:

How to split the anchor
Notice that the Anchor property on the right changes when you split the Anchor, and the X, Y, Width and Height properties become Top, Left, Right and Bottom as well.

We are splitting the anchor of this UI GameObject because we want it its width and height to always be a percentage of the total screen size. In our case, we want our box to take up 50% of the screen space in the middle, and 80% of the screen height.

Manually adjusting the anchor to these values are possible, though it will be more precise for us to adjust the Anchors attribute on the GameObject directly.

Anchor settings for adaptive display
Set the Anchor to 0.25 and 0.75 for X, and 0.05 to 0.85 for Y. Also set the Top, Left, Right and Bottom to 0.

This will make the Anchor take up 50% of the horizontal space in the middle, and 80% of the vertical space somewhere around the middle. By setting the Left, Top, Right and Bottom properties to 0, we are also making sure our box always fills up the box defined by the edges of the Anchor.

Afterwards, try going to the Game screen with the level-up screen open. Try resizing the Game screen and you should find that the level-up screen fits itself into your screen regardless of the size.

The level-up screen is now adaptive
The level-up screen is now adaptive.

d. Rearranging the button layout

Another thing that you might want to consider is rearranging the layout of the icon and text elements in the button. Here is how I have rearranged the button layout, so that there is more space for the text in the weapon description to display:

Level up UI button
Notice that the Anchors of all the elements are split. This is very important, because it makes the element adaptive.

For your icon, since it consists of several child GameObjects that contain images, you also want to make sure their Anchors are made to stretch and fill up their parents. Also, check the Preserve Aspect property on the icon border and the item sprite, so that they always display in their original aspect ratio (i.e. a square), regardless of what the screen size is.

Unity UI Image - Preserve Aspect Ratio
Make sure that Preserve Aspect is checked.

If you don’t check Preserve Aspect, in certain resolutions, your button and border images may get distorted.

Icon without Preserve Aspect
This is what you may see at certain screen resolutions.

Update 1 May 2024: You’ll also need to add another Text (TextMeshPro) field somewhere in your button. I’ve chosen to put it under the icon, but you can choose to put it anywhere you want. In our script for this window later, we will change the text to say “New!” if the item is new. Otherwise, it will display the current level.

Level-up UI button with level
Notice that the Anchor for the new element is split as well, and it is in line with the image.

In the next part, I will cover how we can also show all the evolutions that this upgrade will allow us to access as well.

e. Limitations of the current configuration

Do note that while our level-up window scales now, there are still some limitations to its adaptiveness. If you use a portrait landscape of any kind, especially on mobiles, the display will still not look good.

Mobile layout for level-up screen
It doesn’t look good on Mobile Portrait.

There isn’t really a way to fix it with Anchor placement, because you need to make the horizontal boundaries of the Anchor larger so that it looks better on Portrait screens.

Mobile layout for level-up screen
We need to widen the Anchor’s horizontal coverage so it covers more of the screen.

But this will make the landscape view look less aesthetic, as most of the screen will be covered:

Landscape view for portrait
If we adapt this to portrait views, then the UI doesn’t look good on Landscape.

There is no way to fix this without configuring multiple screens for different aspect ratios. For now, if you are planning to building the game, make sure that you only allow the game to be played on a Landscape aspect ratio.

4. Streamlining access to the level data of an Item

Now that we’ve got the UI elements in our level-up window to become adaptive, the next thing we want to do is create a script that can manage this level-up window. Before we get to doing that, however, we’ll need to modify the variable structure of our Item, Weapon and Passive scripts (as well as their corresponding data classes).

This is because we will need to be able to retrieve an Item’s data at a specific level without knowing whether it is a Passive or Weapon, because our new UIUpgradeWindow script is going to be dealing with both Weapon and Passive items.

a. The problem

Long story short, we want to be able to do this:

Item myItem = inventory.Get(itemData);
Item.LevelData data = itemData.GetLevelData( myItem.currentLevel ); // Get the level data.

But to do this now, we need to either cast an ItemData into a WeaponData or PassiveData first before we can retrieve level data from it, which means we will need to use conditionals to check for this first.

Item myItem = inventory.Get(itemData);

if(myItem is Weapon)
    Weapon.Stats data = (itemData as WeaponData).GetLevelData( myItem.currentLevel );
else if(myItem is Passive) 
    Passive.Modifier = (itemData as PassiveData).GetLevelData( myItem.currentLevel );

This can get extremely cumbersome if we have a lot of code, as every piece of logic will need to be duplicated for both categories of items, and we will need to duplicate more code if we have more categories of items in the future!

b. The solution

The solution to this is to create a common superclass for both Weapon.Stats and Passive.Modifier in Item:

Item.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Base class for both the Passive and the Weapon classes. It is primarily intended
/// to handle weapon evolution, as we want both weapons and passives to be evolve-able.
/// </summary>
public abstract class Item : MonoBehaviour
{
    public int currentLevel = 1, maxLevel = 1;
    protected ItemData.Evolution[] evolutionData;
    protected PlayerInventory inventory;
    protected PlayerStats owner;

    public PlayerStats Owner { get { return owner; } }

    [System.Serializable]
    public class LevelData
    {
        public string name, description;
    }

    public virtual void Initialise(ItemData data)
    {
        maxLevel = data.maxLevel;

        // Store the evolution data as we have to track whether
        // all the catalysts are in the inventory so we can evolve.
        evolutionData = data.evolutionData;

        // We have to find a better way to reference the player inventory
        // in future, as this is inefficient.
        inventory = GetComponentInParent<PlayerInventory>();
        owner = GetComponentInParent<PlayerStats>();
    }

    // Call this function to get all the evolutions that the weapon
    // can currently evolve to.
    public virtual ItemData.Evolution[] CanEvolve()
    {
        List<ItemData.Evolution> possibleEvolutions = new List<ItemData.Evolution>();

        // Check each listed evolution and whether it is in
        // the inventory.
        foreach (ItemData.Evolution e in evolutionData)
        {
            if (CanEvolve(e)) possibleEvolutions.Add(e);
        }


        return possibleEvolutions.ToArray();
    }

    // Checks if a specific evolution is possible.
    public virtual bool CanEvolve(ItemData.Evolution evolution, int levelUpAmount = 1)
    {
        // Cannot evolve if the item hasn't reached the level to evolve.
        if (evolution.evolutionLevel > currentLevel + levelUpAmount)
        {
            Debug.LogWarning(string.Format("Evolution failed. Current level {0}, evolution level {1}", currentLevel, evolution.evolutionLevel));
            return false;
        }

        // Checks to see if all the catalysts are in the inventory.
        foreach (ItemData.Evolution.Config c in evolution.catalysts)
        {
            Item item = inventory.Get(c.itemType);
            if (!item || item.currentLevel < c.level)
            {
                Debug.LogWarning(string.Format("Evolution failed. Missing {0}", c.itemType.name));
                return false;
            }
        }

        return true;
    }

    // AttemptEvolution will spawn a new weapon for the character, and remove all
    // the weapons that are supposed to be consumed.
    public virtual bool AttemptEvolution(ItemData.Evolution evolutionData, int levelUpAmount = 1)
    {
        if (!CanEvolve(evolutionData, levelUpAmount))
            return false;

        // Should we consume passives / weapons?
        bool consumePassives = (evolutionData.consumes & ItemData.Evolution.Consumption.passives) > 0;
        bool consumeWeapons = (evolutionData.consumes & ItemData.Evolution.Consumption.weapons) > 0;

        // Loop through all the catalysts and check if we should consume them.
        foreach (ItemData.Evolution.Config c in evolutionData.catalysts)
        {
            if (c.itemType is PassiveData && consumePassives) inventory.Remove(c.itemType, true);
            if (c.itemType is WeaponData && consumeWeapons) inventory.Remove(c.itemType, true);
        }

        // Should we consume ourselves as well?
        if (this is Passive && consumePassives) inventory.Remove((this as Passive).data, true);
        else if (this is Weapon && consumeWeapons) inventory.Remove((this as Weapon).data, true);

        // Add the new weapon onto our inventory.
        inventory.Add(evolutionData.outcome.itemType);

        return true;
    }

    public virtual bool CanLevelUp()
    {
        return currentLevel <= maxLevel;
    }

    // Whenever an item levels up, attempt to make it evolve.
    public virtual bool DoLevelUp()
    {
        if (evolutionData == null) return true;

        // Tries to evolve into every listed evolution of this weapon,
        // if the weapon's evolution condition is levelling up.
        foreach (ItemData.Evolution e in evolutionData)
        {
            if (e.condition == ItemData.Evolution.Condition.auto)
                AttemptEvolution(e);
        }
        return true;
    }

    // What effects you receive on equipping an item.
    public virtual void OnEquip() { }

    // What effects are removed on unequipping an item.
    public virtual void OnUnequip() { }
}

This allows us to define a new GetLevelData() function in ItemData.cs, which will allow us to call GetLevelData() on our ItemData objects:

ItemData.cs

using UnityEngine;

/// <summary>
/// Base class for all weapons / passive items. The base class is used so that both WeaponData
/// and PassiveItemData are able to be used interchangeably if required.
/// </summary>
public abstract class ItemData : ScriptableObject
{
    public Sprite icon;
    public int maxLevel;

    [System.Serializable]
    public struct Evolution
    {
        public string name;
        public enum Condition { auto, treasureChest }
        public Condition condition;

        [System.Flags] public enum Consumption { passives = 1, weapons = 2 }
        public Consumption consumes;

        public int evolutionLevel;
        public Config[] catalysts;
        public Config outcome;

        [System.Serializable]
        public struct Config
        {
            public ItemData itemType;
            public int level;
        }
    }

    public Evolution[] evolutionData;

    public abstract Item.LevelData GetLevelData(int level);
}

Now, we will extend both Weapon.Stats and Passive.Modifier from Item.LevelData. That way, we can return either a Weapon.Stats or Passive.Modifier when we override GetLevelData() in our child classes (note that we also have to cast the result of GetLevelData() in the respective DoLevelUp() functions of both classes):

Weapon.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Component to be attached to all Weapon prefabs. The Weapon prefab works together with the WeaponData
/// ScriptableObjects to manage and run the behaviours of all weapons in the game.
/// </summary>
public abstract class Weapon : Item
{
    [System.Serializable]
    public structclass Stats : LevelData
    {
        public string name, description;

        [Header("Visuals")]
        public Projectile projectilePrefab; // If attached, a projectile will spawn every time the weapon cools down.
        public Aura auraPrefab; // If attached, an aura will spawn when weapon is equipped.
        public ParticleSystem hitEffect;
        public Rect spawnVariance;

        [Header("Values")]
        public float lifespan; // If 0, it will last forever.
        public float damage, damageVariance, area, speed, cooldown, projectileInterval, knockback;
        public int number, piercing, maxInstances;

        // Allows us to use the + operator to add 2 Stats together.
        // Very important later when we want to increase our weapon stats.
        public static Stats operator +(Stats s1, Stats s2)
        {
            Stats result = new Stats();
            result.name = s2.name ?? s1.name;
            result.description = s2.description ?? s1.description;
            result.projectilePrefab = s2.projectilePrefab ?? s1.projectilePrefab;
            result.auraPrefab = s2.auraPrefab ?? s1.auraPrefab;
            result.hitEffect = s2.hitEffect == null ? s1.hitEffect : s2.hitEffect;
            result.spawnVariance = s2.spawnVariance;
            result.lifespan = s1.lifespan + s2.lifespan;
            result.damage = s1.damage + s2.damage;
            result.damageVariance = s1.damageVariance + s2.damageVariance;
            result.area = s1.area + s2.area;
            result.speed = s1.speed + s2.speed;
            result.cooldown = s1.cooldown + s2.cooldown;
            result.number = s1.number + s2.number;
            result.piercing = s1.piercing + s2.piercing;
            result.projectileInterval = s1.projectileInterval + s2.projectileInterval;
            result.knockback = s1.knockback + s2.knockback;
            return result;
        }

        // Get damage dealt.
        public float GetDamage()
        {
            return damage + Random.Range(0, damageVariance);
        }
    }

    protected Stats currentStats;

    public WeaponData data;

    protected float currentCooldown;

    protected PlayerMovement movement; // Reference to the player's movement.

    // For dynamically created weapons, call initialise to set everything up.
    public virtual void Initialise(WeaponData data)
    {
        base.Initialise(data);
        this.data = data;
        currentStats = data.baseStats;
        movement = GetComponentInParent<PlayerMovement>();
        ActivateCooldown();
    }

    protected virtual void Update()
    {
        currentCooldown -= Time.deltaTime;
        if (currentCooldown <= 0f) //Once the cooldown becomes 0, attack
        {
            Attack(currentStats.number + owner.Stats.amount);
        }
    }

    // Levels up the weapon by 1, and calculates the corresponding stats.
    public override bool DoLevelUp()
    {
        base.DoLevelUp();

        // Prevent level up if we are already at max level.
        if (!CanLevelUp())
        {
            Debug.LogWarning(string.Format("Cannot level up {0} to Level {1}, max level of {2} already reached.", name, currentLevel, data.maxLevel));
            return false;
        }

        // Otherwise, add stats of the next level to our weapon.
        currentStats += (Stats)data.GetLevelData(++currentLevel);
        return true;
    }

    // Lets us check whether this weapon can attack at this current moment.
    public virtual bool CanAttack()
    {
        return currentCooldown <= 0;
    }

    // Performs an attack with the weapon.
    // Returns true if the attack was successful.
    // This doesn't do anything. We have to override this at the child class to add a behaviour.
    protected virtual bool Attack(int attackCount = 1)
    {
        if (CanAttack())
        {
            ActivateCooldown();
            return true;
        }
        return false;
    }

    // Gets the amount of damage that the weapon is supposed to deal.
    // Factoring in the weapon's stats (including damage variance),
    // as well as the character's Might stat.
    public virtual float GetDamage()
    {
        return currentStats.GetDamage() * owner.Stats.might;
    }

    // Get the area, including modifications from the player's stats.
    public virtual float GetArea()
    {
        return currentStats.area * owner.Stats.area;
    }

    // For retrieving the weapon's stats.
    public virtual Stats GetStats() { return currentStats; }

    // Refreshes the cooldown of the weapon.
    // If <strict> is true, refreshes only when currentCooldown < 0.
    public virtual bool ActivateCooldown(bool strict = false)
    {
        // When <strict> is enabled and the cooldown is not yet finished,
        // do not refresh the cooldown.
        if(strict && currentCooldown > 0) return false;

        // Calculate what the cooldown is going to be, factoring in the cooldown
        // reduction stat in the player character.
        float actualCooldown = currentStats.cooldown * Owner.Stats.cooldown;

        // Limit the maximum cooldown to the actual cooldown, so we cannot increase
        // the cooldown above the cooldown stat if we accidentally call this function
        // multiple times.
        currentCooldown = Mathf.Min(actualCooldown, currentCooldown + actualCooldown);
        return true;
    }
}

Passive.cs

using UnityEngine;

/// <summary>
/// A class that takes an PassiveData and is used to increment a player's
/// stats when received.
/// </summary>
public class Passive : Item
{
    public PassiveData data;
    [SerializeField] CharacterData.Stats currentBoosts;

    [System.Serializable]
    public structclass Modifier : LevelData
    {
        public string name, description;
        public CharacterData.Stats boosts;
    }

    // For dynamically created passives, call initialise to set everything up.
    public virtual void Initialise(PassiveData data)
    {
        base.Initialise(data);
        this.data = data;
        currentBoosts = data.baseStats.boosts;
    }

    public virtual CharacterData.Stats GetBoosts()
    {
        return currentBoosts;
    }

    // Levels up the weapon by 1, and calculates the corresponding stats.
    public override bool DoLevelUp()
    {
        base.DoLevelUp();

        // Prevent level up if we are already at max level.
        if (!CanLevelUp())
        {
            Debug.LogWarning(string.Format("Cannot level up {0} to Level {1}, max level of {2} already reached.", name, currentLevel, data.maxLevel));
            return false;
        }

        // Otherwise, add stats of the next level to our weapon.
        currentBoosts += ((Modifier)data.GetLevelData(++currentLevel)).boosts;
        return true;
    }
}

Finally, we modify the GetLevelData() function in both PassiveData and WeaponData so that their method headers contain an override. This is because they are now overriding the abstract function in ItemData.

PassiveData.cs

using UnityEngine;

/// <summary>
/// Replacement for the PassiveItemScriptableObject class. The idea is we want to store all 
/// passive item level data in one single object, instead of having multiple objects to store 
/// a single passive item, which is what we would have had to do if we continued using 
/// PassiveItemScriptableObject.
/// </summary>
[CreateAssetMenu(fileName = "Passive Data", menuName = "2D Top-down Rogue-like/Passive Data")]
public class PassiveData : ItemData
{
    public Passive.Modifier baseStats;
    public Passive.Modifier[] growth;

    public override PassiveModifierItem.LevelData GetLevelData(int level)
    {
        if (level <= 1) return baseStats;

        // Pick the stats from the next level.
        if (level - 2 < growth.Length)
            return growth[level - 2];

        // Return an empty value and a warning.
        Debug.LogWarning(string.Format("Passive doesn't have its level up stats configured for Level {0}!", level));
        return new Passive.Modifier();
    }
}

WeaponData.cs

using UnityEngine;

/// <summary>
/// Replacement for the WeaponScriptableObject class. The idea is we want to store all weapon evolution
/// data in one single object, instead of having multiple objects to store a single weapon, which is
/// what we would have had to do if we continued using WeaponScriptableObject.
/// </summary>
[CreateAssetMenu(fileName = "Weapon Data", menuName = "2D Top-down Rogue-like/Weapon Data")]
public class WeaponData : ItemData
{
    [HideInInspector] public string behaviour;
    public Weapon.Stats baseStats;
    public Weapon.Stats[] linearGrowth;
    public Weapon.Stats[] randomGrowth;

    // Gives us the stat growth / description of the next level.
    public override Weapon.StatsItem.LevelData GetLevelData(int level)
    {
        if (level <= 1) return baseStats;

        // Pick the stats from the next level.
        if (level - 2 < linearGrowth.Length)
            return linearGrowth[level - 2];

        // Otherwise, pick one of the stats from the random growth array.
        if (randomGrowth.Length > 0)
            return randomGrowth[Random.Range(0, randomGrowth.Length)];

        // Return an empty value and a warning.
        Debug.LogWarning(string.Format("Weapon doesn't have its level up stats configured for Level {0}!",level));
        return new Weapon.Stats();
    }

}

Now that both Weapon.Stats and Passive.Modifier are connected through a common superclass, we can proceed to create our script for the level-up window.

Update 4 May 2024: Notice also we have added the following line in the GetLevelData() function in both WeaponData.cs and PassiveData.cs:

if (level <= 1) return baseStats;

This line was added to prevent an IndexOutOfRangeException, because one of the lines in the code does this:

return linearGrowth[level - 2];

Which means that if you call GetLevelData(1) or below, you will get linearGrowth[1 - 2], which will give you linearGrowth[-1] — an invalid index for an array because array indexes do not have negative numbers.

5. Creating the UIUpgradeWindow script

1 May 2024: The UIUpgradeWindow.cs and PlayerInventory.cs scripts here have been updated. Check out this forum post for more details.

As we’ve described above, it is always a good thing when coding components for our game to make sure that it is as modular as possible — this allows us to reuse the component when we need a similar functionality somewhere else in our game later on.

Currently, the level-up screen is controlled by our PlayerInventory script. This is sub-optimal, because it means that no other component in our game can influence or control the contents of the level-up screen without going through the PlayerInventory.

Hence, what we are going to do is decouple the functionality of the level-up screen with the PlayerInventory script.

a. Preparing your UI elements

Before we create the script, let’s delete the rest of the buttons under our Upgrade Options Holder (New) GameObject. Since we want the level-up window to be able to accommodate a flexible number of upgrade options, we are just going to leave 1 button under the GameObject and allow the script to help us generate the rest.

UIUpgradeWindow template
Our rearranged upgrade window GameObject.

Let’s also add a tooltip under the button. This will be displayed or hidden depending on the circumstances, since we need to display a text description under our upgrade window sometimes.

Luck stat in Vampire Survivors
The tooltip will be needed to allow us to display this text.

b. How the UIUpgradeWindow will work

We are going to create a UIUpgradeWindow script that we will attach to the Upgrade Options Holder (New) GameObject that we were making adaptive. The primary role of the script is to:

  • Receive a list of upgrade options from the PlayerInventory, and;
  • Handle the display of all of these options under it.

We also want to make the UIUpgradeWindow be able to change the number of options that it displays. Right now, it is hardcoded to always display 4 options, which limits its flexibility.

Anyhow, here is the script for it:

1 May 2024: Updated the script below with more detailed comments to explain how the script works.

UIUpgradeWindow.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

// We require a VerticalLayoutGroup on the GameObject this is
// attached to, because it uses the component to make sure the
// buttons are evenly spaced out.
[RequireComponent(typeof(VerticalLayoutGroup))]
public class UIUpgradeWindow : MonoBehaviour
{
    // We will need to access the padding / spacing attributes on the layout.
    VerticalLayoutGroup verticalLayout;

    // The button and tooltip template GameObjects we have to assign.
    public RectTransform upgradeOptionTemplate;
    public TextMeshProUGUI tooltipTemplate;

    [Header("Settings")]
    public int maxOptions = 4; // We cannot show more options than this.
    public string newText = "New!"; // The text that shows when a new upgrade is shown;

    // Color of the "New!" text and the regular text.
    public Color newTextColor = Color.yellow, levelTextColor = Color.white;

    // These are the paths to the different UI elements in the <upgradeOptionTemplate>.
    [Header("Paths")]
    public string iconPath = "Icon/Item Icon";
    public string namePath = "Name", descriptionPath = "Description", buttonPath = "Button", levelPath = "Level";

    // These are private variables that are used by the functions to track the status
    // of different things in the UIUpgradeWindow.
    RectTransform rectTransform; // The RectTransform of this element for easy reference.
    float optionHeight; // The default height of the upgradeOptionTemplate.
    int activeOptions; // Tracks the number of options that are active currently.

    // This is a list of all the upgrade buttons on the window.
    List<RectTransform> upgradeOptions = new List<RectTransform>();

    // This is used to track the screen width / height of the last frame.
    // To detect screen size changes, so we know when we have to recalculate the size.
    Vector2 lastScreen;

    // This is the main function that we will be calling on this script.
    // You need to specify which <inventory> to add the item to, and a list of all
    // <possibleUpgrades> to show. It will select <pick> number of upgrades and show
    // them. Finally, if you specify a <tooltip>, then some text will appear at the bottom of
    // the window.
    public void SetUpgrades(PlayerInventory inventory, List<ItemData> possibleUpgrades, int pick = 3, string tooltip = "") 
    {
        pick = Mathf.Min(maxOptions, pick);
        
        // If we don't have enough upgrade option boxes, create them.
        if (maxOptions > upgradeOptions.Count)
        {
            for (int i = upgradeOptions.Count; i < pick; i++)
            {
                GameObject go = Instantiate(upgradeOptionTemplate.gameObject, transform);
                upgradeOptions.Add((RectTransform)go.transform);
            }
        }

        // If a string is provided, turn on the tooltip.
        tooltipTemplate.text = tooltip;
        tooltipTemplate.gameObject.SetActive(tooltip.Trim() != "");

        // Activate only the number of upgrade options we need, and arm the buttons and the
        // different attributes like descriptions, etc.
        activeOptions = 0;
        int totalPossibleUpgrades = possibleUpgrades.Count; // How many upgrades do we have to choose from?
        foreach(RectTransform r in upgradeOptions)
        {
            if (activeOptions < pick && activeOptions < totalPossibleUpgrades)
            {
                r.gameObject.SetActive(true);

                // Select one of the possible upgrades, then remove it from the list.
                ItemData selected = possibleUpgrades[Random.Range(0, possibleUpgrades.Count)];
                possibleUpgrades.Remove(selected);
                Item item = inventory.Get(selected);

                // Insert the name of the item.
                TextMeshProUGUI name = r.Find(namePath).GetComponent<TextMeshProUGUI>();
                if(name)
                {
                    name.text = selected.name;
                }

                // Insert the current level of the item, or a "New!" text if it is a new weapon.
                TextMeshProUGUI level = r.Find(levelPath).GetComponent<TextMeshProUGUI>();
                if(level)
                {
                    if(item) 
                    {
                        if (item.currentLevel >= item.maxLevel)
                        {
                            level.text = "Max!";
                            level.color = newTextColor;
                        }
                        else
                        {
                            level.text = selected.GetLevelData(item.currentLevel + 1).name;
                            level.color = levelTextColor;
                        }
                    }
                    else
                    {
                        level.text = newText;
                        level.color = newTextColor;
                    }
                }

                // Insert the description of the item.
                TextMeshProUGUI desc = r.Find(descriptionPath).GetComponent<TextMeshProUGUI>();
                if (desc)
                {
                    if (item)
                    {
                        desc.text = selected.GetLevelData(item.currentLevel + 1).description;
                    }
                    else
                    {
                        desc.text = selected.GetLevelData(1).description;
                    }
                }

                // Insert the icon of the item.
                Image icon = r.Find(iconPath).GetComponent<Image>();
                if(icon)
                {
                    icon.sprite = selected.icon;
                }

                // Insert the button action binding.
                Button b = r.Find(buttonPath).GetComponent<Button>();
                if (b)
                {
                    b.onClick.RemoveAllListeners();
                    if (item)
                        b.onClick.AddListener(() => inventory.LevelUp(item));
                    else
                        b.onClick.AddListener(() => inventory.Add(selected));
                }

                activeOptions++;
            }
            else r.gameObject.SetActive(false);
        }

        // Sizes all the elements so they do not exceed the size of the box.
        RecalculateLayout();
    }

    // Recalculates the heights of all elements.
    // Called whenever the size of the window changes.
    // We are doing this manually because the VerticalLayoutGroup doesn't always
    // space all the elements evenly.
    void RecalculateLayout()
    {
        
        // Calculates the total available height for all options, then divides it by the number of options.
        optionHeight = (rectTransform.rect.height - verticalLayout.padding.top - verticalLayout.padding.bottom - (maxOptions - 1) * verticalLayout.spacing);
        if (activeOptions == maxOptions && tooltipTemplate.gameObject.activeSelf)
            optionHeight /= maxOptions + 1;
        else
            optionHeight /= maxOptions;

        // Recalculates the height of the tooltip as well if it is currently active.
        if (tooltipTemplate.gameObject.activeSelf)
        {
            RectTransform tooltipRect = (RectTransform)tooltipTemplate.transform;
            tooltipTemplate.gameObject.SetActive(true);
            tooltipRect.sizeDelta = new Vector2(tooltipRect.sizeDelta.x, optionHeight);
            tooltipTemplate.transform.SetAsLastSibling();
        }

        // Sets the height of every active Upgrade Option button.
        foreach (RectTransform r in upgradeOptions)
        {
            if (!r.gameObject.activeSelf) continue;
            r.sizeDelta = new Vector2(r.sizeDelta.x, optionHeight);
        }
    }

    // This function just checks if the last screen width / height
    // is the same as the current one. If not, the screen has changed sizes
    // and we will call RecalculateLayout() to update the height of our buttons.
    void Update()
    {
        // Redraws the boxes in this element if the screen size changes.
        if(lastScreen.x != Screen.width || lastScreen.y != Screen.height)
        {
            RecalculateLayout();
            lastScreen = new Vector2(Screen.width, Screen.height);
        }
    }

    // Start is called before the first frame update
    void Awake()
    {
        // Populates all our important variables.
        verticalLayout = GetComponentInChildren<VerticalLayoutGroup>();
        if (tooltipTemplate) tooltipTemplate.gameObject.SetActive(false);
        if (upgradeOptionTemplate) upgradeOptions.Add(upgradeOptionTemplate);

        // Get the RectTransform of this object for height calculations.
        rectTransform = (RectTransform)transform;
    }

    // Just a convenience function to automatically populate our variables.
    // It will automatically search for a GameObject called "Upgrade Option" and assign
    // it as the upgradeOptionTemplate, then search for a GameObject "Tooltip" to be assigned
    // as the tooltipTemplate.
    void Reset()
    {
        upgradeOptionTemplate = (RectTransform)transform.Find("Upgrade Option");
        tooltipTemplate = transform.Find("Tooltip").GetComponentInChildren<TextMeshProUGUI>();
    }
}

c. Explaining the UIUpgradeWindow component

The quickest way to understand what the script does is to understand how it works as a component:

The UIUpgradeWindow component
The fields in the UIUpgradeWindow component.

Let’s have a look at what each of the fields do:

FieldDescription
Upgrade Option TemplateThis is the most important field to populate. We will need to put the remaining Upgrade Option GameObject here, as it will be used as a template and duplicated as many times as you need options.
Tooltip TemplateAssign the GameObject representing the tooltip here. This will need to be populated for the tooltip to show up when using the component.
Max OptionsWhat is the maximum number of upgrade options this GameObject can show? We set it to 4, as that is the default maximum in Vampire Survivors — but you can increase it to whatever value you want.
New TextIf a new item upgrade is being shown, what is the text under it? By default, this is set to “New!” like in Vampire Survivors.
New Text ColorThis is the color for the New Text. By default, this is set to yellow, but you are recommended to set it to a different color from Level Text Color, as it needs to stand out from regular text.
Level Text ColorThis is the color for the text that shows the next level of the item. It is set to white by default.
Icon Path

These are supposed to be populated with the path names to the respective UI elements in the GameObject assigned to Upgrade Option Template. For example, my fields are populated as Icon/Item Icon, Name, Description and Button because my Upgrade Option template has the following children:

Children of the Upgrade Option
Children of the Upgrade Option GameObject.
Name Path
Description Path
Button Path
Level Path

In a nutshell, the UIUpgradeWindow script is responsible for 3 things:

  1. Providing the SetUpgrades() function. Whenever you want to show the upgrade window, you will call this function and pass it the list of upgrades that you want it to display.
  2. Creating as many Upgrade Option buttons as needed to support the window. We don’t have to worry about managing the buttons in our scripts — it is entirely managed by our UIUpgradeWindow script.
  3. Making sure that each Upgrade Option is evenly-spaced in our window. If we let the Vertical Layout Group manage the sizes of our element, the buttons will always fill up the entire window. For example, if Max Options is set to 4, and we only have 2 upgrade options, the 2 buttons should only take up 50% of the screen. This cannot be done unless we manually calculate the height of each of our buttons in the vertical layout.

I’ve commented the variable and functions in the script above, so give it a read to understand how it works. Further down in this article, there are also more sections that explain the more prominent parts of the script.

d. The SetUpgrades() function

The most important function in the script is the SetUpgrades() function, which takes anywhere from 2 to 4 arguments:

public void SetUpgrades(PlayerInventory inventory, List<ItemData> possibleUpgrades, int pick = 3, string tooltip = "")

It takes the following parameters:

ParameterTypeDescription
inventoryPlayerInventoryThe PlayerInventory component that will receive the upgrade that the player selects.
possibleUpgradesList<ItemData>A list of all the possible upgrades that can be displayed.
pickintThe number of options to show. If not specified, it will default to show 3 items.
tooltipstringShould we show the tooltip under all the options? If left empty, no tooltip would be shown.

d. Hooking it up to PlayerInventory

To get it working, we have to hook it to our PlayerInventory script and remove the existing scripts in there that manage the level-up UI elements directly:

PlayerInventory.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerInventory : MonoBehaviour
{
    [System.Serializable]
    public class Slot
    {
        public Item item;
        public Image image;

        public void Assign(Item assignedItem)
        {
            item = assignedItem;
            if(item is Weapon)
            {
                Weapon w = item as Weapon;
                image.enabled = true;
                image.sprite = w.data.icon;
            }
            else
            {
                Passive p = item as Passive;
                image.enabled = true;
                image.sprite = p.data.icon;
            }
            Debug.Log(string.Format("Assigned {0} to player.", item.name));
        }

        public void Clear()
        {
            item = null;
            image.enabled = false;
            image.sprite = null;
        }

        public bool IsEmpty() { return item == null; }
    }
    public List<Slot> weaponSlots = new List<Slot>(6);
    public List<Slot> passiveSlots = new List<Slot>(6);

    [System.Serializable]
    public class UpgradeUI
    {
        public TMP_Text upgradeNameDisplay;
        public TMP_Text upgradeDescriptionDisplay;
        public Image upgradeIcon;
        public Button upgradeButton;
    }

    [Header("UI Elements")]
    public List<WeaponData> availableWeapons = new List<WeaponData>();    //List of upgrade options for weapons
    public List<PassiveData> availablePassives = new List<PassiveData>(); //List of upgrade options for passive items
    public List<UpgradeUI> upgradeUIOptions = new List<UpgradeUI>();    //List of ui for upgrade options present in the scene
    public UIUpgradeWindow upgradeWindow;

    PlayerStats player;

    void Start()
    {
        player = GetComponent<PlayerStats>();
    }

    // Checks if the inventory has an item of a certain type.
    public bool Has(ItemData type) { return Get(type); }

    public Item Get(ItemData type)
    {
        if (type is WeaponData) return Get(type as WeaponData);
        else if (type is PassiveData) return Get(type as PassiveData);
        return null;
    }

    // Find a passive of a certain type in the inventory.
    public Passive Get(PassiveData type)
    {
        foreach (Slot s in passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p && p.data == type)
                return p;
        }
        return null;
    }

    // Find a weapon of a certain type in the inventory.
    public Weapon Get(WeaponData type)
    {
        foreach (Slot s in weaponSlots)
        {
            Weapon w = s.item as Weapon;
            if (w && w.data == type)
                return w;
        }
        return null;
    }

    // Removes a weapon of a particular type, as specified by .
    public bool Remove(WeaponData data, bool removeUpgradeAvailability = false)
    {
        // Remove this weapon from the upgrade pool.
        if (removeUpgradeAvailability) availableWeapons.Remove(data);

        for(int i = 0; i < weaponSlots.Count; i++)
        {
            Weapon w = weaponSlots[i].item as Weapon;
            if (w.data == data)
            {
                weaponSlots[i].Clear();
                w.OnUnequip();
                Destroy(w.gameObject);
                return true;
            }
        }

        return false;
    }

    // Removes a passive of a particular type, as specified by .
    public bool Remove(PassiveData data, bool removeUpgradeAvailability = false)
    {
        // Remove this passive from the upgrade pool.
        if (removeUpgradeAvailability) availablePassives.Remove(data);

        for (int i = 0; i < weaponSlots.Count; i++)
        {
            Passive p = weaponSlots[i].item as Passive;
            if (p.data == data)
            {
                weaponSlots[i].Clear();
                p.OnUnequip();
                Destroy(p.gameObject);
                return true;
            }
        }

        return false;
    }

    // If an ItemData is passed, determine what type it is and call the respective overload.
    // We also have an optional boolean to remove this item from the upgrade list.
    public bool Remove(ItemData data, bool removeUpgradeAvailability = false)
    {
        if (data is PassiveData) return Remove(data as PassiveData, removeUpgradeAvailability);
        else if(data is WeaponData) return Remove(data as WeaponData, removeUpgradeAvailability);
        return false;
    }

    // Finds an empty slot and adds a weapon of a certain type, returns
    // the slot number that the item was put in.
    public int Add(WeaponData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for(int i = 0; i < weaponSlots.Capacity; i++)
        {
            if (weaponSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the weapon in the slot.
        // Get the type of the weapon we want to spawn.
        Type weaponType = Type.GetType(data.behaviour);

        if (weaponType != null)
        {
            // Spawn the weapon GameObject.
            GameObject go = new GameObject(data.baseStats.name + " Controller");
            Weapon spawnedWeapon = (Weapon)go.AddComponent(weaponType);
            spawnedWeapon.transform.SetParent(transform); //Set the weapon to be a child of the player
            spawnedWeapon.transform.localPosition = Vector2.zero;
            spawnedWeapon.Initialise(data);
            spawnedWeapon.OnEquip();

            // Assign the weapon to the slot.
            weaponSlots[slotNum].Assign(spawnedWeapon);

            // Close the level up UI if it is on.
            if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
                GameManager.instance.EndLevelUp();

            return slotNum;
        }
        else
        {
            Debug.LogWarning(string.Format(
                "Invalid weapon type specified for {0}.",
                data.name
            ));
        }

        return -1;
    }

    // Finds an empty slot and adds a passive of a certain type, returns
    // the slot number that the item was put in.
    public int Add(PassiveData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for (int i = 0; i < passiveSlots.Capacity; i++)
        {
            if (passiveSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the passive in the slot.
        // Get the type of the passive we want to spawn.
        GameObject go = new GameObject(data.baseStats.name + " Passive");
        Passive p = go.AddComponent<Passive>();
        p.Initialise(data);
        p.transform.SetParent(transform); //Set the weapon to be a child of the player
        p.transform.localPosition = Vector2.zero;

        // Assign the passive to the slot.
        passiveSlots[slotNum].Assign(p);

        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }
        player.RecalculateStats();

        return slotNum;
    }

    // If we don't know what item is being added, this function will determine that.
    public int Add(ItemData data)
    {
        if (data is WeaponData) return Add(data as WeaponData);
        else if (data is PassiveData) return Add(data as PassiveData);
        return -1;
    }

    // Overload so that we can use both ItemData or Item to level up an
    // item in the inventory.
    public bool LevelUp(ItemData data)
    {
        Item item = Get(data);
        if (item) return LevelUp(item);
        return false;
    }

    // Levels up a selected weapon in the player inventory.
    public bool LevelUp(Item item)
    {
        // Tries to level up the item.
        if(!item.DoLevelUp())
        {
            Debug.LogWarning(string.Format(
                "Failed to level up {0}.",
                 item.name
            ));
            return false;
        }

        // Close the level up screen afterwards.
        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }

        // If it is a passive, recalculate player stats.
        if(item is Passive) player.RecalculateStats();
        return true;
    }

    // Checks a list of slots to see if there are any slots left.
    int GetSlotsLeft(List<Slot> slots)
    {
        
        int count = 0;
        foreach(Slot s in slots)
        {
            if (s.IsEmpty()) count++;
        }
        return count;
    }

    // Determines what upgrade options should appear.
    void ApplyUpgradeOptions()
    {
	// <availableUpgrades> is an empty list that will be filtered from
	// <allUpgrades>, which is the list of ALL upgrades in PlayerInventory.
	// Not all upgrades can be applied, as some may have already been
	// maxed out the player, or the player may not have enough inventory slots.
	List<ItemData> availableUpgrades = new List<ItemData>();
	List<ItemData> allUpgrades = new List<ItemData>(availableWeapons);
	allUpgrades.AddRange(availablePassives);

        // We need to know how many weapon / passive slots are left.
        int weaponSlotsLeft = GetSlotsLeft(weaponSlots);
        int passiveSlotsLeft = GetSlotsLeft(passiveSlots);

        // Filters through the available weapons and passives and add those
        // that can possibly be an option.
        foreach(ItemData data in allUpgrades)
        {
            // If a weapon of this type exists, allow for the upgrade if the
            // level of the weapon is not already maxed out.
            Item obj = Get(data);
            if(obj)
            {
                if (obj.currentLevel < data.maxLevel) availableUpgrades.Add(data);
            }
            else
            {
                // If we don't have this item in the inventory yet, check if
                // we still have enough slots to take new items.
                if (data is WeaponData && weaponSlotsLeft > 0) availableUpgrades.Add(data);
                else if (data is PassiveData && passiveSlotsLeft > 0) availableUpgrades.Add(data);
            }
        }

        // Show the UI upgrade window if we still have available upgrades left.
        int availUpgradeCount = availableUpgrades.Count;
        if (availUpgradeCount > 0)
        {
            bool getExtraItem = 1f - 1f / player.Stats.luck > UnityEngine.Random.value;
            if (getExtraItem || availUpgradeCount < 4) upgradeWindow.SetUpgrades(this, availableUpgrades, 4);
            else upgradeWindow.SetUpgrades(this, availableUpgrades, 3, "Increase your Luck stat for a chance to get 4 items!");
        }
        else if(GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }

        // Iterate through each slot in the upgrade UI and populate the options.
        foreach (UpgradeUI upgradeOption in upgradeUIOptions)
        {
            // If there are no more available upgrades, then we abort.
            if (availableUpgrades.Count <= 0) return;

            // Pick an upgrade, then remove it so that we don't get it twice.
            ItemData chosenUpgrade = availableUpgrades[UnityEngine.Random.Range(0, availableUpgrades.Count)];
            availableUpgrades.Remove(chosenUpgrade);

            // Ensure that the selected weapon data is valid.
            if (chosenUpgrade != null)
            {
                // Turns on the UI slot.
                EnableUpgradeUI(upgradeOption);

                // If our inventory already has the upgrade, we will make it a level up.
                Item item = Get(chosenUpgrade);
                if(item)
                {
                    upgradeOption.upgradeButton.onClick.AddListener(() => LevelUp(item)); //Apply button functionality
                    if (item is Weapon)
                    {
                        Weapon.Stats nextLevel = ((WeaponData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
                        upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                        upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
                        upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
                    }
                    else
                    {
                        Passive.Modifier nextLevel = ((PassiveData)chosenUpgrade).GetLevelData(item.currentLevel + 1);
                        upgradeOption.upgradeDescriptionDisplay.text = nextLevel.description;
                        upgradeOption.upgradeNameDisplay.text = chosenUpgrade.name + " - " + nextLevel.name;
                        upgradeOption.upgradeIcon.sprite = chosenUpgrade.icon;
                    }
                }
                else
                {
                    if(chosenUpgrade is WeaponData)
                    {
                        WeaponData data = chosenUpgrade as WeaponData;
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
                        upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
                        upgradeOption.upgradeIcon.sprite = data.icon;
                    }
                    else
                    {
                        PassiveData data = chosenUpgrade as PassiveData;
                        upgradeOption.upgradeButton.onClick.AddListener(() => Add(chosenUpgrade)); //Apply button functionality
                        upgradeOption.upgradeDescriptionDisplay.text = data.baseStats.description;  //Apply initial description
                        upgradeOption.upgradeNameDisplay.text = data.baseStats.name;    //Apply initial name
                        upgradeOption.upgradeIcon.sprite = data.icon;
                    }
                    
                }
            }
        }
    }

    void RemoveUpgradeOptions()
    {
        foreach (UpgradeUI upgradeOption in upgradeUIOptions)
        {
            upgradeOption.upgradeButton.onClick.RemoveAllListeners();
            DisableUpgradeUI(upgradeOption);    //Call the DisableUpgradeUI method here to disable all UI options before applying upgrades to them
        }
    }

    public void RemoveAndApplyUpgrades()
    {
        RemoveUpgradeOptions();
        ApplyUpgradeOptions();
    }

    void DisableUpgradeUI(UpgradeUI ui)
    {
        ui.upgradeNameDisplay.transform.parent.gameObject.SetActive(false);
    }

    void EnableUpgradeUI(UpgradeUI ui)
    {
        ui.upgradeNameDisplay.transform.parent.gameObject.SetActive(true);
    }

}

The most notable part of the additions above is this part in ApplyUpgradeOptions(). It shows how SetUpgrades() is called, and it factors in the Luck stat to determine if the player should get 3 or 4 upgrades:

bool getExtraItem = 1f - 1f / player.Stats.luck > UnityEngine.Random.value;
if(getExtraItem) upgradeWindow.SetUpgrades(this, availableUpgrades, 4);
else upgradeWindow.SetUpgrades(this, availableUpgrades, 3, "Increase your Luck stat for a chance to get 4 items!");

What’s happening above is basically as follows:

  1. We calculate the chance to get an extra weapon, which gives us a value between 0 and 1, and we compare it with a randomly generated value using UnityEngine.Random.value, which gives us a number between 0 and 1:
Chance to get extra item = 1 ( 1 Luck )
  1. If the randomly-generated value is lesser than the number we’ve calculated, we generate 4 upgrade options. Otherwise, we will generate 3 options and print a tooltip telling players to increase their Luck stat for a chance to get 4 items.

f. Assigning the new Upgrade Window property

The updated PlayerInventory script replaces the old upgrade options array, because the level-up screen is no longer managed by the PlayerInventory script. In its place, there is a new Upgrade Window variable, and you will need to assign the GameObject containing the UIUpgradeWindow component in there, so that the PlayerInventory is able to update the list of upgrades on it.

Assign the UpgradeWindow variable
Remember to assign the Upgrade Window variable in PlayerInventory.

6. Bugfixes for experience gain

Update 12 May 2024: This is a newly-added section. Thanks to Cameron McWhannell for finding this!

If you have been fiddling with the experience gain and level-up system, you’ll also find a couple of issues with it, which we will be fixing in this section.

a. Optimising the GameManager script

One of the fixes that we will be applying will be to the GameManager.cs script. Before we apply these fixes, let’s optimise some parts of it to make it more efficient and be slightly easier to read.

For starters, we’ll remove the isGameOver and choosingUpgrade booleans, because they are already tracked by our currentState variable. To maintain compatibility with the rest of our scripts, we will replace them with a setter that checks the currentState to determine if either of these boolean should be true or false, depending on the currentState:

// Flag to check if the game is over
public bool isGameOver = false;

// Flag to check if the player is choosing their upgrades
public bool choosingUpgrade = false;

public bool isGameOver { get { return currentState == GameState.GameOver; } }
public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } }

We will also move the instructions in the Update() function for the GameOver and LevelUp states to their respective functions (GameOver() and StartLevelUp()), so that they are not called every frame:

void Update()
{
    // Define the behavior for each state
    switch (currentState)
    {
        case GameState.Gameplay:
            // Code for the gameplay state
            CheckForPauseAndResume();
            UpdateStopwatch();
            break;
        case GameState.Paused:
            // Code for the paused state
            CheckForPauseAndResume();
            break;
        case GameState.GameOver:
            // Code for the game over state
            if (!isGameOver)
            {
                isGameOver = true;
                Time.timeScale = 0f; //Stop the game entirely
                Debug.Log("Game is over");
                DisplayResults();
            }
            break;
        case GameState.LevelUp:
            if (!choosingUpgrade)
            {
                choosingUpgrade = true;
                Time.timeScale = 0f; //Pause the game for now
                Debug.Log("Upgrades shown");
                levelUpScreen.SetActive(true);
            }
            break;
        default:
            Debug.LogWarning("STATE DOES NOT EXIST");
            break;
    }
}

public void GameOver()
{
    timeSurvivedDisplay.text = stopwatchDisplay.text;

    // Set the Game Over variables here.
    ChangeState(GameState.GameOver);
    Time.timeScale = 0f; //Stop the game entirely
    DisplayResults();
}

public void StartLevelUp()
{
    ChangeState(GameState.LevelUp);
    levelUpScreen.SetActive(true);
    Time.timeScale = 0f; //Pause the game for now
    playerObject.SendMessage("RemoveAndApplyUpgrades");
}

Also, because we removed the choosingUpgrade boolean, we’ll need to remove the line that sets it in EndLevelUp():

public void EndLevelUp()
{
    choosingUpgrade = false;
    Time.timeScale = 1f;    //Resume the game
    levelUpScreen.SetActive(false);
    ChangeState(GameState.Gameplay);
}

Finally, our GameManager script has a previousState variable that is only set in PauseGame() (so it only remembers if the previousState is Paused. Let’s rectify this so that it works for every state we have by applying the code to ChangeState() instead:

// Define the method to change the state of the game
public void ChangeState(GameState newState)
{
    previousState = currentState;
    currentState = newState;
}

public void PauseGame()
{
    if (currentState != GameState.Paused)
    {
        previousState = currentState;
        ChangeState(GameState.Paused);
        Time.timeScale = 0f; // Stop the game
        pauseScreen.SetActive(true); // Enable the pause screen
        Debug.Log("Game is paused");
    }
}

public void ResumeGame()
{
    if (currentState == GameState.Paused)
    {
        ChangeState(previousState);
        Time.timeScale = 1f; // Resume the game
        pauseScreen.SetActive(false); //Disable the pause screen
        Debug.Log("Game is resumed");
    }
}

As an aside, let’s also remove the Debug.Log() message in PauseGame() and ResumeGame() as well, because our Console window is getting cluttered with messages when we are testing our game.

Here’s all the changes put together:

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class GameManager : MonoBehaviour
{
    public static GameManager instance;


    // Define the different states of the game
    public enum GameState
    {
        Gameplay,
        Paused,
        GameOver,
        LevelUp
    }

    // Store the current state of the game
    public GameState currentState;

    // Store the previous state of the game before it was paused
    public GameState previousState;

    [Header("Damage Text Settings")]
    public Canvas damageTextCanvas;
    public float textFontSize = 20;
    public TMP_FontAsset textFont;
    public Camera referenceCamera;

    [Header("Screens")]
    public GameObject pauseScreen;
    public GameObject resultsScreen;
    public GameObject levelUpScreen;

    [Header("Results Screen Displays")]
    public Image chosenCharacterImage;
    public TMP_Text chosenCharacterName;
    public TMP_Text levelReachedDisplay;
    public TMP_Text timeSurvivedDisplay;
    public List<Image> chosenWeaponsUI = new List<Image>(6);
    public List<Image> chosenPassiveItemsUI = new List<Image>(6);

    [Header("Stopwatch")]
    public float timeLimit; // The time limit in seconds
    float stopwatchTime; // The current time elapsed since the stopwatch started
    public TMP_Text stopwatchDisplay;

    // Flag to check if the game is over
    public bool isGameOver = false;

    // Flag to check if the player is choosing their upgrades
    public bool choosingUpgrade = false;

    // Reference to the player's game object
    public GameObject playerObject;

    // Getters for parity with older scripts.
    public bool isGameOver { get { return currentState == GameState.Paused; } }
    public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } }

    void Awake()
    {
        //Warning check to see if there is another singleton of this kind already in the game
        if (instance == null)
        {
            instance = this;
        }
        else
        {
            Debug.LogWarning("EXTRA " + this + " DELETED");
            Destroy(gameObject);
        }

        DisableScreens();
    }

    void Update()
    {
        switch (currentState)
        {
            case GameState.Gameplay:
                // Code for the gameplay state
                CheckForPauseAndResume();
                UpdateStopwatch();
                break;
            case GameState.Paused:
                // Code for the paused state
                CheckForPauseAndResume();
                break;
            case GameState.GameOver:
                // Code for the game over state
                if (!isGameOver)
                {
                    isGameOver = true;
                    Time.timeScale = 0f; //Stop the game entirely
                    Debug.Log("Game is over");
                    DisplayResults();
                }
                break;
            case GameState.LevelUp:
                if (!choosingUpgrade)
                {
                    choosingUpgrade = true;
                    Time.timeScale = 0f; //Pause the game for now
                    Debug.Log("Upgrades shown");
                    levelUpScreen.SetActive(true);
                }
                break;
            default:
                Debug.LogWarning("STATE DOES NOT EXIST");
                break;
        }
    }

    IEnumerator GenerateFloatingTextCoroutine(string text, Transform target, float duration = 1f, float speed = 50f)
    {
        // Start generating the floating text.
        GameObject textObj = new GameObject("Damage Floating Text");
        RectTransform rect = textObj.AddComponent<RectTransform>();
        TextMeshProUGUI tmPro = textObj.AddComponent<TextMeshProUGUI>();
        tmPro.text = text;
        tmPro.horizontalAlignment = HorizontalAlignmentOptions.Center;
        tmPro.verticalAlignment = VerticalAlignmentOptions.Middle;
        tmPro.fontSize = textFontSize;
        if (textFont) tmPro.font = textFont;
        rect.position = referenceCamera.WorldToScreenPoint(target.position);

        // Makes sure this is destroyed after the duration finishes.
        Destroy(textObj, duration);

        // Parent the generated text object to the canvas.
        textObj.transform.SetParent(instance.damageTextCanvas.transform);
        textObj.transform.SetSiblingIndex(0);

        // Pan the text upwards and fade it away over time.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0;
        float yOffset = 0;
        Vector3 lastKnownPosition = target.position;
        while (t < duration)
        {
            // If the RectTransform is missing for whatever reason, end this loop.
            if (!rect) break;

            // Fade the text to the right alpha value.
            tmPro.color = new Color(tmPro.color.r, tmPro.color.g, tmPro.color.b, 1 - t / duration);

            // Update the enemy's position if it is still around.
            if (target) lastKnownPosition = target.position;

            // Pan the text upwards.
            yOffset += speed * Time.deltaTime;
            rect.position = referenceCamera.WorldToScreenPoint(lastKnownPosition + new Vector3(0, yOffset));

            // Wait for a frame and update the time.
            yield return w;
            t += Time.deltaTime;
        }
    }

    public static void GenerateFloatingText(string text, Transform target, float duration = 1f, float speed = 1f)
    {
        // If the canvas is not set, end the function so we don't
        // generate any floating text.
        if (!instance.damageTextCanvas) return;

        // Find a relevant camera that we can use to convert the world
        // position to a screen position.
        if (!instance.referenceCamera) instance.referenceCamera = Camera.main;

        instance.StartCoroutine(instance.GenerateFloatingTextCoroutine(
            text, target, duration, speed
        ));
    }

    // Define the method to change the state of the game
    public void ChangeState(GameState newState)
    {
        previousState = currentState;
        currentState = newState;
    }

    public void PauseGame()
    {
        if (currentState != GameState.Paused)
        {
            previousState = currentState;
            ChangeState(GameState.Paused);
            Time.timeScale = 0f; // Stop the game
            pauseScreen.SetActive(true); // Enable the pause screen
            Debug.Log("Game is paused");
        }
    }

    public void ResumeGame()
    {
        if (currentState == GameState.Paused)
        {
            ChangeState(previousState);
            Time.timeScale = 1f; // Resume the game
            pauseScreen.SetActive(false); //Disable the pause screen
            Debug.Log("Game is resumed");
        }
    }

    // Define the method to check for pause and resume input
    void CheckForPauseAndResume()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            if (currentState == GameState.Paused)
            {
                ResumeGame();
            }
            else
            {
                PauseGame();
            }
        }
    }

    void DisableScreens()
    {
        pauseScreen.SetActive(false);
        resultsScreen.SetActive(false);
        levelUpScreen.SetActive(false);
    }

    public void GameOver()
    {
        timeSurvivedDisplay.text = stopwatchDisplay.text;

        // Set the Game Over variables here.
        ChangeState(GameState.GameOver);
        Time.timeScale = 0f; //Stop the game entirely
        DisplayResults();
    }

    void DisplayResults()
    {
        resultsScreen.SetActive(true);
    }

    public void AssignChosenCharacterUI(CharacterData chosenCharacterData)
    {
        chosenCharacterImage.sprite = chosenCharacterData.Icon;
        chosenCharacterName.text = chosenCharacterData.Name;
    }

    public void AssignLevelReachedUI(int levelReachedData)
    {
        levelReachedDisplay.text = levelReachedData.ToString();
    }

    public void AssignChosenWeaponsAndPassiveItemsUI(List<PlayerInventory.Slot> chosenWeaponsData, List<PlayerInventory.Slot> chosenPassiveItemsData)
    {
        // Check that both lists have the same length
        if (chosenWeaponsData.Count != chosenWeaponsUI.Count || chosenPassiveItemsData.Count != chosenPassiveItemsUI.Count)
        {
            Debug.LogError("Chosen weapons and passive items data lists have different lengths");
            return;
        }

        // Assign chosen weapons data to chosenWeaponsUI
        for (int i = 0; i < chosenWeaponsUI.Count; i++)
        {
            // Check that the sprite of the corresponding element in chosenWeaponsData is not null
            if (chosenWeaponsData[i].image.sprite)
            {
                // Enable the corresponding element in chosenWeaponsUI and set its sprite to the corresponding sprite in chosenWeaponsData
                chosenWeaponsUI[i].enabled = true;
                chosenWeaponsUI[i].sprite = chosenWeaponsData[i].image.sprite;
            }
            else
            {
                // If the sprite is null, disable the corresponding element in chosenWeaponsUI
                chosenWeaponsUI[i].enabled = false;
            }
        }

        // Assign chosen passive items data to chosenPassiveItemsUI
        for (int i = 0; i < chosenPassiveItemsUI.Count; i++)
        {
            // Check that the sprite of the corresponding element in chosenPassiveItemsData is not null
            if (chosenPassiveItemsData[i].image.sprite)
            {
                // Enable the corresponding element in chosenPassiveItemsUI and set its sprite to the corresponding sprite in chosenPassiveItemsData
                chosenPassiveItemsUI[i].enabled = true;
                chosenPassiveItemsUI[i].sprite = chosenPassiveItemsData[i].image.sprite;
            }
            else
            {
                // If the sprite is null, disable the corresponding element in chosenPassiveItemsUI
                chosenPassiveItemsUI[i].enabled = false;
            }
        }
    }

    void UpdateStopwatch()
    {
        stopwatchTime += Time.deltaTime;

        UpdateStopwatchDisplay();

        if (stopwatchTime >= timeLimit)
        {
            playerObject.SendMessage("Kill");
        }
    }

    void UpdateStopwatchDisplay()
    {
        // Calculate the number of minutes and seconds that have elapsed
        int minutes = Mathf.FloorToInt(stopwatchTime / 60);
        int seconds = Mathf.FloorToInt(stopwatchTime % 60);

        // Update the stopwatch text to display the elapsed time
        stopwatchDisplay.text = string.Format("{0:00}:{1:00}", minutes, seconds);
    }

    public void StartLevelUp()
    {
        ChangeState(GameState.LevelUp);
        levelUpScreen.SetActive(true);
        Time.timeScale = 0f; //Pause the game for now
        playerObject.SendMessage("RemoveAndApplyUpgrades");
    }

    public void EndLevelUp()
    {
        choosingUpgrade = false;
        Time.timeScale = 1f;    //Resume the game
        levelUpScreen.SetActive(false);
        ChangeState(GameState.Gameplay);
    }
}

b. Level-ups do not stack

If you try to collect stacked gems, and if these stacked gems reach you in the same frame, your character will level-up multiple times, but there will only be 1 level-up screen popping up — the rest of the level-ups will be ignored.

To make the gems reach you at the same frame, they will usually have to be stacking directly on each other (i.e. exact same positioning). If you’ve implemented the bobbing animation from Part 17, you will also need to turn off, because the script randomises the positioning of each gem when the gem spawns.

To rectify this, we’ll also need to modify our GameManager.cs script so that it will remember if StartLevelUp() is called multiple times, so that when EndLevelUp() is called, it will start another level-up process.

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class GameManager : MonoBehaviour
{
    public static GameManager instance;

    // Define the different states of the game
    public enum GameState
    {
        Gameplay,
        Paused,
        GameOver,
        LevelUp
    }

    // Store the current state of the game
    public GameState currentState;

    // Store the previous state of the game before it was paused
    public GameState previousState;

    [Header("Damage Text Settings")]
    public Canvas damageTextCanvas;
    public float textFontSize = 20;
    public TMP_FontAsset textFont;
    public Camera referenceCamera;

    [Header("Screens")]
    public GameObject pauseScreen;
    public GameObject resultsScreen;
    public GameObject levelUpScreen;
    int stackedLevelUps = 0; // If we try to StartLevelUp() multiple times.

    [Header("Results Screen Displays")]
    public Image chosenCharacterImage;
    public TMP_Text chosenCharacterName;
    public TMP_Text levelReachedDisplay;
    public TMP_Text timeSurvivedDisplay;
    public List<Image> chosenWeaponsUI = new List<Image>(6);
    public List<Image> chosenPassiveItemsUI = new List<Image>(6);

    [Header("Stopwatch")]
    public float timeLimit; // The time limit in seconds
    float stopwatchTime; // The current time elapsed since the stopwatch started
    public TMP_Text stopwatchDisplay;

    // Reference to the player's game object
    public GameObject playerObject;

    // Getters for parity with older scripts.
    public bool isGameOver { get { return currentState == GameState.Paused; } }
    public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } }

    void Awake()
    {
        //Warning check to see if there is another singleton of this kind already in the game
        if (instance == null)
        {
            instance = this;
        }
        else
        {
            Debug.LogWarning("EXTRA " + this + " DELETED");
            Destroy(gameObject);
        }

        DisableScreens();
    }

    void Update()
    {
        switch (currentState)
        {
            case GameState.Gameplay:
                // Code for the gameplay state
                CheckForPauseAndResume();
                UpdateStopwatch();
                break;
            case GameState.Paused:
                // Code for the paused state
                CheckForPauseAndResume();
                break;
            case GameState.GameOver:
            case GameState.LevelUp:
                break;
            default:
                Debug.LogWarning("STATE DOES NOT EXIST");
                break;
        }
    }

    IEnumerator GenerateFloatingTextCoroutine(string text, Transform target, float duration = 1f, float speed = 50f)
    {
        // Start generating the floating text.
        GameObject textObj = new GameObject("Damage Floating Text");
        RectTransform rect = textObj.AddComponent<RectTransform>();
        TextMeshProUGUI tmPro = textObj.AddComponent<TextMeshProUGUI>();
        tmPro.text = text;
        tmPro.horizontalAlignment = HorizontalAlignmentOptions.Center;
        tmPro.verticalAlignment = VerticalAlignmentOptions.Middle;
        tmPro.fontSize = textFontSize;
        if (textFont) tmPro.font = textFont;
        rect.position = referenceCamera.WorldToScreenPoint(target.position);

        // Makes sure this is destroyed after the duration finishes.
        Destroy(textObj, duration);

        // Parent the generated text object to the canvas.
        textObj.transform.SetParent(instance.damageTextCanvas.transform);
        textObj.transform.SetSiblingIndex(0);

        // Pan the text upwards and fade it away over time.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0;
        float yOffset = 0;
        Vector3 lastKnownPosition = target.position;
        while (t < duration)
        {
            // If the RectTransform is missing for whatever reason, end this loop.
            if (!rect) break;

            // Fade the text to the right alpha value.
            tmPro.color = new Color(tmPro.color.r, tmPro.color.g, tmPro.color.b, 1 - t / duration);

            // Update the enemy's position if it is still around.
            if (target) lastKnownPosition = target.position;

            // Pan the text upwards.
            yOffset += speed * Time.deltaTime;
            rect.position = referenceCamera.WorldToScreenPoint(lastKnownPosition + new Vector3(0, yOffset));

            // Wait for a frame and update the time.
            yield return w;
            t += Time.deltaTime;
        }
    }

    public static void GenerateFloatingText(string text, Transform target, float duration = 1f, float speed = 1f)
    {
        // If the canvas is not set, end the function so we don't
        // generate any floating text.
        if (!instance.damageTextCanvas) return;

        // Find a relevant camera that we can use to convert the world
        // position to a screen position.
        if (!instance.referenceCamera) instance.referenceCamera = Camera.main;

        instance.StartCoroutine(instance.GenerateFloatingTextCoroutine(
            text, target, duration, speed
        ));
    }

    // Define the method to change the state of the game
    public void ChangeState(GameState newState)
    {
        previousState = currentState;
        currentState = newState;
    }

    public void PauseGame()
    {
        if (currentState != GameState.Paused)
        {
            ChangeState(GameState.Paused);
            Time.timeScale = 0f; // Stop the game
            pauseScreen.SetActive(true); // Enable the pause screen
        }
    }

    public void ResumeGame()
    {
        if (currentState == GameState.Paused)
        {
            ChangeState(previousState);
            Time.timeScale = 1f; // Resume the game
            pauseScreen.SetActive(false); //Disable the pause screen
        }
    }

    // Define the method to check for pause and resume input
    void CheckForPauseAndResume()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            if (currentState == GameState.Paused)
            {
                ResumeGame();
            }
            else
            {
                PauseGame();
            }
        }
    }

    void DisableScreens()
    {
        pauseScreen.SetActive(false);
        resultsScreen.SetActive(false);
        levelUpScreen.SetActive(false);
    }

    public void GameOver()
    {
        timeSurvivedDisplay.text = stopwatchDisplay.text;

        // Set the Game Over variables here.
        ChangeState(GameState.GameOver);
        Time.timeScale = 0f; //Stop the game entirely
        DisplayResults();
    }

    void DisplayResults()
    {
        resultsScreen.SetActive(true);
    }

    public void AssignChosenCharacterUI(CharacterData chosenCharacterData)
    {
        chosenCharacterImage.sprite = chosenCharacterData.Icon;
        chosenCharacterName.text = chosenCharacterData.Name;
    }

    public void AssignLevelReachedUI(int levelReachedData)
    {
        levelReachedDisplay.text = levelReachedData.ToString();
    }

    public void AssignChosenWeaponsAndPassiveItemsUI(List<PlayerInventory.Slot> chosenWeaponsData, List<PlayerInventory.Slot> chosenPassiveItemsData)
    {
        // Check that both lists have the same length
        if (chosenWeaponsData.Count != chosenWeaponsUI.Count || chosenPassiveItemsData.Count != chosenPassiveItemsUI.Count)
        {
            Debug.LogError("Chosen weapons and passive items data lists have different lengths");
            return;
        }

        // Assign chosen weapons data to chosenWeaponsUI
        for (int i = 0; i < chosenWeaponsUI.Count; i++)
        {
            // Check that the sprite of the corresponding element in chosenWeaponsData is not null
            if (chosenWeaponsData[i].image.sprite)
            {
                // Enable the corresponding element in chosenWeaponsUI and set its sprite to the corresponding sprite in chosenWeaponsData
                chosenWeaponsUI[i].enabled = true;
                chosenWeaponsUI[i].sprite = chosenWeaponsData[i].image.sprite;
            }
            else
            {
                // If the sprite is null, disable the corresponding element in chosenWeaponsUI
                chosenWeaponsUI[i].enabled = false;
            }
        }

        // Assign chosen passive items data to chosenPassiveItemsUI
        for (int i = 0; i < chosenPassiveItemsUI.Count; i++)
        {
            // Check that the sprite of the corresponding element in chosenPassiveItemsData is not null
            if (chosenPassiveItemsData[i].image.sprite)
            {
                // Enable the corresponding element in chosenPassiveItemsUI and set its sprite to the corresponding sprite in chosenPassiveItemsData
                chosenPassiveItemsUI[i].enabled = true;
                chosenPassiveItemsUI[i].sprite = chosenPassiveItemsData[i].image.sprite;
            }
            else
            {
                // If the sprite is null, disable the corresponding element in chosenPassiveItemsUI
                chosenPassiveItemsUI[i].enabled = false;
            }
        }
    }

    void UpdateStopwatch()
    {
        stopwatchTime += Time.deltaTime;
        UpdateStopwatchDisplay();

        if (stopwatchTime >= timeLimit)
        {
            playerObject.SendMessage("Kill");
        }
    }

    void UpdateStopwatchDisplay()
    {
        // Calculate the number of minutes and seconds that have elapsed
        int minutes = Mathf.FloorToInt(stopwatchTime / 60);
        int seconds = Mathf.FloorToInt(stopwatchTime % 60);

        // Update the stopwatch text to display the elapsed time
        stopwatchDisplay.text = string.Format("{0:00}:{1:00}", minutes, seconds);
    }

    public void StartLevelUp()
    {
        ChangeState(GameState.LevelUp);

        // If the level up screen is already active, record it.
        if(levelUpScreen.activeSelf) stackedLevelUps++;
        else
        {
            levelUpScreen.SetActive(true);
            Time.timeScale = 0f; //Pause the game for now
            playerObject.SendMessage("RemoveAndApplyUpgrades");
        }
    }

    public void EndLevelUp()
    {
        Time.timeScale = 1f;    //Resume the game
        levelUpScreen.SetActive(false);
        ChangeState(GameState.Gameplay);
        
        if(stackedLevelUps > 0)
        {
            stackedLevelUps--;
            StartLevelUp();
        }
    }
}

Basically, the edits introduce a new variable called stackedLevelUps, so that if StartLevelUp() is called when a level-up screen is already active, the stackedLevelUps integer is incremented instead. This keeps a record of how many times we have to spawn the level-up screen — otherwise, if you call StartLevelUp() when a level-up screen is already active, it will be ignored.

When EndLevelUp() is called, if stackedLevelUps is more than 0, it will deactivate the level-up screen, then reactivate it again with another call to StartLevelUp().

c. Supporting multiple level-ups in one go

Another bug that can happen is when you call IncreaseExperience() on PlayerStats, but give more experience than is needed for levelling up in this level. This will cause only 1 level up to register, and your level will be incorrectly reflected:

This is a pretty easy fix compared to the previous one, you’ll just need to add a single line to the LevelUpChecker() function in PlayerStats.cs:

PlayerStats.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerStats : MonoBehaviour
{

    CharacterData characterData;
    public CharacterData.Stats baseStats;
    [SerializeField] CharacterData.Stats actualStats;

    public CharacterData.Stats Stats
    {
        get { return actualStats;  }
        set { 
            actualStats = value;
        }
    }

    float health;

    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

        // If we try and set the current health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed

            if (health != value)
            {
                health = value;
                UpdateHealthBar();
            }
        }
    }
    #endregion

    [Header("Visuals")]
    public ParticleSystem damageEffect; // If damage is dealt.
    public ParticleSystem blockedEffect; // If armor completely blocks damage.

    //Experience and level of the player
    [Header("Experience/Level")]
    public int experience = 0;
    public int level = 1;
    public int experienceCap;

    //Class for defining a level range and the corresponding experience cap increase for that range
    [System.Serializable]
    public class LevelRange
    {
        public int startLevel;
        public int endLevel;
        public int experienceCapIncrease;
    }

    //I-Frames
    [Header("I-Frames")]
    public float invincibilityDuration;
    float invincibilityTimer;
    bool isInvincible;

    public List<LevelRange> levelRanges;


    PlayerInventory inventory;
    PlayerCollector collector;
    public int weaponIndex;
    public int passiveItemIndex;

    [Header("UI")]
    public Image healthBar;
    public Image expBar;
    public TMP_Text levelText;

    void Awake()
    {
        characterData = CharacterSelector.GetData();
        if(CharacterSelector.instance) 
            CharacterSelector.instance.DestroySingleton();

        inventory = GetComponent<PlayerInventory>();
        collector = GetComponentInChildren<PlayerCollector>();

        //Assign the variables
        baseStats = actualStats = characterData.stats;
        collector.SetRadius(actualStats.magnet);
        health = actualStats.maxHealth;
    }

    void Start()
    {
        //Spawn the starting weapon
        inventory.Add(characterData.StartingWeapon);

        //Initialize the experience cap as the first experience cap increase
        experienceCap = levelRanges[0].experienceCapIncrease;

        GameManager.instance.AssignChosenCharacterUI(characterData);

        UpdateHealthBar();
        UpdateExpBar();
        UpdateLevelText();
    }

    void Update()
    {
        if (invincibilityTimer > 0)
        {
            invincibilityTimer -= Time.deltaTime;
        }
        //If the invincibility timer has reached 0, set the invincibility flag to false
        else if (isInvincible)
        {
            isInvincible = false;
        }

        Recover();
    }

    public void RecalculateStats()
    {
        actualStats = baseStats;
        foreach (PlayerInventory.Slot s in inventory.passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p)
            {
                actualStats += p.GetBoosts();
            }
        }

        // Update the PlayerCollector's radius.
        collector.SetRadius(actualStats.magnet);
    }

    public void IncreaseExperience(int amount)
    {
        experience += amount;

        LevelUpChecker();
        UpdateExpBar();
    }

    void LevelUpChecker()
    {
        if (experience >= experienceCap)
        {
            //Level up the player and reduce their experience by the experience cap
            level++;
            experience -= experienceCap;

            //Find the experience cap increase for the current level range
            int experienceCapIncrease = 0;
            foreach (LevelRange range in levelRanges)
            {
                if (level >= range.startLevel && level <= range.endLevel)
                {
                    experienceCapIncrease = range.experienceCapIncrease;
                    break;
                }
            }
            experienceCap += experienceCapIncrease;

            UpdateLevelText();

            GameManager.instance.StartLevelUp();

            // If the experience still exceeds the experience cap, level up again.
            if(experience >= experienceCap) LevelUpChecker();
        }
    }

    void UpdateExpBar()
    {
        // Update exp bar fill amount
        expBar.fillAmount = (float)experience / experienceCap;
    }

    void UpdateLevelText()
    {
        // Update level text
        levelText.text = "LV " + level.ToString();
    }

    public void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            // Take armor into account before dealing the damage.
            dmg -= actualStats.armor;

            if (dmg > 0)
            {
                // Deal the damage.
                CurrentHealth -= dmg;

                // If there is a damage effect assigned, play it.
                if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);

                if (CurrentHealth <= 0)
                {
                    Kill();
                }
            }
            else
            {
                // If there is a blocked effect assigned, play it.
                if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
            }

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;
        }
    }

    void UpdateHealthBar()
    {
        //Update the health bar
        healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
    }

    public void Kill()
    {
        if (!GameManager.instance.isGameOver)
        {
            GameManager.instance.AssignLevelReachedUI(level);
            GameManager.instance.AssignChosenWeaponsAndPassiveItemsUI(inventory.weaponSlots, inventory.passiveSlots);
            GameManager.instance.GameOver();
        }
    }

    public void RestoreHealth(float amount)
    {
        // Only heal the player if their current health is less than their maximum health
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += amount;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }

    void Recover()
    {
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += Stats.recovery * Time.deltaTime;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }
}

What the extra line of code does is: after levelling up, we check again if we still have more experience than the experience cap. If we do, we run LevelUpChecker() again to make another level-up happen. This will fix the issue reflected in the video above.

d. Configuring the level-up ranges properly

In the project files for this part, I’ve also input the proper level-up values in the PlayerStats component, according to what is listed in the Vampire Survivors Wiki.

Vampire Survivors Level Experience Ranges
From Level 41 onwards, it takes 2400 more experience per level to level up.

To recap how the Level Ranges work, you will need to return to Part 9 of our series.

Another thing we’ve edited in the project files is the experience that the generic experience gem pickup gives — now it only gives 5 experience per piece.

Modifying the Pickup prefab
The generic experience pickup now gives only 5 XP per piece.

7. Conclusion

After the UIUpgradeWindow.cs script is created and properly hooked up to PlayerInventory, we simply need to attach it to the Upgrade Options Holder (New) GameObject that we’ve created and populate its variables properly.

After that, test out the game and see if the new level-up screen works!

If you are a Silver Patron, you can download the project files for Part 19, as well as the upcoming Part 20, where I will be covering how to revamp the inventory slot display and the result screen.

Leave a Reply

Your email address will not be published. Required fields are marked *

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.