Metroidvania Part 13

Creating a Metroidvania (like Hollow Knight) — Part 13: Next-Level UI for Health & Mana

This article will be free until the accompanying video is released.

In the last part, we focused on building a new, modular and much simpler way of handling UI. We are following this up in Part 13 by upgrading and simplifying the code for our health and mana UI as well, as there are a couple of problems with it.

  1. Upgrading the Health UI script
    1. Problems with the Health UI
    2. Creating the new UIHealth script
    3. How to use UIHealth
  2. Upgrading the Mana UI script
    1. Problems with the Mana UI
    2. Creating the new UIMana script
  3. Updating the PlayerController script
    1. Updating the UIManager
    2. Updating the GameManager, InventoryManager, and SaveData
    3. Updating the AddManaOrb and OrbShard scripts
    4. Updating the old health and mana scripts
  4. Setting up the New Health UI
    1. New heart sprites
    2. Creating the new heart prefab
    3. Setting up the new health UI
  5. Finishing touches

1. Upgrading the Health UI script

Let’s work on upgrading our health UI first.

a. Problems with the Health UI

The current health UI has the following problems:

  • It is not responsive—at certain screen sizes, the heart sprites get cut off. Although we won’t be able to make it fully responsive without the use of Unity’s new UI Toolkit, the new configuration still makes it much more device-friendly.
Health UI cut off
The health UI is not responsive at all.
  • The HeartController is hardcoded to only work with the PlayerController script. It cannot be decoupled and made to display health for anything else, which makes it not ideal as it is not very modular.
  • Our HeartController script also does not handle excess blue health, so we will take this opportunity to add that functionality as well.
Lifeblood in Hollow Knight
Our UI (and health system) does not support blue health at the moment.

b. Creating the new UIHealth script

To fix the above problems, we will create a new script called UIHealth, which will inherit from the UIScreen script that we made in the previous part:

UIHealth.cs

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

[DisallowMultipleComponent]
public class UIHealth : UIScreen
{
    [Header("Health UI")]
    public int healthPerUnit = 1;
    public GameObject healthUnitPrefab, excessHealthUnitPrefab;
    public string containerPath, fillPath;
    public Color excessHealthColour = Color.blue;

    readonly List<GameObject> healthUnits = new List<GameObject>(),
                              excessHealthUnits = new List<GameObject>();

    protected override void Awake()
    {
        base.Awake();
        for(int i = 0; i < transform.childCount; i++)
        {
            GameObject go = transform.GetChild(i).gameObject;
            if (go.name.StartsWith(healthUnitPrefab.name) || go.name.StartsWith(excessHealthUnitPrefab.name))
                Destroy(go);
        }
    }

    public void Refresh(float health, float maxHealth, float excessHealth = 0)
    {
        float targetItemCount = maxHealth / healthPerUnit;
        float excessItemCount = excessHealth / healthPerUnit;

        // Remove any extra children.
        while(healthUnits.Count > targetItemCount)
        {
            // Remove any extra children.
            GameObject toRemove = healthUnits[healthUnits.Count - 1];
            if (healthUnits.Remove(toRemove)) Destroy(toRemove);
        }

        // Add extra children if needed.
        while (healthUnits.Count < targetItemCount)
            healthUnits.Add(Instantiate(healthUnitPrefab, transform));

        // Removes all excess health units, so that we can re-add them later.
        for (int i = 0; i < excessHealthUnits.Count; i++)
        {
            GameObject go = excessHealthUnits[i];
            if (excessHealthUnits.Count > excessItemCount)
            {
                if (excessHealthUnits.Remove(go))
                {
                    Destroy(go);
                    i--;
                    continue;
                }
            }
            else
            {
                go.transform.SetAsLastSibling();
            }
        }

        // Add extra excess health if needed.
        while (excessHealthUnits.Count < excessItemCount)
            excessHealthUnits.Add(Instantiate(excessHealthUnitPrefab, transform));

        // Set the exact health.
        float fillUnits = health / healthPerUnit;
        for (int i = 0; i < healthUnits.Count; i++)
        {
            // Update the containers inside of each heart.
            Transform item = healthUnits[i].transform;
            Transform container = string.IsNullOrWhiteSpace(containerPath) ? item : item.Find(containerPath);
            Image containerImg = container.GetComponent<Image>();
            float remainder = targetItemCount - i;
            containerImg.fillAmount = Mathf.Clamp01(remainder);

            // Update the fills inside of each heart.
            Transform fill = string.IsNullOrWhiteSpace(fillPath) ? item : item.Find(fillPath);
            if (fill)
            {
                Image fillImg = fill.GetComponent<Image>();
                remainder = fillUnits - i;
                fillImg.fillAmount = Mathf.Clamp01(remainder);
            }
        }

        // Set the exact excess health.
        float excessFillUnits = excessHealth * 1.0f / healthPerUnit;
        for (int i = 0; i < excessHealthUnits.Count; i++)
        {
            // Update the containers inside of each heart.
            Transform item = excessHealthUnits[i].transform;
            Image containerImg = item.GetComponent<Image>();
            float remainder = excessFillUnits - i;
            containerImg.fillAmount = Mathf.Clamp01(remainder);
            if (remainder < 1) item.SetAsLastSibling();
        }
    }
}

c. Why create a new UIHealth script?

This new script has several advantages over the old HeartController when it comes to handling the health UI of the player. For starters, it has more options than the old HeartController—something we will discuss further later when setting it up for our game.

The UIHealth component
The UIHealth component is much more configurable.
Heart Controller
The old HeartController component.

It is also much more modularised, and as a result, much less complicated to set up than the HeartController. The old HeartController script was attached to the UIManager GameObject, and was hardcoded to reference the PlayerController in multiple parts.

This means that the HeartController script isn’t very flexible, as it will strictly only work with the PlayerController script. An especially awkward part of its functionality is in its Start() function, where it references PlayerController variables, and adds itself into the onHealthChangedCallback delegate.

void Start()
{
    heartContainers = new GameObject[PlayerController.Instance.maxTotalHealth];
    heartFills = new Image[PlayerController.Instance.maxTotalHealth];

    PlayerController.Instance.onHealthChangedCallback += UpdateHeartsHUD;
    InstantiateHeartContainers();
    UpdateHeartsHUD();
}

By using the variables in PlayerController like this, it makes our code especially prone to errors. For instance, if you had a scene where you had the HeartController component attached to something, but didn’t have a player object on the Scene, the Start() function would cause UIHealth to throw multiple NullReferenceException errors because PlayerController.Instance would be null. This is not very healthy for development, especially if multiple people are working on the project, because it will create hard-to-trace errors that clutter the Console window.

d. How to use UIHealth

The new UIHealth script is a lot easier to use, and also much more error-free, than the old HeartController script. It can be used to display the health of not just the player, but of anyone else as well.

To use it, we simply get a reference to the UIHealth component we want to update, and call the Refresh() function on it, like so:

healthUIObject.Refresh(health, maxHealth, excessHealth);

We specify the amount of health, the maximum health we should have, as well as the excess health, and the UIHealth script will take the information, and draw the hearts accordingly.

How the hearts will be drawn, will depend on the settings that you place into the UIHealth component:

The UIHealth component
The UIHealth component settings.

Most of the settings you find in the UIHealth component are inherited from UIScreen—the ones that are not are drawn inside the red square.

Why does UIHealth inherit from UIScreen? Primarily because classes inheriting from UIScreen can easily be faded in and out using the Activate() and Deactivate() functions that we’ve created in the previous part. There may be times when you may choose to hide your health UI, for various reasons like when showing cutscenes.

The most important settings here are the first three:

PropertyDescription
Health Per UnitControls how many divisions each heart has. If Health Per Unit is set to 2, for example, then every heart can be full or half-filled; if set to 3, then it can be full, 2-thirds or 1-third, etc.
Health Unit PrefabThis is the prefab that will be used to show every unit of health. It is similar to the Heart Container Prefab in the HeartController script.
Excess Health Unit PrefabThis is the prefab that will be used to show excess health. These are equivalent to the blue health that you find in Hollow Knight.
Container PathThis is the path to the GameObject in your health prefabs that contains the image for the container. It is used by the script to find and adjust the size of the container (in instances where you may not have a full heart of health).
Fill PathThis is the path to the GameObject in your health prefabs that contains the image for the fill of the heart. It is used by the script to adjust whether the heart is filled or not. It cannot be left empty.

I will explain Container Path and Fill Path more later on when we set up the new health and mana UI on the Canvas, as it is easier to do so then.

2. Upgrading the Mana UI script

Just like the health UI script, we will be upgrading the way that the mana UI is handled in our code too.

a. Problems with the Mana UI

Like the health UI, the way the mana UI is coded is not very ideal. In fact, there is much more hardcoding with the mana UI than there is in the health UI, and functionality for the player’s mana management and mana UI are mixed together and spread across multiple scripts.

For starters, if you remember from previous parts, the main and sub-orbs of the UI are handled by completely different scripts:

Mana UI problems
The mana UI is handled by 3 different scripts.

The PlayerController script handles both the mana, and the main ball of mana on the UI, while the additional mana orbs, and the excess mana they provide, are handled by ManaOrbsHandler. Finally, we have the UIManager, which handles the halving of the mana when the player dies.

While spreading the code over multiple scripts is not a bad thing, it is important for there to be a clear separation of functionality between the scripts. This keeps things organised and minimises errors in the long run:

  1. PlayerController, being in charge of the player character, should manage the excess mana as well, and not let its UI scripts do it. It also should not be handling the display of the mana UI elements.
  2. The scripts responsible for handling mana UI should be handling both the main mana ball and the excess ones.
  3. The UIManager should not handle anything. It should simply serve as the bridge between the PlayerController and the mana UI script if necessary.

This will give us a clear separation of concerns between the scripts, and it will go a long way to make our codes not just error-free, but also less error-prone.

b. Creating the new UIMana script

To fix the above problems, we will create a new UIMana script to replace the old ManaOrbsHandler script. This new script will handle the UI for all mana orbs, instead of just the smaller extra ones.

UIMana.cs

using UnityEngine;
using UnityEngine.UI;

public class UIMana : UIScreen
{

    [Header("Mana UI")]
    public Image primaryFillUI;
    public Image[] excessFillsUI;
    public Image overlayUI;
    public Sprite defaultOverlaySprite, penaltyOverlaySprite;

