Lesson 5 (Parts 1-3): Astral Attackers Week 3

This week we are going to look at improving the quality of our UI, moving between scenes, preserving objects within those moves, and publishing our games. We have a ton of content to get to, so let’s go!

Part 1: Sharper Text (with TextMeshPro)

One thing that you may have noticed when working with the text objects is that they do not scale well. When scaled up to a larger size, the characters in the text often appear to be blurry, with fuzzy edges. This is because Unity uses a “bitmap” texture – an image of the font – to build your text. It chops up the texture into small rectangles and displays those on your screen. Depending on the original size of your font, this jagged effect – an artifact of upscaling the anti-aliased font image – may be quite pronounced.

Thankfully, Unity now offers us an alternative solution that can render crisp text at any resolution. TextMeshPro was once a third-party asset, but was so popular that Unity acquired it and now includes it with the engine. It takes a few more steps to set up, and is implemented in a slightly different manner, but the end results are spectacular.

Left: Unity’s legacy text | Right: TextMeshPro
Both set to a font size of 18

TextMeshPro differs from the legacy Unity text system in that it does not display bitmap textures – instead it uses a technique called a Signed Distance Field (SDF). In simple terms, it uses a low resolution image that looks like a blurry version of the font texture and applies a bunch of crazy graphics math on those images to generate a crisp looking render at any distance. If you would like to read more about the process, check out this SIGGRAPH paper from the folks at Valve who invented the technique.

A closeup of the “font atlas”

What this means for us is that we cannot simply load and assign a font as we can with a Text object. Instead, we need to generate a Font Atlas by importing a font and letting TextMeshPro process it into one of these fancy blurry texture maps.

To begin, launch the font creator by going to Window > TextMeshPro > Font Asset Creator. If this is the first time you have launched TextMeshPro in this project, you will get a menu asking you to Import TMP Essentials. Click this and install. You do not need to load the Examples & Extras.

Importing Fonts

In the Font Asset Creator, assign a Source Font File – this is a font file that you have already loaded into your Assets folder. As a reminder, Unity supports both True-Type Font (.ttf) and Open-Type Font (.otf) formats. Click the Generate Font Atlas button and the creator will generate the font image. Select Save As… to save the font atlas to your asset folder.

Font Asset Creator
A completed font atlas – just 512×512 pixels to store everything for our font!

TextMeshPro will also create a folder in your Asset directory that includes some instructions and documentation if you want to dig deeper into the settings.

TextMeshPro UI

To create a TextMeshPro UI object, select the option from the UI menu, such as Create > UI > TextMeshPro – Text.

For a TextMeshPro object, the Text component is replaced with a TextMeshPro UGUI component with more settings than before. One of the first things you will notice is that you will load a Font Asset instead of a Font. Directly below that is the Material Preset. This will refer to the “Material” settings at the bottom of the panel – the options that allow you to modify the appearance of your TMP object with effects like Outline, Underlay, or Bevel.

What you will notice is that by default, as you create more items they will also share the same Material settings. To create a new instance of the material, right-click the Material panel and select Create Material Preset.

NOTE: Some features like Bevel are only available when the material shader is set to TextMeshPro/Distance Field (Surface Shader). In general this is slightly more computationally expensive, but for our project will not make much of a difference.

TextMeshPro: In-World

If you want to place TextMeshPro Objects in the 3D world, you will use a different type of TMP object. You can make these by selecting Create > 3D Object > Text – TextMeshPro. This will place a TMP objects into the worldspace. Although it has a Rect Transform, it will work the same way as a regular transform in terms of worldspace position, orientation, and scale.

Changing TextMeshPro Text Values

In the past, we used GetComponent<Text>.text to set the value of a text string. TextMeshPro objects have their own component types – one for UI, and one for In-World objects. To access these, you must also call the TMPro library.

using UnityEngine.UI;
using TMPro;
 
...
 
GetComponent<Text>().text = "This is how you set a Unity UI Text value";
 
