Lesson 6: Astral Attackers 3

This week, we take the pieces of a game and assemble them into a full game, complete with a fully compiled executable. We will examine Unity’s Scene Management system, take a deeper dive into the User Interface (UI) by setting up a menu with custom buttons, get hands-on with the Build process, and finally look at how we can manipulate color and materials programmatically to create cool visual effects. Let’s go!

Part 1: Scene Management

1A: Introduction to the SceneManager

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 (Menu) that will serve as our Title 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 (“MenuScriptObject”) and added a script (also called “MenuScript”) which contained a single function that we would use to move to the next scene (“btn_StartTheGame”). 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.

The Scene Manager

Now that we have a button in place, let’s connect some scenes.  I’m going to make my “MenuScript” 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.)

1B: Building a “Level Manager”

Our next step is to create a “LevelManager” script that will be included on one object within each level. This script will hold our SceneManagement calls, and each level’s instance will hold information about the level itself, and where to go next. We will make this a script a singleton to provide easy access for other objects (like our GameManager), but there will only ever be one of these objects in a level – it will appear and disappear with the scene itself – so we do not need to worry about testing for a pre-existing version of the manager.

We create variables to hold the current level name (to be used by the level’s UI when we first enter the scene) and a variable that holds the name of the next scene (so that our script knows where to go next once the player beats the level). We replace our GameManager’s success state with a call to LevelManager.S.GoToNextRound( ) 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 level start
    public string nextScene; // name of the scene we move to if successful

    private void Awake()
    {
        S = this; // singleton definition
    }
    public void GoToNextRound()
    {
        SceneManager.LoadScene(nextScene);
    }
}

Once we have completed our script, we place LevelManager objects in both of our scenes and test the results. In our Level02 scene, we set our “next scene” to “Menu”, which will return us to the main screen.

1C: Don’t Destroy on Load

Now that we are using the LevelManager to progress to the next level when the player wins, can we also use it to manage the process when the player loses? Yes, but it comes with some new complications that require us to rethink our GameManager and game flow logic.

Restarting the Level

In the past lesson, we spent a lot of time having our GameManager reset the level to the same condition as when we first entered the scene. A fantastically convenient (and conveniently lazy) way to eliminate these steps is to 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, where all objects completely respawn to the original state, but for classic arcade games this 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 load itself, and voila… instant round reset!

Staying Alive (aka Don’t Destroy Me Bro…)

The reset worked wonderfully, but we have created a new issue – our GameManager was also destroyed with our scene.

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 a “but”, 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. To address this, we make our Game Manager into a prefab, and drop an instance of this prefab 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 move frome one scene to the next. Even though they are similarly named, these UI elements are NOT the same elements that our GameManager is familiar with. Those were destroyed with the rest of the scene.

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.

1D: Updating our Game Flow

Now that we have our scene resetting itself and our LevelManager moving us between levels, it is time to update our flow diagram so that we can have LevelManager and GameManager communicate more effectively.

If a pre-existing GameManager enters our scene, it simply continues to exist – the Start( ) command will not be run on this again, and it is unaware that a new scene has begun. Our LevelManager instance, on the other hand, will be brand new with each scene load and so we can use LevelManager.Start( ) to trigger the mechanism that moves us into the pre-rounds state.

    private void Start()
    {
        GameManager.S.LevelStarted();
    }

In our GameManager, we set up a corresponding function to handle the call to start the level. To further complicate things, I want to announce the level name when we first arrive, but not if we were killed and respawning – in that case we should just proceed to gameplay. To manage this, we create a string variable “currentLevel” that will hold the name of the level the game manager thinks that it is on. Each time LevelStarted( ) is called, it will compare values – if they do not match, this is a new level, and a coroutine will be run that will show the level’s name for a few seconds before progressing to ResetRound( ). If they do match, this is not our first time through and we progress directly to ResetRound( );

