Metroidvania Part 14

Creating a Metroidvania (like Hollow Knight) — Part 14: Modular Pickup System

This article is a part of the series:
Creating a Metroidvania (like Hollow Knight) in Unity

This article will be free until the video is released.

In Part 14, we are going to be improving upon the pickup system, and the UI overlay that pops up when you pick up an item. We are also going to be using the hit stop system that we made in Part 11 for the UI interlude that happens whenever we pick up an item.

  1. The problem with the current pickups
  2. The new Pickup system
    1. Class Hierarchy for Pickups
    2. Implementing the new Pickup scripts
    3. Implementing InstantPickup
    4. Creating the excess health pickup
  3. Re-implementing our old pickups
    1. Introducing the PopupPickup
  4. The UnlockAbilityPickup script
    1. Improving the ability unlock system on PlayerController
    2. Updating the SaveData script with ability updates
    3. Updating the InventoryManager script
    4. Deleting the old unlock pickup scripts
    5. Creating the UnlockAbilityPickup script
    6. Retooling our unlock prefabs
  5. The UpgradePickup script
    1. Creating the UIPickupNotification script
    2. Creating the UpgradePickup script
    3. Retooling our upgrade prefabs
  6. Cleaning up the old scripts
  7. Conclusion

1. The problem with the current pickups

The current way that the pickups are coded in the project are suboptimal. This is because we have a different script for each pickup, and none of these scripts are related through a parent class. What this causes us to end up with, are scripts that are almost 99% similar.

Take the UnlockDash and UnlockDownCast scripts for example. They are virtually identical.

UnlockDash.cs

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

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

    bool used;
    // Start is called before the first frame update
    void Start()
    {
        if (PlayerController.Instance.unlockedDash)
        {
            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);

        yield return new WaitForSeconds(4f);
        PlayerController.Instance.unlockedDash = true;
        SaveData.Instance.SavePlayerData();
        canvasUI.SetActive(false);
        Destroy(gameObject);
    }
}

UnlockDownCast.cs

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

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

    bool used;
    // Start is called before the first frame update
    void Start()
    {
        if (PlayerController.Instance.unlockedDownCast)
        {
            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);

        yield return new WaitForSeconds(4f);
        PlayerController.Instance.unlockedDownCast = true;
        SaveData.Instance.SavePlayerData();
        canvasUI.SetActive(false);
        Destroy(gameObject);
    }
}

This is not only a waste of code—it also makes it difficult to deploy any global updates or patches that affect all pickups. Because we have 6 pickup scripts that unlock abilities, if we wanted to—for instance—change the UI animation for all pickups, we’ll have to update all 6 scripts similarly.

Obviously, this is not the most conducive for game development—especially if you have dozens or hundreds of pickups.

2. The new Pickup system

To this end, we are going to be reorganising our pickup scripts so that code for their behavioural logic is not replicated across multiple scripts. This can be done by setting up a more organised hierarchical structure for all pickup scripts.

a. Class Hierarchy for Pickups

Instead of having a separate script for every single pickup we have in the game, we’ll create 3 scripts that will help us handle the behaviour of all pickups in the game. This simplifies our codebase, and ensures that when we want to make mechanical changes to all our pickups in future, we don’t have to update all of the scripts.

The Pickup scripts that we will have in the game will be arranged in the following order:

Pickup class hierarchy
We will condense our current 8 pickup scripts into the 5 above, and implement more functionality than before.

Each of these scripts will serve a very specific purpose, and are coded in such a way that we won’t have to repeat the logic across multiple scripts:

  • The Pickup class handles the base logic of all pickups in our game, i.e. the animation of the pickup, the behaviour of being picked up, as well as the application of the pickup’s effects.
  • The InstantPickup will be a component that can be used for all pickups that grant instantaneous effects, such as blue health, or health and mana pickups (if you want to implement those in your game). They can also be used for currency pickups (when we eventually implement it).
  • The PopupPickup will contain functionality for all pickups that stop the game and show a UI overlay after they are picked up, such as the health / mana upgrades, or ability unlocks.
    • Under the PopupPickup, we will have the UnlockAbilityPickup, which is meant to replace our old scripts: UnlockDash, UnlockDownCast, UnlockSideCast, UnlockUpCast, UnlockVariableJump and UnlockWallJump.
    • The UpgradePickup is also under PopupPickup, and it will replace the old OrbShard and HeartShards scripts, as well as the AddManaOrbs and IncreaseMaxHealth scripts.