    public void Refresh(float mana, float maxMana, float excessMana, float excessMaxMana)
    {
        primaryFillUI.fillAmount = mana / maxMana;
        for(int i = 0; i < excessFillsUI.Length; i++)
        {
            if (excessMaxMana <= i)
            {
                excessFillsUI[i].gameObject.SetActive(false);
                excessFillsUI[i].fillAmount = 0;
            }
            else
            {
                excessFillsUI[i].gameObject.SetActive(true);
                if (excessMana >= i)
                {
                    excessFillsUI[i].fillAmount = excessMana - i;
                }
            }
        }
    }

    public void SetMode(float penalty)
    {
        if(!overlayUI) return;
        if(penalty < 1f) overlayUI.sprite = penaltyOverlaySprite;
        else overlayUI.sprite = defaultOverlaySprite;
    }
}

c. How to use UIMana

Like UIHealth, this new UIMana script is completely separated from the PlayerController script, which alleviates the same problems that UIHealth had, where the old UI code from ManaOrbsHandler directly referenced the PlayerController script in several functions:

// Start is called before the first frame update
void Start()
{
    for(int i = 0; i < PlayerController.Instance.manaOrbs; i++)
    {
        manaOrbs[i].SetActive(true);
    }
}

// Update is called once per frame
void Update()
{
    for (int i = 0; i < PlayerController.Instance.manaOrbs; i++)
    {
        manaOrbs[i].SetActive(true);
    }
    CashInMana();
}
void CashInMana()
{
    if(usedMana && PlayerController.Instance.Mana <=1)
    {
        countDown -= Time.deltaTime;
    }

    if(countDown <= 0)
    {
        usedMana = false;
        countDown = 3;

        totalManaPool = (orbFills[0].fillAmount += orbFills[1].fillAmount += orbFills[2].fillAmount) * 0.33f;
        float manaNeeded = 1 - PlayerController.Instance.Mana;

        if(manaNeeded > 0)
        {
            if(totalManaPool >= manaNeeded)
            {
                PlayerController.Instance.Mana += manaNeeded;
                for(int i = 0; i < orbFills.Count; i++)
                {
                    orbFills[i].fillAmount = 0;
                }

                float addBackTotal = (totalManaPool - manaNeeded) / 0.33f;
                while(addBackTotal > 0)
                {
                    UpdateMana(addBackTotal);
                    addBackTotal -= 1;
                }
            }
            else
            {
                PlayerController.Instance.Mana += totalManaPool;
                for (int i = 0; i < orbFills.Count; i++)
                {
                    orbFills[i].fillAmount = 0;
                }
            }
        }
    }
}

The new UIMana script is completely decoupled from the PlayerController, and to update it, you simply have to call the Refresh() function, just like UIHealth:

manaUI.Refresh(mana, maxMana, excessMana, excessMaxMana);

The component itself has a bit more complexity than UIHealth, as it has more fields for us to fill in:

UIMana component
The UIMana component has more fields.

The reason there are more fields here, is because the script is now also responsible for the main mana orb, instead of just the excess ones. There has also been an upgrade to half mana UI display, so additional fields were needed for this:

Cracked mana UI
The redesigned mana UI.
PropertyDescription
Primary Fill UIThe Image component for the main mana orb.
Excess Fills UIAn array of Image components for the smaller mana orbs.
Overlay UIThe image component that covers over the main mana orb. It is replaced with a cracked texture when the mana is under penalty
Default Overlay SpriteThe sprite for when mana is not under penalty.
Penalty Overlay SpriteThe sprite for when mana is under penalty.

Now that we have both UI scripts set up and ready to use, let’s modify the rest of our scripts to work with them, and implement these new components on the Canvas.

3. Updating the PlayerController script

Now that we have both of the new components, we’ll need to modify the PlayerController script to make use of them.

a. Updating the UIManager

Before we get to that though, let’s update the UIManager so that it holds a reference to both UIHealth and UIMana, as well as the static functions UpdateHealthUI() and UpdateManaUI(), so that the PlayerController will be able to access the health and mana UI through it.

We also remove the SwitchMana() function, as well as the variables and any other logic that handle the UI for the player’s mana penalties on death, since all our logic for mana UI will be handled by UIMana.

UIManager.cs

using UnityEngine;

public class UIManager : UIScreen
{
    public static UIManager Instance;

    [Header("UI Manager")]
    public UIHealth healthUI;
    public UIMana manaUI;
    public UIScreen deathScreen;
    public GameObject mapHandler;
    public GameObject inventory;
    [SerializeField] GameObject halfMana, fullMana;

    public static void UpdateHealthUI(int health, int maxHealth, int excessHealth = 0)
    {
        if (Instance && Instance.healthUI)
            Instance.healthUI.Refresh(health, maxHealth, excessHealth);
    }

    public static void UpdateManaUI(float mana, float maxMana, float excessMana, float excessMaxMana, float manaPenalty = 1f)
    {
        if (Instance && Instance.manaUI)
            Instance.manaUI.Refresh(mana, maxMana, excessMana, excessMaxMana);
        Instance.manaUI.SetMode(manaPenalty);
    }

    public enum ManaState
    {
        FullMana,
        HalfMana
    }
    public ManaState manaState;
    protected override void Awake()
    {
        base.Awake();

        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }

        base.Awake();

        DontDestroyOnLoad(gameObject);
        Instance = this;
        
    }
    public void SwitchMana(ManaState _manaState)
    {
        switch(_manaState)
        {
            case ManaState.FullMana:

                halfMana.SetActive(false);
                fullMana.SetActive(true);

                break;

            case ManaState.HalfMana:

                fullMana.SetActive(false);
                halfMana.SetActive(true);

                break;
        }
        manaState = _manaState;
    }

}

b. The new PlayerController script

Now that the UIManager is updated, we can update our PlayerController script. There are quite a number of changes made to the PlayerController script, so let’s go through them:

For the player’s health stats, the updates are pretty straightfoward:

  1. We add a new excessHealth variable to handle blue health. To that end, we also modify the TakeDamage(), so that it absorbs damage with excessHealth first before reducing the player’s health.
  2. We also mark the OnHealthChangedDelegate() and onHealthChangedCallback as obsolete, because we’ll be replacing all calls to onHealthChangedCallback() with the function UIManager.Instance.UpdateHealthUI() instead.

Why are we removing the onHealthChangedCallback delegate? Doesn’t it make things more flexible?

Yes, delegates do make things more flexible, because you can add or remove actions from it to change the way it behaves. However, in our current case, it overcomplicates things—the UI updates for the player’s health will always be there, so there is no need to go through the additional step of hooking it onto a delegate, since it will never be turned off in-game.

A delegate that fires whenever the player’s health changes will be very useful when we implement charms in future, as they can change the way the player takes and receives damage. However, we can create separate delegates for them in the future that are more specific.

The logic for the player’s mana is where the complexity is at, because it used to be managed by the ManaOrbsHandler previously:

  1. We move the variables from ManaOrbsHandler over to the PlayerController and give them more descriptive names. These variables are excessMana, excessMaxManaUnits, excessMaxManaUnitsLimit, manaPerExcessUnit, excessManaRestoreDelay, excessManaRestoreRate and excessManaRestoreCooldown. Since there are a substantial number of properties added, below is a table summarising these new properties.
AttributeDescription
Mana Settings
ManaThe current amount of mana in the main orb.
Max ManaHow much mana is in the orb when it is full. In the old system, the max mana is 1; but we’ve changed it to 3.
Mana PenaltyA value between 0 and 1. Affects how much max mana you can have. When the player dies, this will be set to 0.5 since the maximum mana will be halved.
Excess Mana Settings
Excess ManaHow much excess mana there is.
Excess Max Mana UnitsMaximum amount of Excess Mana possible.
Excess Max Mana Units LimitMaximum amount of Excess Max Mana Units possible.
Mana Per Excess UnitHow much mana is equal to 1 mini mana orb. The default is 1, but you can change this if you want each mini orb to contain more than 1 mana.
Excess Mana Restore DelayHow long to wait before the Excess Mana refills the main orb.
Excess Mana Restore RateIn the older version, the mana restoration is instant. But in Hollow Knight, it is not! This property determines how fast the excess mana fills into the main mana orb to be used when activated.
Mana ShardsHow many mana shards have been collected.
Mana Shards Per Excess UnitThis controls how many mana shards need to be collected to give you 1 extra mana orb.
Spell Settings
Attack Mana GainHow much mana is gained when attacking. Renamed from the old manaGain variable, as the name wasn’t very descriptive previously.
Heal Mana Cost Per SecondHow much mana is drained per second when healing. Renamed from the old manaDrain variable, which wasn’t a very descriptive name.
The updated PlayerController mana properties
There are many new properties.
  1. A new function HandleRestoreManaWithExcess() is created to handle the refilling of mana whenever we have excess. The function is called every frame in Update(), and it tracks a variable excessManaRestoreCooldown. When the value hits 0, we restore excess mana into the main mana tank.
  2. In the Mana property, we add some additional logic into the setter to detect when it is being increased, and to funnel excess mana into the excessMana variable (instead of continuing to fill up mana). This makes it so that we can directly do Mana += attackManaGain when we hit enemies, instead of having to write a complex conditional block.
  3. A MaxMana property is also defined. This property retrieves the maxMana value, but factors in the manaPenalty variable as well. This means that if I have maxMana at 3, and manaPenalty at 0.5, MaxMana will be 1.5 (3 × 0.5).
  4. Another property, ExcessMaxMana, is also defined. This creates an easy way to retrieve how much the max excess mana currently is, since the maximum excess mana is excessMaxManaUnits × manaPerExcessUnit.