public void LevelStarted()
{
    // level manager tells me what level we are on
    string thisLevel = LevelManager.S.levelName;

    if (currentLevel != thisLevel)
    {
        // update the current level value
        currentLevel = thisLevel;
        // this is a new level, run first time level co-routine
        StartCoroutine(ShowLevelName());
    } else
    {
        ResetRound();
    }
}

Destroying that which Does Not Destroy

One final issue to address with our game flow is “what happens to the GameObject when we DO want to destroy it?”

If we complete our game and return to the “Menu” scene, you will notice that the GameManager is still alive with the UI in the DontDestroyOnLoad scene. Thankfully this is easy to resolve – we simply destroy the object manually. In this case we will use our MenuScript to detect the rogue manager and delete it.

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

Part 2: UI Systems

Rather than go step-by-step through the workshop, here I present a reference for the UI system as this is applicable for most projects.

UI: Canvas

When you create your first UI element, Unity will automatically create a Canvas object inside of your scene, along with the “EventSystem” object that you can ignore for now.

The Canvas is the two-dimensional object that holds your user interface objects. Text, buttons, images, sliders, dropdowns… all of these UI type objects need to be contained inside of a Canvas object. All objects on the canvas are placed using the Rect Transform, a method of rectangular transformation for 2D objects. A Rect Transform defines a rectangle of pixel dimensions W (width) and H (height), and the offsets X and Y measuring the distance the pivot of the object should be placed from the “anchor”. The anchor is a method of defining how it should be offset from it’s parent object, and can be placed at a normalized value horizontally or vertically. The Anchor presets give you options for placement from the Top, Middle, or Bottom vertically, or Left, Center, or Right horizontally. These presets work similarly to text alignment in a word document or spreadsheet cell.

There are two settings inside the Canvas object which are particularly important for setting up your UI. The first is the Render Mode inside the Canvas component. There are three modes available:

  • “Screen Space – Overlay” is the default setting and will be your most common use case. This setting causes the canvas objects to be drawn over top of your content on the screen. The canvas will cover the entire area of your screen, and resize if your window resizes. This is similar to the concept of “compositing” video by adding graphics overtop of a video feed. The two do not interact visually at all.
  • “ScreenSpace – Camera” works similar to the Overlay method, however it sets the screen out at a distance from the camera, and the camera’s perspective will apply, as opposed to the overlay’s planar projection style.
  • “World Space” creates a canvas object in the scene, as opposed to an overlay. This object is a rectangle but has a transform (and thus a position and orientation) within the environment itself. This is what is commonly referred to as a “diagetic” interface, in that it exists within the world itself, rather than just from the perspective of the player/observer.

The Unity Manual has more descriptions and examples of these modes.

The Canvas Scaler controls the way in which objects will respond to different window sizes. You may have noticed that often that the size and placement of UI objects in your game window do not match their size and placement when you run the game at full screen. This is because the Canvas Scaler is set by default to Constant Pixel Size. In this mode, objects retain their width and height values, unaffected by the larger screen. I recommend using the second option, Scale With Screen which will scale the size and positioning of UI objects up and down with the game screen size, thus preserving your layout as it appears in your editor.

UI: Panels

If you want to group your buttons into a more formal menu, you can use the Panel object. This simply creates a UI Image object, but one that has a background that mimics the button background, only more transparent. By default, the Panel will fill the screen, but by changing the Rect Transform you can resize it and make objects children of this object.

Panels are an easy way to collect buttons and other UI elements into one place, and can be activated and deactivated. This can be useful for making menus that slide up or over, or populating a tabbed menu. Panels are also useful for gathering UI elements into one location, and for providing contrast for the UI elements against the game view itself.

Unity’s default panel uses the rounded rectangle UI Sprite to soften it’s edges. If you would prefer to use a solid color, just set the Source Image setting to “None”.

UI: Buttons

Creating Buttons

To create a button, select Create > UI > Button. This will generate a simple grey button with black text on your screen, and place it in your Canvas. (If you have not created a Canvas yet, Unity will generate one for you.)

You can use the Rect Transform to position the object. Clicking the Anchor diagram will call up the Anchor Presets option. Note the modifiers listed at the top for the Shift / Alt keys. Pressing “Shift-Alt” and clicking the center object will move your button to the middle of the screen.

The text of your button is actually a separate text object – to change the text, find the button object in your hierarchy, and expand it to see the child objects (in this case a “Text” object) and edit the value in the Text field.

Button Actions

To assign a function to a button, you must first have an object in your scene that has a script with a public function. In this case, we are accessing the “UIScript” class that has been attached to the “UIManager” object and lives in the scene.

With the button selected, look at the “Button” component and find the On Click ( ) panel a the bottom.

Click the “+” symbol to add a new On Click action. You’ll see an empty Object field.

Drag the object that with the script or component that contains the method you want to trigger with this button into the Object field.

Next, click the dropdown (currently showing “No Function”) and select the script or component, and then the function you want to run. Now the button press will call that function when clicked. Button Actions can also have up to one parameter.

Button Sounds

We can use the same On Click ( ) panel to emit a sound when a button is pressed. We created an empty object and added an audio clip to it, which generated an AudioSource component.

In the OnClick( ) section of our button we add another action, this time adding our sound object, then setting the function dropdown to AudioSource > Play( )

AudioSource is exposed because it is a component of the object, and the Play( ) command is available publicly. Another handy tool is the AudioSource > PlayOneShot( ) method, which lets us play a single pass of an audioClip. Selecting this opens another field of the AudioClip type, and we can drag that directly from our Assets folder into the field, and now it will make that noise when clicked.

Button Sprites (Color & Image)

One way to change the visual appearance of your game is to edit the color of the buttons to move away from the default light gray button that Unity provides. In the Button’s Image component, you can set the Color value to customize the appearance.

The Button component gives you access to the states of the button so that you can provide feedback for selections, presses, and active vs. inactive states. The default Button object uses Color Tint as the method for changing these states, which provides a subtle adjustment to the overall brightness of the button image (but not the text object)

Another way to customize this is to create your own custom button states using sprites. If you ever created your own buttons to build a website, this process will feel familiar – you generate similar images for each and then swap their placement when in the various states. We will look more at this in the next project.

Button Navigation

You may have noticed that when we have a button selected, we can use the arrow keys to cycle through the selected option. This is thanks to the Navigation system. By default, Navigation is set to “Automatic”, which means that Unity will try to figure out the logic for itself. Sometimes this logic does not make a great deal of sense, so if you want to see what is going on, click the Visualize button and you will see a flow diagram appear between your UI elements.

The yellow arrows indicate where the selected state will transition when a horizontal or vertical button is pressed. If you want to set these yourself rather than rely on Unity’s automatic setting, open the dropdown and deactivate the items so that only “Explicit” is selected, then you can define the object by dragging it into each directional slot.

Part 3: 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, BUT you should still zip your .app files as they are really just folder structures, and when you post an uncompressed .app to a cloud service like Box it never quite comes back out the way it went in.)

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

Part 4: Controlling Color with MeshRenderer

White boxes falling from the sky is a beautiful site, but it would look even better if the debris appeared to change color. In this section we are going to look at how to access the “color” property of an object programmatically using the Mesh Renderer component.

Every 3D mesh object in our scene has a Mesh Renderer component that includes properties about how this will be visualized. Included in this is the Materials channel that holds the material to be applied to this mesh. (Actually, materials… meshes can have multiple materials applied to the same object)

To change the color of a material (the “Albedo” color, in our standard unity material), we can use the Material.SetColor( ) function (This method takes two parameters, a string containing the name of the channel we wish to set, usually “_Color”, and a Color value), or we can simply set the Renderer.material.color property, which is the method we use here.

The Color type is similar in structure to a Vector3, but instead of (x, y, z) values, it holds (r, g, b, a) values, each one a float corresponding to the Red, Green, Blue and Alpha (transparency) channels. (If you’ve never heard of rgb values to define colors, check out the Wikipedia entry on the RGB color model.)

