Changing enemy loop mechanism to create levels

I’m trying to implement a different form of the gameplay loop. Instead of respawning waves infinitely, I’d like the waves to persist, with only enemies you didn’t kill going back into the wave and that wave moving to the back of the spawn queue. I’ve been trying this for a good 8 hours, and keep struggling to conceptualize the pieces, how they should fit together, so I’m asking for some guidance or how you might go about it. Here’s some mockup code of what I’ve been trying so far.

FirstPass bool => Instantiate only in first pass.

if (!FirstPass && spawner isReady)

check status of each WaveConfig, represented by -1 = all dead, 0 = in-flight, 1 = flight path finished. > grab the first 1 you see, set as currentWave.
clean out the dead enemies from currentWave. Should I be deleting them, or just ignoring them?
if currentWave has only dead enemies, set status of the currentWave to -1, and move on
SpawnOneWave with current wave, but since !FirstPass, just set their position = start of flight path, set them to active again.

This logic makes decent sense to me, but what I’m struggling with is which Scripts should be responsible for checking whether a wave is in-flight or waveCompleted, and when enemies are dead. waveCompleted would need to confirm that every enemy either is dead, or has reached the end of its flight path.

Should I be making a separate WaveManager, that maintains these? Should I be using Events to decouple the code a bit? Should the EnemySpawner be telling each WaveConfigSO in waveConfigs what it’s index in the is, so the WaveConfigSO can broadcast an event to the EnemySpawner when all its elements are either dead or reached end of flight path?

Any ideas you might have are welcome, thanks!

Hi Joshua,

I think you are on the right track, so I’m just giving you a little tip: If you parent all enemies of a wave to a game object, you could check the childCount of said game object. This way, you will know if the player killed all enemies of the wave.

And one more tip if childCount didn’t work as expected: Check childCount after the last enemy of the wave was spawned.

I hope this helped. Let me know how you are getting on with this. :slight_smile:

Hi Nina. Thanks for you response. I’ve been working on this for like 30hours now (insane!), and still can’t get the behavior to work. My current problem, I believe, has to do with how I’m subscribing to events. Even after I attempt to subscribe to an event like OnEnemyDied or OnEnemyReachedPathEnd, those event Action objects are still null => didn’t work like I tried.

Here are the relevant code excerpts - desperately hope you can spot my mistake!

First, the relevant part of the HasHealth class (component that all the Enemy Prefabs, and Player, have to track their health).

public class HasHealth : MonoBehaviour
{
    // other declarations

    public event Action OnEnemyDied;

   // other methods

    private void Die()
    {
        if (currentHealth == 0)
        {
            // other stuff
            
            if(OnEnemyDied == null) // the key section of code
            {
                Debug.Log("OnEnemyDied event is null, so it can't invoke"); // this is currently hitting! That's the problem
            }
            
            else
            {
                OnEnemyDied?.Invoke(); // trigger the event, which other stuff listens to
            }
            
            isAlive = false; // don't think this matters
            Destroy(gameObject); // this all happens, so I know code is getting here
        }
    }

Next, the class I created to keep track of enemies, see when they have died or reached end of their flight path. I’ll instantiate EnemyStatusTrackers in the EnemySpawner class. They do successfully show up in the game heirarchy during runtime, and they do successfully reach the debug lines Debug.Log(“tracking enemy health”) and Debug.Log(“tracking enemy flight path”);

public class EnemyStatusTracker : MonoBehaviour
{

    private int numAccountedFor;
    private int numMaxEnemies;
    private int numDeadEnemies;
    private bool allAccountedFor;
    private bool allDead;
    private bool someStillAlive;
    public event Action OnAllEnemiesDead;
    public event Action OnAllEnemiesAccountedFor;
    public event Action OnSomeEnemiesSurvived;

    public void Start()
    {
        ResetMe();
    }

    public void ResetMe(){
        numAccountedFor = 0;
        numDeadEnemies = 0;
        allAccountedFor = false;
        allDead = false;
        someStillAlive = true;
    }

    // Spawner is going to assign enemies to this tracker
    public void AddEnemyToTrack(GameObject enemy){
        numMaxEnemies++;
        // trackedEnemies.Add(enemy); // remove this
        if (enemy.TryGetComponent<HasHealth>(out HasHealth health))
            {
                health.OnEnemyDied += OneEnemyDied; // subscribe this WaveConfig's statusTracker to that died event
                Debug.Log("tracking enemy health");

            }
        if (enemy.TryGetComponent<EnemyPathfinder>(out EnemyPathfinder pathfinder))
            {
                pathfinder.OnEnemyReachedPathEnd += OneEnemyReachedEnd;
                Debug.Log("tracking enemy flight path");
        }
        UpdateStatus();

    }

    public void OneEnemyDied(){
        AccountedFor();
        numDeadEnemies++;
        Debug.Log("Event: one enemy died");
        UpdateStatus();
    }

    public void OneEnemyReachedEnd()
    {
        AccountedFor();
        Debug.Log("Event: one enemy reached end of flight path");
        UpdateStatus();
    }

    private void AccountedFor()
    {
        numAccountedFor++;
    }

    private void UpdateStatus()
    {
        if(numAccountedFor == numMaxEnemies)
        {
            allAccountedFor = true;
            OnAllEnemiesAccountedFor?.Invoke();
        }
        
        if(numDeadEnemies == numMaxEnemies)
        {
            allDead = true;
            OnAllEnemiesDead?.Invoke();
        }

        if(allAccountedFor && !allDead) // Spawner will see event, iterate to find Wave with allAccountedFor
        {
            OnSomeEnemiesSurvived?.Invoke();
            someStillAlive = true;
        }

        string outputLog = "numMaxEnemies = " + numMaxEnemies;
        outputLog += ". numDead = " + numDeadEnemies;
        outputLog += ". numAccountedFor = " + numAccountedFor;

        Debug.Log("EnemyStatusTracker updated. " + outputLog);

    }

    public bool getAllAccountedFor()
    {
        return allAccountedFor;
    }
    