b. Implementing the new Pickup scripts

The first script that we will be implementing is the Pickup script:

Pickup.cs

using UnityEngine;
using System.Collections;

public abstract class Pickup : MonoBehaviour
{

    protected bool used; // Used to track if the pickup has been picked up.

    [Tooltip("How long after touching the pickup before the Use() function fires.")]
    public float useDelay = 0f;

    [Tooltip("How long after using the pickup before the Used() function fires.")]
    public float usedDelay = 0f;

    // Struct to allow us to set a bobbing animation for the pickup.
    [System.Serializable]
    public struct Animation
    {
        public float frequency;
        public Vector3 direction;
        public Vector3 torque;

        [Tooltip("Effect that plays when the pickup is touched.")]
        public ParticleSystem destroyEffectPrefab; // Effect that plays when pickup is picked up.
        [Tooltip("Effect that plays on the target affected by the pickup.")]
        public ParticleSystem targetEffectPrefab; // Effect that plays on the target picking this up.
    }
    public Animation animation = new Animation
    {
        frequency = 2f,
        direction = new Vector2(0, 0.3f)
    };
    Vector3 initialPosition;
    float initialOffset;

    protected virtual void Update()
    {
        // Handles the bobbing animation.
        transform.position = initialPosition + animation.direction * Mathf.Sin((Time.time + initialOffset) * animation.frequency);
        transform.Rotate(animation.torque * Time.deltaTime);
    }

    protected virtual void Start()
    {
        // Settings for the bobbing animation.
        initialPosition = transform.position;
        initialOffset = Random.Range(0, animation.frequency);
    }

    protected virtual void OnTriggerEnter2D(Collider2D other)
    {
        if (used) return; // Don't allow pickup if its already used.

        // Otherwise, only if the player is touching, begin the pickup process.
        if (other.TryGetComponent(out PlayerController p))
        {
            StartCoroutine(HandleUse(p));
        }
    }

    // Overrideable function, determines what happens when we first touch the pickup.
    public virtual void Touch(PlayerController p)
    {
        // Play the effect on Pickup.
        if (animation.destroyEffectPrefab)
        {
            ParticleSystem fx = Instantiate(animation.destroyEffectPrefab, transform.position, Quaternion.identity);
            Destroy(fx, fx.main.duration); // Ensure that this effect is cleaned up if it is not properly set up.
        }
    }

    // This is where you implement the effects of the item.
    public virtual void Use(PlayerController p)
    {
        used = true;
        if(animation.targetEffectPrefab)
        {
            ParticleSystem fx = Instantiate(animation.targetEffectPrefab, p.transform);
            Destroy(fx, fx.main.duration); // Ensure that this effect is cleaned up if it is not properly set up.
        }
    }

    // What happens after the item is consumed.
    public virtual void Used(PlayerController p)
    {
        Destroy(gameObject);
    }

    protected virtual IEnumerator HandleUse(PlayerController p)
    {
        // Trigger the various events associated with Pickups.
        Touch(p);

        yield return Delay(useDelay);
        Use(p);

        yield return Delay(usedDelay);
        Used(p);
    }

    // This delay function is here to ensure that the wait still 
    protected virtual IEnumerator Delay(float duration)
    {
        WaitForSecondsRealtime r = new WaitForSecondsRealtime(.05f);
        while (duration > 0)
        {
            yield return r;
            if (!GameManager.Instance.isPaused)
                duration -= r.waitTime;
        }
    }
}

This new Pickup script has several advantages over the original:

  1. The current implementation has separate scripts (UnlockSideCast.cs, AddManaOrb.cs, IncreaseMaxHealth.cs, etc.) that share nearly identical functionality. The unified Pickup class eliminates this redundancy by providing a single, robust implementation of these common features.