GetComponent<TextMeshPro>().text = "This is how you set a TMPro In-World text value";
 
GetComponent<TextMeshProUGUI>().text = "This is how youet a TMPro UI value";

Note: There is a big difference between TextMeshProUGUI and TextMeshPro components. One is for UI, the other for in-world. They are not interchangeable.

To update our game UI elements, I simply created new TextMeshPro Text objects for the UI, then changed the assignment type of the public variables in our GameManager from “Text” to “TextMeshProUGUI”. The rest continues to work as expected, by updating the content of the .text property.

Part 2: More Levels (with Scene Manager)

In the early days of gaming, developers would divide a game up into small chunks called “levels”.   While they were useful for players to measure their progress, their real purpose was to minimize the amount of data a game had to hold in memory in order to be playable.   Even today, as games allow us to explore cities, countries, worlds, galaxies… the “level” is commonly used as a way to divide up content.  It would be silly to hold the final boss battle of a game in active memory when a player is hours away from encountering it.   Each draw call would have to consider every polygon in the entire game, every object and behavior would have to be set just so.  In short, it would be chaos.  Computationally expensive chaos.  Why spend all of those cycles considering things that are hours away from use?

So instead we break up our game into smaller chunks and these load into memory when they are needed, hence the “loading” screen that so many games have.  (You may be thinking “but what about sandbox games?”  Well, those are divided into smaller areas as well – and not just areas, even objects inside those areas have various levels of detail that can be called up.  The loading of that content happens in the background, and will swap places with a lower quality model when ready, usually fading between the two so as to disguise the effect and not distract the eye.  Most often this happens in the distance, and the game is using predictive algorithms to determine which content you are most likely to need yet and start to load it.)

When Unity was first created, they also included a level system, which became known as Scenes.  You will recognize this today as the format that we save our files in.  Scenes have evolved to be much much more than just game levels, and are frequently seen used for other cases such as UI screens/menus, code and asset loading, and animated cutscenes.  Sometimes entire games will be saved as scenes.   Projects (the things we open when we launch Unity, and where our Assets and such are stored) can contain multiple scenes, and when we build our game we will designate which of the scenes we create will be included in the build.  (This is particularly useful when you’re working on branches or variations of your game – you can simply swap one out for another if you need to!)

For the demo, I copy our basic scene and create two new “Levels” (named Level01 and Level02) that will serve as our gameplay, and then create a new empty scene (TitleMenu) that will serve as our Menu UI for the game.

Creating the “Start” Button

Unity’s UI buttons are easy to generate by going to Create > UI > Button or in the top menu GameObject > UI > Button.  This will create a new button object as a child of the Canvas object (which will also be created if you don’t already have one).  Buttons consist of two parts, the Button object, and a child Text object.   The Button object has two Components to note – the “Image (Script)” component where you define the look of your button, and the “Button (Script)” component where you can define the behaviors and transitions of the buttons.  Details for how to use this are covered in the next section.

Now for the complicated part – adding an action to your button.  Buttons have an “OnClick()” message that they send when a user clicks them but accessing them is not as simple as accessing collider messages.  At the bottom of the “Button (Script)” component you will see an “OnClick()” panel that is empty.  If you click the “+” button it will create a new action.  This action requires an object to be associated with it in order to access its scripted functions.   You can add a script to the Button object, but then you will have to self-associate the button with itself.   NOTE: The object HAS to be an object currently in the hierarchy.  Linking to a prefab does not work.

In class, I created an empty game object (“ButtonManager”) and added a script (also called “ButtonManager”) which contained a single function that we would use to move to the next scene (“btn_StarTheGame”). In the OnClick( ) menu I associate the script object and select a function from the dropdown on the right by going to the specific component “titlescript” and selecting the function I want to use.

NOTE:  Any function that will be accessed by a Button must be PUBLIC, and VOID.  It can only take up to one (1) parameter, that parameter can only be a Float, Int, String, or Object.