By making all of these changes, the PlayerController is now fully managing its own excess mana supply. Even without ManaOrbsHandler, it will now be able to accumulate and redistribute excess mana.

  1. The last change is a slight modification to Respawned()—it no longer relies on UIManager to halve the player’s mana, and it takes an optional parameter that sets the manaPenalty. Previously, whenever the player respawns, he will always get a mana penalty. With this modification, we can call Respawned(0) to respawn with no mana penalty, and Respawned(0.5) to respawn with 50% penalty, so that we have more flexibility in our respawn options.

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEditor.Build;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("Horizontal Movement Settings:")]
    [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground
    [Space(5)]

    [Header("Vertical Movement Settings")]
    [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump

    private float jumpBufferCounter = 0; //stores the jump button input
    [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored

    private float coyoteTimeCounter = 0; //stores the Grounded() bool
    [SerializeField] private float coyoteTime; ////sets the max amount of frames the Grounded() bool is stored

    private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air
    [SerializeField] private int maxAirJumps; //the max no. of air jumps
    [SerializeField] private int maxFallingSpeed; //the max no. of air jumps

    private float gravity; //stores the gravity scale at start
    [Space(5)]

    [Header("Wall Jump Settings")]
    [SerializeField] private float wallSlidingSpeed = 2f;
    [SerializeField] private Transform wallCheck;
    [SerializeField] private LayerMask wallLayer;
    [SerializeField] private float wallJumpingDuration;
    [SerializeField] private Vector2 wallJumpingPower;
    float wallJumpingDirection;
    bool isWallSliding;
    bool isWallJumping;
    [Space(5)]

    [Header("Ground Check Settings:")]
    [SerializeField] private Transform groundCheckPoint; //point at which ground check happens
    [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked
    [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is
    [SerializeField] private LayerMask whatIsGround; //sets the ground layer
    [Space(5)]

    [Header("Dash Settings")]
    [SerializeField] private float dashSpeed; //speed of the dash
    [SerializeField] private float dashTime; //amount of time spent dashing
    [SerializeField] private float dashCooldown; //amount of time between dashes
    [SerializeField] GameObject dashEffect;
    private bool canDash = true, dashed;
    [Space(5)]

    [Header("Attack Settings:")]
    [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area
    [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is

    [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area
    [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is

    [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area
    [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is

    [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of

    [SerializeField] private float timeBetweenAttack;
    private float timeSinceAttack;

    [SerializeField] private float damage; //the damage the player does to an enemy

    [SerializeField] private GameObject slashEffect; //the effect of the slashs

    bool restoreTime;
    float restoreTimeSpeed;
    [Space(5)]

    [Header("Recoil Settings:")]
    [SerializeField] private int recoilXSteps = 5; //how many FixedUpdates() the player recoils horizontally for
    [SerializeField] private int recoilYSteps = 5; //how many FixedUpdates() the player recoils vertically for

    [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil
    [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil

    private int stepsXRecoiled, stepsYRecoiled; //the no. of steps recoiled horizontally and verticall
    [Space(5)]

    [Header("Health Settings")]
    public int health;
    public int maxHealth;
    public int maxTotalHealth = 10;
    public int excessHealth = 0;
    public int heartShards;
    [Min(1)] public int heartShardsPerHealth = 4;
    [SerializeField] GameObject bloodSpurt;
    [SerializeField] float hitFlashSpeed;
    [System.Obsolete] public delegate void OnHealthChangedDelegate();
    [HideInInspector][System.Obsolete] public OnHealthChangedDelegate onHealthChangedCallback;

    float healTimer;
    [SerializeField] float timeToHeal;
    [Space(5)]

    [Header("Mana Settings")]
    [SerializeField] UnityEngine.UI.Image manaStorage;

    [SerializeField] float mana;
    [SerializeField] float manaDrainSpeed;
    [SerializeField] float manaGain;
    public bool halfMana;

    public ManaOrbsHandler manaOrbsHandler;
    public int orbShard;
    public int manaOrbs;
    public float mana = 3;
    public float maxMana = 3;
    [Range(0, 1)] public float manaPenalty = 0f;

    [Header("Excess Mana Settings")]
    public float excessMana = 0;
    public int excessMaxManaUnits = 0, excessMaxManaUnitsLimit = 3;
    public float manaPerExcessUnit = 1f;
    [SerializeField] float excessManaRestoreDelay = 3f, excessManaRestoreRate = 1f;
    float excessManaRestoreCooldown = 0f;

    public int manaShards = 0;
    [Min(1)] public int manaShardsPerExcessUnit = 4;
    [Space(5)]

    [Header("Spell Settings")]
    //spell stats
    [SerializeField] float attackManaGain = 0.34f;
    [SerializeField] float healManaCostPerSecond = 1f;
    [SerializeField] float manaSpellCost = 0.3f;
    [SerializeField] float timeBetweenCast = 0.5f;
    [SerializeField] float spellDamage; //upspellexplosion and downspellfireball
    [SerializeField] float downSpellForce; // desolate dive only
    //spell cast objects
    [SerializeField] GameObject sideSpellFireball;
    [SerializeField] GameObject upSpellExplosion;
    [SerializeField] GameObject downSpellFireball;
    float timeSinceCast;
    float castOrHealTimer;
    [Space(5)]

    [Header("Camera Stuff")]
    [SerializeField] private float playerFallSpeedThreshold = -10;
    [Space(5)]

    [Header("Audio")]
    [SerializeField] AudioClip landingSound;
    [SerializeField] AudioClip jumpSound;
    [SerializeField] AudioClip dashAndAttackSound;
    [SerializeField] AudioClip spellCastSound;
    [SerializeField] AudioClip hurtSound;

    [HideInInspector] public PlayerStateList pState;
    [HideInInspector] public Rigidbody2D rb;
    private Animator anim;
    private SpriteRenderer sr;
    private AudioSource audioSource;

    //Input Variables
    private float xAxis, yAxis;
    private bool attack = false;
    bool openMap;
    bool openInventory;

    private bool canFlash = true;

    private bool landingSoundPlayed;

    public static PlayerController Instance;

    //unlocking 
    public bool unlockedWallJump;
    public bool unlockedDash;
    public bool unlockedVarJump;

    public bool unlockedSideCast;
    public bool unlockedUpCast;
    public bool unlockedDownCast;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }

        DontDestroyOnLoad(gameObject);
    }


    // Start is called before the first frame update
    void Start()
    {
        pState = GetComponent<PlayerStateList>();

        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();

        anim = GetComponent<Animator>();

        manaOrbsHandler = FindObjectOfType<ManaOrbsHandler>();

        audioSource = GetComponent<AudioSource>();

        gravity = rb.gravityScale;

        Mana = mana;
        manaStorage.fillAmount = Mana;

        Health = maxHealth;

        SaveData.Instance.LoadPlayerData();
        if (manaOrbs > 3)
        {
            manaOrbs = 3;
        }

        if (halfMana)
        {
            UIManager.Instance.SwitchMana(UIManager.ManaState.HalfMana);
        }
        else
        {
            UIManager.Instance.SwitchMana(UIManager.ManaState.FullMana);
        }

        if (onHealthChangedCallback != null) onHealthChangedCallback.Invoke();

        if (Health == 0)
        {
            pState.alive = false;
            GameManager.Instance.RespawnPlayer();
        }
        UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
    }

    private void OnDrawGizmos()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea);
        Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea);
        Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea);
    }

    void HandleRestoreManaWithExcess()
    {
        if(excessManaRestoreCooldown > 0)
        {
            excessManaRestoreCooldown -= Time.deltaTime;
        } 
        else if(Mana < MaxMana && excessMana > 0f)
        {
            float restoreAmount = Mathf.Min(excessMana, excessManaRestoreRate * Time.deltaTime);
            Mana += restoreAmount;
            ExcessMana -= restoreAmount;
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (pState.cutscene || GameManager.Instance.isPaused) return;

        if (pState.alive)
        {
            HandleRestoreManaWithExcess();
            GetInputs();
            ToggleMap();
            ToggleInventory();
        }
        UpdateJumpVariables();

        UpdateCameraYDampForPlayerFall();

        if (pState.dashing) return;
        FlashWhileInvincible();
        if (!pState.alive) return;
        if (!isWallJumping)
        {
            Move();
        }
        Heal();
        CastSpell();
        if (pState.healing) return;
        if (!isWallJumping)
        {
            Flip();
            Jump();
        }

        if (unlockedWallJump)
        {
            WallSlide();
            WallJump();
        }

        if (unlockedDash)
        {
            StartDash();
        }
        Attack();
    }
    private void OnTriggerEnter2D(Collider2D _other) //for up and down cast spell
    {
        if (_other.GetComponent<Enemy>() != null && pState.casting)
        {
            _other.GetComponent<Enemy>().EnemyGetsHit(spellDamage, (_other.transform.position - transform.position).normalized, -recoilYSpeed);
        }
    }

    private void FixedUpdate()
    {
        if (pState.cutscene) return;

        if (pState.dashing || pState.healing) return;
        Recoil();
    }

    void GetInputs()
    {
        //if (GameManager.Instance.isPaused || GameManager.isStopped) return;

        xAxis = Input.GetAxisRaw("Horizontal");
        yAxis = Input.GetAxisRaw("Vertical");
        attack = Input.GetButtonDown("Attack");
        openMap = Input.GetButton("Map");
        openInventory = Input.GetButton("Inventory");

        if (Input.GetButton("Cast/Heal"))
        {
            castOrHealTimer += Time.deltaTime;
        }
    }
    void ToggleMap()
    {
        if (openMap)
        {
            UIManager.Instance.mapHandler.SetActive(true);
        }
        else
        {
            UIManager.Instance.mapHandler.SetActive(false);
        }
    }
    void ToggleInventory()
    {
        if (openInventory)
        {
            UIManager.Instance.inventory.SetActive(true);
        }
        else
        {
            UIManager.Instance.inventory.SetActive(false);
        }
    }

    void Flip()
    {
        if (xAxis < 0)
        {
            transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y);
            pState.lookingRight = false;
        }
        else if (xAxis > 0)
        {
            transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y);
            pState.lookingRight = true;
        }
    }

    private void Move()
    {
        if (pState.healing) rb.velocity = new Vector2(0, 0);
        rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y);
        anim.SetBool("Walking", rb.velocity.x != 0 && Grounded());
    }

    void UpdateCameraYDampForPlayerFall()
    {
        //if falling past a certain speed threshold
        if (rb.velocity.y < playerFallSpeedThreshold && !CameraManager.Instance.isLerpingYDamping && !CameraManager.Instance.hasLerpedYDamping)
        {
            StartCoroutine(CameraManager.Instance.LerpYDamping(true));
        }
        //if standing stil or moving up
        if (rb.velocity.y >= 0 && !CameraManager.Instance.isLerpingYDamping && CameraManager.Instance.hasLerpedYDamping)
        {
            //reset camera function
            CameraManager.Instance.hasLerpedYDamping = false;
            StartCoroutine(CameraManager.Instance.LerpYDamping(false));
        }

    }

    void StartDash()
    {
        if (Input.GetButtonDown("Dash") && canDash && !dashed)
        {
            StartCoroutine(Dash());
            dashed = true;
        }

        if (Grounded())
        {
            dashed = false;
        }
    }

    IEnumerator Dash()
    {
        canDash = false;
        pState.dashing = true;
        anim.SetTrigger("Dashing");
        audioSource.PlayOneShot(dashAndAttackSound);
        rb.gravityScale = 0;
        int _dir = pState.lookingRight ? 1 : -1;
        rb.velocity = new Vector2(_dir * dashSpeed, 0);
        if (Grounded()) Instantiate(dashEffect, transform);
        yield return new WaitForSeconds(dashTime);
        rb.gravityScale = gravity;
        pState.dashing = false;
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }

    public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay)
    {
        pState.invincible = true;

        //If exit direction is upwards
        if (_exitDir.y != 0)
        {
            rb.velocity = jumpForce * _exitDir;
        }

        //If exit direction requires horizontal movement
        if (_exitDir.x != 0)
        {
            xAxis = _exitDir.x > 0 ? 1 : -1;

            Move();
        }

        Flip();
        yield return new WaitForSeconds(_delay);
        print("cutscene played");
        pState.invincible = false;
        pState.cutscene = false;
    }

    void Attack()
    {
        timeSinceAttack += Time.deltaTime;
        if (attack && timeSinceAttack >= timeBetweenAttack)
        {
            timeSinceAttack = 0;
            anim.SetTrigger("Attacking");
            audioSource.PlayOneShot(dashAndAttackSound);

            if (yAxis == 0 || yAxis < 0 && Grounded())
            {
                int _recoilLeftOrRight = pState.lookingRight ? 1 : -1;

                Hit(SideAttackTransform, SideAttackArea, ref pState.recoilingX, Vector2.right * _recoilLeftOrRight, recoilXSpeed);
                Instantiate(slashEffect, SideAttackTransform);
            }
            else if (yAxis > 0)
            {
                Hit(UpAttackTransform, UpAttackArea, ref pState.recoilingY, Vector2.up, recoilYSpeed);
                SlashEffectAtAngle(slashEffect, 80, UpAttackTransform);
            }
            else if (yAxis < 0 && !Grounded())
            {
                Hit(DownAttackTransform, DownAttackArea, ref pState.recoilingY, Vector2.down, recoilYSpeed);
                SlashEffectAtAngle(slashEffect, -90, DownAttackTransform);
            }
        }


    }
    void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilBool, Vector2 _recoilDir, float _recoilStrength)
    {
        Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer);
        List<Enemy> hitEnemies = new List<Enemy>();

        if (objectsToHit.Length > 0)
        {
            _recoilBool = true;
        }
        for (int i = 0; i < objectsToHit.Length; i++)
        {
            Enemy e = objectsToHit[i].GetComponent<Enemy>();
            if (e && !hitEnemies.Contains(e))
            {
                e.EnemyGetsHit(damage, _recoilDir, _recoilStrength);
                hitEnemies.Add(e);

                if (objectsToHit[i].CompareTag("Enemy"))
                {
                    if (!halfMana && Mana < 1 || (halfMana && Mana < 0.5))
                    {
                        Mana += manaGain;
                    }
                    else
                    {
                        manaOrbsHandler.UpdateMana(manaGain * 3);
                    }
                    Mana += attackManaGain;
                }
            }
        }
    }
    void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform)
    {
        _slashEffect = Instantiate(_slashEffect, _attackTransform);
        _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle);
        _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y);
    }
    void Recoil()
    {
        if (pState.recoilingX)
        {
            if (pState.lookingRight)
            {
                rb.velocity = new Vector2(-recoilXSpeed, 0);
            }
            else
            {
                rb.velocity = new Vector2(recoilXSpeed, 0);
            }
        }

        if (pState.recoilingY)
        {
            rb.gravityScale = 0;
            if (yAxis < 0)
            {
                rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed);
            }
            else
            {
                rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed);
            }
            airJumpCounter = 0;
        }
        else
        {
            rb.gravityScale = gravity;
        }

        //stop recoil
        if (pState.recoilingX && stepsXRecoiled < recoilXSteps)
        {
            stepsXRecoiled++;
        }
        else
        {
            StopRecoilX();
        }
        if (pState.recoilingY && stepsYRecoiled < recoilYSteps)
        {
            stepsYRecoiled++;
        }
        else
        {
            StopRecoilY();
        }

        if (Grounded())
        {
            StopRecoilY();
        }
    }
    void StopRecoilX()
    {
        stepsXRecoiled = 0;
        pState.recoilingX = false;
    }
    void StopRecoilY()
    {
        stepsYRecoiled = 0;
        pState.recoilingY = false;
    }
    public void TakeDamage(float _damage)
    {
        if (pState.alive)
        {
            audioSource.PlayOneShot(hurtSound);

            // Absorb damage with excess health.
            if(ExcessHealth > 0)
            {
                if(ExcessHealth > _damage)
                {
                    ExcessHealth -= Mathf.RoundToInt(_damage);
                    _damage = 0;
                }
                else
                {
                    _damage -= ExcessHealth;
                    ExcessHealth = 0;
                }
            }

            // Reduce player health.
            Health -= Mathf.RoundToInt(_damage);
            if (Health <= 0)
            {
                Health = 0;
                StartCoroutine(Death());
            }
            else
            {
                StartCoroutine(StopTakingDamage());
            }
        }

    }
    IEnumerator StopTakingDamage()
    {
        pState.invincible = true;
        GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity);
        Destroy(_bloodSpurtParticles, 1.5f);
        anim.SetTrigger("TakeDamage");
        yield return new WaitForSeconds(1f);
        pState.invincible = false;
    }

    IEnumerator Flash()
    {
        sr.enabled = !sr.enabled;
        canFlash = false;
        yield return new WaitForSeconds(0.1f);
        canFlash = true;
    }

    void FlashWhileInvincible()
    {
        if (pState.invincible && !pState.cutscene)
        {
            if (Time.timeScale > 0.2 && canFlash)
            {
                StartCoroutine(Flash());
            }
        }
        else
        {
            sr.enabled = true;
        }
    }


    IEnumerator Death()
    {
        pState.alive = false;
        Time.timeScale = 1f;
        GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity);
        Destroy(_bloodSpurtParticles, 1.5f);
        anim.SetTrigger("Death");

        rb.constraints = RigidbodyConstraints2D.FreezePosition;
        GetComponent<BoxCollider2D>().enabled = false;

        yield return new WaitForSeconds(0.9f);
        UIManager.Instance.deathScreen.Activate();

        yield return new WaitForSeconds(0.1f);
        Instantiate(GameManager.Instance.shade, transform.position, Quaternion.identity);

        SaveData.Instance.SavePlayerData();
    }

    public void Respawned(float manaPenalty = 0.5f)
    {
        if (!pState.alive)
        {
            rb.constraints = RigidbodyConstraints2D.None;
            rb.constraints = RigidbodyConstraints2D.FreezeRotation;
            GetComponent<BoxCollider2D>().enabled = true;
            pState.alive = true;
            halfMana = true;
            UIManager.Instance.SwitchMana(UIManager.ManaState.HalfMana);
            Mana = 0;
            Health = maxHealth;
            anim.Play("Player_Idle");
            // Address the component specific settings if the components
            // are there.
            if (rb)
            {
                rb.constraints = RigidbodyConstraints2D.None;
                rb.constraints = RigidbodyConstraints2D.FreezeRotation;
            }
            if (anim) anim.Play("Player_Idle");

            // Set the rest of the variables.
            GetComponent<BoxCollider2D>().enabled = true;
            pState.alive = true;

            // Apply mana penalty if the flag is set to true.
            this.manaPenalty = manaPenalty;
            mana = excessMana = 0;
            UIManager.UpdateManaUI(mana, maxMana, excessMana, ExcessMaxMana, 1f - manaPenalty);
            
            Health = maxHealth;
        }
    }
    public void RestoreMana()
    {
        halfMana = false;
        manaPenalty = 0f;
        UIManager.Instance.SwitchMana(UIManager.ManaState.FullMana);
    }
    public int Health
    {
        get { return health; }
        set
        {
            if (health != value)
            {
                health = Mathf.Clamp(value, 0, maxHealth);
                if (onHealthChangedCallback != null)
                {
                    onHealthChangedCallback.Invoke();
                }
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }
    public int ExcessHealth
    {
        get { return excessHealth; }
        set
        {
            if (excessHealth != value)
            {
                excessHealth = Mathf.Clamp(value, 0, excessHealth);
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }

    // Converts health shards to actual excess health units.
    public void ConvertHeartShards()
    {
        // While converting, we also want to make sure that it is not possible
        // to exceed the max health as set by maxTotalHealth.
        int remainingUnits = maxTotalHealth - maxHealth;
        if (heartShards >= heartShardsPerHealth && remainingUnits > 0)
        {
            // If the awarded units is more than the remaining units, we award only remaining units.
            int awardedUnits = Mathf.Min(remainingUnits, heartShards / heartShardsPerHealth);

            // Award units and subtract the mana shards.
            maxHealth += awardedUnits;
            heartShards -= awardedUnits * heartShardsPerHealth;

            UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
        }
    }

    void Heal()
    {
        if (Input.GetButton("Cast/Heal") && castOrHealTimer > 0.1f && Health < maxHealth && Mana > 0 && Grounded() && !pState.dashing)
        {
            pState.healing = true;
            anim.SetBool("Healing", true);

            //healing
            healTimer += Time.deltaTime;
            if (healTimer >= timeToHeal)
            {
                Health++;
                healTimer = 0;
            }

            //drain mana
            manaOrbsHandler.usedMana = true;
            manaOrbsHandler.countDown = 3f;
            Mana -= Time.deltaTime * manaDrainSpeedhealManaCostPerSecond;
        }
        else
        {
            pState.healing = false;
            anim.SetBool("Healing", false);
            healTimer = 0;
        }
    }
    public float Mana
    {
        get { return mana; }
        set
        {
            //if mana stats change
            if (mana != value)
            {
                if (!halfMana)
                {
                    mana = Mathf.Clamp(value, 0, 1);
                }
                else
                {
                    mana = Mathf.Clamp(value, 0, 0.5f);
                }

                manaStorage.fillAmount = Mana;
            }
            // If there is excess mana, move it to the excess mana slot.
            float excess = value - MaxMana;
            if(excess > 0)
            {
                mana = MaxMana;
                ExcessMana += excess;
            }
            else
            {
                // If mana is being reduced, institute a restore cooldown first.
                if (value < mana) excessManaRestoreCooldown = excessManaRestoreDelay;
                mana = Mathf.Max(0, value);
            }
            UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana, 1 - manaPenalty);
        }
    }

    public float ExcessMana {
        get { return excessMana; }
        set { excessMana = Mathf.Clamp(value, 0, ExcessMaxMana); }
    }

    public float MaxMana { get { return maxMana * (1 - manaPenalty); } }
    public float ExcessMaxMana { get { return excessMaxManaUnits * manaPerExcessUnit; } }

    // Converts mana shards to actual excess mana units.
    public void ConvertManaShards()
    {
        // While converting, we also want to make sure that it is not possible
        // to exceed the max excess units as set by excessMaxManaUnitsLimit.
        // Hence all of the logic here.
        int remainingUnits = excessMaxManaUnitsLimit - excessMaxManaUnits;
        if(manaShards >= manaShardsPerExcessUnit && remainingUnits > 0)
        {
            // If the awarded units is more than the remaining units, we award only remaining units.
            int awardedUnits = Mathf.Min(remainingUnits, manaShards / manaShardsPerExcessUnit);

            // Award units and subtract the mana shards.
            excessMaxManaUnits += awardedUnits;
            manaShards -= awardedUnits * manaShardsPerExcessUnit;

            // We will have to update the mana UI once its up as well.
            UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana);
        }
    }

    void CastSpell()
    {
        if (Input.GetButtonUp("Cast/Heal") && castOrHealTimer <= 0.1f && timeSinceCast >= timeBetweenCast && Mana >= manaSpellCost)
        {
            pState.casting = true;
            timeSinceCast = 0;
            StartCoroutine(CastCoroutine());
        }
        else
        {
            timeSinceCast += Time.deltaTime;
        }

        if (!Input.GetButton("Cast/Heal"))
        {
            castOrHealTimer = 0;
        }

        if (Grounded())
        {
            //disable downspell if on the ground
            downSpellFireball.SetActive(false);
        }
        //if down spell is active, force player down until grounded
        if (downSpellFireball.activeInHierarchy)
        {
            rb.velocity += downSpellForce * Vector2.down;
        }
    }
    IEnumerator CastCoroutine()
    {
        //side cast
        if ((yAxis == 0 || (yAxis < 0 && Grounded())) && unlockedSideCast)
        {
            audioSource.PlayOneShot(spellCastSound);
            anim.SetBool("Casting", true);
            yield return new WaitForSeconds(0.15f);
            GameObject _fireBall = Instantiate(sideSpellFireball, SideAttackTransform.position, Quaternion.identity);

            //flip fireball
            if (pState.lookingRight)
            {
                _fireBall.transform.eulerAngles = Vector3.zero; // if facing right, fireball continues as per normal
            }
            else
            {
                _fireBall.transform.eulerAngles = new Vector2(_fireBall.transform.eulerAngles.x, 180);
                //if not facing right, rotate the fireball 180 deg
            }
            pState.recoilingX = true;

            Mana -= manaSpellCost;
            manaOrbsHandler.usedMana = true;
            manaOrbsHandler.countDown = 3f;
            yield return new WaitForSeconds(0.35f);
        }

        //up cast
        else if (yAxis > 0 && unlockedUpCast)
        {
            audioSource.PlayOneShot(spellCastSound);
            anim.SetBool("Casting", true);
            yield return new WaitForSeconds(0.15f);

            Instantiate(upSpellExplosion, transform);
            rb.velocity = Vector2.zero;

            Mana -= manaSpellCost;
            manaOrbsHandler.usedMana = true;
            manaOrbsHandler.countDown = 3f;
            yield return new WaitForSeconds(0.35f);
        }

        //down cast
        else if ((yAxis < 0 && !Grounded()) && unlockedDownCast)
        {
            audioSource.PlayOneShot(spellCastSound);
            anim.SetBool("Casting", true);
            yield return new WaitForSeconds(0.15f);

            downSpellFireball.SetActive(true);

            Mana -= manaSpellCost;
            manaOrbsHandler.usedMana = true;
            manaOrbsHandler.countDown = 3f;
            yield return new WaitForSeconds(0.35f);
        }


        anim.SetBool("Casting", false);
        pState.casting = false;
    }

    public bool Grounded()
    {
        if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround)
            || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)
            || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround))
        {
            return true;
        }
        else
        {
            return false;
        }
    }

    void Jump()
    {
        if (jumpBufferCounter > 0 && coyoteTimeCounter > 0 && !pState.jumping)
        {
            if (Input.GetButtonDown("Jump"))
            {
                audioSource.PlayOneShot(jumpSound);
            }

            rb.velocity = new Vector3(rb.velocity.x, jumpForce);

            pState.jumping = true;
        }

        if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && unlockedVarJump)
        {
            audioSource.PlayOneShot(jumpSound);

            pState.jumping = true;

            airJumpCounter++;

            rb.velocity = new Vector3(rb.velocity.x, jumpForce);
        }

        if (Input.GetButtonUp("Jump") && rb.velocity.y > 3)
        {
            pState.jumping = false;

            rb.velocity = new Vector2(rb.velocity.x, 0);
        }

        rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -maxFallingSpeed, rb.velocity.y));

        anim.SetBool("Jumping", !Grounded());
    }

    void UpdateJumpVariables()
    {
        if (Grounded())
        {
            if (!landingSoundPlayed)
            {
                audioSource.PlayOneShot(landingSound);
                landingSoundPlayed = true;
            }
            pState.jumping = false;
            coyoteTimeCounter = coyoteTime;
            airJumpCounter = 0;
        }
        else
        {
            coyoteTimeCounter -= Time.deltaTime;
            landingSoundPlayed = false;
        }

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferFrames;
        }
        else
        {
            jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10;
        }
    }

    private bool Walled()
    {
        return Physics2D.OverlapCircle(wallCheck.position, 0.2f, wallLayer);
    }

    void WallSlide()
    {
        if (Walled() && !Grounded() && xAxis != 0)
        {
            isWallSliding = true;
            rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -wallSlidingSpeed, float.MaxValue));
        }
        else
        {
            isWallSliding = false;
        }
    }
    void WallJump()
    {
        if (isWallSliding)
        {
            isWallJumping = false;
            wallJumpingDirection = !pState.lookingRight ? 1 : -1;

            CancelInvoke(nameof(StopWallJumping));
        }

        if (Input.GetButtonDown("Jump") && isWallSliding)
        {
            audioSource.PlayOneShot(jumpSound);
            isWallJumping = true;
            rb.velocity = new Vector2(wallJumpingDirection * wallJumpingPower.x, wallJumpingPower.y);

            dashed = false;
            airJumpCounter = 0;

            pState.lookingRight = !pState.lookingRight;

            float jumpDirection = pState.lookingRight ? 0 : 180;
            transform.eulerAngles = new Vector2(transform.eulerAngles.x, jumpDirection);

            Invoke(nameof(StopWallJumping), wallJumpingDuration);
        }
    }

    void StopWallJumping()
    {
        isWallJumping = false;
        transform.eulerAngles = new Vector2(transform.eulerAngles.x, 0);
    }
}

