Skip to content

Latest commit

 

History

History
1013 lines (706 loc) · 28.6 KB

C14.md

File metadata and controls

1013 lines (706 loc) · 28.6 KB

Outline | Previous: Level Won Timeline | Next: To Review, Level 2

14) UI

Create a menu and in game UI for points and lives. Add scene transitions so the game may play multiple times.

14.1) Points

YouTube | Source before | Source after

Display the number of points in the top right.

How

Create TextPoints:

using UnityEngine;
using UnityEngine.UI;

public class TextPoints : MonoBehaviour
{
  [SerializeField]
  float scrollSpeed = .1f;

  Text text;

  int lastPointsDisplayed = -1;

  protected void Awake()
  {
    text = GetComponent<Text>();
  }

  protected void Update()
  {
    int currentPoints = GameController.instance.points;
    int deltaPoints = currentPoints - lastPointsDisplayed;
    if(deltaPoints > 0)
    {
      float speed = scrollSpeed * Time.deltaTime;
      float pointsTarget =
        Mathf.Lerp(lastPointsDisplayed, currentPoints, speed);
      int pointsToDisplay = (int)pointsTarget;
      if(pointsToDisplay == lastPointsDisplayed)
      {
        pointsToDisplay++;
      }
      text.text = pointsToDisplay.ToString("N0");
      lastPointsDisplayed = pointsToDisplay;
    }
  }
}


Add text:

  • In the Hierarchy, right click create UI -> Text.
    • This creates a Canvas, EventSystem, and Text GameObject.
  • Select the "Text" GameObject:
    • Name it "Points".
    • Text: 1,000,000
    • Pivot: (1, 1)
    • Paragraph Alignment: Right
    • Anchor: Top right
  • Use the move tool to position the text in the top right (you may need to zoom out a lot).


Configure text:

  • Select the Text GameObject:
    • Add TextPoints.
    • Color: white
    • Font: kenpixel_future
    • Font size: 32 (text may disappear)
    • Height: 40 (text should be too large)
    • Width: 500
    • Use the scale tool to scale down until its a good size.


Test:

  • When you start the level you should have 0 points, jump over an enemy to earn points. The points displayed should scroll up to 100 after you jump over the first enemy.


Explain the code

'using' clauses at the top of a file brings APIs into scope. Used for:

  • UnityEngine.Mathf
  • UnityEngine.MonoBehaviour
  • UnityEngine.SerializeField
  • UnityEngine.UI.Text
using UnityEngine;
using UnityEngine.UI;

We inherit from MonoBehaviour, which allows this script to be added as a component on a GameObject.

public is optional here. Used for consistency.

public class TextPoints : MonoBehaviour
{

This is a Unity-specific attribute that exposes a field in the Inspector, allowing you to configure it for the object.

  [SerializeField]

This defines how quickly the points should change from the last displayed value to the current point value. You can change the default in the Inspector.

  float scrollSpeed = .1f;

This is a reference to the Text component on this GameObject. Cached here for performance.

  Text text;

This holds the last point value displayed, used for scrolling that value to the current amount of points. We store it here so we do not need to parse the text itself to know what was last last shown.

This defaults to -1 so that the first Update causes the display to change the text to "0".

  int lastPointsDisplayed = -1;

Awake is a Unity event which is called once, when the GameObject is first added to a scene.

protected is optional here. Used for consistency.

  protected void Awake()
  {

Here we get a reference to the Text component on this GameObject.

    text = GetComponent<Text>();
  }

Update is a Unity event which is called once per frame.

protected is optional here. Used for consistency.

  protected void Update()
  {

Here we are calculating the deltaPoints, or the number of points which should added to the current display.

    int currentPoints = GameController.instance.points;
    int deltaPoints = currentPoints - lastPointsDisplayed;

Check if the number to be added is greater than 0, otherwise there is nothing to do.

    if(deltaPoints > 0)
    {

The speed at which the point value changes is defined by the scrollSpeed value in the Inspector as well as the amount of time which passed that frame, represented by Time.deltaTime.

      float speed = scrollSpeed * Time.deltaTime;

To calculate the number of points to display this frame, we use lerp to select a value between the last value shown and the current number of points.

The speed value here does not accumulate. Because of this, the speed value here is basically a constant (with a little variation due to variations in the frame time).

With this approach, the lerp method below will progress fastest when there is a large delta between the values. As the points gets closer to the final value, the rate of change becomes slow.

      float pointsTarget =
        Mathf.Lerp(lastPointsDisplayed, currentPoints, speed);

We always want to display a whole number of points so we cast the value calculated to an int, which truncates the result.

      int pointsToDisplay = (int)pointsTarget;

If the amount of points to display did not change, the increment by one to ensure that it does not get stuck displaying a value less then the actual.

      if(pointsToDisplay == lastPointsDisplayed)
      {
        pointsToDisplay++;
      }

Here we assign the text to display to the string for the points to display calculated above.

Using "N0" in ToString formats this as a whole number including commas or periods (e.g. 1,000).

      text.text = pointsToDisplay.ToString("N0");

Here we store the last points displayed to use on the next update.

      lastPointsDisplayed = pointsToDisplay;
    }
  }
}