The Scene Manager

Now that we have a button in place, let’s connect some scenes.  I’m going to make my “ButtonManager” script handle our first scene jump.

First, I have to add a new library to the code, so I start the file by adding this line:

using UnityEngine.SceneManagement;

Now we can access the scene management commands.   I can create a button command, so looking at our button example above to make us jump to level 1, I create the following in button script.

    public void btn_StartTheGame()
    {
        SceneManager.LoadScene("Level01");
    }

Again, please note that this must public and void for the editor to see it.  Here, ours takes no arguments.    And the command itself is rather simple – we run the LoadScene ( ) function and pass in the string name of our level.   We can also pass in an integer that corresponds with the scene index.  Speaking of…

Adding Scenes to the Build

In order to load scenes, Unity has to have those scenes associated with the build.  You can do this by going to File > Build Settings, and dragging the scenes you will be using from the Asset panel into the scene list in Build Settings.  Notice that when you add a scene, it also assigns it an indexed location.

Once you have associated the scenes, you can close the window.  (There is no save button to worry about, your changes are automatically registered.)

Level Management

Next I revisit the flow of our game to simplify our process. We no longer need to press a button to start the game. We also need to reconsider the “GameOverWin” state, because now the game exists beyond a single level. Our flow must update to take successful level progression into account. In order to do this, we are going to create a “LevelManager” that will live as an object in each level to handle our scene management and let our game know where to go next. We will make this a Singleton and include it in every level, but it will NOT carry over between levels with us.

Here, we create variables to hold the current level name (we will use this in the UI to let the user know what level they are on) as well as a variable with the name of the next scene (so that we know where to go if the player beats the level). We replace our GameManager’s success state with a call to LevelManager.S.RoundWin( ) and now we move to the next level automatically.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LevelManager : MonoBehaviour
{
    public static LevelManager S;

    public string levelName; // string to display at the level start
    public string nextScene; // string that is the level name

    private void Awake()
    {
        S = this; // singleton assignment
    }

    public void RoundWin()
    {
        SceneManager.LoadScene(nextScene);
    }
}

Part 3 – Staying Alive (DontDestroyMeBro!)

Most aspects of our game can simply disappear between scenes, but our GameManager is pretty important! It holds a lot of critical information such as our score and the number of lives remaining. In other word, the important attributes of our game that transcend the individual level and are of importance to the game. We made sure to include this prefab in both of our scenes, because we cannot play without it… but we also don’t want to destroy it with our scene, or if we can bring it along, overwrite it when the next instance arrives with a new scene.

To accomplish this, we spare GameManager from an untimely deletion by adding the following line to the Start ( ) function in the GameManager script.

 DontDestroyOnLoad (this);

DontDestroyOnLoad ( ) preserves a game object across transitions.  It actually creates a little side scene in the Hierarchy and moves the object into it during runtime, as seen below:

Now when we jump scenes, this object remains, and our score persists as we play through.

Still with us?

But… (you knew there would be one, didn’t you?)

For our game to run correctly, we need to have GameManager running in every scene. This means that either we would ALWAYS have to start our game from the very beginning in the scene where we first instantiate it and play all the way through – not fun, especially when we just want to test one tiny thing in level 5 – OR we have to find a way to include it in EVERY scene. This is why we chose to make our Game Manager a prefab, and drop an instance of it into every level.

You may remember in our previous class, I discussed the “two manager” situation, where a new manager comes onto the floor (into existence in the scene) and checks for a previous instance. If a previous instance exists, it simply destroys itself.

And so, we build in a check in our Game Manager start script that checks to see if any instance of the singleton exists.  If none exists, great – we’re first to the party and we set up shop.  But if there is already a singleton defined, we kill this instance of the manager as it is not needed.  The script for that looks like this:

    private void Awake()
    {
        if (S)
        {
            Destroy(this.gameObject);  
            // Singleton already exists, remove this instance
        } else { 
            S = this;  // Singleton Definition
        }
    }