All our Pickup scripts
Our original pickup scripts were all different scripts, but they do the same thing and share 99% of the same code.
  1. It adds an Animation attribute that allows us to easily set values to add animations onto our items.
Pickup animation attributes
Much more convenient than setting up an Animator and a bunch of Animation Clips for every pickup.
  1. There are also settings that we can adjust to delay the effect of the pickup. This will come in handy later on when we need to set delays before the popup pickups for our abilities and unlock take effect.
Pickup delay settings
This allows us to set a delay time before the pickup takes effect.
  1. The new Pickup script also provides overridable events Touch(), Use() and Used(), which we will be making use of later via overriding to allow us to tweak minor differences in behaviour between different kinds of pickups.

c. Implementing InstantPickup

One thing you will notice about the images we showed above, is that none of them actually show the use of the Pickup component, even though we were discussing the attributes that the Pickup script uses. This is because the Pickup script is marked abstract, which means that it is not designed to be used directly. Instead, to use it, we will need to create another script that subclasses it:

💡 Although we cannot directly use abstract components, they act as blueprints that define a common structure and shared behaviors for other scripts to follow. By forcing derived scripts to implement certain methods, it ensures consistency across different components and centralises reusable logic.

InstantPickup.cs

using UnityEngine;

public class InstantPickup : Pickup
{
    [Header("Rewards")]
    public int health = 0;
    public int excessHealth = 1;
    public float mana = 0;
    public float excessMana = 0;

    public override void Use(PlayerController p)
    {
        base.Use(p);
        if(!Mathf.Approximately(health, 0)) p.Health += health;
        if(!Mathf.Approximately(excessHealth, 0)) p.ExcessHealth += excessHealth;
        if(!Mathf.Approximately(mana, 0)) p.Mana += mana;
        if(!Mathf.Approximately(excessMana, 0)) p.ExcessMana += excessMana;
    }
}

The InstantPickup script is pretty straightforward. It makes use of the functionalities that were defined in pickup to implement its own behaviour—namely, the Use() function.

In this script, we define the rewards that we expect an instant pickup to be able to provide, and register them as variables in the component. Then, we override the Use() function, so that we can define the behaviour of the InstantPickup when it is picked up.

d. Creating the excess health pickup

With the InstantPickup script implemented, we can now create pickups for our game that apply their effects instantaneously without any cinematic, such as the excess health pickup. To do so, create a new GameObject on the Scene with the following components:

  1. Sprite Renderer
  2. Circle Collider
  3. Rigidbody
  4. Instant Pickup
Excess Health Pickup
Components for our Excess Health Pickup. Take note to adjust the Excess Health to 1 for this pickup. If you want the object to animate, you should also set the Direction attribute’s Y value to 0.3.

Then, add a couple of Light 2D components as children to the heart sprite, as well as an additional GameObject containing a Sprite Renderer to provide an outline to the heart. Your final heart pickup should look like the image below.

Excess Health Pickup
Children of the Excess Health component.

You can also add VFX prefabs to the Destroy Effect Prefab and Target Effect Prefab fields on the Instant Pickup component. The Destroy Effect Prefab attribute plays an effect on the object itself when it is destroyed, whereas the Target Effect Prefab plays an effect attached to the entity that picks up the object (i.e. the player).

Instant Pickup components
The new Instant Pickup component makes it easy to attach visual effects to the pickup.

Of course, you’ll also want to ensure that when you pickup the blue health, it shows up on the UI for your game.

Excess Health Pickup UI
Make sure that the pickup replenishes your blue health!

💡 If you find it cumbersome to create the new blue health pickup from scratch, you can also duplicate one of the existing pickups from the previous parts, and retool it into your extra health pickup.

3. Re-implementing our old pickups

In the earlier parts of this series, we implemented pickups that boosted your health and mana. These pickups have their behaviours spread over 4 different scripts: IncreaseMaxHealth and HeartShards manage the health boost; and AddManaOrb and OrbShard manage the mana boost.

We also implemented pickups that gave us new abilities.

All of these pickups have a similarity—they triggered a popup over the screen when they were picked up. Hence, we will create a new PopupPickup class to handle this for us.