c. Updating the GameManager, InventoryManager, and SaveData

With the updates made to PlayerController, you will find that it is no longer compatible with the GameManager, InventoryManager and SaveData scripts, as the scripts use some variables that we have since deleted. As such, we will need to update all the scripts to use the new variables in PlayerController.

GameManager.cs

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

[DisallowMultipleComponent]
public class GameManager : MonoBehaviour
{
    public string transitionedFromScene;

    public Vector2 platformingRespawnPoint;
    public Vector2 respawnPoint;
    public Vector2 defaultRespawnPoint;
    [SerializeField] Bench bench;

    public GameObject shade;

    [SerializeField] private UIScreen pauseMenu;

    public bool isPaused;
    float lastTimeScale = -1f;
    static Coroutine stopGameCoroutine;
    public static bool isStopped { get { return stopGameCoroutine != null; } }

    public bool THKDefeated = false;

    public static GameManager Instance { get; private set; }
    private void Awake()
    {
        SaveData.Instance.Initialize();

        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }

        if (PlayerController.Instance != null)
        {
            if (PlayerController.Instance.halfMana)
            if(PlayerController.Instance.manaPenalty > 0f)
            {
                SaveData.Instance.LoadShadeData();
                if (SaveData.Instance.sceneWithShade == SceneManager.GetActiveScene().name || SaveData.Instance.sceneWithShade == "")
                {
                    Instantiate(shade, SaveData.Instance.shadePos, SaveData.Instance.shadeRot);
                }
            }
        }