Here we check for the existence of another version of GameManager.  If none exists, GameManager.S will return “null”.  If not null, someone is already here.  We destroy this new version, and we submit a “return” command, which exits our script rather than continuing on.

Once we’ve passed our null-check, the script is still executing so we know we must be the one true game manager. We assign Singleton status and then in the Start( ) method we declare our intention to live forever.

Bringing the UI

The next issue that we encounter is that our UI references are severed when we leave the scene. Even though there are similarly named elements in the next scene, they are NOT the instances that we had previously associated in our GameManager object. There are solutions for this, we could run a “Find” if the reference were not intact, or have UI Manager associated in each screen (even our Level Manager could do double duty in this regard). But an even easier (lazier) way is to simply make the UI elements a child of our GameManager object. Now when a fresh GameManager enters the scene and finds that another GameManager already exists, it will destroy not only itself, but its child objects as well, taking the detached UI with it. The UI from our earlier level is safely preserved down in DontDestroyOnLoad Land.

Restarting the Level

We’ve now made some major adjustments to our flow, removing much of the state management from the GameManager itself, and letting Unity’s scene system serve the same purpose.

Hmm… What else can we trim here?

Our GameManager is still running the restart of the level. And this has been creating issues because we have different mother ship objects for levels one and two. Rather than building a list of all mothership objects and having our GameManagerscript manage that, let’s remove the need for our GameManagerto re-instantiate anything.

A fantastically convenient (and conveniently lazy) way to reduce this workload, is to take advantage of the natural state of a scene. If we want to reset our scene to its original state after a life is lost, we can simply reload the scene itself. This way the GameManager does not need to be at all aware of what enemy objects should spawn, or what the range and speed of the player should be, or where shields should go! This only works if you’re treating levels as a non-persistent environment, of course, but for classic arcade games that was definitely the case. We accomplish this lazy sorcery within our LevelManager like so:

    public void RestartLevel()
    {
        // reload this scene
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }

Here, we load a scene that happens to be the one we are currently in. How do we know that? Because we use GetActiveScene( ) to return details about the current scene that we are in. After our GameManager passes through its “Oops” state, it deducts a life, tells the LevelManager to go load itself, and voila, instant round reset.

I’m Ready to Go Now

What about when we DO want something to disappear? In the case of our game flow, how do we reset a game if we reach a win or lose state? How to do we go from zero lives remaining back to three, to full health, to an empty inventory? We could write an elaborate script to reset our conditions internally, but the easier method would be to simply let the GameManager die, return to the Title Screen, and let the next GameManager that comes along start the process all over again.

It turns out that the answer to this is quite simple – we Destroy( ) the Game Manager from within itself. DontDestroyOnLoad only preserves objects across transitions – it does not make them immortal.

In this example from our LevelManager, we are returning to the title screen and we remove the GameManager object.

public void ReturnToMainMenu()
{
    Destroy(GameManager.S.gameObject);
    SceneManager.LoadScene("TitleMenu");

}

Building the Executable

At last, the time has come to build our project – to take it from the Unity editor and turn it into a completely independent application that we can play and share.

When we “build” the game, Unity will combine all of our code, all of the assets and packages that we use in the game, and all of it’s underlying systems and combine them into an application (if you are running MacOS) or an executable and a series of support files and folders (if you are running on Windows). Unity is also capable of building to other platforms such as Linux, Android, iOS, PS4, XBox, and HTML5. That’s one of the advantages of working with Unity – the platform specific instructions and abstractions are already built in, making it easy to publish cross platform games.

When you are ready to publish your game, you will want to open the Build Settings panel (File > Build Settings).

The first thing to do is to add your current scene to the build, by clicking the Add Open Scenes button. This will bring your current scene into the build process. Games can have many scenes, but the build process will only include those that are listed in the Scenes in Build panel above. Also note the number to the right – these scenes have an index number defined by order in this process, that can be used to jump to the next or previous scene. We will see this in the next project when we get into Scene Management.

Next, check your Platform Settings to make sure you are building towards the appropriate target. This will be set to the platform you targeted when opening the Editor (the Target setting in Unity Hub). If you change this now, Unity may need to reload the project. For this project, use whatever platform you are currently on – Windows or Mac. While it is possible for one platform to build for the other, we will not get into that in this class.

One final step – open the Player Settings panel by clicking the button in the lower left. This will open up the Project Settings panel to the Player Settings tab. The important items to know about in here are the Company and Product name, the Icon, and the Resolution. By default, applications are set to run as Fullscreen.

Once you are ready to publish your game and your settings are correct, click the Build or Build and Run button. Unity will ask you for a location to save this. If you are building for the Windows platform, you will want to create a new folder to contain the various files.

Then you’ll have to wait for a few moments while Unity does its thing.  Now is the time where your game engine takes all of your code and objects and compiles them into raw game code and zipped binaries of data.  Often this process is short, mostly just compressing files, but if you generating baked lighting and light/reflection probes as part of the build process this may extend the time required to complete this.

The end result will be either an Application (Mac) or an Executable (Windows) .  If you publish for Windows, you will get an“.exe” file and a “Data” folder. (And a few other folders… and a UnityCrashHandler.exe)   You need to include these files and folders as they contain contain relevant components – for instance, “Data” holds all of your assets.   (Apple users don’t have to worry about this, as both their executable and data are both inside the “application” container.)

And that’s it!  Now you have a self contained game.  Congratulations!


LevelManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LevelManager : MonoBehaviour
{
    public static LevelManager S;

    [Header ("Level Info")]
    public string levelName; // string to display at the level start
    public GameObject motherShipObject;

    [Header ("Scene Info")]
    public string nextScene; // string that is the level name
    public bool finalScene; // indicates that this is the end of the game

    private void Awake()
    {
        S = this; // singleton assignment
    }

    private void Start()
    {
        if (GameManager.S) { 
            GameManager.S.ResetRound();
        }
    }

    public void RoundWin()
    {
        SceneManager.LoadScene(nextScene);
    }

    public void RestartLevel()
    {
        // reload this scene
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }

    public void ReturnToMainMenu()
    {
        Destroy(GameManager.S.gameObject);
        SceneManager.LoadScene("TitleMenu");
    }

}


GameManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public enum GameState { menu, getReady, playing, oops, gameOver, roundWin};

public class GameManager : MonoBehaviour
{
    // Singleton Declaration
    public static GameManager S;

    // Game State
    public GameState gameState;

    // UI variables
    public TextMeshProUGUI messageOverlay;

    // Game variables
    private int livesLeft;
    private int livesStart = 3;
    // public Text livesText;
    public TextMeshProUGUI livesText;

    private int score = 0; // default start score
    // public Text scoreText;
    public TextMeshProUGUI scoreText;

    // attacker variables
    // public GameObject motherShipPrefab;
    private GameObject currentMotherShip;
    private bool _checkDestruction = false; // are there objects left?

    // player object
    // public GameObject playerPrefab;
    // private GameObject currentPlayer;

