Metroidvania series Part 15

Creating a Metroidvania (like Hollow Knight) — Part 15: Multi-Slot Save System

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

This post will be free until the video is released.

In Part 15 of our tutorial series, we will be working on implementing a new and improved save system that supports multiple slots for our game. This new save system will be more robust and less buggy than our old implementation, which used the deprecated (and less flexible) BinaryWriter class in C#, and had a multitude of problems as a result.

  1. Problems with the old save system
    1. Structural dependence of data
    2. Security risk from reading binary files
    3. Difficulty of viewing and debugging save data
  2. Bench Universal Save System
    1. UI for viewing save data
    2. More modular, less hardcoding
  3. Updating Pickup for Bench
    1. Marking Pickup to be saved
    2. Setting up savable pickups in the Editor
  4. Updating PlayerController for Bench
    1. Optimising PlayerStateList
    2. Updating pState in enemy scripts
    3. Adding the save and load functionality
  5. Replacing SaveData and updating GameManager
    1. Merging SaveData into GameManager
    2. Updating the Shade
    3. Letting MapManager save visited scenes
    4. Retooling our scripts
    5. Updating or removing the old SaveData script
  6. Updating our save point script
    1. Modifying our save point script
    2. Adding a Rest() method for PlayerController
    3. Modifying GameManager to save the last bench used
    4. Adding the rest animation
    5. Adding the Save Indicator
  7. Adding save slots in the Main Menu
    1. Adding the save slots screen
    2. Configuring the Save Slots

1. Problems with the old save system

Although the old save system was a working one, there were quite a few issues with it due to the rather inflexible way that it was coded.

a. Structural dependence of data

Our old save system using the SaveData script saved data into the deprecated BinaryWriter in a sequential format that—because of the way binary data is stored—didn’t leave room for us to add additional data in future.

Take the saving of data in the PlayerController script for example:

playerHealth = PlayerController.Instance.Health;
writer.Write(playerHealth);
playerMaxHealth = PlayerController.Instance.maxHealth;
writer.Write(playerMaxHealth);
playerHeartShards = PlayerController.Instance.heartShards;
writer.Write(playerHeartShards);

If we later wanted to save the player’s blue health as well, we would do something like this:

playerHealth = PlayerController.Instance.Health;
writer.Write(playerHealth);
playerBlueHealth = PlayerController.Instance.extraHealth;
writer.Write(playerBlueHealth);
playerMaxHealth = PlayerController.Instance.maxHealth;
writer.Write(playerMaxHealth);
playerHeartShards = PlayerController.Instance.heartShards;
writer.Write(playerHeartShards);

This would instantly break the reading of any existing save data that we had, because it changes the sequence in which the data is stored. Because of the structural inflexibility of binary data, it is not very conducive for being used in a game that is still in development, because you will very likely have to change your save data structure as you add new features.

b. Security risk from reading binary files

Microsoft has also recently removed the BinaryWriter from the latest version of C#, because of the inherent security issues that it has. According to ChatGPT:

Using a binary writer to persist data directly in binary form can introduce several security risks, particularly when the format is tightly coupled to internal program structures. Since the data is not self-describing or human-readable, it’s difficult to validate or sanitize before consumption.

If an attacker can tamper with the binary file or stream, they may craft malicious inputs that exploit assumptions in the code about field order, size, or expected ranges. For example, malformed lengths or corrupted object data could trigger buffer overflows, deserialization flaws, or logic errors, potentially leading to denial-of-service or even arbitrary code execution in poorly safeguarded systems.

In other words, binary files can allow the injection of malicious, unintended classes into our game. Hence, Microsoft phased the class out in favour of other formats that can save our data without exposing such security risks.

c. Single-slotted, non-modular design

Our current save system is designed in a very non-modular fashion—all the logic is contained within one class, which will make it very difficult to manage if we add too much content.

For example, the save system does not currently record whether a certain pickup has been picked up; and if we wanted to do this, it will be near impossible for the current save system to store the state of 100s of pickups scattered across the game.

The SaveData script also does not accommodate the saving and loading of data across multiple slots, which is something that is in Hollow Knight has:

Hollow Knight multiple save slots
Save slots in Hollow Knight.

d. Difficulty of viewing and debugging save data

Because the data that we save is stored in binary format, which looks like this:

            ?                ÝÿZ¿g/@Cave_1_1/@Cave_1

It is extremely difficult for us to debug the save system when any issues arise, as there is no convenient way to view the data besides using print() or Debug.Log() to output snippets of data into our Console window.

Due to all the problems listed above, I’ve spent almost a year developing a new save system that later became a Unity Asset Store asset called the…

2. Bench Universal Save System

This asset was originally just meant to be a save system that we could deploy for Part 15, but as it got developed more and more, the codebase became extremely complex, which would make it very hard to explain everything in an article (or even a video!).

Also, as the system got its features fleshed out, I also noticed that it became very possible for us to use a lot of the same scripts for the save systems of our other projects (such as our Farming RPG series), so I turned the save system I developed for this project into a Unity Asset.

a. UI for viewing save data

The Bench Universal Save System fixes a lot of the issues that were highlighted above, the most significant of it being that there is a UI for users to look through the save data from the Inspector window:

Bench Save System Inspector
Much easier to inspect our save data with this.

b. More modular, less hardcoding

Instead of concentrating all the save logic into a single script, functionality is split up over multiple classes that are easily extensible, which makes it very easy for us to customise them to fit the functionality that we want them to have.

3. Updating Pickup for Bench

The first script that we are going to reconfigure for saving is the Pickup class, as it is the easiest to implement saving for.

a. Marking Pickup to be saved

The Bench Universal Save System asset package that we are using is designed to be highly modular and very easy to use. To demonstrate how it works, let’s start by implementing saving on our pickups.

Since we want certain pickups in our game to only be usable once (e.g. health / mana upgrades, or ability unlocks), we need the game to save the state of these pickups once they are used. To this end, we’ll have to modify the Pickup script, so that their states can be saved:

Pickup.cs

using UnityEngine;
using System.Collections;
using Terresquall;

public abstract class Pickup : MonoBehaviourPersistentObject
{

    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);
        gameObject.SetActive(false);
    }

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

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData
    {
        public bool used;
    }

    public override PersistentObject.SaveData Save()
    {
        if (CanSave() && used)
            return new SaveData { used = used };

        return null;
    }

    public override bool Load(PersistentObject.SaveData data)
    {
        SaveData s = (SaveData)data;
        if (s != null && s.used)
        {
            Destroy(gameObject);
            return true;
        }
        return false;
    }
}

To enable saving on any component, the following changes were made to the script above:

  1. Add the using Terresquall; namespace. This will allow us to…
  2. Inherit from PersistentObject instead of MonoBehaviour. This lets the Bench save system know that all pickups will need their data saved when the game is saved.

Once that is done, we define what kind of data is saved when the game itself is saved. This is done by:

  1. Creating a new nested class called SaveData in Pickup (i.e. making the fully-qualified class name Pickup.SaveData). This determines the data that will be saved when the game is saved.
  2. Creating a Save() function in Pickup. This is used to determine how our object data maps to the SaveData class.
  3. Creating a Load() function in Pickup. This is used to determine how the saved data maps to the loaded object.

The Pickup class is relatively simple in this aspect, because it only has 1 variable that needs to be saved: used. Hence, the SaveData nested class, as well as the Save() and Load() functions in Pickup are relatively straightforward.

Notice also that in the Used() function, we replaced Destroy(gameObject) with gameObject.SetActive(false) instead. This is because if we destroy the GameObject, the save system will not pick up on it.

b. Setting up savable pickups in the Editor

Once the script has been updated, you will find that all pickups in your project will now have a new field called Save ID.

Pickup new Save ID
This will apply to all the pickups we made in the last part, because Pickup is the parent class of all of them.

To make your pickup’s used state save across different games, you will need to assign a unique Save ID to it. Make sure that no other GameObject in your whole game uses the same Save ID, because the Save ID is used to identify which save data belongs to which GameObject when a game is loaded. When you have 2 objects having the same Save ID, they will overwrite one another and cause bugs or errors.

Pickups that are left empty (i.e. not assigned a Save ID) will not be saved by the system. In our project, we will only want to assign a Save ID to UpgradePickup and UnlockAbilityPickup GameObjects, and let the rest of the pickups (i.e. the blue hearts) respawn whenever we reload the scene or reload the game.

💡 Need help generating a unique Save ID? Use the Generate New Save ID button (only available when the Save ID field is empty) to generate a unique string that will never clash with the others. If you want the Save ID to be easier to remember, you can replace the first few characters of the Save ID with a string describing the object.

4. Updating PlayerController for Bench

Like Pickup, we will have to make similar modifications to PlayerController so that the player’s data is saved as well, since SaveData is no longer working to record the player’s data. Before we do so, however, let’s optimise some of the data in the PlayerController script first:

a. Optimising PlayerStateList

The PlayerController script uses a component called PlayerStateList to track the different states that the player can be in throughout the game:

PlayerStateList
All the possible states the player character can be in.

While this helps to organise things somewhat, there is a more efficient way to organise all of these states directly inside of the PlayerController script by replacing the PlayerStateList component with an enum type that can be formatted into a dropdown.

Player State dropdown
This new enum list will be neater and more space-efficient.

We will also have to add a few functions to the PlayerController to check the state of each of these flags, as checking them manually will be quite lengthy:

  1. Is() allows us to check if a specific state is active, e.g. Is(State.healing).
  2. Set() allows us to set a state to true or false, e.g. Set(State.cutscene, true).
  3. Toggle() flips a flag from false to true, or vice versa, e.g. Toggle(State.alive).

