This article will be free until the accompanying video is released.
In the last part, we focused on building a new, modular and much simpler way of handling UI. We are following this up in Part 13 by upgrading and simplifying the code for our health and mana UI as well, as there are a couple of problems with it.
1. Upgrading the Health UI script
Let’s work on upgrading our health UI first.
a. Problems with the Health UI
The current health UI has the following problems:
- It is not responsive—at certain screen sizes, the heart sprites get cut off. Although we won’t be able to make it fully responsive without the use of Unity’s new UI Toolkit, the new configuration still makes it much more device-friendly.

- The
HeartController
is hardcoded to only work with thePlayerController
script. It cannot be decoupled and made to display health for anything else, which makes it not ideal as it is not very modular. - Our
HeartController
script also does not handle excess blue health, so we will take this opportunity to add that functionality as well.

b. Creating the new UIHealth
script
To fix the above problems, we will create a new script called UIHealth
, which will inherit from the UIScreen
script that we made in the previous part:
UIHealth.cs
using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [DisallowMultipleComponent] public class UIHealth : UIScreen { [Header("Health UI")] public int healthPerUnit = 1; public GameObject healthUnitPrefab, excessHealthUnitPrefab; public string containerPath, fillPath; public Color excessHealthColour = Color.blue; readonly List<GameObject> healthUnits = new List<GameObject>(), excessHealthUnits = new List<GameObject>(); protected override void Awake() { base.Awake(); for(int i = 0; i < transform.childCount; i++) { GameObject go = transform.GetChild(i).gameObject; if (go.name.StartsWith(healthUnitPrefab.name) || go.name.StartsWith(excessHealthUnitPrefab.name)) Destroy(go); } } public void Refresh(float health, float maxHealth, float excessHealth = 0) { float targetItemCount = maxHealth / healthPerUnit; float excessItemCount = excessHealth / healthPerUnit; // Remove any extra children. while(healthUnits.Count > targetItemCount) { // Remove any extra children. GameObject toRemove = healthUnits[healthUnits.Count - 1]; if (healthUnits.Remove(toRemove)) Destroy(toRemove); } // Add extra children if needed. while (healthUnits.Count < targetItemCount) healthUnits.Add(Instantiate(healthUnitPrefab, transform)); // Removes all excess health units, so that we can re-add them later. for (int i = 0; i < excessHealthUnits.Count; i++) { GameObject go = excessHealthUnits[i]; if (excessHealthUnits.Count > excessItemCount) { if (excessHealthUnits.Remove(go)) { Destroy(go); i--; continue; } } else { go.transform.SetAsLastSibling(); } } // Add extra excess health if needed. while (excessHealthUnits.Count < excessItemCount) excessHealthUnits.Add(Instantiate(excessHealthUnitPrefab, transform)); // Set the exact health. float fillUnits = health / healthPerUnit; for (int i = 0; i < healthUnits.Count; i++) { // Update the containers inside of each heart. Transform item = healthUnits[i].transform; Transform container = string.IsNullOrWhiteSpace(containerPath) ? item : item.Find(containerPath); Image containerImg = container.GetComponent<Image>(); float remainder = targetItemCount - i; containerImg.fillAmount = Mathf.Clamp01(remainder); // Update the fills inside of each heart. Transform fill = string.IsNullOrWhiteSpace(fillPath) ? item : item.Find(fillPath); if (fill) { Image fillImg = fill.GetComponent<Image>(); remainder = fillUnits - i; fillImg.fillAmount = Mathf.Clamp01(remainder); } } // Set the exact excess health. float excessFillUnits = excessHealth * 1.0f / healthPerUnit; for (int i = 0; i < excessHealthUnits.Count; i++) { // Update the containers inside of each heart. Transform item = excessHealthUnits[i].transform; Image containerImg = item.GetComponent<Image>(); float remainder = excessFillUnits - i; containerImg.fillAmount = Mathf.Clamp01(remainder); if (remainder < 1) item.SetAsLastSibling(); } } }
c. Why create a new UIHealth
script?
This new script has several advantages over the old HeartController
when it comes to handling the health UI of the player. For starters, it has more options than the old HeartController
—something we will discuss further later when setting it up for our game.

UIHealth
component is much more configurable.
It is also much more modularised, and as a result, much less complicated to set up than the HeartController
. The old HeartController
script was attached to the UIManager
GameObject, and was hardcoded to reference the PlayerController
in multiple parts.
This means that the HeartController
script isn’t very flexible, as it will strictly only work with the PlayerController
script. An especially awkward part of its functionality is in its Start()
function, where it references PlayerController
variables, and adds itself into the onHealthChangedCallback
delegate.
void Start() { heartContainers = new GameObject[PlayerController.Instance.maxTotalHealth]; heartFills = new Image[PlayerController.Instance.maxTotalHealth]; PlayerController.Instance.onHealthChangedCallback += UpdateHeartsHUD; InstantiateHeartContainers(); UpdateHeartsHUD(); }
By using the variables in PlayerController
like this, it makes our code especially prone to errors. For instance, if you had a scene where you had the HeartController
component attached to something, but didn’t have a player object on the Scene, the Start()
function would cause UIHealth
to throw multiple NullReferenceException
errors because PlayerController.Instance
would be null. This is not very healthy for development, especially if multiple people are working on the project, because it will create hard-to-trace errors that clutter the Console window.
d. How to use UIHealth
The new UIHealth
script is a lot easier to use, and also much more error-free, than the old HeartController
script. It can be used to display the health of not just the player, but of anyone else as well.
To use it, we simply get a reference to the UIHealth
component we want to update, and call the Refresh()
function on it, like so:
healthUIObject.Refresh(health, maxHealth, excessHealth);
We specify the amount of health, the maximum health we should have, as well as the excess health, and the UIHealth
script will take the information, and draw the hearts accordingly.
How the hearts will be drawn, will depend on the settings that you place into the UIHealth
component:

UIHealth
component settings.Most of the settings you find in the UIHealth
component are inherited from UIScreen
—the ones that are not are drawn inside the red square.
Why does UIHealth
inherit from UIScreen
? Primarily because classes inheriting from UIScreen
can easily be faded in and out using the Activate()
and Deactivate()
functions that we’ve created in the previous part. There may be times when you may choose to hide your health UI, for various reasons like when showing cutscenes.
The most important settings here are the first three:
Property | Description |
---|---|
Health Per Unit | Controls how many divisions each heart has. If Health Per Unit is set to 2, for example, then every heart can be full or half-filled; if set to 3, then it can be full, 2-thirds or 1-third, etc. |
Health Unit Prefab | This is the prefab that will be used to show every unit of health. It is similar to the Heart Container Prefab in the HeartController script. |
Excess Health Unit Prefab | This is the prefab that will be used to show excess health. These are equivalent to the blue health that you find in Hollow Knight. |
Container Path | This is the path to the GameObject in your health prefabs that contains the image for the container. It is used by the script to find and adjust the size of the container (in instances where you may not have a full heart of health). |
Fill Path | This is the path to the GameObject in your health prefabs that contains the image for the fill of the heart. It is used by the script to adjust whether the heart is filled or not. It cannot be left empty. |
I will explain Container Path and Fill Path more later on when we set up the new health and mana UI on the Canvas, as it is easier to do so then.
2. Upgrading the Mana UI script
Just like the health UI script, we will be upgrading the way that the mana UI is handled in our code too.
a. Problems with the Mana UI
Like the health UI, the way the mana UI is coded is not very ideal. In fact, there is much more hardcoding with the mana UI than there is in the health UI, and functionality for the player’s mana management and mana UI are mixed together and spread across multiple scripts.
For starters, if you remember from previous parts, the main and sub-orbs of the UI are handled by completely different scripts:

The PlayerController
script handles both the mana, and the main ball of mana on the UI, while the additional mana orbs, and the excess mana they provide, are handled by ManaOrbsHandler
. Finally, we have the UIManager
, which handles the halving of the mana when the player dies.
While spreading the code over multiple scripts is not a bad thing, it is important for there to be a clear separation of functionality between the scripts. This keeps things organised and minimises errors in the long run:
PlayerController
, being in charge of the player character, should manage the excess mana as well, and not let its UI scripts do it. It also should not be handling the display of the mana UI elements.- The scripts responsible for handling mana UI should be handling both the main mana ball and the excess ones.
- The
UIManager
should not handle anything. It should simply serve as the bridge between thePlayerController
and the mana UI script if necessary.
This will give us a clear separation of concerns between the scripts, and it will go a long way to make our codes not just error-free, but also less error-prone.
b. Creating the new UIMana
script
To fix the above problems, we will create a new UIMana
script to replace the old ManaOrbsHandler
script. This new script will handle the UI for all mana orbs, instead of just the smaller extra ones.
UIMana.cs
using UnityEngine; using UnityEngine.UI; public class UIMana : UIScreen { [Header("Mana UI")] public Image primaryFillUI; public Image[] excessFillsUI; public Image overlayUI; public Sprite defaultOverlaySprite, penaltyOverlaySprite; public void Refresh(float mana, float maxMana, float excessMana, float excessMaxMana) { primaryFillUI.fillAmount = mana / maxMana; for(int i = 0; i < excessFillsUI.Length; i++) { if (excessMaxMana <= i) { excessFillsUI[i].gameObject.SetActive(false); excessFillsUI[i].fillAmount = 0; } else { excessFillsUI[i].gameObject.SetActive(true); if (excessMana >= i) { excessFillsUI[i].fillAmount = excessMana - i; } } } } public void SetMode(float penalty) { if(!overlayUI) return; if(penalty < 1f) overlayUI.sprite = penaltyOverlaySprite; else overlayUI.sprite = defaultOverlaySprite; } }
c. How to use UIMana
Like UIHealth
, this new UIMana
script is completely separated from the PlayerController
script, which alleviates the same problems that UIHealth
had, where the old UI code from ManaOrbsHandler
directly referenced the PlayerController
script in several functions:
// Start is called before the first frame update void Start() { for(int i = 0; i < PlayerController.Instance.manaOrbs; i++) { manaOrbs[i].SetActive(true); } } // Update is called once per frame void Update() { for (int i = 0; i < PlayerController.Instance.manaOrbs; i++) { manaOrbs[i].SetActive(true); } CashInMana(); } void CashInMana() { if(usedMana && PlayerController.Instance.Mana <=1) { countDown -= Time.deltaTime; } if(countDown <= 0) { usedMana = false; countDown = 3; totalManaPool = (orbFills[0].fillAmount += orbFills[1].fillAmount += orbFills[2].fillAmount) * 0.33f; float manaNeeded = 1 - PlayerController.Instance.Mana; if(manaNeeded > 0) { if(totalManaPool >= manaNeeded) { PlayerController.Instance.Mana += manaNeeded; for(int i = 0; i < orbFills.Count; i++) { orbFills[i].fillAmount = 0; } float addBackTotal = (totalManaPool - manaNeeded) / 0.33f; while(addBackTotal > 0) { UpdateMana(addBackTotal); addBackTotal -= 1; } } else { PlayerController.Instance.Mana += totalManaPool; for (int i = 0; i < orbFills.Count; i++) { orbFills[i].fillAmount = 0; } } } } }
The new UIMana
script is completely decoupled from the PlayerController
, and to update it, you simply have to call the Refresh()
function, just like UIHealth
:
manaUI.Refresh(mana, maxMana, excessMana, excessMaxMana);
The component itself has a bit more complexity than UIHealth
, as it has more fields for us to fill in:

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

Property | Description |
---|---|
Primary Fill UI | The Image component for the main mana orb. |
Excess Fills UI | An array of Image components for the smaller mana orbs. |
Overlay UI | The image component that covers over the main mana orb. It is replaced with a cracked texture when the mana is under penalty |
Default Overlay Sprite | The sprite for when mana is not under penalty. |
Penalty Overlay Sprite | The sprite for when mana is under penalty. |
Now that we have both UI scripts set up and ready to use, let’s modify the rest of our scripts to work with them, and implement these new components on the Canvas.
3. Updating the PlayerController
script
Now that we have both of the new components, we’ll need to modify the PlayerController
script to make use of them.
a. Updating the UIManager
Before we get to that though, let’s update the UIManager
so that it holds a reference to both UIHealth
and UIMana
, as well as the static functions UpdateHealthUI()
and UpdateManaUI()
, so that the PlayerController
will be able to access the health and mana UI through it.
We also remove the SwitchMana()
function, as well as the variables and any other logic that handle the UI for the player’s mana penalties on death, since all our logic for mana UI will be handled by UIMana
.
UIManager.cs
using UnityEngine; public class UIManager : UIScreen { public static UIManager Instance; [Header("UI Manager")] public UIHealth healthUI; public UIMana manaUI; public UIScreen deathScreen; public GameObject mapHandler; public GameObject inventory;[SerializeField] GameObject halfMana, fullMana;public static void UpdateHealthUI(int health, int maxHealth, int excessHealth = 0) { if (Instance && Instance.healthUI) Instance.healthUI.Refresh(health, maxHealth, excessHealth); } public static void UpdateManaUI(float mana, float maxMana, float excessMana, float excessMaxMana, float manaPenalty = 1f) { if (Instance && Instance.manaUI) Instance.manaUI.Refresh(mana, maxMana, excessMana, excessMaxMana); Instance.manaUI.SetMode(manaPenalty); }public enum ManaState { FullMana, HalfMana } public ManaState manaState;protected override void Awake() {base.Awake();if (Instance != null && Instance != this) { Destroy(gameObject); return; } base.Awake(); DontDestroyOnLoad(gameObject); Instance = this; }public void SwitchMana(ManaState _manaState) { switch(_manaState) { case ManaState.FullMana: halfMana.SetActive(false); fullMana.SetActive(true); break; case ManaState.HalfMana: fullMana.SetActive(false); halfMana.SetActive(true); break; } manaState = _manaState; }}
b. The new PlayerController
script
Now that the UIManager
is updated, we can update our PlayerController
script. There are quite a number of changes made to the PlayerController
script, so let’s go through them:
For the player’s health stats, the updates are pretty straightfoward:
- We add a new
excessHealth
variable to handle blue health. To that end, we also modify theTakeDamage()
, so that it absorbs damage withexcessHealth
first before reducing the player’s health. - We also mark the
OnHealthChangedDelegate()
andonHealthChangedCallback
as obsolete, because we’ll be replacing all calls toonHealthChangedCallback()
with the functionUIManager.Instance.UpdateHealthUI()
instead.
Why are we removing the onHealthChangedCallback
delegate? Doesn’t it make things more flexible?
Yes, delegates do make things more flexible, because you can add or remove actions from it to change the way it behaves. However, in our current case, it overcomplicates things—the UI updates for the player’s health will always be there, so there is no need to go through the additional step of hooking it onto a delegate, since it will never be turned off in-game.
A delegate that fires whenever the player’s health changes will be very useful when we implement charms in future, as they can change the way the player takes and receives damage. However, we can create separate delegates for them in the future that are more specific.
The logic for the player’s mana is where the complexity is at, because it used to be managed by the ManaOrbsHandler
previously:
- We move the variables from
ManaOrbsHandler
over to thePlayerController
and give them more descriptive names. These variables areexcessMana
,excessMaxManaUnits
,excessMaxManaUnitsLimit
,manaPerExcessUnit
,excessManaRestoreDelay
,excessManaRestoreRate
andexcessManaRestoreCooldown
. Since there are a substantial number of properties added, below is a table summarising these new properties.
Attribute | Description |
---|---|
Mana Settings | |
Mana | The current amount of mana in the main orb. |
Max Mana | How much mana is in the orb when it is full. In the old system, the max mana is 1; but we’ve changed it to 3. |
Mana Penalty | A value between 0 and 1. Affects how much max mana you can have. When the player dies, this will be set to 0.5 since the maximum mana will be halved. |
Excess Mana Settings | |
Excess Mana | How much excess mana there is. |
Excess Max Mana Units | Maximum amount of Excess Mana possible. |
Excess Max Mana Units Limit | Maximum amount of Excess Max Mana Units possible. |
Mana Per Excess Unit | How much mana is equal to 1 mini mana orb. The default is 1, but you can change this if you want each mini orb to contain more than 1 mana. |
Excess Mana Restore Delay | How long to wait before the Excess Mana refills the main orb. |
Excess Mana Restore Rate | In the older version, the mana restoration is instant. But in Hollow Knight, it is not! This property determines how fast the excess mana fills into the main mana orb to be used when activated. |
Mana Shards | How many mana shards have been collected. |
Mana Shards Per Excess Unit | This controls how many mana shards need to be collected to give you 1 extra mana orb. |
Spell Settings | |
Attack Mana Gain | How much mana is gained when attacking. Renamed from the old manaGain variable, as the name wasn’t very descriptive previously. |
Heal Mana Cost Per Second | How much mana is drained per second when healing. Renamed from the old manaDrain variable, which wasn’t a very descriptive name. |

- A new function
HandleRestoreManaWithExcess()
is created to handle the refilling of mana whenever we have excess. The function is called every frame inUpdate()
, and it tracks a variableexcessManaRestoreCooldown
. When the value hits 0, we restore excess mana into the main mana tank. - In the
Mana
property, we add some additional logic into the setter to detect when it is being increased, and to funnel excess mana into theexcessMana
variable (instead of continuing to fill upmana
). This makes it so that we can directly doMana += attackManaGain
when we hit enemies, instead of having to write a complex conditional block. - A
MaxMana
property is also defined. This property retrieves themaxMana
value, but factors in themanaPenalty
variable as well. This means that if I havemaxMana
at 3, andmanaPenalty
at 0.5,MaxMana
will be 1.5 (3 × 0.5). - Another property,
ExcessMaxMana
, is also defined. This creates an easy way to retrieve how much the max excess mana currently is, since the maximum excess mana isexcessMaxManaUnits
×manaPerExcessUnit
.
By making all of these changes, the PlayerController
is now fully managing its own excess mana supply. Even without ManaOrbsHandler
, it will now be able to accumulate and redistribute excess mana.
- The last change is a slight modification to
Respawned()
—it no longer relies onUIManager
to halve the player’s mana, and it takes an optional parameter that sets themanaPenalty
. Previously, whenever the player respawns, he will always get a mana penalty. With this modification, we can callRespawned(0)
to respawn with no mana penalty, andRespawned(0.5)
to respawn with 50% penalty, so that we have more flexibility in our respawn options.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEditor.Build; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; ////sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps [SerializeField] private int maxFallingSpeed; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Wall Jump Settings")] [SerializeField] private float wallSlidingSpeed = 2f; [SerializeField] private Transform wallCheck; [SerializeField] private LayerMask wallLayer; [SerializeField] private float wallJumpingDuration; [SerializeField] private Vector2 wallJumpingPower; float wallJumpingDirection; bool isWallSliding; bool isWallJumping; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs bool restoreTime; float restoreTimeSpeed; [Space(5)] [Header("Recoil Settings:")] [SerializeField] private int recoilXSteps = 5; //how many FixedUpdates() the player recoils horizontally for [SerializeField] private int recoilYSteps = 5; //how many FixedUpdates() the player recoils vertically for [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil private int stepsXRecoiled, stepsYRecoiled; //the no. of steps recoiled horizontally and verticall [Space(5)] [Header("Health Settings")] public int health; public int maxHealth; public int maxTotalHealth = 10; public int excessHealth = 0; public int heartShards; [Min(1)] public int heartShardsPerHealth = 4; [SerializeField] GameObject bloodSpurt; [SerializeField] float hitFlashSpeed; [System.Obsolete] public delegate void OnHealthChangedDelegate();[HideInInspector][System.Obsolete] public OnHealthChangedDelegate onHealthChangedCallback; float healTimer; [SerializeField] float timeToHeal; [Space(5)] [Header("Mana Settings")][SerializeField] UnityEngine.UI.Image manaStorage; [SerializeField] float mana; [SerializeField] float manaDrainSpeed; [SerializeField] float manaGain; public bool halfMana; public ManaOrbsHandler manaOrbsHandler; public int orbShard; public int manaOrbs;public float mana = 3; public float maxMana = 3; [Range(0, 1)] public float manaPenalty = 0f; [Header("Excess Mana Settings")] public float excessMana = 0; public int excessMaxManaUnits = 0, excessMaxManaUnitsLimit = 3; public float manaPerExcessUnit = 1f; [SerializeField] float excessManaRestoreDelay = 3f, excessManaRestoreRate = 1f; float excessManaRestoreCooldown = 0f; public int manaShards = 0; [Min(1)] public int manaShardsPerExcessUnit = 4;[Space(5)] [Header("Spell Settings")] //spell stats [SerializeField] float attackManaGain = 0.34f; [SerializeField] float healManaCostPerSecond = 1f; [SerializeField] float manaSpellCost = 0.3f; [SerializeField] float timeBetweenCast = 0.5f; [SerializeField] float spellDamage; //upspellexplosion and downspellfireball [SerializeField] float downSpellForce; // desolate dive only //spell cast objects [SerializeField] GameObject sideSpellFireball; [SerializeField] GameObject upSpellExplosion; [SerializeField] GameObject downSpellFireball; float timeSinceCast; float castOrHealTimer; [Space(5)] [Header("Camera Stuff")] [SerializeField] private float playerFallSpeedThreshold = -10; [Space(5)] [Header("Audio")] [SerializeField] AudioClip landingSound; [SerializeField] AudioClip jumpSound; [SerializeField] AudioClip dashAndAttackSound; [SerializeField] AudioClip spellCastSound; [SerializeField] AudioClip hurtSound; [HideInInspector] public PlayerStateList pState; [HideInInspector] public Rigidbody2D rb; private Animator anim; private SpriteRenderer sr; private AudioSource audioSource; //Input Variables private float xAxis, yAxis; private bool attack = false; bool openMap; bool openInventory; private bool canFlash = true; private bool landingSoundPlayed; public static PlayerController Instance; //unlocking public bool unlockedWallJump; public bool unlockedDash; public bool unlockedVarJump; public bool unlockedSideCast; public bool unlockedUpCast; public bool unlockedDownCast; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } DontDestroyOnLoad(gameObject); } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); sr = GetComponent<SpriteRenderer>(); anim = GetComponent<Animator>();manaOrbsHandler = FindObjectOfType<ManaOrbsHandler>();audioSource = GetComponent<AudioSource>(); gravity = rb.gravityScale; Mana = mana;manaStorage.fillAmount = Mana;Health = maxHealth; SaveData.Instance.LoadPlayerData();if (manaOrbs > 3) { manaOrbs = 3; } if (halfMana) { UIManager.Instance.SwitchMana(UIManager.ManaState.HalfMana); } else { UIManager.Instance.SwitchMana(UIManager.ManaState.FullMana); } if (onHealthChangedCallback != null) onHealthChangedCallback.Invoke(); if (Health == 0) { pState.alive = false; GameManager.Instance.RespawnPlayer(); }UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } void HandleRestoreManaWithExcess() { if(excessManaRestoreCooldown > 0) { excessManaRestoreCooldown -= Time.deltaTime; } else if(Mana < MaxMana && excessMana > 0f) { float restoreAmount = Mathf.Min(excessMana, excessManaRestoreRate * Time.deltaTime); Mana += restoreAmount; ExcessMana -= restoreAmount; } } // Update is called once per frame void Update() { if (pState.cutscene || GameManager.Instance.isPaused) return; if (pState.alive) { HandleRestoreManaWithExcess(); GetInputs(); ToggleMap(); ToggleInventory(); } UpdateJumpVariables(); UpdateCameraYDampForPlayerFall(); if (pState.dashing) return; FlashWhileInvincible(); if (!pState.alive) return; if (!isWallJumping) { Move(); } Heal(); CastSpell(); if (pState.healing) return; if (!isWallJumping) { Flip(); Jump(); } if (unlockedWallJump) { WallSlide(); WallJump(); } if (unlockedDash) { StartDash(); } Attack(); } private void OnTriggerEnter2D(Collider2D _other) //for up and down cast spell { if (_other.GetComponent<Enemy>() != null && pState.casting) { _other.GetComponent<Enemy>().EnemyGetsHit(spellDamage, (_other.transform.position - transform.position).normalized, -recoilYSpeed); } } private void FixedUpdate() { if (pState.cutscene) return; if (pState.dashing || pState.healing) return; Recoil(); } void GetInputs() { //if (GameManager.Instance.isPaused || GameManager.isStopped) return; xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetButtonDown("Attack"); openMap = Input.GetButton("Map"); openInventory = Input.GetButton("Inventory"); if (Input.GetButton("Cast/Heal")) { castOrHealTimer += Time.deltaTime; } } void ToggleMap() { if (openMap) { UIManager.Instance.mapHandler.SetActive(true); } else { UIManager.Instance.mapHandler.SetActive(false); } } void ToggleInventory() { if (openInventory) { UIManager.Instance.inventory.SetActive(true); } else { UIManager.Instance.inventory.SetActive(false); } } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } private void Move() { if (pState.healing) rb.velocity = new Vector2(0, 0); rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void UpdateCameraYDampForPlayerFall() { //if falling past a certain speed threshold if (rb.velocity.y < playerFallSpeedThreshold && !CameraManager.Instance.isLerpingYDamping && !CameraManager.Instance.hasLerpedYDamping) { StartCoroutine(CameraManager.Instance.LerpYDamping(true)); } //if standing stil or moving up if (rb.velocity.y >= 0 && !CameraManager.Instance.isLerpingYDamping && CameraManager.Instance.hasLerpedYDamping) { //reset camera function CameraManager.Instance.hasLerpedYDamping = false; StartCoroutine(CameraManager.Instance.LerpYDamping(false)); } } void StartDash() { if (Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; } } IEnumerator Dash() { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); audioSource.PlayOneShot(dashAndAttackSound); rb.gravityScale = 0; int _dir = pState.lookingRight ? 1 : -1; rb.velocity = new Vector2(_dir * dashSpeed, 0); if (Grounded()) Instantiate(dashEffect, transform); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public IEnumerator WalkIntoNewScene(Vector2 _exitDir, float _delay) { pState.invincible = true; //If exit direction is upwards if (_exitDir.y != 0) { rb.velocity = jumpForce * _exitDir; } //If exit direction requires horizontal movement if (_exitDir.x != 0) { xAxis = _exitDir.x > 0 ? 1 : -1; Move(); } Flip(); yield return new WaitForSeconds(_delay); print("cutscene played"); pState.invincible = false; pState.cutscene = false; } void Attack() { timeSinceAttack += Time.deltaTime; if (attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); audioSource.PlayOneShot(dashAndAttackSound); if (yAxis == 0 || yAxis < 0 && Grounded()) { int _recoilLeftOrRight = pState.lookingRight ? 1 : -1; Hit(SideAttackTransform, SideAttackArea, ref pState.recoilingX, Vector2.right * _recoilLeftOrRight, recoilXSpeed); Instantiate(slashEffect, SideAttackTransform); } else if (yAxis > 0) { Hit(UpAttackTransform, UpAttackArea, ref pState.recoilingY, Vector2.up, recoilYSpeed); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea, ref pState.recoilingY, Vector2.down, recoilYSpeed); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilBool, Vector2 _recoilDir, float _recoilStrength) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); if (objectsToHit.Length > 0) { _recoilBool = true; } for (int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if (e && !hitEnemies.Contains(e)) { e.EnemyGetsHit(damage, _recoilDir, _recoilStrength); hitEnemies.Add(e); if (objectsToHit[i].CompareTag("Enemy")) {if (!halfMana && Mana < 1 || (halfMana && Mana < 0.5)) { Mana += manaGain; } else { manaOrbsHandler.UpdateMana(manaGain * 3); }Mana += attackManaGain; } } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if (pState.recoilingX) { if (pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if (pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } //stop recoil if (pState.recoilingX && stepsXRecoiled < recoilXSteps) { stepsXRecoiled++; } else { StopRecoilX(); } if (pState.recoilingY && stepsYRecoiled < recoilYSteps) { stepsYRecoiled++; } else { StopRecoilY(); } if (Grounded()) { StopRecoilY(); } } void StopRecoilX() { stepsXRecoiled = 0; pState.recoilingX = false; } void StopRecoilY() { stepsYRecoiled = 0; pState.recoilingY = false; } public void TakeDamage(float _damage) { if (pState.alive) { audioSource.PlayOneShot(hurtSound); // Absorb damage with excess health. if(ExcessHealth > 0) { if(ExcessHealth > _damage) { ExcessHealth -= Mathf.RoundToInt(_damage); _damage = 0; } else { _damage -= ExcessHealth; ExcessHealth = 0; } } // Reduce player health. Health -= Mathf.RoundToInt(_damage); if (Health <= 0) { Health = 0; StartCoroutine(Death()); } else { StartCoroutine(StopTakingDamage()); } } } IEnumerator StopTakingDamage() { pState.invincible = true; GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity); Destroy(_bloodSpurtParticles, 1.5f); anim.SetTrigger("TakeDamage"); yield return new WaitForSeconds(1f); pState.invincible = false; } IEnumerator Flash() { sr.enabled = !sr.enabled; canFlash = false; yield return new WaitForSeconds(0.1f); canFlash = true; } void FlashWhileInvincible() { if (pState.invincible && !pState.cutscene) { if (Time.timeScale > 0.2 && canFlash) { StartCoroutine(Flash()); } } else { sr.enabled = true; } } IEnumerator Death() { pState.alive = false; Time.timeScale = 1f; GameObject _bloodSpurtParticles = Instantiate(bloodSpurt, transform.position, Quaternion.identity); Destroy(_bloodSpurtParticles, 1.5f); anim.SetTrigger("Death"); rb.constraints = RigidbodyConstraints2D.FreezePosition; GetComponent<BoxCollider2D>().enabled = false; yield return new WaitForSeconds(0.9f); UIManager.Instance.deathScreen.Activate(); yield return new WaitForSeconds(0.1f); Instantiate(GameManager.Instance.shade, transform.position, Quaternion.identity); SaveData.Instance.SavePlayerData(); } public void Respawned(float manaPenalty = 0.5f) { if (!pState.alive) {rb.constraints = RigidbodyConstraints2D.None; rb.constraints = RigidbodyConstraints2D.FreezeRotation; GetComponent<BoxCollider2D>().enabled = true; pState.alive = true; halfMana = true; UIManager.Instance.SwitchMana(UIManager.ManaState.HalfMana); Mana = 0; Health = maxHealth; anim.Play("Player_Idle");// Address the component specific settings if the components // are there. if (rb) { rb.constraints = RigidbodyConstraints2D.None; rb.constraints = RigidbodyConstraints2D.FreezeRotation; } if (anim) anim.Play("Player_Idle"); // Set the rest of the variables. GetComponent<BoxCollider2D>().enabled = true; pState.alive = true; // Apply mana penalty if the flag is set to true. this.manaPenalty = manaPenalty; mana = excessMana = 0; UIManager.UpdateManaUI(mana, maxMana, excessMana, ExcessMaxMana, 1f - manaPenalty); Health = maxHealth; } } public void RestoreMana() {halfMana = false;manaPenalty = 0f;UIManager.Instance.SwitchMana(UIManager.ManaState.FullMana);} public int Health { get { return health; } set { if (health != value) { health = Mathf.Clamp(value, 0, maxHealth);if (onHealthChangedCallback != null) { onHealthChangedCallback.Invoke(); }UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } } public int ExcessHealth { get { return excessHealth; } set { if (excessHealth != value) { excessHealth = Mathf.Clamp(value, 0, excessHealth); UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } } // Converts health shards to actual excess health units. public void ConvertHeartShards() { // While converting, we also want to make sure that it is not possible // to exceed the max health as set by maxTotalHealth. int remainingUnits = maxTotalHealth - maxHealth; if (heartShards >= heartShardsPerHealth && remainingUnits > 0) { // If the awarded units is more than the remaining units, we award only remaining units. int awardedUnits = Mathf.Min(remainingUnits, heartShards / heartShardsPerHealth); // Award units and subtract the mana shards. maxHealth += awardedUnits; heartShards -= awardedUnits * heartShardsPerHealth; UIManager.UpdateHealthUI(health, maxHealth, excessHealth); } } void Heal() { if (Input.GetButton("Cast/Heal") && castOrHealTimer > 0.1f && Health < maxHealth && Mana > 0 && Grounded() && !pState.dashing) { pState.healing = true; anim.SetBool("Healing", true); //healing healTimer += Time.deltaTime; if (healTimer >= timeToHeal) { Health++; healTimer = 0; } //drain manamanaOrbsHandler.usedMana = true; manaOrbsHandler.countDown = 3f;Mana -= Time.deltaTime *manaDrainSpeedhealManaCostPerSecond; } else { pState.healing = false; anim.SetBool("Healing", false); healTimer = 0; } } public float Mana { get { return mana; } set {//if mana stats change if (mana != value) { if (!halfMana) { mana = Mathf.Clamp(value, 0, 1); } else { mana = Mathf.Clamp(value, 0, 0.5f); } manaStorage.fillAmount = Mana; }// If there is excess mana, move it to the excess mana slot. float excess = value - MaxMana; if(excess > 0) { mana = MaxMana; ExcessMana += excess; } else { // If mana is being reduced, institute a restore cooldown first. if (value < mana) excessManaRestoreCooldown = excessManaRestoreDelay; mana = Mathf.Max(0, value); } UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana, 1 - manaPenalty); } } public float ExcessMana { get { return excessMana; } set { excessMana = Mathf.Clamp(value, 0, ExcessMaxMana); } } public float MaxMana { get { return maxMana * (1 - manaPenalty); } } public float ExcessMaxMana { get { return excessMaxManaUnits * manaPerExcessUnit; } } // Converts mana shards to actual excess mana units. public void ConvertManaShards() { // While converting, we also want to make sure that it is not possible // to exceed the max excess units as set by excessMaxManaUnitsLimit. // Hence all of the logic here. int remainingUnits = excessMaxManaUnitsLimit - excessMaxManaUnits; if(manaShards >= manaShardsPerExcessUnit && remainingUnits > 0) { // If the awarded units is more than the remaining units, we award only remaining units. int awardedUnits = Mathf.Min(remainingUnits, manaShards / manaShardsPerExcessUnit); // Award units and subtract the mana shards. excessMaxManaUnits += awardedUnits; manaShards -= awardedUnits * manaShardsPerExcessUnit; // We will have to update the mana UI once its up as well. UIManager.UpdateManaUI(mana, maxMana, ExcessMana, ExcessMaxMana); } } void CastSpell() { if (Input.GetButtonUp("Cast/Heal") && castOrHealTimer <= 0.1f && timeSinceCast >= timeBetweenCast && Mana >= manaSpellCost) { pState.casting = true; timeSinceCast = 0; StartCoroutine(CastCoroutine()); } else { timeSinceCast += Time.deltaTime; } if (!Input.GetButton("Cast/Heal")) { castOrHealTimer = 0; } if (Grounded()) { //disable downspell if on the ground downSpellFireball.SetActive(false); } //if down spell is active, force player down until grounded if (downSpellFireball.activeInHierarchy) { rb.velocity += downSpellForce * Vector2.down; } } IEnumerator CastCoroutine() { //side cast if ((yAxis == 0 || (yAxis < 0 && Grounded())) && unlockedSideCast) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); GameObject _fireBall = Instantiate(sideSpellFireball, SideAttackTransform.position, Quaternion.identity); //flip fireball if (pState.lookingRight) { _fireBall.transform.eulerAngles = Vector3.zero; // if facing right, fireball continues as per normal } else { _fireBall.transform.eulerAngles = new Vector2(_fireBall.transform.eulerAngles.x, 180); //if not facing right, rotate the fireball 180 deg } pState.recoilingX = true; Mana -= manaSpellCost;manaOrbsHandler.usedMana = true; manaOrbsHandler.countDown = 3f;yield return new WaitForSeconds(0.35f); } //up cast else if (yAxis > 0 && unlockedUpCast) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); Instantiate(upSpellExplosion, transform); rb.velocity = Vector2.zero; Mana -= manaSpellCost;manaOrbsHandler.usedMana = true; manaOrbsHandler.countDown = 3f;yield return new WaitForSeconds(0.35f); } //down cast else if ((yAxis < 0 && !Grounded()) && unlockedDownCast) { audioSource.PlayOneShot(spellCastSound); anim.SetBool("Casting", true); yield return new WaitForSeconds(0.15f); downSpellFireball.SetActive(true); Mana -= manaSpellCost;manaOrbsHandler.usedMana = true; manaOrbsHandler.countDown = 3f;yield return new WaitForSeconds(0.35f); } anim.SetBool("Casting", false); pState.casting = false; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0 && !pState.jumping) { if (Input.GetButtonDown("Jump")) { audioSource.PlayOneShot(jumpSound); } rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump") && unlockedVarJump) { audioSource.PlayOneShot(jumpSound); pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } if (Input.GetButtonUp("Jump") && rb.velocity.y > 3) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -maxFallingSpeed, rb.velocity.y)); anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { if (!landingSoundPlayed) { audioSource.PlayOneShot(landingSound); landingSoundPlayed = true; } pState.jumping = false; coyoteTimeCounter = coyoteTime; airJumpCounter = 0; } else { coyoteTimeCounter -= Time.deltaTime; landingSoundPlayed = false; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } private bool Walled() { return Physics2D.OverlapCircle(wallCheck.position, 0.2f, wallLayer); } void WallSlide() { if (Walled() && !Grounded() && xAxis != 0) { isWallSliding = true; rb.velocity = new Vector2(rb.velocity.x, Mathf.Clamp(rb.velocity.y, -wallSlidingSpeed, float.MaxValue)); } else { isWallSliding = false; } } void WallJump() { if (isWallSliding) { isWallJumping = false; wallJumpingDirection = !pState.lookingRight ? 1 : -1; CancelInvoke(nameof(StopWallJumping)); } if (Input.GetButtonDown("Jump") && isWallSliding) { audioSource.PlayOneShot(jumpSound); isWallJumping = true; rb.velocity = new Vector2(wallJumpingDirection * wallJumpingPower.x, wallJumpingPower.y); dashed = false; airJumpCounter = 0; pState.lookingRight = !pState.lookingRight; float jumpDirection = pState.lookingRight ? 0 : 180; transform.eulerAngles = new Vector2(transform.eulerAngles.x, jumpDirection); Invoke(nameof(StopWallJumping), wallJumpingDuration); } } void StopWallJumping() { isWallJumping = false; transform.eulerAngles = new Vector2(transform.eulerAngles.x, 0); } }
c. Updating the GameManager
, InventoryManager
, and SaveData
With the updates made to PlayerController
, you will find that it is no longer compatible with the GameManager
, InventoryManager
and SaveData
scripts, as the scripts use some variables that we have since deleted. As such, we will need to update all the scripts to use the new variables in PlayerController
.
GameManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; [DisallowMultipleComponent] public class GameManager : MonoBehaviour { public string transitionedFromScene; public Vector2 platformingRespawnPoint; public Vector2 respawnPoint; public Vector2 defaultRespawnPoint; [SerializeField] Bench bench; public GameObject shade; [SerializeField] private UIScreen pauseMenu; public bool isPaused; float lastTimeScale = -1f; static Coroutine stopGameCoroutine; public static bool isStopped { get { return stopGameCoroutine != null; } } public bool THKDefeated = false; public static GameManager Instance { get; private set; } private void Awake() { SaveData.Instance.Initialize(); if (Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } if (PlayerController.Instance != null) {if (PlayerController.Instance.halfMana)if(PlayerController.Instance.manaPenalty > 0f) { SaveData.Instance.LoadShadeData(); if (SaveData.Instance.sceneWithShade == SceneManager.GetActiveScene().name || SaveData.Instance.sceneWithShade == "") { Instantiate(shade, SaveData.Instance.shadePos, SaveData.Instance.shadeRot); } } } SaveScene(); DontDestroyOnLoad(gameObject); bench = FindObjectOfType<Bench>(); SaveData.Instance.LoadBossData(); } private void Update() { if (Input.GetKeyDown(KeyCode.P)) { SaveData.Instance.SavePlayerData(); } if (Input.GetKeyDown(KeyCode.Escape)) { Pause(!isPaused); } } public void Pause(bool b) { if (b) { // Save the timescale we will restore to. if (lastTimeScale < 0) lastTimeScale = Time.timeScale; Time.timeScale = 0f; pauseMenu.Activate(); } else { if (!isStopped) { Time.timeScale = lastTimeScale > 0f ? lastTimeScale : 1f; lastTimeScale = -1; } pauseMenu.Deactivate(); } isPaused = b; } public static void Stop(float duration = .5f, float restoreDelay = .1f, float slowMultiplier = 0f) { if (stopGameCoroutine != null) return; stopGameCoroutine = Instance.StartCoroutine(HandleStopGame(duration, restoreDelay, slowMultiplier)); } // Used to create the hit stop effect. // <duration> specifies how long it lasts for. // <restoreDelay> specifies how quickly we go back to the original time scale. // <stopMultiplier> lets us control how much the stop is. static IEnumerator HandleStopGame(float duration, float restoreDelay, float slowMultiplier = 0f) { if (Instance.lastTimeScale < 0) Instance.lastTimeScale = Time.timeScale; // Saves the original time scale for restoration later. Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier); // Counts down every frame until the stop game is finished. WaitForEndOfFrame w = new WaitForEndOfFrame(); while (duration > 0) { // Don't count down if the game is paused, and don't loop as well. if (Instance.isPaused) { yield return w; continue; } // Set the time back to zero, since unpausing sets it back to 1. Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier); // Counts down. duration -= Time.unscaledDeltaTime; yield return w; } // Save the last time scale we want to restore to. float timeScaleToRestore = Instance.lastTimeScale; // Signal that the stop is finished. Instance.lastTimeScale = -1; stopGameCoroutine = null; // If a restore delay is set, restore the time scale gradually. if (restoreDelay > 0) { // Moves the timescale from the value it is set to, to its original value. float currentTimeScale = timeScaleToRestore * slowMultiplier; float restoreSpeed = (timeScaleToRestore - currentTimeScale) / restoreDelay; while (currentTimeScale < timeScaleToRestore) { // Stop this if the game is paused. if (Instance.isPaused) { yield return w; continue; } // End this coroutine if another stop has started. if (isStopped) yield break; // Set the timescale to the current value this frame. currentTimeScale += restoreSpeed * Time.unscaledDeltaTime; Time.timeScale = Mathf.Max(0, currentTimeScale); // Wait for a frame. yield return w; } } // Only restore the timeScale if it is not stopped again. // Can happen if another stop fires while restoring the time scale. if (!isStopped) Time.timeScale = Mathf.Max(0, timeScaleToRestore); } public void SaveScene() { string currentSceneName = SceneManager.GetActiveScene().name; SaveData.Instance.sceneNames.Add(currentSceneName); } public void SaveGame() { SaveData.Instance.SavePlayerData(); } public void RespawnPlayer(float manaPenalty = 0f) { SaveData.Instance.LoadBench(); if (SaveData.Instance.benchSceneName != null) //load the bench's scene if it exists. { SceneManager.LoadScene(SaveData.Instance.benchSceneName); } if (SaveData.Instance.benchPos != null) //set the respawn point to the bench's position. { respawnPoint = SaveData.Instance.benchPos; } else { respawnPoint = defaultRespawnPoint; } PlayerController.Instance.transform.position = respawnPoint; UIManager.Instance.deathScreen.Deactivate(); PlayerController.Instance.Respawned(manaPenalty); } }
InventoryManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class InventoryManager : MonoBehaviour { [SerializeField] Image heartShard; [SerializeField] Image manaShard; [SerializeField] GameObject upCast, sideCast, downCast; [SerializeField] GameObject dash, varJump, wallJump; private void OnEnable() { //heart shard heartShard.fillAmount = PlayerController.Instance.heartShards * 0.25f; //mana shards manaShard.fillAmount = PlayerController.Instance.orbShardmanaShards * 0.34f; //spells if(PlayerController.Instance.unlockedUpCast) { upCast.SetActive(true); } else { upCast.SetActive(false); } if (PlayerController.Instance.unlockedSideCast) { sideCast.SetActive(true); } else { sideCast.SetActive(false); } if (PlayerController.Instance.unlockedDownCast) { downCast.SetActive(true); } else { downCast.SetActive(false); } //abilities if (PlayerController.Instance.unlockedDash) { dash.SetActive(true); } else { dash.SetActive(false); } if (PlayerController.Instance.unlockedVarJump) { varJump.SetActive(true); } else { varJump.SetActive(false); } if (PlayerController.Instance.unlockedWallJump) { wallJump.SetActive(true); } else { wallJump.SetActive(false); } } }
SaveData.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using System.IO; using UnityEngine.SceneManagement; [System.Serializable] public struct SaveData { public static SaveData Instance; //map stuff public HashSet<string> sceneNames; //bench stuff public string benchSceneName; public Vector2 benchPos; //player stuff public int playerHealth; public int playerMaxHealth; public int playerHeartShards; public float playerMana; publicint playerManaOrbsfloat playerExcessMana; public intplayerOrbShardplayerManaShards;public float playerOrb0fill, playerOrb1fill, playerOrb2fill; public bool playerHalfMana;public float playerManaPenalty; public Vector2 playerPosition; public string lastScene; public bool playerUnlockedWallJump, playerUnlockedDash, playerUnlockedVarJump; public bool playerUnlockedSideCast, playerUnlockedUpCast, playerUnlockedDownCast; //enemies stuff //shade public Vector2 shadePos; public string sceneWithShade; public Quaternion shadeRot; //THK public bool THKDefeated; public void Initialize() { if(!File.Exists(Application.persistentDataPath + "/save.bench.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.bench.data")); } if (!File.Exists(Application.persistentDataPath + "/save.player.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.player.data")); } if (!File.Exists(Application.persistentDataPath + "/save.shade.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.shade.data")); } if (sceneNames == null) { sceneNames = new HashSet<string>(); } } #region Bench Stuff public void SaveBench() { using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.bench.data"))) { writer.Write(benchSceneName); writer.Write(benchPos.x); writer.Write(benchPos.y); } } public void LoadBench() { string savePath = Application.persistentDataPath + "/save.bench.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.bench.data"))) { benchSceneName = reader.ReadString(); benchPos.x = reader.ReadSingle(); benchPos.y = reader.ReadSingle(); } } else { Debug.Log("Bench doesnt exist"); } } #endregion #region Player stuff public void SavePlayerData() { using(BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.player.data"))) { playerHealth = PlayerController.Instance.Health; writer.Write(playerHealth); playerMaxHealth = PlayerController.Instance.maxHealth; writer.Write(playerMaxHealth); playerHeartShards = PlayerController.Instance.heartShards; writer.Write(playerHeartShards); playerMana = PlayerController.Instance.Mana; writer.Write(playerMana);playerHalfMana = PlayerController.Instance.halfMana; writer.Write(playerHalfMana);playerManaPenalty = PlayerController.Instance.manaPenalty; writer.Write(playerManaPenalty);playerManaOrbs = PlayerController.Instance.manaOrbs; writer.Write(playerManaOrbs);playerManaPenalty = PlayerController.Instance.manaPenalty; writer.Write(playerManaPenalty);playerOrbShard = PlayerController.Instance.orbShard; writer.Write(playerOrbShard); playerOrb0fill =playerManaPenalty = PlayerController.Instance.manaPenalty; writer.Write(playerManaPenalty);PlayerController.Instance.manaOrbsHandler.orbFills[0].fillAmount; writer.Write(playerOrb0fill); playerOrb1fill = PlayerController.Instance.manaOrbsHandler.orbFills[1].fillAmount; writer.Write(playerOrb1fill); playerOrb2fill = PlayerController.Instance.manaOrbsHandler.orbFills[2].fillAmount; writer.Write(playerOrb2fill);playerUnlockedWallJump = PlayerController.Instance.unlockedWallJump; writer.Write(playerUnlockedWallJump); playerUnlockedDash = PlayerController.Instance.unlockedDash; writer.Write(playerUnlockedDash); playerUnlockedVarJump = PlayerController.Instance.unlockedVarJump; writer.Write(playerUnlockedVarJump); playerUnlockedSideCast = PlayerController.Instance.unlockedSideCast; writer.Write(playerUnlockedSideCast); playerUnlockedUpCast = PlayerController.Instance.unlockedUpCast; writer.Write(playerUnlockedUpCast); playerUnlockedDownCast = PlayerController.Instance.unlockedDownCast; writer.Write(playerUnlockedDownCast); playerPosition = PlayerController.Instance.transform.position; writer.Write(playerPosition.x); writer.Write(playerPosition.y); lastScene = SceneManager.GetActiveScene().name; writer.Write(lastScene); } Debug.Log("saved player data"); } public void LoadPlayerData() { string savePath = Application.persistentDataPath + "/save.player.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.player.data"))) { playerHealth = reader.ReadInt32(); playerMaxHealth = reader.ReadInt32(); playerHeartShards = reader.ReadInt32(); playerMana = reader.ReadSingle();playerHalfMana = reader.ReadBoolean(); playerManaOrbs = reader.ReadInt32(); playerOrbShard = reader.ReadInt32(); playerOrb0fill = reader.ReadSingle(); playerOrb1fill = reader.ReadSingle(); playerOrb2fill = reader.ReadSingle();playerManaPenalty = reader.ReadInt32(); playerExcessMana = reader.ReadSingle(); playerManaShards = reader.ReadInt32(); playerUnlockedWallJump = reader.ReadBoolean(); playerUnlockedDash = reader.ReadBoolean(); playerUnlockedVarJump = reader.ReadBoolean(); playerUnlockedSideCast = reader.ReadBoolean(); playerUnlockedUpCast = reader.ReadBoolean(); playerUnlockedDownCast = reader.ReadBoolean(); playerPosition.x = reader.ReadSingle(); playerPosition.y = reader.ReadSingle(); lastScene = reader.ReadString(); SceneManager.LoadScene(lastScene); PlayerController.Instance.transform.position = playerPosition;PlayerController.Instance.halfMana = playerHalfMana;PlayerController.Instance.manaPenalty = playerManaPenalty; PlayerController.Instance.Health = playerHealth; PlayerController.Instance.maxHealth = playerMaxHealth; PlayerController.Instance.heartShards = playerHeartShards; PlayerController.Instance.Mana = playerMana;PlayerController.Instance.manaOrbs = playerManaOrbs; PlayerController.Instance.orbShard = playerOrbShard; PlayerController.Instance.manaOrbsHandler.orbFills[0].fillAmount = playerOrb0fill; PlayerController.Instance.manaOrbsHandler.orbFills[1].fillAmount = playerOrb1fill; PlayerController.Instance.manaOrbsHandler.orbFills[2].fillAmount = playerOrb2fill;PlayerController.Instance.excessMana = playerExcessMana; PlayerController.Instance.manaShards = playerManaShards; PlayerController.Instance.unlockedWallJump = playerUnlockedWallJump; PlayerController.Instance.unlockedDash = playerUnlockedDash; PlayerController.Instance.unlockedVarJump = playerUnlockedVarJump; PlayerController.Instance.unlockedSideCast = playerUnlockedSideCast; PlayerController.Instance.unlockedUpCast = playerUnlockedUpCast; PlayerController.Instance.unlockedDownCast = playerUnlockedDownCast; }Debug.Log("load player data"); Debug.Log(playerHalfMana);} else { Debug.Log("File doesnt exist");PlayerController.Instance.halfMana = false;PlayerController.Instance.manaPenalty = 0; PlayerController.Instance.Health = PlayerController.Instance.maxHealth; PlayerController.Instance.Mana = 0.5f; PlayerController.Instance.heartShards = 0; PlayerController.Instance.unlockedWallJump = false; PlayerController.Instance.unlockedDash = false; PlayerController.Instance.unlockedVarJump = false; } } #endregion #region enemy stuff public void SaveShadeData() { using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.shade.data"))) { sceneWithShade = SceneManager.GetActiveScene().name; shadePos = Shade.Instance.transform.position; shadeRot = Shade.Instance.transform.rotation; writer.Write(sceneWithShade); writer.Write(shadePos.x); writer.Write(shadePos.y); writer.Write(shadeRot.x); writer.Write(shadeRot.y); writer.Write(shadeRot.z); writer.Write(shadeRot.w); } } public void LoadShadeData() { string savePath = Application.persistentDataPath + "/save.shade.data"; if(File.Exists(savePath) && new FileInfo(savePath).Length > 0) { using(BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.shade.data"))) { sceneWithShade = reader.ReadString(); shadePos.x = reader.ReadSingle(); shadePos.y = reader.ReadSingle(); float rotationX = reader.ReadSingle(); float rotationY = reader.ReadSingle(); float rotationZ = reader.ReadSingle(); float rotationW = reader.ReadSingle(); shadeRot = new Quaternion(rotationX, rotationY, rotationZ, rotationW); } Debug.Log("Load shade data"); } else { Debug.Log("Shade doesnt exist"); } } public void SaveBossData() { if (!File.Exists(Application.persistentDataPath + "/save.boss.data")) //if file doesnt exist, well create the file { BinaryWriter writer = new BinaryWriter(File.Create(Application.persistentDataPath + "/save.boss.data")); } using (BinaryWriter writer = new BinaryWriter(File.OpenWrite(Application.persistentDataPath + "/save.boss.data"))) { THKDefeated = GameManager.Instance.THKDefeated; writer.Write(THKDefeated); } } public void LoadBossData() { if (File.Exists(Application.persistentDataPath + "/save.Boss.data")) { using (BinaryReader reader = new BinaryReader(File.OpenRead(Application.persistentDataPath + "/save.boss.data"))) { THKDefeated = reader.ReadBoolean(); GameManager.Instance.THKDefeated = THKDefeated; } } else { Debug.Log("Boss doesnt exist"); } } #endregion }
d. Updating the AddManaOrb
and OrbShard
scripts
We also have to update the scripts for the heart and orb shards, so that they use our newly-named variables instead of the old ones.
AddManaOrb.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class AddManaOrb : MonoBehaviour { [SerializeField] GameObject particles; [SerializeField] GameObject canvasUI; [SerializeField] OrbShard orbShard; bool used; // Start is called before the first frame update void Start() {if (PlayerController.Instance.manaOrbs >= 3)if (PlayerController.Instance.excessMana >= PlayerController.Instance.ExcessMaxMana) { Destroy(gameObject); } } private void OnTriggerEnter2D(Collider2D _collision) { if (_collision.CompareTag("Player") && !used) { used = true; StartCoroutine(ShowUI()); } } IEnumerator ShowUI() { GameObject _particles = Instantiate(particles, transform.position, Quaternion.identity); Destroy(_particles, 0.5f); yield return new WaitForSeconds(0.5f); gameObject.GetComponent<SpriteRenderer>().enabled = false; canvasUI.SetActive(true);orbShard.initialFillAmount = PlayerController.Instance.orbShard * 0.34f;orbShard.initialFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit; PlayerController.Instance.orbShardmanaShards++;orbShard.targetFillAmount = PlayerController.Instance.orbShard * 0.34f;orbShard.targetFillAmount = PlayerController.Instance.manaShards / PlayerController.Instance.manaShardsPerExcessUnit; StartCoroutine(orbShard.LerpFill()); yield return new WaitForSeconds(2.5f); SaveData.Instance.SavePlayerData(); canvasUI.SetActive(false); Destroy(gameObject); } }
OrbShard.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class OrbShard : MonoBehaviour { public Image fill; public float targetFillAmount; public float lerpDuration = 1.5f; public float initialFillAmount; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public IEnumerator LerpFill() { float elapsedTime = 0f; while (elapsedTime < lerpDuration) { elapsedTime += Time.deltaTime; float t = Mathf.Clamp01(elapsedTime / lerpDuration); float lerpedFillAmount = Mathf.Lerp(initialFillAmount, targetFillAmount, t); fill.fillAmount = lerpedFillAmount; yield return null; } fill.fillAmount = targetFillAmount; if (fill.fillAmount == 1) { PlayerController.Instance.manaOrbsexcessMaxManaUnits++; PlayerController.Instance.orbShardmanaShards = 0; } } }
e. Updating the old health and mana scripts
Since we removed certain variables on PlayerController
with the changes above, we’ll also need to update our old health and mana UI scripts to stop using the old variables.
You can also delete these scripts if you don’t want to update them.
If you are keeping the scripts, we will also add a [System.Obsolete]
to the class, so that Unity shows a warning whenever we use the script.
ManaOrbsHandler.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [System.Obsolete] public class ManaOrbsHandler : MonoBehaviour { public bool usedMana; public List<GameObject> manaOrbs; public List<Image> orbFills; public float countDown = 3f; float totalManaPool; // Start is called before the first frame update void Start() { for(int i = 0; i < PlayerController.Instance.manaOrbsexcessMaxManaUnits; i++) { manaOrbs[i].SetActive(true); } } // Update is called once per frame void Update() { for (int i = 0; i < PlayerController.Instance.manaOrbsexcessMaxManaUnits; i++) { manaOrbs[i].SetActive(true); } CashInMana(); } public void UpdateMana(float _manaGainFrom) { for(int i = 0; i < manaOrbs.Count; i++) { if (manaOrbs[i].activeInHierarchy && orbFills[i].fillAmount < 1) { orbFills[i].fillAmount += _manaGainFrom; break; } } } void CashInMana() { if(usedMana && PlayerController.Instance.Mana <=1) { countDown -= Time.deltaTime; } if(countDown <= 0) { usedMana = false; countDown = 3; totalManaPool = (orbFills[0].fillAmount += orbFills[1].fillAmount += orbFills[2].fillAmount) * 0.33f; float manaNeeded = 1 - PlayerController.Instance.Mana; if(manaNeeded > 0) { if(totalManaPool >= manaNeeded) { PlayerController.Instance.Mana += manaNeeded; for(int i = 0; i < orbFills.Count; i++) { orbFills[i].fillAmount = 0; } float addBackTotal = (totalManaPool - manaNeeded) / 0.33f; while(addBackTotal > 0) { UpdateMana(addBackTotal); addBackTotal -= 1; } } else { PlayerController.Instance.Mana += totalManaPool; for (int i = 0; i < orbFills.Count; i++) { orbFills[i].fillAmount = 0; } } } } } }
HeartController.cs
using UnityEngine; using UnityEngine.UI; [System.Obsolete] public class HeartController : MonoBehaviour { private GameObject[] heartContainers; private Image[] heartFills; public Transform heartsParent; public GameObject heartContainerPrefab; // Start is called before the first frame update void Start() { heartContainers = new GameObject[PlayerController.Instance.maxTotalHealth]; heartFills = new Image[PlayerController.Instance.maxTotalHealth]; PlayerController.Instance.onHealthChangedCallback += UpdateHeartsHUD; InstantiateHeartContainers(); UpdateHeartsHUD(); } // Update is called once per frame void Update() { } void SetHeartContainers() { for(int i = 0; i < heartContainers.Length; i++) { if(i < PlayerController.Instance.maxHealth) { heartContainers[i].SetActive(true); } else { heartContainers[i].SetActive(false); } } } void SetFilledHearts() { for (int i = 0; i < heartFills.Length; i++) { if (i < PlayerController.Instance.health) { heartFills[i].fillAmount = 1; } else { heartFills[i].fillAmount = 0; } } } void InstantiateHeartContainers() { for(int i = 0; i < PlayerController.Instance.maxTotalHealth; i++) { GameObject temp = Instantiate(heartContainerPrefab); temp.transform.SetParent(heartsParent, false); heartContainers[i] = temp; heartFills[i] = temp.transform.Find("HeartFill").GetComponent<Image>(); } } void UpdateHeartsHUD() { SetHeartContainers(); SetFilledHearts(); } }
4. Setting up the New Health UI
Now that we have the new UIHealth
script, let’s set up the UI element in our Canvas.
a. New heart sprites
Firstly, we will be replacing the old red heart sprite and outline we have been using:


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

There are several reasons why we are changing the file being used to the new sprite sheet:
- Sprite sheets are more memory efficient. It is much easier for the engine to load and manage 1 image, as opposed to 2.
- Making the sprite white means that we can later tint it to any other colour we want using the sprite renderer. This means that we won’t have to import a separate blue sprite for the blue heart.
- The new sprite has no outline, because we want to dynamically generate it using Unity components. This allows our interface to not just display the outline of an entire heart, but also half or a third of a heart.

After uploading, slice the sprite into 2 using the Sprite Editor, and we will be ready to set up our health UI.
b. Creating the new heart prefab
With the new heart sprites set up, we will now be able to create a new heart prefab that will be replacing the old one. As demonstrated above, the new heart prefab will have its outline be dynamically generated, so it can be halved, quartered, and drawn up in all kinds of interesting ways based on the way you have set up the Fill Type in its image component.


To begin, let’s create a new folder at Prefabs/UI to store our hearts prefab. I would suggest moving the old heart prefab (Heart Container) inside here as well.
Then, create a new prefab called Heart Regular with a 64×64 Image component inside. This new Image component should use the white sprite on the left.
- Set the sprite to whatever colour you want, and set the Alpha in its Colour attribute to a low value like 0.2, so that the object is mostly see-through.
- Then, add an Outline component to it, and you should get a very nice outline effect that will serve as the background for your heart container.

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

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


Next, create another empty GameObject named Fill, parented under your Heart Regular GameObject, and add an Image component to it. Then, set its Anchor Presets to Stretched (make sure to Alt + Click so that it covers the entire parent).
Finally, assign the heart sprite on the right to it, and you should have a heart prefab that is ready for use.

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

It consists of a Image component, and an Outline component in the same GameObject. Remember to set the Image Type of the Image component to Fill as well, so that our UIHealth
script will be able to adjust it later on.
d. Setting up the new health UI
With the new heart sprites uploaded and set up, we’re now ready to set up our new health UI. Open up the Canvas prefab that we are using for our in-game HUD UI, then add a new empty GameObject directly under it, naming it Health UI.
This will be replacing the Hearts Parents object that we have been using to put our hearts under.
Make sure you right-click the Canvas and select the empty GameObject from there. This will ensure that your empty GameObject has a RectTransform
instead of a Transform
. Otherwise, it will not show up later.

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

HeartController
and UIAudio
components are no longer needed.On the Health UI GameObject:
- Add a Horizontal Layout Group with the Height checkbox of Control Child Size, Use Child Scale and Child Force Expand checked.
- Add the
UIHealth
component, and assign the Health Regular prefab to Health Unit Prefab; as well as the Health Blue prefab to Excess Health Unit Prefab. - Leave the Container field empty, and set the Fill variable to “Fill” (remember that we named the fill GameObject “Fill” on our Heart Regular prefab).

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

If you’d like, you can also add a few copies of the heart and heart blue prefabs under your Health UI GameObject, so that you can see where the hearts will appear in-game. These GameObjects will be automatically removed by the UIHealth
script when the game starts.
5. Setting up the new mana UI
Now that the health UI is done, let’s move on to setting up the mana.
a. The Mana UI object
Let’s go back to the in-game HUD canvas prefab, and create a new Mana UI GameObject under it. Unlike the Health UI GameObject, it will not be an empty GameObject.

UIMana
component. The Canvas Group is added by UIMana
. Notice also that Preserve Aspect is checked.When placing the mana UI, make sure to split the anchors for it as well:

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

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

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

b. Additional orbs
Now, let’s create the additional mana orbs that contain our excess mana. We will create an empty GameObject called Excess Orbs, parented under the Mana UI.
Under the Excess Orbs, we create 3 smaller orbs. The smaller orbs will have the same setup as our main Mana UI object—with a circle and an overlay over it.

The tricky part about setting up the extra orbs, is how to place the anchors to make them responsive. I’ve experimented with several, and found that the following setup works best.
Firstly, here are the Anchor values for the Excess Orbs object:

And here are the anchors for the mini orbs that are parented to the Excess Orbs GameObject:
c. Setting up the UIMana
component
Now, let’s go back to the UIMana
component on the main Mana UI object, and assign all the empty slots on it.

And our mana UI is ready-to-use!
6. Finishing touches
Now that everything is set up, we’ll need to add some finishing touches to get everything working.
a. Linking UIHealth
and UIMana
to UIManager
To get both our UI to work, we’ll just need to assign our Health UI and Mana UI GameObjects to our UIManager
GameObject (modify the Canvas prefab, not the Canvas on the Scene).

b. Setting your game resolution properly
Because we are not using Unity’s new UI Toolkit for our UI, it is not possible for our redesigned UI to be completely adaptive to all screen sizes.
No matter how you place the Anchors, it’s not going to be able to adapt to all resolutions.

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

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

The final piece of the puzzle is making use of the Canvas Scaler—it is a component that should be found on wherever your Canvas is.
Set it to Scale With Screen Size, and test until you find a resolution where the UI looks good. The Canvas Scaler ensures that, even in bigger screens such as 4k UHD, your UI elements don’t appear too small.

7. Conclusion
This about wraps it up for Part 13! In the next part, we will be cleaning up the codes for our Pickups, and we should be ready to move into building a very robust, new-and-improved save system.
Silver-tier Patrons can download the project files as well.