Also like the Vector3 format, the Color type has some preset values, like Color.red or Color.black. You can read more about these and the methods of the Color type in the Unity script API. We can also declare a public Color variable and our editor will provide us with a color swatch and color selection menu.

To demonstrate methods of changing color, we created a script that can be called three different ways. This process of defining multiple functions with the same name but different implementations is called overloading.

The method SetThisObjectColor( ) contains three different implementations, which you can see by the parameters it accepts. If I call

  • SetThisObjectColor(Color thisColor) will set the color of the object it is currently on by accessing the Mesh Renderer and setting the material color.
  • SetThisObjectColor(Renderer thisRenderer, Color thisColor) takes a Mesh Renderer instance as an argument and will set THAT object’s renderer.material.color to the chosen color.
  • SetThisObjectColor(Renderer[] theseRenderers, Color thisColor) takes an array of Renderers as an argument and will set ALL of their material.color values to our chosen color. (This is especially useful for our enemy objects which are made up of many smaller objects)
public class ColorEffectScript : MonoBehaviour
{
    public bool setStartColor = false;
    public Color startColor = Color.white;

    // Start is called before the first frame update
    void Start()
    {
        if(setStartColor) { SetThisObjectColor(startColor); };
    }

    public void SetThisObjectColor(Color thisColor)
    {
        Renderer renderer = GetComponent<Renderer>();
        renderer.material.color = thisColor;
    }

    public void SetThisObjectColor(Renderer thisRenderer, Color thisColor)
    {
        thisRenderer.material.color = thisColor;
    }

    public void SetThisObjectColor(Renderer[] theseRenderers, Color thisColor)
    {
        foreach (Renderer renderer in theseRenderers)
        {
            renderer.material.color = thisColor;
        }
    }
}

When we apply this method to an object, we can have other components on that object utilize it to change values like this transition script from ColorDemo3Script:

    void Start()
    {
        thisEffectScript = GetComponent<ColorEffectScript>();
        childRenders = GetComponentsInChildren<Renderer>();

        thisEffectScript.SetThisObjectColor(childRenders, firstColor);

        // StartCoroutine(TransitionColor(firstColor, secondColor, transitionTime));
    }

    // Update is called once per frame
    void Update()
    {
        float currentLerpValue = Mathf.PingPong(Time.time, transitionTime) / transitionTime;
        Color newColor = Color.Lerp(firstColor, secondColor, currentLerpValue);
        thisEffectScript.SetThisObjectColor(childRenders, newColor);
    }

Here we start by accessing our ColorEffectScript and retrieving all Renderer components from this objects children and placing them into an array. Then during the Update( ) loop we transition all children’s colors back and forth between our two values using a Color.Lerp( ) as well as MathF.PingPong( ) which increases and decreases a result between two values.

We also looked at how we can use [RequireComponent(typeof(ColorEffectScript))] at the beginning of our file to ensure that the ColorEffectScript is attached to this object.

Part 5: Lerping Materials

Another method to interpolate between our mesh object’s appearance is to modify the material itself. In the final section we create a “fade” effect by transitioning between two material values – an opaque yellow metallic material, and a black, mostly translucent material. (This transluceny effect is achieved by setting our material’s Render Mode to Fade – this allows our material to use the “alpha” channel to create a partially or fully clear object. (note: both materials must have the same Render Mode for this transition to work properly)

To achieve this, we set up two materials and create a “DebrisScript” that will fade whatever object it is attached to over a fixed duration, which we manage with a Coroutine. Here we set up a timer, adjusting the transition from start to finish as a percentage based upon the time elapsed over duration. We pause at the end of our loop until the just next frame using WaitForEndOfFrame( ).

public IEnumerator FadeThisDebris()
{
    float currentTime = 0f;
    Renderer renderer = GetComponent<Renderer>();
    renderer.material = startMaterial;

    while (currentTime < duration)
    {
        // get lerp material values
        float lerpValue = currentTime / duration;

        // set the material to blend
        renderer.material.Lerp(startMaterial, endMaterial, lerpValue);

        // update current time
        currentTime += Time.deltaTime;

        // pause until next frame
        yield return new WaitForEndOfFrame();

    }

    // time has expired
    Destroy(this.gameObject);

}

Rather than have to worry about applying this script to every object in advance, we can add this component during runtime with GameObject.AddComponent<>( ). If we look at our EnemyScript, we can identify the spot where we apply our explosive force to each rigidbody object, and then simply apply this new script to the gameObject that rigidbody is attached to, like so:

    foreach (Rigidbody block in enemyBlocks)
    {
        // adding the explosive force
        block.AddExplosionForce(explosionForce, (transform.position + Vector3.back * 2f), explosionRadius, 0f, ForceMode.Impulse);

        // randomize the fade time
        float thisFadeTime = Random.Range(0.1f, maxFadeTime);

        // set up the material fading process
        GameObject thisObject = block.gameObject;
        DebrisScript thisScript = thisObject.AddComponent<DebrisScript>();
        thisScript.StartFade(startMaterial, endMaterial, thisFadeTime);

    }

And that’s it! Now we have objects that fade over a duration within a random range, so that each appears to behave slightly different than the ones around it.


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

public class MenuScript : MonoBehaviour
{

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

    public void btn_StartTheGame()
    {
        Debug.Log("Game Launching");
        SceneManager.LoadScene("Level01");
    }

    public void btn_GoToMenu()
    {
        SceneManager.LoadScene("Menu");
    }

    public void btn_GoToScene(string thisScene)
    {
        SceneManager.LoadScene(thisScene);
    }

    public void QuitTheGame()
    {
        Application.Quit();
    }


}


LevelManager.cs
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 level start
    public string nextScene; // name of the scene we move to if successful

    public GameObject levelMotherShipObject;

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

    private void Start()
    {
        GameManager.S.LevelStarted();
    }

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

    public void ReloadLevel()
    {
        // reload the same scene we are currently in
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }

}


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

public enum GameState { Menu, PreRound, Playing, PostRound, GameOver};

public class GameManager : MonoBehaviour
{
    public static GameManager S; // define the singleton

    public TextMeshProUGUI messageOverlayObject;
    public TextMeshProUGUI scoreText;
    public TextMeshProUGUI livesText;
    public GameState currentState;

    public int score = 0;
    private int livesRemaining;
    private int MAX_START_LIVES = 3;

    // public GameObject motherShipPrefab;
    private GameObject currentMotherShip;

    private string currentLevel;


    private void Awake()
    {
        if (GameManager.S)
        {
            // the game manager already exists, destroy myself
            Destroy(this.gameObject);
        } else
        {
            S = this; 
        }
    }

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


    // Update is called once per frame
    void Update()
    {



    }


    private void StartANewGame()
    {
        //reset our score
        score = 0;

        // reset our lives
        livesRemaining = MAX_START_LIVES;

        // start a new round
        
    }

    public void LevelStarted()
    {
        // level manager tells me what level we are on
        string thisLevel = LevelManager.S.levelName;

        if (currentLevel != thisLevel)
        {

            // update the current level value
            currentLevel = thisLevel;
            // this is a new level, run first time level co-routine
            StartCoroutine(ShowLevelName());
        } else
        {
            ResetRound();
        }



    }

    private IEnumerator ShowLevelName()
    {
        messageOverlayObject.text = currentLevel;
        yield return new WaitForSeconds(3.0f);

        ResetRound();
    }

    private void ResetRound()
    {
        // does current mothership exist?
        if (currentMotherShip)
        {
            // if so, get rid of it
            Destroy(currentMotherShip);
        }

        // get the mothership object that level manager is holding for me.
        currentMotherShip = LevelManager.S.levelMotherShipObject;

        // run the Get Ready coroutine
        StartCoroutine(GetReady());

    }

    private IEnumerator GetReady()
    {
        messageOverlayObject.enabled = true;
        messageOverlayObject.text = "Get Ready!!!";

        yield return new WaitForSeconds(3.0f);


        messageOverlayObject.enabled = false;

        StartRound();
    }

    private void StartRound()
    {
        // start the music
        SoundManager.S.StartTheMusic();

        // start the attack
        currentMotherShip.GetComponent<MotherShipScript>().StartTheAttack();

        // set our current state to playing
        currentState = GameState.Playing;

    }

    public void PlayerObjectDestroyed()
    {
        currentState = GameState.PostRound;

        // this is a tragedy!  stop the enemies
        currentMotherShip.GetComponent<MotherShipScript>().StopTheAttack();


        // stop all sounds and play the player explosion sound
        SoundManager.S.PlayerExplosionSequence();

        // process my lives remaining
        livesRemaining--;
        UpdateUI();


        if (livesRemaining > 0)
        {
            StartCoroutine(OopsState());
        } 
        // else the game is over and you lost

    }

    private IEnumerator OopsState()
    {
        messageOverlayObject.text = "You Died!!!";
        messageOverlayObject.enabled = true;

        yield return new WaitForSeconds(3.0f);

        LevelManager.S.ReloadLevel();
    }

    private IEnumerator WinState()
    {
        messageOverlayObject.text = "Round Complete";
        messageOverlayObject.enabled = true;

        yield return new WaitForSeconds(3.0f);

        /*
        messageOverlayObject.text = "Replay?";
        messageOverlayObject.enabled = true;
        */

        LevelManager.S.GoToNextRound();
    }

    public void AllEnemiesDestroyed()
    {
        Debug.Log("no more enemies");
        currentMotherShip.GetComponent<MotherShipScript>().StopTheAttack();
        StartCoroutine(WinState());
    }

    public void AddScore(int thisValue)
    {
        score += thisValue;
        UpdateUI();
    }

    private void UpdateUI()
    {
        scoreText.text = "Score: " + score;
        livesText.text = "Lives: " + livesRemaining;
    }


}


ColorEffectScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ColorEffectScript : MonoBehaviour
{
    public bool setStartColor = false;
    public Color startColor = Color.white;

    // Start is called before the first frame update
    void Start()
    {
        if(setStartColor) { SetThisObjectColor(startColor); };
    }

    public void SetThisObjectColor(Color thisColor)
    {
        Renderer renderer = GetComponent<Renderer>();
        renderer.material.color = thisColor;
    }

    public void SetThisObjectColor(Renderer thisRenderer, Color thisColor)
    {
        thisRenderer.material.color = thisColor;
    }

    public void SetThisObjectColor(Renderer[] theseRenderers, Color thisColor)
    {
        foreach (Renderer renderer in theseRenderers)
        {
            renderer.material.color = thisColor;
        }
    }

    
}


ColorDemo3Script.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(ColorEffectScript))]

public class ColorDemo3Script : MonoBehaviour
{
    private ColorEffectScript thisEffectScript;
    private Renderer[] childRenders;

    public Color firstColor = Color.white;
    public Color secondColor = Color.white;

    public float transitionTime = 1.0f;

    // Start is called before the first frame update
    void Start()
    {
        thisEffectScript = GetComponent<ColorEffectScript>();
        childRenders = GetComponentsInChildren<Renderer>();

        thisEffectScript.SetThisObjectColor(childRenders, firstColor);

        // StartCoroutine(TransitionColor(firstColor, secondColor, transitionTime));
    }

    // Update is called once per frame
    void Update()
    {
        float currentLerpValue = Mathf.PingPong(Time.time, transitionTime) / transitionTime;
        Color newColor = Color.Lerp(firstColor, secondColor, currentLerpValue);
        thisEffectScript.SetThisObjectColor(childRenders, newColor);
    }

    private IEnumerator TransitionColor(Color startColor, Color endColor, float duration)
    {
        float currentTime = 0f;



        bool timeExpired = false;

        while (currentTime/duration < 1.0f) {

            Color newColor = Color.Lerp(startColor, endColor, currentTime / duration);
            thisEffectScript.SetThisObjectColor(childRenders, newColor);
            currentTime += Time.deltaTime;
            yield return new WaitForEndOfFrame();
        }

        thisEffectScript.SetThisObjectColor(childRenders, endColor);



    }

}


DebrisScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DebrisScript : MonoBehaviour
{
    public Material startMaterial, endMaterial;
    public float duration;

    public void StartFade(Material startMat, Material endMat, float fadeTime)
    {
        startMaterial = startMat;
        endMaterial = endMat;
        duration = fadeTime;

        // start the coroutine
        StartCoroutine(FadeThisDebris());

    }

    public IEnumerator FadeThisDebris()
    {
        float currentTime = 0f;
        Renderer renderer = GetComponent<Renderer>();

        renderer.material = startMaterial;

        while (currentTime < duration)
        {
            // get lerp material values
            float lerpValue = currentTime / duration;

            // set the material to blend
            renderer.material.Lerp(startMaterial, endMaterial, lerpValue);

            // update current time
            currentTime += Time.deltaTime;

            // pause until next frame
            yield return new WaitForEndOfFrame();

        }

        // time has expired
        Destroy(this.gameObject);

    }


}


EnemyScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyScript : MonoBehaviour
{
    public GameObject enemyBombPrefab;

    public GameObject enemyFrameOne, enemyFrameTwo, enemyExplode;

    public int points = 100;

    [HeaderAttribute("Explosion Parameters")]
    public float explosionForce;
    public float explosionRadius;

    [HeaderAttribute("Material Parameters")]
    public Material startMaterial;
    public Material endMaterial;
    public float maxFadeTime = 1f;


    // Start is called before the first frame update
    void Start()
    {
        enemyFrameOne.SetActive(true);
        enemyFrameTwo.SetActive(false);
        enemyExplode.SetActive(false);




    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.B))
        {
            DropABomb();
        }

        if (Input.GetKeyDown(KeyCode.S))
        {
            SwapFrames();
        }

        if (Input.GetKeyDown(KeyCode.E))
        {
            EnemyExplode();
        }

    }


    private void EnemyExplode()
    {
        // turn off the other objects and turn on the explosion frame
        enemyFrameOne.SetActive(false);
        enemyFrameTwo.SetActive(false);
        enemyExplode.SetActive(true);

        // Make the enemy explosion noise
        SoundManager.S.MakeEnemyExplosionSound();
        

        // get all of the rigidbodies
        Rigidbody[] enemyBlocks = GetComponentsInChildren<Rigidbody>();

        foreach (Rigidbody block in enemyBlocks)
        {
            // adding the explosive force
            block.AddExplosionForce(explosionForce, (transform.position + Vector3.back * 2f), explosionRadius, 0f, ForceMode.Impulse);

            // randomize the fade time
            float thisFadeTime = Random.Range(0.1f, maxFadeTime);

            // set up the material fading process
            GameObject thisObject = block.gameObject;
            DebrisScript thisScript = thisObject.AddComponent<DebrisScript>();
            thisScript.StartFade(startMaterial, endMaterial, thisFadeTime);

        }
        // orphan the explosion object
        enemyExplode.transform.parent = null;
    }

    private void SwapFrames()
    {
        enemyFrameOne.SetActive(!enemyFrameOne.activeSelf);
        enemyFrameTwo.SetActive(!enemyFrameTwo.activeSelf);
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.tag == "PlayerBullet")
        {
            // make everything explode
            EnemyExplode();
            GameManager.S.AddScore(points);

            // destroy this enemy object
            Destroy(this.gameObject);

            // destroy the bullet
            Destroy(collision.gameObject);
        }
    }

    public void DropABomb()
    {
        // make an instance of the bomb
        Instantiate(enemyBombPrefab, transform.position + Vector3.down, Quaternion.identity);
    }

}