Preface
Before we begin, I just want to note that there are no parts 1 through 33 written out. This is a new thing I am trying to accompany the YouTube series.
Introduction
In part 33 we created our IDamageable
and IKnockbackable
interfaces that determines how we interact with objects that we can damage and knock around with our attacks. Today we are going to focus on adding our Stats
core component.
Bug Fixes
But before we start all that, it’s time for some BUG FIXES – better known as: What did I forget to do last time?
Last time, in our Combat.cs script, we implemented our IKnockbackable
interface.
Combat.cs
private float knockbackStartTime;
public void Knockback(Vector2 angle, float strength, int direction)
{
core.Movement.SetVelocity(strength, angle, direction);
core.Movement.CanSetVelocity = false;
isKnockbackActive = true;
knockbackStartTime = Time.time;
}
private void CheckKnockback()
{
if(isKnockbackActive && core.Movement.CurrentVelocity.y <= 0.01f && core.CollisionSenses.Ground)
{
isKnockbackActive = false;
core.Movement.CanSetVelocity = true;
}
}
We mentioned that we wanted to not only stop the knockback when the entity lands on the ground, but also if a timer runs out before that happens. This is why we created the private float knockbackStartTime;
variable and assign it to the current time in the Knockback()
function. I forgot to actually make use of it in the CheckKnockback()
function. So, here is what we need to do.
First, in our Combat.cs script, let’s declare [SerializeField] private float maxKnockbackTime = 0.2f;
This will be the maximum amount of time the knockback can take place and we will stop the knockback after this if the entity has not yet landed on ground again. Then in our CheckKnockback()
function, we simply need to add a time check to the if statement. So, we want to set the knockback to false if the character is grounded OR the time has passed. So, we simply add || Time.time >= knockbackStartTime + maxKnockbackTime
to the if statement parameters and surround it and the ground check with brackets. We end up with:
Combat.cs
private void CheckKnockback()
{
if(isKnockbackActive && ((core.Movement.CurrentVelocity.y <= 0.01f && (core.CollisionSenses.Ground) || Time.time >= knockbackStartTime + maxKnockbackTime))
{
isKnockbackActive = false;
core.Movement.CanSetVelocity = true;
}
}
And that’s it for things I forgot to do, now let’s move on to things I wish I did earlier.
List of Logic Updates
Let’s consider our Core.cs and CoreComponent.cs scripts.
Core.cs
using UnityEngine;
public class Core : MonoBehaviour
{
public Movement Movement
{
get => GenericNotImplementedError.TryGet(movement, transform.parent.name);
private set => movement = value;
}
public CollisionSenses CollisionSenses
{
get => GenericNotImplementedError.TryGet(collisionSenses, transform.parent.name);
private set => collisionSenses = value;
}
public Combat Combat
{
get => GenericNotImplementedError.TryGet(combat, transform.parent.name);
private set => combat = value;
}
private Movement movement;
private CollisionSenses collisionSenses;
private Combat combat;
private void Awake()
{
Movement = GetComponentInChildren();
CollisionSenses = GetComponentInChildren();
Combat = GetComponentInChildren();
}
public void LogicUpdate()
{
Movement.LogicUpdate();
Combat.LogicUpdate();
}
}
CoreComponent.cs
using UnityEngine;
public class CoreComponent : MonoBehaviour
{
protected Core core;
protected virtual void Awake()
{
core = transform.parent.GetComponent();
if(core == null) { Debug.LogError("There is no Core on the parent"); }
}
}
Our Core gives our entities access to some other useful and reusable components. Some of these components, like Movement
and Combat
, make use of the LogicUpdate()
functions. With our current approach, every time we add a new Core Component that uses it, we need to remember to call it from our Core LogicUpdate()
. Instead what we are going to do create a new interface called ILogicUpdate
that we are going to implement in our CoreComponent.cs. Then in our Core.cs, we are going to create a list of type ILogicUpdate
that our core components can add themselves to. We can then simply loop through this list in the Core’s Logic Update and call the item’s Logic Update functions. The code will now look like this:
ILogicUpdate.cs
public interface ILogicUpdate
{
void LogicUpdate();
}
Core.cs
using System.Collections.Generic; //Notice this new library!
using UnityEngine;
public class Core : MonoBehaviour
{
public Movement Movement
{
get => GenericNotImplementedError.TryGet(movement, transform.parent.name);
private set => movement = value;
}
public CollisionSenses CollisionSenses
{
get => GenericNotImplementedError.TryGet(collisionSenses, transform.parent.name);
private set => collisionSenses = value;
}
public Combat Combat
{
get => GenericNotImplementedError.TryGet(combat, transform.parent.name);
private set => combat = value;
}
private Movement movement;
private CollisionSenses collisionSenses;
private Combat combat;
//Here is our list of ILogicUpdate objects.
private List components = new List();
private void Awake()
{
Movement = GetComponentInChildren();
CollisionSenses = GetComponentInChildren();
Combat = GetComponentInChildren();
}
public void LogicUpdate()
{
//In here we replace calling each LogicUpdate() individually.
foreach (ILogicUpdate component in components)
{
component.LogicUpdate();
}
}
//This function is called by CoreComponents when they want to add themselves to the update list.
public void AddComponent(ILogicUpdate component)
{
//Check to make sure components is not already part of the list - .Contains() comes from the Linq library we added.
if (!components.Contains(component))
{
components.Add(component);
}
}
}
CoreComponent.cs
using UnityEngine;
//Add ILogicUpdate
public class CoreComponent : MonoBehaviour, ILogicUpdate
{
protected Core core;
protected virtual void Awake()
{
core = transform.parent.GetComponent();
if (core == null) { Debug.LogError("There is no Core on the parent"); }
core.AddComponent(this); //Add the component to the list
}
public virtual void LogicUpdate() { } //Implement ILogicUpdate as a virtual function
}
Now we can update our Combat.cs and Movement.cs CoreComponents. We simply need change their Logic Updates to override the Logic Update from the base class.
All CoreComponents that use logic update
public override void LogicUpdate()
{
...code here
}
We can now run the game and ensure everything is still working as expected. We are now ready to move on to the focus of the tutorial.
Stats:
We can start off by creating our new script in the Assets> Scripts> Core> CoreComponents folder.
We can then open it up, get rid of all the generated code, and make it inherit from CoreComponent
instead
using UnityEngine;
public class Stats : CoreComponent
{
}
Our focus today is going to be health, so let’s go ahead and declare two fields for our maximum and current health.
[SerializeField] private float maxHealth;
private float currentHealth;
We add [SerializeField]
in front of our max health so we can set the health in the inspector. Next let’s set our current health to our max health when the game starts. The best place to do this is in the Awake()
function.
protected override void Awake()
{
base.Awake();
currentHealth = maxHealth;
}
Remember that we are using protected override as we have declared our Awake function as a virtual function in our base CoreComponent script. Next we need to create two public functions that we can use to increase and decrease our health. Both of these functions will return void and take in a float parameters that is the amount to increase or decrease our health by.
In our DecreaseHealth(float amount) function, we want to start by subtracting the amount from our current health. Then we want to check if our health is less than or equal to zero, in which case the entity will die. In this case we will also set the current health to 0 as our health should never be less than that.
public void DecreaseHealth(float amount)
{
currentHealth -= amount;
if(currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Health is zero!!");
}
}
Currently we are not doing anything when the health reaches zero other than stating it via a debug statement. Eventually here is where the entity will let the system know that it has died and the system will respond appropriately. In the case of enemies, this could be subtracting a counter for a wave, or updating a quest tracker, and in the case of the player this could be triggering a respawn.
In our IncreaseHealth(float amount) function, we will simply increase the current health by the amount, and then clamp our health between 0 and our max health.
public void IncreaseHealth(float amount)
{
currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
}
Here Mathf.Clamp takes in 3 parameters. The first parameter is the value we wish to clamp. Note that this value is not directly affected and that the clamped value is only returned by the function. The second parameter is our minimum value, and the last parameter is our maximum value.
Now that is the basics for the Stats.cs script. Later on we will be adding more stats to it, like poise, which will affect when an attack stuns an entity. We might want to change up our approach and make it more generic, allowing us to create any stat we want without having to change the code, but that is out of scope for now.
Here is the final Stats.cs script:
Stats.cs
using UnityEngine;
public class Stats : CoreComponent
{
[SerializeField] private float maxHealth;
private float currentHealth;
protected override void Awake()
{
base.Awake();
currentHealth = maxHealth;
}
public void DecreaseHealth(float amount)
{
currentHealth -= amount;
if(currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Health is zero!!");
}
}
public void IncreaseHealth(float amount)
{
currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
}
}
Now we can add our Stats to our player by creating a new empty game object under the Core game object. We can rename it to stats and then in the inspector click on “Add Component”, search for “Stats” and click on it. We can also set our max health to be 100 by default.
We can then add Stats to our Core. So, in Core.cs we can declare a new field of type Stats and then create the property for it. Underneath our combat field we can say:
private Stats stats;
Then underneath our combat property we can say:
public Stats Stats
{
get => GenericNotImplementedError.TryGet(stats, transform.parent.name);
private set => stats = value;
}
Next, we just need to set our reference to stats in the Awake()
function.
stats = GetComponentInChildren();
Finally, our Core will look like this:
Core.cs
using System.Collections.Generic;
using UnityEngine;
public class Core : MonoBehaviour
{
public Movement Movement
{
get => GenericNotImplementedError.TryGet(movement, transform.parent.name);
private set => movement = value;
}
public CollisionSenses CollisionSenses
{
get => GenericNotImplementedError.TryGet(collisionSenses, transform.parent.name);
private set => collisionSenses = value;
}
public Combat Combat
{
get => GenericNotImplementedError.TryGet(combat, transform.parent.name);
private set => combat = value;
}
public Stats Stats
{
get => GenericNotImplementedError.TryGet(stats, transform.parent.name);
private set => stats = value;
}
private Movement movement;
private CollisionSenses collisionSenses;
private Combat combat;
private Stats stats;
private List components = new List();
private void Awake()
{
Movement = GetComponentInChildren();
CollisionSenses = GetComponentInChildren();
Combat = GetComponentInChildren();
stats = GetComponentInChildren();
}
public void LogicUpdate()
{
foreach (ILogicUpdate component in components)
{
component.LogicUpdate();
}
}
public void AddComponent(ILogicUpdate component)
{
if (!components.Contains(component))
{
components.Add(component);
}
}
}
Now that we have access to Stats from our core, we can go ahead and damage our entity from the Damage(float amount) function in our Combat.cs script. To do this, we simply call the DecreaseHealth(float amount) function when we get damaged. So we add:
core.Stats.DecreaseHealth(amount);
And then our combat script will finally look like:
Combat.cs
using UnityEngine;
public class Combat : CoreComponent, IDamageable, IKnockbackable
{
[SerializeField] private float maxKnockbackTime = 0.2f;
private bool isKnockbackActive;
private float knockbackStartTime;
public override void LogicUpdate()
{
CheckKnockback();
}
public void Damage(float amount)
{
Debug.Log(core.transform.parent.name + " Damaged!");
core.Stats.DecreaseHealth(amount);
}
public void Knockback(Vector2 angle, float strength, int direction)
{
core.Movement.SetVelocity(strength, angle, direction);
core.Movement.CanSetVelocity = false;
isKnockbackActive = true;
knockbackStartTime = Time.time;
}
private void CheckKnockback()
{
if(isKnockbackActive && core.Movement.CurrentVelocity.y <= 0.01f && (core.CollisionSenses.Ground || Time.time >= knockbackStartTime + maxKnockbackTime))
{
isKnockbackActive = false;
core.Movement.CanSetVelocity = true;
}
}
}
And that’s it. This is how we connect up our damage function to some sort of stat. To test it out, let’s go up to an enemy and let it hit the us for a bit and ensure we get the debug log stating that our health is 0.
We should also go ahead and add the Stats game object to both of our enemies. For now I set both their max health to 20 for testing.
Don’t forget to also test on your enemies.
I hope you found reading this enjoyable and that you learned something new. This is a new format for me but I am excited about the possibilities. If you have any feedback, feel free to reach out to me on my discord server.
If you want to see the rest of the tutorials up to this point, you can find them on my YouTube Channel.
And finally, I just want to say thank you to all of my supporters over on Patreon. You guys drive me to worked harder on this series and it means a lot!