Day 9: Easy Pong 4 (Coroutines & Simple UI)

Today we look at how to extend our functions beyond the Update loop by allowing them to pause and resume execution using “Coroutines”. We also take our first look at Unity’s UI system, and use these together to create a “Get Ready” state to prepare our players for gameplay. Get Ready!!!


Part 1: Coroutines

In past discussions, I have mentioned that Unity runs through it’s objects and their scripts sequentially. It is a very linear process. When the “awake” message is sent out, Unity goes down the list of objects that it maintains (and that you will not control) and one by one passes it the “awake” message. That message is passed to each one of that object’s components, and if that object has an Awake( ) function it is executed, line by line. Once every object has received the “awake” message, the “start” message is called prior to rendering the next frame, and Unity again goes down the list, object by object, component by component, and any corresponding function will execute.

When a function is called, it must run until it is completes every line (or until it encounters a “return”, which is a type of completion). If another function is called during that original function, then THAT new function must run to completion before moving to the next line in the original, and so forth and so on.

Using this method, it can be difficult or cumbersome to create functions that execute periodically (such as a timed event), or to occur across a span of time and across many frames (such as a fading transition). Creating and maintaining internal timers can be tricky, and it requires a lot of extra calls.

A coroutine is a function that lets us break out of this pattern by allowing us to pause it’s execution, hand control back to Unity, and then resume later and pick back up where it left off after some measure.

In our game, we would like to create a new state to let the player “get ready” to begin a round. A moment between when the ball is reset, and the action begins. For our first co-routine, we are going to make this moment last for 3 seconds.

We create our function by declaring an IEnumerator. This function will wait for three seconds, and then call the StartBall( ) function.

public IEnumerator GetReady()
{
    // tell the engine to come back in three seconds
    yield return new WaitForSeconds(3.0f);
    
    // resumes after the pause
    StartBall();
}

Next we call this by replacing the StartBall( ) function call in InitRound( ) with the StartCoroutine( ) function, passing our coroutine’s call as the parameter.

private void InitRound()
{
    // Reset Ball
    ResetBall();
    // start the coroutine
    StartCoroutine(GetReady());
}

Now when we run this, our ball sits there for three seconds and then begins to move.

Part 2: Basic UI

Three seconds can be a long time, and we don’t want our player to get confused, so first we should build a message to the player letting them know that now is the time to Get Ready!!!

To do this, we create a new text object by going to Game Object > UI > Legacy > Text or Create > UI > Legacy > Text.

Make sure to use the “Legacy” Text objects for this assignment. In a future assignment we will look at TextMeshPro, which is a more advanced (and thus more complicated) system.

When you do this, you will see a few new objects appear in your scene, including a Canvas object, and an EventSystem object. We will cover the UI system more in a future lesson, for now just know that the Canvas is the two-dimensional space where UI elements live, and renders as an overlay for the active camera.

One change you will definitely want to make now is in the Canvas object. Look for the Canvas Scaler component and change the UI Scale Mode to Scale with Screen Size. This ensures that your UI elements will fill the same proportions of the screen whether it is in the Game window or full screen.

Select the text object that you have created and change the name. We used myOverlayTextObject (because we will use this for more than one message). In the Text component, change the Text field (a longform text area, and yes, text inside text is redundant) to read something besides “New Text”. I chose “Hello World”. Set your Alignments to “Center” and “Middle”, and set your Horizontal and Vertical overflow values to Overflow. Finally, adjust the font size until you have something that looks good to you.

Next, go to the EasyPing script, and add a Text object variable to hold a reference to our text object. UI elements have their own Unity library, which we have to call first by adding the following line to the “using” commands at the very top:

using UnityEngine.UI;

Without this line, your calls to get and set text objects will return errors because Unity does not recognize that you will be using them.

Next, create the text object to hold the message text, and use our drag-and-drop method in the Unity editor to assign the text object to this field.

// define text objects
public Text overlayMessage;

Now inside of the Start( ) function, we set our text message to read “Get Ready!!!”, and then set the enabled property to false so that it will not display until we tell it to.

// set the text message to read "Get Ready!!!"
overlayMessage.text = "Get Ready!!!";
overlayMessage.enabled = false;

We want to make our message display while the GetReady coroutine is running, and turn off again when the stage is over and we move into the Playing state. We do this by adjusting the text object’s enabled property like so:

public IEnumerator GetReady()
{
    overlayMessage.enabled = true // turn the message visibility on
    // tell the engine to come back in three seconds
    yield return new WaitForSeconds(3.0f);
    
    overlayMessage.enabled = false  // turn the message visibility off
    // resumes after the pause
    StartBall();
}