        SaveScene();

        DontDestroyOnLoad(gameObject);

        bench = FindObjectOfType<Bench>();

        SaveData.Instance.LoadBossData();
    }
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.P))
        {
            SaveData.Instance.SavePlayerData();
        }

        if (Input.GetKeyDown(KeyCode.Escape))
        {
            Pause(!isPaused);
        }
    }

    public void Pause(bool b)
    {
        if (b)
        {
            // Save the timescale we will restore to.
            if (lastTimeScale < 0)
                lastTimeScale = Time.timeScale;
            Time.timeScale = 0f;
            pauseMenu.Activate();
        }
        else
        {
            if (!isStopped)
            {
                Time.timeScale = lastTimeScale > 0f ? lastTimeScale : 1f;
                lastTimeScale = -1;
            }
            pauseMenu.Deactivate();
        }

        isPaused = b;
    }

    public static void Stop(float duration = .5f, float restoreDelay = .1f, float slowMultiplier = 0f)
    {
        if (stopGameCoroutine != null) return;
        stopGameCoroutine = Instance.StartCoroutine(HandleStopGame(duration, restoreDelay, slowMultiplier));
    }

    // Used to create the hit stop effect. 
    // <duration> specifies how long it lasts for.
    // <restoreDelay> specifies how quickly we go back to the original time scale.
    // <stopMultiplier> lets us control how much the stop is.
    static IEnumerator HandleStopGame(float duration, float restoreDelay, float slowMultiplier = 0f)
    {
        if (Instance.lastTimeScale < 0)
            Instance.lastTimeScale = Time.timeScale; // Saves the original time scale for restoration later.

        Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier);

        // Counts down every frame until the stop game is finished.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        while (duration > 0)
        {
            // Don't count down if the game is paused, and don't loop as well.
            if (Instance.isPaused)
            {
                yield return w;
                continue;
            }

            // Set the time back to zero, since unpausing sets it back to 1.
            Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier);

            // Counts down.
            duration -= Time.unscaledDeltaTime;
            yield return w;
        }

        // Save the last time scale we want to restore to.
        float timeScaleToRestore = Instance.lastTimeScale;

        // Signal that the stop is finished.
        Instance.lastTimeScale = -1;
        stopGameCoroutine = null;

        // If a restore delay is set, restore the time scale gradually.
        if (restoreDelay > 0)
        {
            // Moves the timescale from the value it is set to, to its original value.
            float currentTimeScale = timeScaleToRestore * slowMultiplier;
            float restoreSpeed = (timeScaleToRestore - currentTimeScale) / restoreDelay;
            while (currentTimeScale < timeScaleToRestore)
            {
                // Stop this if the game is paused.
                if (Instance.isPaused)
                {
                    yield return w;
                    continue;
                }

                // End this coroutine if another stop has started.
                if (isStopped) yield break;

                // Set the timescale to the current value this frame.
                currentTimeScale += restoreSpeed * Time.unscaledDeltaTime;
                Time.timeScale = Mathf.Max(0, currentTimeScale);

                // Wait for a frame.
                yield return w;
            }
        }

        // Only restore the timeScale if it is not stopped again.
        // Can happen if another stop fires while restoring the time scale.
        if (!isStopped) Time.timeScale = Mathf.Max(0, timeScaleToRestore);
    }
    public void SaveScene()
    {
        string currentSceneName = SceneManager.GetActiveScene().name;
        SaveData.Instance.sceneNames.Add(currentSceneName);
    }

    public void SaveGame()
    {
        SaveData.Instance.SavePlayerData();
    }
    public void RespawnPlayer(float manaPenalty = 0f)
    {
        SaveData.Instance.LoadBench();
        if (SaveData.Instance.benchSceneName != null) //load the bench's scene if it exists.
        {
            SceneManager.LoadScene(SaveData.Instance.benchSceneName);
        }

        if (SaveData.Instance.benchPos != null) //set the respawn point to the bench's position.
        {
            respawnPoint = SaveData.Instance.benchPos;
        }
        else
        {
            respawnPoint = defaultRespawnPoint;
        }

        PlayerController.Instance.transform.position = respawnPoint;

        UIManager.Instance.deathScreen.Deactivate();
        PlayerController.Instance.Respawned(manaPenalty);
    }
}