    private void Awake()
    {
        // Singleton Definition
        if (GameManager.S)
        {
            // singleton exists, delete this object
            Destroy(this.gameObject);
        } else
        {
            S = this;
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        DontDestroyOnLoad(this);
        StartANewGame();
    }

    // Update is called once per frame
    void Update()
    {
        if (gameState == GameState.playing)
        {
            if (_checkDestruction)
            {
                if (currentMotherShip.transform.childCount <= 0)
                {
                    // all enemies have been destroyed
                    currentMotherShip.GetComponent<MotherShip>().StopTheAttack();

                    if (LevelManager.S.finalScene)
                    {
                        GameOverWin();
                    } else { 
                        // cue the end of round notice
                        StartCoroutine(LevelComplete());
                    }
                }

                // reset the check
                _checkDestruction = false;
            }
        } else if (gameState == GameState.gameOver)
        {
            if (Input.GetKeyDown(KeyCode.R))
            {
                LevelManager.S.ReturnToMainMenu();
            }
        }
    }

    private void StartANewGame()
    {
        // reset lives
        livesLeft = livesStart; // reset our lives to 3

        // reset score
        score = 0;

        // reset the round
        // ResetRound();
    }

    public void ResetRound()
    {
        // set the ui values
        UpdateScore();
        UpdateLives();

        currentMotherShip = LevelManager.S.motherShipObject; // pass the assignment

        /*
        // reset the enemy
        if (currentMotherShip) { Destroy(currentMotherShip); }
        currentMotherShip = Instantiate(motherShipPrefab);

        
        // reset the player
        if (!currentPlayer) { 
            // only creates if it does not exist
            currentPlayer = Instantiate(playerPrefab); 
        }
        */

        // put game into get ready state
        gameState = GameState.getReady;

        // turn the music back on
        SoundManager.S.ambientSound.Play();

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

    private void StartRound()
    {
        currentMotherShip.GetComponent<MotherShip>().StartTheAttack();

        gameState = GameState.playing;
    }

    public IEnumerator GetReadyState()
    {
        // turn on the message
        messageOverlay.enabled = true;
        // messageOverlay.text = "Get Ready!!!";
        messageOverlay.text = "" + LevelManager.S.levelName + "\nGet Ready!!!";


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

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

        // start the game
        StartRound();
    }

    public void PlayerDestroyed()
    {
        // make the explosion sound
        SoundManager.S.StopAllSounds();
        SoundManager.S.MakePlayerExplosionSound();

        // go to the oops state
        StartCoroutine(OopsState());
    }
    public void GroundReached()
    {
        // go to the oops state
        StartCoroutine(OopsState());
    }
    public void EnemyDestroyed(int points)
    {
        // add to the score
        score = score + points;

        // update the score value
        UpdateScore();

        // set the destruction flag
        _checkDestruction = true;
    }

    private void UpdateScore()
    {
        scoreText.text = score.ToString("000000"); // could also use ("" + score)
    }

    private void UpdateLives()
    {
        livesText.text = livesLeft.ToString();
    }

    public IEnumerator OopsState()
    {
        // set the gamestate to oops
        gameState = GameState.oops;

        // remove a life
        livesLeft--;
        Debug.Log("Lives Left = " + livesLeft);
        UpdateLives();

        // stop the attack
        currentMotherShip.GetComponent<MotherShip>().StopTheAttack();

        // turn on the message
        messageOverlay.enabled = true;
        messageOverlay.text = "Lives Remaining: " + livesLeft;

        // leave the message for 2 seconds
        yield return new WaitForSeconds(2.0f);

        messageOverlay.enabled = false;

        // do we continued?
        if (livesLeft <= 0)
        {
            // game over, man!
            GameOverLose();
        } else
        {
            // start the next round
            // ResetRound();

            LevelManager.S.RestartLevel();
        }
        

    }

    private void GameOverLose()
    {
        // enter the game over state
        gameState = GameState.gameOver;

        // set the message
        messageOverlay.enabled = true;
        messageOverlay.text = "You Lose.\nPress 'R' to Restart";
    }

    private void GameOverWin()
    {
        // enter the game over state
        gameState = GameState.gameOver;

        // set the message
        messageOverlay.enabled = true;
        messageOverlay.text = "You Win!!!\nPress 'R' to Restart";
    }

    public IEnumerator LevelComplete()
    {
        gameState = GameState.roundWin;

        // set the message
        messageOverlay.enabled = true;
        messageOverlay.text = "Round Complete";

        yield return new WaitForSeconds(2.0f);

        LevelManager.S.RoundWin();
    }

}