This article will be free until the accompanying video is released.
In Part 7 of our series, we created an enemy spawning system so that we had a way to generate enemies and run the core gameplay loop of our game. The enemy spawning system that we created then had a couple of limitations. For example, it was not possible to spawn the infamous Flower Wall that you saw in the Vampire Survivors game.
Hence, we’re setting out to improve our enemy spawning system so that this sort of spawn becomes possible.
1. Limitations of the existing EnemySpawner
script
Since we are looking at adding some major functionalities to our enemy spawning system, it makes sense for us to also identify shortcomings with our current enemy spawning system, as we can also take this chance to code in some improvements to the script.
a. The stored Wave
data is not easily portable
In our current EnemySpawner
script, we enter the wave data of our enemies into the Enemy Spawner component directly.
At first glance, this seems to be a totally fine way to store wave data, but there are a few drawbacks with it:
- The data that you have is not reusable. For example, if you want to have a wave of Bats at 5 minutes, and then another wave of bats at 15 minutes, you will have to enter 2 identical entries into your Waves array, which is not space efficient because you are repeating the same set of data twice.
- It is much easier to accidentally delete or accidentally change variables attached to GameObjects. For example, if you set the number of items in the Waves field to 0 by accident, this will delete all of your enemy wave data.
A simple solution to the issues presented here is to have the wave data be stored as scriptable objects instead, just like our weapons and passives.
b. The EnemySpawner
component is not self-documenting
The 2nd problem with the Enemy Spawner component is that it is pretty difficult to understand what some of the properties do. This is primarily because some of the variables that are exposed on the component, such as Current Wave Count, Enemies Alive and the Spawn Count property inside each wave, are not meant to be adjusted manually.
The Current Wave Count property, for example, is updated by the script itself, and should be left at a value of 0 when we are tweaking the component for our game.
In a larger game development environment (or any kind of application development environment), where you have many people working on the same project, this is not ideal. Especially when working in large groups, it is good for us to aim to have our code components be self-documenting, which means that it is easy for someone else using our components to understand what it does at a glance.
For example, if you look at the Rigidbody
or Rigidbody2D
components in Unity, just by looking at the properties, you can get a rough idea of what properties you need to tweak to get it to work the way you want:
We can’t say the same for our Enemy Spawner component, because a lot of variables that are meant to be used by the script itself are exposed as public variables:
c. Enemy spawn points are hardcoded (and not adaptive)
The 3rd issue we have with the current enemy spawner, is that the spawn points for the enemies are manually set using empty GameObjects.
While these work fine as long as you place them outside of your camera’s view area, it presents a problem when you play the game with a larger screen resolution. This is because a larger screen resolution means a larger camera view area, and a larger camera view area means that the spawn points may be within the camera’s view area, as opposed to being outside.
If we want our game to be playable in many different screen resolutions (i.e. be adaptive), then we will need to code the enemy spawn system so that it is able to spawn enemies right outside of the camera’s view area without us using preset GameObjects to set the position.
2. Replacing the EnemySpawner
To this end, we will set out to create a new script that is meant to replace the EnemySpawner
. This is not going to be as big of an overhaul as the one to our weapon system — a lot of the core functionalities are going to behave the same. But because a lot of code is going to be changed, it is much easier to start with an entirely new component.
To begin, let’s set our old EnemySpawner
script to be obsolete:
EnemySpawner.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Obsolete("Replaced by the Spawn Manager.")] public class EnemySpawner : MonoBehaviour { [System.Serializable] public class Wave { public string waveName; public List<EnemyGroup> enemyGroups; //A list of groups of enemies to spawn in this wave public int waveQuota; //The total number of enemies to spawn in this wave public float spawnInterval; //The interval at which to spawn enemies public int spawnCount; //The number of enemies already spawned in this wave } [System.Serializable] public class EnemyGroup { public string enemyName; public int enemyCount; //The number of enemies of this type to spawn in this wave public int spawnCount; //The number of enemies of this type already spawned in this wave public GameObject enemyPrefab; } public List<Wave> waves; //A list of all the waves in the game public int currentWaveCount; //The index of the current wave [Remember, a list starts from 0] [Header("Spawner Attributes")] float spawnTimer; //Timer used to determine when to spawn the next enemy public int enemiesAlive; public int maxEnemiesAllowed; //The maximum number of enemies allowed on the map at once public bool maxEnemiesReached = false; //A flag indicating if the maximum number of enemies has been reached public float waveInterval; //The interval between each wave bool isWaveActive = false; [Header("Spawn Positions")] public List<Transform> relativeSpawnPoints; //A list to store all the relative spawn points of enemies Transform player; void Start() { player = FindObjectOfType<PlayerStats>().transform; CalculateWaveQuota(); } void Update() { // If we have finished all the waves in the game, and // if the current wave has also finished spawning its enemies. if (currentWaveCount < waves.Count && waves[currentWaveCount].spawnCount == 0 && !isWaveActive) //Check if the wave has ended and the next wave should start { StartCoroutine(BeginNextWave()); } spawnTimer += Time.deltaTime; //Check if it's time to spawn the next enemy if (spawnTimer >= waves[currentWaveCount].spawnInterval) { spawnTimer = 0f; SpawnEnemies(); } } IEnumerator BeginNextWave() { isWaveActive = true; //Wait for `waveInterval` seconds before starting the next wave. yield return new WaitForSeconds(waveInterval); //If there are more waves to start after the current wave, move on to the next wave if (currentWaveCount < waves.Count - 1) { isWaveActive = false; currentWaveCount++; CalculateWaveQuota(); } } void CalculateWaveQuota() { int currentWaveQuota = 0; foreach (var enemyGroup in waves[currentWaveCount].enemyGroups) { currentWaveQuota += enemyGroup.enemyCount; } waves[currentWaveCount].waveQuota = currentWaveQuota; Debug.LogWarning(currentWaveQuota); } /// <summary> /// This method will stop spawning enemies if the amount of enemies on the map is maxmimum. /// The method will only spawn enemies in a particular wave until it is time for the next wave's enemies to be spawned /// </summary> void SpawnEnemies() { // Check if the minimum number of enemies in the wave have been spawned if (waves[currentWaveCount].spawnCount < waves[currentWaveCount].waveQuota && !maxEnemiesReached) { // Spawn each type of enemy until the quota is filled foreach (var enemyGroup in waves[currentWaveCount].enemyGroups) { // Check if the minimum number of enemies of this type have been spawned if (enemyGroup.spawnCount < enemyGroup.enemyCount) { // Spawn the enemy at a random position close to the player Instantiate(enemyGroup.enemyPrefab, player.position + relativeSpawnPoints[Random.Range(0, relativeSpawnPoints.Count)].position, Quaternion.identity); enemyGroup.spawnCount++; waves[currentWaveCount].spawnCount++; enemiesAlive++; // Limit the number of enemies that can be spawned at once if (enemiesAlive >= maxEnemiesAllowed) { maxEnemiesReached = true; return; } } } } } // Call this function when an enemy is killed public void OnEnemyKilled() { //Decrement the number of enemies alive enemiesAlive--; //Reset the maxEnemiesReached flag if the number of enemies alive has dropped below the maximum allowed if (enemiesAlive < maxEnemiesAllowed) { maxEnemiesReached = false; } } }
a. Creating the WaveData
scriptable object
Note: In our video, we store all the new scripts here inside of a new folder Scripts/Spawning.
The first thing we will do is create a WaveData
scriptable object, which will be a part of a hierarchy of classes designed to store spawn data. The class hierarchy will be as follows:
You might be wondering — we are just creating a scriptable object to store wave data right? Why do we have to create such a complex hierarchy of classes?
This is because, later on in this part, we will also be creating an event system that spawns enemy configurations like the Flower Wall and the Bat Swarm. Our wave data scriptable object system has to be able to accommodate these as well, which is why we create a superclass SpawnData
above our WaveData
.
Let’s create our SpawnData
first. This is a class that declares the basic variables that a wave will need to store:
SpawnData.cs
using UnityEngine; public abstract class SpawnData : ScriptableObject { [Tooltip("A list of all possible GameObjects that can be spawned.")] public GameObject[] possibleSpawnPrefabs = new GameObject[1]; [Tooltip("Time between each spawn (in seconds). Will take a random number between X and Y.")] public Vector2 spawnInterval = new Vector2(2, 3); [Tooltip("How many enemies are spawned per interval?")] public Vector2Int spawnsPerTick = new Vector2Int(1, 1); [Tooltip("How long (in seconds) this will spawn enemies for.")] [Min(0.1f)] public float duration = 60; // Returns an array of prefabs that we should spawn. // Takes an optional parameter of how many enemies are on the screen at the moment. public virtual GameObject[] GetSpawns(int totalEnemies = 0) { // Determine how many enemies to spawn. int count = Random.Range(spawnsPerTick.x, spawnsPerTick.y); // Generate the result. GameObject[] result = new GameObject[count]; for (int i = 0; i < count; i++) { // Randomly picks one of the possible spawns and inserts it // into the result array. result[i] = possibleSpawnPrefabs[Random.Range(0, possibleSpawnPrefabs.Length)]; } return result; } // Get a random spawn interval between the min and max values. public virtual float GetSpawnInterval() { return Random.Range(spawnInterval.x, spawnInterval.y); } }
Then, using the SpawnData
class, we will create the WaveData
class. Because a lot of the variables that the WaveData
needs is already declared in SpawnData
, our WaveData
script is a lot shorter than what it would have been.
WaveData.cs
using UnityEngine; [CreateAssetMenu(fileName = "Wave Data", menuName = "2D Top-down Rogue-like/Wave Data")] public class WaveData : SpawnData { [Header("Wave Data")] [Tooltip("If there are less than this number of enemies, we will keep spawning until we get there.")] [Min(0)] public int startingCount = 0; [Tooltip("How many enemies can this wave spawn at maximum?")] [Min(1)] public uint totalSpawns = uint.MaxValue; [System.Flags] public enum ExitCondition { waveDuration = 1, reachedTotalSpawns = 2 } [Tooltip("Set the things that can trigger the end of this wave")] public ExitCondition exitConditions = (ExitCondition)1; [Tooltip("All enemies must be dead for the wave to advance.")] public bool mustKillAll = false; [HideInInspector] public uint spawnCount; //The number of enemies already spawned in this wave // Returns an array of prefabs that this wave can spawn. // Takes an optional parameter of how many enemies are on the screen at the moment. public override GameObject[] GetSpawns(int totalEnemies = 0) { // Determine how many enemies to spawn. int count = Random.Range(spawnsPerTick.x, spawnsPerTick.y); // If we have less than <minimumEnemies> on the screen, we will // set the count to be equals to the number of enemies to spawn to // populate the screen until it has <minimumEnemies> within. if (totalEnemies + count < startingCount) count = startingCount - totalEnemies; // Generate the result. GameObject[] result = new GameObject[count]; for(int i = 0; i < count; i++) { // Randomly picks one of the possible spawns and inserts it // into the result array. result[i] = possibleSpawnPrefabs[Random.Range(0, possibleSpawnPrefabs.Length)]; } return result; } }
b. The WaveData
class explained
Being a scriptable object, the WaveData
class is designed to allow us to create new assets — much like our WeaponData
or PassiveData
objects — which we can use to store data about enemy waves in our spawning system.
As expected, the WaveData
class is designed to store information about a wave of enemies. It bears mentioning that when I say wave, I don’t mean only a single mob of enemies. Instead, here, a “wave” means that for a fixed amount of time, only the enemies inside of the Possible Spawn Prefabs array spawn, according to the parameters of the WaveData
object.
Below is a table describing the various properties in a WaveData
scriptable object, and what they control:
Attribute | Description |
---|---|
Possible Spawn Prefabs | A list of all possible GameObjects that can be spawned by this wave. To control the probability of each spawn, you can add certain prefabs to the list multiple times. For example, in the above image, the Bat has a 75% chance of spawning, while the Red Bat only has 25% chance. This is because the Bat prefab was put into this array 3 times, while the Red Bat prefab was only put in once. |
Spawn Interval | This controls the amount of time between each set of spawns. For example, if this is set to 5 to 10, then a new set of enemies will spawn every 5 to 10 seconds. |
Spawns Per Tick | This controls how many enemies spawn in each set of spawns. For example, if this is set to 2 to 3, then every new set of enemies spawned in this wave will have 2 to 3 enemies. |
Duration | This controls how long the wave will be active for. A wave with a duration of 60, for example, will be active for 60 seconds. |
Wave Data | |
Starting Count | If the map has less than this number of enemies on the map when this wave starts, then the wave will keep spawning enemies until it reaches this number. |
Total Spawns | The total number of enemies this wave is allowed to spawn. By default, this is set to a very high value so that it will never be reached. |
Exit Conditions | This is a list of conditions that will cause the wave to end (thereby activating the next wave). There are currently only 2 exit conditions:
|
On top of the variables in the WaveData
class, the SpawnData
class also defines 2 functions that will later make it easy for us to retrieve data from the WaveData
/ SpawnData
scriptable objects in our new SpawnManager
script (which will be taking over EnemySpawner
as the script that spawns enemies).
GetSpawnInterval()
: This is a function that gets a number between the X and Y values of the Spawn Interval attribute. It basically is a function that is meant to make it easy for us to retrieve a random value from theWaveData
later on.GetSpawns()
: This is a function that generates the group of enemies to be spawned, based on the settings in Possible Spawn Prefabs and Spawns Per Tick. For example, if we have Spawns Per Tick of 5 to 10, this function will randomly pick 5 to 10 enemies from Possible Spawn Prefabs and return these picked enemies as an array of GameObjects. It is meant to make it easy for theSpawnManager
to generate the list of enemies to spawn later on.
Now that we have the WaveData
set up, it is time for us to create our new SpawnManager
script.
c. The new replacement: SpawnManager
Now that we have our scriptable object set up for storing our wave data, let’s create the new spawner script that is capable of reading the WaveData
scriptable objects.
SpawnManager.cs
using UnityEngine; public class SpawnManager : MonoBehaviour { int currentWaveIndex; //The index of the current wave [Remember, a list starts from 0] int currentWaveSpawnCount = 0; // Tracks how many enemies current wave has spawned. public WaveData[] data; public Camera referenceCamera; [Tooltip("If there are more than this number of enemies, stop spawning any more. For performance.")] public int maximumEnemyCount = 300; float spawnTimer; // Timer used to determine when to spawn the next group of enemy. float currentWaveDuration = 0f; public static SpawnManager instance; void Start() { if(instance) Debug.LogWarning("There is more than 1 Spawn Manager in the Scene! Please remove the extras."); instance = this; } void Update() { // Updates the spawn timer at every frame. spawnTimer -= Time.deltaTime; currentWaveDuration += Time.deltaTime; if(spawnTimer <= 0) { // Check if we are ready to move on to the new wave. if(HasWaveEnded()) { currentWaveIndex++; currentWaveDuration = currentWaveSpawnCount = 0; // If we have gone through all the waves, disable this component. if (currentWaveIndex >= data.Length) { Debug.Log("All waves have been spawned! Shutting down.", this); enabled = false; } return; } // Do not spawn enemies if we do not meet the conditions to do so. if (!CanSpawn()) { spawnTimer += data[currentWaveIndex].GetSpawnInterval(); return; } // Get the array of enemies that we are spawning for this tick. GameObject[] spawns = data[currentWaveIndex].GetSpawns(EnemyStats.count); // Loop through and spawn all the prefabs. foreach(GameObject prefab in spawns) { // Stop spawning enemies if we exceed the limit. if (!CanSpawn()) continue; // Spawn the enemy. Instantiate(prefab, GeneratePosition(), Quaternion.identity); currentWaveSpawnCount++; } // Regenerates the spawn timer. spawnTimer += data[currentWaveIndex].GetSpawnInterval(); } } // Do we meet the conditions to be able to continue spawning? public bool CanSpawn() { // Don't spawn anymore if we exceed the max limit. if (HasExceededMaxEnemies()) return false; // Don't spawn if we exceeded the max spawns for the wave. if (instance.currentWaveSpawnCount > instance.data[instance.currentWaveIndex].totalSpawns) return false; // Don't spawn if we exceeded the wave's duration. if (instance.currentWaveDuration > instance.data[instance.currentWaveIndex].duration) return false; return true; } // Allows other scripts to check if we have exceeded the maximum number of enemies. public static bool HasExceededMaxEnemies() { if (!instance) return false; // If there is no spawn manager, don't limit max enemies. if(EnemyStats.count > instance.maximumEnemyCount) return true; return false; } public bool HasWaveEnded() { WaveData currentWave = data[currentWaveIndex]; // If waveDuration is one of the exit conditions, check how long the wave has been running. // If current wave duration is not greater than wave duration, do not exit yet. if ((currentWave.exitConditions & WaveData.ExitCondition.waveDuration) > 0) if (currentWaveDuration < currentWave.duration) return false; // If reachedTotalSpawns is one of the exit conditions, check if we have spawned enough // enemies. If not, return false. if ((currentWave.exitConditions & WaveData.ExitCondition.reachedTotalSpawns) > 0) if (currentWaveSpawnCount < currentWave.totalSpawns) return false; // Otherwise, if kill all is checked, we have to make sure there are no more enemies first. if (currentWave.mustKillAll && EnemyStats.count > 0) return false; return true; } void Reset() { referenceCamera = Camera.main; } // Creates a new location where we can place the enemy at. public static Vector3 GeneratePosition() { // If there is no reference camera, then get one. if(!instance.referenceCamera) instance.referenceCamera = Camera.main; // Give a warning if the camera is not orthographic. if(!instance.referenceCamera.orthographic) Debug.LogWarning("The reference camera is not orthographic! This will cause enemy spawns to sometimes appear within camera boundaries!"); // Generate a position outside of camera boundaries using 2 random numbers. float x = Random.Range(0f, 1f), y = Random.Range(0f, 1f); // Then, randomly choose whether we want to round the x or the y value. switch(Random.Range(0, 2)) { case 0: default: return instance.referenceCamera.ViewportToWorldPoint( new Vector3(Mathf.Round(x), y) ); case 1: return instance.referenceCamera.ViewportToWorldPoint( new Vector3(x, Mathf.Round(y)) ); } } // Checking if the enemy is within the camera's boundaries. public static bool IsWithinBoundaries(Transform checkedObject) { // Get the camera to check if we are within boundaries. Camera c = instance && instance.referenceCamera ? instance.referenceCamera : Camera.main; Vector2 viewport = c.WorldToViewportPoint(checkedObject.position); if (viewport.x < 0f || viewport.x > 1f) return false; if (viewport.y < 0f || viewport.y > 1f) return false; return true; } }
In this new SpawnManager
script, we make it a point to only expose the variables that are meant to be set by the level designer (i.e. the person who is editing the Scene).
d. Explaining the SpawnManager
script
As a component, the new SpawnManager
script is much simpler than the old EnemySpawner
script. It only has 3 properties for you to adjust:
Below is a table describing what these properties do:
Attribute | Description |
---|---|
Data | An array of all the waves that the SpawnManager should cycle through for the duration of the game. |
Reference Camera | As the SpawnManager will always spawn enemies right outside of the camera, this attribute controls which camera is used to determine which parts of the map are considered to be “outside of the camera”. When you attach this component to a GameObject, the Reference Camera is always automatically set to the main camera in your Scene. |
Maximum Enemy Count | In Vampire Survivors, there is a hard limit of 300 enemies on screen at all times. If you have more than 300 of them, no more new enemies will spawn. If you want to remove this limit, set this value to 0. |
To understand what is going on in the SpawnManager
script, the most important function to look at is the Update()
function, which does the following:
- The
SpawnManager
script has 2 private variables that are very important to its functioning:spawnTimer
tracks the “cooldown” of theSpawnManager
. Whenever it is cooled down, a new group of enemies is spawned.currentWaveDuration
tracks how long the wave has been running. Once the wave has run for a duration longer than the Duration attribute of the current wave, theSpawnManager
will automatically move on to the next wave.
- Every time the
Update()
function runs, thespawnTimer
is reduced byTime.deltaTime
, and thecurrentWaveDuration
is increased byTime.deltaTime
. This updates the cooldown and the time that the wave is currently running for. - If
spawnTimer
is currently less than 0, this means it is time to spawn the next group of enemies. Before we spawn the next group, we check if the wave has ended using theHasWaveEnded()
function (explained below later). If the wave has ended, we will try to advance theSpawnManager
to the next wave. - If the wave has not ended, we first run the
CanSpawn()
function to check if we are allowed to spawn more enemies. If we are not allowed to, then we reset thespawnTimer
to stop any more enemies from spawning. - Finally, if the code is still running here, we can spawn enemies. We will access the
WaveData
object of the current wave and callGetSpawns()
to generate a list of the enemies we need to spawn, then run a for loop to spawn them. Finally, we resetspawnTimer
to a random number between the Spawn Interval values.
e. Why we have the CanSpawn()
and HasWaveEnded()
functions?
One thing that you might be wondering about our SpawnManager
script, is why created the CanSpawn()
and HasWaveEnded()
functions, instead of writing the conditions directly in the Update()
function. There are 2 reasons for this:
- It makes the code more readable. If you look at either of those functions, you will see a lot of conditional logic within, which makes it hard to read and discern what the logic is trying to do. By wrapping them into functions, you make the code a lot more readable; and because the code is grouped, it also has the effect of…
- Breaking the code into simpler parts, which is always beneficial, because breaking a bigger problem down into smaller parts can make it easier to think about it.
Can you imagine, for instance, if you had to write all the conditions for HasWaveEnded()
in the snippet below? Makes the code so much more complicated, doesn’t it?
if(spawnTimer <= 0) { // Check if we are ready to move on to the new wave. if(HasWaveEnded()) { currentWaveIndex++; currentWaveDuration = currentWaveSpawnCount = 0; // If we have gone through all the waves, disable this component. if (currentWaveIndex >= data.Length) { Debug.Log("All waves have been spawned! Shutting down.", this); enabled = false; } ...
f. Other functions in SpawnManager
On top of the aforementioned functions above, the SpawnManager
script also contains 3 static functions:
HasExceededMaxEnemies()
: Which is used byCanSpawn()
to check if we have more than the maximum number of enemies in the map currently.GeneratePosition()
: Which is used byUpdate()
to randomly pick a position just outside of the camera to spawn the enemy.IsWithinBoundaries()
: Which can be used by any script or GameObject to see if it is within the boundaries of the Reference Camera currently.
Of the 3 functions, (i) and (ii) are used by SpawnManager
itself to perform its role, whereas (iii) is used by our enemy scripts to determine whether it is within the boundaries of the camera currently.
g. Setting up the Spawn Manager
Once you are done adding the script to your project, you can hook up the Spawn Manager component to your old Enemy Spawner GameObject, and disable the Enemy Spawner component. Afterwards, create some new WaveData
files containing the waves that you want your Spawn Manager to spawn, and add it to the Data field of the Spawn Manager.
You can also remove the old spawn points from the Enemy Spawner GameObject, as they are no longer needed by the new SpawnManager
.
You’ll find, however, that after adding the SpawnManager
script, your Console will show some errors. This is because we’ll need to…
3. Update the enemies’ behaviour scripts
In our new SpawnManager
script above, there are some parts that refer to a static variable called EnemyStats.count
, which doesn’t currently exist. Hence, we’ll first need to update our EnemyStats
script to have this variable.
a. Adding a counter in EnemyStats
We’ll add a static
count
variable to our EnemyStats
script, which gets increased by 1 in the Awake()
function (i.e. every time a new EnemyStats
component is created). This value also gets decreased by 1 in the OnDestroy()
function (i.e. every time an EnemyStats
component is destroyed).
This essentially creates a variable which can be accessed by typing EnemyStats.count
anywhere in our code, which gives us the number of enemies currently on the screen. Since SpawnManager
uses this EnemyStats.count
variable, making this change will fix the errors that appeared after adding the script; and if you’ll like to know how static variables work, we have an article below that explains this in more detail.
On top of that, we also had a despawn and respawn system in EnemyStats
that detects if an enemy is too far away from the player, and teleports it closer to the player. We’ll remove this from the EnemyStats
script as well (see the removed Update()
and ReturnEnemy()
functions below), and create a more advanced version of this in EnemyMovement
later on.
EnemyStats.cs
using System.Collections;using System.Collections.Generic;using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class EnemyStats : MonoBehaviour { public EnemyScriptableObject enemyData; //Current stats public float currentMoveSpeed; public float currentHealth; public float currentDamage;public float despawnDistance = 20f;Transform player; [Header("Damage Feedback")] public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be. public float damageFlashDuration = 0.2f; // How long the flash should last. public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade. Color originalColor; SpriteRenderer sr; EnemyMovement movement; public static int count; // Track the number of enemies on the screen. void Awake() { count++; //Assign the vaiables currentMoveSpeed = enemyData.MoveSpeed; currentHealth = enemyData.MaxHealth; currentDamage = enemyData.Damage; } void Start() { player = FindObjectOfType<PlayerStats>().transform; sr = GetComponent<SpriteRenderer>(); originalColor = sr.color; movement = GetComponent<EnemyMovement>(); }void Update() { if (Vector2.Distance(transform.position, player.position) >= despawnDistance) { ReturnEnemy(); } }// This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate // the direction of the knockback. public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f) { currentHealth -= dmg; StartCoroutine(DamageFlash()); // Create the text popup when enemy takes damage. if (dmg > 0) GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform); // Apply knockback if it is not zero. if(knockbackForce > 0) { // Gets the direction of knockback. Vector2 dir = (Vector2)transform.position - sourcePosition; movement.Knockback(dir.normalized * knockbackForce, knockbackDuration); } // Kills the enemy if the health drops below zero. if (currentHealth <= 0) { Kill(); } } // This is a Coroutine function that makes the enemy flash when taking damage. IEnumerator DamageFlash() { sr.color = damageColor; yield return new WaitForSeconds(damageFlashDuration); sr.color = originalColor; } public void Kill() { StartCoroutine(KillFade()); } // This is a Coroutine function that fades the enemy away slowly. IEnumerator KillFade() { // Waits for a single frame. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0, origAlpha = sr.color.a; // This is a loop that fires every frame. while(t < deathFadeTime) { yield return w; t += Time.deltaTime; // Set the colour for this frame. sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha); } Destroy(gameObject); } void OnCollisionStay2D(Collision2D col) { //Reference the script from the collided collider and deal damage using TakeDamage() if (col.gameObject.CompareTag("Player")) { PlayerStats player = col.gameObject.GetComponent<PlayerStats>(); player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future } } private void OnDestroy() {EnemySpawner es = FindObjectOfType<EnemySpawner>(); if(es) es.OnEnemyKilled();count--; }void ReturnEnemy() { EnemySpawner es = FindObjectOfType<EnemySpawner>(); transform.position = player.position + es.relativeSpawnPoints[Random.Range(0, es.relativeSpawnPoints.Count)].position; }}
b. Updating the EnemyMovement
script
Also, since we removed the despawning and respawning functionality in EnemyStats
, we’ll have to implement it here in EnemyMovement
. There are a few reasons for us to move the functionality to EnemyMovement
instead:
- It is organisationally more appropriate, since the despawn and respawn functionality basically moves the enemy closer to the player, it makes more sense for
EnemyMovement
to handle it. - The
EnemyMovement
script currently does very little, compared to theEnemyStats
script, which also manages enemies’ health and damage functionality. By putting this inEnemyMovement
, it makes it easier for us to add more features to the despawning and respawning functionality, since the script is much less complex than ourEnemyStats
script.
Below are the changes we’re making to the EnemyMovement
script:
EnemyMovement.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyMovement : MonoBehaviour { protected EnemyStats enemy; protected Transform player; protected Vector2 knockbackVelocity; protected float knockbackDuration; public enum OutOfFrameAction { none, respawnAtEdge, despawn } public OutOfFrameAction outOfFrameAction = OutOfFrameAction.respawnAtEdge; protected bool spawnedOutOfFrame = false; protected virtual void Start() { spawnedOutOfFrame = !SpawnManager.IsWithinBoundaries(transform); enemy = GetComponent<EnemyStats>();player = FindObjectOfType<PlayerMovement>().transform;// Picks a random player on the screen, instead of always picking the 1st player. PlayerMovement[] allPlayers = FindObjectsOfType<PlayerMovement>(); player = allPlayers[Random.Range(0, allPlayers.Length)].transform; } protected virtual void Update() { // If we are currently being knocked back, then process the knockback. if(knockbackDuration > 0) { transform.position += (Vector3)knockbackVelocity * Time.deltaTime; knockbackDuration -= Time.deltaTime; } else {// Constantly move the enemy towards the player transform.position = Vector2.MoveTowards(transform.position, player.transform.position, enemy.currentMoveSpeed * Time.deltaTime);Move(); HandleOutOfFrameAction(); } } // If the enemy falls outside of the frame, handle it. protected virtual void HandleOutOfFrameAction() { // Handle the enemy when it is out of frame. if (!SpawnManager.IsWithinBoundaries(transform)) { switch(outOfFrameAction) { case OutOfFrameAction.none: default: break; case OutOfFrameAction.respawnAtEdge: // If the enemy is outside the camera frame, teleport it back to the edge of the frame. transform.position = SpawnManager.GeneratePosition(); break; case OutOfFrameAction.despawn: // Don't destroy if it is spawned outside the frame. if (!spawnedOutOfFrame) { Destroy(gameObject); } break; } } else spawnedOutOfFrame = false; } // This is meant to be called from other scripts to create knockback. public virtual void Knockback(Vector2 velocity, float duration) { // Ignore the knockback if the duration is greater than 0. if(knockbackDuration > 0) return; // Begins the knockback. knockbackVelocity = velocity; knockbackDuration = duration; } public virtual void Move() { // Constantly move the enemy towards the player transform.position = Vector2.MoveTowards(transform.position, player.transform.position, enemy.currentMoveSpeed * Time.deltaTime); } }
c. Explaining the EnemyMovement
script
The primary change to the EnemyMovement
script is the addition of a property called Out Of Frame Action. This property controls what happens to the enemies when they are outside of our camera’s view.
Here is a list of what each Out Of Frame Action does:
Option | Description |
---|---|
None | Nothing happens when the enemy goes out of frame. |
Respawn At Edge | Respawns the enemy just at the edge when it falls out of the camera. |
Despawn | Remove the enemy from the game if it falls out of the camera frame. |
In the updated EnemyMovement
code, we move the lines responsible for the enemy’s movement into its own Move()
function, and we create a new HandleOutOfFrameAction()
function that checks if an enemy is within the boundaries of the camera using the SpawnManager.IsWithinBoundaries()
function. The switch
statement within the HandleOutOfFrameAction()
function then handles the relocation of an enemy outside of the boundaries, depending on which Out Of Frame Action is selected.
Outside of the main changes above, there are also a couple of minor improvements to the EnemyMovement
script:
- In anticipation of supporting a multiplayer version in future, the enemy will now randomly pick a
PlayerMovement
component to follow from the map, instead of always picking the 1st player (refer to the change in howplayer
variable is assigned in theStart()
function). - A lot of the properties and methods have been made
protected
andvirtual
. This opens up the possibility of subclassing theEnemyMovement
script to create different kinds of enemies. Currently, we only have the option of making enemies that follow the player, but not all enemies in Vampire Survivors do that — some hover around the player, others charge in a fixed direction, etc. These changes allow us to access or override methods in subclasses, so we can more easily make other kinds of enemy movements based on this script. You will see an example of this later on when we make aChargingEnemyMovement
script. - Notice also that there is a
spawnedOutOfFrame
variable added toEnemyMovement
. This variable is used to track whether the enemy is spawned outside of the frame. If it is, any Out Of Frame Action will not apply for it until the first time it enters the camera frame — otherwise, enemies with an Out Of Frame Action of Despawn will not be able to spawn outside of the frame!
d. Updating the DropRateManager
and EnemyStats
script
With the new EnemyMovement
script above, if we use the despawn
Out Of Frame Action to remove enemies when they move out of the screen, the enemies that are despawned will drop items.
To fix this, we will have to modify DropRateManager
so that they don’t always drop items when a GameObject is destroyed. For this to work, we’ll need to add a new active
variable to the DropRateManager
script, so that when we deactivate it, it won’t spawn any new items:
DropRateManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class DropRateManager : MonoBehaviour { [System.Serializable] //Serialize the class public class Drops { public string name; public GameObject itemPrefab; public float dropRate; } public bool active = false; public List<Drops> drops; void OnDestroy() { if (!active) return; if (!gameObject.scene.isLoaded) //Stops the spawning error from appearing when stopping play mode { return; } float randomNumber = UnityEngine.Random.Range(0f, 100f); List<Drops> possibleDrops = new List<Drops>(); foreach (Drops rate in drops) { if (randomNumber <= rate.dropRate) { possibleDrops.Add(rate); } } //Check if there are possible drops if (possibleDrops.Count > 0) { Drops drops = possibleDrops[UnityEngine.Random.Range(0, possibleDrops.Count)]; Instantiate(drops.itemPrefab, transform.position, Quaternion.identity); } } }
Since we are also setting the active
variable to false
by default, we will need to have a script turn it on at the right time. To this end, we will modify the Kill()
function in EnemyStats
so that it turns on the active
variable in DropRateManager
— what this does is that it makes an enemy capable of dropping items only when they are killed.
EnemyStats.cs
using System.Collections; using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class EnemyStats : MonoBehaviour { public EnemyScriptableObject enemyData; //Current stats public float currentMoveSpeed; public float currentHealth; public float currentDamage; Transform player; [Header("Damage Feedback")] public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be. public float damageFlashDuration = 0.2f; // How long the flash should last. public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade. Color originalColor; SpriteRenderer sr; EnemyMovement movement; public static int count; // Track the number of enemies on the screen. void Awake() { count++; //Assign the vaiables currentMoveSpeed = enemyData.MoveSpeed; currentHealth = enemyData.MaxHealth; currentDamage = enemyData.Damage; } void Start() { player = FindObjectOfType<PlayerStats>().transform; sr = GetComponent<SpriteRenderer>(); originalColor = sr.color; movement = GetComponent<EnemyMovement>(); } // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate // the direction of the knockback. public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f) { currentHealth -= dmg; StartCoroutine(DamageFlash()); // Create the text popup when enemy takes damage. if (dmg > 0) GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform); // Apply knockback if it is not zero. if(knockbackForce > 0) { // Gets the direction of knockback. Vector2 dir = (Vector2)transform.position - sourcePosition; movement.Knockback(dir.normalized * knockbackForce, knockbackDuration); } // Kills the enemy if the health drops below zero. if (currentHealth <= 0) { Kill(); } } // This is a Coroutine function that makes the enemy flash when taking damage. IEnumerator DamageFlash() { sr.color = damageColor; yield return new WaitForSeconds(damageFlashDuration); sr.color = originalColor; } public void Kill() { // Enable drops if the enemy is killed, // since drops are disabled by default. DropRateManager drops = GetComponent<DropRateManager>(); if(drops) drops.active = true; StartCoroutine(KillFade()); } // This is a Coroutine function that fades the enemy away slowly. IEnumerator KillFade() { // Waits for a single frame. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0, origAlpha = sr.color.a; // This is a loop that fires every frame. while(t < deathFadeTime) { yield return w; t += Time.deltaTime; // Set the colour for this frame. sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha); } Destroy(gameObject); } void OnCollisionStay2D(Collision2D col) { //Reference the script from the collided collider and deal damage using TakeDamage() if (col.gameObject.CompareTag("Player")) { PlayerStats player = col.gameObject.GetComponent<PlayerStats>(); player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future } } private void OnDestroy() { count--; } }
4. Removing the EnemyScriptableObject
Another change that we are going to make to the EnemyStats
script is to remove its dependency on the EnemyScriptableObject
script.
a. Updates to EnemyStats
and deleting EnemyScriptableObject
It’s a really simple change — we simply remove the enemyData
variable, and remove the variables that are set by the enemyData
.
using System.Collections; using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class EnemyStats : MonoBehaviour {public EnemyScriptableObject enemyData;//Current stats public float currentMoveSpeed; public float currentHealth; public float currentDamage; Transform player; [Header("Damage Feedback")] public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be. public float damageFlashDuration = 0.2f; // How long the flash should last. public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade. Color originalColor; SpriteRenderer sr; EnemyMovement movement; public static int count; // Track the number of enemies on the screen. void Awake() { count++;//Assign the vaiables currentMoveSpeed = enemyData.MoveSpeed; currentHealth = enemyData.MaxHealth; currentDamage = enemyData.Damage;} void Start() { player = FindObjectOfType<PlayerStats>().transform; sr = GetComponent<SpriteRenderer>(); originalColor = sr.color; movement = GetComponent<EnemyMovement>(); } // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate // the direction of the knockback. public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f) { currentHealth -= dmg; StartCoroutine(DamageFlash()); // Create the text popup when enemy takes damage. if (dmg > 0) GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform); // Apply knockback if it is not zero. if(knockbackForce > 0) { // Gets the direction of knockback. Vector2 dir = (Vector2)transform.position - sourcePosition; movement.Knockback(dir.normalized * knockbackForce, knockbackDuration); } // Kills the enemy if the health drops below zero. if (currentHealth <= 0) { Kill(); } } // This is a Coroutine function that makes the enemy flash when taking damage. IEnumerator DamageFlash() { sr.color = damageColor; yield return new WaitForSeconds(damageFlashDuration); sr.color = originalColor; } public void Kill() { // Enable drops if the enemy is killed, // since drops are disabled by default. DropRateManager drops = GetComponent<DropRateManager>(); if(drops) drops.active = true; StartCoroutine(KillFade()); } // This is a Coroutine function that fades the enemy away slowly. IEnumerator KillFade() { // Waits for a single frame. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0, origAlpha = sr.color.a; // This is a loop that fires every frame. while(t < deathFadeTime) { yield return w; t += Time.deltaTime; // Set the colour for this frame. sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha); } Destroy(gameObject); } void OnCollisionStay2D(Collision2D col) { //Reference the script from the collided collider and deal damage using TakeDamage() if (col.gameObject.CompareTag("Player")) { PlayerStats player = col.gameObject.GetComponent<PlayerStats>(); player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future } } private void OnDestroy() { count--; } }
Then, we delete the EnemyScriptableObject
script and remove all EnemyScriptableObject
assets in our project.
After doing so, remember to set the stats directly in your enemy prefabs! Otherwise, they will have no health and be unable to move!
b. Why remove the dependency?
With the EnemyScriptableObject
, when we want to create a new enemy type, we will need to:
- Create a new enemy prefab, as well as set the appropriate sprites and animations to it in its Sprite Renderer and Animator components.
- Create a new
EnemyScriptableObject
asset, and set the stats of the enemy in the asset.
The way this system is set up causes redundancy, because we can already set the enemy’s stats in the prefab itself:
So there is no need to create an EnemyScriptableObject
asset specifically for the stats, because the prefab itself is also an asset that is capable of storing values!
By forcing the EnemyScriptableObject
in Enemy Data to set the stats instead, we are also making our project confusing to handle — if you forget that an enemy’s stats is set by the EnemyScriptableObject
in Enemy Data (happens more than you think on a big project!) and set it on the prefab instead, the stats will get overwritten by the EnemyScriptableObject
instead. This can sometimes cause bugs that take a long time to find, and can be an enormous waste of time.
5. Adding a new charging enemy movement
Now that we have the new spawn system set up, we’re going to set up special event spawns next.
a. How the charging enemy works
At the top of this article, we’ve already talked about the Flower Wall as one of the event spawns:
The other event spawns we want are mob spawns, which look like this:
These enemies do not track the player. Instead, when they spawn, they are pointed in the direction of the player at the time of spawn, and do not change their trajectory afterwards. Hence, if the player moves away, the enemies will continue to move towards where the player was.
b. The ChargingEnemyMovement
script
Remember how we made the EnemyMovement
script contain a bunch of protected
and virtual
variables and functions? The reason we did that was because I wanted to make the script support subclassing.
For our ChargingEnemyMovement
script, we want the enemies to behave mostly the same, except that it does not follow the player. Hence, instead of writing a brand new script, we simply write a new script that inherits from EnemyMovement
and override the Move()
function instead:
ChargingEnemyMovement.cs
using UnityEngine; public class ChargingEnemyMovement : EnemyMovement { Vector2 chargeDirection; // We calculate the direction where the enemy charges towards first, // i.e. where the player is when the enemy spawns. protected override void Start() { base.Start(); chargeDirection = (player.transform.position - transform.position).normalized; } // Instead of moving towards the player, we just move towards // the direction we are charging towards. public override void Move() { transform.position += (Vector3)chargeDirection * enemy.currentMoveSpeed * Time.deltaTime; } }
c. Testing out ChargingEnemyMovement
To test out this script, you can make a prefab variant of your Bat enemy. In this variant, you will replace the EnemyMovement
script with a ChargingEnemyMovement
script instead. This will cause the bat to charge towards a direction, instead of tracking the player.
You might also want to increase the move speed of this Mob variant of your bat enemy, so that it feels more threatening.
To create a prefab variant, simply right-click on the prefab, then go to Create > Prefab Variant. Prefab Variants are convenient ways for us to create different variations of a prefab, so that when we change the main prefab, the variant will become affected as well.
d. Adding more movement types
If you like to create more kinds of enemies that move differently, you can create more subclasses of EnemyMovement
and override the Move()
function. For example, Vampire Survivors has mage-type enemies that hover around the player instead of moving.
Give it a try and share your code in the forums!
6. Adding the EventManager
Now that we have the enemy scripts all sorted out, let’s look at creating an EventManager
script. In Vampire Survivors, random events happen every 30 seconds, and these events will spawn either mobs or items in certain configurations. Before we do that, however, let’s create a new scriptable object called EventData
.
a. A new EventData
scriptable object
This new EventData
scriptable object will be similar to the WaveData
object. Hence, it will inherit from the SpawnData
class we created earlier. However, because it is used by the EventManager
we will be creating later (instead of the SpawnManager
), we will also introduce a few new properties unique to it.
EventData.cs
using UnityEngine; public abstract class EventData : SpawnData { [Header("Event Data")] [Range(0f, 1f)] public float probability = 1f; // Whether this event occurs. [Range(0f, 1f)] public float luckFactor = 1f; // How much luck affects the probability of this event. [Tooltip("If a value is specified, this event will only occur after the level runs for this number of seconds.")] public float activeAfter = 0; public abstract bool Activate(PlayerStats player = null); // Checks whether this event is currently active. public bool IsActive() { if (!GameManager.instance) return false; if (GameManager.instance.GetElapsedTime() > activeAfter) return true; return false; } // Calculates a random probability of this event happening. public bool CheckIfWillHappen(PlayerStats s) { // Probability of 1 means it always happens. if (probability >= 1) return true; // Otherwise, get a random number and see if we pass the probability test. if(probability / Mathf.Max(1,(s.Stats.luck * luckFactor)) >= Random.Range(0f, 1f)) return true; return false; } }
The EventData
class is a ScriptableObject
, which means it is meant to be used like WaveData
— for creating asset data files in our project. However, note that this class is also abstract
, which means that we are still unable to create any EventData
scriptable objects yet. To use this, we will need to create more scripts that subclass EventData
later on.
What is the purpose of EventData
? Primarily, it contains variables that we are able to adjust to control how often we get to skip an event (a higher Luck stat allows us to skip certain events in Vampire Survivors):
- It contains a
probabilityFactor
property, which is the basic probability of the event happening. If set to 1, it will always happen. - It also contains a
luckFactor
property, which controls how much Luck is able to affect the probability of the event skipping. The higher the Luck, the likelier that the event will be skipped (following the formula in the link above). - Finally, we also have an
activeAfter
property, which can be set to prevent certain events from happening too early. For example, if you want the Flower Wall event to start spawning only after 5 minutes in-game, you can setactiveAfter
to 250.
The CheckIfWillHappen()
function will be used by the EventManager
we are coding later to see if the event will happen, based on probabilityFactor
and luckFactor
. The IsActive()
function will also be used by the EventManager
later on, to check if the in-game time is already more than activeAfter
.
Finally, we also have an abstract
Activate()
function. This function will be overrided by our EventData
subclasses later, to allow us to create different kinds of spawn patterns.
Note that this script also uses a function called GetElapsedTime()
in IsActive()
, which currently doesn’t exist from on the GameManager
. Hence, for this script to work, we’ll need to also:
b. Updating the GameManager
The update is simple. We’ll just need to create a new GetElapsedTime()
function, which returns the stopwatchTime
variable in the GameManager
. This is because the EventData
needs to be able to check how long the game has been running.
GameManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; public class GameManager : MonoBehaviour { public static GameManager instance; // Define the different states of the game public enum GameState { Gameplay, Paused, GameOver, LevelUp } // Store the current state of the game public GameState currentState; // Store the previous state of the game before it was paused public GameState previousState; [Header("Damage Text Settings")] public Canvas damageTextCanvas; public float textFontSize = 20; public TMP_FontAsset textFont; public Camera referenceCamera; [Header("Screens")] public GameObject pauseScreen; public GameObject resultsScreen; public GameObject levelUpScreen; int stackedLevelUps = 0; // If we try to StartLevelUp() multiple times. [Header("Results Screen Displays")] public Image chosenCharacterImage; public TMP_Text chosenCharacterName; public TMP_Text levelReachedDisplay; public TMP_Text timeSurvivedDisplay; [Header("Stopwatch")] public float timeLimit; // The time limit in seconds float stopwatchTime; // The current time elapsed since the stopwatch started public TMP_Text stopwatchDisplay; // Reference to the player's game object public GameObject playerObject; // Getters for parity with older scripts. public bool isGameOver { get { return currentState == GameState.Paused; } } public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } } // Gives us the time since the level has started. public float GetElapsedTime() { return stopwatchTime; } void Awake() { //Warning check to see if there is another singleton of this kind already in the game if (instance == null) { instance = this; } else { Debug.LogWarning("EXTRA " + this + " DELETED"); Destroy(gameObject); } DisableScreens(); } void Update() { switch (currentState) { case GameState.Gameplay: // Code for the gameplay state CheckForPauseAndResume(); UpdateStopwatch(); break; case GameState.Paused: // Code for the paused state CheckForPauseAndResume(); break; case GameState.GameOver: case GameState.LevelUp: break; default: Debug.LogWarning("STATE DOES NOT EXIST"); break; } } IEnumerator GenerateFloatingTextCoroutine(string text, Transform target, float duration = 1f, float speed = 50f) { // Start generating the floating text. GameObject textObj = new GameObject("Damage Floating Text"); RectTransform rect = textObj.AddComponent<RectTransform>(); TextMeshProUGUI tmPro = textObj.AddComponent<TextMeshProUGUI>(); tmPro.text = text; tmPro.horizontalAlignment = HorizontalAlignmentOptions.Center; tmPro.verticalAlignment = VerticalAlignmentOptions.Middle; tmPro.fontSize = textFontSize; if (textFont) tmPro.font = textFont; rect.position = referenceCamera.WorldToScreenPoint(target.position); // Makes sure this is destroyed after the duration finishes. Destroy(textObj, duration); // Parent the generated text object to the canvas. textObj.transform.SetParent(instance.damageTextCanvas.transform); textObj.transform.SetSiblingIndex(0); // Pan the text upwards and fade it away over time. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0; float yOffset = 0; Vector3 lastKnownPosition = target.position; while (t < duration) { // If the RectTransform is missing for whatever reason, end this loop. if (!rect) break; // Fade the text to the right alpha value. tmPro.color = new Color(tmPro.color.r, tmPro.color.g, tmPro.color.b, 1 - t / duration); // Update the enemy's position if it is still around. if (target) lastKnownPosition = target.position; // Pan the text upwards. yOffset += speed * Time.deltaTime; rect.position = referenceCamera.WorldToScreenPoint(lastKnownPosition + new Vector3(0, yOffset)); // Wait for a frame and update the time. yield return w; t += Time.deltaTime; } } public static void GenerateFloatingText(string text, Transform target, float duration = 1f, float speed = 1f) { // If the canvas is not set, end the function so we don't // generate any floating text. if (!instance.damageTextCanvas) return; // Find a relevant camera that we can use to convert the world // position to a screen position. if (!instance.referenceCamera) instance.referenceCamera = Camera.main; instance.StartCoroutine(instance.GenerateFloatingTextCoroutine( text, target, duration, speed )); } // Define the method to change the state of the game public void ChangeState(GameState newState) { previousState = currentState; currentState = newState; } public void PauseGame() { if (currentState != GameState.Paused) { ChangeState(GameState.Paused); Time.timeScale = 0f; // Stop the game pauseScreen.SetActive(true); // Enable the pause screen } } public void ResumeGame() { if (currentState == GameState.Paused) { ChangeState(previousState); Time.timeScale = 1f; // Resume the game pauseScreen.SetActive(false); //Disable the pause screen } } // Define the method to check for pause and resume input void CheckForPauseAndResume() { if (Input.GetKeyDown(KeyCode.Escape)) { if (currentState == GameState.Paused) { ResumeGame(); } else { PauseGame(); } } } void DisableScreens() { pauseScreen.SetActive(false); resultsScreen.SetActive(false); levelUpScreen.SetActive(false); } public void GameOver() { timeSurvivedDisplay.text = stopwatchDisplay.text; // Set the Game Over variables here. ChangeState(GameState.GameOver); Time.timeScale = 0f; //Stop the game entirely DisplayResults(); } void DisplayResults() { resultsScreen.SetActive(true); } public void AssignChosenCharacterUI(CharacterData chosenCharacterData) { chosenCharacterImage.sprite = chosenCharacterData.Icon; chosenCharacterName.text = chosenCharacterData.Name; } public void AssignLevelReachedUI(int levelReachedData) { levelReachedDisplay.text = levelReachedData.ToString(); } void UpdateStopwatch() { stopwatchTime += Time.deltaTime; UpdateStopwatchDisplay(); if (stopwatchTime >= timeLimit) { playerObject.SendMessage("Kill"); } } void UpdateStopwatchDisplay() { // Calculate the number of minutes and seconds that have elapsed int minutes = Mathf.FloorToInt(stopwatchTime / 60); int seconds = Mathf.FloorToInt(stopwatchTime % 60); // Update the stopwatch text to display the elapsed time stopwatchDisplay.text = string.Format("{0:00}:{1:00}", minutes, seconds); } public void StartLevelUp() { ChangeState(GameState.LevelUp); // If the level up screen is already active, record it. if (levelUpScreen.activeSelf) stackedLevelUps++; else { levelUpScreen.SetActive(true); Time.timeScale = 0f; //Pause the game for now playerObject.SendMessage("RemoveAndApplyUpgrades"); } } public void EndLevelUp() { Time.timeScale = 1f; //Resume the game levelUpScreen.SetActive(false); ChangeState(GameState.Gameplay); if (stackedLevelUps > 0) { stackedLevelUps--; StartLevelUp(); } } }
Now that the EventData
is set up, we can move on to creating the EventManager
.
c. The EventManager
script
Here is the EventManager
script. It is a pretty straightforward one, containing:
- An array of
events
, containingEventData
scriptable objects, that determine what events it is capable of triggering. - A
firstTriggerDelay
variable, which allows us to control when the first event starts firing. - A
triggerInterval
variable, which controls how often events fire (after the first event fires).
Most of the logic is in Update()
, which is responsible for counting down, and randomly picking an event from the events
array whenever the countdown expires. We have a GetRandomEvent()
function to help randomly pick an event in Update()
as well, since we need to loop through every event and check if it is active before we can pick it.
EventManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class EventManager : MonoBehaviour { float currentEventCooldown = 0; public EventData[] events; [Tooltip("How long to wait before this becomes active.")] public float firstTriggerDelay = 180f; [Tooltip("How long to wait between each event.")] public float triggerInterval = 30f; public static EventManager instance; [System.Serializable] public class Event { public EventData data; public float duration, cooldown = 0; } List<Event> runningEvents = new List<Event>(); // These are events that have been activated, and are running. PlayerStats[] allPlayers; // Start is called before the first frame update void Start() { if (instance) Debug.LogWarning("There is more than 1 Spawn Manager in the Scene! Please remove the extras."); instance = this; currentEventCooldown = firstTriggerDelay > 0 ? firstTriggerDelay : triggerInterval; allPlayers = FindObjectsOfType<PlayerStats>(); } // Update is called once per frame void Update() { // Cooldown for adding another event to the slate. currentEventCooldown -= Time.deltaTime; if (currentEventCooldown <= 0) { // Get an event and try to execute it. EventData e = GetRandomEvent(); if (e && e.CheckIfWillHappen(allPlayers[Random.Range(0, allPlayers.Length)])) runningEvents.Add(new Event { data = e, duration = e.duration }); // Set the cooldown for the event. currentEventCooldown = triggerInterval; } // Events that we want to remove. List<Event> toRemove = new List<Event>(); // Cooldown for existing event to see if they should continue running. foreach (Event e in runningEvents) { // Reduce the current duration. e.duration -= Time.deltaTime; if (e.duration <= 0) { toRemove.Add(e); continue; } // Reduce the current cooldown. e.cooldown -= Time.deltaTime; if (e.cooldown <= 0) { // Pick a random player to sic this mob on, // then reset the cooldown. e.data.Activate(allPlayers[Random.Range(0, allPlayers.Length)]); e.cooldown = e.data.GetSpawnInterval(); } } // Remove all the events that have expired. foreach (Event e in toRemove) runningEvents.Remove(e); } public EventData GetRandomEvent() { // If no events are assigned, don't return anything. if (events.Length <= 0) return null; // Get a list of all possible events. List<EventData> possibleEvents = new List<EventData>(events); // Randomly pick an event and check if it can be used. // Keep doing this until we find a suitable event. EventData result = possibleEvents[Random.Range(0, possibleEvents.Count)]; while (!result.IsActive()) { possibleEvents.Remove(result); if (possibleEvents.Count > 0) result = events[Random.Range(0, possibleEvents.Count)]; else return null; } return result; } }
Here is a screenshot of how the EventManager
component looks like:
Now that we’ve got the EventManager
set up, we will need to create the events that the component will spawn!
7. Creating the Mob Event
The Mob Event is a pretty straightforward one. It is when you get a swarm of enemies rushing across the map like this:
a. Creating the MobEventData
To create mobs like this, we will need to create a MobEventData
which subclasses the EventData
script. All of the EventData
scripts that we create will override the Activate()
function, which is responsible for determining how the GameObjects spawned by the event will be configured.
MobEventData.cs
using UnityEngine; [CreateAssetMenu(fileName = "Mob Event Data", menuName = "2D Top-down Rogue-like/Event Data/Mob")] public class MobEventData : EventData { [Header("Mob Data")] [Range(0f, 360f)] public float possibleAngles = 360f; [Min(0)] public float spawnRadius = 2f, spawnDistance = 20f; public override bool Activate(PlayerStats player = null) { // Only activate this if the player is present. if(player) { // Otherwise, we spawn a mob outside of the screen and move it towards the player. float randomAngle = Random.Range(0, possibleAngles) * Mathf.Deg2Rad; foreach (GameObject o in GetSpawns()) { Instantiate(o, player.transform.position + new Vector3( (spawnDistance + Random.Range(-spawnRadius, spawnRadius)) * Mathf.Cos(randomAngle), (spawnDistance + Random.Range(-spawnRadius, spawnRadius)) * Mathf.Sin(randomAngle) ), Quaternion.identity); } } return false; } }
b. Explaining the MobEventData
Here is how the MobEventData
scriptable object looks like when you try creating a copy of it:
The first set of properties belong to SpawnData
, which you can find on WaveData
as well. Refer to the part above to get a recap on its properties. With a Duration of 12, and a Spawn Interval of 2 to 3, this event will spawn anywhere between 4 to 6 groups of enemies. If you want this event to only fire once, make sure the Duration is less than the Spawn Interval values.
Note: Make sure you put at least 1 enemy prefab under Possible Spawn Prefabs as well!
The properties under Event Data belong to the EventData
class, and control the probability of this event happening. I set the Probability to 1 so that the event always fires.
The properties under Mob Data are unique to the MobEventData
. It controls:
- The
possibleAngles
that the mob can come from. - How large a
spawnRadius
the mobs can spawn in. If you set this to 0, they will all spawn and stack at the same spot, so give this a reasonable value. - How far away from the player it spawns (
spawnDistance
).
Once you’ve set up a new MobEventData
asset, assign it to the EventManager
and test it out!
8. Creating the infamous Flower Wall
Now that the mobs are created, let’s create the infamous Flower Wall!
a. Creating the RingEventData
The Ring Event Data is a script that will create a ring of GameObjects around the player character. This need not necessarily spawn enemies — it can spawn any kind of GameObject.
RingEventData.cs
using UnityEngine; [CreateAssetMenu(fileName = "Ring Event Data", menuName = "2D Top-down Rogue-like/Event Data/Ring")] public class RingEventData : EventData { [Header("Mob Data")] public ParticleSystem spawnEffectPrefab; public Vector2 scale = new Vector2(1, 1); [Min(0)] public float spawnRadius = 10f, lifespan = 15f; public override bool Activate(PlayerStats player = null) { // Only activate this if the player is present. if (player) { GameObject[] spawns = GetSpawns(); float angleOffset = 2 * Mathf.PI / Mathf.Max(1, spawns.Length); float currentAngle = 0; foreach(GameObject g in spawns) { // Calculate the spawn position. Vector3 spawnPosition = player.transform.position + new Vector3( spawnRadius * Mathf.Cos(currentAngle) * scale.x, spawnRadius * Mathf.Sin(currentAngle) * scale.y ); // If a particle effect is assigned, play it on the position. if(spawnEffectPrefab) Instantiate(spawnEffectPrefab, spawnPosition, Quaternion.identity); // Then spawn the enemy. GameObject s = Instantiate(g, spawnPosition, Quaternion.identity); // If there is a lifespan on the mob, set them to be destroyed. if (lifespan > 0) Destroy(s, lifespan); currentAngle += angleOffset; } } return false; } }
b. Explaining the RingEventData
Like before, here’s how the component looks like:
Pretty similar to the MobEventData
, except for the Mob Data portion, which contains a different set of properties.
- The Spawn Radius property is the most important by far, because it determines how large the ring will be.
- The next most important property is Scale, which allows you to create oval-shaped rings (instead of perfectly circular ones). For example, with a Scale of (2, 1), my rings appear like this instead:
- The Lifespan property allows you to set a time limit to how long the mob lasts.
- Finally, the Spawn Effect Prefab allows you to assign a particle effect that will play on the spot where the enemy will spawn. This can be left empty if you don’t want any visual effect to play, but it can help make the spawn look less sudden.
c. The Flower sprite sheet
If you can’t find any suitable sprites for your flower enemy, here is the sprite sheet I used.
When creating the enemy prefabs for your new enemies, note that you can use Animator Override Controllers as well, since all of the enemies share the same animation states.
9. Additional enemy sprite sheets
If you need sprites for more kinds of enemies, you can also use these:
10. Conclusion
And that about wraps up this part! As usual, if you are a Silver Patron and above, you can also download the project files.
In the next part, we will be expanding upon the list of stats that enemies have.