InventoryManager.cs

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

public class InventoryManager : MonoBehaviour
{

    [SerializeField] Image heartShard;
    [SerializeField] Image manaShard;
    [SerializeField] GameObject upCast, sideCast, downCast;
    [SerializeField] GameObject dash, varJump, wallJump;

    private void OnEnable()
    {
        //heart shard
        heartShard.fillAmount = PlayerController.Instance.heartShards * 0.25f;

        //mana shards
        manaShard.fillAmount = PlayerController.Instance.orbShardmanaShards * 0.34f;


        //spells
        if(PlayerController.Instance.unlockedUpCast)
        {
            upCast.SetActive(true);
        }
        else
        {
            upCast.SetActive(false);
        }
        if (PlayerController.Instance.unlockedSideCast)
        {
            sideCast.SetActive(true);
        }
        else
        {
            sideCast.SetActive(false);
        }
        if (PlayerController.Instance.unlockedDownCast)
        {
            downCast.SetActive(true);
        }
        else
        {
            downCast.SetActive(false);
        }

        //abilities
        if (PlayerController.Instance.unlockedDash)
        {
            dash.SetActive(true);
        }
        else
        {
            dash.SetActive(false);
        }
        if (PlayerController.Instance.unlockedVarJump)
        {
            varJump.SetActive(true);
        }
        else
        {
            varJump.SetActive(false);
        }
        if (PlayerController.Instance.unlockedWallJump)
        {
            wallJump.SetActive(true);
        }
        else
        {
            wallJump.SetActive(false);
        }
    }
}

SaveData.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using UnityEngine.SceneManagement;


[System.Serializable]
public struct SaveData
{
    public static SaveData Instance;

    //map stuff
    public HashSet<string> sceneNames;

    //bench stuff
    public string benchSceneName;
    public Vector2 benchPos;

    //player stuff
    public int playerHealth;
    public int playerMaxHealth;
    public int playerHeartShards;
    public float playerMana;
    public int playerManaOrbsfloat playerExcessMana;
    public int playerOrbShardplayerManaShards;
    public float playerOrb0fill, playerOrb1fill, playerOrb2fill;
    public bool playerHalfMana;
    public float playerManaPenalty;
    public Vector2 playerPosition;
    public string lastScene;

    public bool playerUnlockedWallJump, playerUnlockedDash, playerUnlockedVarJump;
    public bool playerUnlockedSideCast, playerUnlockedUpCast, playerUnlockedDownCast;

    //enemies stuff
    //shade
    public Vector2 shadePos;
    public string sceneWithShade;
    public Quaternion shadeRot;

    //THK
    public bool THKDefeated;



    public void Initialize()
    {
        if(!File.Exists(Application.persistentDataPath + "/save.bench.data")) //if file doesnt exist, well create the file
        {
            BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.bench.data"));
        }
        if (!File.Exists(Application.persistentDataPath + "/save.player.data")) //if file doesnt exist, well create the file
        {
            BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.player.data"));
        }
        if (!File.Exists(Application.persistentDataPath + "/save.shade.data")) //if file doesnt exist, well create the file
        {
            BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.shade.data"));
        }

        if (sceneNames == null)
        {
            sceneNames = new HashSet<string>();
        }
    }
    #region Bench Stuff
    public void SaveBench()
    {
        using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.bench.data")))
        {
            writer.Write(benchSceneName);
            writer.Write(benchPos.x);
            writer.Write(benchPos.y);
        }
    }
    public void LoadBench()
    {
        string savePath = Application.persistentDataPath + "/save.bench.data";
        if(File.Exists(savePath) && new FileInfo(savePath).Length > 0)
        {
            using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.bench.data")))
            {
                benchSceneName = reader.ReadString();
                benchPos.x = reader.ReadSingle();
                benchPos.y = reader.ReadSingle();
            }
        }
        else
        {
            Debug.Log("Bench doesnt exist");
        }
    }
    #endregion

    #region Player stuff
    public void SavePlayerData()
    {
        using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.player.data")))
        {
            playerHealth = PlayerController.Instance.Health;
            writer.Write(playerHealth);
            playerMaxHealth = PlayerController.Instance.maxHealth;
            writer.Write(playerMaxHealth);
            playerHeartShards = PlayerController.Instance.heartShards;
            writer.Write(playerHeartShards);

            playerMana = PlayerController.Instance.Mana;
            writer.Write(playerMana);
            playerHalfMana = PlayerController.Instance.halfMana;
            writer.Write(playerHalfMana);
            playerManaPenalty = PlayerController.Instance.manaPenalty;
            writer.Write(playerManaPenalty);
            playerManaOrbs = PlayerController.Instance.manaOrbs;
            writer.Write(playerManaOrbs);
            playerManaPenalty = PlayerController.Instance.manaPenalty;
            writer.Write(playerManaPenalty);
            playerOrbShard = PlayerController.Instance.orbShard;
            writer.Write(playerOrbShard);
            playerOrb0fill = 
            playerManaPenalty = PlayerController.Instance.manaPenalty;
            writer.Write(playerManaPenalty);
PlayerController.Instance.manaOrbsHandler.orbFills[0].fillAmount;
            writer.Write(playerOrb0fill);
            playerOrb1fill = PlayerController.Instance.manaOrbsHandler.orbFills[1].fillAmount;
            writer.Write(playerOrb1fill);
            playerOrb2fill = PlayerController.Instance.manaOrbsHandler.orbFills[2].fillAmount;
            writer.Write(playerOrb2fill);

            playerUnlockedWallJump = PlayerController.Instance.unlockedWallJump;
            writer.Write(playerUnlockedWallJump);
            playerUnlockedDash = PlayerController.Instance.unlockedDash;
            writer.Write(playerUnlockedDash);
            playerUnlockedVarJump = PlayerController.Instance.unlockedVarJump;
            writer.Write(playerUnlockedVarJump);

            playerUnlockedSideCast = PlayerController.Instance.unlockedSideCast;
            writer.Write(playerUnlockedSideCast);
            playerUnlockedUpCast = PlayerController.Instance.unlockedUpCast;
            writer.Write(playerUnlockedUpCast);
            playerUnlockedDownCast = PlayerController.Instance.unlockedDownCast;
            writer.Write(playerUnlockedDownCast);

            playerPosition = PlayerController.Instance.transform.position;
            writer.Write(playerPosition.x);
            writer.Write(playerPosition.y);

            lastScene = SceneManager.GetActiveScene().name;
            writer.Write(lastScene);
        }
        Debug.Log("saved player data");
        

    }
    public void LoadPlayerData()
    {
        string savePath = Application.persistentDataPath + "/save.player.data";
        if(File.Exists(savePath) && new FileInfo(savePath).Length > 0)
        {
            using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.player.data")))
            {
                playerHealth = reader.ReadInt32();
                playerMaxHealth = reader.ReadInt32();
                playerHeartShards = reader.ReadInt32();
                playerMana = reader.ReadSingle();
                playerHalfMana = reader.ReadBoolean();
                playerManaOrbs = reader.ReadInt32();
                playerOrbShard = reader.ReadInt32();
                playerOrb0fill = reader.ReadSingle();
                playerOrb1fill = reader.ReadSingle();
                playerOrb2fill = reader.ReadSingle();
                playerManaPenalty = reader.ReadInt32();
                playerExcessMana = reader.ReadSingle();
                playerManaShards = reader.ReadInt32();

                playerUnlockedWallJump = reader.ReadBoolean();
                playerUnlockedDash = reader.ReadBoolean();
                playerUnlockedVarJump = reader.ReadBoolean();

                playerUnlockedSideCast = reader.ReadBoolean();
                playerUnlockedUpCast = reader.ReadBoolean();
                playerUnlockedDownCast = reader.ReadBoolean();

                playerPosition.x = reader.ReadSingle();
                playerPosition.y = reader.ReadSingle();

                lastScene = reader.ReadString();

                SceneManager.LoadScene(lastScene);
                PlayerController.Instance.transform.position = playerPosition;
                PlayerController.Instance.halfMana = playerHalfMana;
                PlayerController.Instance.manaPenalty = playerManaPenalty;
                PlayerController.Instance.Health = playerHealth;
                PlayerController.Instance.maxHealth = playerMaxHealth;
                PlayerController.Instance.heartShards = playerHeartShards;
                PlayerController.Instance.Mana = playerMana;
                PlayerController.Instance.manaOrbs = playerManaOrbs;
                PlayerController.Instance.orbShard = playerOrbShard;
                PlayerController.Instance.manaOrbsHandler.orbFills[0].fillAmount = playerOrb0fill;
                PlayerController.Instance.manaOrbsHandler.orbFills[1].fillAmount = playerOrb1fill;
                PlayerController.Instance.manaOrbsHandler.orbFills[2].fillAmount = playerOrb2fill;
                PlayerController.Instance.excessMana = playerExcessMana;
                PlayerController.Instance.manaShards = playerManaShards;

                PlayerController.Instance.unlockedWallJump = playerUnlockedWallJump;
                PlayerController.Instance.unlockedDash = playerUnlockedDash;
                PlayerController.Instance.unlockedVarJump = playerUnlockedVarJump;

                PlayerController.Instance.unlockedSideCast = playerUnlockedSideCast;
                PlayerController.Instance.unlockedUpCast = playerUnlockedUpCast;
                PlayerController.Instance.unlockedDownCast = playerUnlockedDownCast;
            }
            Debug.Log("load player data");
            Debug.Log(playerHalfMana);
        }
        else
        {
            Debug.Log("File doesnt exist");
            PlayerController.Instance.halfMana = false;
            PlayerController.Instance.manaPenalty = 0;
            PlayerController.Instance.Health = PlayerController.Instance.maxHealth;
            PlayerController.Instance.Mana = 0.5f;
            PlayerController.Instance.heartShards = 0;

            PlayerController.Instance.unlockedWallJump = false;
            PlayerController.Instance.unlockedDash = false;
            PlayerController.Instance.unlockedVarJump = false;
        }
    }