    public bool getAllDead()
    {
        return allDead;
    }

    public bool getSomeStillAlive()
    {
        return someStillAlive;
    }

}

And finally, the relevant part of the EnemySpawner class, where it assigns each spawning Enemy to a StatusTracker. The lines where I think I could have a mistake is where I try to subscribe to the relevant events from each instance of enemyPrefab.

currentStatusTracker.OnSomeEnemiesSurvived += AddWaveReady;
and
currentStatusTracker.AddEnemyToTrack(enemyPrefab);
and
enemyPrefab.GetComponent().OnEnemyDied += OneEnemyDiedTester;

public class EnemySpawner : MonoBehaviour
{
    
    bool isFirstPass = true; // Flag to track the first pass, so you only Instantiate on the first pass

    [SerializeField] GameObject statusTracker;
    List<EnemyStatusTracker> enemyStatusTrackers;
    EnemyStatusTracker currentStatusTracker;

    int numWavesReady;
    int numWavesDead;
    
    void Start()
    {
        // activeWaveConfigs = new List<WaveConfigSO>(waveConfigs); // fill the copy
        // switch for testing
        activeWaveConfigs = waveConfigs;
        enemyStatusTrackers = new List<EnemyStatusTracker>();
        for(int i=0;i<activeWaveConfigs.Count;i++){
            // remember our EnemyStatusTracker is actually attached to a game object. So you instantiate the type game object
            GameObject newStatusTrackerObject = Instantiate(statusTracker, transform.position, Quaternion.Euler(0, 0, 180), transform);
            // then with that prefab, you grab its EnemyStatusTracker component
            EnemyStatusTracker newStatusTracker = newStatusTrackerObject.GetComponent<EnemyStatusTracker>();
            //add it to the list
            enemyStatusTrackers.Add(newStatusTracker);
        }
        
        numWavesReady = waveConfigs.Count;
        numWavesDead = 0;
         
        // StartCoroutine(Wait(1f)); // so the WaveConfig can awake first
        StartCoroutine(SpawnWaves());
    }

    IEnumerator SpawnWaves()
    {
        for(int i = 0; i < activeWaveConfigs.Count; i++){
            currentWave = activeWaveConfigs[i];
            currentStatusTracker = enemyStatusTrackers[i];
            currentStatusTracker.OnSomeEnemiesSurvived += AddWaveReady; // @Nina, subscribe to event in the EnemyStatusTracker class
            currentStatusTracker.OnAllEnemiesDead += AddWaveDead; // same

            StartCoroutine(SpawnOneWave());
            yield return new WaitForSeconds(timeBetweenWaves);
        }
        
        isFirstPass = false; // if you made it down here, you're no longer first pass
        StartCoroutine(RespawnLivingEnemies());
    }

    IEnumerator SpawnOneWave()
    {
        numWavesReady-=1; // one being sent out so it's not ready anymore
        currentStatusTracker.ResetMe();
        
        for (int i = 0; i < currentWave.GetEnemyCount(); i++)
        {
            Debug.Log("Status tracker given " + i + "prefab");
            GameObject enemyPrefab = currentWave.GetEnemyPrefab(i);
            currentStatusTracker.AddEnemyToTrack(enemyPrefab); // @Nina, the important tracker line.
            // for testing if the event really gets invoked
            enemyPrefab.GetComponent<HasHealth>().OnEnemyDied += OneEnemyDiedTester; // @Nina, another test Event that I added, to see if events all contained within this class work. They don't.
            
            if(isFirstPass)
            {
                // Debug.Log("hits the instantiate part of loop");
                Instantiate(enemyPrefab, currentWave.GetStartingWaypoint().position, Quaternion.Euler(0, 0, 180), currentStatusTracker.transform);
                // GameObject enemyInstance = Instantiate(enemyPrefab, currentWave.getStartingWaypoint().position, Quaternion.Euler(0, 0, 180), transform);
                // Optionally, you can set any additional properties or components of the enemy instance here

            }
            else
            {
                Debug.Log("hits the activeSelf part of loop");
                // Enemy survived, reposition it at the starting waypoint
                enemyPrefab.SetActive(true);
                enemyPrefab.GetComponent<EnemyPathfinder>().Restart();
            }
            
            yield return new WaitForSeconds(currentWave.GenerateRandomSpawnTime());  
        }
    }

    IEnumerator RespawnLivingEnemies()
    {
        // if all the waves aren't dead
        while(!GetAllWavesDead())
        {
            // wait for a wave to be ready
            while(GetNumberWavesReady() == 0) // need it to be a method, not a variable
            {
                // so you don't accidentally get infinetely stuck here after passing the previous while condition:
                if(GetAllWavesDead())
                {
                    yield break;
                }
                // otherwise, keep waiting
                // Debug.Log("Gets to first !allAccountedFor, has to wait");
                yield return null; // wait
            }

            // once it breaks out of that loop, find the one that has some surviving enemies
            int i = 0;
            while(!enemyStatusTrackers[i].getSomeStillAlive())
            {
                i++;
            }

            // once breaks out, set that as the wave and tracker we'll use
            currentWave = activeWaveConfigs[i];
            currentStatusTracker = enemyStatusTrackers[i];

            StartCoroutine(SpawnOneWave());

        }
    }
        
// other stuff
    

    // need this as a method, for the purposes of IEnumerator loop
    private int GetNumberWavesReady()
    {
        return numWavesReady;
    }

    // to be subscribed to the EnemyStatusTrackers
    private void AddWaveReady()
    {
        numWavesReady++;
    }

    // need this as a method, for the purposes of IEnumerator loop
    private bool GetAllWavesDead()
    {
        return numWavesDead == waveConfigs.Count;
    }
    
    // to be subscribed to the EnemyStatusTrackers
    private void AddWaveDead()
    {
        numWavesDead++;
    }