a. Introducing the PopupPickup

The popup pickup works similarly to the InstantPickup script that we have just created, with the exception that it turns on a UI popup after it is picked up for an amount of time.

When it is picked up, it will:

  1. Pause the game for a duration equivalent to useDelay + usedDelay.
  2. Open the popup after useDelay.
  3. Close the popup after usedDelay.

To achieve the activation and deactivation of the popups, it overrides the Touch(), Use() and Used() functions in the parent pickup class.

PopupPickup.cs

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

public abstract class PopupPickup : Pickup
{

    [Header("Pop Up Settings")]
    public UIScreen popUp; // Popup that appears when pickup is picked up.

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        popUp.gameObject.SetActive(false);
    }

    // Overrideable function, determines what happens when we first touch the pickup.
    public override void Touch(PlayerController p)
    {
        base.Touch(p);

        // Create the stop effect.
        GameManager.Stop(useDelay + usedDelay);
    }

    public override void Use(PlayerController p)
    {
        base.Use(p);
        popUp.Activate(false);
    }

    // What happens after the item is consumed.
    public override void Used(PlayerController p)
    {
        popUp.Deactivate(.2f);
        base.Used(p);
    }

    void Reset()
    {
        useDelay = 0.5f;
        usedDelay = 4f;
        popUp = GetComponentInChildren<UIScreen>();
    }
}

Since the PopupPickup will be used to support 2 kinds of pickups—the ones that increase our max health and mana, as well as the ones that give us upgrades, the script is also marked abstract, since it doesn’t actually give the player anything except trigger a popup.

4. The UnlockAbilityPickup script

We are first going to use PopupPickup to replace our unlock ability scripts, but before we do that, let’s first improve on the way that unlocked abilities are managed on our PlayerController script.

a. Improving the ability unlock system on PlayerController

Currently, on our PlayerController, our unlocks are managed by a series of booleans, which makes it very difficult for us to handle all the unlocking of abilities by the pickups in one single script.

PlayerController unlocks
This could be organised in a better way.

Hence, what we are going to do instead, is to combine them into a single enum type called Abilities. You will need to make the following changes on your script:

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();
    [System.Obsolete] public OnHealthChangedDelegate onHealthChangedCallback;

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

    [Header("Mana Settings")]

    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;
    [System.Flags]
    public enum Abilities : byte
    {
        dash = 1, variableJump = 2, wallJump = 4,
        upCast = 8, sideCast = 16, downCast = 32
    }
    [Header("Misc")] public Abilities abilities;

    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>();



        audioSource = GetComponent<AudioSource>();

        gravity = rb.gravityScale;

        Mana = mana;


        Health = maxHealth;

        SaveData.Instance.LoadPlayerData();

        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 (unlockedWallJumpabilities.HasFlag(Abilities.wallJump))
        {
            WallSlide();
            WallJump();
        }

        if (unlockedDashabilities.HasFlag(Abilities.dash))
        {
            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"))
                {

                    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)
        {

            // 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()
    {

        manaPenalty = 0f;

    }
    public int Health
    {
        get { return health; }
        set
        {
            if (health != value)
            {
                health = Mathf.Max(value, 0);
                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

            Mana -= Time.deltaTime * healManaCostPerSecond;
        }
        else
        {
            pState.healing = false;
            anim.SetBool("Healing", false);
            healTimer = 0;
        }
    }
    public float Mana
    {
        get { return mana; }
        set
        {

            // 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())) && unlockedSideCastabilities.HasFlag(Abilities.sideCast))
        {
            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;

            yield return new WaitForSeconds(0.35f);
        }

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

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

            Mana -= manaSpellCost;

            yield return new WaitForSeconds(0.35f);
        }

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

            downSpellFireball.SetActive(true);

            Mana -= manaSpellCost;

            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") && unlockedVarJumpabilities.HasFlag(Abilities.variableJump))
        {
            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);
    }
}

By organising our abilities inside of an enum, we create a much nicer interface for ourselves for the player’s abilities on the Inspector:

Enum Dropdown for Abilities
A much cleaner and organised interface than having 6 checkboxes.

b. Updating the SaveData script with ability updates

Because our old SaveData script uses the boolean variables we just removed in PlayerController, we need to update the SaveData script to save abilities in the new format as well.

Because we are updating the values that are saved in the SaveData script, this update will break your old existing saves, so the game will load janky. For more information about why this happens, check out this forum post.

In the next part of this series, we will be recoding our save system so that we won’t run into this issue again.

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 float playerExcessMana;
    public int playerManaShards;
    public float playerManaPenalty;
    public Vector2 playerPosition;
    public string lastScene;

    public bool playerUnlockedWallJump, playerUnlockedDash, playerUnlockedVarJump;
    public bool playerUnlockedSideCast, playerUnlockedUpCast, playerUnlockedDownCast;
    public PlayerController.Abilities playerAbilities;

    //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);
            playerManaPenalty = PlayerController.Instance.manaPenalty;
            writer.Write(playerManaPenalty);
            playerExcessMana = PlayerController.Instance.excessMana;
            writer.Write(playerExcessMana);
            playerManaShards = PlayerController.Instance.manaShards;
            writer.Write(playerManaShards);

            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);
            playerAbilities = PlayerController.Instance.abilities;
            writer.Write((int)playerAbilities);

            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();
                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();
                playerAbilities = (PlayerController.Abilities)reader.ReadInt32();

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

                lastScene = reader.ReadString();

                SceneManager.LoadScene(lastScene);
                PlayerController.Instance.transform.position = playerPosition;
                PlayerController.Instance.manaPenalty = playerManaPenalty;
                PlayerController.Instance.Health = playerHealth;
                PlayerController.Instance.maxHealth = playerMaxHealth;
                PlayerController.Instance.heartShards = playerHeartShards;
                PlayerController.Instance.Mana = playerMana;
                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;
                PlayerController.Instance.abilities = playerAbilities;
            }
        }
        else
        {
            Debug.Log("File doesnt exist");
            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;
            PlayerController.Instance.abilities = 0;
        }
    }

    #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
}

