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: Basic UI

Yesterday, we created our “GetReady” state, where we used a co-routine to hold the ball for three seconds before launching, to give the players a moment to prepare.

Three seconds can be a long time to wait, and we want to make sure our players know the game is actually running, so let’s build a message to the player letting them know they should 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 “GetReady”. 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.

// UI elements
public Text overlayMessage;

Now inside of the Start( ) function, we set the enabled property to false so that it will not display until we tell it to.

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()
    {
        // tell the manager to wait three seconds, then start the round

        // get ready state
        currentState = GameState.GetReady;

        // put up the message
        overlayMessage.enabled = true;

        // wait for 3
        yield return new WaitForSeconds(3.0f);

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

        // start the ball
        StartBall();

    }

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

Part 2: Coroutines

As we discussed yesterday and today, 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 3: 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 4: 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 System.Collections;
using UnityEngine;
using UnityEngine.UI;

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

public class EasyPong : MonoBehaviour
{
    // Ball Object
    public GameObject ballObject;
    private Rigidbody ballRB;
    private Renderer ballRender;

    // Game Details
    public GameState currentState;
    private int playerOneScore, playerTwoScore;
    private int MAX_SCORE = 3;

    // Speed Details
    public Vector3 direction;
    public float force;
    private Vector3 startPosition;

    // UI
    public Text messageOverlay; 

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        // set up the start menu
        messageOverlay.text = "Press \"S\" to Start";

        // make sure the overlay is off
        messageOverlay.enabled = true;

        // get the ball rigidbody
        ballRB = ballObject.GetComponent<Rigidbody>();

        // get the ball mesh renderer
        ballRender = ballObject.GetComponent<Renderer>();

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

        // get ball properties
        startPosition = Vector3.zero;

        // initialize our score
        playerOneScore = 0;
        playerTwoScore = 0;
        
        // get into the start position
        currentState = GameState.TitleMenu;
    }

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

    private void NewRound()
    {
        currentState = GameState.SetUp;

        // reset the ball position
        ResetBall();

        // go to the get ready state
        StartCoroutine(GetReady());
    }

    private IEnumerator GetReady()
    {
        // move into the get ready state, pause for 3 seconds, and then start the ball

        currentState = GameState.GetReady; // setting our currenstate

        // set the message
        messageOverlay.text = "Get Ready!!!";

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

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

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

            // turn off the message (tomorrow)
            messageOverlay.enabled = false;

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

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

        // make sure the ball is on for safety
        ballRender.enabled = true;

        // start the ball
        StartBall();
    }

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

        // put the ball back at the start position
        ballObject.transform.position = startPosition;
    }

    private void StartBall()
    {
        direction.Normalize(); // normalize the direction

        // start the 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 the current gamestate
        currentState = GameState.SetUp;

        // start a new round
        NewRound();
    }
}