Finally, we also update all of the parts of the code where the old PlayerStateList component is used, and replace them with one of the 3 functions above.

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;


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

    [System.Flags]
    public enum State
    {
        jumping = 1, dashing = 2, recoilingX = 4, recoilingY = 8,
        lookingRight = 16, invincible = 32, healing = 64, casting = 128,
        cutscene = 256, alive = 512
    }

    public State state;
    public bool Is(State s) { return state.HasFlag(s); }
    public void Set(State s, bool on)
    {
        if (on) state |= s;
        else state &= ~s;
    }
    public bool Toggle(State s)
    {
        state ^= s;
        return Is(s);
    }

    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;
        if (Health > 0) Set(State.alive, true);
        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.cutsceneIs(State.cutscene) || GameManager.Instance.isPaused) return;

        if (pState.aliveIs(State.alive))
        {
            HandleRestoreManaWithExcess();
            GetInputs();
            ToggleMap();
            ToggleInventory();
            UpdateJumpVariables();
            UpdateCameraYDampForPlayerFall();
            FlashWhileInvincible();
        } else return;

        UpdateJumpVariables();

        UpdateCameraYDampForPlayerFall();

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

        if (abilities.HasFlag(Abilities.wallJump))
        {
            WallSlide();
            WallJump();
        }

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

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

        if (pState.dashing || pState.healing) return;
        if (Is(State.dashing) || Is(State.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;
            Set(State.lookingRight, false);
        }
        else if (xAxis > 0)
        {
            transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y);
            pState.lookingRight = true;
            Set(State.lookingRight, true);
        }
    }

    private void Move()
    {
        if (pState.healingIs(State.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;
        Set(State.dashing, true);
        anim.SetTrigger("Dashing");
        audioSource.PlayOneShot(dashAndAttackSound);
        rb.gravityScale = 0;
        int _dir = pState.lookingRightIs(State.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;
        Set(State.dashing, false);
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }

    public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay)
    {
        pState.invincible = true;
        Set(State.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;
        Set(State.invincible, false);
        Set(State.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.lookingRightIs(State.lookingRight) ? 1 : -1;

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


    }
    void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilBoolState recoilState, 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;
            Set(recoilState, 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.recoilingXIs(State.recoilingX))
        {
            if (pState.lookingRightIs(State.lookingRight))
            {
                rb.velocity = new Vector2(-recoilXSpeed, 0);
            }
            else
            {
                rb.velocity = new Vector2(recoilXSpeed, 0);
            }
        }

        if (pState.recoilingYIs(State.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.recoilingXIs(State.recoilingX) && stepsXRecoiled < recoilXSteps)
        {
            stepsXRecoiled++;
        }
        else
        {
            StopRecoilX();
        }
        if (pState.recoilingYIs(State.recoilingY) && stepsYRecoiled < recoilYSteps)
        {
            stepsYRecoiled++;
        }
        else
        {
            StopRecoilY();
        }

        if (Grounded())
        {
            StopRecoilY();
        }
    }
    void StopRecoilX()
    {
        stepsXRecoiled = 0;
        pState.recoilingX = false;
        Set(State.recoilingX, false);
    }
    void StopRecoilY()
    {
        stepsYRecoiled = 0;
        pState.recoilingY = false;
        Set(State.recoilingY, false);
    }
    public void TakeDamage(float _damage)
    {
        if (pState.aliveIs(State.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;
        Set(State.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;
        Set(State.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 (Is(State.invincible) && !Is(State.cutscene))
        {
            if (Time.timeScale > 0.2 && canFlash)
            {
                StartCoroutine(Flash());
            }
        }
        else
        {
            sr.enabled = true;
        }
    }


    IEnumerator Death()
    {
        pState.alive = false;
        Set(State.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;
        manaPenalty = 0.5f;

        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();
        Bench.SaveGame();
    }

    public void Respawned(float manaPenalty = 0.5f)
    {
        if (!pState.alive)
        if (!Is(State.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;
            Set(State.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.Clamp(value, 0, maxHealth);
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }
    public int ExcessHealth
    {
        get { return excessHealth; }
        set
        {
            if (excessHealth != value)
            {
                excessHealth = Mathf.Max(value, 0);
                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!Is(State.dashing))
        {
            pState.healing = true;
            Set(State.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;
            Set(State.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;
            Set(State.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())) && abilities.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.lookingRightIs(State.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;
            Set(State.recoilingX, true);

            Mana -= manaSpellCost;

            yield return new WaitForSeconds(0.35f);
        }

        //up cast
        else if (yAxis > 0 && abilities.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()) && abilities.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;
        Set(State.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!Is(State.jumping))
        {
            if (Input.GetButtonDown("Jump"))
            {
                audioSource.PlayOneShot(jumpSound);
            }

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

            pState.jumping = true;
            Set(State.jumping, true);
        }

        if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && abilities.HasFlag(Abilities.variableJump))
        {
            audioSource.PlayOneShot(jumpSound);

            pState.jumping = true;
            Set(State.jumping, true);
            airJumpCounter++;

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

        if (Input.GetButtonUp("Jump") && rb.velocity.y > 3)
        {
            pState.jumping = false;
            Set(State.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;
            Set(State.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!Is(State.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.lookingRightToggle(State.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);
    }
}

b. Updating pState in enemy scripts

Once the above changes are made, we will also need to update our existing scripts that reference the old pState variable (referring to the PlayerStateList) that we have just removed from PlayerController.

In a later part, we will be revamping the enemy system of this game, as there is a lot of repeat code in all of our enemy scripts. With inheritance, this shouldn’t be happening with our scripts.

Enemy.cs

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

public class Enemy : MonoBehaviour
{
    [SerializeField] protected float health;
    [SerializeField] protected float recoilLength;
    [SerializeField] protected float recoilFactor;
    [SerializeField] protected bool isRecoiling = false;

    [SerializeField] public float speed;

    [SerializeField] public float damage;
    [SerializeField] protected GameObject orangeBlood;

    [SerializeField] AudioClip hurtSound;

    protected float recoilTimer;
    [HideInInspector] public Rigidbody2D rb;
    protected SpriteRenderer sr;
    public Animator anim;
    protected AudioSource audioSource;

    protected enum EnemyStates
    {
        //Crawler
        Crawler_Idle,
        Crawler_Flip,

        //Bat
        Bat_Idle,
        Bat_Chase,
        Bat_Stunned,
        Bat_Death,

        //Charger
        Charger_Idle,
        Charger_Suprised,
        Charger_Charge,

        //Shade
        Shade_Idle,
        Shade_Chase,
        Shade_Stunned,
        Shade_Death,

        //THK
        THK_Stage1,
        THK_Stage2,
        THK_Stage3,
        THK_Stage4
    }
    protected EnemyStates currentEnemyState;

    protected virtual EnemyStates GetCurrentEnemyState
    {
        get { return currentEnemyState; }
        set
        {
            if(currentEnemyState != value)
            {
                currentEnemyState = value;

                ChangeCurrentAnimation();
            }
        }
    }

    // Start is called before the first frame update
    protected virtual void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();
        anim = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
    }
    // Update is called once per frame
    protected virtual void Update()
    {
        if (GameManager.Instance.isPaused) return;

        if(isRecoiling)
        {
            if(recoilTimer < recoilLength)
            {
                recoilTimer += Time.deltaTime;
            }
            else
            {
                isRecoiling = false;
                recoilTimer = 0;
            }
        }
        else
        {
            UpdateEnemyStates();
        }
    }

    public virtual void EnemyGetsHit(float _damageDone, Vector2 _hitDirection, float _hitForce)
    {
        health -= _damageDone;
        if(!isRecoiling)
        {
            audioSource.PlayOneShot(hurtSound);
            GameObject _orangeBlood = Instantiate(orangeBlood, transform.position, Quaternion.identity);
            Destroy(_orangeBlood, 5.5f);
            rb.velocity = _hitForce * recoilFactor * _hitDirection;
            isRecoiling = true;
        }
    }
    protected virtual void OnCollisionStay2D(Collision2D _other)
    {
        if(_other.gameObject.CompareTag("Player") && !PlayerController.Instance.pState.invincible && health > 0)
        if(_other.gameObject.CompareTag("Player") && !PlayerController.Instance.Is(PlayerController.State.invincible) && health > 0)
        {
            Attack();
            if(PlayerController.Instance.pState.alive)
            if(PlayerController.Instance.Is(PlayerController.State.alive))
            {
                GameManager.Stop(.5f, .5f, 0.01f);
            }            
        }
    }

    protected virtual void Death(float _destroyTime)
    {
        Destroy(gameObject, _destroyTime);
    }

    protected virtual void UpdateEnemyStates() { }
    protected virtual void ChangeCurrentAnimation() { }

    protected void ChangeState(EnemyStates _newState)
    {
        GetCurrentEnemyState = _newState;
    }

    protected virtual void Attack()
    {
        PlayerController.Instance.TakeDamage(damage);
    }
    
}

Bat.cs

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

public class Bat : Enemy
{
    [SerializeField] private float chaseDistance;
    [SerializeField] private float stunDuration;

    float timer;

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        ChangeState(EnemyStates.Bat_Idle);
    }
    protected override void Update()
    {
        base.Update();
        if (!PlayerController.Instance.pState.alive)
        if (!PlayerController.Instance.Is(PlayerController.State.alive))
        {
            ChangeState(EnemyStates.Bat_Idle);
        }
    }

    protected override void UpdateEnemyStates()
    {
        float _dist = Vector2.Distance(transform.position, PlayerController.Instance.transform.position);

        switch (GetCurrentEnemyState)
        {
            case EnemyStates.Bat_Idle:
                rb.velocity = new Vector2(0, 0);
                if (_dist < chaseDistance)
                {
                    ChangeState(EnemyStates.Bat_Chase);
                }
                break;

            case EnemyStates.Bat_Chase:
                rb.MovePosition(Vector2.MoveTowards(transform.position, PlayerController.Instance.transform.position, Time.deltaTime * speed));

                FlipBat();
                if (_dist > chaseDistance)
                {
                    ChangeState(EnemyStates.Bat_Idle);
                }
                break;

            case EnemyStates.Bat_Stunned:
                timer += Time.deltaTime;

                if(timer >= stunDuration)
                {
                    ChangeState(EnemyStates.Bat_Idle);
                    timer = 0;
                }
                break;

            case EnemyStates.Bat_Death:
                Death(Random.Range(5, 10));
                break;
        }
    }

    public override void EnemyGetsHit(float _damageDone, Vector2 _hitDirection, float _hitForce)
    {
        base.EnemyGetsHit(_damageDone, _hitDirection, _hitForce);
        
        if(health > 0)
        {
            ChangeState(EnemyStates.Bat_Stunned);
        }
        else
        {
            ChangeState(EnemyStates.Bat_Death);
        }
    }

    protected override void Death(float _destroyTime)
    {
        rb.gravityScale = 12;
        base.Death(_destroyTime);
    }

    protected override void ChangeCurrentAnimation()
    {
        anim.SetBool("Idle", GetCurrentEnemyState == EnemyStates.Bat_Idle);

        anim.SetBool("Chase", GetCurrentEnemyState == EnemyStates.Bat_Chase);

        anim.SetBool("Stunned", GetCurrentEnemyState == EnemyStates.Bat_Stunned);

        if (GetCurrentEnemyState == EnemyStates.Bat_Death)
        {
            anim.SetTrigger("Death");
            int LayerIgnorePlayer = LayerMask.NameToLayer("Ignore Player");
            gameObject.layer = LayerIgnorePlayer;
        }
    }

    void FlipBat()
    {
        sr.flipX = PlayerController.Instance.transform.position.x < transform.position.x;
    }
}

Shade.cs

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

public class Shade : Enemy
{
    [SerializeField] private float chaseDistance;
    [SerializeField] private float stunDuration;

    float timer;

    public static Shade Instance;

    private void Awake()
    {
        if(Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }
        Debug.Log("shade spawned");
        SaveData.Instance.SaveShadeData();
    }

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        ChangeState(EnemyStates.Shade_Idle);
    }
    protected override void Update()
    {
        base.Update();
        if (!PlayerController.Instance.pState.alive)
        if (!PlayerController.Instance.Is(PlayerController.State.alive))
        {
            ChangeState(EnemyStates.Shade_Idle);
        }
    }

    protected override void UpdateEnemyStates()
    {
        float _dist = Vector2.Distance(transform.position, PlayerController.Instance.transform.position);

        switch (GetCurrentEnemyState)
        {
            case EnemyStates.Shade_Idle:
                if (_dist < chaseDistance)
                {
                    ChangeState(EnemyStates.Shade_Chase);
                }
                break;

            case EnemyStates.Shade_Chase:
                rb.MovePosition(Vector2.MoveTowards(transform.position, PlayerController.Instance.transform.position, Time.deltaTime * speed));

                Flip();
                break;

            case EnemyStates.Shade_Stunned:
                timer += Time.deltaTime;

                if (timer > stunDuration)
                {
                    ChangeState(EnemyStates.Shade_Idle);
                    timer = 0;
                }
                break;

            case EnemyStates.Shade_Death:
                Death(Random.Range(5, 10));
                break;
        }
    }

    public override void EnemyGetsHit(float _damageDone, Vector2 _hitDirection, float _hitForce)
    {
        base.EnemyGetsHit(_damageDone, _hitDirection, _hitForce);

        if (health > 0)
        {
            ChangeState(EnemyStates.Shade_Stunned);
        }
        else
        {
            ChangeState(EnemyStates.Shade_Death);
        }
    }

    protected override void Death(float _destroyTime)
    {
        rb.gravityScale = 12;
        base.Death(_destroyTime);
    }

    protected override void ChangeCurrentAnimation()
    {
        if(GetCurrentEnemyState == EnemyStates.Shade_Idle)
        {
            anim.Play("Player_Idle");
        }

        anim.SetBool("Walking", GetCurrentEnemyState == EnemyStates.Shade_Chase);

        if (GetCurrentEnemyState == EnemyStates.Shade_Death)
        {
            PlayerController.Instance.RestoreMana();
            SaveData.Instance.SavePlayerData();
            anim.SetTrigger("Death");
            Destroy(gameObject, 0.5f);
        }
    }
    protected override void Attack()
    {
        anim.SetTrigger("Attacking");
        PlayerController.Instance.TakeDamage(damage);
    }

    void Flip()
    {
        sr.flipX = PlayerController.Instance.transform.position.x < transform.position.x;
    }
}

Charger.cs

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

public class Charger : Enemy
{
    [SerializeField] private float ledgeCheckX;
    [SerializeField] private float ledgeCheckY;
    [SerializeField] private float chargeSpeedMultiplier;
    [SerializeField] private float chargeDuration;
    [SerializeField] private float jumpForce;
    [SerializeField] private LayerMask whatIsGround;


    float timer;

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        ChangeState(EnemyStates.Charger_Idle);
        rb.gravityScale = 12f;
    }
    protected override void Update()
    {
        base.Update();
        if (!PlayerController.Instance.pState.alive)
        if (!PlayerController.Instance.Is(PlayerController.State.alive))
        {
            ChangeState(EnemyStates.Charger_Idle);
        }
    }
    private void OnCollisionEnter2D(Collision2D _collision)
    {
        if (_collision.gameObject.CompareTag("Enemy"))
        {
            transform.localScale = new Vector2(transform.localScale.x * -1, transform.localScale.y);
        }
    }

    protected override void UpdateEnemyStates()
    {
        if (health <= 0)
        {
            Death(0.05f);
        }

        Vector3 _ledgeCheckStart = transform.localScale.x > 0 ? new Vector3(ledgeCheckX, 0) : new Vector3(-ledgeCheckX, 0);
        Vector2 _wallCheckDir = transform.localScale.x > 0 ? transform.right : -transform.right;

        switch (GetCurrentEnemyState)
        {
            case EnemyStates.Charger_Idle:
                if (!Physics2D.Raycast(transform.position + _ledgeCheckStart, Vector2.down, ledgeCheckY, whatIsGround)
                    || Physics2D.Raycast(transform.position, _wallCheckDir, ledgeCheckX, whatIsGround))
                {
                    transform.localScale = new Vector2(transform.localScale.x * -1, transform.localScale.y);
                }


                RaycastHit2D _hit = Physics2D.Raycast(transform.position + _ledgeCheckStart, _wallCheckDir, ledgeCheckX * 10);
                if (_hit.collider != null && _hit.collider.gameObject.CompareTag("Player"))
                {
                    ChangeState(EnemyStates.Charger_Suprised);
                }

                if (transform.localScale.x > 0)
                {
                    rb.velocity = new Vector2(speed, rb.velocity.y);
                }
                else
                {
                    rb.velocity = new Vector2(-speed, rb.velocity.y);
                }
                break;

            case EnemyStates.Charger_Suprised:
                rb.velocity = new Vector2(0, jumpForce);

                ChangeState(EnemyStates.Charger_Charge);
                break;

            case EnemyStates.Charger_Charge:
                timer += Time.deltaTime;

                if(timer < chargeDuration)
                {
                    if(Physics2D.Raycast(transform.position, Vector2.down, ledgeCheckY, whatIsGround))
                    {
                        if (transform.localScale.x > 0)
                        {
                            rb.velocity = new Vector2(speed * chargeSpeedMultiplier, rb.velocity.y);
                        }
                        else
                        {
                            rb.velocity = new Vector2(-speed * chargeSpeedMultiplier, rb.velocity.y);
                        }
                    }
                    else
                    {
                        rb.velocity = new Vector2(0, rb.velocity.y);
                    }
                }
                else
                {
                    timer = 0;
                    ChangeState(EnemyStates.Charger_Idle);
                }
                break;
        }
    }

    protected override void ChangeCurrentAnimation()
    {
        if(GetCurrentEnemyState == EnemyStates.Charger_Idle)
        {
            anim.speed = 1;
        }

        if(GetCurrentEnemyState == EnemyStates.Charger_Charge)
        {
            anim.speed = chargeSpeedMultiplier;
        }
    }
}

Crawler.cs

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

public class Crawler : Enemy
{    
    [SerializeField] private float flipWaitTime;
    [SerializeField] private float ledgeCheckX;
    [SerializeField] private float ledgeCheckY;
    [SerializeField] private LayerMask whatIsGround;

    
    float timer;

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        rb.gravityScale = 12f;
    }
    protected override void Update()
    {
        base.Update();
        if(!PlayerController.Instance.pState.alive)
        if (!PlayerController.Instance.Is(PlayerController.State.alive))
        {
            ChangeState(EnemyStates.Crawler_Idle);
        }
    }
    private void OnCollisionEnter2D(Collision2D _collision)
    {
        if(_collision.gameObject.CompareTag("Enemy"))
        {
            ChangeState(EnemyStates.Crawler_Flip);
        }
    }

    protected override void UpdateEnemyStates()
    {
        if(health <= 0)
        {
            Death(0.05f);
        }


        switch (GetCurrentEnemyState)
        {
            case EnemyStates.Crawler_Idle:
                Vector3 _ledgeCheckStart = transform.localScale.x > 0 ? new Vector3(ledgeCheckX, 0) : new Vector3(-ledgeCheckX, 0);
                Vector2 _wallCheckDir = transform.localScale.x > 0 ? transform.right : -transform.right;

                if (!Physics2D.Raycast(transform.position + _ledgeCheckStart, Vector2.down, ledgeCheckY, whatIsGround)
                    || Physics2D.Raycast(transform.position, _wallCheckDir, ledgeCheckX, whatIsGround))
                {
                    ChangeState(EnemyStates.Crawler_Flip);
                }

                if (transform.localScale.x > 0)
                {
                    rb.velocity = new Vector2(speed, rb.velocity.y);
                }
                else
                {
                    rb.velocity = new Vector2(-speed, rb.velocity.y);
                }
                break;

            case EnemyStates.Crawler_Flip:
                timer += Time.deltaTime;

                if(timer > flipWaitTime)
                {
                    timer = 0;
                    transform.localScale = new Vector2(transform.localScale.x * -1, transform.localScale.y);
                    ChangeState(EnemyStates.Crawler_Idle);
                }
                break;
        }
    }
}

BarrageFireball.cs

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

public class BarrageFireball : MonoBehaviour
{
    [SerializeField] Vector2 startForceMinMax;
    [SerializeField] float turnSpeed = 0.5f;

    Rigidbody2D rb;
    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        Destroy(gameObject, 4f);
        rb.AddForce(transform.right * Random.Range(startForceMinMax.x, startForceMinMax.y), ForceMode2D.Impulse);
    }

    // Update is called once per frame
    void Update()
    {
        var _dir = rb.velocity;

        if(_dir != Vector2.zero)
        {
            Vector3 _frontVector = Vector3.right;

            Quaternion _targetRotation = Quaternion.FromToRotation(_frontVector, _dir - (Vector2)transform.position);
            if(_dir.x > 0)
            {
                transform.rotation = Quaternion.Lerp(transform.rotation, _targetRotation, turnSpeed);
                transform.eulerAngles = new Vector3(transform.eulerAngles.x, 180, transform.eulerAngles.z);
            }
            else
            {
                transform.rotation = Quaternion.Lerp(transform.rotation, _targetRotation, turnSpeed);
            }
           
        }
    }
    private void OnTriggerEnter2D(Collider2D _other)
    {
        if (_other.tag == "Player" && !PlayerController.Instance.pState.invincible)
        if (_other.tag == "Player" && !PlayerController.Instance.Is(PlayerController.State.invincible))
        {
            _other.GetComponent<PlayerController>().TakeDamage(TheHollowKnight.Instance.damage);
            Destroy(gameObject);
            if (PlayerController.Instance.pState.alive)
            if (PlayerController.Instance.Is(PlayerController.State.alive))
            {
                GameManager.Stop();
            }
        }
    }
}

DivingPillar.cs

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

public class DivingPillar : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D _other)
    {
        if(_other.CompareTag("Player") && !PlayerController.Instance.pState.invincible)
        if(_other.CompareTag("Player") && !PlayerController.Instance.Is(PlayerController.State.invincible))
        {
            _other.GetComponent<PlayerController>().TakeDamage(TheHollowKnight.Instance.damage);
            if (PlayerController.Instance.pState.alive)
            if (PlayerController.Instance.Is(PlayerController.State.alive))
            {
                GameManager.Stop();
            }
        }
    }
}

Boss_Lunge.cs

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

public class Boss_Lunge : StateMachineBehaviour
{
    Rigidbody2D rb;
    // OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
    override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        rb = animator.GetComponentInParent<Rigidbody2D>();
    }

    // OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
    override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        rb.gravityScale = 0;
        int _dir = TheHollowKnight.Instance.facingRight ? 1 : -1;
        rb.velocity = new Vector2(_dir * (TheHollowKnight.Instance.speed * 5), 0f);

        if(Vector2.Distance(PlayerController.Instance.transform.position, rb.position) <= TheHollowKnight.Instance.attackRange && 
            !TheHollowKnight.Instance.damagedPlayer && !PlayerController.Instance.pState.invincible)
        if(Vector2.Distance(PlayerController.Instance.transform.position, rb.position) <= TheHollowKnight.Instance.attackRange && 
            !TheHollowKnight.Instance.damagedPlayer && !PlayerController.Instance.Is(PlayerController.State.invincible))
        {
            PlayerController.Instance.TakeDamage(TheHollowKnight.Instance.damage);
            TheHollowKnight.Instance.damagedPlayer = true;
            if (PlayerController.Instance.pState.alive)
            if (PlayerController.Instance.Is(PlayerController.State.alive))
            {
                GameManager.Stop();
            }
        }
    }

    // OnStateExit is called when a transition ends and the state machine finishes evaluating this state
    override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {

    }
}

SceneTransition.cs

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneTransition : MonoBehaviour
{
    [SerializeField] private string transitionTo;
    public Color fadeColor = Color.black;
    public float fadeTime = 0.5f;
    [SerializeField] private Transform startPoint;

    [SerializeField] private Vector2 exitDirection;

    [SerializeField] private float exitTime;

    // Start is called before the first frame update
    private void Start()
    {
        if (GameManager.Instance.transitionedFromScene == transitionTo)
        {
            PlayerController.Instance.transform.position = startPoint.position;

            StartCoroutine(PlayerController.Instance.WalkIntoNewScene(exitDirection, exitTime));
        }

        StartCoroutine(UIScreen.FadeTo(fadeColor, -1, fadeTime));
    }

    private void OnTriggerEnter2D(Collider2D _other)
    {
        if (_other.CompareTag("Player"))
        {
            CheckShadeData();

            GameManager.Instance.transitionedFromScene = SceneManager.GetActiveScene().name;

            PlayerController.Instance.pState.cutscene = true;
            PlayerController.Instance.pState.invincible = true;
            PlayerController.Instance.Set(PlayerController.State.cutscene, true);
            PlayerController.Instance.Set(PlayerController.State.invincible, true);

            UIManager.Instance.LoadScene(transitionTo, fadeTime);
        }
    }
    void CheckShadeData()
    {
        GameObject[] enemyObjects = GameObject.FindGameObjectsWithTag("Enemy");

        for (int i = 0; i < enemyObjects.Length; i++)
        {
            if (enemyObjects[i].GetComponent<Shade>() != null)
            {
                SaveData.Instance.SaveShadeData();
            }
        }
    }
}

THKEvents.cs

using System.Collections;
using UnityEngine;

public class THKEvents : MonoBehaviour
{
    void SlashDamagePlayer()
    {
        if (PlayerController.Instance.transform.position.x - transform.position.x != 0)
        {
            Hit(TheHollowKnight.Instance.SideAttackTransform, TheHollowKnight.Instance.SideAttackArea);
        }
        else if (PlayerController.Instance.transform.position.y > transform.position.y)
        {
            Hit(TheHollowKnight.Instance.UpAttackTransform, TheHollowKnight.Instance.UpAttackArea);
        }
        else if (PlayerController.Instance.transform.position.y < transform.position.y)
        {
            Hit(TheHollowKnight.Instance.DownAttackTransform, TheHollowKnight.Instance.DownAttackArea);
        }
    }
    void Hit(Transform _attackTransform, Vector2 _attackArea)
    {
        Collider2D[] _objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0);

        for (int i = 0; i < _objectsToHit.Length; i++)
        {
            if (_objectsToHit[i].GetComponent<PlayerController>() != null && !PlayerController.Instance.pState.invincible)
            if (_objectsToHit[i].GetComponent<PlayerController>() != null && !PlayerController.Instance.Is(PlayerController.State.invincible))
            {
                _objectsToHit[i].GetComponent<PlayerController>().TakeDamage(TheHollowKnight.Instance.damage);
                if (PlayerController.Instance.pState.alive)
                if (PlayerController.Instance.Is(PlayerController.State.alive))
                {
                    GameManager.Stop();
                }
            }
        }
    }
    void Parrying()
    {
        TheHollowKnight.Instance.parrying = true;
    }
    void BendDownCheck()
    {
        if(TheHollowKnight.Instance.barrageAttack)
        {
            StartCoroutine(BarrageAttackTransition());
        }
        if(TheHollowKnight.Instance.outbreakAttack)
        {
            StartCoroutine(OutbreakAttackTransition());
        }
        if(TheHollowKnight.Instance.bounceAttack)
        {
            TheHollowKnight.Instance.anim.SetTrigger("Bounce1");
        }
    }
    void BarrageOrOutbreak()
    {
        if(TheHollowKnight.Instance.barrageAttack)
        {
            TheHollowKnight.Instance.StartCoroutine(TheHollowKnight.Instance.Barrage());
        }
        if (TheHollowKnight.Instance.outbreakAttack)
        {
            TheHollowKnight.Instance.StartCoroutine(TheHollowKnight.Instance.Outbreak());
        }
    }
    IEnumerator BarrageAttackTransition()
    {
        yield return new WaitForSeconds(1f);
        TheHollowKnight.Instance.anim.SetBool("Cast", true);
    }
    IEnumerator OutbreakAttackTransition()
    {
        yield return new WaitForSeconds(1f);
        TheHollowKnight.Instance.anim.SetBool("Cast", true);
    }
    void DestroyAfterDeath()
    {
        SpawnBoss.Instance.IsNotTrigger();
        TheHollowKnight.Instance.DestroyAfterDeath();
        GameManager.Instance.THKDefeated = true;
        SaveData.Instance.SaveBossData();
        SaveData.Instance.SavePlayerData();
    }

}

SpawnBoss.cs

using System.Collections;
using UnityEngine;

public class SpawnBoss : MonoBehaviour
{
    public static SpawnBoss Instance;
    [SerializeField] Transform spawnPoint;
    [SerializeField] GameObject boss;
    [SerializeField] Vector2 exitDirection;
    bool callOnce;
    BoxCollider2D col;

    private void Awake()
    {
        if (TheHollowKnight.Instance != null)
        {
            Destroy(TheHollowKnight.Instance);
            callOnce = false;
            col.isTrigger = true;
        }

        if (GameManager.Instance.THKDefeated)
        {
            callOnce = true;
        }

        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }
    }
    // Start is called before the first frame update
    void Start()
    {
        col = GetComponent<BoxCollider2D>();
    }

    // Update is called once per frame
    void Update()
    {
        
    }
    private void OnTriggerEnter2D(Collider2D _other)
    {
        if(_other.CompareTag("Player") && !callOnce && !GameManager.Instance.THKDefeated)
        {
            StartCoroutine(WalkIntoRoom());
            callOnce = true;
        }
    }
    IEnumerator WalkIntoRoom()
    {
        StartCoroutine(PlayerController.Instance.WalkIntoNewScene(exitDirection, 1));
        PlayerController.Instance.pState.cutscene = true;
        PlayerController.Instance.Set(PlayerController.State.cutscene, true);
        yield return new WaitForSeconds(1f);
        col.isTrigger = false;
        Instantiate(boss, spawnPoint.position, Quaternion.identity);
    }
    public void IsNotTrigger()
    {
        col.isTrigger = true;
    }
}

Spikes.cs

using System.Collections;
using UnityEngine;

public class Spikes : MonoBehaviour
{

    public Color hitFadeColor = Color.black;
    public float hitFadeTime = 0.6f;

    private void OnTriggerEnter2D(Collider2D _other)
    {
        if (_other.CompareTag("Player"))
        {
            StartCoroutine(RespawnPoint());
        }
    }

    IEnumerator RespawnPoint()
    {
        PlayerController.Instance.pState.cutscene = true;
        PlayerController.Instance.pState.invincible = true;
        PlayerController.Instance.Set(PlayerController.State.cutscene, true);
        PlayerController.Instance.Set(PlayerController.State.invincible, true);
        PlayerController.Instance.rb.velocity = Vector2.zero;
        Time.timeScale = 0f;

        StartCoroutine(UIScreen.FadeTo(hitFadeColor, 1, hitFadeTime));
        PlayerController.Instance.TakeDamage(1);
        yield return new WaitForSecondsRealtime(hitFadeTime);
        Time.timeScale = 1;
        PlayerController.Instance.transform.position = GameManager.Instance.platformingRespawnPoint;

        StartCoroutine(UIScreen.FadeTo(hitFadeColor, -1, hitFadeTime));
        yield return new WaitForSecondsRealtime(hitFadeTime);
        PlayerController.Instance.pState.cutscene = false;
        PlayerController.Instance.pState.invincible = false;
        PlayerController.Instance.Set(PlayerController.State.cutscene, false);
        PlayerController.Instance.Set(PlayerController.State.invincible, false);
    }
}

TheHollowKnight.cs

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

public class TheHollowKnight : Enemy
{
    public static TheHollowKnight Instance;

    [SerializeField] GameObject slashEffect;
    public Transform SideAttackTransform; //the middle of the side attack area
    public Vector2 SideAttackArea; //how large the area of side attack is

    public Transform UpAttackTransform; //the middle of the up attack area
    public Vector2 UpAttackArea; //how large the area of side attack is

    public Transform DownAttackTransform; //the middle of the down attack area
    public Vector2 DownAttackArea; //how large the area of down attack is

    public float attackRange;
    public float attackTimer;

    [HideInInspector] public bool facingRight;

    [Header("Ground Check Settings:")]
    [SerializeField] public Transform groundCheckPoint; //point at which ground check happens
    public Transform wallCheckPoint; //point at which wall 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

    int hitCounter;
    bool stunned, canStun;
    bool alive;

    [HideInInspector] public float runSpeed;

    public GameObject impactParticle;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }        
    }
    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        sr = GetComponentInChildren<SpriteRenderer>();
        anim = GetComponentInChildren<Animator>();
        ChangeState(EnemyStates.THK_Stage1);
        alive = true;
    }
    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;
        }
    }

    public bool TouchedWall()
    {
        if (Physics2D.Raycast(wallCheckPoint.position, Vector2.down, groundCheckY, whatIsGround)
    || Physics2D.Raycast(wallCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)
    || Physics2D.Raycast(wallCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround))
        {
            return true;
        }
        else
        {
            return false;
        }
    }
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea);
        Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea);
        Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea);
    }

    float bloodCountdown;
    float bloodTimer;
    // Update is called once per frame
    protected override void Update()
    {
        base.Update();

        if(health <= 0 && alive)
        {
            Death(0);
        }

        if(!attacking)
        {
            attackCountdown -= Time.deltaTime;
        }
        if(stunned)
        {
            rb.velocity = Vector2.zero;
        }

        bloodCountdown -= Time.deltaTime;
        if(bloodCountdown <= 0 && (currentEnemyState != EnemyStates.THK_Stage1 && currentEnemyState != EnemyStates.THK_Stage2))
        {
            GameObject _orangeBlood = Instantiate(orangeBlood, groundCheckPoint.position, Quaternion.identity);
            Destroy(_orangeBlood, 4f);
            bloodCountdown = bloodTimer;
        }
    }
    public void Flip()
    {
        if(PlayerController.Instance.transform.position.x < transform.position.x && transform.localScale.x > 0)
        {
            transform.eulerAngles = new Vector2(transform.eulerAngles.x, 180);
            facingRight = false;
        }
        else
        {
            transform.eulerAngles = new Vector2(transform.eulerAngles.x, 0);
            facingRight = true;
        }
    }
    protected override void UpdateEnemyStates()
    {
        if(PlayerController.Instance != null)
        {
            switch(GetCurrentEnemyState)
            {
                case EnemyStates.THK_Stage1:
                    canStun = true;
                    attackTimer = 6;
                    runSpeed = speed;
                    break;

                case EnemyStates.THK_Stage2:
                    canStun = true;
                    attackTimer = 5;
                    break;

                case EnemyStates.THK_Stage3:
                    canStun = false;
                    attackTimer = 8;
                    bloodTimer = 5f;
                    break;

                case EnemyStates.THK_Stage4:
                    canStun = false;
                    attackTimer = 10;
                    runSpeed = speed / 2;
                    bloodTimer = 1.5f;
                    break;
            }
        }
    }
    protected override void OnCollisionStay2D(Collision2D _other)
    {
        
    }
    #region attacking
    #region variables
    [HideInInspector] public bool attacking;
    [HideInInspector] public float attackCountdown;
    [HideInInspector] public bool damagedPlayer = false;
    [HideInInspector] public bool parrying;

    [HideInInspector] public Vector2 moveToPosition;
    [HideInInspector] public bool diveAttack;
    public GameObject divingCollider;
    public GameObject pillar;

    [HideInInspector] public bool barrageAttack;
    public GameObject barrageFireball;
    [HideInInspector] public bool outbreakAttack;

    [HideInInspector] public bool bounceAttack;
    [HideInInspector] public float rotationDirectionToTarget;
    [HideInInspector] public int bounceCount;
    #endregion

    #region Control
    public void AttackHandler()
    {
        if(currentEnemyState == EnemyStates.THK_Stage1)
        {
            if(Vector2.Distance(PlayerController.Instance.transform.position, rb.position) <= attackRange)
            {
                StartCoroutine(TripleSlash());
            }
            else
            {
                StartCoroutine(Lunge());
            }
        }
        if (currentEnemyState == EnemyStates.THK_Stage2)
        {
            if (Vector2.Distance(PlayerController.Instance.transform.position, rb.position) <= attackRange)
            {
                //StartCoroutine(TripleSlash());
            }
            else
            {
                int _attackChosen = Random.Range(3, 3);
                if(_attackChosen == 1)
                {
                    //StartCoroutine(Lunge());
                }
                if(_attackChosen == 2)
                {
                    //DiveAttackJump();
                }
                if(_attackChosen == 3)
                {
                    BarrageBendDown();
                }
            }
        }
        if (currentEnemyState == EnemyStates.THK_Stage3)
        {
            int _attackChosen = Random.Range(1, 4);
            if (_attackChosen == 1)
            {
                OutbreakBendDown();
            }
            if (_attackChosen == 2)
            {
                DiveAttackJump();
            }
            if (_attackChosen == 3)
            {
                BarrageBendDown();
            }
            if(_attackChosen == 4)
            {
                BounceAttack();
            }
        }
        if (currentEnemyState == EnemyStates.THK_Stage4)
        {
            if (Vector2.Distance(PlayerController.Instance.transform.position, rb.position) <= attackRange)
            {
                StartCoroutine(Slash());
            }
            else
            {
                BounceAttack();
            }
        }
    }
    public void ResetAllAttacks()
    {
        attacking = false;

        StopCoroutine(TripleSlash());
        StopCoroutine(Lunge());
        StopCoroutine(Parry());
        StopCoroutine(Slash());

        diveAttack = false;
        barrageAttack = false;
        outbreakAttack = false;
        bounceAttack = false;
    }
    #endregion

    #region Stage 1

    IEnumerator TripleSlash()
    {
        attacking = true;
        rb.velocity = Vector2.zero;

        anim.SetTrigger("Slash");
        SlashAngle();
        yield return new WaitForSeconds(0.3f);
        anim.ResetTrigger("Slash");

        anim.SetTrigger("Slash");
        SlashAngle();
        yield return new WaitForSeconds(0.5f);
        anim.ResetTrigger("Slash");

        anim.SetTrigger("Slash");
        SlashAngle();
        yield return new WaitForSeconds(0.2f);
        anim.ResetTrigger("Slash");

        ResetAllAttacks();
    }
    void SlashAngle()
    {
        if(PlayerController.Instance.transform.position.x - transform.position.x != 0)
        {
            Instantiate(slashEffect, SideAttackTransform);
        }
        else if (PlayerController.Instance.transform.position.y > transform.position.y)
        {
            SlashEffectAtAngle(slashEffect, 80, UpAttackTransform);
        }
        else if (PlayerController.Instance.transform.position.y < transform.position.y)
        {
            SlashEffectAtAngle(slashEffect, -90, UpAttackTransform);
        }
    }

    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);
    }
    IEnumerator Lunge()
    {
        Flip();
        attacking = true;

        anim.SetBool("Lunge", true);
        yield return new WaitForSeconds(1f);
        anim.SetBool("Lunge", false);
        damagedPlayer = false;
        ResetAllAttacks();
    }
    IEnumerator Parry()
    {
        attacking = true;
        rb.velocity = Vector2.zero;
        anim.SetBool("Parry", true);
        yield return new WaitForSeconds(0.8f);
        anim.SetBool("Parry", false);

        parrying = false;
        ResetAllAttacks();
    }
    IEnumerator Slash()
    {
        attacking = true;
        rb.velocity = Vector2.zero;

        anim.SetTrigger("Slash");
        SlashAngle();
        yield return new WaitForSeconds(0.2f);
        anim.ResetTrigger("Slash");

        ResetAllAttacks();
    }

    #endregion
    #region Stage 2
    void DiveAttackJump()
    {
        attacking = true;
        moveToPosition = new Vector2(PlayerController.Instance.transform.position.x, rb.position.y + 10);
        diveAttack = true;
        anim.SetBool("Jump", true);
    }

    public void Dive()
    {
        anim.SetBool("Dive", true);
        anim.SetBool("Jump", false);
    }
    private void OnTriggerEnter2D(Collider2D _other)
    {
        if(_other.GetComponent<PlayerController>() != null && (diveAttack || bounceAttack))
        {
            _other.GetComponent<PlayerController>().TakeDamage(damage * 2);
            PlayerController.Instance.pState.recoilingX = true;
            PlayerController.Instance.Set(PlayerController.State.recoilingX, true);
        }
    }
    public void DivingPillars()
    {
        Vector2 _impactPoint = groundCheckPoint.position;
        float _spawnDistance = 5;

        for(int i = 0; i < 10; i++)
        {
            Vector2 _pillarSpawnPointRight = _impactPoint + new Vector2(_spawnDistance, 0);
            Vector2 _pillarSpawnPointLeft = _impactPoint - new Vector2(_spawnDistance, 0);
            Instantiate(pillar, _pillarSpawnPointRight, Quaternion.Euler(0, 0, -90));
            Instantiate(pillar, _pillarSpawnPointLeft, Quaternion.Euler(0, 0, -90));

            _spawnDistance += 5;
        }
        ResetAllAttacks();
    }

    void BarrageBendDown()
    {
        attacking = true;
        rb.velocity = Vector2.zero;
        barrageAttack = true;
        anim.SetTrigger("BendDown");
    }

    public IEnumerator Barrage()
    {
        rb.velocity = Vector2.zero;

        float _currentAngle = 30f;
        for(int i = 0; i < 10; i++)
        {
            GameObject _projectile = Instantiate(barrageFireball, transform.position, Quaternion.Euler(0, 0, _currentAngle));

            if(facingRight)
            {
                _projectile.transform.eulerAngles = new Vector3(_projectile.transform.eulerAngles.x, 0, _currentAngle);
            }
            else
            {
                _projectile.transform.eulerAngles = new Vector3(_projectile.transform.eulerAngles.x, 180, _currentAngle);
            }

            _currentAngle += 5f;

            yield return new WaitForSeconds(0.3f);
        }
        yield return new WaitForSeconds(0.1f);
        anim.SetBool("Cast", false);
        ResetAllAttacks();
    }
    #endregion
    #region Stage 3
    void OutbreakBendDown()
    {
        attacking = true;
        rb.velocity = Vector2.zero;
        moveToPosition = new Vector2(transform.position.x, rb.position.y + 5);
        outbreakAttack = true;
        anim.SetTrigger("BendDown");
    }

    public IEnumerator Outbreak()
    {
        yield return new WaitForSeconds(1f);
        anim.SetBool("Cast", true);

        rb.velocity = Vector2.zero;
        for(int i = 0; i < 30; i++)
        {
            Instantiate(barrageFireball, transform.position, Quaternion.Euler(0, 0, Random.Range(110, 130))); //downwards
            Instantiate(barrageFireball, transform.position, Quaternion.Euler(0, 0, Random.Range(50, 70))); // diagonally right
            Instantiate(barrageFireball, transform.position, Quaternion.Euler(0, 0, Random.Range(260, 280))); // diagonally left

            yield return new WaitForSeconds(0.2f);
        }
        yield return new WaitForSeconds(0.1f);
        rb.constraints = RigidbodyConstraints2D.None;
        rb.constraints = RigidbodyConstraints2D.FreezeRotation;
        rb.velocity = new Vector2(rb.velocity.x, -10);
        yield return new WaitForSeconds(0.1f);
        anim.SetBool("Cast", false);
        ResetAllAttacks();
    }
    void BounceAttack()
    {
        attacking = true;
        bounceCount = Random.Range(2, 5);
        BounceBendDown();
    }
    int _bounces = 0;
    public void CheckBounce()
    {
        if(_bounces < bounceCount - 1)
        {
            _bounces++;
            BounceBendDown();
        }
        else
        {
            _bounces = 0;
            anim.Play("Boss_Run");
        }
    }
    public void BounceBendDown()
    {
        rb.velocity = Vector2.zero;
        moveToPosition = new Vector2(PlayerController.Instance.transform.position.x, rb.position.y + 10);
        bounceAttack = true;
        anim.SetTrigger("BendDown");
    }

    public void CalculateTargetAngle()
    {
        Vector3 _directionToTarget = (PlayerController.Instance.transform.position - transform.position).normalized;

        float _angleOfTarget = Mathf.Atan2(_directionToTarget.y, _directionToTarget.x) * Mathf.Rad2Deg;
        rotationDirectionToTarget = _angleOfTarget;
    }

    #endregion
    #endregion

    public override void EnemyGetsHit(float _damageDone, Vector2 _hitDirection, float _hitForce)
    {
        if (!stunned)
        {
            if (!parrying)
            {
                if(canStun)
                {
                    hitCounter++;
                    if(hitCounter >= 3)
                    {
                        ResetAllAttacks();
                        StartCoroutine(Stunned());
                    }
                }
                ResetAllAttacks();
                base.EnemyGetsHit(_damageDone, _hitDirection, _hitForce);

                if (currentEnemyState != EnemyStates.THK_Stage4)
                {
                    ResetAllAttacks(); //cancel any current attack to avoid bugs 
                    StartCoroutine(Parry());
                }

            }
            else
            {
                StopCoroutine(Parry());
                parrying = false;
                ResetAllAttacks();
                StartCoroutine(Slash());  //riposte
            }
        }
        else
        {
            StopCoroutine(Stunned());
            anim.SetBool("Stunned", false);
            stunned = false;
        }
        #region health to state

        if(health > 15)
        {
            ChangeState(EnemyStates.THK_Stage1);
        }
        if(health <= 15 && health > 10)
        {
            ChangeState(EnemyStates.THK_Stage2);
        }
        if (health <= 10 && health > 5)
        {
            ChangeState(EnemyStates.THK_Stage3);
        }
        if (health < 5)
        {
            ChangeState(EnemyStates.THK_Stage4);
        }
        if(health <= 0 && alive)
        {
            Death(0);
        }

        #endregion

    }
    public IEnumerator Stunned()
    {
        stunned = true;
        hitCounter = 0;
        anim.SetBool("Stunned", true);
        yield return new WaitForSeconds(6f);
        anim.SetBool("Stunned", false);
        stunned = false;
    }
    protected override void Death(float _destroyTime)
    {
        ResetAllAttacks();
        alive = false;
        rb.velocity = new Vector2(rb.velocity.x, -25);
        anim.SetTrigger("Die");
        bloodTimer = 0.8f;
    }
    public void DestroyAfterDeath()
    {
        Destroy(gameObject);
    }
}

If you get an error in your Console that says:

Assets\Scripts\Singletons\PlayerController.cs(569,15): error CS0117: 'Bench' does not contain a definition for 'SaveGame'

Don’t worry, this is an error we will fix later. The Bench Universal Save System contains a class called Bench as well, which conflicts with the save point in our game called Bench. However, because the scripts are under different namespaces, they are able to coexist without causing errors.

The error in PlayerController is caused by Unity not knowing which Bench you want to use, which you can temporarily fix by adding the following line:

Terresquall.Bench.SaveGame();

The green highlighted section above will no longer be needed once we name our Bench script in the project.

c. Adding the save and load functionality

Like Pickup, we need to make PlayerController inherit from PersistentObject, and then add a new nested PlayerController.SaveData class, as well as a Save() and Load() function, for its data to be saved when we call Bench.SaveGame():

PlayerController.cs

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

public class PlayerController : MonoBehaviourPersistentObject
{
    [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 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;


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

    [System.Flags]
    public enum State
    {
        jumping = 1, dashing = 2, recoilingX = 4, recoilingY = 8,
        lookingRight = 16, invincible = 32, healing = 64, casting = 128,
        cutscene = 256, alive = 512
    }

    public State state;
    public bool Is(State s) { return state.HasFlag(s); }
    public void Set(State s, bool on)
    {
        if (on) state |= s;
        else state &= ~s;
    }
    public bool Toggle(State s)
    {
        state ^= s;
        return Is(s);
    }

    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()
    {
        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();
        anim = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
        gravity = rb.gravityScale;
        Mana = mana;
        Health = maxHealth;
        if (Health > 0) Set(State.alive, true);
        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 (Is(State.cutscene) || GameManager.Instance.isPaused) return;

        if (Is(State.alive))
        {
            HandleRestoreManaWithExcess();
            GetInputs();
            ToggleMap();
            ToggleInventory();
            UpdateJumpVariables();
            UpdateCameraYDampForPlayerFall();
            FlashWhileInvincible();
        } else return;

        if (Is(State.dashing)) return;
        if (!isWallJumping)
        {
            Move();
        }
        Heal();
        CastSpell();
        if (Is(State.healing)) return;
        if (!isWallJumping)
        {
            Flip();
            Jump();
        }

        if (abilities.HasFlag(Abilities.wallJump))
        {
            WallSlide();
            WallJump();
        }

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

    private void FixedUpdate()
    {
        if (Is(State.cutscene)) return;

        if (Is(State.dashing) || Is(State.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);
            Set(State.lookingRight, false);
        }
        else if (xAxis > 0)
        {
            transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y);
            Set(State.lookingRight, true);
        }
    }

    private void Move()
    {
        if (Is(State.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;
        Set(State.dashing, true);
        anim.SetTrigger("Dashing");
        audioSource.PlayOneShot(dashAndAttackSound);
        rb.gravityScale = 0;
        int _dir = Is(State.lookingRight) ? 1 : -1;
        rb.velocity = new Vector2(_dir * dashSpeed, 0);
        if (Grounded()) Instantiate(dashEffect, transform);
        yield return new WaitForSeconds(dashTime);
        rb.gravityScale = gravity;
        Set(State.dashing, false);
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }

    public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay)
    {
        Set(State.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);
        Set(State.invincible, false);
        Set(State.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 = Is(State.lookingRight) ? 1 : -1;

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


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

        if (objectsToHit.Length > 0)
        {
            Set(recoilState, 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 (Is(State.recoilingX))
        {
            if (Is(State.lookingRight))
            {
                rb.velocity = new Vector2(-recoilXSpeed, 0);
            }
            else
            {
                rb.velocity = new Vector2(recoilXSpeed, 0);
            }
        }

        if (Is(State.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 (Is(State.recoilingX) && stepsXRecoiled < recoilXSteps)
        {
            stepsXRecoiled++;
        }
        else
        {
            StopRecoilX();
        }
        if (Is(State.recoilingY) && stepsYRecoiled < recoilYSteps)
        {
            stepsYRecoiled++;
        }
        else
        {
            StopRecoilY();
        }

        if (Grounded())
        {
            StopRecoilY();
        }
    }
    void StopRecoilX()
    {
        stepsXRecoiled = 0;
        Set(State.recoilingX, false);
    }
    void StopRecoilY()
    {
        stepsYRecoiled = 0;
        Set(State.recoilingY, false);
    }
    public void TakeDamage(float _damage)
    {
        if (Is(State.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()
    {
        Set(State.invincible, true);
        GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity);
        Destroy(_bloodSpurtParticles, 1.5f);
        anim.SetTrigger("TakeDamage");
        yield return new WaitForSeconds(1f);
        Set(State.invincible, false);
    }

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

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


    IEnumerator Death()
    {
        Set(State.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;
        manaPenalty = 0.5f;

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

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

        Bench.SaveGame();
    }

    public void Respawned(float manaPenalty = 0.5f)
    {
        if (!Is(State.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;
            Set(State.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.Clamp(value, 0, maxHealth);
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }
    public int ExcessHealth
    {
        get { return excessHealth; }
        set
        {
            if (excessHealth != value)
            {
                excessHealth = Mathf.Max(value, 0);
                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() && !Is(State.dashing))
        {
            Set(State.healing, true);
            anim.SetBool("Healing", true);

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

            //drain mana

            Mana -= Time.deltaTime * healManaCostPerSecond;
        }
        else
        {
            Set(State.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)
        {
            Set(State.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())) && abilities.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 (Is(State.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
            }
            Set(State.recoilingX, true);

            Mana -= manaSpellCost;

            yield return new WaitForSeconds(0.35f);
        }

        //up cast
        else if (yAxis > 0 && abilities.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()) && abilities.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);
        Set(State.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 && !Is(State.jumping))
        {
            if (Input.GetButtonDown("Jump"))
            {
                audioSource.PlayOneShot(jumpSound);
            }

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

            Set(State.jumping, true);
        }

        if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && abilities.HasFlag(Abilities.variableJump))
        {
            audioSource.PlayOneShot(jumpSound);

            Set(State.jumping, true);
            airJumpCounter++;

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

        if (Input.GetButtonUp("Jump") && rb.velocity.y > 3)
        {
            Set(State.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;
            }
            Set(State.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 = !Is(State.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;

            float jumpDirection = Toggle(State.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);
    }

    public override PersistentObject.SaveData Save()
    {
        SaveData playerdata = new SaveData();
        if (CanSave())
        {
            return new SaveData 
            {
                saveID = saveID,
                Health = Health,
                maxHealth = maxHealth,
                maxTotalHealth = maxTotalHealth,
                heartShards = heartShards,

                Mana = Mana,
                manaPenalty = manaPenalty,
                manaOrbs = excessMaxManaUnits,
                orbShard = manaShards,

                unlocks = (byte)abilities,

                position = transform.position,

                lastScene = SceneManager.GetActiveScene().name
            };
        }

        return null;
    }

    public override bool Load(PersistentObject.SaveData data)
    {
        // If the data passed in is invalid, return.
        if (data == null) return false;
        SaveData playerData = data as SaveData;
        if (playerData == null) return false;

        // Otherwise continue loading the data.
        //SceneManager.LoadScene(playerData.lastScene);

        Health = playerData.Health;
        maxHealth = playerData.maxHealth;
        heartShards = playerData.heartShards;

        manaPenalty = playerData.manaPenalty;
        excessMaxManaUnits = playerData.manaOrbs;
        manaShards = playerData.orbShard;
        Mana = playerData.Mana;

        abilities = (Abilities)playerData.unlocks;

        transform.position = playerData.position;
        return true;
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData 
    {
        public float positionX, positionY, positionZ;
        public float manaPenalty;
        public int Health;
        public int maxHealth;
        public int maxTotalHealth;
        public int heartShards;
        public float Mana;
        public int manaOrbs;
        public int orbShard;
        public byte unlocks;
        public string lastScene;

        public Vector3 position
        {
            get { return new Vector3(positionX, positionY, positionZ); }
            set
            {
                positionX = value.x;
                positionY = value.y;
                positionZ = value.z;
            }
        }

    }
}

The SaveData class for PlayerController contains data that we used to save about the PlayerController back in our old save class, except now we have transferred the logic from that script to our own PlayerController script.

💡 You may also notice that the SaveData class for PlayerController has a position property that is defined, which helps to set the positionX, positionY and positionZ value. This helps us to more easily set the variables in SaveData, as the Vector3 type cannot be serialized and have to be converted to floats. The position property allows us to write:

data.position = transform.position;

Instead of the longer:

data.positionX = transform.position.x;
data.positionY = transform.position.y;
data.positionZ = transform.position.z;

5. Replacing SaveData and updating GameManager

The most important file that we will need to update is the GameManager script, as it is where some of the more important bits of data are saved.

Currently, the SaveData script manages the entirety of our save functionality through the GameManager. For us to use the new save system, this will need to be migrated from SaveData to our GameManager class.

a. Merging SaveData into GameManager

This means that we will need to:

  1. Remove all function calls to SaveData from GameManager, and;
  2. Move all functionalities in SaveData to GameManager, such as the tracking of where the player’s shade is.

All the data that we move from SaveData to the GameManager will be saved in an instance of the GameManager.SaveData object that we will be creating, called globalData. There will not be a separate copy of each variable saved on the class itself, unlike in PlayerController and Pickup. This makes our data more compact, and saving faster, as globalData can be used directly by the Save() and Load().

💡 After updating the GameManager script with the snippet below, do remember to go to your Canvas UI Prefab and reassign the Shade Prefab field, as its name has been changed. Otherwise, your shade will stop spawning!

Reassign shade prefab

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using Terresquall;
using System.Threading.Tasks;

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

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

    public GameObject shade;
    public Shade shadePrefab;
    Shade currentShade;

    [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 SaveData globalData = new SaveData();

    public static GameManager Instance { get; private set; }
    private void Awake()
    {
        SaveData.Instance.Initialize();
        // Spawn shade if appropriate in the Scene before destroying this instance.
        if (PlayerController.Instance != null)
        {
            if (globalData.shadeScene == SceneManager.GetActiveScene().name)
            {
                SpawnShade(globalData.shadePosition, globalData.shadeRotation);
            }
        }

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

        if (PlayerController.Instance != null)
        {

            if(PlayerController.Instance.manaPenalty > 0f)
            {
                SaveData.Instance.LoadShadeData();
                if (SaveData.Instance.sceneWithShade == SceneManager.GetActiveScene().name || SaveData.Instance.sceneWithShade == "")
                if(globalData.shadeScene == SceneManager.GetActiveScene().name)
                {
                    Instantiate(shade, SaveData.Instance.shadePos, SaveData.Instance.shadeRot);
                    Instantiate(shade, globalData.shadePosition, globalData.shadeRotation);
                }
            }
        }

        SaveScene();

        // Load the GameManager SaveData if there is any found.
        DontDestroyOnLoad(gameObject);
        UpdateMap();
        bench = FindObjectOfType<Bench>();
        SaveData.Instance.LoadBossData();
    }

    async void Start()
    {
        await Task.Delay(100);
        Bench.QuickLoad();

        // If the code gets here, we check whether we need to spawn a shade again.
        if (PlayerController.Instance != null)
        {
            if (globalData.shadeScene == SceneManager.GetActiveScene().name)
            {
                SpawnShade(globalData.shadePosition, globalData.shadeRotation);
            }
        }
    }

    public static Shade SpawnShade(Vector3 position, Quaternion rotation)
    {
        if(Instance.currentShade) return null;
        Instance.currentShade = Instantiate(Instance.shadePrefab, position, rotation);
        return Instance.currentShade;
    }

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

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

    protected override void Reset()
    {
        saveID = "GameManager";
        defaultRespawnPoint = FindObjectOfType<PlayerController>().transform.position;
        pauseMenu = FindObjectOfType<UIScreen>();
    }

    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 SaveSceneUpdateMap()
    {
        string currentSceneName = SceneManager.GetActiveScene().name;
        SaveData.Instance.sceneNames.Add(currentSceneName);
        MapManager.data.visitedScenes.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;
        }
        // If a benchScene is saved, it means that we benched somewhere.
        if (!string.IsNullOrEmpty(globalData.lastScene) && globalData.lastScene != SceneManager.GetActiveScene().name) //load the bench's scene if it exists.
        {
            SceneManager.LoadScene(globalData.lastScene);
        }
        else
        {
            // Otherwise we spawn at the default respawn point.
            respawnPoint = defaultRespawnPoint;
        }

        PlayerController.Instance.transform.position = respawnPoint;
        UIManager.Instance.deathScreen.Deactivate();
        PlayerController.Instance.Respawned(manaPenalty);
    }

    public override PersistentObject.SaveData Save()
    {
        if (CanSave())
        {
            globalData.saveID = saveID;
            return globalData;
        }
        
        Debug.LogError("GameManager failed to save.");
        return null;

    }

    public override bool Load(PersistentObject.SaveData data)
    {
        if (data == null) return false;
        globalData = data as SaveData;
        if (globalData == null) return false;
        return true;
    }

    [System.Flags] public enum Flags : long { None = 0, THKDefeated = 1 }
    public static bool Is(Flags f) { return globalData.flags.HasFlag(f); }
    public static void Set(Flags f, bool on)
    { 
        if(on) globalData.flags |= f; 
        else globalData.flags &= f;
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData
    {
        public string lastScene;

        public string shadeScene;
        public float shadePositionX, shadePositionY, shadePositionZ;
        public float shadeRotationX, shadeRotationY, shadeRotationZ, shadeRotationW;
        public Flags flags;

        public Vector3 shadePosition 
        {
            get { return new Vector3(shadePositionX, shadePositionY, shadePositionZ); }
            set 
            { 
                shadePositionX = value.x;
                shadePositionY = value.y;
                shadePositionZ = value.z;
            }
        }

        public Quaternion shadeRotation 
        {
            get { return new Quaternion(shadeRotationX, shadeRotationY, shadeRotationZ, shadeRotationW); }
            set 
            {
                shadeRotationX = value.x;
                shadeRotationY = value.y;
                shadeRotationZ = value.z;
                shadeRotationW = value.w;
            }
        }
    }
}

Below are some additional things to note about the newly-updated GameManager script:

  1. A new Reset() function was added to GameManager, to help automatically set the Save ID to GameManager. This ensures that all the Game Managers in our different scenes share the same Save ID, as they are supposed to be the same entity.
Ensure all Game Managers have the same save ID.
Ensure all Game Managers in different scenes have the same Save ID, or they will each save their data separately.
  1. On top of that, the Reset() function also automatically sets the defaultRespawnPoint to wherever the player is placed on the scene, and tries to find and assign the pauseMenu—a small quality of life change for level designers.
  2. We also introduce a new async Start() function with which to call QuickLoad() with. The reason we delay this is to ensure that we only try to load data after all the GameObjects in the Scene have been loaded. Otherwise, some of the data we load may not be updated onto the Scene’s GameObjects.
  3. The way Shades are spawned have also been redesigned, as the old code did not always spawn our shades after we reloaded our game. The new system here checks whether a Shade needs to be spawned in Awake(), ensuring that our shade will be respawned if we leave and re-enter a Scene (we have a GameManager in every Scene that is removed in Awake(), after the shade check is done). The shade check also occurs in the new Start() function, as the first time our game loads, the Awake() function will not have any shades to load, as the save data is still not loaded.

b. Updating the Shade

In light of the updates to GameManager, we have to update our Shade script to stop using SaveData as well. Instead of inheriting from PersistentObject and having its own Save() and Load() function, I’ve decided to make it refer to GameManager to determine its data. This is because otherwise, we will need to include a Shade prefab in every Scene, as the bench system only reads data for objects that are in the Scene.

The script update to Shade below also makes sure to save the Scene that the shade is first spawned in, and to remove that Scene when it dies. Even though our old SaveData script had a sceneWithShade variable, our code never used it (it’s a bug).

Shade.cs

using UnityEngine;
using UnityEngine.SceneManagement;
public class Shade : Enemy
{
    [SerializeField] private float chaseDistance;
    [SerializeField] private float stunDuration;

    float timer;

    public static Shade Instance;

    private void Awake()
    {
        if(Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }
        Debug.Log("shade spawned");
        SaveData.Instance.SaveShadeData();
        GameManager.globalData.shadeScene = SceneManager.GetActiveScene().name;
        GameManager.globalData.shadePosition = transform.position;
        GameManager.globalData.shadeRotation = transform.rotation;
    }

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        ChangeState(EnemyStates.Shade_Idle);
    }
    protected override void Update()
    {
        base.Update();
        if (!PlayerController.Instance.Is(PlayerController.State.alive))
        {
            ChangeState(EnemyStates.Shade_Idle);
        }
    }

    protected override void UpdateEnemyStates()
    {
        float _dist = Vector2.Distance(transform.position, PlayerController.Instance.transform.position);

        switch (GetCurrentEnemyState)
        {
            case EnemyStates.Shade_Idle:
                if (_dist < chaseDistance)
                {
                    ChangeState(EnemyStates.Shade_Chase);
                }
                break;

            case EnemyStates.Shade_Chase:
                rb.MovePosition(Vector2.MoveTowards(transform.position, PlayerController.Instance.transform.position, Time.deltaTime * speed));

                Flip();
                break;

            case EnemyStates.Shade_Stunned:
                timer += Time.deltaTime;

                if (timer > stunDuration)
                {
                    ChangeState(EnemyStates.Shade_Idle);
                    timer = 0;
                }
                break;

            case EnemyStates.Shade_Death:
                Death(Random.Range(5, 10));
                break;
        }
    }

    public override void EnemyGetsHit(float _damageDone, Vector2 _hitDirection, float _hitForce)
    {
        base.EnemyGetsHit(_damageDone, _hitDirection, _hitForce);

        if (health > 0)
        {
            ChangeState(EnemyStates.Shade_Stunned);
        }
        else
        {
            ChangeState(EnemyStates.Shade_Death);
        }
    }

    protected override void Death(float _destroyTime)
    {
        rb.gravityScale = 12;
        base.Death(_destroyTime);
    }

    protected override void ChangeCurrentAnimation()
    {
        if(GetCurrentEnemyState == EnemyStates.Shade_Idle)
        {
            anim.Play("Player_Idle");
        }

        anim.SetBool("Walking", GetCurrentEnemyState == EnemyStates.Shade_Chase);

        if (GetCurrentEnemyState == EnemyStates.Shade_Death)
        {
            PlayerController.Instance.RestoreMana();
            SaveData.Instance.SavePlayerData();
            GameManager.globalData.shadeScene = "";
            Terresquall.Bench.SaveGameAsync();
            anim.SetTrigger("Death");
            Destroy(gameObject, 0.5f);
        }
    }
    protected override void Attack()
    {
        anim.SetTrigger("Attacking");
        PlayerController.Instance.TakeDamage(damage);
    }

    void Flip()
    {
        sr.flipX = PlayerController.Instance.transform.position.x < transform.position.x;
    }
}

Now that we’ve successfully moved the save data for Shade off SaveData, let’s update SceneTransition, which used to be responsible for saving shade data:

SceneTransition.cs

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneTransition : MonoBehaviour {
    [SerializeField] private string transitionTo;
    public Color fadeColor = Color.black;
    public float fadeTime = 0.5f;
    [SerializeField] private Transform startPoint;

    [SerializeField] private Vector2 exitDirection;

    [SerializeField] private float exitTime;

    // Start is called before the first frame update
    private void Start() {
        if (GameManager.Instance.transitionedFromScene == transitionTo) {
            PlayerController.Instance.transform.position = startPoint.position;

            StartCoroutine(PlayerController.Instance.WalkIntoNewScene(exitDirection, exitTime));
        }

        StartCoroutine(UIScreen.FadeTo(fadeColor, -1, fadeTime));
    }

    private void OnTriggerEnter2D(Collider2D _other) {
        if (_other.CompareTag("Player")) {
            CheckShadeData();
            Terresquall.Bench.QuickSave();

            GameManager.Instance.transitionedFromScene = SceneManager.GetActiveScene().name;

            PlayerController.Instance.Set(PlayerController.State.cutscene, true);
            PlayerController.Instance.Set(PlayerController.State.invincible, true);

            UIManager.Instance.LoadScene(transitionTo, fadeTime);
        }
    }
    void CheckShadeData() {
        GameObject[] enemyObjects = GameObject.FindGameObjectsWithTag("Enemy");

        for (int i = 0; i < enemyObjects.Length; i++) {
            if (enemyObjects[i].GetComponent<Shade>() != null) {
                SaveData.Instance.SaveShadeData();
            }
        }
    }
}

Because we’ve introduced a new SpawnShade() function in GameManager to spawn the shades, and the function prevents us from spawning more than one shade, let’s also update PlayerController to use the SpawnShade() function when it dies instead of calling Instantiate() directly:

PlayerController.cs

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

public class PlayerController : PersistentObject
{
    [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 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;


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

    [System.Flags]
    public enum State
    {
        jumping = 1, dashing = 2, recoilingX = 4, recoilingY = 8,
        lookingRight = 16, invincible = 32, healing = 64, casting = 128,
        cutscene = 256, alive = 512
    }

    public State state;
    public bool Is(State s) { return state.HasFlag(s); }
    public void Set(State s, bool on)
    {
        if (on) state |= s;
        else state &= ~s;
    }
    public bool Toggle(State s)
    {
        state ^= s;
        return Is(s);
    }

    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()
    {
        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();
        anim = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
        gravity = rb.gravityScale;
        Mana = mana;
        Health = maxHealth;
        if (Health > 0) Set(State.alive, true);
        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 (Is(State.cutscene) || GameManager.Instance.isPaused) return;

        if (Is(State.alive))
        {
            HandleRestoreManaWithExcess();
            GetInputs();
            ToggleMap();
            ToggleInventory();
            UpdateJumpVariables();
            UpdateCameraYDampForPlayerFall();
            FlashWhileInvincible();
        } else return;

        if (Is(State.dashing)) return;
        if (!isWallJumping)
        {
            Move();
        }
        Heal();
        CastSpell();
        if (Is(State.healing)) return;
        if (!isWallJumping)
        {
            Flip();
            Jump();
        }

        if (abilities.HasFlag(Abilities.wallJump))
        {
            WallSlide();
            WallJump();
        }

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

    private void FixedUpdate()
    {
        if (Is(State.cutscene)) return;

        if (Is(State.dashing) || Is(State.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);
            Set(State.lookingRight, false);
        }
        else if (xAxis > 0)
        {
            transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y);
            Set(State.lookingRight, true);
        }
    }

    private void Move()
    {
        if (Is(State.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;
        Set(State.dashing, true);
        anim.SetTrigger("Dashing");
        audioSource.PlayOneShot(dashAndAttackSound);
        rb.gravityScale = 0;
        int _dir = Is(State.lookingRight) ? 1 : -1;
        rb.velocity = new Vector2(_dir * dashSpeed, 0);
        if (Grounded()) Instantiate(dashEffect, transform);
        yield return new WaitForSeconds(dashTime);
        rb.gravityScale = gravity;
        Set(State.dashing, false);
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }

    public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay)
    {
        Set(State.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);
        Set(State.invincible, false);
        Set(State.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 = Is(State.lookingRight) ? 1 : -1;

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


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

        if (objectsToHit.Length > 0)
        {
            Set(recoilState, 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 (Is(State.recoilingX))
        {
            if (Is(State.lookingRight))
            {
                rb.velocity = new Vector2(-recoilXSpeed, 0);
            }
            else
            {
                rb.velocity = new Vector2(recoilXSpeed, 0);
            }
        }

        if (Is(State.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 (Is(State.recoilingX) && stepsXRecoiled < recoilXSteps)
        {
            stepsXRecoiled++;
        }
        else
        {
            StopRecoilX();
        }
        if (Is(State.recoilingY) && stepsYRecoiled < recoilYSteps)
        {
            stepsYRecoiled++;
        }
        else
        {
            StopRecoilY();
        }

        if (Grounded())
        {
            StopRecoilY();
        }
    }
    void StopRecoilX()
    {
        stepsXRecoiled = 0;
        Set(State.recoilingX, false);
    }
    void StopRecoilY()
    {
        stepsYRecoiled = 0;
        Set(State.recoilingY, false);
    }
    public void TakeDamage(float _damage)
    {
        if (Is(State.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()
    {
        Set(State.invincible, true);
        GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity);
        Destroy(_bloodSpurtParticles, 1.5f);
        anim.SetTrigger("TakeDamage");
        yield return new WaitForSeconds(1f);
        Set(State.invincible, false);
    }

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

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


    IEnumerator Death()
    {
        Set(State.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;
        manaPenalty = 0.5f;

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

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

        Bench.SaveGame();
    }

    public void Respawned(float manaPenalty = 0.5f)
    {
        if (!Is(State.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;
            Set(State.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.Clamp(value, 0, maxHealth);
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }
    public int ExcessHealth
    {
        get { return excessHealth; }
        set
        {
            if (excessHealth != value)
            {
                excessHealth = Mathf.Max(value, 0);
                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() && !Is(State.dashing))
        {
            Set(State.healing, true);
            anim.SetBool("Healing", true);

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

            //drain mana

            Mana -= Time.deltaTime * healManaCostPerSecond;
        }
        else
        {
            Set(State.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)
        {
            Set(State.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())) && abilities.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 (Is(State.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
            }
            Set(State.recoilingX, true);

            Mana -= manaSpellCost;

            yield return new WaitForSeconds(0.35f);
        }

        //up cast
        else if (yAxis > 0 && abilities.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()) && abilities.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);
        Set(State.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 && !Is(State.jumping))
        {
            if (Input.GetButtonDown("Jump"))
            {
                audioSource.PlayOneShot(jumpSound);
            }

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

            Set(State.jumping, true);
        }

        if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && abilities.HasFlag(Abilities.variableJump))
        {
            audioSource.PlayOneShot(jumpSound);

            Set(State.jumping, true);
            airJumpCounter++;

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

        if (Input.GetButtonUp("Jump") && rb.velocity.y > 3)
        {
            Set(State.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;
            }
            Set(State.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 = !Is(State.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;

            float jumpDirection = Toggle(State.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);
    }

    public override PersistentObject.SaveData Save()
    {
        SaveData playerdata = new SaveData();
        if (CanSave())
        {
            return new SaveData 
            {
                saveID = saveID,
                Health = Health,
                maxHealth = maxHealth,
                maxTotalHealth = maxTotalHealth,
                heartShards = heartShards,

                Mana = Mana,
                manaPenalty = manaPenalty,
                manaOrbs = excessMaxManaUnits,
                orbShard = manaShards,

                unlocks = (byte)abilities,

                position = transform.position,

                lastScene = SceneManager.GetActiveScene().name
            };
        }

        return null;
    }

    public override bool Load(PersistentObject.SaveData data)
    {
        // If the data passed in is invalid, return.
        if (data == null) return false;
        SaveData playerData = data as SaveData;
        if (playerData == null) return false;

        // Otherwise continue loading the data.
        //SceneManager.LoadScene(playerData.lastScene);

        Health = playerData.Health;
        maxHealth = playerData.maxHealth;
        heartShards = playerData.heartShards;

        manaPenalty = playerData.manaPenalty;
        excessMaxManaUnits = playerData.manaOrbs;
        manaShards = playerData.orbShard;
        mana = playerData.Mana;

        abilities = (Abilities)playerData.unlocks;

        transform.position = playerData.position;
        return true;
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData 
    {
        public float positionX, positionY, positionZ;
        public float manaPenalty;
        public int Health;
        public int maxHealth;
        public int maxTotalHealth;
        public int heartShards;
        public float Mana;
        public int manaOrbs;
        public int orbShard;
        public byte unlocks;
        public string lastScene;

        public Vector3 position
        {
            get { return new Vector3(positionX, positionY, positionZ); }
            set
            {
                positionX = value.x;
                positionY = value.y;
                positionZ = value.z;
            }
        }

    }
}

c. Letting MapManager save visited scenes

Notice in the updated GameManager script above, we did not move the sceneNames variable from SaveData over to our GameManager. This is because we will be letting the MapManager handle the saving of which maps have been unlocked:

MapManager.cs

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

public class MapManager : MonoBehaviourPersistentObject
{
    [SerializeField] GameObject[] maps;
    public static SaveData data = new SaveData();

    Bench bench;
    // Start is called before the first frame update
    private void OnEnable()
    {
        bench = FindObjectOfType<Bench>();
        if(bench != null)
        {
            if(bench.interacted)
            {
                UpdateMap();
            }
        }
        if(data == null) Load( Bench.QuickLoad(this) );
        UpdateMap();
    }
    void UpdateMap()
    {
        Debug.Log("updated");
        var savedScenes = SaveData.Instance.sceneNames;

        for(int i = 0; i < maps.Length; i++)
        {
            if(savedScenes.Contains("Cave_" + (i + 1))) //this is i + 1 as arrays start from 0
            if(data.currentMaps.Contains("Cave_" + (i + 1)))
            {
                maps[i].SetActive(true);
            }
            else
            {
                maps[i].SetActive(false);
            }
        }
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData
    {
        public HashSet<string> visitedScenes = new HashSet<string>(),
                               currentMaps = new HashSet<string>();
    }

    public override PersistentObject.SaveData Save() 
    {
        if (CanSave())
        {
            data.currentMaps = data.visitedScenes;
            return data;
        }
        return null;
    }

    public override bool Load(PersistentObject.SaveData data)
    {
        SaveData s = (SaveData)data;
        if (s != null) 
        {
            MapManager.data = s;
            return true;
        }
        return false;
    }
}

The main change that we make to MapManager is letting it save its own data using the Bench Universal Save System. Like GameManager, we store all the data we need to save in a variable called data, so that we can save the object directly whenever we save the game.

Instead of having a single variable recording the scenes we have visited, we’ve also introduced 2 such variables instead (in the nested MapManager.SaveData object). This is because Hollow Knight only updates the map whenever we sit on a bench and make a save — hence, we have a visitedScenes variable that records all the scenes we have visited, and whenever we Save() the game, we will also move the value of visitedScenes to currentMaps to make the newly-visited scenes reflect in our map.

Of course, to get our MapManager to display properly, we will also need to make sure it is assigned a Save ID to our Map Handler in our Game HUD prefab. All MapManager components should share the same Save ID, as they are supposed to be the same across the whole game.

MapManager Save ID
Remember to assign the Save ID to the Prefab, as you need it to apply to all your Map Managers in all your Scenes.

d. Retooling our scripts

Our old GameManager script had a THKDefeated variable that we removed from the class. Instead of storing it directly in the GameManager, we created a flag enum to store all events we want to take note of. We can lengthen this enum to store more events as we develop this project further along, as shown in the yellow highlight below:

[System.Flags] public enum Flags : long { None = 0, THKDefeated = 1, OtherBoss = 2 }

Then, we create a flags variable in our GameManager.SaveData object to track the status of all the flags, as well as a Is() function we can easily use to check the status of this flag, and a Set() function we can easily use to set the value of any of our flags:

public Flags flags;
public static bool Is(Flags f) { return globalData.flags.HasFlag(f); }
public static void Set(Flags f, bool on)
{ 
    if(on) globalData.flags |= f; 
    else globalData.flags &= f;
}

Because of these changes, we’ll need to update our other scripts to stop using THKDefeated, and instead use the new flags system.

On top of that, we’ll also need to remove references to the old SaveData script in our existing scripts as well.

THKEvents.cs

using System.Collections;
using UnityEngine;
public class THKEvents : MonoBehaviour
{
    void SlashDamagePlayer()
    {
        if (PlayerController.Instance.transform.position.x - transform.position.x != 0)
        {
            Hit(TheHollowKnight.Instance.SideAttackTransform, TheHollowKnight.Instance.SideAttackArea);
        }
        else if (PlayerController.Instance.transform.position.y > transform.position.y)
        {
            Hit(TheHollowKnight.Instance.UpAttackTransform, TheHollowKnight.Instance.UpAttackArea);
        }
        else if (PlayerController.Instance.transform.position.y < transform.position.y)
        {
            Hit(TheHollowKnight.Instance.DownAttackTransform, TheHollowKnight.Instance.DownAttackArea);
        }
    }
    void Hit(Transform _attackTransform, Vector2 _attackArea)
    {
        Collider2D[] _objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0);

        for (int i = 0; i < _objectsToHit.Length; i++)
        {

            if (_objectsToHit[i].GetComponent<PlayerController>() != null && !PlayerController.Instance.Is(PlayerController.State.invincible))
            {
                _objectsToHit[i].GetComponent<PlayerController>().TakeDamage(TheHollowKnight.Instance.damage);

                if (PlayerController.Instance.Is(PlayerController.State.alive))
                {
                    GameManager.Stop();
                }
            }
        }
    }
    void Parrying()
    {
        TheHollowKnight.Instance.parrying = true;
    }
    void BendDownCheck()
    {
        if(TheHollowKnight.Instance.barrageAttack)
        {
            StartCoroutine(BarrageAttackTransition());
        }
        if(TheHollowKnight.Instance.outbreakAttack)
        {
            StartCoroutine(OutbreakAttackTransition());
        }
        if(TheHollowKnight.Instance.bounceAttack)
        {
            TheHollowKnight.Instance.anim.SetTrigger("Bounce1");
        }
    }
    void BarrageOrOutbreak()
    {
        if(TheHollowKnight.Instance.barrageAttack)
        {
            TheHollowKnight.Instance.StartCoroutine(TheHollowKnight.Instance.Barrage());
        }
        if (TheHollowKnight.Instance.outbreakAttack)
        {
            TheHollowKnight.Instance.StartCoroutine(TheHollowKnight.Instance.Outbreak());
        }
    }
    IEnumerator BarrageAttackTransition()
    {
        yield return new WaitForSeconds(1f);
        TheHollowKnight.Instance.anim.SetBool("Cast", true);
    }
    IEnumerator OutbreakAttackTransition()
    {
        yield return new WaitForSeconds(1f);
        TheHollowKnight.Instance.anim.SetBool("Cast", true);
    }
    void DestroyAfterDeath()
    {
        SpawnBoss.Instance.IsNotTrigger();
        TheHollowKnight.Instance.DestroyAfterDeath();
        GameManager.Instance.THKDefeated = true;
        GameManager.Set(GameManager.Flags.THKDefeated, true);
        SaveData.Instance.SaveBossData();
        SaveData.Instance.SavePlayerData();
        Terresquall.Bench.SaveGameAsync();
    }

}

SpawnBoss.cs

using System.Collections;
using UnityEngine;

public class SpawnBoss : MonoBehaviour
{
    public static SpawnBoss Instance;
    [SerializeField] Transform spawnPoint;
    [SerializeField] GameObject boss;
    [SerializeField] Vector2 exitDirection;
    bool callOnce;
    BoxCollider2D col;

    private void Awake()
    {
        if (TheHollowKnight.Instance != null)
        {
            Destroy(TheHollowKnight.Instance);
            callOnce = false;
            col.isTrigger = true;
        }

        if (GameManager.Instance.THKDefeated)
        if (GameManager.Is(GameManager.Flags.THKDefeated))
        {
            callOnce = true;
        }

        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            Instance = this;
        }
    }
    // Start is called before the first frame update
    void Start()
    {
        col = GetComponent<BoxCollider2D>();
    }


    private void OnTriggerEnter2D(Collider2D _other)
    {
        if(_other.CompareTag("Player") && !callOnce && !GameManager.Instance.THKDefeated)
        if(_other.CompareTag("Player") && !callOnce && !GameManager.Is(GameManager.Flags.THKDefeated))
        {
            StartCoroutine(WalkIntoRoom());
            callOnce = true;
        }
    }
    IEnumerator WalkIntoRoom()
    {
        StartCoroutine(PlayerController.Instance.WalkIntoNewScene(exitDirection, 1));

        PlayerController.Instance.Set(PlayerController.State.cutscene, true);
        yield return new WaitForSeconds(1f);
        col.isTrigger = false;
        Instantiate(boss, spawnPoint.position, Quaternion.identity);
    }
    public void IsNotTrigger()
    {
        col.isTrigger = true;
    }
}

e. Updating or removing the old SaveData script

Because the original SaveData script also references THKDefeated, we will also have to update it if we want to continue keeping it. Alternatively, you can also delete it, since it is no longer in use.

If you are planning to keep the script, it will be good to label the script [System.Obsolete] as well, as it is no longer in use.

SaveData.cs

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

[System.Obsolete]
[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 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);


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


                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.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.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;
            THKDefeated = GameManager.Set(GameManager.Flags.THKDefeated, true);
            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;
                GameManager.Set(GameManager.Flags.THKDefeated);
            }
        }
        else
        {
            Debug.Log("Boss doesnt exist");
        }
    }
    #endregion
}

Or we can simply delete the script.

6. Updating our save point script

The last thing we need to do before our new save system is up and running is to modify is our save point script so it uses the new Bench.SaveGame() or Bench.SaveGameAsync() functions to save the game.

Since we’re doing that, we are also going to add a slight visual improvement to the saving process as well, by making the player character sit on the bench when the saving happens:

Bench sitting and standing
The little indicator at the bottom right is included with the Bench Universal Save System.

a. Modifying our save point script

The first thing that we need to do is to modify our save point script, so that it uses the new save function and allows our player character to sit on it.

💡 Because our save point script is named Bench, and the Bench Universal Save System has a Bench class which handles all the important saving functionality, we’ll rename our script to SavePoint.cs instead.

BenchSavePoint.cs

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

public class BenchSavePoint : MonoBehaviourPersistentObject
{
    bool inRange = false;
    public bool interacted;
    public Vector2 anchor;

    public Vector3 GetAnchorPosition() { return transform.position + (Vector3)anchor; }

    private void OnDrawGizmosSelected() {
        Gizmos.DrawIcon((Vector2)transform.position + anchor, "sv_icon_dot8_sml");
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetButtonDown("Interact") && inRange)
        {
            interacted = true;

            SaveData.Instance.benchSceneName = SceneManager.GetActiveScene().name;
            SaveData.Instance.benchPos = new Vector2(gameObject.transform.position.x, gameObject.transform.position.y);
            SaveData.Instance.SaveBench();
            SaveData.Instance.SavePlayerData();

            Debug.Log("benched");
        }
        if (Input.GetButtonDown("Interact"))
        {
            if (interacted)
            {
                PlayerController.Instance.Unrest();
            } 
            else if(inRange)
            {
                interacted = true;

                GameManager.globalData.benchSaveID = saveID;
                PlayerController.Instance.Rest(this);

                Bench.SaveGameAsync();
            }
                
        }
    }

    public override SaveData Save() { return null; }
    public override bool Load(SaveData data) { return true; }

    void OnTriggerEnter2D(Collider2D _collision)
    {
        if (_collision.CompareTag("Player")) inRange = true;
    }

    private void OnTriggerExit2D(Collider2D _collision)
    {
        if (_collision.CompareTag("Player"))
        {
            inRange = false;
        }
    }
}

There are a couple of things happening here:

  1. We convert the script to a PersistentObject, but create empty Save() and Load() functions for it. This is because we don’t actually need it to save. Later on, we will be modifying our GameManager script to record the last bench that we saved on, and we will be recognising the bench via its save ID and the Bench.FindObjectBySaveID() method from the asset pack.
  2. An anchor variable is created, which is displayed through OnDrawGizmos(). This is the point the player character will attach to when he is sitting on the bench.
Anchor for save point
The dot is drawn by OnDrawGizmos().
  1. We update the interaction code so that it saves only when the player isn’t sitting on it. And when the player is sitting, we instead get the player off it when it is interacted with.

b. Adding a Rest() method for PlayerController

Next, we’ll need to add the Rest() (and Unrest()) methods to the PlayerController, which are used by our new SavePoint script to mount and unmount the player from it. We will also modify the Load() function that we wrote prior so that it retrieves the bench we sat on when we saved the game last, and put our player character on that Bench.

PlayerController.cs

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

public class PlayerController : PersistentObject
{
    [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 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;
    SavePoint currentSavePoint;

    public static PlayerController Instance;


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

    [System.Flags]
    public enum State
    {
        jumping = 1, dashing = 2, recoilingX = 4, recoilingY = 8,
        lookingRight = 16, invincible = 32, healing = 64, casting = 128,
        cutscene = 256, alive = 512
    }

    public State state;
    public bool Is(State s) { return state.HasFlag(s); }
    public void Set(State s, bool on)
    {
        if (on) state |= s;
        else state &= ~s;
    }
    public bool Toggle(State s)
    {
        state ^= s;
        return Is(s);
    }

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

        DontDestroyOnLoad(gameObject);
    }

    public void Rest(SavePoint sp)
    {
        // Disallow resting if we are already on a save point,
        // or if we are in a state not conducive for saving.
        if(currentSavePoint) return;
        if (!Is(State.alive)) return;
        if (Is(State.casting | State.cutscene | State.dashing | State.healing)) return;
        if (Is(State.jumping | State.recoilingX | State.recoilingY)) return;

        // Set the player to be invincible and in a cutscene.
        // Parent it to the bench and play the respective animations.
        // Then, disable the Rigidbody and heal the player.
        Set(State.cutscene | State.invincible, true);
        transform.SetParent(sp.transform);
        anim.SetBool("Resting", true);
        transform.localPosition = sp.anchor;
        rb.simulated = false;
        sp.interacted = true;
        currentSavePoint = sp;
        Health += 99;
    }

    public void Unrest()
    {
        // Disallow unresting if we are not on a save point.
        if (!currentSavePoint) return;

        // Unset everything that was set in Rest().
        currentSavePoint.interacted = false;
        Set(State.cutscene | State.invincible, false);
        transform.SetParent(null);
        rb.simulated = true;
        anim.SetBool("Resting", false);
        currentSavePoint = null;
        DontDestroyOnLoad(gameObject);
    }

    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();
        anim = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
        gravity = rb.gravityScale;
        Mana = mana;
        Health = maxHealth;
        if (Health > 0) Set(State.alive, true);
        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 (Is(State.cutscene) || GameManager.Instance.isPaused) return;

        if (Is(State.alive))
        {
            HandleRestoreManaWithExcess();
            GetInputs();
            ToggleMap();
            ToggleInventory();
            UpdateJumpVariables();
            UpdateCameraYDampForPlayerFall();
            FlashWhileInvincible();
        } else return;

        if (Is(State.dashing)) return;
        if (!isWallJumping)
        {
            Move();
        }
        Heal();
        CastSpell();
        if (Is(State.healing)) return;
        if (!isWallJumping)
        {
            Flip();
            Jump();
        }

        if (abilities.HasFlag(Abilities.wallJump))
        {
            WallSlide();
            WallJump();
        }

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

    private void FixedUpdate()
    {
        if (Is(State.cutscene)) return;

        if (Is(State.dashing) || Is(State.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);
            Set(State.lookingRight, false);
        }
        else if (xAxis > 0)
        {
            transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y);
            Set(State.lookingRight, true);
        }
    }

    private void Move()
    {
        if (Is(State.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;
        Set(State.dashing, true);
        anim.SetTrigger("Dashing");
        audioSource.PlayOneShot(dashAndAttackSound);
        rb.gravityScale = 0;
        int _dir = Is(State.lookingRight) ? 1 : -1;
        rb.velocity = new Vector2(_dir * dashSpeed, 0);
        if (Grounded()) Instantiate(dashEffect, transform);
        yield return new WaitForSeconds(dashTime);
        rb.gravityScale = gravity;
        Set(State.dashing, false);
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }

    public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay)
    {
        Set(State.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);
        Set(State.invincible, false);
        Set(State.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 = Is(State.lookingRight) ? 1 : -1;

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


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

        if (objectsToHit.Length > 0)
        {
            Set(recoilState, 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 (Is(State.recoilingX))
        {
            if (Is(State.lookingRight))
            {
                rb.velocity = new Vector2(-recoilXSpeed, 0);
            }
            else
            {
                rb.velocity = new Vector2(recoilXSpeed, 0);
            }
        }

        if (Is(State.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 (Is(State.recoilingX) && stepsXRecoiled < recoilXSteps)
        {
            stepsXRecoiled++;
        }
        else
        {
            StopRecoilX();
        }
        if (Is(State.recoilingY) && stepsYRecoiled < recoilYSteps)
        {
            stepsYRecoiled++;
        }
        else
        {
            StopRecoilY();
        }

        if (Grounded())
        {
            StopRecoilY();
        }
    }
    void StopRecoilX()
    {
        stepsXRecoiled = 0;
        Set(State.recoilingX, false);
    }
    void StopRecoilY()
    {
        stepsYRecoiled = 0;
        Set(State.recoilingY, false);
    }
    public void TakeDamage(float _damage)
    {
        if (Is(State.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()
    {
        Set(State.invincible, true);
        GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity);
        Destroy(_bloodSpurtParticles, 1.5f);
        anim.SetTrigger("TakeDamage");
        yield return new WaitForSeconds(1f);
        Set(State.invincible, false);
    }

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

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


    IEnumerator Death()
    {
        Set(State.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;
        manaPenalty = 0.5f;

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

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

        Bench.SaveGame();
    }

    public void Respawned(float manaPenalty = 0.5f)
    {
        if (!Is(State.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;
            Set(State.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.Clamp(value, 0, maxHealth);
                UIManager.UpdateHealthUI(health, maxHealth, excessHealth);
            }
        }
    }
    public int ExcessHealth
    {
        get { return excessHealth; }
        set
        {
            if (excessHealth != value)
            {
                excessHealth = Mathf.Max(value, 0);
                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() && !Is(State.dashing))
        {
            Set(State.healing, true);
            anim.SetBool("Healing", true);

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

            //drain mana

            Mana -= Time.deltaTime * healManaCostPerSecond;
        }
        else
        {
            Set(State.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)
        {
            Set(State.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())) && abilities.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 (Is(State.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
            }
            Set(State.recoilingX, true);

            Mana -= manaSpellCost;

            yield return new WaitForSeconds(0.35f);
        }

        //up cast
        else if (yAxis > 0 && abilities.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()) && abilities.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);
        Set(State.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 && !Is(State.jumping))
        {
            if (Input.GetButtonDown("Jump"))
            {
                audioSource.PlayOneShot(jumpSound);
            }

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

            Set(State.jumping, true);
        }

        if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && abilities.HasFlag(Abilities.variableJump))
        {
            audioSource.PlayOneShot(jumpSound);

            Set(State.jumping, true);
            airJumpCounter++;

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

        if (Input.GetButtonUp("Jump") && rb.velocity.y > 3)
        {
            Set(State.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;
            }
            Set(State.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 = !Is(State.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;

            float jumpDirection = Toggle(State.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);
    }

    public override PersistentObject.SaveData Save()
    {
        SaveData playerdata = new SaveData();
        if (CanSave())
        {
            return new SaveData
            {
                saveID = saveID,
                Health = Health,
                maxHealth = maxHealth,
                maxTotalHealth = maxTotalHealth,
                heartShards = heartShards,

                Mana = Mana,
                manaPenalty = manaPenalty,
                manaOrbs = excessMaxManaUnits,
                orbShard = manaShards,

                unlocks = (byte)abilities,

                position = transform.position,

                lastScene = SceneManager.GetActiveScene().name
            };
        }

        return null;
    }

    public override bool Load(PersistentObject.SaveData data)
    {
        // If the data passed in is invalid, return.
        if (data == null) return false;
        SaveData playerData = data as SaveData;
        if (playerData == null) return false;

        // Otherwise continue loading the data.
        //SceneManager.LoadScene(playerData.lastScene);

        Health = playerData.Health;
        maxHealth = playerData.maxHealth;
        heartShards = playerData.heartShards;

        manaPenalty = playerData.manaPenalty;
        excessMaxManaUnits = playerData.manaOrbs;
        manaShards = playerData.orbShard;
        mana = playerData.Mana;

        abilities = (Abilities)playerData.unlocks;

        transform.position = playerData.position;
        transform.position = playerData.position;
        SavePoint lastSavePoint = Bench.FindObjectBySaveID<SavePoint>(GameManager.globalData.benchSaveID);
        if (lastSavePoint) Rest(lastSavePoint);
        return true;
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData 
    {
        public float positionX, positionY, positionZ;
        public float manaPenalty;
        public int Health;
        public int maxHealth;
        public int maxTotalHealth;
        public int heartShards;
        public float Mana;
        public int manaOrbs;
        public int orbShard;
        public byte unlocks;
        public string lastScene;

        public Vector3 position
        {
            get { return new Vector3(positionX, positionY, positionZ); }
            set
            {
                positionX = value.x;
                positionY = value.y;
                positionZ = value.z;
            }
        }
    }
}

c. Modifying GameManager to save the last bench used

Finally, to finish up, we need to modify GameManager so that it saves a benchSaveID in its save data, which we were just updating our PlayerController to retrieve. able to save the bench that our player is sitting on, and load the player onto the bench whenever we load the game.

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using Terresquall;
using System.Threading.Tasks;

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

    public Vector2 platformingRespawnPoint;
    public Vector2 respawnPoint;
    public Vector2 defaultRespawnPoint;

    public Shade shadePrefab;
    Shade currentShade;

    [SerializeField] private UIScreen pauseMenu;

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

    public static SaveData globalData = new SaveData();

    public static GameManager Instance { get; private set; }
    private void Awake()
    {
        // Spawn shade if appropriate in the Scene before destroying this instance.
        if (PlayerController.Instance != null)
        {
            if (globalData.shadeScene == SceneManager.GetActiveScene().name)
            {
                SpawnShade(globalData.shadePosition, globalData.shadeRotation);
            }
        }

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

        // Load the GameManager SaveData if there is any found.
        DontDestroyOnLoad(gameObject);
        UpdateMap();
    }

    async void Start()
    {
        await Task.Delay(100);
        Bench.QuickLoad();

        // If the code gets here, we check whether we need to spawn a shade again.
        if (PlayerController.Instance != null) {
            if (globalData.shadeScene == SceneManager.GetActiveScene().name) {
                SpawnShade(globalData.shadePosition, globalData.shadeRotation);
            }
        }
    }

    public static Shade SpawnShade(Vector3 position, Quaternion rotation)
    {
        if(Instance.currentShade) return null;
        Instance.currentShade = Instantiate(Instance.shadePrefab, position, rotation);
        return Instance.currentShade;
    }

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

    protected override void Reset()
    {
        saveID = "GameManager";
        defaultRespawnPoint = FindObjectOfType<PlayerController>().transform.position;
        pauseMenu = FindObjectOfType<UIScreen>();
    }

    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 UpdateMap()
    {
        string currentSceneName = SceneManager.GetActiveScene().name;
        MapManager.data.visitedScenes.Add(currentSceneName);
    }

    public void RespawnPlayer(float manaPenalty = 0f)
    {
        // If a benchScene is saved, it means that we benched somewhere.
        if (!string.IsNullOrEmpty(globalData.lastScene) && globalData.lastScene != SceneManager.GetActiveScene().name) //load the bench's scene if it exists.
        {
            SceneManager.LoadScene(globalData.lastScene);
            SavePoint sp = Bench.FindObjectBySaveID(globalData.benchSaveID) as SavePoint;
            if(sp) respawnPoint = sp.GetAnchorPosition();
        }
        else
        {
            // Otherwise we spawn at the default respawn point.
            respawnPoint = defaultRespawnPoint;
        }

        PlayerController.Instance.transform.position = respawnPoint;
        UIManager.Instance.deathScreen.Deactivate();
        PlayerController.Instance.Respawned(manaPenalty);
    }

    public override PersistentObject.SaveData Save()
    {
        if (CanSave())
        {
            globalData.saveID = saveID;
            return globalData;
        }
        
        Debug.LogError("GameManager failed to save.");
        return null;

    }

    public override bool Load(PersistentObject.SaveData data)
    {
        if (data == null) return false;
        globalData = data as SaveData;
        if (globalData == null) return false;
        return true;
    }

    [System.Flags] public enum Flags : long { None = 0, THKDefeated = 1 }
    public static bool Is(Flags f) { return globalData.flags.HasFlag(f); }
    public static void Set(Flags f, bool on)
    { 
        if(on) globalData.flags |= f; 
        else globalData.flags &= f;
    }

    [System.Serializable]
    public new class SaveData : PersistentObject.SaveData
    {
        public string lastScene;
        public string benchSaveID;

        public string shadeScene;
        public float shadePositionX, shadePositionY, shadePositionZ;
        public float shadeRotationX, shadeRotationY, shadeRotationZ, shadeRotationW;
        public Flags flags;

        public Vector3 shadePosition 
        {
            get { return new Vector3(shadePositionX, shadePositionY, shadePositionZ); }
            set 
            { 
                shadePositionX = value.x;
                shadePositionY = value.y;
                shadePositionZ = value.z;
            }
        }

        public Quaternion shadeRotation 
        {
            get { return new Quaternion(shadeRotationX, shadeRotationY, shadeRotationZ, shadeRotationW); }
            set 
            {
                shadeRotationX = value.x;
                shadeRotationY = value.y;
                shadeRotationZ = value.z;
                shadeRotationW = value.w;
            }
        }
    }
}

d. Adding the rest animation

In the Rest() function that we added to the PlayerController, one of the things that we did was trigger a boolean animation parameter called "Resting". This is supposed to trigger the sitting animation of the player.

public void Rest(SavePoint sp)
{
    // Disallow resting if we are already on a save point,
    // or if we are in a state not conducive for saving.
    if(currentSavePoint) return;
    if (!Is(State.alive)) return;
    if (Is(State.casting | State.cutscene | State.dashing | State.healing)) return;
    if (Is(State.jumping | State.recoilingX | State.recoilingY)) return;

    // Set the player to be invincible and in a cutscene.
    // Parent it to the bench and play the respective animations.
    // Then, disable the Rigidbody and heal the player.
    Set(State.cutscene | State.invincible, true);
    transform.SetParent(sp.transform);
    anim.SetBool("Resting", true);
    transform.localPosition = sp.anchor;
    rb.simulated = false;
    sp.interacted = true;
    currentSavePoint = sp;
    Health += 99;
}

Which means we have to set it up:

Knight_Sitting_01
Right-click and Save as, then import this into your project.

Although the sitting is only one frame, we can make use of the other sprites we already have of the knight to set up a transition animation to and fro:

Cast transition into sitting.
The cast animations (Knight_cast_01 to 05) are also perfect as a transition into the sitting animation.

So create a new clip from the Animation window, call it Player_Sitting, then save the clip together with the rest of your animations in your project.

Loop time unchecked
Remember to ensure that Loop Time is unchecked in the newly-created clip, as the sitting animation does not loop.

Then, in the new animation clip, add the 5 cast frames, together with the sitting frame as the 6th one.

Knight Sitting Animation
How the animation looks in action. Remember that you can adjust the Sample Rate to make it faster or slower.

Once done, you’ll need to:

  1. Add a new boolean parameter in your Animator for the player called Resting. Then;
  2. Move your new Player_Sitting animation near the Idle and Walk animations.
  3. Duplicate the Player_Sitting animation and name it Player_Unsitting.
  4. Link Player_IdlePlayer_SittingPlayer_UnsittingPlayer_Idle, as well as Player_WalkPlayer_Sitting.
Knight new animator setup
  1. Then, for the transition (i.e. arrow) between Player_Idle / Player_Walk and Player_Sitting, set the Condition to Resting is true, with 0 Exit Time and 0 Transition Duration.
  2. For the transition (i.e. arrow) between Player_Sitting and Player_Unsitting, set the Condition to Resting is false, with 0 Exit Time and 0 Transition Duration.
  3. For the transition (i.e. arrow) between Player_Unsitting and Player_Idle, set no Condition, and an Exit Time of 1 with 0 Transition Duration.

If your Animator is properly set up, when you try to sit on a Bench, the player character should now hop on and smoothly transition onto it.

e. Adding the Save Indicator

Lastly, we will also need to add the save indicator icon to our Canvas, so that it appears when we save the game. To do that, we will need to modify our HUD Canvas prefab (make sure you don’t add it directly onto the Scene if you already have a prefab for the HUD UI), and drag the Default Indicator from Bench Universal Save System > Prefabs > Save Indicators onto the HUD Canvas:

Save Indicator on Canvas
Drag ‘n drop, that’s all that’s needed.

7. Adding save slots in the Main Menu

Now that the scripts we want to save are all updated, we will need to modify our main menu so that it uses the save slots which come with the Bench Universal Save System asset.

a. Adding the save slots screen

The Bench Universal Save System asset comes with prefabs for displaying game save slots. To display these slots, go to the Bench Universal Save System > Prefabs folder, and drag out the prefab onto the Canvas in the Main Menu Scene.

Native Save Slots UI
You don’t need to create your own Save Slots UI, as the Bench Universal Save System comes with premade ones.

Add a UIScreen component to it, so that we can use the Activate() function to fade it in when the Start button is pressed. Then deactivate the GameObject.

Add a UIScreen component to the Native Save Slots Canvas.
Add a UIScreen component to the Native Save Slots Canvas.

Following that, change the action in your Start button so that it calls UIScreen.Activate() on it, instead of the previous LoadScene() function to load the first level directly.

Reconfigure Start button
Make sure the checkbox under UIScreen.Activate is checked, as it will disable all the other screens when the one we have marked finishes activating.

Lastly, add a Back to Main Menu button onto the Native Save UI Canvas, and make it activate the Main Menu UI Screen when clicked, so we can transition between both scenes.

Back to Main Menu setup
We should be able to click between the Save Slots screen and Main Menu after this.

b. Configuring the Save Slots

To configure the save slots, we will first need to find the Settings file inside the Bench Universal Save System folder. This file allows us to configure how the save system will work in our game, as well as view existing saves:

Max Save Slots - Bench
Set the Max Save Slots in the Settings asset to control how many save slots your game will have.

Once the Max Save Slots is set, navigate to the UI Save Slot Manager under the Native UI Save Slot prefab, and find the Save Slot Template under the Scroll View component. You should find a UISaveSlot component that you need to configure to determine how the save slot will display.

UI Save Slot important settings
These are the important settings you’ll need to tweak to get the save slot to work. To learn more about how the settings work, read the Bench Universal Save System asset guide.

Here’s a quick summary of what each of the circled attributes control:

  • Default Scene: Controls the scene that is loaded by default if there is no save data.
  • Mappings: Control what attributes are shown on your save slots.
  • No Save Data Found: For slots without save data, this is the message that will be shown.

After you are done setting the attributes, select the UI Save Slot Manager component on top of the UI Save Slot Template, and click on Generate Save Slot Elements button in the Inspector. This will generate the number of slots for the save slot UI that you set in Max Save Slots.

Generating the Save Slots
Your save system is ready to go.

8. Conclusion

With this done, you should now be able to save and load your game properly using multiple slots. The most important thing you have to ensure you do is to assign a non-repeated Save ID to all of your pickups in your scenes. Otherwise, their states will not be saved.

Silver Patrons will be able to download the project files as usual.