    private void OneEnemyDiedTester()
    {
        Debug.Log("EnemySpawner OneEnemyDiedTester called");
    }

}

I feel pretty discouraged and wasteful having spent so much time on this. I was optimistic about using Events, as they seem so simple and intuitive, but they just don’t seem to do what I expected. Any guidance welcome.

Thanks!

First of all, good job on sticking with the problem for such a long time. Believe me, you are learning a lot by it, even if you get stuck and/or if you cannot find any solution. Your skills improve by trying to solve problems like this. :slight_smile:

I tried to read your code but it quickly becomes very complex, and I feel that this is the main problem. At the moment, I’m not quite sure what exactly you are trying to achieve because there are so many variables, so many names. However, I can see that you had many ideas that you tried to implement.

When I try to solve a problem and notice that the complexity of my code increases and that I haven’t solved my actual problem or at least a few sub-problems yet, I usually start from scratch. Well, not completely from scratch but I usually reconsider my concept and reuse parts of my “failed” attempt to implement my revised concept. In most cases, I am able to simplify my code significantly by doing that. And writing these things from scratch usually saves me time.


From what I see, you have the parts you need in your current code. They are just scattered all over the place and not well connected yet. This is completely normal, though, and happens to the best programmers. That’s why something called “refactoring” exists. We often revise and refactor our code until it is good. We rarely write good code from scratch.

Here are a few potential problems I identified:

In your EnemySpawner, I can see two concepts mixed together: the wave(s) and individual enemies. It would probably be a good idea to reconsider the organisation/structure of your implementation. For example, you could create a WavesHandler keeping track of all waves, and an WaveHandler keeping track of the enemies of a single wave. The WaveHandler could notify the WavesHandler once all enemies are dead or if something else happened that is relevant for your concept. Maybe you’ll have better names than I. Discard Gary’s classes if they don’t work well with your own ideas.

To restructure your concept and to simplify your code, check all members at the top of your relevant classes. Are you able to immediately tell the purpose of each one? For example, for what do you need numWavesReady and numWavesDead? Why do you have multiple EnemyStatusTrackers? What status does an enemy have? And why do they need Actions in the “current” EnemyStatusTracker?

pathfinder.OnEnemyReachedPathEnd += OneEnemyReachedEnd;: Why is the pathfinder interested in this? And do your objects need to depend on so many other objects? Or could you decrease the dependencies?

Could you rework your concept in a way, so you are able to immediately tell which is the most powerful class? At the moment, the rather irrelevant HasHealth class has got a lot of power because, with OnEnemyDied?.Invoke();, it could trigger an unknown number of things nobody would expect being triggered by something called HasHealth. This makes debugging the logic flow difficult, not just for beginners but also for experienced programmers because the ideas behind the code are not obvious.

If you had a WaveHandler, the expected approach is that the enemy calls a method in the WaveHandler, e.g. NotifyOnDeath(). Then the WaveHandler notifies everything that is interested in this “enemy died” event: for example, the ScoreHandler, the WavesHandler, and so on. Nothing is supposed to know anything about the individual enemy, except for the WaveHandler. (Of course, this is just an idea, not how you must do it.)

As you can probably see, there seems to be a problem with the structure of your concept. At least, I am currently unable to identify clear tasks. Try to break your ideas down into a few big problems and sub-problems, then into tasks. Do that with pen and paper. If possible, try to follow the single responsibility principle. Then you’ll very likely be able to solve your problems. :slight_smile:


OnEnemyDied being null in your HasHealth object might be due to things like enemyPrefab.GetComponent<HasHealth>().OnEnemyDied. It makes no sense to assign anything to a prefab because, when you instantiate a prefab, a new object gets created. The HasHealth object of the instantiated enemy will not be the same as the HasHealth object of the prefab object.


The idea behind events is not to track anything. The idea is to “wait” until something happens. Then you get notified that something has happened, and based on this notification, you execute things without knowing the specifics about . I would recommend to watch this tutorial video by Mosh Hamedani:

Thank you for your encouragement, Nina. I will trust that you’re right - that I am learning to learn through the long process!

And thank you for identifying the over-complexity of the code, then providing a few ideas of how to better structure the classes/dependencies. The principals you laid out - being able to quickly understand what class members do, single task responsibility, writing structure out on paper - seem like great level-ups for my abilities. Something I’ll take with me to my next projects!

I’ll look at the video too. I have watched maybe 3 of them by now, and while I think I understand the concept (waiting for alerts, rather than tightly coupling pieces), it looks like there were some general gaps in my C# knowledge. You pointed out what appears to be the main bug of my code for this whole event system I’ve tried designing: I was setting up the events and invokes with the Enemy Prefab, not with the instance of that prefab. A simple GameObject enemyInstance = Instantiate(blah blah) has it, and now the events are talking with each other as I thought they would!

Nonetheless, I will take the best practices you suggested, and refactor the code here. A good exercise I think!

Do you know of a good tutorial for better use of Debug stuff? Do you think that’s important even? I was relying on Debug.Log(“the variable x value here is:” + x) type of stuff, which sort of works but feels pretty amateurish. I wonder if I could have caught my mistakes more quickly with better debugging too?

Again, appreciate your help Nina!

Fantastic! In C# and all other OOP languages, issues with references are the main source of bugs. :slight_smile:

Do you know of a good tutorial for better use of Debug stuff? Do you think that’s important even? I was relying on Debug.Log(“the variable x value here is:” + x) type of stuff, which sort of works but feels pretty amateurish. I wonder if I could have caught my mistakes more quickly with better debugging too?

Yes, being able to debug is a programmer’s most crucial skill, aside from being able to do research, of course. If you want to be able to write solutions for your unique problems, you definitely should learn how to debug. However, while there are a few technique, debugging is not something somebody can teach you because a huge part of debugging is guessing what the problem might be, trying to get data to verify one’s assumptions and interpreting the data.

For debugging with Visual Studio, I would recommend to learn about breakpoints, inspect variables and use step in/ step out to go into the details of the code if necessary.

Here is a tutorial on breakpoints:

For a simple (or let’s say: not overly complex) project like yours, I mainly rely on Debug.Log. There is nothing wrong with it. However, breakpoints and inspecting variables can speed up the debugging if the project becomes more complex or if you have to repeat the same steps over and over again while debugging. Also, if I have to get a lot of different data, Debug.Log could spam the console with messages, which makes interpreting the data difficult. For inspecting the content of a list or array, I use the autos/local window. However, if I just want to get the name of a game object or something like that, I simply use Debug.Log because that’s usually faster.

Once you know these tools/features, you have to practice using these tools to quickly get relevant information about your program at runtime. If you know how to debug, you will automatically write better code because you always have “what if I need to debug this?” in mind while writing your code. And you will hopefully write the code in a way which will make debugging as easy as possible. :slight_smile:

I wonder if I could have caught my mistakes more quickly with better debugging too?

That’s very likely the case, and that’s why knowing how to debug is so important. When I read your code, I quickly noticed that it makes no sense to read it line by line because a lot is going on. It is “impossible” to remember every single detail unless I learn everything by heart. This is a problem with code in general, not just yours. And that’s why the SOLID principles (and others) exist.

Again, writing good code and being able to debug requires a lot of practice with a lot of trial and error. Nobody writes good code just because they read a Wikipedia article or watched a tutorial on Youtube. Your 30+ hours are definitely not wasted but an important part of your learning journey. :slight_smile:


A longer video on debugging:

In my opinion, Mosh Hamedani is a good instructor. If you are interested in other C# topics, you can find free tutorials by him in Youtube.

1 Like

I’ve just modified Gary’s project as follows:

  • I made the enemies move back and forth on the same path instead of killing them when they reach the last waypoint.

  • After all enemies of a wave were spawned, I made the EnemySpawner check if all spawned enemies are dead before spawning the next wave.

If you want to see how I achieved that, here is the code:

EnemySpawner.cs
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] List<WaveConfigSO> waveConfigs;
    [SerializeField] float timeBetweenWaves = 0f;
    [SerializeField] bool isLooping;
    WaveConfigSO currentWave;

    void Start()
    {
        StartCoroutine(SpawnEnemyWaves());
    }

    public WaveConfigSO GetCurrentWave()
    {
        return currentWave;
    }

    IEnumerator SpawnEnemyWaves()
    {
        do
        {
            foreach (WaveConfigSO wave in waveConfigs)
            {
                currentWave = wave;

                for (int i = 0; i < currentWave.GetEnemyCount(); i++)
                {
                    Instantiate(currentWave.GetEnemyPrefab(i),
                                currentWave.GetStartingWaypoint().position,
                                Quaternion.Euler(0,0,180),
                                transform);

                    yield return new WaitForSeconds(currentWave.GetRandomSpawnTime());
                }

                // Wait until all enemies in wave are dead
                while (this.transform.childCount > 0) { yield return null; }

                yield return new WaitForSeconds(timeBetweenWaves);
            }
        }
        while(isLooping);
    }
}
Pathfinder.cs
using System.Collections.Generic;
using UnityEngine;

public class Pathfinder : MonoBehaviour
{
    EnemySpawner enemySpawner;
    WaveConfigSO waveConfig;
    List<Transform> waypoints;

    int waypointIndex = 0;
    bool reverse = false;

    void Awake()
    {
        enemySpawner = FindObjectOfType<EnemySpawner>();
    }

    void Start()
    {
        waveConfig = enemySpawner.GetCurrentWave();
        waypoints = waveConfig.GetWaypoints();
        transform.position = waypoints[waypointIndex].position;
    }

    void Update()
    {
        FollowPath();
    }

    void FollowPath()
    {
        if (waypointIndex < waypoints.Count && waypointIndex > -1)
        {
            Vector3 targetPosition = waypoints[waypointIndex].position;
            float delta = waveConfig.GetMoveSpeed() * Time.deltaTime;
            transform.position = Vector3.MoveTowards(transform.position, targetPosition, delta);
            
            if (transform.position == targetPosition)
            {
                if (waypointIndex == 0 || waypointIndex == waypoints.Count - 1)
                {
                    reverse = (waypointIndex == waypoints.Count - 1);
                }

                if (reverse) { waypointIndex--; }
                else         { waypointIndex++; }
            }
        }
    }
}

Maybe this will inspire you to keep working on your own solution. Feel free to use the code as a basis for your own ideas if it doesn’t do exactly what you had in mind.

Thank you for following up with me, Nina. I had meant to reply to your previous post, but was too excited to charge ahead and forgot to do so.

I like the way you used the transform.childCount to keep track of enemies being alive. That worked elegantly with the way you are having enemies go back and forth along their path - cool!

After stepping back to re-factor my code, and write it out the main elements on paper, I ended up with something along the lines of how you suggested earlier with a WaveTracker, and a WavesTracker (I named them slightly differently). Then my EnemySpawner maintains a queue of waves that are ready, keeps pulling from that queue as long as there is still an enemy alive from the wave.

To be honest, I suspect that my code is probably still more complicated than it needs to be - you’ll have to tell me how confusing the below is. But it at least made sense to me on paper, and I could follow it well enough to pretty quickly debug and such. I’m also just trying to force myself to learn Event action stuff too, so I can decouple the code. For my next game feature, I’ve even added a separate EventManager singleton class, which various other classes can call so they are decoupled from one another. I might come back and refactor this waves section to use the same EventManager.

Here’s what I ended up with for the Spawner, OneWaveStatusTracker, and WavesTracker. A small naming convention question - I didn’t like calling it WaveStatusTracker and WavesStatusTracker, because the difference (can you spot it!?) is only a plural ‘s’ - the classes didn’t stand out enough to me. What’s common practice though?

Thanks again for your interest! I’m sure I’ll be back in the forums somewhere along the way as I continue with my souped-up Laser Defender.

public class EnemySpawner : MonoBehaviour
{
    [SerializeField] List<WaveConfigSO> waveConfigs;
    [SerializeField] GameObject oneWaveStatusTrackerPrefab; // the OneWaveStatusTracker prefab
    [SerializeField] float timeBetweenWaves = 3f;