What's a canvas do and why is our level so small in comparison?

The Canvas is a container holding UI. It allows Unity to manage features such as automatically scaling UI to fit the current resolution. Unity offers components such as the VerticalLayoutGroup which help in getting positioning and sizing correct.

Canvas appears in the Scene window along side other objects in the game. It's huge, and overlaps the world center a little. This is an arbitrary decision from Unity - the Canvas is actually completely separate from the rest of the game. I believe they choose to display this way as a simplification so you don't need another window for editing.

You can use the Layers button in the editor to hide UI if you prefer, allowing you to just look at the game or level design.


Why size the font too large and then scale it down?

Fonts by default may look blurry. We size the font too large and then scale it down via the RectTransform to fit in order to make the rendering more clear for users.

Here is an example, the top is sized only using font size while the bottom is oversized and then scaled down:


What is a RectTransform, how does it differ from a Transform?

A RectTransform is the UI version of the Transform used for GameObjects. RectTransform inherits from Transform, adding features specifically for UI positioning such as pivot points and an anchor. Anything displayed in a Canvas must use a RectTransform... as that is how Canvas does layout and positioning.


Why use ceiling here?

We need to ensure that each iteration of Update increases the points displayed by at least one, if we are not already displaying the final value. Without this, it's possible each Update would calculate less than 1 - if we simply cast that means that each update would progress by 0 and therefore never actually display the correct amount.


What does setting the anchor point / pivot on UI do?

Setting the anchor changes how the position for the Rect is determined. The default is center, which means places (0, 0) at the center of the screen. The unit for these coordinates is pixels.

As the screen size changes, the offset from the anchor point is still defined in pixels. If we positioned the points with a center anchor, it would not be position correctly when the resolution changed.

Pivot point is the spot in the GameObject which is used for positioning against the anchor. It is defined in percent of the object's size, 0 to 1. So if we have an anchor point of top right and the pivot is center (.5, .5) than the position (0, 0) will center the object in the corner, causing half of it to be offscreen. Switch the pivot point to (1, 1) and the entire object is visible.

Unity also offers the Canvas Scaler component on the Canvas GameObject which can be used to automatically update position and sizing when the resolution changes.


What's C# ToString("N0") do?

ToString is available on all types in C#. When using ToString to convert a number, you may optionally include format codes like this. "N0" is a common one.

  • "N" states it should formatted as a number, with commas in the states and periods in Europe, etc (e.g., 12,000,000).
  • "0" means any decimal places should not be included (e.g., 1000.234 would display as 1,000).

There are a lot of options when it comes to generating strings. Read more from Microsoft here.


14.2) Lives

YouTube | Source before | Source after

Add sprites to display how many lives remain.

How

Create LifeLine:

using UnityEngine;

public class LifeLine : PlayerDeathMonoBehaviour
{
  [SerializeField]
  int lifeCount = 1;

  protected void Start() 
  {
    if(GameController.instance.lifeCount < lifeCount)
    {
      Destroy(gameObject);
    }
  }

  public override void OnPlayerDeath()
  {
    if(GameController.instance.lifeCount < lifeCount)
    {
      DeathEffectManager.PlayDeathEffectsThenDestroy(gameObject);
    }
  }
}


Add sprites:

  • Add an Empty GameObject as a child to the Canvas, named "Lives".
    • Add HorizontalLayoutGroup:
      • Width: 500
      • Child Alignment: Upper Right
      • Uncheck Child Force Expand Width
  • Add an Image as a child of the Lives GameObject, named "Life".
    • Change the Source Image. We are using hudHeart_full.
    • Add LifeLine:
    • Add DeathEffectThrob (this will add a DeathEffectManager).
    • Copy / paste Life so that there are 3.
      • Change the lifeCount for each so that the first is 3, the second 2, and the last 1.
  • Select the Lives GameObject:
    • Scale to the desired size.
    • Change the anchor to Top Right.


Explain the code

'using' clauses at the top of a file brings APIs into scope. Used for:

  • UnityEngine.SerializeFieldAttribute
using UnityEngine;

We inherit from PlayerDeathMonoBehaviour which in turn inherits from MonoBehaviour, which allows this script to be added as a component on a GameObject.

By inheriting from PlayerDeathMonoBehaviour, this component will be called by the LevelController when the player dies.

public class LifeLine : PlayerDeathMonoBehaviour
{

This is a Unity-specific attribute that exposes a field in the Inspector, allowing you to configure it for the object.

  [SerializeField]

This defines the life value that this GameObject represents. When the total number of lives remaining drops below this value, this GameObject will be destroyed.

  int lifeCount = 1;

Start is a Unity method which is called once, the first time this component is enabled.

protected is optional here. Used for consistency.

  protected void Start()
  {

Check if the current life count is less than the live value that this GameObject represents.

    if(GameController.instance.lifeCount < lifeCount)
    {

This will destroy the GameObject.

This could happen when loading into Level 2 with less than 3 lives. We do not call PlayDeathEffectsThenDestroy because we would not want the hearts to animate out at the start of the level.

      Destroy(gameObject);
    }
  }

This overrides PlayerDeathMonoBehaviour's method which will be called when the player has died.

  public override void OnPlayerDeath()
  {

Check if the current life count is less than the live value that this GameObject represents.

    if(GameController.instance.lifeCount < lifeCount)
    {

This will start any death effects on this GameObject and then destroy it.

      DeathEffectManager.PlayDeathEffectsThenDestroy(gameObject);
    }
  }
}

How does the HorizontalLayoutGroup work?

The Horizontal Layout Group places its child GameObjects next to each other, side by side. There are various options for controlling the layout, such as:

  • Spacing: Adds padding between each of the child GameObjects.
  • Child Alignment: Defines if the child GameObjects should appear in the center, left, or right, etc of this GameObject.
  • Child Force Expand: Causes the child GameObjects to get wider, filling the entire parent GameObject. This appears as whitespace between objects.

Why an Image and not a Sprite?

Image is essentially a special kind of sprite with a RectTransform, to be used with a Canvas. The Canvas and its associated components, such as the HorizontalLayoutGroup, only work with GameObjects that have a RectTransform.


What does Child Force Expand Width?

Force Expand Width will automatically increase the Spacing so that the Images fill the entire container. If we were to use this, and get things positioned correctly by modifying the RectTransform width - it may look correct at the start but once one of the lives is destroyed, the others would re-layout to fill that gap... and that would look wrong.


14.3) Main Menu

YouTube | Source before | Source after

Create a main menu to show at the start of the game.

How

GameController prefab:

  • Create a prefab for the GameController (but do not delete the GameObject).


Create the Menu:

  • Create a new Scene, save it as Scenes/Menu.
    • Add the Scene to Build Settings.
      • Drag and drop it so that it is the first scene in the list.
  • Add the GameController prefab.


Add a Character:

  • Add a Platform sprite to the bottom.
    • Layer: Floor
    • Tile the width to stretch across the entire screen.
    • Add BoxCollider2D and resize.
  • Add the Character prefab.
    • Add WanderWalkController.
    • Add BounceOffScreenEdges.
    • Remove the PlayerController.
    • Under FadeInThenEnable:
      • Components to Enable Size: 0
    • Enable the sprite's Animator.
    • DO NOT click Apply, we do not want these changes to appear in Level 1.


Add a cloud:

  • Add the cloud sprite:
    • Create an animation to loop, named Animations/MenuCloud.
      • We have the cloud moving around the top of the screen.
      • The animation should end with the same position it started with.
    • Adjust the playback speed in the Animator Controller.


Music:

  • Add AudioSource to the Main Camera:
    • Set the AudioClip. We are using BoxCat_Games_-10-_Epic_Song.
    • Check Loop.
    • Adjust the Volume.


Title:

  • Add UI->Text (this automatically adds a Canvas and EventSystem).
  • Select the Canvas:
    • UI Scale Mode: Scale With Screen Size
  • Select the Text:
    • Rename to "Title".
    • Height: 50
    • Width: 300
    • Font: kenpixel_future
    • Font Size: 40
    • Color: white
    • Alignment: center
    • Add an Outline component.
    • Position the text


Test:

  • The Character should automatically pace back and forth.
  • The cloud should play its animation on a loop.
  • Music should be playing on a loop.


Does order matter for scenes in the Build Settings?

The first enabled scene in Build Settings list is what appears first when playing the game. Drag and drop scenes to change their order in that list.

You can disable scenes in Build Settings by unchecking the box, this excludes that scene from the build. You can also select and hit Delete.

The order beyond the first does not matter for anything except for the index ID they are assigned. When loading a scene you can either load by name or by index.

I prefer using the name, as code is easier to follow. You might also consider using an enum to define each scene in the correct order. This way it's easier to maintain code if scene names or the order changes.


How does the Canvas Scaler / Scale with Screen Size work?

The Canvas Scaler controls the size of UI elements on the screen. The default is constant pixel size which means that as the resolution gets larger, the relative size of UI is smaller (i.e., it does not scale up). We are using Scale with Screen Size with makes UI elements bigger the bigger the screen is.


14.4) Start Level 1

YouTube | Source before | Source after

Allow the player to start Level 1 from the menu.

How

Create ButtonChangeScene:

using UnityEngine;
using UnityEngine.SceneManagement;

public class ButtonChangeScene : MonoBehaviour
{
  [SerializeField]
  string sceneName;

  public void OnClickLoadScene()
  {
    SceneManager.LoadScene(sceneName);
  }
}


Add a play button:

  • Create UI -> Button, named "Play".
    • Change the Source Image. We are using sign.
    • Click Set Native Size.
    • Position the button on the menu screen.
  • Select the Text GameObject under Play.
    • Text: "Play"
    • Font Size: 50
    • Color: black
    • RectTransform Top: about -22 so the text is positioned well on the sign.
  • Add ButtonChangeScene to the Play GameObject:
    • Scene Name: Level1
  • Under the button component:
    • Create a new OnClick event.
  • Drag and drop the ButtonChangeScene component onto the click event object box.
  • Select the OnClickLoadScene event.


Test:

  • Click the Play button to start the game.



Explain the code

'using' clauses at the top of a file brings APIs into scope. Used for:

  • UnityEngine.MonoBehaviour
  • UnityEngine.SceneManagement.SceneManager
  • UnityEngine.SerializeFieldAttribute
using UnityEngine;
using UnityEngine.SceneManagement;

We inherit from MonoBehaviour, which allows this script to be added as a component on a GameObject.

public is optional here. Used for consistency.

public class ButtonChangeScene : MonoBehaviour
{

This is a Unity-specific attribute that exposes a field in the Inspector, allowing you to configure it for the object.

  [SerializeField]

This defines which scene to load when the method below is called. Set in the Inspector.

  string sceneName;

This is a public method which may be called to change scenes.

We will be wiring the UI Button's event to call this method when clicked.

  public void OnClickLoadScene()
  {

This will destroy all the GameObjects and then load another scene.

    SceneManager.LoadScene(sceneName);
  }
}

Why Remove Component instead of disable it?

Either way should work. I find it more clear to remove the component instead of just leaving it disabled as it's easier to understand what's happening with that GameObject. Several times in this tutorial we have GameObjects with components which are disabled by default - all of them may be enabled if the right use case triggers it. So removing the component clearly indicates there is no PlayerController in the menu, vs maybe there is a hidden way of enabling it.


How do UI events / button OnClick events work?

When an event occurs, such as OnClick for buttons, you can execute any number of methods. Hit plus to add another event to call.

To call an event, you first select the GameObject you want to operate on. Once selected, each of the components on the GameObject are selectable from the event list.

Often you will be calling an event on the same object like we did here.


14.5) Play Again

YouTube | Source before | Source after

When the game is over, return to the menu and allow the player to play again.

How

Update LevelController:

Existing code
using UnityEngine;
using UnityEngine.Playables;

using UnityEngine.SceneManagement;
Existing code
public class LevelController : MonoBehaviour
{
  [SerializeField]
  GameObject playerPrefab;

  protected bool isGameOver;

  [SerializeField]
  PlayableDirector director;

  [SerializeField]
  PlayableAsset TimelineEventPlayable;

  [SerializeField]
  int levelNumber = 1;

  protected void OnEnable()
  {
    GameController.instance.onLifeCounterChange
      += Instance_onLifeCounterChange;

    StartLevel();
  }

  protected void OnDisable()
  {
    GameController.instance.onLifeCounterChange
      -= Instance_onLifeCounterChange;
  }

  void Instance_onLifeCounterChange()
  {
    if(isGameOver)
    {
      return;
    }

    BroadcastEndOfLevel();

    if(GameController.instance.lifeCounter <= 0)
    {
      isGameOver = true;
      YouLose();
    }
    else
    {
      StartLevel();
    }
  }

  public void YouWin()
  {
    if(isGameOver == true)
    {
      return;
    }

    isGameOver = true;

    director.Play(TimelineEventPlayable);

    DisableComponentsOnEndOfLevel[] disableComponentList
      = GameObject.FindObjectsOfType<DisableComponentsOnEndOfLevel>();
    for(int i = 0; i < disableComponentList.Length; i++)
    {
      DisableComponentsOnEndOfLevel disableComponent = disableComponentList[i];
      disableComponent.OnEndOfLevel();
    }
  }

  void StartLevel()
  {
    Instantiate(playerPrefab);
  }

  void BroadcastEndOfLevel()
  {
    PlayerDeathMonoBehaviour[] gameObjectList
      = GameObject.FindObjectsOfType<PlayerDeathMonoBehaviour>();
    for(int i = 0; i < gameObjectList.Length; i++)
    {
      PlayerDeathMonoBehaviour playerDeath = gameObjectList[i];
      playerDeath.OnPlayerDeath();
    }
  }

  void YouLose()
  {

    SceneManager.LoadScene("Menu");
Existing code
  }
}


Update GameController:

Existing code
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
Existing code
public class GameController : MonoBehaviour
{
  public static GameController instance;

  public event Action onLifeCountChange;

  [SerializeField]
  int _lifeCount = 3;
  public int lifeCount
  {
    get
    {
      return _lifeCount;
    }
    set
    {
      _lifeCount = value;
      if(onLifeCountChange != null)
      {
        onLifeCountChange();
      }
    }
  }

  public int points;

  public Bounds screenBounds
  {
    get; private set;
  }

  int originalLifeCount;

  protected void Awake()
  {
    if(instance != null)
    {
      Destroy(gameObject);
      return;
    }

    instance = this;
    DontDestroyOnLoad(gameObject);

    originalLifeCount = lifeCount;

    CalcScreenSize();
    SceneManager.sceneLoaded += SceneManager_sceneLoaded; 
Existing code
  }
  void SceneManager_sceneLoaded( 
    Scene scene, 
    LoadSceneMode sceneMode)
  {
    if(scene.name == "Level1")
    {
      lifeCount = originalLifeCount;
      points = 0;
    }
  }
Existing code
  protected void Update()
  {
    CalcScreenSize();
  }

  void CalcScreenSize()
  {
    Vector2 screenSize = new Vector2(
          (float)Screen.width / Screen.height,
          1);
    screenSize *= Camera.main.orthographicSize * 2;
    screenBounds = new Bounds(
      (Vector2)Camera.main.transform.position,
      screenSize);
  }
}


Test:

  • The lose all 3 lives in level 1 to return to the menu.


Explain the code

LevelController:

'using' clauses at the top of a file brings APIs into scope. Used for:

  • UnityEngine.SceneManagement.SceneManager
using UnityEngine.SceneManagement;

This will destroy all the GameObjects and then load the Menu scene.

    SceneManager.LoadScene("Menu");


GameController:

'using' clauses at the top of a file brings APIs into scope. Used for:

  • UnityEngine.SceneManagement.SceneManager
using UnityEngine.SceneManagement;

This subscribes to the SceneManager's sceneLoaded event. The SceneManager will call our method below anytime the scene changes.

    SceneManager.sceneLoaded += SceneManager_sceneLoaded; 

This is the method which will be called by the SceneManager's sceneLoaded event anytime the scene changes.

The first parameter, scene, is information about the scene which was just loaded. The second parameter will be ignored.

  void SceneManager_sceneLoaded( 
    Scene scene, 
    LoadSceneMode sceneMode)
  {

Check if the scene which was just loaded is Level1.

    if(scene.name == "Level1")
    {

Every time the player starts Level1, this will reset the lives back to the original value configured in the Inspector and clear points.

      lifeCount = originalLifeCount;
      points = 0;
    }
  }

To Review

Testing / debugging tips
  • TODO

Up Next

Chapter 15 To Review, Level 2



Questions, issues, or suggestions? Please use the YouTube comments for the best fit section.

Support on Patreon, with Paypal, or by subscribing on Twitch (free with Amazon Prime).

License. Created live at twitch.tv/HardlyDifficult August 2017.