c. Updating the InventoryManager script

Similarly, the InventoryManager script also references to the old boolean variables that we have removed from the PlayerController.

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.manaShards * 0.34f;


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

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

d. Deleting the old unlock pickup scripts

Finally, we’ll also need to delete all our old pickup scripts, i.e.:

  • UnlockDownCast
  • UnlockUpCast
  • UnlockSideCast
  • UnlockVariableJump
  • UnlockWallJump
  • UnlockDash

Why, you ask? Because they refer to the old booleans we just replaced with an enum in our PlayerController script. If you wish to keep them around, you can update the scripts to use the enum and mark them as obsolete.

e. Creating the UnlockAbilityPickup script

Now that we have the enum set up on the player, we can use the same enum (PlayerController.Abilities) to create our UnlockAbilityPickup script:

UnlockAbilityPickup.cs

public class UnlockAbilityPickup : PopupPickup
{

    public PlayerController.Abilities unlocks;
    public bool destroyIfLearnt = true;

    protected override void Start()
    {
        base.Start();

        // If player already has the ability, destroy this pickup.
        if (destroyIfLearnt && PlayerController.Instance.abilities.HasFlag(unlocks))
            Destroy(gameObject);
    }

    public override void Used(PlayerController p)
    {
        PlayerController.Instance.abilities |= unlocks;
        base.Used(p);
    }
}

This is a very simple script that expands upon the PopupPickup script. All it does is, in the Start() function, it checks whether the player has already unlocked the abilities that this pickup script provides. If it does, the pickup is automatically destroyed if destroyIfLearnt is true.

In the Used() function, we use a bitwise operation to flip the abilities variable in the PlayerController instance, basically unlocking the ability for the player.

f. Retooling our unlock prefabs

Now that the script is set up, we will need to go back to every one of our unlock pickup prefabs, and assign a UIScreen component to each of their Canvas elements. In our new UnlockAbilityPickup component, the script is no longer responsible for activating and animating the UI that appears after the pickup happens—this is now handled by the UIScreen script that we made in Part 12:

New unlock pickup Canvas components
We let the UIScreen script handle all of our UI animations, so that we don’t have to write code on UnlockAbilityPickup to animate it. Remember to set Time Scale Mode to Nothing, as time will be paused when this is picked up.