    Queue<WaveConfigSO> waveQueue;
    WaveConfigSO currentWave;
    OneWaveStatusTracker currentOneWaveStatusTracker;
    List<OneWaveStatusTracker> statusTrackers;
    WavesTracker wavesTracker;

    bool isSpawning;

    void Start()
    {
        // initialize variables
        waveQueue = new Queue<WaveConfigSO>();
        statusTrackers = new List<OneWaveStatusTracker>();
        
        // connect to key classes
        wavesTracker = FindObjectOfType<WavesTracker>();
        
        // initialize stuff
        InitializeOneWaveStatusTrackers();
        // order matters for next 3 :
        InitializeWaveConfigSOs();  // just giving them a wave number
        InitializeWavesTracker(); // must be called AFTER InitializeWaveConfigSOs
        InitializeWaveQueue(); // must be called AFTER InitializeWavesTracker
        
        // immediate Events needed
        wavesTracker.OnAllWavesDead += AllWavesDead;
        
        // main loop
        StartCoroutine(SpawnWaves());
        
    }

    public void InitializeOneWaveStatusTrackers()
    {
        for(int i=0;i<waveConfigs.Count;i++){
            // remember our EnemyStatusTracker is actually attached to a game object. So you instantiate the type game object
            GameObject newStatusTrackerObject = Instantiate(oneWaveStatusTrackerPrefab, transform.position, Quaternion.Euler(0, 0, 180), transform);
            // then with that prefab, you grab its EnemyStatusTracker component
            OneWaveStatusTracker newStatusTracker = newStatusTrackerObject.GetComponent<OneWaveStatusTracker>();
            // initialize its ID and number of enemies
            newStatusTracker.InitializeMe(i, waveConfigs[i].GetEnemyCount());
            // add it to the list
            statusTrackers.Add(newStatusTracker);
        }
    }

    // give each WaveConfigSO an index num, which will be used throughout
    private void InitializeWaveConfigSOs()
    {
        int i = 0;
        foreach(WaveConfigSO waveconfig in waveConfigs)
        {
            waveconfig.SetAssignedWaveNum(i);
            i++;
        }
    }

    private void InitializeWavesTracker()
    {
        foreach(OneWaveStatusTracker tracker in statusTrackers)
        {
            wavesTracker.AddTrackedWave(tracker);
        }
    }

    private void InitializeWaveQueue()
    {
        // put each waveConfig into the Queue
        foreach(WaveConfigSO waveConfig in waveConfigs)
        {
            waveQueue.Enqueue(waveConfig);
        }
        // Event so it receives notification of a wave ready
        wavesTracker.OnOneWaveReady += AddWaveToQueue;
    }

    // version that's triggered through Event, when a wave survives and needs to be respawned 
    public void AddWaveToQueue(int i)
    {
        waveQueue.Enqueue(waveConfigs[i]);
        // Debug.Log("In EnemySpawner, added wave " + i + "to the queue");
        
        // if not currently spawning, need to start the coroutine again
        if(!isSpawning)
        {
            StartCoroutine((SpawnWaves()));
        }
    }

    IEnumerator SpawnWaves()
    {
        isSpawning = true;
        while(waveQueue.Count != 0){
            currentWave = waveQueue.Dequeue();
            
            // So that as we spawn each enemy, we can assign appropriate OneWaveStatusTracker.
            // The WaveConfigSO has its wavenum, which is equivalent to index of statusTrackers list.
            // We could probably do this with events instead, but we're doing it explictly instead.
            currentOneWaveStatusTracker = statusTrackers[currentWave.GetAssignedWaveNum()];

            StartCoroutine(SpawnOneWave());
            yield return new WaitForSeconds(timeBetweenWaves);
        }
        isSpawning = false;
    }

    IEnumerator SpawnOneWave()
    {
        // determine how many enemies didn't die in last wave, so will be spawned
        List<int> enemyStatuses = new List<int>();
        enemyStatuses = currentOneWaveStatusTracker.GetEnemyStatuses();
        // Debug.Log("For wave " + currentOneWaveStatusTracker.GetWaveID() + ", enemyStatuses before spawn: " + DebugLogList(enemyStatuses));
        int numToSpawn = 0;
        foreach(int enemyStatus in enemyStatuses){
            if (enemyStatus==1) // means it survived last flight
            {
                numToSpawn++;
            }
        }
        // Debug.Log("For wave " + currentOneWaveStatusTracker.GetWaveID() + " going to spawn " + numToSpawn);

        // cheating, by assuming that all enemies of the wave are of the same type! Big forced thing
        GameObject enemyPrefab = currentWave.GetEnemyPrefab(0);

        // tell the tracker, so it sets enemy statuses to -1.
        // This needs to be done before all the spawning happens, because you might destroy an enemy before the whole wave is spawned.
        currentOneWaveStatusTracker.WaveSpawned();

        // spawn that number of enemies
        for (int i = 0; i < numToSpawn; i++)
        {
            GameObject enemyInstance = Instantiate(enemyPrefab, currentWave.GetStartingWaypoint().position, Quaternion.Euler(0, 0, 180), currentOneWaveStatusTracker.transform);
            // hook up the Events to that instance
            currentOneWaveStatusTracker.AddEnemyToTrack(enemyInstance);
            // Debug.Log("Status tracker given index" + i + " prefab");
            
            // random pause between enemies
            yield return new WaitForSeconds(currentWave.GenerateRandomSpawnTime());  
        }
    }

    // used by EnemyPathfinder script
    public WaveConfigSO GetCurrentWave(){
        return currentWave;
    }

    IEnumerator Wait(float time)
    {
        yield return new WaitForSeconds(time);
    }

    // Event call from WavesTracker
    private void AllWavesDead()
    {
        Debug.Log("EnemySpawner receives Event AllWavesDead");
    }

