Creating a Rogue-like (Vampire Survivors) Part 17

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 17: Fixing Pickup Collection

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

Update 18 February 2024: Added a new section to fix a bug with the health bar not updating after picking up a health potion.

If you’ve been following our series for some time, one thing that you will be aware of is that our pickup system is a little janky, because sometimes our items will miss the player if he is moving too fast, or moving at an odd angle (thanks to g Dni for pointing this out).

View post on imgur.com

Initially, I thought that this would be a pretty simple fix, as we just had to make a few changes to the PlayerCollector script, but as I was working on it on stream, I realised that it was better to streamline and optimise the entire pickup system as well.

  1. The problem with the PlayerCollector script
  2. Letting Pickup handle its own movement
    1. The new Update() function
    2. The Collect() function and class properties
    3. Removing the ICollectible interface
  3. Updating the PlayerCollector
    1. Updating the Collect() function
    2. Optimising the collector radius update
    3. Updating PlayerStat to refresh the collector radius
  4. Merging the HealthPotion and ExperienceGem
    1. Updating the Pickup class
    2. Fixing the health bar not updating after healing
  5. Merging the BobbingAnimation into Pickup
    1. Pickup script with BobbingAnimation
    2. Randomising the starting frame
  6. Reconfiguring your prefabs
    1. Reducing the Pull Speed in PlayerCollector
    2. Updating your pickup prefabs
  7. Conclusion

1. The problem with the PlayerCollector script

The main cause of the issue that you see in the video above is due to the way the attraction is coded in the PlayerCollector script. Specifically, it is this line in the OnTriggerEnter2D() function.

void OnTriggerEnter2D(Collider2D col)
{
    //Check if the other game object has the ICollectible interface
    if (col.gameObject.TryGetComponent(out ICollectible collectible))
    {
        //Pulling animation
        //Gets the Rigidbody2D component on the item
        Rigidbody2D rb = col.gameObject.GetComponent<Rigidbody2D>();
        //Vector2 pointing from the item to the player
        Vector2 forceDirection = (transform.position - col.transform.position).normalized;
        //Applies force to the item in the forceDirection with pullSpeed
        rb.AddForce(forceDirection * pullSpeed);

        //If it does, call the OnCollect method
        collectible.Collect();
    }
}

When the pickup gets within range of the player, a force is applied on it to push it towards the player. However, because the force is only applied at the first frame, if the player moves away quickly after (or at an odd angle), it is possible for the pickup to miss the player entirely.

For this to work, we will need the pickup to constantly check where the player is, and readjust its positioning accordingly every frame. This means that instead of applying the motion in OnTriggerEnter2D(), we should apply it in Update() instead at every frame.

One solution to this, as you will see me attempting to apply in the topic here, is to modify the PlayerCollector script to apply a force to the pickup every frame in Update() until the item touches the player. This makes the code unnecessarily complex, however, because we will need to use a list, together with a couple of loops to manage the movement of every gem.

A simpler way is to let each pickup manage its own movement in its Pickup script instead. That way, we won’t have to create all of these arrays in the PlayerCollector script. Right now, the Pickup script contains very little code, and doesn’t do a lot, so letting it handle its own movement will make it “carry more of its own weight”.

2. Letting Pickup handle its own movement

To this end, we will recode the pickup script so that when Collect() is called on it, it will automatically try to find its way towards the player character.

Pickup.cs

using UnityEngine;

public class Pickup : MonoBehaviour, ICollectible
{
    public bool hasBeenCollected = false;
    public float lifespan = 0.5f;
    protected PlayerStats target; // If the pickup has a target, then fly towards the target.
    protected float speed; // The speed at which the pickup travels.

    protected virtual void Update()
    {
        if(target)
        {
            // Move it towards the player and check the distance between.
            Vector2 distance = target.transform.position - transform.position;
            if (distance.sqrMagnitude > speed * speed * Time.deltaTime)
                transform.position += (Vector3)distance.normalized * speed * Time.deltaTime;
            else
                Destroy(gameObject);

        }
    }

    public virtual voidbool Collect(PlayerStats target, float speed, float lifespan = 0f)
    {
        hasBeenCollected = true;
        if (!this.target)
        {
            this.target = target;
            this.speed = speed;
            if (lifespan > 0) this.lifespan = lifespan;
            Destroy(gameObject, Mathf.Max(0.01f, this.lifespan));
            return true;
        }
        return false;
    }

    private void OnTriggerEnter2D(Collider2D col)
    {
        Destroy(gameObject);
    }
}

There are quite a few changes here, so let’s go through what’s going on.

a. The new Update() function

The most important change to the Pickup script is to its Collect() function. Instead of just toggling the hasBeenCollected flag (to prevent a bug that causes it to be picked up more than once), it is now responsible for “activating” the Pickup’s movement.

To fully understand how it works, however, we first need to look at the newly-added Update() function:

protected virtual void Update()
{
    if(target)
    {
        // Move it towards the player and check the distance between.
        Vector2 distance = target.transform.position - transform.position;
        if (distance.sqrMagnitude > speed * speed * Time.deltaTime)
            transform.position += (Vector3)distance.normalized * speed * Time.deltaTime;
        else
            Destroy(gameObject);
        }
    }
}

Essentially, the function checks if the target property of the Pickup has been set. If it has, we will move it towards the target every frame, until it reaches the player’s position, whereupon it will be destroyed.

  • distance is a vector pointing from the pickup to the target (i.e. player). It is calculated by subtracting the target’s position with the pickup’s position. If you’re not familiar with vector maths, you can check this article out.
  • To determine if the gem has already reached the player, we check the sqrMagnitude of the distance vector against the square of the amount of distance we will move this frame. If the distance is lesser than the amount of distance we are moving, then we take it that the pickup has reached the player and destroy the pickup.

Why do we use sqrMagnitude over the magnitude attribute, which gives the exact distance instead of the squared distance? This is because every time we access the magnitude of a vector, Unity needs to calculate calculate the length of the vector, and that requires it to perform the square root operation, which is expensive:

Length of 2D vector = x 2 + y 2 Length of 3D vector = x 2 + y 2 + z 2

It is much cheaper to square a number than it is to find a square root — hence why we do this:

distance.sqrMagnitude > speed * speed * Time.deltaTime

Instead of this:

distance.magnitude > speed * Time.deltaTime

b. The Collect() function and class properties

The old Collect() function served a simple purpose. It set the hasBeenCollected flag on an item, so that it will not be possible for players to pick up an item multiple times (and gain its benefits more than once).

In the recoded Pickup script, on top of this, it will also serve as the function that “activates” the item. Recall that in Update(), the pickup starts moving towards target once it is assigned. Collect() is the function that is responsible for assigning this target variable.

On top of the target variable, Collect() is also responsible for assigning a few of the newly-declared properties in the Pickup class:

PropertyDescription
targetThe object (i.e. the player) that the pickup will move towards.
lifespanHow long the pickup will try to move towards the player before being automatically collected.
speedHow much distance the pickup will cover in 1 second. This property will receive the pullSpeed property from PlayerCollector.

The most important property out of all of these is the lifespan property, which has also been made public so that it can be set by each individual pickup. The lifespan property controls how long an item will try to fly towards and reach the player before it automatically considers itself collected (without reaching the player). This is a useful failsafe, as it prevents gems from flying off out into the borders of the map and becoming a memory leak.

c. Removing the ICollectible interface

We are also removing the ICollectible interface that the Pickup script implements, as it does not serve a purpose. For reference, here is the code that the ICollectible interface contains:

ICollectible.cs

public interface ICollectible
{
    void Collect();
}

Interfaces are meant to help us group classes from different class hierarchies together with some common functions. In our case, because all our pickups currently inherit from Pickup, they will inherit the Collect() function from it anyway, so there is no need to have an additional interface for it.

3. Updating the PlayerCollector

With the change to the number of parameters that the Collect() function on Pickup needs, the PlayerCollector script should now show some errors. Hence, we’ll need to modify it so that it passes the correct number of parameters to Collect().

Since it is no longer responsible for moving the pickups, we will also remove the part of its code that handles the movement of the gem, and make a few optimisations to the code.

PlayerCollector.cs

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

[RequireComponent(typeof(CircleCollider2D))]
public class PlayerCollector : MonoBehaviour
{
    PlayerStats player;
    CircleCollider2D playerCollectordetector;
    public float pullSpeed;

    private void Start()
    {
        player = FindObjectOfType<PlayerStats>();
        playerCollector = GetComponent<CircleCollider2D>();
        player = GetComponentInParent<PlayerStats>();
    }

    void Update()
    {
        playerCollector.radius = player.Magnet;
    }


    public void SetRadius(float r)
    {
        if(!detector) detector = GetComponent<CircleCollider2D>();
        detector.radius = r;
    }

    void OnTriggerEnter2D(Collider2D col)
    {
        //Check if the other game object has the ICollectible interfaceGameObject is a Pickup.
        if (col.gameObject.TryGetComponent(out ICollectible collectiblePickup p))

        {
            //Pulling animation
            //Gets the Rigidbody2D component on the item
            Rigidbody2D rb = col.gameObject.GetComponent<Rigidbody2D>();
            //Vector2 pointing from the item to the player
            Vector2 forceDirection = (transform.position - col.transform.position).normalized;
            //Applies force to the item in the forceDirection with pullSpeed
            rb.AddForce(forceDirection * pullSpeed);

            //If it does, call the OnCollect method
            collectiblep.Collect(player, pullSpeed);
        }
    }

}

a. Updating the Collect() function

The primary change to the PlayerCollector script is to the OnTriggerEnter2D() function, which applied a force to the pickup in the player’s direction and called the Collect() function. We remove the part of the code that applies the code — since movement is handled by the Pickup script itself now — and update the Collect() function so that 2 parameters are given to it.

Just by making these changes, the collection code should start working properly.

b. Optimising the collector radius update

There is one more optimisation that can be made to the PlayerCollector code however, and it has to do with the Update() function:

void Update()
{
    playerCollector.radius = player.Magnet;
}

What’s happening here is that the PlayerCollector (a CircleCollider2D component) is updating its radius property at every frame. This is hardly efficient, since we only need to update the radius whenever the player’s stats change.

Hence, we remove the Update() function, add a SetRadius() function in its place, and rename the playerCollector variable to detector (this is just me being anal — it’s a more descriptive variable name).

public void SetRadius(float r)
{
    if(!detector) detector = GetComponent<CircleCollider2D>();
    detector.radius = r;
}

Also, because we are not preventing detector.radius from being set if no CircleCollider2D is found, we will get a NullReferenceException error if we accidentally leave out the CircleCollider2D component from our PlayerCollector. Hence, we add a [RequireComponent] attribute to our class. This will prevent users from being able to add a PlayerCollector component to their GameObjects without a circle collider.

[RequireComponent(typeof(CircleCollider2D))]
public class PlayerCollector : MonoBehaviour

c. Updating PlayerStat to refresh the collector radius

Now that the SetRadius() function is all set up, we modify PlayerStats so that it updates the collector’s radius at 2 instances:

  1. When the game first starts.
  2. Whenever the player’s stats changes.

To this end, we declare a new collector property in `PlayerStats, and modify its Awake() and RecalculateStats() functions:

PlayerStats.cs

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

public class PlayerStats : MonoBehaviour
{

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


    float health;

    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

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

            if (health != value)
            {

                health = value;
                if (GameManager.instance != null)
                {

                    GameManager.instance.currentHealthDisplay.text = string.Format(
                        "Health: {0} / {1}",
                        health, actualStats.maxHealth
                    );
                }

            }
        }
    }

    public float MaxHealth
    {
        get { return actualStats.maxHealth; }

        // If we try and set the max health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed
            if (actualStats.maxHealth != value)
            {
                actualStats.maxHealth = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentHealthDisplay.text = string.Format(
                        "Health: {0} / {1}",
                        health, actualStats.maxHealth
                    );
                }
                //Update the real time value of the stat
                //Add any additional logic here that needs to be executed when the value changes
            }
        }
    }

    public float CurrentRecovery
    {
        get { return Recovery; }
        set { Recovery = value; }
    }
    public float Recovery
    {
        get { return actualStats.recovery; }
        set
        {
            //Check if the value has changed
            if (actualStats.recovery != value)
            {
                actualStats.recovery = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentRecoveryDisplay.text = "Recovery: " + actualStats.recovery;
                }
            }
        }
    }

    public float CurrentMoveSpeed
    {
        get { return MoveSpeed; }
        set { MoveSpeed = value; }
    }
    public float MoveSpeed
    {
        get { return actualStats.moveSpeed; }
        set
        {
            //Check if the value has changed
            if (actualStats.moveSpeed != value)
            {
                actualStats.moveSpeed = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMoveSpeedDisplay.text = "Move Speed: " + actualStats.moveSpeed;
                }
            }
        }
    }

    public float CurrentMight
    {
        get { return Might; }
        set { Might = value; }
    }
    public float Might
    {
        get { return actualStats.might; }
        set
        {
            //Check if the value has changed
            if (actualStats.might != value)
            {
                actualStats.might = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMightDisplay.text = "Might: " + actualStats.might;
                }
            }
        }
    }

    public float CurrentProjectileSpeed
    {
        get { return Speed; }
        set { Speed = value; }
    }
    public float Speed
    {
        get { return actualStats.speed; }
        set
        {
            //Check if the value has changed
            if (actualStats.speed != value)
            {
                actualStats.speed = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentProjectileSpeedDisplay.text = "Projectile Speed: " + actualStats.speed;
                }
            }
        }
    }

    public float CurrentMagnet
    {
        get { return Magnet; }
        set { Magnet = value; }
    }
    public float Magnet
    {
        get { return actualStats.magnet; }
        set
        {
            //Check if the value has changed
            if (actualStats.magnet != value)
            {
                actualStats.magnet = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMagnetDisplay.text = "Magnet: " + actualStats.magnet;
                }
            }
        }
    }
    #endregion

    public ParticleSystem damageEffect;

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

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

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

    public List<LevelRange> levelRanges;


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

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

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

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

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

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

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

        //Set the current stats display
        GameManager.instance.currentHealthDisplay.text = "Health: " + CurrentHealth;
        GameManager.instance.currentRecoveryDisplay.text = "Recovery: " + CurrentRecovery;
        GameManager.instance.currentMoveSpeedDisplay.text = "Move Speed: " + CurrentMoveSpeed;
        GameManager.instance.currentMightDisplay.text = "Might: " + CurrentMight;
        GameManager.instance.currentProjectileSpeedDisplay.text = "Projectile Speed: " + CurrentProjectileSpeed;
        GameManager.instance.currentMagnetDisplay.text = "Magnet: " + CurrentMagnet;

        GameManager.instance.AssignChosenCharacterUI(characterData);

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

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

        Recover();
    }

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

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

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

        LevelUpChecker();
        UpdateExpBar();
    }

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

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

            UpdateLevelText();

            GameManager.instance.StartLevelUp();
        }
    }

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

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

    public void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            CurrentHealth -= dmg;

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

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;

            if (CurrentHealth <= 0)
            {
                Kill();
            }

            UpdateHealthBar();
        }
    }

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

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

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

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

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

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

    [System.Obsolete("Old function that is kept to maintain compatibility with the InventoryManager. Will be removed soon.")]
    public void SpawnWeapon(GameObject weapon)
    // Creates a weapon using a specific weapon data.
    {
        //Checking if the slots are full, and returning if it is
        if (weaponIndex >= inventory.weaponSlots.Count - 1) //Must be -1 because a list starts from 0
        {
            Debug.LogError("Inventory slots already full");
            return;
        }

        //Spawn the starting weapon
        GameObject spawnedWeapon = Instantiate(weapon, transform.position, Quaternion.identity);
        spawnedWeapon.transform.SetParent(transform);    //Set the weapon to be a child of the player
        //inventory.AddWeapon(weaponIndex, spawnedWeapon.GetComponent<WeaponController>());   //Add the weapon to it's slot

        weaponIndex++;  //Need to increase so slots don't overlap [INCREMENT ONLY AFTER ADDING THE WEAPON TO THE SLOT]
    }

    [System.Obsolete("No need to spawn passive items directly now.")]
    public void SpawnPassiveItem(GameObject passiveItem)
    {
        //Checking if the slots are full, and returning if it is
        if (passiveItemIndex >= inventory.passiveSlots.Count - 1) //Must be -1 because a list starts from 0
        {
            Debug.LogError("Inventory slots already full");
            return;
        }

        //Spawn the passive item
        GameObject spawnedPassiveItem = Instantiate(passiveItem, transform.position, Quaternion.identity);
        spawnedPassiveItem.transform.SetParent(transform);    //Set the passive item to be a child of the player
        //inventory.AddPassiveItem(passiveItemIndex, spawnedPassiveItem.GetComponent<PassiveItem>());   //Add the passive item to it's slot

        passiveItemIndex++;  //Need to increase so slots don't overlap [INCREMENT ONLY AFTER ADDING THE PASSIVE ITEM TO THE SLOT]
    }
}

4. Merging the HealthPotion and ExperienceGem

Currently, for both our HealthPotion and ExperienceGem pickups, we subclass them from Pickup, which actually requires us to write more code than if we were to integrate their functionality into the Pickup class directly.

a. Updating the Pickup class

Hence, let’s update the Pickup class so that it is capable of giving health or experience to the player character, so that we can remove both the HealthPotion and ExperienceGem scripts.

Pickup.cs

using UnityEngine;

public class Pickup : MonoBehaviour
{
    public float lifespan = 0.5f;
    protected PlayerStats target; // If the pickup has a target, then fly towards the target.
    protected float speed; // The speed at which the pickup travels.

    [Header("Bonuses")]
    public int experience;
    public int health;

    protected virtual void Update()
    {
        if(target)
        {
            // Move it towards the player and check the distance between.
            Vector2 distance = target.transform.position - transform.position;
            if (distance.sqrMagnitude > speed * speed * Time.deltaTime)
                transform.position += (Vector3)distance.normalized * speed * Time.deltaTime;
            else
                Destroy(gameObject);

        }
    }

    public virtual bool Collect(PlayerStats target, float speed, float lifespan = 0f)
    {
        if (!this.target)
        {
            this.target = target;
            this.speed = speed;
            if (lifespan > 0) this.lifespan = lifespan;
            Destroy(gameObject, Mathf.Max(0.01f, this.lifespan));
            return true;
        }
        return false;
    }

    protected virtual void OnDestroy()
    {
        if(!target) return;
        if(experience != 0) target.IncreaseExperience(experience);
        if(health != 0) target.RestoreHealth(health);
    }
}

The changes here are pretty straightforward — before our Pickup is destroyed, we attempt to award the health and experience that is set in the Pickup component to the player.

We also check if the target still exists before awarding the health and experience — in the case that the player dies before a pickup reaches him. Otherwise, this will result in a NullReferenceException, as a dead player will no longer be there to receive the health and experience bonuses.

Pickup Configuration for the Experience Gem
The newly-added Health and Experience bonuses for the Pickup.

In future, we will be adding more bonuses for our pickups, as well as pickups with special effects, such as the Nduja Fritta Tanto (the flamethrower pickup), the Orolegion (time stop pickup) and the Vacuum (attracts all gems pickup).

This is why we make the OnDestroy() function protected and virtual, because for some of these features to be implemented efficiently, we will have to subclass the Pickup class.

b. Fixing the health bar not updating after healing

Newly-added on 18 February 2024: This section is new, as we found this bug while recording the video for this part.

We also need to update the PlayerStats script, as the Recover() and RestoreHealth() functions do not update the player’s health bar. Currently, only the TakeDamage() function does, so the health bar does not instantaneously update after we pick up a health potion.

Instead, we have to pick up a health potion, then take damage, for the restored health to show up.

Health bar not updating
The health bar only updates when we take damage.

Hence, we add a line to call UpdateHealthBar() in both the Recover() and RestoreHealth() functions in PlayerStats.

PlayerStats.cs

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

public class PlayerStats : MonoBehaviour
{

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


    float health;

    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

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

            if (health != value)
            {

                health = value;
                if (GameManager.instance != null)
                {

                    GameManager.instance.currentHealthDisplay.text = string.Format(
                        "Health: {0} / {1}",
                        health, actualStats.maxHealth
                    );
                }

            }
        }
    }

    public float MaxHealth
    {
        get { return actualStats.maxHealth; }

        // If we try and set the max health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed
            if (actualStats.maxHealth != value)
            {
                actualStats.maxHealth = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentHealthDisplay.text = string.Format(
                        "Health: {0} / {1}",
                        health, actualStats.maxHealth
                    );
                }
                //Update the real time value of the stat
                //Add any additional logic here that needs to be executed when the value changes
            }
        }
    }

    public float CurrentRecovery
    {
        get { return Recovery; }
        set { Recovery = value; }
    }
    public float Recovery
    {
        get { return actualStats.recovery; }
        set
        {
            //Check if the value has changed
            if (actualStats.recovery != value)
            {
                actualStats.recovery = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentRecoveryDisplay.text = "Recovery: " + actualStats.recovery;
                }
            }
        }
    }

    public float CurrentMoveSpeed
    {
        get { return MoveSpeed; }
        set { MoveSpeed = value; }
    }
    public float MoveSpeed
    {
        get { return actualStats.moveSpeed; }
        set
        {
            //Check if the value has changed
            if (actualStats.moveSpeed != value)
            {
                actualStats.moveSpeed = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMoveSpeedDisplay.text = "Move Speed: " + actualStats.moveSpeed;
                }
            }
        }
    }

    public float CurrentMight
    {
        get { return Might; }
        set { Might = value; }
    }
    public float Might
    {
        get { return actualStats.might; }
        set
        {
            //Check if the value has changed
            if (actualStats.might != value)
            {
                actualStats.might = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMightDisplay.text = "Might: " + actualStats.might;
                }
            }
        }
    }

    public float CurrentProjectileSpeed
    {
        get { return Speed; }
        set { Speed = value; }
    }
    public float Speed
    {
        get { return actualStats.speed; }
        set
        {
            //Check if the value has changed
            if (actualStats.speed != value)
            {
                actualStats.speed = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentProjectileSpeedDisplay.text = "Projectile Speed: " + actualStats.speed;
                }
            }
        }
    }

    public float CurrentMagnet
    {
        get { return Magnet; }
        set { Magnet = value; }
    }
    public float Magnet
    {
        get { return actualStats.magnet; }
        set
        {
            //Check if the value has changed
            if (actualStats.magnet != value)
            {
                actualStats.magnet = value;
                if (GameManager.instance != null)
                {
                    GameManager.instance.currentMagnetDisplay.text = "Magnet: " + actualStats.magnet;
                }
            }
        }
    }
    #endregion

    public ParticleSystem damageEffect;

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

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

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

    public List<LevelRange> levelRanges;


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

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

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

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

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

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

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

        //Set the current stats display
        GameManager.instance.currentHealthDisplay.text = "Health: " + CurrentHealth;
        GameManager.instance.currentRecoveryDisplay.text = "Recovery: " + CurrentRecovery;
        GameManager.instance.currentMoveSpeedDisplay.text = "Move Speed: " + CurrentMoveSpeed;
        GameManager.instance.currentMightDisplay.text = "Might: " + CurrentMight;
        GameManager.instance.currentProjectileSpeedDisplay.text = "Projectile Speed: " + CurrentProjectileSpeed;
        GameManager.instance.currentMagnetDisplay.text = "Magnet: " + CurrentMagnet;

        GameManager.instance.AssignChosenCharacterUI(characterData);

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

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

        Recover();
    }

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

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

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

        LevelUpChecker();
        UpdateExpBar();
    }

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

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

            UpdateLevelText();

            GameManager.instance.StartLevelUp();
        }
    }

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

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

    public void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            CurrentHealth -= dmg;

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

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;

            if (CurrentHealth <= 0)
            {
                Kill();
            }

            UpdateHealthBar();
        }
    }

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

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

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

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

            UpdateHealthBar();
        }
    }

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

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

            UpdateHealthBar();
        }
    }

    [System.Obsolete("Old function that is kept to maintain compatibility with the InventoryManager. Will be removed soon.")]
    public void SpawnWeapon(GameObject weapon)
    // Creates a weapon using a specific weapon data.
    {
        //Checking if the slots are full, and returning if it is
        if (weaponIndex >= inventory.weaponSlots.Count - 1) //Must be -1 because a list starts from 0
        {
            Debug.LogError("Inventory slots already full");
            return;
        }

        //Spawn the starting weapon
        GameObject spawnedWeapon = Instantiate(weapon, transform.position, Quaternion.identity);
        spawnedWeapon.transform.SetParent(transform);    //Set the weapon to be a child of the player
        //inventory.AddWeapon(weaponIndex, spawnedWeapon.GetComponent<WeaponController>());   //Add the weapon to it's slot

        weaponIndex++;  //Need to increase so slots don't overlap [INCREMENT ONLY AFTER ADDING THE WEAPON TO THE SLOT]
    }

    [System.Obsolete("No need to spawn passive items directly now.")]
    public void SpawnPassiveItem(GameObject passiveItem)
    {
        //Checking if the slots are full, and returning if it is
        if (passiveItemIndex >= inventory.passiveSlots.Count - 1) //Must be -1 because a list starts from 0
        {
            Debug.LogError("Inventory slots already full");
            return;
        }

        //Spawn the passive item
        GameObject spawnedPassiveItem = Instantiate(passiveItem, transform.position, Quaternion.identity);
        spawnedPassiveItem.transform.SetParent(transform);    //Set the passive item to be a child of the player
        //inventory.AddPassiveItem(passiveItemIndex, spawnedPassiveItem.GetComponent<PassiveItem>());   //Add the passive item to it's slot

        passiveItemIndex++;  //Need to increase so slots don't overlap [INCREMENT ONLY AFTER ADDING THE PASSIVE ITEM TO THE SLOT]
    }
}

5. Merging the BobbingAnimation into Pickup

The last optimisation we will make here is to merge the BobbingAnimation script into the Pickup class. You may be wondering why this is an optimisation — wouldn’t keeping it separate from Pickup mean that we can also apply the animation to other objects in future?

This would be true if the BobbingAnimation script operated independently of the Pickup script, which it doesn’t:

public class BobbingAnimation : MonoBehaviour
{
    public float frequency;  // Speed of movement
    public float magnitude; // Range of movement
    public Vector3 direction; // Direction of movement
    Vector3 initialPosition;
    Pickup pickup;

    void Start()
    {
        pickup = GetComponent<Pickup>();

        // Save the starting position of the game object
        initialPosition = transform.position;
    }

    void Update()
    {
        if (pickup && !pickup.hasBeenCollected)
        {
            // Sine function for smooth bobbing effect
            transform.position = initialPosition + direction * Mathf.Sin(Time.time * frequency) * magnitude;
        }
    }
}

Because of this, it would be more efficient to integrate its workings into the Pickup script itself. That way, we won’t need to have 2 components that need to constantly communicate with each other in every one of our Pickups.

a. Pickup script with BobbingAnimation

Essentially, what we’re doing here is finding the right places to insert the BobbingAnimation code into Pickup. Of special mention is the fact that the actual animation code goes perfectly into the else clause inside the Update() function — quite an elegant fit.

Pickup.cs

using UnityEngine;

public class Pickup : MonoBehaviour
{
    public float lifespan = 0.5f;
    protected PlayerStats target; // If the pickup has a target, then fly towards the target.
    protected float speed; // The speed at which the pickup travels.
    Vector2 initialPosition;

    // To represent the bobbing animation of the object.
    [System.Serializable]
    public struct BobbingAnimation
    {
        public float frequency;
        public Vector2 direction;
    }
    public BobbingAnimation bobbingAnimation = new BobbingAnimation {
        frequency = 2f, direction = new Vector2(0,0.3f)
    };

    [Header("Bonuses")]
    public int experience;
    public int health;

    protected virtual void Start()
    {
        initialPosition = transform.position;
    }

    protected virtual void Update()
    {
        if(target)
        {
            // Move it towards the player and check the distance between.
            Vector2 distance = target.transform.position - transform.position;
            if (distance.sqrMagnitude > speed * speed * Time.deltaTime)
                transform.position += (Vector3)distance.normalized * speed * Time.deltaTime;
            else
                Destroy(gameObject);

        }
        else
        {
            // Handle the animation of the object.
            transform.position = initialPosition + bobbingAnimation.direction * Mathf.Sin(Time.time * bobbingAnimation.frequency);
        }
    }

    public virtual bool Collect(PlayerStats target, float speed, float lifespan = 0f)
    {
        if (!this.target)
        {
            this.target = target;
            this.speed = speed;
            if (lifespan > 0) this.lifespan = lifespan;
            Destroy(gameObject, Mathf.Max(0.01f, this.lifespan));
            return true;
        }
        return false;
    }

    protected virtual void OnDestroy()
    {
        if(!target) return;
        target.IncreaseExperience(experience);
        target.RestoreHealth(health);
    }
}

Also, because we store the attributes of the bobbing animation inside a nested struct, the variables fit nicely inside a dropdown in Pickup, keeping the Inspector interface relatively neat.

Pickup with bobbing animation
Keeping things neat.

b. Randomising the starting frame

One issue that you will start to see with the pickups is, if the spawn at the same time, this happens.

Uniform bobbing animation
If the pickups spawn at the
same time…

To make things look more organic, we can randomise the starting frame of their animation, so that they no longer look like they move in tandem:

Pickup.cs

using UnityEngine;

public class Pickup : MonoBehaviour
{
    public float lifespan = 0.5f;
    protected PlayerStats target; // If the pickup has a target, then fly towards the target.
    protected float speed; // The speed at which the pickup travels.
    Vector2 initialPosition;
    float initialOffset;

    // To represent the bobbing animation of the object.
    [System.Serializable]
    public struct BobbingAnimation
    {
        public float frequency;
        public Vector2 direction;
    }
    public BobbingAnimation bobbingAnimation = new BobbingAnimation {
        frequency = 2f, direction = new Vector2(0,0.3f)
    };

    [Header("Bonuses")]
    public int experience;
    public int health;

    protected virtual void Start()
    {
        initialPosition = transform.position;
        initialOffset = Random.Range(0, bobbingAnimation.frequency);
    }

    protected virtual void Update()
    {
        if(target)
        {
            // Move it towards the player and check the distance between.
            Vector2 distance = target.transform.position - transform.position;
            if (distance.sqrMagnitude > speed * speed * Time.deltaTime)
                transform.position += (Vector3)distance.normalized * speed * Time.deltaTime;
            else
                Destroy(gameObject);

        }
        else
        {
            // Handle the animation of the object.
            transform.position = initialPosition + bobbingAnimation.direction * Mathf.Sin((Time.time + initialOffset) * bobbingAnimation.frequency);
        }
    }

    public virtual bool Collect(PlayerStats target, float speed, float lifespan = 0f)
    {
        if (!this.target)
        {
            this.target = target;
            this.speed = speed;
            if (lifespan > 0) this.lifespan = lifespan;
            Destroy(gameObject, Mathf.Max(0.01f, this.lifespan));
            return true;
        }
        return false;
    }

    protected virtual void OnDestroy()
    {
        if(!target) return;
        target.IncreaseExperience(experience);
        target.RestoreHealth(health);
    }
}

This will give us more organic looking pickups, even if they spawn at the same time!

Randomised bobbing animation
This looks more natural.

6. Reconfiguring your prefabs

After making all the code changes above, the last thing you will need to do is reconfigure your prefabs, so that the collection system continues to work properly.

a. Reducing the Pull Speed in PlayerCollector

The first thing you want to do is head to your PlayerCollector component and reduce the Pull Speed from 200 to 10. Otherwise, your pickups will be collected instantly when you get near them because of how fast they are moving.

Reducing the pull speed
Reducing the Pull Speed.

The reason why we have to set the Pull Speed much lower is because we used to use Rigidbody.AddForce() to move it. If you recall from your high school physics classes, force is equals to mass times acceleration. Hence, the effective acceleration of any object with a force applied to it is divided by its mass — which is why the effective change in speed is much slower.

In our new code, we do not apply a force to the pickups. Instead, we set their velocities directly — which is why we can no longer set a high pull speed, because the number is no longer being divided by the pickup’s mass.

b. Updating your pickup prefabs

Because we removed the BobbingAnimation, ExperienceGem and HealthPotion scripts, you will need to revisit all your pickup prefabs, remove these components (which will now be labelled missing), and add a Pickup component to all of them.

Here are all the components I have on my pickup prefabs:

  • Transform
  • Sprite Renderer
  • Collider 2D
  • Rigidbody 2D
  • Pickup

Remember to set all the variables in the newly-added Pickup component as well!

7. Conclusion

Once that is done, you should have a pickup system that looks and works much better. Let us know in the forums if you find any bugs with this new system!

Silver-tier Patrons will get to download the project files as well.

Leave a Reply

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

Note: You can use Markdown to format your comments.

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

I agree to these terms.

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