💡 One good thing about having the UIScreen handle animation is that we can very easily configure, using the Time Scale Mode property, our UI to either be paused by the pause screen, or not. In our previous implementation, the item UI animations do not get affected by us pausing the game, which led to some very janky scenarios.

Once that is done, we should also replace the old unlock script with our new UnlockAbilityPickup component. Be sure to:

  1. Assign the parented Canvas under the prefab to Pop Up;
  2. Select the correct ability to unlock on the Unlocks drop down, and;
  3. Assign the old pickup effect in the Particles field to Destroy Effect Prefab;
New UnlockAbilityPickup component
Make sure to assign the old Canvas and Particles fields to the corresponding fields in the new UnlockAbilityPickup. Also make sure to set the correct unlock in the Unlocks dropdown.

5. The UpgradePickup script

Replacing the health and mana (orb) upgrade pickups will be a little more complex, because each of the upgrade pickups are controlled by 2 different scripts:

  1. The health increase pickup is handled by the IncreaseMaxHealth script, which provides the behaviour for the pickup; as well as the HeartShards script, which provides the UI overlay that fills up the circle that pops up.
  2. The mana increase pickup is handled by the AddManaOrb script, which provides the behaviour for the pickup; as well as the OrbShards script, which provides the UI overlay that fills up the circle that pops up.

We are going to replace the IncreaseMaxHealth and AddManaOrb script with UpgradePickup, since both of these scripts are very similar. For the HeartShards and AddManaOrb scripts, we will replace them with a new script called UIPickupNotification, which will be a subclass of the UIScreen script we created in Part 12.

a. Creating the UIPickupNotification script

Let’s first create the UIPickupNotification script, since our UpgradePickup relies on it. This will work similarly to the Canvas that we have on our UnlockAbilityPickup, but with some additional code to manage the animation of a circle filling up:

UIPickupNotification.cs

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

[RequireComponent(typeof(CanvasGroup))]
public class UIPickupNotification : UIScreen
{
    [Header("Fill Settings")]
    public Image fillObject;
    public float fillDelay = 0.5f;
    float duration, initialFill, finalFill;

    public void SetFill(float duration, float initialFill, float finalFill)
    {
        fillObject.fillAmount = this.initialFill = initialFill;
        this.duration = duration;
        this.finalFill = finalFill;
    }

    IEnumerator Fill()
    {
        WaitForSecondsRealtime w = new WaitForSecondsRealtime(updateFrequency);
        
        // Don't play as long as another animation is playing on this.
        while (isAnimating) yield return w;

        isAnimating = true;

        float elapsedTime = 0f;
        while (elapsedTime < duration)
        {
            yield return w;
            float timeScale = GetTimeScale();
            if (timeScale <= 0) continue;

            elapsedTime += w.waitTime * timeScale;
            float t = Mathf.Clamp01(elapsedTime / duration);

            float lerpedFillAmount = Mathf.Lerp(initialFill, finalFill, t);
            fillObject.fillAmount = lerpedFillAmount;
        }

        fillObject.fillAmount = finalFill;

        isAnimating = false;
    }

    // Override the activate function and call the coroutine to start the fill.
    public override IEnumerator Activate(float delay)
    {
        yield return base.Activate(delay);
        yield return new WaitForSecondsRealtime(Mathf.Max(updateFrequency, this.fillDelay));

        // Call the coroutine to start the fill.
        if (!Mathf.Approximately(initialFill, finalFill))
            StartCoroutine(Fill());
    }
}

The code is very similar to the LerpFill() function found in both HeartShards and OrbShards, and the Activate() function is overriden so that when the UIScreen is turned on, the filling animation is automatically started after a delay.

There is also a SetFill() function, which the UpgradePickup script will be using to tell this component where the fill should start, and where it should end.

b. Creating the UpgradePickup script

Now that the UIPickupNotification script is set up, we can write our UpgradePickup script:

UpgradePickup.cs

using Player = PlayerController;

public class UpgradePickup : PopupPickup
{