    // only used for debugging
    public string DebugLogList(List<int> list)
    {
        StringBuilder builder = new StringBuilder();
        builder.Append("[");
        for (int i = 0; i < list.Count; i++)
        {
            builder.Append(list[i]);
            if (i < list.Count - 1)
            {
            builder.Append(", ");
            }
        }
        builder.Append("]");
        return builder.ToString();
    }

}

Each wave has a tracker, called OneWaveStatusTracker

/*
################### Basic idea ##############
> Initialized with a WaveID and int # enemies. EnemySpawner will do this.
> Create an array of ints, each int represents status of one enemy = main logic feature for class.
> > 0 = dead, 1 = alive and de-spawned back home, -1 = alive and flying
> When spawner grabs a WaveConfigSO + spawns the enemies, this class gets reference to each enemy, adds them to its Event listeners
> Every time an enemy event happens (dies, reaches end of flight path), triggers an UpdateStatus method here.
> > (those events happen in HasHealth, and EnemyPathfinder)
> UpdateStatus checks if all enemies are dead, or if all are accounted for (ie safe back at home or dead)
> If all accounted for, and some are still alive, broadcasts an event that this wave is ready to be respawned. WavesTracker listens.
> If all dead, broadcasts event. WavesTracker listens.
*/


public class OneWaveStatusTracker : MonoBehaviour
{
    private int numMaxEnemies;
    private int numDeadEnemies;
    private int numAccountedFor; // debug only line, not needed for logic
    private bool readyToSpawn;
    // private bool allDead;
    // private bool someStillAlive;

    private List<int> enemyStatuses; // is this the right way?
    
    public event Action<int> OnOneWaveDead;
    public event Action<int> OnOneWaveSpawned;
    public event Action<int> OnOneWaveReady;

    private int waveID;

    // tightly coupled with EnemySpawner - will be called by thatB
    public void InitializeMe(int waveID, int enemyCount)
    {
        enemyStatuses = new List<int>();
        numAccountedFor = 0;
        numDeadEnemies = 0;
        this.waveID = waveID;
        
        for(int i = 0; i<enemyCount; i++)
        {
            enemyStatuses.Add(1); // initialize as alive and accounted for
        }
        // Debug.Log("Tracker initialized for wave " + waveID + ". Enemy count given: " + enemyCount);
        numMaxEnemies = enemyCount;
    }

    // Spawner is going to assign enemies to this tracker
    public void AddEnemyToTrack(GameObject enemy){
        // trackedEnemies.Add(enemy); // remove this
        
        if (enemy.TryGetComponent<HasHealth>(out HasHealth health))
            {
                health.OnEnemyDied += OneEnemyDied; // subscribe this WaveConfig's statusTracker to that died event
                // Debug.Log("tracking enemy health");

            }
        if (enemy.TryGetComponent<EnemyPathfinder>(out EnemyPathfinder pathfinder))
            {
                pathfinder.OnEnemyReachedPathEnd += OneEnemyReachedEnd;
                // Debug.Log("tracking enemy flight path");
        }
        
        // debugging only
        // string outputLog = WaveStatusToString();
        // Debug.Log(outputLog);

    }

    // when wave spawned or re-spawned, set all the 1s to -1, because enemies are no longer accounted for
    public void WaveSpawned()
    {
        for(int i=0; i<enemyStatuses.Count; i++)
        {
            if (enemyStatuses[i]==1){
                enemyStatuses[i] = -1;
            }
        }

        OnOneWaveSpawned?.Invoke(waveID);
    }
    
    public void OneEnemyDied(){
        int index = 0;
        // since they could only be triggered as die after they've been sent out (aka status = -1), look for the first -1
        while(enemyStatuses[index] != -1)
        {
            index++;
        }
        enemyStatuses[index] = 0; // set dead, aka 0
        numDeadEnemies++;
        numAccountedFor++; // for debug

        // Debug.Log("Event: one enemy died");
        UpdateStatus();
    }

    public void OneEnemyReachedEnd()
    {
        int index = 0;
        // since they could only reach end if they've been sent out (aka status set to -1) look for the first -1
        while(enemyStatuses[index] != -1)
        {
            index++;
        }
        enemyStatuses[index] = 1; // set alive and accounted for, aka 1
        
        numAccountedFor++; // for debug
        // Debug.Log("Event: one enemy reached end of flight path");
        UpdateStatus();
    }

    // called after event that could make Wave either all dead or ready to spawn
    private void UpdateStatus()
    {
        // for debugging
        string outputLog = WaveStatusToString();

        // check if any unnacounted for. If so, no more status check needed
        if (enemyStatuses.Contains(-1))
        {
            // Debug.Log("EnemyStatusTracker updated. At least one -1. " + outputLog);
            return;
        }

        // then check if all dead. If so, broadcast event
        if (numDeadEnemies == numMaxEnemies)
        {
            // allDead = true;
            OnOneWaveDead?.Invoke(waveID);
            // Debug.Log("EnemyStatusTracker updated. All are dead. " + outputLog);
            return;
        }

        // if the other two ifs aren't hitting, and the UpdateStatus has been called, it's because one is accounted for
        // Debug.Log("EnemyStatusTracker updated. All must be 1. " + outputLog);
        OnOneWaveReady?.Invoke(waveID);
    }

    private string WaveStatusToString()
    {
        string outputLog = "numMaxEnemies = " + numMaxEnemies;
        outputLog += ". numDead = " + numDeadEnemies;
        outputLog += ". numAccountedFor = " + numAccountedFor;
        return outputLog;
    }

    // used by EnemySpawner to decide how many to respawn. Array representing how many enemies in this wave are dead vs alive
    public List<int> GetEnemyStatuses()
    {
        return enemyStatuses;
    }

    public int GetWaveID()
    {
        return waveID;
    }

}

And then one bigger tracker, WavesTracker, which subscribes to status-related Events from the OneWaveStatusTrackers. When I finish up, it will broadcast an event notifying when all waves are dead, and then the level will advance.

public class WavesTracker : MonoBehaviour
{
    public event Action<int> OnOneWaveReady;
    public event Action OnAllWavesDead;

