This article will be free until the video is released.
In Part 14, we are going to be improving upon the pickup system, and the UI overlay that pops up when you pick up an item. We are also going to be using the hit stop system that we made in Part 11 for the UI interlude that happens whenever we pick up an item.
1. The problem with the current pickups
The current way that the pickups are coded in the project are suboptimal. This is because we have a different script for each pickup, and none of these scripts are related through a parent class. What this causes us to end up with, are scripts that are almost 99% similar.
Take the UnlockDash
and UnlockDownCast
scripts for example. They are virtually identical.
UnlockDash.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UnlockDash : MonoBehaviour
{
[SerializeField] GameObject particles;
[SerializeField] GameObject canvasUI;
bool used;
// Start is called before the first frame update
void Start()
{
if (PlayerController.Instance.unlockedDash)
{
Destroy(gameObject);
}
}
private void OnTriggerEnter2D(Collider2D _collision)
{
if (_collision.CompareTag("Player") && !used)
{
used = true;
StartCoroutine(ShowUI());
}
}
IEnumerator ShowUI()
{
GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity);
Destroy(_particles, 0.5f);
yield return new WaitForSeconds(0.5f);
gameObject.GetComponent<SpriteRenderer>().enabled = false;
canvasUI.SetActive(true);
yield return new WaitForSeconds(4f);
PlayerController.Instance.unlockedDash = true;
SaveData.Instance.SavePlayerData();
canvasUI.SetActive(false);
Destroy(gameObject);
}
}
UnlockDownCast.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UnlockDownCast : MonoBehaviour
{
[SerializeField] GameObject particles;
[SerializeField] GameObject canvasUI;
bool used;
// Start is called before the first frame update
void Start()
{
if (PlayerController.Instance.unlockedDownCast)
{
Destroy(gameObject);
}
}
private void OnTriggerEnter2D(Collider2D _collision)
{
if (_collision.CompareTag("Player") && !used)
{
used = true;
StartCoroutine(ShowUI());
}
}
IEnumerator ShowUI()
{
GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity);
Destroy(_particles, 0.5f);
yield return new WaitForSeconds(0.5f);
gameObject.GetComponent<SpriteRenderer>().enabled = false;
canvasUI.SetActive(true);
yield return new WaitForSeconds(4f);
PlayerController.Instance.unlockedDownCast = true;
SaveData.Instance.SavePlayerData();
canvasUI.SetActive(false);
Destroy(gameObject);
}
}
This is not only a waste of code—it also makes it difficult to deploy any global updates or patches that affect all pickups. Because we have 6 pickup scripts that unlock abilities, if we wanted to—for instance—change the UI animation for all pickups, we’ll have to update all 6 scripts similarly.
Obviously, this is not the most conducive for game development—especially if you have dozens or hundreds of pickups.
2. The new Pickup system
To this end, we are going to be reorganising our pickup scripts so that code for their behavioural logic is not replicated across multiple scripts. This can be done by setting up a more organised hierarchical structure for all pickup scripts.
a. Class Hierarchy for Pickups
Instead of having a separate script for every single pickup we have in the game, we’ll create 3 scripts that will help us handle the behaviour of all pickups in the game. This simplifies our codebase, and ensures that when we want to make mechanical changes to all our pickups in future, we don’t have to update all of the scripts.
The Pickup
scripts that we will have in the game will be arranged in the following order:

Each of these scripts will serve a very specific purpose, and are coded in such a way that we won’t have to repeat the logic across multiple scripts:
- The
Pickup
class handles the base logic of all pickups in our game, i.e. the animation of the pickup, the behaviour of being picked up, as well as the application of the pickup’s effects. - The
InstantPickup
will be a component that can be used for all pickups that grant instantaneous effects, such as blue health, or health and mana pickups (if you want to implement those in your game). They can also be used for currency pickups (when we eventually implement it). - The
PopupPickup
will contain functionality for all pickups that stop the game and show a UI overlay after they are picked up, such as the health / mana upgrades, or ability unlocks.- Under the
PopupPickup
, we will have theUnlockAbilityPickup
, which is meant to replace our old scripts:UnlockDash
,UnlockDownCast
,UnlockSideCast
,UnlockUpCast
,UnlockVariableJump
andUnlockWallJump
. - The
UpgradePickup
is also underPopupPickup
, and it will replace the oldOrbShard
andHeartShards
scripts, as well as theAddManaOrbs
andIncreaseMaxHealth
scripts.
- Under the
b. Implementing the new Pickup
scripts
The first script that we will be implementing is the Pickup
script:
Pickup.cs
using UnityEngine; using System.Collections; public abstract class Pickup : MonoBehaviour { protected bool used; // Used to track if the pickup has been picked up. [Tooltip("How long after touching the pickup before the Use() function fires.")] public float useDelay = 0f; [Tooltip("How long after using the pickup before the Used() function fires.")] public float usedDelay = 0f; // Struct to allow us to set a bobbing animation for the pickup. [System.Serializable] public struct Animation { public float frequency; public Vector3 direction; public Vector3 torque; [Tooltip("Effect that plays when the pickup is touched.")] public ParticleSystem destroyEffectPrefab; // Effect that plays when pickup is picked up. [Tooltip("Effect that plays on the target affected by the pickup.")] public ParticleSystem targetEffectPrefab; // Effect that plays on the target picking this up. } public Animation animation = new Animation { frequency = 2f, direction = new Vector2(0, 0.3f) }; Vector3 initialPosition; float initialOffset; protected virtual void Update() { // Handles the bobbing animation. transform.position = initialPosition + animation.direction * Mathf.Sin((Time.time + initialOffset) * animation.frequency); transform.Rotate(animation.torque * Time.deltaTime); } protected virtual void Start() { // Settings for the bobbing animation. initialPosition = transform.position; initialOffset = Random.Range(0, animation.frequency); } protected virtual void OnTriggerEnter2D(Collider2D other) { if (used) return; // Don't allow pickup if its already used. // Otherwise, only if the player is touching, begin the pickup process. if (other.TryGetComponent(out PlayerController p)) { StartCoroutine(HandleUse(p)); } } // Overrideable function, determines what happens when we first touch the pickup. public virtual void Touch(PlayerController p) { // Play the effect on Pickup. if (animation.destroyEffectPrefab) { ParticleSystem fx = Instantiate(animation.destroyEffectPrefab, transform.position, Quaternion.identity); Destroy(fx, fx.main.duration); // Ensure that this effect is cleaned up if it is not properly set up. } } // This is where you implement the effects of the item. public virtual void Use(PlayerController p) { used = true; if(animation.targetEffectPrefab) { ParticleSystem fx = Instantiate(animation.targetEffectPrefab, p.transform); Destroy(fx, fx.main.duration); // Ensure that this effect is cleaned up if it is not properly set up. } } // What happens after the item is consumed. public virtual void Used(PlayerController p) { Destroy(gameObject); } protected virtual IEnumerator HandleUse(PlayerController p) { // Trigger the various events associated with Pickups. Touch(p); yield return Delay(useDelay); Use(p); yield return Delay(usedDelay); Used(p); } // This delay function is here to ensure that the wait still protected virtual IEnumerator Delay(float duration) { WaitForSecondsRealtime r = new WaitForSecondsRealtime(.05f); while (duration > 0) { yield return r; if (!GameManager.Instance.isPaused) duration -= r.waitTime; } } }
This new Pickup
script has several advantages over the original:
- The current implementation has separate scripts (
UnlockSideCast.cs
,AddManaOrb.cs
,IncreaseMaxHealth.cs
, etc.) that share nearly identical functionality. The unifiedPickup
class eliminates this redundancy by providing a single, robust implementation of these common features.

- It adds an Animation attribute that allows us to easily set values to add animations onto our items.

- There are also settings that we can adjust to delay the effect of the pickup. This will come in handy later on when we need to set delays before the popup pickups for our abilities and unlock take effect.

- The new
Pickup
script also provides overridable eventsTouch()
,Use()
andUsed()
, which we will be making use of later via overriding to allow us to tweak minor differences in behaviour between different kinds of pickups.
c. Implementing InstantPickup
One thing you will notice about the images we showed above, is that none of them actually show the use of the Pickup
component, even though we were discussing the attributes that the Pickup
script uses. This is because the Pickup
script is marked abstract
, which means that it is not designed to be used directly. Instead, to use it, we will need to create another script that subclasses it:
💡 Although we cannot directly use abstract components, they act as blueprints that define a common structure and shared behaviors for other scripts to follow. By forcing derived scripts to implement certain methods, it ensures consistency across different components and centralises reusable logic.
InstantPickup.cs
using UnityEngine; public class InstantPickup : Pickup { [Header("Rewards")] public int health = 0; public int excessHealth = 1; public float mana = 0; public float excessMana = 0; public override void Use(PlayerController p) { base.Use(p); if(!Mathf.Approximately(health, 0)) p.Health += health; if(!Mathf.Approximately(excessHealth, 0)) p.ExcessHealth += excessHealth; if(!Mathf.Approximately(mana, 0)) p.Mana += mana; if(!Mathf.Approximately(excessMana, 0)) p.ExcessMana += excessMana; } }
The InstantPickup
script is pretty straightforward. It makes use of the functionalities that were defined in pickup to implement its own behaviour—namely, the Use()
function.
In this script, we define the rewards that we expect an instant pickup to be able to provide, and register them as variables in the component. Then, we override the Use()
function, so that we can define the behaviour of the InstantPickup
when it is picked up.
d. Creating the excess health pickup
With the InstantPickup
script implemented, we can now create pickups for our game that apply their effects instantaneously without any cinematic, such as the excess health pickup. To do so, create a new GameObject on the Scene with the following components:
- Sprite Renderer
- Circle Collider
- Rigidbody
- Instant Pickup

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

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

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

💡 If you find it cumbersome to create the new blue health pickup from scratch, you can also duplicate one of the existing pickups from the previous parts, and retool it into your extra health pickup.
3. Re-implementing our old pickups
In the earlier parts of this series, we implemented pickups that boosted your health and mana. These pickups have their behaviours spread over 4 different scripts: IncreaseMaxHealth
and HeartShards
manage the health boost; and AddManaOrb
and OrbShard
manage the mana boost.
We also implemented pickups that gave us new abilities.
All of these pickups have a similarity—they triggered a popup over the screen when they were picked up. Hence, we will create a new PopupPickup
class to handle this for us.
a. Introducing the PopupPickup
The popup pickup works similarly to the InstantPickup
script that we have just created, with the exception that it turns on a UI popup after it is picked up for an amount of time.
When it is picked up, it will:
- Pause the game for a duration equivalent to
useDelay
+usedDelay
. - Open the popup after
useDelay
. - Close the popup after
usedDelay
.
To achieve the activation and deactivation of the popups, it overrides the Touch()
, Use()
and Used()
functions in the parent pickup class.
PopupPickup.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public abstract class PopupPickup : Pickup { [Header("Pop Up Settings")] public UIScreen popUp; // Popup that appears when pickup is picked up. // Start is called before the first frame update protected override void Start() { base.Start(); popUp.gameObject.SetActive(false); } // Overrideable function, determines what happens when we first touch the pickup. public override void Touch(PlayerController p) { base.Touch(p); // Create the stop effect. GameManager.Stop(useDelay + usedDelay); } public override void Use(PlayerController p) { base.Use(p); popUp.Activate(false); } // What happens after the item is consumed. public override void Used(PlayerController p) { popUp.Deactivate(.2f); base.Used(p); } void Reset() { useDelay = 0.5f; usedDelay = 4f; popUp = GetComponentInChildren<UIScreen>(); } }
Since the PopupPickup
will be used to support 2 kinds of pickups—the ones that increase our max health and mana, as well as the ones that give us upgrades, the script is also marked abstract
, since it doesn’t actually give the player anything except trigger a popup.
4. The UnlockAbilityPickup
script
We are first going to use PopupPickup
to replace our unlock ability scripts, but before we do that, let’s first improve on the way that unlocked abilities are managed on our PlayerController
script.
a. Improving the ability unlock system on PlayerController
Currently, on our PlayerController
, our unlocks are managed by a series of booleans, which makes it very difficult for us to handle all the unlocking of abilities by the pickups in one single script.

Hence, what we are going to do instead, is to combine them into a single enum type called Abilities
. You will need to make the following changes on your script:
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEditor.Build; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; ////sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps [SerializeField] private int maxFallingSpeed; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Wall Jump Settings")] [SerializeField] private float wallSlidingSpeed = 2f; [SerializeField] private Transform wallCheck; [SerializeField] private LayerMask wallLayer; [SerializeField] private float wallJumpingDuration; [SerializeField] private Vector2 wallJumpingPower; float wallJumpingDirection; bool isWallSliding; bool isWallJumping; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs bool restoreTime; float restoreTimeSpeed; [Space(5)] [Header("Recoil Settings:")] [SerializeField] private int recoilXSteps = 5; //how many FixedUpdates() the player recoils horizontally for [SerializeField] private int recoilYSteps = 5; //how many FixedUpdates() the player recoils vertically for [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil private int stepsXRecoiled, stepsYRecoiled; //the no. of steps recoiled horizontally and verticall [Space(5)] [Header("Health Settings")] public int health; public int maxHealth; public int maxTotalHealth = 10; public int excessHealth = 0; public int heartShards; [Min(1)] public int heartShardsPerHealth = 4; [SerializeField] GameObject bloodSpurt; [SerializeField] float hitFlashSpeed; [System.Obsolete] public delegate void OnHealthChangedDelegate(); [System.Obsolete] public OnHealthChangedDelegate onHealthChangedCallback; float healTimer; [SerializeField] float timeToHeal; [Space(5)] [Header("Mana Settings")] public float mana = 3; public float maxMana = 3; [Range(0, 1)] public float manaPenalty = 0f; [Header("Excess Mana Settings")] public float excessMana = 0; public int excessMaxManaUnits = 0, excessMaxManaUnitsLimit = 3; public float manaPerExcessUnit = 1f; [SerializeField] float excessManaRestoreDelay = 3f, excessManaRestoreRate = 1f; float excessManaRestoreCooldown = 0f; public int manaShards = 0; [Min(1)] public int manaShardsPerExcessUnit = 4; [Space(5)] [Header("Spell Settings")] //spell stats [SerializeField] float attackManaGain = 0.34f; [SerializeField] float healManaCostPerSecond = 1f; [SerializeField] float manaSpellCost = 0.3f; [SerializeField] float timeBetweenCast = 0.5f; [SerializeField] float spellDamage; //upspellexplosion and downspellfireball [SerializeField] float downSpellForce; // desolate dive only //spell cast objects [SerializeField] GameObject sideSpellFireball; [SerializeField] GameObject upSpellExplosion; [SerializeField] GameObject downSpellFireball; float timeSinceCast; float castOrHealTimer; [Space(5)] [Header("Camera Stuff")] [SerializeField] private float playerFallSpeedThreshold = -10; [Space(5)] [Header("Audio")] [SerializeField] AudioClip landingSound; [SerializeField] AudioClip jumpSound; [SerializeField] AudioClip dashAndAttackSound; [SerializeField] AudioClip spellCastSound; [SerializeField] AudioClip hurtSound; [HideInInspector] public PlayerStateList pState; [HideInInspector] public Rigidbody2D rb; private Animator anim; private SpriteRenderer sr; private AudioSource audioSource; //Input Variables private float xAxis, yAxis; private bool attack = false; bool openMap; bool openInventory; private bool canFlash = true; private bool landingSoundPlayed; public static PlayerController Instance;//unlocking public bool unlockedWallJump; public bool unlockedDash; public bool unlockedVarJump; public bool unlockedSideCast; public bool unlockedUpCast; public bool unlockedDownCast;[System.Flags] public enum Abilities : byte { dash = 1, variableJump = 2, wallJump = 4, upCast = 8, sideCast = 16, downCast = 32 } [Header("Misc")] public Abilities abilities; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } DontDestroyOnLoad(gameObject); } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); sr = GetComponent<SpriteRenderer>(); anim = GetComponent<Animator>(); audioSource = GetComponent<AudioSource>(); gravity = rb.gravityScale; Mana = mana; Health = maxHealth; SaveData.Instance.LoadPlayerData(); UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } void HandleRestoreManaWithExcess() { if(excessManaRestoreCooldown > 0) { excessManaRestoreCooldown -= Time.deltaTime; } else if(Mana < MaxMana && excessMana > 0f) { float restoreAmount = Mathf.Min(excessMana, excessManaRestoreRate * Time.deltaTime); Mana += restoreAmount; ExcessMana -= restoreAmount; } } // Update is called once per frame void Update() { if (pState.cutscene || GameManager.Instance.isPaused) return; if (pState.alive) { HandleRestoreManaWithExcess(); GetInputs(); ToggleMap(); ToggleInventory(); } UpdateJumpVariables(); UpdateCameraYDampForPlayerFall(); if (pState.dashing) return; FlashWhileInvincible(); if (!pState.alive) return; if (!isWallJumping) { Move(); } Heal(); CastSpell(); if (pState.healing) return; if (!isWallJumping) { Flip(); Jump(); } if (unlockedWallJumpabilities.HasFlag(Abilities.wallJump)) { WallSlide(); WallJump(); } if (unlockedDashabilities.HasFlag(Abilities.dash)) { StartDash(); } Attack(); } private void OnTriggerEnter2D(Collider2D _other) //for up and down cast spell { if (_other.GetComponent<Enemy>() != null && pState.casting) { _other.GetComponent<Enemy>().EnemyGetsHit(spellDamage, (_other.transform.position - transform.position).normalized, -recoilYSpeed); } } private void FixedUpdate() { if (pState.cutscene) return; if (pState.dashing || pState.healing) return; Recoil(); } void GetInputs() { //if (GameManager.Instance.isPaused || GameManager.isStopped) return; xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetButtonDown("Attack"); openMap = Input.GetButton("Map"); openInventory = Input.GetButton("Inventory"); if (Input.GetButton("Cast/Heal")) { castOrHealTimer += Time.deltaTime; } } void ToggleMap() { if (openMap) { UIManager.Instance.mapHandler.SetActive(true); } else { UIManager.Instance.mapHandler.SetActive(false); } } void ToggleInventory() { if (openInventory) { UIManager.Instance.inventory.SetActive(true); } else { UIManager.Instance.inventory.SetActive(false); } } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } private void Move() { if (pState.healing) rb.velocity = new Vector2(0, 0); rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void UpdateCameraYDampForPlayerFall() { //if falling past a certain speed threshold if (rb.velocity.y < playerFallSpeedThreshold && !CameraManager.Instance.isLerpingYDamping && !CameraManager.Instance.hasLerpedYDamping) { StartCoroutine(CameraManager.Instance.LerpYDamping(true)); } //if standing stil or moving up if (rb.velocity.y >= 0 && !CameraManager.Instance.isLerpingYDamping && CameraManager.Instance.hasLerpedYDamping) { //reset camera function CameraManager.Instance.hasLerpedYDamping = false; StartCoroutine(CameraManager.Instance.LerpYDamping(false)); } } void StartDash() { if (Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; } } IEnumerator Dash() { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); audioSource.PlayOneShot(dashAndAttackSound); rb.gravityScale = 0; int _dir = pState.lookingRight ? 1 : -1; rb.velocity = new Vector2(_dir * dashSpeed, 0); if (Grounded()) Instantiate(dashEffect, transform); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay) { pState.invincible = true; //If exit direction is upwards if (_exitDir.y != 0) { rb.velocity = jumpForce * _exitDir; } //If exit direction requires horizontal movement if (_exitDir.x != 0) { xAxis = _exitDir.x > 0 ? 1 : -1; Move(); } Flip(); yield return new WaitForSeconds(_delay); print("cutscene played"); pState.invincible = false; pState.cutscene = false; } void Attack() { timeSinceAttack += Time.deltaTime; if (attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); audioSource.PlayOneShot(dashAndAttackSound); if (yAxis == 0 || yAxis < 0 && Grounded()) { int _recoilLeftOrRight = pState.lookingRight ? 1 : -1; Hit(SideAttackTransform, SideAttackArea, ref pState.recoilingX, Vector2.right * _recoilLeftOrRight, recoilXSpeed); Instantiate(slashEffect, SideAttackTransform); } else if (yAxis > 0) { Hit(UpAttackTransform, UpAttackArea, ref pState.recoilingY, Vector2.up, recoilYSpeed); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea, ref pState.recoilingY, Vector2.down, recoilYSpeed); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilBool, Vector2 _recoilDir, float _recoilStrength) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); if (objectsToHit.Length > 0) { _recoilBool = true; } for (int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if (e && !hitEnemies.Contains(e)) { e.EnemyGetsHit(damage, _recoilDir, _recoilStrength); hitEnemies.Add(e); if (objectsToHit[i].CompareTag("Enemy")) { Mana += attackManaGain; } } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if (pState.recoilingX) { if (pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if (pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } //stop recoil if (pState.recoilingX && stepsXRecoiled < recoilXSteps) { stepsXRecoiled++; } else { StopRecoilX(); } if (pState.recoilingY && stepsYRecoiled < recoilYSteps) { stepsYRecoiled++; } else { StopRecoilY(); } if (Grounded()) { StopRecoilY(); } } void StopRecoilX() { stepsXRecoiled = 0; pState.recoilingX = false; } void StopRecoilY() { stepsYRecoiled = 0; pState.recoilingY = false; } public void TakeDamage(float _damage) { if (pState.alive) { audioSource.PlayOneShot(hurtSound); // Absorb damage with excess health. if(ExcessHealth > 0) { if(ExcessHealth > _damage) { ExcessHealth -= Mathf.RoundToInt(_damage); _damage = 0; } else { _damage -= ExcessHealth; ExcessHealth = 0; } } // Reduce player health. Health -= Mathf.RoundToInt(_damage); if (Health <= 0) { Health = 0; StartCoroutine(Death()); } else { StartCoroutine(StopTakingDamage()); } } } IEnumerator StopTakingDamage() { pState.invincible = true; GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity); Destroy(_bloodSpurtParticles, 1.5f); anim.SetTrigger("TakeDamage"); yield return new WaitForSeconds(1f); pState.invincible = false; } IEnumerator Flash() { sr.enabled = !sr.enabled; canFlash = false; yield return new WaitForSeconds(0.1f); canFlash = true; } void FlashWhileInvincible() { if (pState.invincible && !pState.cutscene) { if (Time.timeScale > 0.2 && canFlash) { StartCoroutine(Flash()); } } else { sr.enabled = true; } } IEnumerator Death() { pState.alive = false; Time.timeScale = 1f; GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity); Destroy(_bloodSpurtParticles, 1.5f); anim.SetTrigger("Death"); rb.constraints = RigidbodyConstraints2D.FreezePosition; GetComponent<BoxCollider2D>().enabled = false; yield return new WaitForSeconds(0.9f); UIManager.Instance.deathScreen.Activate(); yield return new WaitForSeconds(0.1f); Instantiate(GameManager.Instance.shade, transform.position, Quaternion.identity); SaveData.Instance.SavePlayerData(); } public void Respawned(float manaPenalty = 0.5f) { if (!pState.alive) { // Address the component specific settings if the components // are there. if (rb) { rb.constraints = RigidbodyConstraints2D.None; rb.constraints = RigidbodyConstraints2D.FreezeRotation; } if (anim) anim.Play("Player_Idle"); // Set the rest of the variables. GetComponent<BoxCollider2D>().enabled = true; pState.alive = true; // Apply mana penalty if the flag is set to true. this.manaPenalty = manaPenalty; mana = excessMana = 0; UIManager.UpdateManaUI(mana, maxMana, excessMana, ExcessMaxMana, 1f - manaPenalty); Health = maxHealth; } } public void RestoreMana() { manaPenalty = 0f; } public int Health { get { return health; } set { if (health != value) { health = Mathf.Max(value, 0); UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } } public int ExcessHealth { get { return excessHealth; } set { if (excessHealth != value) { excessHealth = Mathf.Clamp(value, 0, excessHealth); UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } } // Converts health shards to actual excess health units. public void ConvertHeartShards() { // While converting, we also want to make sure that it is not possible // to exceed the max health as set by maxTotalHealth. int remainingUnits = maxTotalHealth - maxHealth; if (heartShards >= heartShardsPerHealth && remainingUnits > 0) { // If the awarded units is more than the remaining units, we award only remaining units. int awardedUnits = Mathf.Min(remainingUnits, heartShards / heartShardsPerHealth); // Award units and subtract the mana shards. maxHealth += awardedUnits; heartShards -= awardedUnits * heartShardsPerHealth; UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } void Heal() { if (Input.GetButton("Cast/Heal") && castOrHealTimer > 0.1f && Health < maxHealth && Mana > 0 && Grounded() && !pState.dashing) { pState.healing = true; anim.SetBool("Healing", true); //healing healTimer += Time.deltaTime; if (healTimer >= timeToHeal) { Health++; healTimer = 0; } //drain mana Mana -= Time.deltaTime * healManaCostPerSecond; } else { pState.healing = false; anim.SetBool("Healing", false); healTimer = 0; } } public float Mana { get { return mana; } set { // If there is excess mana, move it to the excess mana slot. float excess = value - MaxMana; if(excess > 0) { mana = MaxMana; ExcessMana += excess; } else { // If mana is being reduced, institute a restore cooldown first. if (value < mana) excessManaRestoreCooldown = excessManaRestoreDelay; mana = Mathf.Max(0, value); } UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana, 1 - manaPenalty); } } public float ExcessMana { get { return excessMana; } set { excessMana = Mathf.Clamp(value, 0, ExcessMaxMana); } } public float MaxMana { get { return maxMana * (1 - manaPenalty); } } public float ExcessMaxMana { get { return excessMaxManaUnits * manaPerExcessUnit; } } // Converts mana shards to actual excess mana units. public void ConvertManaShards() { // While converting, we also want to make sure that it is not possible // to exceed the max excess units as set by excessMaxManaUnitsLimit. // Hence all of the logic here. int remainingUnits = excessMaxManaUnitsLimit - excessMaxManaUnits; if(manaShards >= manaShardsPerExcessUnit && remainingUnits > 0) { // If the awarded units is more than the remaining units, we award only remaining units. int awardedUnits = Mathf.Min(remainingUnits, manaShards / manaShardsPerExcessUnit); // Award units and subtract the mana shards. excessMaxManaUnits += awardedUnits; manaShards -= awardedUnits * manaShardsPerExcessUnit; // We will have to update the mana UI once its up as well. UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana); } } void CastSpell() { if (Input.GetButtonUp("Cast/Heal") && castOrHealTimer <= 0.1f && timeSinceCast >= timeBetweenCast && Mana >= manaSpellCost) { pState.casting = true; timeSinceCast = 0; StartCoroutine(CastCoroutine()); } else { timeSinceCast += Time.deltaTime; } if (!Input.GetButton("Cast/Heal")) { castOrHealTimer = 0; } if (Grounded()) { //disable downspell if on the ground downSpellFireball.SetActive(false); } //if down spell is active, force player down until grounded if (downSpellFireball.activeInHierarchy) { rb.velocity += downSpellForce * Vector2.down; } } IEnumerator CastCoroutine() { //side cast if ((yAxis == 0 || (yAxis < 0 && Grounded())) &&unlockedSideCastabilities.HasFlag(Abilities.sideCast)) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); GameObject _fireBall = Instantiate(sideSpellFireball, SideAttackTransform.position, Quaternion.identity); //flip fireball if (pState.lookingRight) { _fireBall.transform.eulerAngles = Vector3.zero; // if facing right, fireball continues as per normal } else { _fireBall.transform.eulerAngles = new Vector2(_fireBall.transform.eulerAngles.x, 180); //if not facing right, rotate the fireball 180 deg } pState.recoilingX = true; Mana -= manaSpellCost; yield return new WaitForSeconds(0.35f); } //up cast else if (yAxis > 0 &&unlockedUpCastabilities.HasFlag(Abilities.upCast)) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); Instantiate(upSpellExplosion, transform); rb.velocity = Vector2.zero; Mana -= manaSpellCost; yield return new WaitForSeconds(0.35f); } //down cast else if ((yAxis < 0 && !Grounded()) &&unlockedDownCastabilities.HasFlag(Abilities.downCast)) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); downSpellFireball.SetActive(true); Mana -= manaSpellCost; yield return new WaitForSeconds(0.35f); } anim.SetBool("Casting", false); pState.casting = false; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0 && !pState.jumping) { if (Input.GetButtonDown("Jump")) { audioSource.PlayOneShot(jumpSound); } rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") &&unlockedVarJumpabilities.HasFlag(Abilities.variableJump)) { audioSource.PlayOneShot(jumpSound); pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } if (Input.GetButtonUp("Jump") && rb.velocity.y > 3) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -maxFallingSpeed, rb.velocity.y)); anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { if (!landingSoundPlayed) { audioSource.PlayOneShot(landingSound); landingSoundPlayed = true; } pState.jumping = false; coyoteTimeCounter = coyoteTime; airJumpCounter = 0; } else { coyoteTimeCounter -= Time.deltaTime; landingSoundPlayed = false; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } private bool Walled() { return Physics2D.OverlapCircle(wallCheck.position, 0.2f, wallLayer); } void WallSlide() { if (Walled() && !Grounded() && xAxis != 0) { isWallSliding = true; rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -wallSlidingSpeed, float.MaxValue)); } else { isWallSliding = false; } } void WallJump() { if (isWallSliding) { isWallJumping = false; wallJumpingDirection = !pState.lookingRight ? 1 : -1; CancelInvoke(nameof(StopWallJumping)); } if (Input.GetButtonDown("Jump") && isWallSliding) { audioSource.PlayOneShot(jumpSound); isWallJumping = true; rb.velocity = new Vector2(wallJumpingDirection * wallJumpingPower.x, wallJumpingPower.y); dashed = false; airJumpCounter = 0; pState.lookingRight = !pState.lookingRight; float jumpDirection = pState.lookingRight ? 0 : 180; transform.eulerAngles = new Vector2(transform.eulerAngles.x, jumpDirection); Invoke(nameof(StopWallJumping), wallJumpingDuration); } } void StopWallJumping() { isWallJumping = false; transform.eulerAngles = new Vector2(transform.eulerAngles.x, 0); } }
By organising our abilities inside of an enum, we create a much nicer interface for ourselves for the player’s abilities on the Inspector:

b. Updating the SaveData
script with ability updates
Because our old SaveData
script uses the boolean variables we just removed in PlayerController
, we need to update the SaveData
script to save abilities in the new format as well.
⚠ Because we are updating the values that are saved in the SaveData
script, this update will break your old existing saves, so the game will load janky. For more information about why this happens, check out this forum post.
In the next part of this series, we will be recoding our save system so that we won’t run into this issue again.
SaveData.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using System.IO; using UnityEngine.SceneManagement; [System.Serializable] public struct SaveData { public static SaveData Instance; //map stuff public HashSet<string> sceneNames; //bench stuff public string benchSceneName; public Vector2 benchPos; //player stuff public int playerHealth; public int playerMaxHealth; public int playerHeartShards; public float playerMana; public float playerExcessMana; public int playerManaShards; public float playerManaPenalty; public Vector2 playerPosition; public string lastScene;public bool playerUnlockedWallJump, playerUnlockedDash, playerUnlockedVarJump; public bool playerUnlockedSideCast, playerUnlockedUpCast, playerUnlockedDownCast;public PlayerController.Abilities playerAbilities; //enemies stuff //shade public Vector2 shadePos; public string sceneWithShade; public Quaternion shadeRot; //THK public bool THKDefeated; public void Initialize() { if(!File.Exists(Application.persistentDataPath + "/save.bench.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.bench.data")); } if (!File.Exists(Application.persistentDataPath + "/save.player.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.player.data")); } if (!File.Exists(Application.persistentDataPath + "/save.shade.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.shade.data")); } if (sceneNames == null) { sceneNames = new HashSet<string>(); } } #region Bench Stuff public void SaveBench() { using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.bench.data"))) { writer.Write(benchSceneName); writer.Write(benchPos.x); writer.Write(benchPos.y); } } public void LoadBench() { string savePath = Application.persistentDataPath + "/save.bench.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.bench.data"))) { benchSceneName = reader.ReadString(); benchPos.x = reader.ReadSingle(); benchPos.y = reader.ReadSingle(); } } else { Debug.Log("Bench doesnt exist"); } } #endregion #region Player stuff public void SavePlayerData() { using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.player.data"))) { playerHealth = PlayerController.Instance.Health; writer.Write(playerHealth); playerMaxHealth = PlayerController.Instance.maxHealth; writer.Write(playerMaxHealth); playerHeartShards = PlayerController.Instance.heartShards; writer.Write(playerHeartShards); playerMana = PlayerController.Instance.Mana; writer.Write(playerMana); playerManaPenalty = PlayerController.Instance.manaPenalty; writer.Write(playerManaPenalty); playerExcessMana = PlayerController.Instance.excessMana; writer.Write(playerExcessMana); playerManaShards = PlayerController.Instance.manaShards; writer.Write(playerManaShards);playerUnlockedWallJump = PlayerController.Instance.unlockedWallJump; writer.Write(playerUnlockedWallJump); playerUnlockedDash = PlayerController.Instance.unlockedDash; writer.Write(playerUnlockedDash); playerUnlockedVarJump = PlayerController.Instance.unlockedVarJump; writer.Write(playerUnlockedVarJump); playerUnlockedSideCast = PlayerController.Instance.unlockedSideCast; writer.Write(playerUnlockedSideCast); playerUnlockedUpCast = PlayerController.Instance.unlockedUpCast; writer.Write(playerUnlockedUpCast); playerUnlockedDownCast = PlayerController.Instance.unlockedDownCast; writer.Write(playerUnlockedDownCast);playerAbilities = PlayerController.Instance.abilities; writer.Write((int)playerAbilities); playerPosition = PlayerController.Instance.transform.position; writer.Write(playerPosition.x); writer.Write(playerPosition.y); lastScene = SceneManager.GetActiveScene().name; writer.Write(lastScene); } Debug.Log("saved player data"); } public void LoadPlayerData() { string savePath = Application.persistentDataPath + "/save.player.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.player.data"))) { playerHealth = reader.ReadInt32(); playerMaxHealth = reader.ReadInt32(); playerHeartShards = reader.ReadInt32(); playerMana = reader.ReadSingle(); playerManaPenalty = reader.ReadInt32(); playerExcessMana = reader.ReadSingle(); playerManaShards = reader.ReadInt32();playerUnlockedWallJump = reader.ReadBoolean(); playerUnlockedDash = reader.ReadBoolean(); playerUnlockedVarJump = reader.ReadBoolean(); playerUnlockedSideCast = reader.ReadBoolean(); playerUnlockedUpCast = reader.ReadBoolean(); playerUnlockedDownCast = reader.ReadBoolean();playerAbilities = (PlayerController.Abilities)reader.ReadInt32(); playerPosition.x = reader.ReadSingle(); playerPosition.y = reader.ReadSingle(); lastScene = reader.ReadString(); SceneManager.LoadScene(lastScene); PlayerController.Instance.transform.position = playerPosition; PlayerController.Instance.manaPenalty = playerManaPenalty; PlayerController.Instance.Health = playerHealth; PlayerController.Instance.maxHealth = playerMaxHealth; PlayerController.Instance.heartShards = playerHeartShards; PlayerController.Instance.Mana = playerMana; PlayerController.Instance.excessMana = playerExcessMana; PlayerController.Instance.manaShards = playerManaShards;PlayerController.Instance.unlockedWallJump = playerUnlockedWallJump; PlayerController.Instance.unlockedDash = playerUnlockedDash; PlayerController.Instance.unlockedVarJump = playerUnlockedVarJump; PlayerController.Instance.unlockedSideCast = playerUnlockedSideCast; PlayerController.Instance.unlockedUpCast = playerUnlockedUpCast; PlayerController.Instance.unlockedDownCast = playerUnlockedDownCast;PlayerController.Instance.abilities = playerAbilities; } } else { Debug.Log("File doesnt exist"); PlayerController.Instance.manaPenalty = 0; PlayerController.Instance.Health = PlayerController.Instance.maxHealth; PlayerController.Instance.Mana = 0.5f; PlayerController.Instance.heartShards = 0;PlayerController.Instance.unlockedWallJump = false; PlayerController.Instance.unlockedDash = false; PlayerController.Instance.unlockedVarJump = false;PlayerController.Instance.abilities = 0; } } #endregion #region enemy stuff public void SaveShadeData() { using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.shade.data"))) { sceneWithShade = SceneManager.GetActiveScene().name; shadePos = Shade.Instance.transform.position; shadeRot = Shade.Instance.transform.rotation; writer.Write(sceneWithShade); writer.Write(shadePos.x); writer.Write(shadePos.y); writer.Write(shadeRot.x); writer.Write(shadeRot.y); writer.Write(shadeRot.z); writer.Write(shadeRot.w); } } public void LoadShadeData() { string savePath = Application.persistentDataPath + "/save.shade.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.shade.data"))) { sceneWithShade = reader.ReadString(); shadePos.x = reader.ReadSingle(); shadePos.y = reader.ReadSingle(); float rotationX = reader.ReadSingle(); float rotationY = reader.ReadSingle(); float rotationZ = reader.ReadSingle(); float rotationW = reader.ReadSingle(); shadeRot = new Quaternion(rotationX, rotationY, rotationZ, rotationW); } Debug.Log("Load shade data"); } else { Debug.Log("Shade doesnt exist"); } } public void SaveBossData() { if (!File.Exists(Application.persistentDataPath + "/save.boss.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.boss.data")); } using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.boss.data"))) { THKDefeated = GameManager.Instance.THKDefeated; writer.Write(THKDefeated); } } public void LoadBossData() { if (File.Exists(Application.persistentDataPath + "/save.Boss.data")) { using (BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.boss.data"))) { THKDefeated = reader.ReadBoolean(); GameManager.Instance.THKDefeated = THKDefeated; } } else { Debug.Log("Boss doesnt exist"); } } #endregion }
c. Updating the InventoryManager
script
Similarly, the InventoryManager
script also references to the old boolean variables that we have removed from the PlayerController
.
InventoryManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class InventoryManager : MonoBehaviour { [SerializeField] Image heartShard; [SerializeField] Image manaShard; [SerializeField] GameObject upCast, sideCast, downCast; [SerializeField] GameObject dash, varJump, wallJump; private void OnEnable() { //heart shard heartShard.fillAmount = PlayerController.Instance.heartShards * 0.25f; //mana shards manaShard.fillAmount = PlayerController.Instance.manaShards * 0.34f; //spellsif(PlayerController.Instance.unlockedUpCast)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.upCast)) { upCast.SetActive(true); } else { upCast.SetActive(false); }if (PlayerController.Instance.unlockedSideCast)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.sideCast)) { sideCast.SetActive(true); } else { sideCast.SetActive(false); }if (PlayerController.Instance.unlockedDownCast)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.downCast)) { downCast.SetActive(true); } else { downCast.SetActive(false); } //abilitiesif (PlayerController.Instance.unlockedDash)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.dash)) { dash.SetActive(true); } else { dash.SetActive(false); }if (PlayerController.Instance.unlockedVarJump)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.variableJump)) { varJump.SetActive(true); } else { varJump.SetActive(false); }if (PlayerController.Instance.unlockedWallJump)if(PlayerController.Instance.abilities.HasFlag(PlayerController.Abilities.wallJump)) { wallJump.SetActive(true); } else { wallJump.SetActive(false); } } }
d. Deleting the old unlock pickup scripts
Finally, we’ll also need to delete all our old pickup scripts, i.e.:
UnlockDownCast
UnlockUpCast
UnlockSideCast
UnlockVariableJump
UnlockWallJump
UnlockDash
Why, you ask? Because they refer to the old booleans we just replaced with an enum in our PlayerController
script. If you wish to keep them around, you can update the scripts to use the enum and mark them as obsolete.
e. Creating the UnlockAbilityPickup
script
Now that we have the enum set up on the player, we can use the same enum (PlayerController.Abilities
) to create our UnlockAbilityPickup
script:
UnlockAbilityPickup.cs
public class UnlockAbilityPickup : PopupPickup
{
public PlayerController.Abilities unlocks;
public bool destroyIfLearnt = true;
protected override void Start()
{
base.Start();
// If player already has the ability, destroy this pickup.
if (destroyIfLearnt && PlayerController.Instance.abilities.HasFlag(unlocks))
Destroy(gameObject);
}
public override void Used(PlayerController p)
{
PlayerController.Instance.abilities |= unlocks;
base.Used(p);
}
}
This is a very simple script that expands upon the PopupPickup
script. All it does is, in the Start()
function, it checks whether the player has already unlocked the abilities that this pickup script provides. If it does, the pickup is automatically destroyed if destroyIfLearnt
is true.
In the Used()
function, we use a bitwise operation to flip the abilities
variable in the PlayerController
instance, basically unlocking the ability for the player.
f. Retooling our unlock prefabs
Now that the script is set up, we will need to go back to every one of our unlock pickup prefabs, and assign a UIScreen
component to each of their Canvas elements. In our new UnlockAbilityPickup
component, the script is no longer responsible for activating and animating the UI that appears after the pickup happens—this is now handled by the UIScreen
script that we made in Part 12:

UIScreen
script handle all of our UI animations, so that we don’t have to write code on UnlockAbilityPickup
to animate it. Remember to set Time Scale Mode to Nothing, as time will be paused when this is picked up.💡 One good thing about having the UIScreen
handle animation is that we can very easily configure, using the Time Scale Mode property, our UI to either be paused by the pause screen, or not. In our previous implementation, the item UI animations do not get affected by us pausing the game, which led to some very janky scenarios.
Once that is done, we should also replace the old unlock script with our new UnlockAbilityPickup
component. Be sure to:
- Assign the parented Canvas under the prefab to Pop Up;
- Select the correct ability to unlock on the Unlocks drop down, and;
- Assign the old pickup effect in the Particles field to Destroy Effect Prefab;

UnlockAbilityPickup
. Also make sure to set the correct unlock in the Unlocks dropdown.5. The UpgradePickup
script
Replacing the health and mana (orb) upgrade pickups will be a little more complex, because each of the upgrade pickups are controlled by 2 different scripts:
- The health increase pickup is handled by the
IncreaseMaxHealth
script, which provides the behaviour for the pickup; as well as theHeartShards
script, which provides the UI overlay that fills up the circle that pops up. - The mana increase pickup is handled by the
AddManaOrb
script, which provides the behaviour for the pickup; as well as theOrbShards
script, which provides the UI overlay that fills up the circle that pops up.
We are going to replace the IncreaseMaxHealth
and AddManaOrb
script with UpgradePickup
, since both of these scripts are very similar. For the HeartShards
and AddManaOrb
scripts, we will replace them with a new script called UIPickupNotification
, which will be a subclass of the UIScreen
script we created in Part 12.
a. Creating the UIPickupNotification
script
Let’s first create the UIPickupNotification
script, since our UpgradePickup
relies on it. This will work similarly to the Canvas that we have on our UnlockAbilityPickup
, but with some additional code to manage the animation of a circle filling up:
UIPickupNotification.cs
using System.Collections; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(CanvasGroup))] public class UIPickupNotification : UIScreen { [Header("Fill Settings")] public Image fillObject; public float fillDelay = 0.5f; float duration, initialFill, finalFill; public void SetFill(float duration, float initialFill, float finalFill) { fillObject.fillAmount = this.initialFill = initialFill; this.duration = duration; this.finalFill = finalFill; } IEnumerator Fill() { WaitForSecondsRealtime w = new WaitForSecondsRealtime(updateFrequency); // Don't play as long as another animation is playing on this. while (isAnimating) yield return w; isAnimating = true; float elapsedTime = 0f; while (elapsedTime < duration) { yield return w; float timeScale = GetTimeScale(); if (timeScale <= 0) continue; elapsedTime += w.waitTime * timeScale; float t = Mathf.Clamp01(elapsedTime / duration); float lerpedFillAmount = Mathf.Lerp(initialFill, finalFill, t); fillObject.fillAmount = lerpedFillAmount; } fillObject.fillAmount = finalFill; isAnimating = false; } // Override the activate function and call the coroutine to start the fill. public override IEnumerator Activate(float delay) { yield return base.Activate(delay); yield return new WaitForSecondsRealtime(Mathf.Max(updateFrequency, this.fillDelay)); // Call the coroutine to start the fill. if (!Mathf.Approximately(initialFill, finalFill)) StartCoroutine(Fill()); } }
The code is very similar to the LerpFill()
function found in both HeartShards
and OrbShards
, and the Activate()
function is overriden so that when the UIScreen
is turned on, the filling animation is automatically started after a delay.
There is also a SetFill()
function, which the UpgradePickup
script will be using to tell this component where the fill should start, and where it should end.
b. Creating the UpgradePickup
script
Now that the UIPickupNotification
script is set up, we can write our UpgradePickup
script:
UpgradePickup.cs
using Player = PlayerController; public class UpgradePickup : PopupPickup { public enum Type { health, mana } public Type type; // Start is called before the first frame update protected override void Start() { base.Start(); // Destroy this pickup if we are already maxed out. switch (type) { case Type.health: if (Player.Instance.maxHealth >= Player.Instance.maxTotalHealth) Destroy(gameObject); break; case Type.mana: if (Player.Instance.excessMaxManaUnits >= Player.Instance.excessMaxManaUnitsLimit) Destroy(gameObject); break; } } // What happens when the item is picked up. public override void Use(PlayerController p) { base.Use(p); UIPickupNotification uip = popUp as UIPickupNotification; if (!uip) return; switch(type) { case Type.health: default: uip.SetFill( 1f, Player.Instance.heartShards++ * 1f / Player.Instance.heartShardsPerHealth, Player.Instance.heartShards * 1f / Player.Instance.heartShardsPerHealth ); Player.Instance.ConvertHeartShards(); break; case Type.mana: uip.SetFill( 1f, Player.Instance.manaShards++ * 1f / Player.Instance.manaShardsPerExcessUnit, Player.Instance.manaShards * 1f / Player.Instance.manaShardsPerExcessUnit ); Player.Instance.ConvertManaShards(); break; } } }
This is, again, a pretty short script that is a combination of the OrbShard
and HeartShards
UI scripts, so that we don’t have 2 scripts that perform largely similar functionality.
By organising our scripts in this manner, we make our project easier to manage, as the functionality of each of the scripts is made clearer.
c. Retooling our upgrade prefabs
Like with the UnlockAbilityPickup
prefabs, we will need to update the Canvas parented under our health and mana upgrade pickups:

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

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

Also, it would be good to mark your old HeartShards
, OrbShard
, IncreaseMaxHealth
and AddManaOrb
scripts as [Obsolete]
, so that we don’t accidentally use them again in the future.
IncreaseMaxHealth.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Obsolete]
public class IncreaseMaxHealth : MonoBehaviour
{
[SerializeField] GameObject particles;
[SerializeField] GameObject canvasUI;
[SerializeField] HeartShards heartShards;
bool used;
// Start is called before the first frame update
void Start()
{
if (PlayerController.Instance.maxHealth >= PlayerController.Instance.maxTotalHealth)
{
Destroy(gameObject);
}
}
private void OnTriggerEnter2D(Collider2D _collision)
{
if (_collision.CompareTag("Player") && !used)
{
used = true;
StartCoroutine(ShowUI());
}
}
IEnumerator ShowUI()
{
GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity);
Destroy(_particles, 0.5f);
yield return new WaitForSeconds(0.5f);
gameObject.GetComponent<SpriteRenderer>().enabled = false;
canvasUI.SetActive(true);
heartShards.initialFillAmount = PlayerController.Instance.heartShards * 0.25f;
PlayerController.Instance.heartShards++;
heartShards.targetFillAmount = PlayerController.Instance.heartShards * 0.25f;
StartCoroutine(heartShards.LerpFill());
yield return new WaitForSeconds(2.5f);
SaveData.Instance.SavePlayerData();
canvasUI.SetActive(false);
Destroy(gameObject);
}
}
HeartShards.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [System.Obsolete] public class HeartShards : MonoBehaviour { public Image fill; public float targetFillAmount; public float lerpDuration = 1.5f; public float initialFillAmount; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public IEnumerator LerpFill() { float elapsedTime = 0f; while(elapsedTime < lerpDuration) { elapsedTime += Time.deltaTime; float t = Mathf.Clamp01(elapsedTime / lerpDuration); float lerpedFillAmount = Mathf.Lerp(initialFillAmount, targetFillAmount, t); fill.fillAmount = lerpedFillAmount; yield return null; } fill.fillAmount = targetFillAmount; if(fill.fillAmount == 1) { PlayerController.Instance.maxHealth++; PlayerController.Instance.onHealthChangedCallback(); PlayerController.Instance.heartShards = 0; } } }
AddManaOrb
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Obsolete]
public class AddManaOrb : MonoBehaviour
{
[SerializeField] GameObject particles;
[SerializeField] GameObject canvasUI;
[SerializeField] OrbShard orbShard;
bool used;
// Start is called before the first frame update
void Start()
{
if (PlayerController.Instance.excessMana >= PlayerController.Instance.ExcessMaxMana)
{
Destroy(gameObject);
}
}
private void OnTriggerEnter2D(Collider2D _collision)
{
if (_collision.CompareTag("Player") && !used)
{
used = true;
StartCoroutine(ShowUI());
}
}
IEnumerator ShowUI()
{
GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity);
Destroy(_particles, 0.5f);
yield return new WaitForSeconds(0.5f);
gameObject.GetComponent<SpriteRenderer>().enabled = false;
canvasUI.SetActive(true);
orbShard.initialFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit;
PlayerController.Instance.manaShards++;
orbShard.targetFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit;
StartCoroutine(orbShard.LerpFill());
yield return new WaitForSeconds(2.5f);
SaveData.Instance.SavePlayerData();
canvasUI.SetActive(false);
Destroy(gameObject);
}
}
OrbShard.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [System.Obsolete] public class OrbShard : MonoBehaviour { public Image fill; public float targetFillAmount; public float lerpDuration = 1.5f; public float initialFillAmount; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public IEnumerator LerpFill() { float elapsedTime = 0f; while (elapsedTime < lerpDuration) { elapsedTime += Time.deltaTime; float t = Mathf.Clamp01(elapsedTime / lerpDuration); float lerpedFillAmount = Mathf.Lerp(initialFillAmount, targetFillAmount, t); fill.fillAmount = lerpedFillAmount; yield return null; } fill.fillAmount = targetFillAmount; if (fill.fillAmount == 1) { PlayerController.Instance.excessMaxManaUnits++; PlayerController.Instance.manaShards = 0; } } }
7. Conclusion
With the new pickup system set up, we are now in a much better position to implement saving in the next part of our series.
Silver Patrons and above can download the project files as well.