    public enum Type { health, mana }
    public Type type;

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();

        // Destroy this pickup if we are already maxed out.
        switch (type)
        {
            case Type.health:
                if (Player.Instance.maxHealth >= Player.Instance.maxTotalHealth)
                    Destroy(gameObject);
                break;
            case Type.mana:
                if (Player.Instance.excessMaxManaUnits >= Player.Instance.excessMaxManaUnitsLimit)
                    Destroy(gameObject);
                break;
        }
    }

    // What happens when the item is picked up.
    public override void Use(PlayerController p)
    {
        base.Use(p);
        UIPickupNotification uip = popUp as UIPickupNotification;
        if (!uip) return;

        switch(type)
        {
            case Type.health: default:
                uip.SetFill(
                    1f, Player.Instance.heartShards++ * 1f / Player.Instance.heartShardsPerHealth,
                    Player.Instance.heartShards * 1f / Player.Instance.heartShardsPerHealth
                );
                Player.Instance.ConvertHeartShards();
                break;
            case Type.mana:
                uip.SetFill(
                    1f, Player.Instance.manaShards++ * 1f / Player.Instance.manaShardsPerExcessUnit, 
                    Player.Instance.manaShards * 1f / Player.Instance.manaShardsPerExcessUnit
                );
                Player.Instance.ConvertManaShards();
                break;
        }
        
    }
}

This is, again, a pretty short script that is a combination of the OrbShard and HeartShards UI scripts, so that we don’t have 2 scripts that perform largely similar functionality.

By organising our scripts in this manner, we make our project easier to manage, as the functionality of each of the scripts is made clearer.

c. Retooling our upgrade prefabs

Like with the UnlockAbilityPickup prefabs, we will need to update the Canvas parented under our health and mana upgrade pickups:

Upgrade Pickup Canvas setup
Be sure to populate the Fill Object with the Fill GameObject parented under the Canvas. Remember to set the Time Scale Mode to Nothing as well.

Then, in the root object of the prefab, we will need to replace the old IncreaseMaxHealth and AddManaOrb components with the new UpgradePickup component:

New Increase Max Health prefab
Be sure to select the correct type of pick up in the Type field.

6. Cleaning up the old scripts

With that, our pickups should be properly upgraded to use the new system. Do test out all your pickups and ensure that they still work as per normal. If any of your existing pickups in your levels malfunction, make sure to revert them to the original prefab’s settings, as your old prefab settings might have overriden the new changes:

Reverting a prefab
Your existing prefab instances might still have the old prefab’s original settings.

Also, it would be good to mark your old HeartShards, OrbShard, IncreaseMaxHealth and AddManaOrb scripts as [Obsolete], so that we don’t accidentally use them again in the future.

IncreaseMaxHealth.cs

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

[System.Obsolete]
public class IncreaseMaxHealth : MonoBehaviour
{
    [SerializeField] GameObject particles;
    [SerializeField] GameObject canvasUI;

    [SerializeField] HeartShards heartShards;

    bool used;

    // Start is called before the first frame update
    void Start()
    {
        if (PlayerController.Instance.maxHealth >= PlayerController.Instance.maxTotalHealth)
        {
            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);
        heartShards.initialFillAmount = PlayerController.Instance.heartShards * 0.25f;
        PlayerController.Instance.heartShards++;
        heartShards.targetFillAmount = PlayerController.Instance.heartShards * 0.25f;

        StartCoroutine(heartShards.LerpFill());

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

HeartShards.cs

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

[System.Obsolete]
public class HeartShards : 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.maxHealth++;
            PlayerController.Instance.onHealthChangedCallback();
            PlayerController.Instance.heartShards = 0;
        }
    }
}

AddManaOrb

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

[System.Obsolete]
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.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.manaShards / PlayerController.Instance.manaShardsPerExcessUnit;
        PlayerController.Instance.manaShards++;
        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;

[System.Obsolete]
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.excessMaxManaUnits++;
            PlayerController.Instance.manaShards = 0;
        }
    }
}

7. Conclusion

With the new pickup system set up, we are now in a much better position to implement saving in the next part of our series.

Silver Patrons and above can download the project files as well.