    List<int> waveStatuses;

    private void Awake() {
        // initialize variables
        // needed to move this to Awake, or sometimes it didn't hit beforean AddTrackedWave was called by EnemySpawner.
        waveStatuses = new List<int>();
    }
    
    private void Start() {
        // debug
        // Debug.Log("WavesTracker Start() method called, initial list values: " + DebugLogList(waveStatuses));
    }

    public void AddTrackedWave(OneWaveStatusTracker tracker)
    {
        waveStatuses.Add(1);
        tracker.OnOneWaveReady += OneWaveReady;
        tracker.OnOneWaveDead += OneWaveDead;
        tracker.OnOneWaveSpawned += OneWaveSpawned;
    }

    private void OneWaveReady(int i)
    {
        waveStatuses[i] = 1;
        // Debug.Log("WavesTracker OneWaveReady index " + i + ", waveStatuses: " + DebugLogList(waveStatuses));
        
        // Pass the Event up the chain. Could have EnemySpawner do that, but that would be mixing responsibility
        OnOneWaveReady?.Invoke(i);
        
    }

    private void OneWaveDead(int i)
    {
        waveStatuses[i] = 0;
        // Debug.Log("WavesTracker OneWaveDead index " + i + ", waveStatuses: " + DebugLogList(waveStatuses));
        CheckIfAllDead(); // handles the Event broadcast
    }

    // unnecessary for now. Adding it for completeness' sake, and debugging
    private void OneWaveSpawned(int i)
    {
        waveStatuses[i] = -1;
        // Debug.Log("WavesTracker OneWaveSpawned index " + i + ", waveStatuses: " + DebugLogList(waveStatuses));
    }

    // if they're all dead, will Invoke an Event
    private void CheckIfAllDead()
    {
        // Debug.Log("WavesTracker CheckIfAllDead, waveStatuses: " + DebugLogList(waveStatuses));
        for(int i = 0; i < waveStatuses.Count;i++)
        {
           if(waveStatuses[i] == 1 || waveStatuses[i] == -1)
           {
                // Debug.Log("Gets into this break line");
                return; // an enemy is not dead
           }
        }
        // if you reached end of loop, then they are all dead
        OnAllWavesDead?.Invoke();
    }

    // only used for debugging
    public string DebugLogList(List<int> list)
    {
        StringBuilder builder = new StringBuilder();
        builder.Append("[");
        for (int i = 0; i < list.Count; i++)
        {
            builder.Append(list[i]);
            if (i < list.Count - 1)
            {
            builder.Append(", ");
            }
        }
        builder.Append("]");
        return builder.ToString();
    }
}

A few lines needed in other scripts, which I have the OneWaveStatusTracker listening for:

EnemyPathfinder gets - OnEnemyReachedPathEnd?.Invoke();
HasHealth gets - OnEnemyDied?.Invoke(); // trigger the event, which other stuff listens to

With that big change that you pointed out to me, passing an Instance rather than a Prefab, that got me there.

I’ve now moved on to having subsequent “zones” with restricted movement, between which the player advances when all enemies are dead, or gets knocked back to when her health reaches zero. Events are still tripping me up so much more than I expected - they seem so simple!

I’m glad to see that you are still working on your solution. :slight_smile:

Don’t worry if it is still complex. It is common that we start with overly complex code which we simplify when refactoring. We rarely write perfect code at the first onset. This is also true for experienced programmers. Everybody has to refactor at some point.

I’m afraid, there is no common practice apart from ‘the best name is the one reflecting your idea best’. WaveTracker sounds as if it handles one wave only, and WavesTracker sounds as if it handles multiple waves. If that’s what your naming was supposed to convey, your naming is obviously fine.


I’ve skimmed your code, and it seems more structured now, which is a step forward. Your code obviously reflects a concept, whatever the concept might be. (I’m still not sure what your concept is exactly.)

You are right: Your code is still complex. However, ‘complex’ is not necessarily bad. Maybe you need everything. Maybe you don’t. If you want to figure out whether you could simplify things further, I would suggest to revise your concept with pen and paper because the code itself looks fine to me. There is no chaos, and everything seems to have its place. :slight_smile:


One little detail I noticed: You named your Action events after the methods: OnOneWaveReady and OnAllWavesDead. Actions are not methods. In common C# naming conventions, the ‘On’ prefix is reserved for methods, though. If you want to follow the common C# naming conventions, rename the events to OneWaveReady and AllWavesDead. See the example on the official C# website.

One more thing: waveStatuses[i] = 1; might be difficult to interpret if you read this code again in a couple of weeks. Depending on what your idea is/was, an enum could be a good idea to increase the readability. If fact, if you have more than two statuses or statues whose purpose cannot be conveyed by a bool, it is common practice to use an enum. You could even cast the enum value to int if needed.

Apart from these two things, which are a pure matter of personal preference, I do not see any issue with your code. Keep it up! :slight_smile:

Thank you for your valuable feedback! I’ll definitely put the enum suggestion on my next refactoring pass. And I’ll take into account the best practice with On- being reserved for methods. ChatGPT had told me to call Events with On-, but as I’ve come to realize, it can be a GD liar at times.

I’ve just overcome a 8-hr search for a bug who’s root was…Project Script Execution Order! That’s a new one to me. Could I ask you about setting up something like an EventManager, and if 1) it’s an advisable decoupling practice for games (even small size like this), and 2) if it’s normal to have to make sure it’s early in the script execution order, so that everything gets subscribed to it correctly? You don’t really need to see the code for the question, but here below nonetheless. I have other classes calling methods in the EventManager singleton - eg HasHealth calls EventManager.Instance.TriggerPlayerDeath() when health reaches zero.

// singleton for managing Global events, so I can decouple the code a bit more
public class EventManager : MonoBehaviour
{
    public static EventManager instance;

