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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public IEnumerator GetReady()
{
// tell the engine to come back in three seconds
yield return new WaitForSeconds(3.0f);
// resumes after the pause
StartBall();
}
public IEnumerator GetReady() { // tell the engine to come back in three seconds yield return new WaitForSeconds(3.0f); // resumes after the pause StartBall(); }
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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
private void InitRound()
{
// Reset Ball
ResetBall();
// start the coroutine
StartCoroutine(GetReady());
}
private void InitRound() { // Reset Ball ResetBall(); // start the coroutine StartCoroutine(GetReady()); }
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
using UnityEngine.UI;
using UnityEngine.UI;
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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// define text objects
public Text overlayMessage;
// define text objects public Text overlayMessage;
// 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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// set the text message to read "Get Ready!!!"
overlayMessage.text = "Get Ready!!!";
overlayMessage.enabled = false;
// set the text message to read "Get Ready!!!" overlayMessage.text = "Get Ready!!!"; overlayMessage.enabled = false;
// 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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
}
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(); }
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
private Renderer ballRender;
private Renderer ballRender;
private Renderer ballRender;

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

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
ballRender = ballObject.GetComponent<Renderer>();
ballRender = ballObject.GetComponent<Renderer>();
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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
}
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(); }
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
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
}
}
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(); } }
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();
    }

}