This chapter continues on from the previous one by completing the twin-stick space shooter game. At this point, we already have a working game. The gamer can control a spaceship using two axes: movement and rotation. The WASD keys on the keyboard control movement and the spaceship rotates to face the mouse cursor. In addition to player controls, the level features enemy characters that spawn at regular intervals and move toward the player. Finally, both the player and enemies support a Health component, which means both objects are susceptible to damage. Right now, however,the game lacks two essential features: the player cannot fight back against the enemies and there is no system to keep track of and display the score. This chapter tackles these issues and more.
Once you've completed this chapter, you should have a solid understanding of the following topics:
Generating projectiles, as we'll see, presents a particularly interesting problem regarding memory management. However, before we get to that, let's look at the technical requirements for this chapter and create the projectile prefab.
As this chapter is a continuation of the previous one, it assumes that you have completed and are comfortable with the information provided in the last chapter, specifically how to create a new script and add it to an object as a component, and creating prefabs from objects in the scene.
The project so far can be found in the book's companion files in the Chapter04/Start folder. You can start there and follow along with this chapter if you don't have your own project already. If you want to test the final game, you can find the completed project in the Chapter04/End folder.
Currently, the level contains a player and an enemy ship, which the player must shoot, as shown in Figure 4.1:
The player doesn't yet have any method of killing the enemy, so we'll start the chapter by implementing the last significant system missing from the game: the projectile system. Thinking carefully about how we want our weapon systems to behave, we can identify a few concepts that need development:
Any future projectile system you create may have different requirements and therefore require you to implement different systems.
We'll tackle each of these areas in order, starting with the projectile spawn location.
We'll call the points where bullets are spawned and fired "turrets." For this game, the player will have only one turret, but ideally, the game should support easily adding more turrets if desired. Let's jump right in and create the first turret:
Once you've followed these steps, you'll have a setup similar to Figure 4.2:
Creating a Turret object for the ammo as a spawn location is an excellent start, but for ammo to be fired, we'll need an ammo object. Specifically, we'll create an Ammo prefab that can be instantiated as ammo when needed. We'll do this next.
When the player presses the fire button, the spaceship should shoot projectile objects. These objects will be based on an Ammo prefab. As part of the prefab creation, we will configure the projectile's texture, reuse a previously written script to enable projectile movement, and add new logic to handle collisions with enemies. We'll start by configuring the projectile's texture; that way, we can more easily see the projectile move and collide with enemies, which will help us debug any issues when we get to that stage.
To start, we'll configure the texture to be used as the projectile graphic:
Currently, Unity recognizes the texture (and each ammo element) as a complete unit. We can use the Sprite Editor, however, to separate each part.
After accepting the changes in the Sprite Editor, Unity automatically cuts the relevant sprites into separate units, each of which can now be selected as an object in the Project panel by clicking on the left arrow to the side of the texture:
Now that we've separated the sprites, drag and drop one of them from the Project panel to the scene via the Hierarchy panel. On doing this, it will be added as a Sprite object. This object represents the beginning of our Ammo prefab. The sprite itself may not initially be oriented to face the game camera. Rename the object to Ammo_Sprite and, if required, rotate the sprite by 90 degrees on the X axis, as shown in Figure 4.7:
With the initial projectile object created, correctly rotated, and visible to the player (and us), we can move on to configuring the object so that it moves through the scene when spawned by the player.
As we've previously created a movement script, enabling movement for the projectile will be quick and easy. However, before we can attach the Mover script, we'll create a parent object to attach the script to:
Tip
If the projectile moves up or down incorrectly, then make sure that the parent object is rotated so that its blue forward vector is pointing forward, as shown in Figure 4.8.
Great! Now that the projectile is moving through the scene, we can set up collisions so that it destroys any enemies in its path.
Any objects that we want to collide in Unity should have a Rigidbody component attached so that they are included in Unity's physics system. We'll add and configure a Rigidbody component for the ammo now:
For our purposes, gravity does not need to apply to the ammo as it should simply travel along and eventually be destroyed. This highlights an essential point in game development generally: real-world physics need not apply to every object accurately. We only need physics to make objects appear correct to the player when they're looking.
In addition to adding a Mover script and physics components, we also need the ammo to damage objects and destroy or disable itself on collision. To achieve this, create a new script file called Ammo.cs. The entire code for this is included in the following code block, as follows:
public class Ammo : MonoBehaviour
{
public float Damage = 100f;
public float LifeTime = 2f;
void OnEnable()
{
CancelInvoke();
Invoke("Die", LifeTime);
}
void OnTriggerEnter(Collider Col)
{
Health H = Col.gameObject.GetComponent<Health>();
if(H == null) { return; }
H.HealthPoints -= Damage;
Die();
}
void Die()
{
gameObject.SetActive(false);
}
}
The following points summarize the preceding code:
To apply the new script to the ammo object, take the following steps:
Congratulations! You've now created an Ammo prefab, which we will spawn from the turret location we previously set up. However, before we can start work on the spawn system, we need a method of creating and storing projectiles efficiently, which is where the object pool design pattern comes in.
The Ammo prefab created so far presents us with a technical problem that, if not taken seriously, has the potential to cause serious performance issues in our game. When the spaceship weapon is fired, we'll need to generate ammo that launches into the scene and destroys the enemies on collision. This is fine in general, but the problem is that the player could potentially press the fire button many times in quick succession, and could even hold down the fire button for long periods of time, thereby spawning potentially hundreds of Ammo prefabs. We could use the Instantiate function to generate these prefabs dynamically, but this is problematic because instantiating is computationally expensive. When used to instantiate many items in succession, it will typically cause a nightmarish slowdown that'll reduce the frames per second (FPS) to unacceptable levels. We need to avoid this!
The solution is known as Pooling, Object Pooling, or Object Caching. In essence, it means that we must spawn a large and recyclable batch of ammo objects at the level startup (a pool of objects) that initially begin hidden or deactivated, and we activate the objects as and when needed. When the ammo collides with an enemy, or when its lifetime expires, we don't destroy the object entirely; we deactivate it again, returning it to the pool for reuse later if needed. In this way, we avoid all calls to instantiate, apart from the initial pool creation, and recycle the ammo objects that we have.
To get started with coding the object pool, we'll make an AmmoManager class. This class will be responsible for two things:
Consider the following AmmoManager code block created to achieve this:
public class AmmoManager : MonoBehaviour
{
public static AmmoManager AmmoManagerSingleton = null;
public GameObject AmmoPrefab = null;
public int PoolSize = 100;
public Queue<Transform> AmmoQueue = new Queue<Transform>();
private GameObject[] AmmoArray;
void Awake ()
{
if(AmmoManagerSingleton != null)
{
Destroy(GetComponent<AmmoManager>());
return;
}
AmmoManagerSingleton = this;
AmmoArray = new GameObject[PoolSize];
for(int i = 0; i < PoolSize; ++i)
{
AmmoArray[i] = Instantiate(AmmoPrefab, Vector3.zero, Quaternion.identity, transform) as GameObject;
Transform ObjTransform = AmmoArray[i].transform;
AmmoQueue.Enqueue(ObjTransform); AmmoArray[i].SetActive(false);
}
}
public static Transform SpawnAmmo (Vector3 Position, Quaternion Rotation)
{
Transform SpawnedAmmo = AmmoManagerSingleton.AmmoQueue.Dequeue();
SpawnedAmmo.gameObject.SetActive(true);
SpawnedAmmo.position = Position;
SpawnedAmmo.localRotation = Rotation;
AmmoManagerSingleton.AmmoQueue.Enqueue(SpawnedAmmo);
return SpawnedAmmo;
}
}
The following points summarize the code block:
To use this class, take the following steps:
The scene now features an AmmoManager object to maintain an ammo pool. However, nothing in our existing functionality connects a fire button press from the gamer with the generation of a projectile in the scene. Let's fix that now.
The game now has an efficient way of storing projectiles that we can retrieve when required. We'll put this system to the test soon as we create the logic to spawn the projectiles whenever the player presses a specific button. The functionality we require will sit nicely in the PlayerController script that we started in the previous chapter. The amended PlayerController class is included in the following code block. Only the changes are included in the following code; for the full class, please refer to the Chapter04/End/Assets/Scripts folder:
public class PlayerController : MonoBehaviour
{
public string FireAxis = "Fire1";
public float ReloadDelay = 0.3f;
public bool CanFire = true;
public Transform[] TurretTransforms;
Void Awake ()
{
ThisBody = GetComponent<Rigidbody>();
}
Void FixedUpdate ()
{
…
if(Input.GetButtonDown(FireAxis) && CanFire)
{
foreach(Transform T in TurretTransforms)
{
AmmoManager.SpawnAmmo(T.position, T.rotation);
}
CanFire = false;
Invoke ("EnableFire", ReloadDelay);
}
}
void EnableFire()
{
CanFire = true;
}
}
In summary, we have the following:
Once the ammo is fired, ReloadDelay is engaged (set to true). This means that the delay must first expire before new ammo can be fired again later.
After adding this code to PlayerController, select the Player object in the scene, enter 1 in the Size field of Turret Transforms, and then drag and drop the empty object onto the Turret Transforms slot. This example uses only one turret, but you could add more if desired:
Now you're ready to playtest and fire ammo. By playing the scene and pressing fire on the keyboard or mouse (left-click), projectiles will be generated. Excellent! However, when testing, you may notice two main problems. First, the ammo is the incorrect size. Second, the ammo sometimes bounces, flips, or reacts to the player's spaceship. Fixing the size issue is simple:
However, fixing the second issue, where the projectile collides with the player ship, is slightly more complex, and involves editing Unity's physics settings, which is what we'll look at next.
If the ammo appears to bounce or react to the player's spaceship, then we'll need to make the ammo immune or unresponsive to the Player object. To achieve this, we can use physics layers:
Both Player and Ammo have now been assigned to the same layer. From here, we can make all objects in the same layer ignore each other.
With the Layer Collision Matrix set, test the game again by pressing Play on the toolbar. When you do this and press fire, the projectiles should no longer react to the player spaceship. The projectile should, however, still collide with and destroy the enemies:
Excellent work! We now have a spaceship that can fire weapons and destroy enemies, and the physics works as expected. Maybe you'd like to customize the player controls a little, or perhaps you want to use a gamepad. The next section will explore this further.
You may not like the default controls and key combinations associated with the input axes—Horizontal, Vertical, and Fire1. Perhaps you want to change them. Even if you don't want to change the controls for this game, you most certainly will in a future project, so it's good to know how.
The input axes are read using the Input.GetAxis function (shown earlier) and are specified by human-readable names, but it's not immediately clear how Unity maps specific input buttons and devices to these virtual axes. Here, we'll see how to customize them. To get started, do the following:
On selecting this option, a collection of custom-defined input axes appears as a list. This defines all the axes used by the input system. The Horizontal and Vertical axes should be listed here:
By expanding each axis in the Project Settings panel, you can specify how specific keys and controls on hardware devices, such as a keyboard and mouse, will map to an axis. The Horizontal axis, for example, is defined twice. For the first definition, Horizontal is mapped to the left, right, and A and D keys on the keyboard. Right and D are mapped as Positive Button because, when pressed, they produce positive floating-point values from the Input.GetAxis function (0–1). Left and A are mapped as Negative Button because, when pressed, they result in negative floating-point values for Input.GetAxis. This makes it easy to move objects left and right using negative and positive numbers:
As mentioned, the Horizontal input is defined twice—once near the top of the list and again near the bottom. These two definitions are accumulative and not contradictory—they stack on top of one another. They allow you to map multiple devices to the same axis, giving you cross-platform and multidevice control over your games.
By default, Horizontal is mapped in the first definition to the left, right, A, and D keys on the keyboard, and in the second definition, to joystick motion. Both definitions are valid and work together. You can have as many definitions for the same axis as you need, depending on the controls you need to support.
More Information
More information on player input and customizing controls can be found in the online Unity documentation at https://docs.unity3d.com/Manual/class-InputManager.html.
For this project, the controls will remain at their defaults, but go ahead and change or add additional controls if you want to support different configurations.
With the player now controlled precisely as we want, and able to shoot projectiles that collide with and destroy our enemies, there's one last thing missing from our core gameplay loop—keeping track of and displaying the score.
We want to be able to assign a point value to each enemy and have a visible score increase with each kill. This goal can be split into two tasks: first, we need the UI to display the score and second, we need logic to track the current score and push any changes to the UI. As the second task (the score logic) requires the UI, we'll start by creating an interface to display the score before moving on to the logic.
UI is an acronym for user interface and refers to all the 2D graphical elements that sit atop the game window and provide information to the player:
The Canvas object is not visible itself in the Game tab. Instead, it acts as a container. Even so, it strongly influences how contained objects appear on the screen in terms of size, position, and scale. For this reason, before adding objects and refining the design of an interface, it's helpful to configure your Canvas object first. To do this, take these steps:
By adjusting the Canvas Scaler component to Scale With Screen Size, the UI for the game will automatically stretch and shrink to fit the target resolution, ensuring that each element is scaled to the same proportions, maintaining the overall look and feel. This is a quick and easy method to create a UI once and have it adjust its size to fit nearly any resolution. It may not always be the best solution to maintaining the highest quality graphical fidelity, but it's functional and suitable for many applications.
In any case, before proceeding with the UI design, it's helpful to see both the Scene viewport and Game tab side by side in the interface (or across two monitors, if you have a multi-monitor configuration). This allows us to build the interface in the Scene viewport, and then preview its effects in the Game tab. You can rearrange the Scene and Game tabs by dragging and dropping the Game tab beside the Scene tab in the Unity Editor. Your Unity Editor should then look similar to the one in Figure 4.21:
Next, let's add the text widget to the UI to display the game score:
This creates a new text object as a child of the Canvas object, as opposed to a top-level object with no parent. The text object is useful to draw text on screen with a specific color, size, and font setting.
By default, the text object may not initially appear visible in either the scene or viewport, even though it's listed as an object in the Hierarchy panel. However, look more closely at the scene, and you're likely to see a very small and dark text object, which appears both in the Canvas and the Game tab, as shown in Figure 4.22:
By default, new text objects feature black text with a small font size. For this project, these settings will need to be changed:
The text still appears too small, even after changing its size. If you increase the size further, however, the text may disappear from view. This happens because each text object has a rectangular boundary defining its limits. When the font size increases beyond what can fit in the boundary, the text is hidden altogether. To fix this, we'll expand the text boundary:
Although the text is now aligned vertically within its containing boundary, we'll still need to align it as a whole to the canvas container to ensure that it remains on screen at the same position and orientation, even if the Game window is resized and realigned. To do this, we'll use Anchors:
As you move the text object in the Scene viewport, check its appearance in the Game tab to ensure that it looks correct and appropriate. To better understand what it will look like in-game, you can set the Text field in the Inspector to Score:, as shown in Figure 4.26. However, this is not essential as we will update this text programmatically soon::
To secure the position of the text object on screen (preventing it from sliding or moving), even if the user resizes the Game tab, we can set the object's Anchor position to the top-right corner of the screen. Using an Anchor ensures that the text is always positioned as a constant, proportional offset from its Anchor. To configure an Anchor for the text object, do the following:
Excellent work! The text object is now ready to use. Of course, in play mode, the text remains unchanged and doesn't display a real score. That's because we need to add some code. However, overall, the text object is in place, and we can move on to writing that code.
To display a score in the newly created UI, we'll first need to create a scoring system in code. The score functionality will be added to a general, overarching GameController class, responsible for all game-wide logic and features. The code for GameController is included in the following code block:
public class GameController : MonoBehaviour
{
public static GameController ThisInstance = null;
public static int Score;
public string ScorePrefix = string.Empty;
public Text ScoreText = null;
public Text GameOverText = null;
void Awake()
{
ThisInstance = this;
}
void Update()
{
if(ScoreText!=null) {
ScoreText.text = ScorePrefix + Score.ToString();
}
}
public static void GameOver()
{
if(ThisInstance.GameOverText!=null) {
ThisInstance.GameOverText.gameObject. SetActive(true); }
}
}
In summary, we have the following:
To use the new script, take the following steps:
Now, take the game for a test run, and you'll see the score display at the top-right corner of the Game tab using the UI text object. The score will always remain at 0 because we are yet to write the functionality to increase it. For our game, the score should increase when an enemy object is destroyed. To achieve this, we'll create a new script file, ScoreOnDestroy:
public class ScoreOnDestroy : MonoBehaviour
{
public int ScoreValue = 50;
void OnDestroy()
{
GameController.Score += ScoreValue;
}
}
The script should be attached to any object that assigns you points when it's destroyed, such as the enemies. ScoreValue specifies the total number of points awarded. To attach the script to the enemy prefab, take the following steps:
Great work! You now have destroyable enemies that assign you points on destruction. This means that you can finally have an in-game score and could even extend gameplay to include high-score features and leaderboards. This also means that our game is almost finished and ready to build, but before that, we'll add a couple of small final touches.
In this section, we'll add the final touches to the game. We'll start by adding a background image to fit the space theme, and then we'll move on to adding background music. These small additions should have a significant impact on the feel of the game.
First on the agenda is fixing the game background! Until now, the background has displayed the default background color associated with the game camera. However, as the game is set in space, we should display a space background. To do this, take the following steps:
The correctly configured background is shown in Figure 4.30:
With those steps completed, you should now have an appropriate background for your space game!
UI Image
There are several different methods of implementing a background image. We could, for example, have created the background as a UI image. We could then use the rectangle and Anchors tools, outlined in the Implementing the score UI section, to make it fullscreen.
With the background looking the part, let's make it sound the part too by adding background music, an integral component of many games.
Now that the level has a suitable background, let's add some background music, which will play on a loop. To do this, take the following steps:
2D sounds have a consistent volume throughout the level regardless of the player's position because 2D sounds are not spatially located. 3D sounds, in contrast, are used for gunshots, footsteps, explosions, and other sounds that exist in 3D space and whose volume should change based on how close the player is standing to them when they play.
Now, let's take the game for a test run! Click on the Play button on the toolbar and test it out. If the music doesn't play, check that the Mute Audio button is disabled in the Game tab:
And that's almost a wrap! We've implemented every feature we desired in our space game. However, before we move on to the next project, we'll look at the tools Unity provides to help you test and diagnose potential performance issues.
Testing and diagnosis
With practically all games, you'll need to spend considerable time testing and debugging to reduce bugs and errors as much as humanly possible. With this sample program, very little debugging and testing has been required by you, but that's not just because the game is simple. It's because I've already prechecked and pretested most of the code and functionality before presenting the material to you in this book, ensuring that you get a smooth learning experience. For your own projects, however, you'll need to do lots of testing. One way to get started is by using the Stats panel. To open this panel, click on the Stats button in the Game tab:
More Information
More details on the Stats panel are included in Chapter 2, Creating a Collection Game, and more information can be found online in the Unity documentation at http://docs.unity3d.com/Manual/RenderingStatistics.html.
Another debugging tool is the Profiler. This tool is useful when the Stats panel has already helped you identify a general problem, such as a low FPS, and you want to dig deeper to find where the problem might be located.
More Information
More details on the Profiler are included later in Chapter 6, Continuing the 2D Adventure.
To use the Profiler tool, do the following:
Important Note
Reading and understanding the graph requires some experience, but as a general rule, watch out for mountains and peaks (sharp fluctuations) in the graph as this could indicate a problem, especially when it roughly coincides with frame rate drops.
Beneath the graph, you'll find detailed information on the currently selected module. In Figure 4.36, the CPU Usage module is selected, so this area will show a timeline of CPU instructions.
More Information
More information on the Profiler can be found in the online Unity documentation at https://docs.unity3d.com/Manual/Profiler.html.
Now, finally, we're ready to build a standalone version of our game to send off to friends, family, and testers! The process to do this is the same as detailed in Chapter 2, Creating a Collection Game, to build the coin collection game.
Great work! We're really on a roll now, having completed two Unity projects. Both the coin collection game and twin-stick shooter are, ultimately, simple games in that they don't rely on advanced mechanics or display sophisticated features. However, even complicated games, when boiled down to their fundamental ingredients, can be found to rest on a similar foundation of essential concepts such as the ones that we've covered so far. That's why our projects are so critical to understanding Unity from the ground up. In this project alone, we've learned how to spawn prefabs at runtime, import textures and audio, create a basic UI, implement reusable components, and determined how to cache objects using an object pool. No matter what project you are working on, some or all of these techniques will be useful.
In this chapter, while we created a game with a 2D perspective, we used mostly 3D components. In the next chapter, we'll be going fully 2D and creating a 2D platform game. Moving from 3D to 2D brings a few changes in our workflow, and these will be discussed in detail, as well as important 2D topics such as sprites, environmental physics, and 2D movement. We'll also look at adding post-processing effects. These effects can help improve the look of the game and are applied to the image before it appears onscreen.
Q1. Static variables are...
A. Hidden variables for static objects
B. Shared variables across all instances of a class
C. Protected variables
D. Constants
Q2. The Profiler is useful for...
A. Pausing gameplay
B. Identifying performance issues
C. Removing objects
D. Checking audio levels
Q3. UI objects are useful for...
A. Creating interface elements
B. Making animations
C. Sorting objects by name
D. Editing mesh objects
Q4. The Layer Collision Matrix lets you…
A. Prevent groups of objects from colliding
B. Make objects collide
C. Remove all collisions
D. Activate low-quality collisions
For more information, take a look at the following links: