This is what I do, with each damage/damage type showing up one after the other with a small gap between them.
To accomplish this, we need a Queue to hold our messages. A Queue is very similar to a List, except that it is highly specialized to allow you to add something to the end of the list (Enqueue()) and Get and remove something from the beginning of the list (Dequeue()).
Our Queue will be storing a list of System.Actions.
private Queue<Action> actions = new Queue<Actions>();
To manage these actions, we’re going to need to have an Update() in our DamageTextSpawner()
private float last = 0;
private void Update()
{
last += Time.deltaTime;
if (actions.Count > 0 && (last > timeBetweenMessages))
{
var action = actions.Dequeue();
action.Invoke();
last = 0;
}
}
last is the timer. TimeBetweenMessages will be a Serialized float.
Now for the tricky part, Enqueueing the actions as they come in. This is going to require a slight change to our Spawn routine, we’re going to have to use a Lambda Expression to create an anonymous method.
public void Spawn(float damageAmount)
{
actions.Enqueue(() =>
{
DamageText instance = Instantiate<DamageText>(damageTextPrefab, transform);
instance.SetValue(damageAmount);
});
}
So what’s happening here is that we’re creating an anonymous method, sometimes called a Closure. Don’t worry if that link didn’t make much sense just yet, Closures and First Class Functions are an upper-intermediate to advanced topic in C#, but they’re extremely valuable. See, what we just created was a delegate that stores the value of damageAmount inside the method so that even though the next time Spawn is called the damage might be different, the version of the damageAmount inside of our Closure will stay the same (which is why it’s called a Closure, because the information it needs to do it’s job is enclosed inside the anonymous method.
So now, when Spawn() is called, it creates an anonymous function which is essentially what the old Spawn did, and puts it in a queue. Then Update looks at that Queue and one at a time with a delay between executes the anonymous functions.
This, by itself, will space out the messages to make them more readable, but the player might get confused when two damage texts pop up for one hit with just numbers. How do I know? Because one of my games used this script and I got direct feedback from the users in the beta. It’s helpful to label the damage as it comes in. For that, we’re going to need to be able to spawn plain text instead of just a value. We’ll need a new method in DamageText
public void SetText(string text)
{
damageText.text = text;
}
And another Spawn method in our DamageTextSpawner
public void SpawnText(string text)
{
actions.Enqueue(() =>
{
DamageText instance = Instantiate<DamageText>(damageTextPrefab, transform);
instance.SetText(text);
});
}
Like Spawn, it’s enqueuing an Action, this time simply setting the text instead of a value.
Here’s our fully fleshed out DamageTextSpawner.cs and DamageText.cs
DamageTextSpawner.cs
using System;
using System.Collections.Generic;
using UnityEngine;
namespace RPG.UI.DamageText
{
/// <summary>
/// Spawns text in 3D space to show damage dealt.
/// </summary>
public class DamageTextSpawner : MonoBehaviour
{
/// <summary>
/// Prefab to instantiate when showing damage text.
/// </summary>
[SerializeField] DamageText damageTextPrefab = null;
/// <summary>
/// The time (in seconds) between each text message spawn.
/// </summary>
[SerializeField] private float timeBetweenMessages = .25f;
/// <summary>
/// Queue of text spawn actions waiting to be performed.
/// </summary>
private Queue<Action> actions = new Queue<Action>();
/// <summary>
/// Time since the previous action was performed.
/// </summary>
private float last = 0;
/// <summary>
/// Updates the spawner. Performs queued actions at intervals defined by 'timeBetweenMessages'.
/// </summary>
private void Update()
{
last += Time.deltaTime;
if (actions.Count > 0 && (last > timeBetweenMessages))
{
var action = actions.Dequeue();
action.Invoke();
last = 0;
}
}
/// <summary>
/// Spawns a new damage text instance, setting its value to damageAmount.
/// </summary>
/// <param name="damageAmount">The amount of damage to display.</param>
public void Spawn(float damageAmount)
{
actions.Enqueue(() =>
{
DamageText instance = Instantiate<DamageText>(damageTextPrefab, transform);
instance.SetValue(damageAmount);
});
}
/// <summary>
/// Spawns a new text message at the spawner's location.
/// </summary>
/// <param name="text">The message to spawn.</param>
public void SpawnText(string text)
{
actions.Enqueue(() =>
{
DamageText instance = Instantiate<DamageText>(damageTextPrefab, transform);
instance.SetText(text);
});
}
}
}
DamageText.cs
using UnityEngine;
using UnityEngine.UI;
namespace RPG.UI.DamageText
{
/// <summary>
/// Represents text displaying damage dealt in UI.
/// </summary>
public class DamageText : MonoBehaviour
{
/// <summary>
/// The Text UI component to display the damage text.
/// </summary>
[SerializeField] Text damageText = null;
/// <summary>
/// Destroys the damage text GameObject.
/// </summary>
public void DestroyText()
{
Destroy(gameObject);
}
/// <summary>
/// Sets the value of the damage text to the specified amount, formatted as an integer.
/// </summary>
/// <param name="amount">The damage amount.</param>
public void SetValue(float amount)
{
damageText.text = $"{amount:F0}";
}
/// <summary>
/// Sets the value of the damage text to the specified string.
/// </summary>
/// <param name="text">The text to set.</param>
public void SetText(string text)
{
damageText.text = text;
}
}
}
This leaves us with describing the damage in our TakeDamage method.
First, I created a new event in Health
public UnityEvent<string> takeDamageText; //Note: In Unity2019 or greater, we no longer need to encapsulate UnityEvent<T> in a wrapper class
Then in TakeDamage, I added an optional parameter to describe the damage
public void TakeDamage(GameObject instigator, float damage, string damageInfo = "")
{
healthPoints.value = Mathf.Max(healthPoints.value - damage, 0);
if(IsDead())
{
onDie.Invoke();
AwardExperience(instigator);
}
else
{
takeDamage.Invoke(damage);
takeDamageText.Invoke($"{damage} {damageInfo}");
}
UpdateState();
}
I then removed the entry in Health for the takeDamage (float), and added an entry in takeDamageText.
Now when you do normal damage, call TakeDamage without any string (it’s optional, and will default to nothing). When you do elemental damage, call TakeDamage with the name of the element (passing the DamageType.ToString() will send the enum’s value as a string).