    public static EventManager Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<EventManager>();
                if (instance == null)
                {
                    // creates it automatically
                    GameObject obj = new GameObject("EventManager");
                    instance = obj.AddComponent<EventManager>();
                }
            }
            return instance;
        }
    }

    // singleton
    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    /* 
    ################### All the Events such start here #####################
    */

    public event Action OnPlayerDeath;
    public event Action OnPlayerKnockback;
    public event Action<Collider2D> OnLeavingRoom;

    // HasHealth calls this
    public void TriggerPlayerDeath()
    {
        // reduce score - reminder to implement
        OnPlayerDeath?.Invoke();

        // will trigger another event here in EventManager
        TriggerKnockbackStuff();
    }

    public void TriggerLeavingRoom(Collider2D collider)
    {
        // Debug.Log("OnLeavingRoom event empty? " + (OnLeavingRoom == null));
        OnLeavingRoom?.Invoke(collider);
        Debug.Log("TriggerLeavingRoom entered");
    }

    public void TriggerKnockbackStuff()
    {
        // trigger BoundsController, trigger GetsKnockedBack
        // Debug.Log("OnPlayerKnockback event empty? " + (OnPlayerKnockback == null));
        OnPlayerKnockback?.Invoke();
        Debug.Log("TriggerKnockbackStuff entered");
    }

}

And that’s how you become an expert in a system. :slight_smile:

Before adding anything to Unity’s execution order list, try to solve the problems with what you already have, e.g. Awake and Start. Otherwise, you might end up managing a long list full of dependencies, which comes with a whole new set of problems. From my personal experience, removing dependencies if possible usually makes adding a script to that list unnecessary. In the past few years, I had maybe one or two cases where I needed the list but those were advanced topics with shaders.

If you don’t need anything in the Unity scene, you could, for example, make your EventManager a non-Unity class by removing MonoBehaviour. Then it’ll be a normal C# class where you could use a real singleton implementation. Then you won’t have to rely on Unity’s execution order because the Instance() method either returns an existing object or creates one and returns it. The ‘First version’ on the linked website is usually sufficient.

“Unity” knows your other classes, so it is easy to connect your Unity classes with non-Unity classes and vice versa. In the end, it’s just C#.

@joshuamv, how are you getting on with this?

Tha’s very thoughtful of you to follow up Nina, thanks!

I undid my changes to Project Script Execution Order, and refactored the Event Manager. Still not entirely sure why the old version wasn’t working to be honest, but the new Event System is a bit more flexible and extensible. I looked at the singleton outside Unity link you provided, and decided I’d first try a Unity version, which has worked thus far: I have an Event Manager class I created (well, frankly copied and pasted from someone online), which works as a central correspondent hub where classes drop off and subscribe to Events. I think that makes it more decoupled, if I understand correctly, and I should be able to use that class in future projects. Like you suggested, I’m using some Enums in that class too.

I’ve moved on a couple more steps, and now have a few “rooms” which Godzilla moves through, each with area bounds for movement, and bounded Virtual Cameras. I added a “knockbac” mechanic for when Godzilla (player) health reaches zero.

I’m pretty proud of what I’ve made so far! So I started putting a mini dev-log on my Instagram too, to show off progress along the way. That too has taken some learning - video capture wise - but I’m enjoying it.

I only have a few additional mechanics to implement, and then I’ll spend a few weeks polishing. Hope to be able to publish the game like in the gamedev.tv tutorial!

Question to you @Nina - how useful is the gamedev.tv Unity 3D tutorial for someone who’s gone through the 2D? I do ultimately want to make 3D games in Unity. If I were to use the tutorial, are there lessons I should skip etc? What would you do?

Thanks again for your guidance.

  • Josh

That sounds great, Josh! I’m happy to read that you developed your idea further. Could you please share a link to your instagram account? I’m really curious to see what you achieved because, at the moment, everything is fairly abstract for me as I just know your ideas and a few code snippets but I’ve never seen anything in action. :slight_smile:

You may also share your progress here: Show - GameDev.tv. If you also mention your instagram account there, maybe you’ll get a few more followers.

how useful is the gamedev.tv Unity 3D tutorial for someone who’s gone through the 2D? I do ultimately want to make 3D games in Unity. If I were to use the tutorial, are there lessons I should skip etc? What would you do?

Are you already enrolled in our Unity 3D course (the one for beginners)? If so, maybe jump to a project you are interested in. Since you spent a lot of time with this problem(s) here, it might be that the code taught in the Unity 3D course is ‘too simple’ for you. If none of those project is interesting/relevant for you, I would suggest to follow one of the (free) official Unity tutorials to see how 3D works. It’s very similar to what you learnt in our 2D course.

If you are not enrolled in our Unity 3D course, maybe follow Code Monkey’s free ‘Awesome Third Person Shooter Controller! (Unity Tutorial)’ to see if the 3D stuff makes sense. If you find it graspable, our intermediate courses would be more benefitial for you. Code Monkey also developed a course for GameDev.tv: ‘Unity Turn Based Strategy: Intermediate C# Coding’. Or maybe you are interested in 3D RPGs?

I appreciate your interest, @Nina !

My instagram handle is joshua.veit. I try to upload a progress video once a week or so. I’ll have a look at Show as well.

I am enrolled in Unity 3D for beginners, so once I finish this game I’ll browse through the sections there and see if anything suits me. Awesome Third Person Shooter sounds up my alley - I want to make something with a gameplay loop similar to Outer Wilds, if you know that game. Obviously of much much much smaller scope, but that’s the kind of stuff I want to make in the big leagues so I’d like to at least learn the basic tools in that design genre.

Are you working on any games at the moment?

Here we go: I posted on Show - GameDev.tv.

Thanks for the links! Your game looks fun. :slight_smile:

Unfortunately, I’m not. A couple of weeks a ago, I left an indie developer group after about 4 years of working on a game together. At the moment, I really don’t have any time anymore for various reasons, and I don’t want to be that one person who always promises to deliver “next time” but never delivers anything. It wasn’t an easy decision for me but it was the right one, I think. :confused:

However, when I see other people working on their games, it inspires me not to give up game development completely. If I have time, I definitely want to develop my own little game. :slight_smile:

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms