Day 13: Singleton Game Manager

Today we implemented our game flow using another singleton, the GameManager, to coordinate this journey.

Part 1: Game Flow

Previously, we mapped out our game flow so that we know what states we intend to use, and how we can move between them. We implemented some (but not all) of these steps, and those that remain are your responsibility for this week’s assignment.

  1. We start in an inert state that invites us to “Press S to Start”. This is the Menu state and eventually we will replace it with a separate scene with way better UI.
  2. Pressing S takes us to a new game, where the number of lives are set, and the objects for the first round are prepared. Once ready, we will go into the GetReady state and hold there momentarily.
  3. Upon completion of the GetReady state, we move into Playing,
  4. During Playing, the player will have control of the ship and be able to shoot, and the enemies will begin their attack. There are three possible ways to exit this state.
  5. The first way occurs in case of a win condition – the player destroys all of the enemy ships – and are taken to the GameOver state with a win message.
  6. The other two methods are losing conditions – the player object is destroyed by an enemy, or the enemy ship reaches the ground. In this case, the player loses a life, and the round is reset after an oops state, or the game is over if the player has run out of lives.

The first step to build out this structure is for us to create our GameManager object (again created from an empty gameobject), and create the GameManager.cs script for it.

We declare our variable to hold the Singleton…

public static GameManager game; // set up the singleton

… and we assign it. This time, we add in some extra protection to make sure that a version of this singleton does not already exist. This can happen when we move between scenes, if we were to preserve our GameManager object, we might enter a scene that already has a GameManager inside of it. We will test for the instance and destroy if we find one already in place.

private void Awake()
    {
        if (game)
        {
            // the singleton already exists, destroy it
            Destroy(this.gameObject);
        } else
        {
            game = this;
        }
    }

Next we define a number of enumerated values for our various gamestates:

public enum GameState { None, TitleMenu, Playing, GetReady, Oops, GameOver }

To implement Step 1, we set up a GameState variable in our script and test against it at appropriate times. For instance, pressing “S” would mean one thing during our Menu stage (it should be interpreted as the “start” command) but might mean something different during gameplay, if we are using our Input.GetAxis(“Horizontal”), which relies on pressing WASD keys. And for the assignment, you can do the same sort of check for the GameOver state looking for a user to press the R key to restart.

void Update()
    {
        if (currentState == GameState.TitleMenu)
        {
            // game is in menu state, press S will start the game
            if (Input.GetKeyDown(KeyCode.S)) { StartANewGame(); }
        }
        else if (currentState == GameState.GameOver)
        {
            // press R to restart

        }
    }

We can begin to set up the stages. We created a StartNewGame() function that resets the lives left (and later will also reset the score, which is part of this week’s assignment)

We also create ResetRound( ) and StartRound( ) functions. ResetRound( ) is Step 2, and will place the block of enemies in the starting position. We make our Mothership object a prefab, and remove it from the scene, but add the prefab to a GameObject variable in this script. ResetRound instantiates the prefab and stores it in another GameObject variable that is our “currentMothership”. Before we instantiate, we test to see if one already exists. If this returns true, then we probably have arrived here after a player lost a round. We want to reset the enemy objects, so we destroy whatever already exists.

private void ResetRound()
    {
        // reset the playing field - spawn a new mothership (instantiate)
        if (currentMotherShip) { Destroy(currentMotherShip); }
        currentMotherShip = Instantiate(motherShipPrefab);

        // put the game into the get ready state
        currentState = GameState.GetReady;

        // start the get ready coroutine
        StartCoroutine(GetReady());
    }

Next we transition to the GetReady state, and kicking off a coroutine that will let us pause for 3 seconds, then trigger StartRound( ), which completes Step 3. StartRound( ) moves us to the Playing state, and we remain here until it is time to end a round.

Now that we can get to the playing state, it is time to adjust some of our object behaviors, and make them state dependent.

First, we want to restrict the player movement so that they can only move and shoot while we are in the Playing state. To accomplish this, we add a gamestate check in the Player’s update function. By wrapping the contents with the following “if” statement, we ensure that motion only occurs while in the Playing state.

if (GameManager.game.currentState == GameState.Playing) {
    ....
}

Again, note that there is no need to associate the game manager with any object in our Player script, and no references to be made. “GameManager.game” is available to be called globally.

The next problem is that our enemy object starts moving and shooting during the get ready state. I also don’t like that it continues to move after my player has been destroyed. I want to prevent the possibility of multiple state requests coming in while we are in our Oops state, so I am going to create new scripts in the Enemy object that will be responsible for starting and stopping the coroutines.

Since I am instantiating the Mothership in GameManager, I have access to the object that is the instance in our scene. I add public functions to StartTheAttack( ) [which starts the coroutines previously held in Start( )] and StopTheAttack( ) in the MotherShip.cs script, and then call those from GameManager using:

currentMotherShip.GetComponent<MotherShipScript>().StopTheAttack();

In our StopTheAttack( ) function, we call the StopAllCoroutines( ) function. This will cease any coroutines currently running from the component.

public void StopTheAttack()
{
    StopAllCoroutines();
}

Next, we implemented one of the three conditions that will get us out – specifically the case where an enemy bomb hits the player. We created a PlayerDestroyed( ) function and then call it from a OnCollisionEnter that we add to the Player script.

private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.tag == "EnemyBomb")
        {
            Destroy(this.gameObject);

            // Let the game manager know the player was destroyed
            GameManager.game.PlayerDestroyed();
        }
    }

Now when the Player collides with an EnemyBomb, the PlayerDestroyed( ) script is called on the GameManager. PlayerDestroyed adjusts the music, and then moves us to the Oops state, through a coroutine.

public void PlayerDestroyed()
{
    // stop the music
    SoundManager.S.StopBackgroundMusic();
    
    // make the explosion noise
    SoundManager.S.MakePlayerExplosion();

    // begin the oops state
    StartCoroutine(OopsState());

}

private IEnumerator OopsState()
{
    // go into the oops gamestate
    currentState = GameState.Oops;

    // reduce the lives by 1
    livesRemaining--;

    // tell the mothership to stop the attack
    currentMotherShip.GetComponent<MotherShipScript>().StopTheAttack();

    // pause for 2 seconds
    yield return new WaitForSeconds(2f);

    // decide if we reset or if the game is over
    if (livesRemaining > 0)
    {
        // we live to play another day, reset theround
        ResetRound();
    } else
    {
        // go to the game over state
        currentState = GameState.GameOver;
    }
}

We start the OopsState coroutine by putting the game into the “oops” gamestate, and we the Mothership to stop moving and dropping bombs. Then we yield activity for a few seconds to pause the action. During this time, each object’s Update( ) command will be called on each frame. This is why we move into a different game-state, and have objects test against that state – because the game engine keeps running even when our game flow is taking a rest. We use these variables to help the other objects exhibit the proper behavior.



GameManager.cs
using System.Collections;
using UnityEngine;

public enum GameState { None, TitleMenu, Playing, GetReady, Oops, GameOver }

public class GameManager : MonoBehaviour
{
    // singleton
    public static GameManager game;

    // game variables
    public GameState currentState = GameState.None;

    public int score = 0;
    public int livesRemaining = 0;
    private int LIVES_AT_START = 3;

    // mothership objects
    public GameObject motherShipPrefab;
    public GameObject currentMotherShip;

    private void Awake()
    {
        if (game)
        {
            // the singleton already exists, destroy it
            Destroy(this.gameObject);
        } else
        {
            game = this;
        }
    }

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        // put us into the title manager state
        currentState = GameState.TitleMenu;

        // set the intro message



    }

    // Update is called once per frame
    void Update()
    {
        if (currentState == GameState.TitleMenu)
        {
            // game is in menu state, press S will start the game
            if (Input.GetKeyDown(KeyCode.S)) { StartANewGame(); }
        }
        else if (currentState == GameState.GameOver)
        {
            // press R to restart

        }


    }

    private void StartANewGame()
    {
        // reset the score
        score = 0;

        // reset the lives
        livesRemaining = LIVES_AT_START;

        // reset the round
        ResetRound();

    }

    private void ResetRound()
    {
        Debug.Log("Resetting Round");

        // reset the playing field - spawn a new mothership (instantiate)
        if (currentMotherShip) { Destroy(currentMotherShip); }
        currentMotherShip = Instantiate(motherShipPrefab);


        // put the game into the get ready state
        currentState = GameState.GetReady;

        // start the background music
        SoundManager.S.StartBackgroundMusic();

        // start the get ready coroutine
        StartCoroutine(GetReady());
    }

    private IEnumerator GetReady()
    {
        Debug.Log("Hit the GetReady State");

        // set up a screen message to get ready

        // pause for a few seconds
        yield return new WaitForSeconds(3f);

        // turn off the get ready message

        // start the round
        StartRound();
    }

    private void StartRound()
    {
        // tell the mother ship to start its attack
        currentMotherShip.GetComponent<MotherShipScript>().StartTheAttack();

        // set the gamestate to playing
        currentState = GameState.Playing;
    }

    public void PlayerDestroyed()
    {
        // stop the music
        SoundManager.S.StopBackgroundMusic();
        
        // make the explosion noise
        SoundManager.S.MakePlayerExplosion();

        // begin the oops state
        StartCoroutine(OopsState());

    }

    private IEnumerator OopsState()
    {
        // go into the oops gamestate
        currentState = GameState.Oops;

        // reduce the lives by 1
        livesRemaining--;

        // tell the mothership to stop the attack
        currentMotherShip.GetComponent<MotherShipScript>().StopTheAttack();

        // pause for 2 seconds
        yield return new WaitForSeconds(2f);

        // decide if we reset or if the game is over
        if (livesRemaining > 0)
        {
            // we live to play another day, reset theround
            ResetRound();
        } else
        {
            // go to the game over state
            currentState = GameState.GameOver;
        }

    }
}


PlayerScript.cs
using UnityEngine;

public class PlayerScript : MonoBehaviour
{
    public float speed;
    public float MAX_OFFSET;

    public GameObject bulletPrefab;

    // Update is called once per frame
    void Update()
    {
        if (GameManager.game.currentState == GameState.Playing)
        {
            MovePlayer();

            // fire the bullet
            if (Input.GetKeyDown(KeyCode.Space))
            {
                FireBullet();
            }

        } else if (GameManager.game.currentState == GameState.GetReady)
        {
            MovePlayer();
        }

    }

    private void MovePlayer()
    {
        // move the player object horizontally
        // get location 
        Vector3 currentPosition = transform.position;

        // adjust by using Input.GetAxis
        currentPosition.x = currentPosition.x + (Input.GetAxisRaw("Horizontal") * speed * Time.deltaTime);

        // clamp the values
        currentPosition.x = Mathf.Clamp(currentPosition.x, -MAX_OFFSET, MAX_OFFSET);

        // return the final position
        transform.position = currentPosition;
    }


    private void FireBullet()
    {
        Instantiate(bulletPrefab, transform.position + Vector3.up, Quaternion.identity);
    }


    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.tag == "EnemyBomb")
        {
            // make the player explode
            // make an explosion effect
            Destroy(this.gameObject);

            // Let the game manager know the player was destroyed
            GameManager.game.PlayerDestroyed();
           
        }
    }

}


MotherShipScript.cs
using System.Collections;
using UnityEngine;

public class MotherShipScript : MonoBehaviour
{
    public int stepsToSide;
    public float sideStepUnits;
    public float downStepUnits;
    public float timeBetweenSteps;
    public float timeBetweenBombs;

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {

    }

    public void StartTheAttack()
    {
        // start the attack
        StartCoroutine(MoveMother());
        StartCoroutine(SendABomb());
    }

    public void StopTheAttack()
    {
        StopAllCoroutines();
    }

    public IEnumerator SendABomb()
    {
        bool isRunning = true;


        while (isRunning) 
        {
            // how many children are there?
            int enemyCount = transform.childCount;

            // if there are children....
            if (enemyCount > 0)
            {
           
                // pick one at random  (between 0 and enemyCount)
                int enemyIndex = Random.Range(0, enemyCount);  // remember int Range is maxExclusive

                // get the object
                Transform thisEnemy = transform.GetChild(enemyIndex);

                // make sure it has the EnemyScript component  (just in case)
                EnemyScript enemyInstance = thisEnemy.GetComponent<EnemyScript>();



                // EnemyScript enemyInstance = transform.GetChild(Random.Range(0, enemyCount)).GetComponent<EnemyScript>();


                if (enemyInstance)
                {
                    // drop the bomb
                    enemyInstance.DropABomb();
                }

            }
            else
            {
                // no more children, stop running
                isRunning = false;
            }

            yield return new WaitForSeconds(timeBetweenBombs);

        }
        Debug.Log("SendABomb has finished");
    }

    public IEnumerator MoveMother()
    {
        // define our vectors
        Vector3 sideVector = Vector3.right * sideStepUnits;
        Vector3 downVector = Vector3.down * downStepUnits;

        // make our movement loop while there are enemies left
        while (transform.childCount > 0)
        {
            // move to the side
            for (int i = 0; i < stepsToSide; i++)
            {
                // move to the side
                transform.position += sideVector;

                // wait for the next move
                yield return new WaitForSeconds(timeBetweenSteps);
            }

            // move down
            transform.position += downVector;
            yield return new WaitForSeconds(timeBetweenSteps);

            // Switch Direction
            sideVector *= -1f;

        }

        Debug.Log("Mothership has no children");

        SoundManager.S.StopBackgroundMusic();
    }
}