Now we should see the message appear for 3 seconds, then disappear as the ball starts moving.

Part 3: Using the Renderer

Three seconds still feels like a long time, doesn’t it? Players might still think our game is broken or stalled. And just when they let their guard down, the ball shoots off!

Let’s help show our players that something is happening, and help them anticipate the beginning of the round. We will do this by making the ball and the message blink.

To achieve this, you might be tempted to the enabled property of the ball object, but don’t do that! Making the ball inactive can destroy the connections, and reactivating it will run the Start( ) command all over again. Instead, we want to manipulate only the visibility of the ball, which we do by enabling and disabling the Mesh Renderer component.

In our EasyPing declarations, we create a new private variable of the type Renderer, like so:

private Renderer ballRender;

… 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()
{
    for (int i=0; i < 3; i++)
        {
            // turn on message, turn off the ball
            overlayMessage.enabled = true;
            ballRender.enabled = false;

            // wait for 1/2 second
            yield return new WaitForSeconds(0.5f);

            // turn on ball, turn off the message
            overlayMessage.enabled = false;
            ballRender.enabled = true;

            // wait for another 1/2 second
            yield return new WaitForSeconds(0.5f);
        }
    StartBall();
}

Now our ready message and ball alternate in quick half-second blinks that let the player get a good sense of the timing that is coming.

With the extra time leftover at the end of class, we also added a “none” state, and changed the initial message to “Press ‘S’ to Start”. We made sure that the Update loop only responded to the S key when in the “menu” state, and from there called InitRound( ).

Now we’ve covered everything that is needed to complete Assignment #2!


EasyPong.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public enum GameState { none, menu, setup, getReady, playing, gameOver};

public class EasyPong : MonoBehaviour
{
    // ball object and components
    public GameObject ballObject;
    private Rigidbody ballRigidbody;
    private Renderer ballRender;

    public Vector3 direction;
    public float force;

    // define my gamestate variable
    public GameState myState = GameState.none;

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

    // ball start position
    private Vector3 ballStartPosition;

    // UI elements
    public Text overlayMessage;

    // Start is called before the first frame update
    void Start()
    {
        // connect to the ball
        ballRigidbody = ballObject.GetComponent<Rigidbody>();

        // access the renderer
        ballRender = ballObject.GetComponent<Renderer>();

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

        // set the ball position
        ballStartPosition = ballObject.transform.position;

        // set the overlay message
        overlayMessage.text = "Press 'S' to Start...";
        overlayMessage.enabled = true;

        // put us in the menu state
        myState = GameState.menu;
    }

    private void Update()
    {
        if (myState == GameState.menu)
        {
            if (Input.GetKeyDown(KeyCode.S))
            {
                // kick off my game
                InitRound();
            }

        }

    }

    private void InitRound()
    {
        Debug.Log("InitRound Called");

        // update the game state
        myState = GameState.setup;


        // Reset the ball position
        ResetBall();

        // coroutine that delays 1 second then starts the ball
        StartCoroutine(GetReady());

    }

    public IEnumerator GetReady()
    {
        // set and activate overlay
        overlayMessage.text = "Get Ready!!!";

        // put us in the getReady state
        myState = GameState.getReady;

        for (int i=0; i < 3; i++)
        {
            // turn on message, turn off the ball
            overlayMessage.enabled = true;
            ballRender.enabled = false;

            // wait for 1/2 second
            yield return new WaitForSeconds(0.5f);

            // turn on ball, turn off the message
            overlayMessage.enabled = false;
            ballRender.enabled = true;

            // wait for another 1/2 second
            yield return new WaitForSeconds(0.5f);
        }

        // resume after the pause
        StartBall();
    }

    private void ResetBall()
    {
        Debug.Log("ResetBall Called");
        // make sure we have control of the ball, not physics.
        ballRigidbody.isKinematic = true;

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

    private void StartBall()
    {
        Debug.Log("Start Ball called");

        // turn kinematics off, let physics control the ball
        ballRigidbody.isKinematic = false;

        // fix the direction vector
        direction.Normalize();

        // push the ball
        ballRigidbody.AddForce((direction * force), ForceMode.VelocityChange);

        // Enter the playing state
        myState = GameState.playing;
    }

    public void RegisterScore(int playerNumber)
    {
        // stop the action
        ballRigidbody.isKinematic = true;

        // process the score
        if (playerNumber == 1)
        {
            playerOneScore++; // increment player one's score
        } else if (playerNumber == 2)
        {
            playerTwoScore++; // increment player two's score
        } else
        {
            Debug.Log("Player Number was not assigned correctly: " + playerNumber);
        }

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

        // stop the gameplay
        myState = GameState.setup;

        // start the next round
        InitRound();
    }

}