Day 9: More Coroutines, UI, and the Renderer

Today we put our final touches on our EasyPong game, adding more “juice” to the interactions in order to make our game more entertaining. To do this, we are going to modify our Coroutine to add more “timing” to the GetReady state, and make adjustments to our messaging so that we can deliver very specific messages at certain times.

Part 1: Coroutine Recap

As we discussed yesterday, Coroutines allow us to create code that can execute outside of the bounds of our usual “do everything before the next frame” pacing. They accomplish this by passing and taking back control of the flow of code in a cooperative manner with the other routines.

This feels similar to “multithreading” – where multiple sets of instructions are run concurrently through the same resources – but that is not the case here. All of our game code runs as a single threaded application, and if we look under the hood we see that our Coroutine is not running concurrently, it is executing part of its code, then yielding control back to the flow for a set interval. Once that interval has passed, control returns to the coroutine and its code resumes execution until it arrives at another yield, or at the end of the function.

Coroutines are especially useful for features that are time-dependent, such as a cooldown, a countdown timer, or having an object perform a periodic action.

Without coroutines, a cooldown timer would look something like this:

private bool shotAvailable = true;
private float cooldownTime = 10f;
private float timeLeft = 0f;

public void Shoot() {
    if (shotAvailable) {
    {    
        ShootTheWeapon(); // some script to actually shoot
        StartCooldown(); 
    }
}

private void StartCooldown() {
{    
    timeLeft = cooldownTime;  // start the cooldown timer by setting timeLeft to the cooldown time
    shotAvailable = false;  // make the shot unavailable. 
}

private void Update()
{
    // is the cooldown active?
    if (!shotAvailable)
    {
        timeLeft -= Time.deltaTime; // subtract the time since the last frame
        if (timeLeft < 0) {shotAvailable = true;}
    }
}

Here we have to set a timer value, and each frame we deduct deltaTime from that time and then test the value to see if our time has expired.

Also, take note of how we enforce the cooldown – we use a boolean named “shotAvailable”, which must be true on Line 6 for the Shoot( ) command to actually execute its contents. Once our cooldown begins, we set the value to false so that no more shots can occur until the cooldown timer has expired.

On Line 22, we test to see if shotAvailable is false using if (!shotAvailable") . The exclamation mark ! is the logical operator “not”, meaning that it will flip true/false values. If shotAvailable is false, !shotAvailable returns as true, and vice-versa.

Using coroutines, we can simplify this process like so:

private bool shotAvailable = true;
private bool cooldownTime = 10f;

public void Shoot() {
    if (shotAvailable) {
    {    
        ShootTheWeapon(); // some script to actually shoot
        StarCoroutine(Cooldown()); 
    }
}

public IEnumerator Cooldown()
{
    // this code runs as soon as Cooldown() is called.
    shotAvailable = false; // make the shot unavailable
    yield return new WaitForSeconds(cooldownTime); // wait for the cooldown time

    // this code will run once the WaitForSeconds yield has concluded
    shotAvailable = true; // make the shot available
}

Another important concept to consider is that Coroutines can have include multiple yield returns, and that code inside of them executes in the same stacked order. Using this, we can create a loop inside of a Coroutine to create a behavior such as a blinking message like so:

    public IEnumerator GetReady()
    {
        for (int i = 0; i < 3; i++)
        {
            // put up the message
            overlayMessage.enabled = true;

            // wait for .5
            yield return new WaitForSeconds(0.5f);

            // turn off the message
            overlayMessage.enabled = false;

            // wait for .5
            yield return new WaitForSeconds(0.5f);
        }

        // start the ball
        StartBall();

    }

Here we replace our 3-second pause with a series of shorter half-second pauses. We use a for-loop to run through a sequence of displaying the message for 0.5 seconds and then turning it of for the next 0.5 message and repeating two more times. This creates a blinking effect that ends with the ball starting in motion.

Part 2: More UI

At this point, our game immediately launches as soon as we hit the Play button, but most games have some form of entry screen that requires us to interact before the game begins. We decided to recreate this process by generating a new menu screen state that waits for a user to press the “S” key in order to start the game.

First, we declare a new state – “TitleMenu” – in our enumerated GameState, and set that as our initial value.

public enum GameState { TitleMenu, SetUp, GetReady, Playing, GameOver };

Next, we listen for this to be pressed using the Update( ) function of EasyPong. We only want to capture this button press when we are in the newly created TitleMenu state, so we use an IF statement to test that value and launch the game:

private void Update()
{
    if (currentState == GameState.TitleMenu)
    {
        if (Input.GetKeyDown(KeyCode.S)) { NewRound(); }
    }
}

This works, but now our “Get Ready!!!” message really doesn’t make sense, and players do not know what to do to start the game. To fix this, we are going to piggyback on the message that we already created and use that text element for ALL messages that appear on the screen. We can overwrite the string that this displays using the text property of the Text class, like so:

// set the title message
overlayMessage.text = "Press \"S\" to Start";

Note the backslash marks \ included here. These are used to “escape” the following character, so that it is read as a value by the editor. If we had simply written "Press "S" to Start" then the compiler would see three elements – "Press ", an undefined variable S, and " to Start". By writing the quotation mark as \" the compiler understands that we are representing the double-quote character, rather than indicating the end of a string. There are also other special characters that use a similar escape, like “tab” \t and “new line” \n.

Once our game has begun, we can use our GetReady( ) method to update the state and the overlay message like this:

    // get ready state
    currentState = GameState.GetReady;

    // update the message
    overlayMessage.text = "Get Ready!!!";

Part 3: The “Renderer”

One final alteration and we will be done – let’s make the ball blink during the GetReady state as well, alternating with the text.

To achieve this, you might be tempted to try to adjust the enabled property of the ball object, but GameObjects do not have an enabled value (anymore), instead they use an Active state that can be changed with the SetActive( ) command. BUT… you really don’t want to do that here either, as changing a GameObject to inactive will disable all components and has other effects such as destroying connections and resetting values.

Instead, we want to manipulate only the visibility of the ball, which we do by enabling and disabling the Mesh Renderer component. In our EasyPong declarations, we can access this by creating a new private variable of the type Renderer, like so:

private Renderer ballRender;

And then in our Start( ) method we assign the Renderer component like this:

 ballRender = ballObject.GetComponent<Renderer>();

Finally, we modify our GetReady( ) script to enable and disable the Renderer component, alternated with our overlay message. We will display one configuration for a half second, and then the other for another half second. The use of a for loop lets us cycle through this three times. Once the loop completes, StartBall( ) is called and our Coroutine ends.

public IEnumerator GetReady()
{ 
    // get ready state
    currentState = GameState.GetReady;

    // update the message
    overlayMessage.text = "Get Ready!!!";

    for (int i = 0; i < 3; i++)
    {
        // put up the message
        overlayMessage.enabled = true;

        // turn the ball off
        ballRender.enabled = false;

        // wait for .5
        yield return new WaitForSeconds(0.5f);

        // turn off the message
        overlayMessage.enabled = false;

        // turn the ball on
        ballRender.enabled = true;

        // wait for .5
        yield return new WaitForSeconds(0.5f);
    }

    // turn off the message
    overlayMessage.enabled = false;

    // turn the ball on
    ballRender.enabled = true;

    // start the ball
    StartBall();
}

EasyPong.cs
using UnityEngine;
using UnityEngine.UI;

public enum GameState { TitleMenu, SetUp, GetReady, Playing, GameOver };

public class EasyPong : MonoBehaviour
{

    // ball object
    public GameObject ballObject;
    private Rigidbody ballRB;
    public Vector3 direction;
    public float force;
    private Vector3 ballStartPosition;
    private Renderer ballRender;

    // define my gamestate variable
    public GameState currentState = GameState.SetUp;

    // score tracking
    private int playerOneScore, playerTwoScore;
    private int MAX_SCORE = 3;

    // UI elements
    public Text overlayMessage;

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        ballRB = ballObject.GetComponent<Rigidbody>(); // assign rigidbody
        ballRender = ballObject.GetComponent<Renderer>(); // assign the renderer
        // get the start position of the ball
        ballStartPosition = ballObject.transform.position;

        // initialize the score
        playerOneScore = 0;
        playerTwoScore = 0;

        // set the gamestate 
        currentState = GameState.TitleMenu;

        // set the title message
        overlayMessage.text = "EasyPong\n\nPress \"S\" to Start";
      
        // start the first round
        // NewRound();
    }

    private void Update()
    {
        if (currentState == GameState.TitleMenu)
        {
            if (Input.GetKeyDown(KeyCode.S)) { NewRound(); }
        }
    }

    private void NewRound()
    {
        // reset the ball position
        ResetBall();

        // get ready to play
        StartCoroutine(GetReady());

    }

    private void ResetBall()
    {
        // make the ball kinematic
        ballRB.isKinematic = true;

        // move the ball
        ballObject.transform.position = ballStartPosition;
    }

    private void StartBall()
    {
        // pick a direction
        // this is where you would randomize the direciton
        direction.Normalize(); // normalize the direction

        // start the ball in motion
        ballRB.isKinematic = false;
        ballRB.AddForce((direction * force), ForceMode.VelocityChange);

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

    public void RegisterScore(int playerNumber)
    {
        // process the score
        if (playerNumber == 1)
        {
            playerOneScore++;
        } 
        else if (playerNumber == 2)
        {
            playerTwoScore++;
        }
        else
        {
            Debug.Log("Player number not assigned correctly: " + playerNumber);
        }

        // show the score
        Debug.Log("The Score is: " + playerOneScore + " - " + playerTwoScore);

        // get out of playing gamestate
        currentState = GameState.SetUp;

        // start a new round
        NewRound();
    }

    public IEnumerator GetReady()
    {
        // tell the manager to wait three seconds, then start the round

        // get ready state
        currentState = GameState.GetReady;

        // update the message
        overlayMessage.text = "Get Ready!!!";

        for (int i = 0; i < 3; i++)
        {
            // put up the message
            overlayMessage.enabled = true;

            // turn the ball off
            ballRender.enabled = false;

            // wait for .5
            yield return new WaitForSeconds(0.5f);

            // turn off the message
            overlayMessage.enabled = false;

            // turn the ball on
            ballRender.enabled = true;

            // wait for .5
            yield return new WaitForSeconds(0.5f);
        }

        // turn off the message
        overlayMessage.enabled = false;

        // turn the ball on
        ballRender.enabled = true;

        // start the ball
        StartBall();

    }
}