In the last chapter, we focused on using code to move the player and camera, with a trip into Unity physics on the side. However, controlling a playable character isn’t enough to make a compelling game; in fact, it’s probably the one area that remains fairly constant across different titles.
A game’s unique spark comes from its core mechanics, and the feeling of power and agency those mechanics give to the players. Without fun and engrossing ways to affect the virtual environment you’ve created, your game doesn’t stand a chance of repeat play, to say nothing of fun. As we venture into implementing the game’s mechanics, we’ll also be upgrading our knowledge of C# and its intermediate-level features.
This chapter will build on the Hero Born prototype by focusing on individually implemented game mechanics, as well as the basics of system design and user interfaces (UIs). You’ll be diving into the following topics:
Remember from the last chapter that Rigidbody components add simulated real-world physics to GameObjects, and Collider components interact with each other using Rigidbody objects.
Another great thing that we didn’t discuss in the previous chapter about using a Rigidbody component to control player movement is that we can easily add in different mechanics that rely on applied force, such as jumping. In this section, we’ll get our player jumping and write our first utility function.
A utility function is a class method that performs some kind of grunt work so that we don’t clutter up gameplay code—for instance, wanting to check whether the player capsule is touching the ground to jump.
Before that, you’ll need to get acquainted with a new data type called enumerations, which you’ll do in the following section.
By definition, an enumeration type is a set, or collection, of named constants that belong to the same variable. These are useful when you want a collection of different values, but with the added benefit of them all being of the same parent type.
It’s easier to show rather than tell with enumerations, so let’s take a look at their syntax in the following code snippet:
enum PlayerAction { Attack, Defend, Flee };
Let’s break down how this works, as follows:
enum
keyword declares the type followed by the variable nameenum
can have are written inside curly brackets, separated by a comma (except for the last item)enum
has to end with a semicolon, just like all other data types we’ve worked withIn this case, we’re declaring a variable called PlayerAction
, of type enum
, which can be set to one of three values—Attack
, Defend
, or Flee
.
To declare an enumeration variable, we use the following syntax:
PlayerAction CurrentAction = PlayerAction.Defend;
Again, we can break this down, as follows:
PlayerAction
, since our enumeration is just like any other type, like a string or integercurrentAction
and set equal to a PlayerAction
valueenum
constant can be accessed using dot notationOur currentAction
variable is now set to Defend
, but it can be changed to Attack
or Flee
at any time.
Enumerations may look simple at first glance, but they are extremely powerful in the right situations. One of their most useful features is the ability to store underlying types, which is the next subject you’ll be jumping into.
Enums come with an underlying type, meaning that each constant inside the curly brackets has an associated value. The default underlying type is int
and starts at 0, just like arrays, with each sequential constant getting the next highest number.
Not all types are created equal—underlying types for enumerations are limited to byte
, sbyte
, short
, ushort
, int
, uint
, long
, and ulong
. These are called integral types, which are used to specify the size of numeric values that a variable can store.
This is a bit advanced for this book, but you’ll be using int
in most cases. More information on these types can be found here: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/enum.
For example, our PlayerAction
enumeration values right now are listed as follows, even though they aren’t explicitly written out:
enum PlayerAction { Attack = 0, Defend = 1, Flee = 2 };
There’s no rule that says underlying values need to start at 0
; in fact, all you have to do is specify the first value, and then C# increments the rest of the values for you, as illustrated in the following code snippet:
enum PlayerAction { Attack = 5, Defend, Flee };
In the preceding example, Defend
equals 6
, and Flee
equals 7
automatically. However, if we wanted the PlayerAction
enum to hold non-sequential values, we could explicitly add them in, like this:
enum PlayerAction { Attack = 10, Defend = 5, Flee = 0};
We can even change the underlying type of PlayerAction
to any of the approved types by adding a colon after the enum name, as follows:
enum PlayerAction : byte { Attack, Defend, Flee };
Retrieving an enum’s underlying type takes an explicit conversion, but we’ve already covered those, so the following syntax shouldn’t be a surprise:
enum PlayerAction { Attack = 10, Defend = 5, Flee = 0};
PlayerAction CurrentAction = PlayerAction.Attack;
int ActionCost = (int)CurrentAction;
Since CurrentAction
is set to Attack
, ActionCost
would be 10
in the above example code.
Enumerations are extremely powerful tools in your programming arsenal. Your next challenge is to use your knowledge of enumerations to gather more specific user input from the keyboard.
Now that we have a basic grasp of enumeration types, we can capture keyboard input using the KeyCode
enum. Update the PlayerBehavior
script with the following highlighted code, save it, and hit Play:
public class PlayerBehavior : MonoBehaviour
{
// ... No other variable changes needed ...
// 1
public float JumpVelocity = 5f;
private bool _isJumping;
void Start()
{
_rb = GetComponent<Rigidbody>();
}
void Update()
{
// 2
_isJumping |= Input.GetKeyDown(KeyCode.J);
// ... No other changes needed ...
}
void FixedUpdate()
{
// 3
if(_isJumping)
{
// 4
_rb.AddForce(Vector3.up * JumpVelocity, ForceMode.Impulse);
}
// 5
_isJumping = false;
// ... No other changes needed ...
}
}
Let’s break down this code, as follows:
_isJumping
to the Input.GetKeyDown()
method, which returns a bool
value depending on whether a specified key is pressed during the current frame and will only fire once even if held down:|=
operator to set _isJumping
, which is the logical or
condition. This operator makes sure that we don’t have consecutive input checks override each other when the player is jumping.string
or a KeyCode
, which is an enumeration type. We specify that we want to check for KeyCode.J
, or the J key being pressed.
Checking for inputs in FixedUpdate
can sometimes lead to input loss or even double inputs because it doesn’t run once per frame. To avoid this problem, we’re checking for inputs in Update
and then applying force or setting the velocity in FixedUpdate
, which is where physics are applied.
if
statement to check if _isJumping
is true, and trigger the jump mechanic if it is.Vector3
and ForceMode
parameters to RigidBody.AddForce()
and make the player jump:up
direction, multiplied by JumpVelocity
.ForceMode
parameter determines how the force is applied and is also an enumeration type. Impulse
applies instant force to an object while taking its mass into account, which is perfect for a jump mechanic.
Other ForceMode
choices can be useful in different situations, all of which are detailed here: https://docs.unity3d.com/ScriptReference/ForceMode.html.
FixedUpdate
frame, we reset _isJumping
to false
so the input check knows a complete jump and the landing cycle have been completed.If you play the game now, you’ll be able to move around and jump when you hit the spacebar. However, the mechanic allows you to keep jumping indefinitely, which isn’t what we want. We’ll work on limiting our jump mechanic to one at a time in the next section, using something called a layer mask.
Think of layer masks as invisible groups that a GameObject can belong to, used by the physics system to determine anything from navigation to intersecting collider components. While more advanced uses of layer masks are outside the scope of this book, we’ll create and use one to perform a simple check—whether the player capsule is touching the ground—in order to limit the player to one jump at a time.
Before we can check that the player capsule is touching the ground, we need to add all the environment objects in our level to a custom layer mask. This will let us perform the actual collision calculation with the Capsule Collider component that’s already attached to the player, in order to detect when the player lands on the ground. Proceed as follows:
Figure 8.1: Selecting layers in the Inspector pane
To provide a complete view of the Unity editor, all our screenshots are taken in full-screen mode. For color versions of all book images, use the link below: https://packt.link/7yy5V.
Ground
by typing the name into the first available slot, which is Layer 6. Layers 0–5 are reserved for Unity’s default layers, even though Layer 3 is empty, as illustrated in the following screenshot:Figure 8.2: Adding layers in the Inspector pane
Figure 8.3: Setting a custom layer
After you have selected the Ground option shown in the preceding screenshot, click Yes, change children when a dialog appears asking you if you want to change all child objects. Here, you’ve defined a new layer called Ground and assigned every child object of Environment to that layer. Even though we can’t jump off the walls of the arena, marking all our environment objects with the ground layer is easier than going through each child object.
Going forward, all the objects on the Ground layer can be checked to see if they intersect with a specific object. You’ll use this in the following challenge to make sure the player can perform a jump if it’s on the ground; no unlimited jump hacks here.
Since we don’t want code cluttering up the Update()
method, we’ll do our layer mask calculations in a utility function and return a true
or false
value based on the outcome. To do so, proceed as follows:
PlayerBehavior
and play the scene again:
public class PlayerBehavior : MonoBehaviour
{
// 1
public float DistanceToGround = 0.1f;
// 2
public LayerMask GroundLayer;
// 3
private CapsuleCollider _col;
// ... No other variable changes needed ...
void Start()
{
_rb = GetComponent<Rigidbody>();
// 4
_col = GetComponent<CapsuleCollider>();
}
void Update()
{
// ... No changes needed ...
}
void FixedUpdate()
{
// 5
if(IsGrounded() && _isJumping)
{
_rb.AddForce(Vector3.up * JumpVelocity,
ForceMode.Impulse);
}
// ... No other changes needed ...
}
// 6
private bool IsGrounded()
{
// 7
Vector3 capsuleBottom = new Vector3(_col.bounds.center.x,
_col.bounds.min.y, _col.bounds.center.z);
// 8
bool grounded = Physics.CheckCapsule(_col.bounds.center,
capsuleBottom, DistanceToGround, GroundLayer,
QueryTriggerInteraction.Ignore);
// 9
return grounded;
}
}
PlayerBehavior
script selected, set Ground Layer in the Inspector pane to Ground from the Ground Layer dropdown, as illustrated in the following screenshot: Figure 8.4: Setting the Ground Layer
Let’s break down the preceding code, as follows:
LayerMask
variable that we can set in the Inspector and use for the collider detection.GetComponent()
to find and return the Capsule Collider attached to the player.if
statement to check whether IsGrounded
returns true
and the J key is pressed before executing the jump code.IsGrounded()
method with a bool
return type.Vector3
variable to store the position at the bottom of the player’s Capsule Collider, which we’ll use to check for collisions with any objects on the Ground layer:bounds
property, which gives us access to the min, max, and center positions of its x, y, and z axesbool
to store the result of the CheckCapsule()
method that we call from the Physics
class, which takes in the following five arguments:capsuleBottom
position we’ve already calculated.DistanceToGround
already set.GroundLayer
in the Inspector.QueryTriggerInteraction.Ignore
enum.
We could also use the Distance
method from the Vector3
class to determine how far we are from the ground, since we know the height of the player capsule. However, we’re going to stick with using the Physics
class, since that’s the focus of this chapter.
grounded
at the end of the calculation.
We could have done the collision calculation manually, but that would require more complex 3D math than we have time to cover here. However, it’s always a good idea to use built-in methods when available.
That was an involved piece of code that we just added into PlayerBehavior
, but when you break it down, the only new thing we did was use a method from the Physics
class. In plain English, we supplied CheckCapsule()
with a start point and endpoint, a collision radius, and a layer mask. If the endpoint gets closer than the collision radius to an object on the layer mask, the method returns true
—meaning the player is touching the ground. If the player is in a mid-jump position, CheckCapsule()
returns false
.
Since we’re checking IsGround
in the if
statement every frame in Update()
, our player’s jump skills are only allowed when touching the ground.
That’s all you’re going to do with the jump mechanic, but the player still needs a way to interact and defend themself against the hordes of enemies that will eventually populate the arena. In the following section, you’ll fix that gap by implementing a simple shooting mechanic.
Shooting mechanics are so common that it’s hard to think of a first-person game without some variation present, and Hero Born is no different. In this section, we’ll talk about how to instantiate GameObjects from Prefabs while the game is running, and use the skills we’ve learned to propel them forward using Unity physics.
The concept of instantiating a GameObject in the game is similar to instantiating an instance of a class—both require starting values so that C# knows what kind of object we want to create and where it needs to be created. To create objects in the scene at runtime, we use the GameObject.Instantiate()
method and provide a Prefab object, a starting position, and a starting rotation.
Essentially, we can tell Unity to create a given object with all its components and scripts at this spot, looking in this direction, and then manipulate it as needed once it’s born in the 3D space. Before we instantiate an object, you’ll need to create the object Prefab itself, which is your next task.
Before we can shoot any projectiles, we’ll need a Prefab to use as a reference, so let’s create that now, as follows:
Bullet
:0.15
in the x, y, and z axes in the Transform componentMaterials
folder using Create | Material, and name it Bullet_Mat
:Bullet
GameObject in the Hierarchy pane:Figure 8.5: Setting projectile properties
Prefabs
folder in the Project panel (you can always tell when an object in the Hierarchy is a Prefab because it turns blue). Then, delete it from the Hierarchy to clean up the scene:Figure 8.6: Creating a projectile Prefab
You created and configured a Bullet Prefab GameObject that can be instantiated as many times as you need in the game and updated as needed. This means you’re ready for the next challenge—shooting projectiles.
Now that we have a Prefab object to work with, we can instantiate and move copies of the Prefab whenever we hit the spacebar key to create a shooting mechanic, as follows:
PlayerBehavior
script with the following code:
public class PlayerBehavior : MonoBehaviour
{
// 1
public GameObject Bullet;
public float BulletSpeed = 100f;
// 2
private bool _isShooting;
// ... No other variable changes needed ...
void Start()
{
// ... No changes needed ...
}
void Update()
{
// 3
_isShooting |= Input.GetKeyDown(KeyCode.Space);
// ... No other changes needed ...
}
void FixedUpdate()
{
// ... No other changes needed ...
// 4
if (_isShooting)
{
// 5
GameObject newBullet = Instantiate(Bullet,
this.transform.position + new Vector3(0, 0, 1),
this.transform.rotation);
// 6
Rigidbody BulletRB =
newBullet.GetComponent<Rigidbody>();
// 7
BulletRB.velocity = this.transform.forward *
BulletSpeed;
}
// 8
_isShooting = false;
}
private bool IsGrounded()
{
// ... No changes needed ...
}
}
PlayerBehavior
, as illustrated in the following screenshot:Figure 8.7: Setting the Bullet Prefab
Let’s break down the code as follows:
Update
method to check if our player should be shooting._isShooting
using the or
logical operator and Input.GetKeyDown(KeyCode.Space
), just like we did for the jumping mechanic. Then, we check if our player is supposed to be shooting using the _isShooting
variable.Instantiate()
method to assign a GameObject to newBullet
by passing in the Bullet
Prefab. We also use the player capsule’s position to place the new Bullet
Prefab in front of the player (one unit forward along the z axis) to avoid any collisions.GameObject
to explicitly cast the returned object to the same type as newBullet
, which in this case is a GameObject.GetComponent()
to return and store the Rigidbody component on newBullet
.velocity
property of the Rigidbody component to the player’s transform.forward
direction multiplied by BulletSpeed
:velocity
instead of using AddForce()
ensures that gravity doesn’t pull our bullets down in an arc when fired_isShooting
value to false
so our shooting input is reset for the next input event.Again, you’ve significantly upgraded the logic the player script is using. You should now be able to use the mouse to shoot projectiles that fly straight out from the player’s position.
However, the problem now is that your game scene, and Hierarchy, is flooded with spent Bullet objects. Your next task is to clean those objects up once they’ve been fired, to avoid any performance issues.
Whether you’re writing a completely code-based application or a 3D game, it’s important to make sure that unused objects are regularly deleted to avoid overloading the program. Our bullets don’t exactly play an important role after they are shot; they just keep existing on the floor near whatever wall or object they collided with.
With a mechanic such as shooting, this could result in hundreds, if not thousands, of bullets down the line, which is something we don’t want. Your next challenge is to destroy each bullet after a set delay time.
For this task, we can take the skills we’ve already learned and make the bullets responsible for their self-destructive behavior, as follows:
Scripts
folder and name it BulletBehavior
.BulletBehavior
script onto the Bullet
Prefab in the Prefabs
folder and add the following code:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BulletBehavior : MonoBehaviour
{
// 1
public float OnscreenDelay = 3f;
void Start ()
{
// 2
Destroy(this.gameObject, OnscreenDelay);
}
}
Let’s break down this code, as follows:
float
variable to store how long we want the Bullet Prefabs to remain in the scene after they are instantiated.Destroy()
method to delete the GameObject:Destroy()
always needs an object as a parameter. In this case, we use the this
keyword to specify the object that the script is attached to.Destroy()
can optionally take an additional float
parameter as a delay, which we use to keep the bullets on screen for a short amount of time.Play the game again, shoot some bullets, and watch as they are deleted from the Hierarchy by themselves in the scene after a specific delay. This means that the bullet executes its defined behavior, without another script having to tell it what to do, which is an ideal application of the Component design pattern.
Now that our housekeeping is done, you’re going to learn about a key component of any well-designed and organized project—the manager class.
A common misconception when learning to program is that all variables should automatically be made public, but in general, this is not a good idea. In my experience, variables should be thought of as protected and private from the start, and only made public if necessary. One way you’ll see experienced programmers protect their data is through manager classes, and since we want to build good habits, we’ll be following suit. Think of manager classes as a funnel where important variables and methods can be accessed safely.
When I say safely, I mean just that, which might seem unfamiliar in a programming context. However, when you have different classes communicating and updating data with each other, things can get messy. That’s why having a single contact point, such as a manager class, can keep this to a minimum. We’ll get into how to do that effectively in the following section.
Hero Born is a simple game, so the only two data points we need to keep track of are how many items the player has collected and how much health they have left. We want these variables to be private so that they can only be modified from the manager class, giving us control and safety. Your next challenge is to create a game manager for Hero Born and populate it with helpful functionality.
Game manager classes will be a constant facet of any project you develop in the future, so let’s learn how to properly create one, as follows:
Scripts
folder and name it GameBehavior
.
Usually, this script would be named GameManager
, but Unity reserves that name for its own scripts. If you ever create a script and a cogwheel icon shows up next to its name instead of the C# file icon, that tells you it’s restricted.
Game Manager
.GameBehavior.cs
script from the Scripts folder onto the Game Manager
object, as illustrated in the following screenshot:
Figure 8.8: Attaching the game manager script
Manager scripts, and other non-game files, are set up on empty objects to put them in the scene, even though they don’t interact with the actual 3D space.
GameBehavior.cs
:
public class GameBehavior : MonoBehaviour
{
private int _itemsCollected = 0;
private int _playerHP = 10;
}
Let’s break down this code. We added two new private
variables to hold the number of items picked up and how many lives the player has left; these are private
because they should only be modifiable in this class. If they were made public
, other classes could change them at will, which could lead to the variables storing incorrect or concurrent data.
Having these variables declared as private
means that you are responsible for how they are accessed. The following topic on get
and set
properties will introduce you to a standard, safe way to accomplish this task going forward.
We’ve got our manager script and private variables set up, but how do we access them from other classes if they’re private? While we could write separate public methods in GameBehavior
to handle passing new values to the private variables, let’s see whether there is a better way of doing things.
In this case, C# provides all variables with get
and set
properties, which are perfectly suited to our task. Think of these as methods that are automatically fired by the C# compiler whether we explicitly call them or not, similar to how Start()
and Update()
are executed by Unity when a scene starts.
get
and set
properties can be added to any variable, with or without an initial value, as illustrated in the following code snippet:
public string FirstName { get; set; };
// OR
public string LastName { get; set; } = "Smith";
However, using them like this doesn’t add any additional benefits; for that, you need to include a code block for each property, as illustrated in the following code snippet:
public string FirstName
{
get {
// Code block executes when variable is accessed
}
set {
// Code block executes when variable is updated
}
}
Now, the get
and set
properties are set up to execute additional logic, depending on where it’s needed. We’re not done yet though, as we still need to handle the new logic.
Every get
code block needs to return a value, while every set
block needs to assign a value; this is where having a combination of a private variable, called a backing variable, and a public variable with get
and set
properties comes into play. The private variable remains protected, while the public variable allows controlled access from other classes, as shown in the following code snippet:
private string _firstName
public string FirstName {
get {
return _firstName;
}
set {
_firstName = value;
}
}
Let’s break this down, as follows:
return
the value stored in the private variable from the get
property anytime another class needs it, without actually giving that outside class direct accessvalue
keyword is a stand-in for whatever new value is assignedThis can seem a little esoteric without an actual application, so let’s update GameBehavior
with public variables with getter
and setter
properties to go along with our existing private variables.
Now that we understand the syntax of the get
and set
property accessors, we can implement them in our manager class for greater efficiency and code readability.
Update the code in GameBehavior
, as follows:
public class GameBehavior : MonoBehaviour
{
private int _itemsCollected = 0;
private int _playerHP = 10;
// 1
public int Items
{
// 2
get { return _itemsCollected; }
// 3
set {
_itemsCollected = value;
Debug.LogFormat("Items: {0}", _itemsCollected);
}
}
// 4
public int HP
{
get { return _playerHP; }
set {
_playerHP = value;
Debug.LogFormat("Lives: {0}", _playerHP);
}
}
}
Let’s break down the code, as follows:
public
variable called Items
with get
and set
properties.get
property to return
the value stored in _itemsCollected
whenever Items
are accessed from an outside class.set
property to assign _itemsCollected
to the new value
of Items
whenever it’s updated, with an added Debug.LogFormat()
call to print out the modified value of _itemsCollected
.public
variable called HP
with get
and set
properties to complement the private _playerHP
backing variable.Both private variables are now readable, but only through their public counterparts; they can only be changed in GameBehavior
. With this setup, we ensure that our private data can only be accessed and modified from specific contact points. This makes it easier to communicate with GameBehavior
from our other mechanical scripts, as well as to display the real-time data in the simple UI we’ll create at the end of the chapter.
Let’s test this out by updating the Items
property when we successfully interact with an item pickup in the arena.
Now that we have our variables set up in GameBehavior
, we can update Items
every time we collect an Item
in the scene, as follows:
ItemBehavior
script:
public class ItemBehavior : MonoBehaviour
{
// 1
public GameBehavior GameManager;
void Start()
{
// 2
GameManager = GameObject.Find("Game Manager").GetComponent<GameBehavior>();
}
void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.name == "Player")
{
Destroy(this.transform.parent.gameObject);
Debug.Log("Item collected!");
// 3
GameManager.Items += 1;
}
}
}
Figure 8.9: Collecting a pickup item
Let’s break down the code, as follows:
GameBehavior
type to store a reference to the attached script.Start()
to initialize GameManager
by looking it up in the scene with Find()
and adding a call to GetComponent()
.
You’ll see this kind of code done in a single line quite often in Unity documentation and community projects. This is done for simplicity, but if you feel more comfortable writing out the Find()
and GetComponent()
calls separately, go right ahead; there’s nothing wrong with clear, explicit formatting.
Items
property using the GameManager
class instance in OnCollisionEnter()
after the Item Prefab is destroyed.Since we already set up ItemBehavior
to take care of collision logic, it’s easy to modify OnCollisionEnter()
to communicate with our manager class when an item is picked up by the player. Keep in mind that separating functionality like this is what makes the code more flexible and less likely to break as you make changes during development.
The last piece Hero Born is missing is some kind of interface that displays game data to the player. In programming and game development, this is called a UI. Your final task in this chapter is to familiarize yourself with how Unity creates and handles the UI code.
At this point, we have several scripts working together to give players access to movement, jumping, collecting, and shooting mechanics. However, we’re still missing any kind of display or visual cue that shows our player’s stats, as well as a way to win and lose the game. We’ll focus on these two topics as we close out this last section.
UIs are the visual components of any computer system. The cursor, folder icons, and programs on your laptop are all UI elements. For our game, we want a simple display to let our players know how many items they’ve collected and their current health, and a textbox to give them updates when certain events happen.
UI elements in Unity can be added in the following two ways:
uGUI is an older UI system in Unity, but we’re going to use it over UI Toolkit because it’s based on GameObjects that can be easily manipulated right in the Scene view like any other object.
We’ll be going over the basics in this chapter, but you can find more information at: https://docs.unity3d.com/Packages/[email protected]/manual/index.html.
While UI Toolkit is a newer addition to the Unity engine, it uses UI Documents (UXML), which is based on standard web technologies and isn’t written in C#. Since we want to keep things squarely focused on C# as much as possible, we’ll be opting for uGUI instead.
If you’re interested in learning the newest Unity features when it comes to user interfaces, check out the UI Toolkit documentation at: https://docs.unity3d.com/2022.1/Documentation/Manual/UIElements.html.
If you’re curious about the comparison specifics between the different UI options in Unity, check out: https://docs.unity3d.com/2022.1/Documentation/Manual/UI-system-compare.html.
Your next task is to add a simple UI to the game scene that displays the items collected, player health, and progress information variables that are stored in GameBehavior.cs
.
First, let’s create three text objects in our scene. User interfaces in Unity work off of a canvas, which is exactly what it sounds like. Think of the canvas as a blank painting that you can draw on that Unity will render on top of the game world for you. Whenever you create your first UI element in the Hierarchy panel, a Canvas parent object is created along with it:
Figure 8.10: Importing TextMeshPro assets
TextMeshPro is Unity’s system for handling, rendering, and styling text. This topic is a little advanced for us to get into here, but if you’re interested you can read the documentation at: https://docs.unity3d.com/Manual/com.unity.textmeshpro.html.
Figure 8.11: Creating a Text element
Figure 8.12: Canvas in the Unity editor
Figure 8.13: Text element on the Unity Canvas
Figure 8.14: Setting anchor presets
Figure 8.15: Setting text color properties
Figure 8.16: Setting text properties
Figure 8.17: Creating another Text element
Figure 8.18: Creating a progress text element
Now that we have our UI set up, let’s connect the variables we already have in our game manager script. Proceed as follows:
GameBehavior
with the following code to collect an item and display onscreen text when items are collected:
// 1
using TMPro;
public class GameBehavior : MonoBehaviour
{
// 2
public int MaxItems = 4;
// 3
public TMP_Text HealthText;
public TMP_Text ItemText;
public TMP_Text ProgressText;
// 4
void Start()
{
ItemText.text += _itemsCollected;
HealthText.text += _playerHP;
}
private int _itemsCollected = 0;
public int Items
{
get { return _itemsCollected; }
set {
_itemsCollected = value;
// 5
ItemText.text = "Items Collected: " + Items;
// 6
if(_itemsCollected >= MaxItems)
{
ProgressText.text = "You've found all the items!";
}
else
{
ProgressText.text = "Item found, only " + (MaxItems - _itemsCollected) + " more!";
}
}
}
private int _playerHP = 10;
public int HP
{
get { return _playerHP; }
set {
_playerHP = value;
// 7
HealthText.text = "Player Health: " + HP;
Debug.LogFormat("Lives: {0}", _playerHP);
}
}
}
GameBehavior
script fields in the Inspector:Figure 8.19: Dragging text elements to script components
Figure 8.20: Testing UI elements in play mode
Let’s break down the code, as follows:
TMPro
namespace so we have access to the TMP_Text variable type, which is what our text objects in the Hierarchy are.Start
method to set the initial values of our health and items text using the += operator.text
property of ItemText to show the updated items
count.if
statement in the set property of _itemsCollected
:MaxItems
, they’ve won, and ProgressText.text
is updatedProgressText.text
shows how many items are still left to collecttext
property of HealthText
with the new value.When we play the game now, our three UI elements show up with the correct values; when an Item is collected, the ProgressText
and _itemsCollected
counts update, as illustrated in the following screenshot:
Figure 8.21: Updating the UI text
Every game can either be won or lost. In the last section of this chapter, your task is to implement those conditions and the UI that goes along with them.
We’ve implemented our core game mechanics and a simple UI, but Hero Born is still missing an important game design element: its win and loss conditions. These conditions will manage how the player wins or loses the game and execute different code depending on the situation.
Back in the game document from Chapter 6, Getting Your Hands Dirty with Unity, we set out our win and loss conditions as follows:
These conditions are going to affect both our UI and game mechanics, but we’ve already set up GameBehavior
to handle this efficiently. Our get
and set
properties will handle any game-related logic and changes to the UI when a player wins or loses.
We’re going to implement the win condition logic in this section because we have the pickup system already in place. When we get to the enemy AI behavior in the next chapter, we’ll add in the loss condition logic. Your next task is to determine when the game is won in code.
We always want to give players clear and immediate feedback, so we’ll start by adding in the logic for a win condition, as follows:
GameBehavior
to match the following code:
//1
using UnityEngine.UI;
public class GameBehavior : MonoBehaviour
{
// 2
public Button WinButton;
private int _itemsCollected = 0;
public int Items
{
get { return _itemsCollected; }
set
{
_itemsCollected = value;
ItemText.text = "Items Collected: " + Items;
if (_itemsCollected >= MaxItems)
{
ProgressText.text = "You've found all the items!";
// 3
WinButton.gameObject.SetActive(true);
}
else
{
ProgressText.text = "Item found, only " + (MaxItems - _itemsCollected) + " more to go!";
}
}
}
}
Figure 8.22: Creating a UI button
Figure 8.23: Updating button text
Figure 8.24: Deactivating the GameObject
This will hide the button until we’ve won the game:
Figure 8.25: Testing the hidden UI button
Figure 8.26: Dragging the UI button onto the script component
1
in the Inspector to test out the new screen, as illustrated in the following screenshot:Figure 8.27: Showing the win screen
Let’s break down the code, as follows:
using
directive for UnityEngine.UI
to access the Button class.With Max Items set to 1
, the Win button will show up on collecting the only Pickup_Item
in the scene. Clicking the button doesn’t do anything right now, but we’ll address that in the following section.
Right now, our win condition works as expected, but the player still has control over the capsule and doesn’t have a way of restarting the game once it’s over. Unity provides a property in the Time
class called timeScale
, which when set to 0
freezes the game scene. However, to restart the game, we need access to a namespace called SceneManagement
that isn’t accessible from our classes by default.
A namespace collects and groups a set of classes under a specific name to organize large projects and avoid conflicts between scripts that may share the same names. A using
directive needs to be added to a class to access a namespace’s classes.
All C# scripts created from Unity come with three default using
directives, shown in the following code snippet:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
These allow access to common namespaces, but Unity and C# offer plenty more that can be added with the using
keyword followed by the name of the namespace.
Since our game will need to be paused and restarted when a player wins or loses, this is a good time to use a namespace that isn’t included in new C# scripts by default:
GameBehavior
and play:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
// 1
using UnityEngine.SceneManagement;
public class GameBehavior : MonoBehaviour
{
// ... No changes needed ...
private int _itemsCollected = 0;
public int Items
{
get { return _itemsCollected; }
set {
_itemsCollected = value;
if (_itemsCollected >= MaxItems)
{
ProgressText.text = "You've found all the items!";
WinButton.gameObject.SetActive(true);
// 2
Time.timeScale = 0f;
}
else
{
ProgressText.text= "Item found, only " + (MaxItems – _itemsCollected) + " more to go!";
}
}
}
public void RestartScene()
{
// 3
SceneManager.LoadScene(0);
// 4
Time.timeScale = 1f;
}
// ... No other changes needed ...
}
Figure 8.28: OnClick section of the button
Figure 8.29: Setting the Game_Manager object in On Click()
Figure 8.30: Choosing the restart method for the button click
Figure 8.31: Lighting panel in the Unity editor
Let’s break down the code, as follows:
SceneManagement
namespace with the using
keyword, which handles all scene-related logic like creating loading scenes.Time.timeScale
to 0
to pause the game when the win screen is displayed, which disables any input or movement.RestartScene
and call LoadScene()
when the win screen button is clicked:LoadScene()
takes in a scene index as an int
parameter0
to restart the game from the beginningTime.timeScale
to the default value of 1
so that when the scene restarts, all controls and behaviors will be able to execute again.Now, when you collect an item and click on the win screen button, the level restarts, with all scripts and components restored to their original values and set up for another round!
Congratulations! Hero Born is now a playable prototype. We implemented jumping and shooting mechanics, managed physics collisions and spawning objects, and added in a few basic UI elements to display feedback. We even got as far as resetting the level when the player wins.
A lot of new topics were introduced in this chapter, and it’s important to go back and make sure you understand what went into the code we wrote. Pay special attention to our discussions on enumerations, get
and set
properties, and namespaces. From here on in, the code is only going to get more complex as we dive further into the possibilities of the C# language.
In the next chapter, we’ll start working on getting our enemy GameObjects to take notice of our player when we get too close, resulting in a follow-and-shoot protocol that will up the stakes for our player.
Don’t forget to check your answers against mine in the Pop Quiz Answers appendix to see how you did!
Read this book alongside other users, Unity game development experts and the author himself.
Ask questions, provide solutions to other readers, chat with the author via. Ask Me Anything sessions and much more.
Scan the QR code or visit the link to join the community.
52.14.240.224