    #endregion

    #region enemy stuff
    public void SaveShadeData()
    {
        using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.shade.data")))
        {
            sceneWithShade = SceneManager.GetActiveScene().name;
            shadePos = Shade.Instance.transform.position;
            shadeRot = Shade.Instance.transform.rotation;

            writer.Write(sceneWithShade);

            writer.Write(shadePos.x);
            writer.Write(shadePos.y);

            writer.Write(shadeRot.x);
            writer.Write(shadeRot.y);
            writer.Write(shadeRot.z);
            writer.Write(shadeRot.w);
        }
    }
    public void LoadShadeData()
    {
        string savePath = Application.persistentDataPath + "/save.shade.data";
        if(File.Exists(savePath) && new FileInfo(savePath).Length > 0)
        {
            using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.shade.data")))
            {
                sceneWithShade = reader.ReadString();
                shadePos.x = reader.ReadSingle();
                shadePos.y = reader.ReadSingle();

                float rotationX = reader.ReadSingle();
                float rotationY = reader.ReadSingle();
                float rotationZ = reader.ReadSingle();
                float rotationW = reader.ReadSingle();
                shadeRot = new Quaternion(rotationX, rotationY, rotationZ, rotationW);
            }
            Debug.Log("Load shade data");
        }
        else
        {
            Debug.Log("Shade doesnt exist");
        }
    }

    public void SaveBossData()
    {
        if (!File.Exists(Application.persistentDataPath + "/save.boss.data")) //if file doesnt exist, well create the file
        {
            BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.boss.data"));
        }

        using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.boss.data")))
        {
            THKDefeated = GameManager.Instance.THKDefeated;
            writer.Write(THKDefeated);
        }
    }

    public void LoadBossData()
    {
        if (File.Exists(Application.persistentDataPath + "/save.Boss.data"))
        {
            using (BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.boss.data")))
            {
                THKDefeated = reader.ReadBoolean();

                GameManager.Instance.THKDefeated = THKDefeated;
            }
        }
        else
        {
            Debug.Log("Boss doesnt exist");
        }
    }
    #endregion
}

d. Updating the AddManaOrb and OrbShard scripts

We also have to update the scripts for the heart and orb shards, so that they use our newly-named variables instead of the old ones.

AddManaOrb.cs

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

public class AddManaOrb : MonoBehaviour
{
    [SerializeField] GameObject particles;
    [SerializeField] GameObject canvasUI;

    [SerializeField] OrbShard orbShard;

    bool used;

    // Start is called before the first frame update
    void Start()
    {
        if (PlayerController.Instance.manaOrbs >= 3)
        if (PlayerController.Instance.excessMana >= PlayerController.Instance.ExcessMaxMana)
        {
            Destroy(gameObject);
        }
    }

    private void OnTriggerEnter2D(Collider2D _collision)
    {
        if (_collision.CompareTag("Player") && !used)
        {
            used = true;
            StartCoroutine(ShowUI());
        }
    }
    IEnumerator ShowUI()
    {
        GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity);
        Destroy(_particles, 0.5f);
        yield return new WaitForSeconds(0.5f);
        gameObject.GetComponent<SpriteRenderer>().enabled = false;

        canvasUI.SetActive(true);
        orbShard.initialFillAmount = PlayerController.Instance.orbShard * 0.34f;
        orbShard.initialFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit;
        PlayerController.Instance.orbShardmanaShards++;
        orbShard.targetFillAmount = PlayerController.Instance.orbShard * 0.34f;
        orbShard.targetFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit;

        StartCoroutine(orbShard.LerpFill());

        yield return new WaitForSeconds(2.5f);
        SaveData.Instance.SavePlayerData();
        canvasUI.SetActive(false);
        Destroy(gameObject);
    }
}

OrbShard.cs

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

public class OrbShard : MonoBehaviour
{
    public Image fill;

    public float targetFillAmount;
    public float lerpDuration = 1.5f;
    public float initialFillAmount;
    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {

    }
    public IEnumerator LerpFill()
    {
        float elapsedTime = 0f;

        while (elapsedTime < lerpDuration)
        {
            elapsedTime += Time.deltaTime;
            float t = Mathf.Clamp01(elapsedTime / lerpDuration);

            float lerpedFillAmount = Mathf.Lerp(initialFillAmount, targetFillAmount, t);
            fill.fillAmount = lerpedFillAmount;

            yield return null;
        }

        fill.fillAmount = targetFillAmount;

        if (fill.fillAmount == 1)
        {
            PlayerController.Instance.manaOrbsexcessMaxManaUnits++;
            PlayerController.Instance.orbShardmanaShards = 0;
        }
    }
}

e. Updating the old health and mana scripts

Since we removed certain variables on PlayerController with the changes above, we’ll also need to update our old health and mana UI scripts to stop using the old variables.

You can also delete these scripts if you don’t want to update them.

If you are keeping the scripts, we will also add a [System.Obsolete] to the class, so that Unity shows a warning whenever we use the script.

ManaOrbsHandler.cs

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

[System.Obsolete]
public class ManaOrbsHandler : MonoBehaviour
{
    public bool usedMana;
    public List<GameObject> manaOrbs;
    public List<Image> orbFills;

    public float countDown = 3f;
    float totalManaPool;
    // Start is called before the first frame update
    void Start()
    {
        for(int i = 0; i < PlayerController.Instance.manaOrbsexcessMaxManaUnits; i++)
        {
            manaOrbs[i].SetActive(true);
        }
    }

    // Update is called once per frame
    void Update()
    {
        for (int i = 0; i < PlayerController.Instance.manaOrbsexcessMaxManaUnits; i++)
        {
            manaOrbs[i].SetActive(true);
        }
        CashInMana();
    }
    public void UpdateMana(float _manaGainFrom)
    {
        for(int i = 0; i < manaOrbs.Count; i++)
        {
            if (manaOrbs[i].activeInHierarchy && orbFills[i].fillAmount < 1)
            {
                orbFills[i].fillAmount += _manaGainFrom;
                break;
            }
        }
    }
    void CashInMana()
    {
        if(usedMana && PlayerController.Instance.Mana <=1)
        {
            countDown -= Time.deltaTime;
        }

        if(countDown <= 0)
        {
            usedMana = false;
            countDown = 3;

            totalManaPool = (orbFills[0].fillAmount += orbFills[1].fillAmount += orbFills[2].fillAmount) * 0.33f;
            float manaNeeded = 1 - PlayerController.Instance.Mana;

            if(manaNeeded > 0)
            {
                if(totalManaPool >= manaNeeded)
                {
                    PlayerController.Instance.Mana += manaNeeded;
                    for(int i = 0; i < orbFills.Count; i++)
                    {
                        orbFills[i].fillAmount = 0;
                    }

                    float addBackTotal = (totalManaPool - manaNeeded) / 0.33f;
                    while(addBackTotal > 0)
                    {
                        UpdateMana(addBackTotal);
                        addBackTotal -= 1;
                    }
                }
                else
                {
                    PlayerController.Instance.Mana += totalManaPool;
                    for (int i = 0; i < orbFills.Count; i++)
                    {
                        orbFills[i].fillAmount = 0;
                    }
                }
            }
        }
    }
}

HeartController.cs

using UnityEngine;
using UnityEngine.UI;

[System.Obsolete]
public class HeartController : MonoBehaviour
{
    private GameObject[] heartContainers;
    private Image[] heartFills;
    public Transform heartsParent;
    public GameObject heartContainerPrefab;
    // Start is called before the first frame update
    void Start()
    {
        heartContainers = new GameObject[PlayerController.Instance.maxTotalHealth];
        heartFills = new Image[PlayerController.Instance.maxTotalHealth];

        PlayerController.Instance.onHealthChangedCallback += UpdateHeartsHUD;
        InstantiateHeartContainers();
        UpdateHeartsHUD();
    }

    // Update is called once per frame
    void Update()
    {
        
    }
    void SetHeartContainers()
    {
        for(int i = 0; i < heartContainers.Length; i++)
        {
            if(i < PlayerController.Instance.maxHealth)
            {
                heartContainers[i].SetActive(true);
            }
            else
            {
                heartContainers[i].SetActive(false);
            }
        }
    }
    void SetFilledHearts()
    {
        for (int i = 0; i < heartFills.Length; i++)
        {
            if (i < PlayerController.Instance.health)
            {
                heartFills[i].fillAmount = 1;
            }
            else
            {
                heartFills[i].fillAmount = 0;
            }
        }
    }
    void InstantiateHeartContainers()
    {
        for(int i = 0; i < PlayerController.Instance.maxTotalHealth; i++)
        {
            GameObject temp = Instantiate(heartContainerPrefab);
            temp.transform.SetParent(heartsParent, false);
            heartContainers[i] = temp;
            heartFills[i] = temp.transform.Find("HeartFill").GetComponent<Image>();
        }
    }
    void UpdateHeartsHUD()
    {
        SetHeartContainers();
        SetFilledHearts();
    }
}

4. Setting up the New Health UI

Now that we have the new UIHealth script, let’s set up the UI element in our Canvas.

a. New heart sprites

Firstly, we will be replacing the old red heart sprite and outline we have been using:

Heart Container

With the following sprite sheet that is contained in a single file:

Heart Sprites
Right-click this image and select Save as to save this image onto your device, then upload it onto your Unity project.

There are several reasons why we are changing the file being used to the new sprite sheet:

  1. Sprite sheets are more memory efficient. It is much easier for the engine to load and manage 1 image, as opposed to 2.
  2. Making the sprite white means that we can later tint it to any other colour we want using the sprite renderer. This means that we won’t have to import a separate blue sprite for the blue heart.
  3. The new sprite has no outline, because we want to dynamically generate it using Unity components. This allows our interface to not just display the outline of an entire heart, but also half or a third of a heart.
Half hearts in the new UI
The new heart outlines are dynamically generated, allowing for more possibilities.

After uploading, slice the sprite into 2 using the Sprite Editor, and we will be ready to set up our health UI.

b. Creating the new heart prefab

With the new heart sprites set up, we will now be able to create a new heart prefab that will be replacing the old one. As demonstrated above, the new heart prefab will have its outline be dynamically generated, so it can be halved, quartered, and drawn up in all kinds of interesting ways based on the way you have set up the Fill Type in its image component.

Horizontal fill heart outline
Radial fill heart outline

To begin, let’s create a new folder at Prefabs/UI to store our hearts prefab. I would suggest moving the old heart prefab (Heart Container) inside here as well.

Then, create a new prefab called Heart Regular with a 64×64 Image component inside. This new Image component should use the white sprite on the left.

  • Set the sprite to whatever colour you want, and set the Alpha in its Colour attribute to a low value like 0.2, so that the object is mostly see-through.
  • Then, add an Outline component to it, and you should get a very nice outline effect that will serve as the background for your heart container.
Dynamically generating an outline.
We use the Outline component to dynamically generate an outline.

Remember to ensure that you have a Rect Transform component on your prefab instead of a Transform as well, because this prefab will be used on the Canvas. If it only has a Transform, you can add a Rect Transform component to convert it.

How to convert a Transform to Rect Transform
How to convert a Transform to Rect Transform.

Once that is done, set the Image Type on the Image component to filled, and you will be able to adjust the newly-exposed Fill attribute to adjust the size of your heart’s outline.

Horizontal fill heart outline
Set the Image Type to fill, and you will be able to adjust the size of your outline.
Radial fill heart outline
There are many Fill Types you can choose from for your outline as well.

Next, create another empty GameObject named Fill, parented under your Heart Regular GameObject, and add an Image component to it. Then, set its Anchor Presets to Stretched (make sure to Alt + Click so that it covers the entire parent).

Finally, assign the heart sprite on the right to it, and you should have a heart prefab that is ready for use.

Completed heart prefab
The completed heart prefab. Remember to colour the sprite red on the Sprite Renderer as well!

c. Creating the new blue heart prefab

Since our PlayerController and UIHealth scripts now support additional blue health, we’ll also need to create a blue heart prefab as well. The blue heart prefab is much simpler, because it does not need a Fill child GameObject, since they cannot be empty.

Blue heart prefab
The blue prefab simply has an Image component with an Outline. It does not have a Fill child GameObject because you cannot refill blue hearts.

It consists of a Image component, and an Outline component in the same GameObject. Remember to set the Image Type of the Image component to Fill as well, so that our UIHealth script will be able to adjust it later on.

d. Setting up the new health UI

With the new heart sprites uploaded and set up, we’re now ready to set up our new health UI. Open up the Canvas prefab that we are using for our in-game HUD UI, then add a new empty GameObject directly under it, naming it Health UI.

This will be replacing the Hearts Parents object that we have been using to put our hearts under.

Make sure you right-click the Canvas and select the empty GameObject from there. This will ensure that your empty GameObject has a RectTransform instead of a Transform. Otherwise, it will not show up later.

New Health UI GameObject
We add a new Health UI GameObject to replace the old Hearts Parent GameObject.

Then, remove the HeartController and UIAudio components under the same Canvas element, and delete the Hearts Parent GameObject that it manages.

Removing the old components
The HeartController and UIAudio components are no longer needed.

On the Health UI GameObject:

  1. Add a Horizontal Layout Group with the Height checkbox of Control Child Size, Use Child Scale and Child Force Expand checked.
  2. Add the UIHealth component, and assign the Health Regular prefab to Health Unit Prefab; as well as the Health Blue prefab to Excess Health Unit Prefab.
  3. Leave the Container field empty, and set the Fill variable to “Fill” (remember that we named the fill GameObject “Fill” on our Heart Regular prefab).
Things to assign to UIHealth
What to assign to UIHealth.

Finally, split the Anchor of the Health UI GameObject so it takes up the top portion of the screen, and it should be ready for use.

Anchor values for Health UI
Having a split anchor ensures that the component is responsive.

If you’d like, you can also add a few copies of the heart and heart blue prefabs under your Health UI GameObject, so that you can see where the hearts will appear in-game. These GameObjects will be automatically removed by the UIHealth script when the game starts.

5. Setting up the new mana UI

Now that the health UI is done, let’s move on to setting up the mana.

a. The Mana UI object

Let’s go back to the in-game HUD canvas prefab, and create a new Mana UI GameObject under it. Unlike the Health UI GameObject, it will not be an empty GameObject.

The new mana UI
Our GameObject has an Image component and the UIMana component. The Canvas Group is added by UIMana. Notice also that Preserve Aspect is checked.

When placing the mana UI, make sure to split the anchors for it as well:

Split anchors for mana UI
The split anchor ensures that it adapts to different screens.

To create the container sprite for it, we create another GameObject parented under the Mana UI GameObject called Overlay. This GameObject will have an Image component with a Circle, set to a transparency (i.e. Alpha) of about 0.13; as well as an Outline component like the backing for the Heart prefabs.

Overlay for the mana orb
The overlay will serve as the container for our mana orb.

Such a setup allows our Overlay to serve as a very nice-looking container. And the UIMana script can replace the Circle sprite assigned to it with a different sprite when our mana is halved as well.

Overlay serving as a container
With such a setup, our UIMana script can adjust the fill of the Mana UI, and replace the sprite of the overlay when needed.

You can download the broken circle sprite used to denote the mana penalty by saving the image (Right-click > Save image) below:

Broken Circle

b. Additional orbs

Now, let’s create the additional mana orbs that contain our excess mana. We will create an empty GameObject called Excess Orbs, parented under the Mana UI.

Under the Excess Orbs, we create 3 smaller orbs. The smaller orbs will have the same setup as our main Mana UI object—with a circle and an overlay over it.

Excess mana orbs hierarchy
Like the main orb, the smaller orbs have a circle with an overlay under them.

The tricky part about setting up the extra orbs, is how to place the anchors to make them responsive. I’ve experimented with several, and found that the following setup works best.

Firstly, here are the Anchor values for the Excess Orbs object:

Excess Orbs location
The GameObject is anchored to the left of our main orb.

And here are the anchors for the mini orbs that are parented to the Excess Orbs GameObject:

The suggested anchor points for the excess orbs.

c. Setting up the UIMana component

Now, let’s go back to the UIMana component on the main Mana UI object, and assign all the empty slots on it.

How to assign UI mana components
Now that we have our mana UI setup, we can proceed to assign our UIMana component.

And our mana UI is ready-to-use!

6. Finishing touches

Now that everything is set up, we’ll need to add some finishing touches to get everything working.

a. Linking UIHealth and UIMana to UIManager

To get both our UI to work, we’ll just need to assign our Health UI and Mana UI GameObjects to our UIManager GameObject (modify the Canvas prefab, not the Canvas on the Scene).

Assigning to UIManager
We’ll need to assign our new UI elements to the UIManager.

b. Setting your game resolution properly

Because we are not using Unity’s new UI Toolkit for our UI, it is not possible for our redesigned UI to be completely adaptive to all screen sizes.

No matter how you place the Anchors, it’s not going to be able to adapt to all resolutions.

New UI screen resolutions
There are still some screen sizes where the UI will display weird, but at least now our UI doesn’t get cut off like before.

Hence, it is important to ensure that, when you build your game, the Supported Aspect Ratios field under your Player Settings window is set. This makes Unity force the game into the Aspect Ratios that you’ve selected, ensuring that your game doesn’t start under resolutions where the UI breaks.

Supported Aspect Ratios
Find this under File > Build Settings > Player Settings.

Of course, this means that you have to ensure that when you are building your game, you use the same resolution for your Game view so that you will get the same UI when you test.

Game view aspect ratios
Make sure you test your game in the correct Aspect Ratios.

The final piece of the puzzle is making use of the Canvas Scaler—it is a component that should be found on wherever your Canvas is.

Set it to Scale With Screen Size, and test until you find a resolution where the UI looks good. The Canvas Scaler ensures that, even in bigger screens such as 4k UHD, your UI elements don’t appear too small.

The Canvas Scaler set to scale with screen size for a 16:9 aspect ratio.
The Canvas Scaler is your best friend.

7. Conclusion

This about wraps it up for Part 13! In the next part, we will be cleaning up the codes for our Pickups, and we should be ready to move into building a very robust, new-and-improved save system.

Silver-tier Patrons can download the project files as well.