This chapter covers
The project in this chapter will tie together everything from previous chapters. Most chapters have been pretty self-contained, and there hasn’t been an end-to-end look at the entire game. I’ll walk you through pulling together pieces that have been introduced separately so that you know how to build a complete game out of all the pieces. I’ll also discuss the encompassing structure of the game, including switching levels and ending the game (displaying “Game Over” when you die, and “Success” when you reach the exit). And I’ll show you how to save the game, because saving the player’s progress becomes increasingly important as the game grows in size.
This chapter’s project is a demo of an action RPG. In this sort of game, the camera is placed high and looks down sharply (see figure 12.1), and the character is controlled by clicking the mouse where you want to go; you may be familiar with the game Diablo, which is an action RPG like this. I’m switching to yet another game genre so that we can squeeze in one more genre before the end of the book!
Figure 12.1 Screenshot of the top-down viewpoint
In full, the project in this chapter will be the biggest game yet. It’ll feature:
Whew, that’s a lot to pack in; good thing this is almost the last chapter!
We’ll develop the action RPG demo by building on the project from chapter 9. Copy that project’s folder and open the copy in Unity to start working. Or, if you skipped directly to this chapter, download the sample project for chapter 9 in order to build on that.
The reason we’re building on the chapter 9 project is that it’s the closest to our goal for this chapter and thus will require the least modification (compared to other projects). Ultimately, we’ll pull together assets from several chapters, so technically it’s not that different than if we started with one of those projects and pulled in assets from chapter 9.
Here’s a recap of what’s in the project from chapter 9:
This hefty list of features covers quite a bit of the action in the RPG demo already, but there’s a bit more that we’ll either need to modify or add.
The first two modifications will be to update the managers framework and to bring in computer-controlled enemies. For the former task, recall that updates to the framework were made in chapter 10, which means those updates aren’t in the project from chapter 9. For the latter task, recall that you programmed an enemy in chapter 3.
Updating the managers is a fairly simple task, so let’s get that out of the way first. The IGameManager interface was modified in chapter 10.
Listing 12.1 Adjusted IGameManager
public interface IGameManager {
ManagerStatus status {get;}
void Startup(NetworkService service);
}
The code in this listing adds a reference to NetworkService, so also be sure to copy over that additional script; drag the file from its location in the chapter 10 project (remember, a Unity project is a folder on your disc, so get the file from there), and drop it in the new project. Now modify Managers.cs to work with the changed interface.
Listing 12.2 Changing a bit of code in the Managers script
...
private IEnumerator StartupManagers() {
NetworkService network = new NetworkService();
foreach (IGameManager manager in _startSequence) {
manager.Startup(network);
}
...
Finally, adjust both InventoryManager and PlayerManager to reflect the changed interface. The next listing shows the modified code from InventoryManager; PlayerManager needs the same code modifications but with different names.
Listing 12.3 Adjusting InventoryManager to reflect IGameManager
...
private NetworkService _network;
public void Startup(NetworkService service) {
Debug.Log("Inventory manager starting...");
_network = service;
_items = new Dictionary<string, int>();
...
Once all the minor code changes are in, everything should still act as before. This update should work invisibly, and the game will still work the same. That adjustment was easy, but the next one will be harder.
Besides the NetworkServices adjustments from chapter 10, you also need the AI enemy from chapter 3. Implementing enemy characters involved a bunch of scripts and art assets, so you need to import all those assets.
First, copy over these scripts (remember, WanderingAI and ReactiveTarget were behaviors for the AI enemy, Fireball was the projectile fired, the enemy attacks the PlayerCharacter component, and SceneController handles spawning enemies):
PlayerCharacter.csSceneController.csWanderingAI.csReactiveTarget.csFireball.csSimilarly, get the Flame material, Fireball prefab, and Enemy prefab by dragging those files in. If you got the enemy from chapter 11 instead of 3, you may also need the added fire particle material.
After copying over all the required assets, the links between assets will probably be broken, so you’ll need to relink everything in order to get them to work. In particular, check the scripts on all prefabs because they probably disconnected. For example, the Enemy prefab has two missing scripts in the Inspector, so click the circle button (indicated in figure 12.2) to choose WanderingAI and ReactiveTarget from the list of scripts. Similarly, check the Fireball prefab and relink that script if needed. Once you’re through with the scripts, check the links to materials and textures.
Figure 12.2 Linking a script to a component
Now add SceneController.cs to the controller object and drag the Enemy prefab onto that component’s Enemy slot in the Inspector. You may need to drag the Fireball prefab onto the Enemy’s script component (select the Enemy prefab and look at WanderingAI in the Inspector). Also attach PlayerCharacter.cs to the player object so that enemies will attack the player.
Play the game and you’ll see the enemy wandering around. The enemy shoots fireballs at the player, although it won’t do much damage; select the Fireball prefab and set its Damage value to 10.
The other issue is that when you wrote this code in chapter 3, the player’s health was an ad hoc thing for testing. Now the game has a PlayerManager, so modify PlayerCharacter according to the next listing in order to work with health in that manager.
Listing 12.4 Adjusting PlayerCharacter to use health in PlayerManager
using UnityEngine;
using System.Collections;
public class PlayerCharacter : MonoBehaviour {
public void Hurt(int damage) {
Managers.Player.ChangeHealth(-damage);
}
}
At this point, you have a game demo with pieces assembled from multiple previous projects. An enemy character has been added to the scene, making the game more threatening. But the controls and viewpoint are still from the third-person movement demo, so let’s implement point-and-click controls for an action RPG.
This demo needs a top-down view and mouse control of the player’s movement (refer to figure 12.1). Currently, the camera responds to the mouse, whereas the player responds to the keyboard (that is, what was programmed in chapter 8), which is the reverse of what you want in this chapter. In addition, you’ll modify the color-changing monitor so that devices are operated by clicking them. In both cases, the existing code isn’t terribly far from what you need; you’ll make adjustments to both the movement and device scripts.
First, you’ll raise the camera to 8 Y to position it for an overhead view. You’ll also adjust OrbitCamera to remove mouse controls from the camera and only use arrow keys.
Listing 12.5 Adjusting OrbitCamera to remove mouse controls
...
void LateUpdate() {
_rotY -= Input.GetAxis("Horizontal") * rotSpeed;
Quaternion rotation = Quaternion.Euler(0, _rotY, 0);
transform.position = target.position - (rotation * _offset);
transform.LookAt(target);
}
...
With the camera raised even higher, the view when you play the game will be top-down. At the moment, though, the movement controls still use the keyboard, so let’s write a script for point-and-click movement.
The general idea for this code (illustrated in figure 12.3) will be to automatically move the player toward its target position. This position is set by clicking in the scene. In this way, the code that moves the player isn’t directly reacting to the mouse, but the player’s movement is being controlled indirectly by clicking.
Figure 12.3 Diagram of how point-and-click controls work
To implement this, create a new script called PointClickMovement and replace the RelativeMovement component on the player. Start coding PointClickMovement by pasting in the entirety of RelativeMovement (because you still want most of that script for handling falling and animations). Then, adjust the code according to this listing.
Listing 12.6 New movement code in PointClickMovement script
...
public class PointClickMovement : MonoBehaviour {
...
public float deceleration = 25.0f;
public float targetBuffer = 1.5f;
private float _curSpeed = 0f;
private Vector3 _targetPos = Vector3.one;
...
void Update() {
Vector3 movement = Vector3.zero;
if (Input.GetMouseButton(0)) {
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit mouseHit;
if (Physics.Raycast(ray, out mouseHit)) {
_targetPos = mouseHit.point;
_curSpeed = moveSpeed;
}
}
if (_targetPos != Vector3.one) {
if (_curSpeed > moveSpeed * .5f) {
Vector3 adjustedPos = new Vector3(_targetPos.x,
transform.position.y, _targetPos.z);
Quaternion targetRot = Quaternion.LookRotation(
adjustedPos - transform.position);
transform.rotation = Quaternion.Slerp(transform.rotation,
targetRot, rotSpeed * Time.deltaTime);
}
movement = _curSpeed * Vector3.forward;
movement = transform.TransformDirection(movement);
if (Vector3.Distance(_targetPos, transform.position) < targetBuffer) {
_curSpeed -= deceleration * Time.deltaTime;
if (_curSpeed <= 0) {
_targetPos = Vector3.one;
}
}
}
_animator.SetFloat("Speed", movement.sqrMagnitude);
...
Almost everything at the beginning of the Update() method was gutted, because that code was handling keyboard movement. Notice that this new code has two main if statements: one that runs when the mouse clicks, and one that runs when a target is set.
When the mouse clicks, set the target according to where the mouse clicked. Here’s yet another great use for raycasting: to determine which point in the scene is under the mouse cursor. The target position is set to where the mouse hits.
As for the second conditional, first rotate to face the target. Quaternion.Slerp() rotates smoothly to face the target, rather than immediately snapping to that rotation; also lock rotation while slowing down (otherwise the player can rotate oddly when at the target) by only rotating over half-speed. Then, transform the forward direction from the player’s local coordinates to global coordinates (in order to move forward). Finally, check the distance between the player and the target: If the player has almost reached the target, decrement the movement speed and eventually end the movement by removing the target position.
This takes care of moving the player using mouse controls. Play the game to test it out. Next, let’s make devices operate when clicked.
In chapter 9 (and here, until we adjust the code), devices were operated by pressing a button. Instead, they should operate when clicked. To do this, you’ll first create a base script that all devices will inherit from; the base script will have the mouse control, and devices will inherit that. Create a new script called BaseDevice and write the code shown in Listing 12.7.
Listing 12.7 BaseDevice script that operates when clicked
using UnityEngine;
using System.Collections;
public class BaseDevice : MonoBehaviour {
public float radius = 3.5f;
void OnMouseDown() {
Transform player = GameObject.FindWithTag("Player").transform;
if (Vector3.Distance(player.position, transform.position) < radius) {
Vector3 direction = transform.position - player.position;
if (Vector3.Dot(player.forward, direction) > .5f) {
Operate();
}
}
}
public virtual void Operate() {
// behavior of the specific device
}
}
Most of this code happens inside OnMouseDown because MonoBehaviour calls that method when the object is clicked. First, it checks the distance to the player, and then it uses the dot product to see if the player is facing the device. Operate() is an empty shell to be filled in by devices that inherit this script.
Now that BaseDevice is programmed, you can modify ColorChangeDevice to inherit from that script. This is the new code.
Listing 12.8 Adjusting ColorChangeDevice to inherit from BaseDevice
using UnityEngine;
using System.Collections;
public class ColorChangeDevice : BaseDevice {
public override void Operate() {
Color random = new Color(Random.Range(0f,1f),
Random.Range(0f,1f), Random.Range(0f,1f));
GetComponent<Renderer>().material.color = random;
}
}
Because this script inherits from BaseDevice instead of MonoBehaviour, it gets the mouse control functionality. Then it overrides the empty Operate() method to program the color-changing behavior.
Now it will operate when you click it. Also remove the player’s DeviceOperator script component, because that script operates devices using the control key.
This new device input brings up an issue with the movement controls: currently, the movement target is set any time the mouse clicks, but you don’t want to set the movement target when clicking devices. You can fix this issue by using layers; similar to how a tag was set on the player, objects can be set to different layers and the code can check for that. Adjust PointClickMovement to check for the object’s layer.
Listing 12.9 Adjusting mouse click code in PointClickMovement
...
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit mouseHit;
if (Physics.Raycast(ray, out mouseHit)) {
GameObject hitObject = mouseHit.transform.gameObject;
if (hitObject.layer == LayerMask.NameToLayer("Ground")) {
_targetPos = mouseHit.point;
_curSpeed = moveSpeed;
}
}
...
This listing adds a conditional inside the mouse click code to see if the clicked object is on the Ground layer. Layers (like Tags) is a drop-down menu at the top of the Inspector; click it to see the options. Also, like tags, several layers are already defined by default. You want to create a new layer, so choose Edit Layers in the menu. Type Ground in an empty layer slot (probably slot 8; NameToLayer() in the code converts names into layer numbers so that you can say the name instead of the number).
Now that the Ground layer has been added to the menu, set ground objects to the Ground layer—that means the floor of the building, along with the ramps and platforms that the player can walk on. Select those objects, and then select Ground in the Layers menu.
Play the game and you won’t move when clicking the color-changing monitor. Great, the point-and-click controls are complete! One more thing to bring into this project from previous projects is the UI.
Chapter 9 used Unity’s old immediate-mode GUI because that approach was simpler to code. But the UI from chapter 9 doesn’t look as nice as the one from chapter 7, so let’s bring over that interface system. The newer UI is more visually polished than the old GUI; figure 12.4 shows the interface you’re going to create.
Figure 12.4 The UI for this chapter’s project
First, you’ll set up the UI graphics. Once the UI images are all in the scene, you can attach scripts to the UI objects. I’ll list the steps involved without going into detail; if you need a refresher, refer to chapter 7:
HUD Canvas and switch to 2D view mode.100, -40.Health: as the text on the label.Inventory Popup.0, 0 and scale the pop-up to 250 for width and 150 for height.Now you have the Health label in the corner and the large blue pop-up window in the center. Let’s program these parts first before getting deeper into the UI functionality. The interface code will use the same Messenger system from chapter 7, so copy over the Messenger script. Then create a GameEvent script.
Listing 12.10 GameEvent script to use with this Messenger system
public static class GameEvent {
public const string HEALTH_UPDATED = "HEALTH_UPDATED";
}
For now, only one event is defined; over the course of this chapter you’ll add a few more events. Broadcast this event from PlayerManager.cs.
Listing 12.11 Broadcasting the health event from PlayerManager.cs
...
public void ChangeHealth(int value) {
health += value;
if (health > maxHealth) {
health = maxHealth;
} else if (health < 0) {
health = 0;
}
Messenger.Broadcast(GameEvent.HEALTH_UPDATED);
}
...
The event is broadcast every time ChangeHealth() finishes to tell the rest of the program that the health has changed. You want to adjust the health label in response to this event, so create a UIController script.
Listing 12.12 The script UIController, which handles the interface
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class UIController : MonoBehaviour {
[SerializeField] private Text healthLabel;
[SerializeField] private InventoryPopup popup;
void Awake() {
Messenger.AddListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
}
void OnDestroy() {
Messenger.RemoveListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
}
void Start() {
OnHealthUpdated();
popup.gameObject.SetActive(false);
}
void Update() {
if (Input.GetKeyDown(KeyCode.M)) {
bool isShowing = popup.gameObject.activeSelf;
popup.gameObject.SetActive(!isShowing);
popup.Refresh();
}
}
private void OnHealthUpdated() {
string message = "Health: " + Managers.Player.health + "/" + Managers.Player.maxHealth;
healthLabel.text = message;
}
}
Attach this new script to the Controller object and remove BasicUI. Also, create an InventoryPopup script (add an empty public Refresh() method for now; the rest will be filled in later) and attach it to the pop-up window (the Image object). Now you can drag the pop-up to the reference slot in the Controller component and also link the health label to the Controller.
The health label changes when you get hurt or use health packs, and pressing the M key toggles the pop-up window. One last detail to adjust is that clicking the pop-up window currently causes the player to move; as with devices, you don’t want to set the target position when the UI has been clicked. Make the adjustment to PointClickMovement.
Listing 12.13 Checking the UI in PointClickMovement
using UnityEngine.EventSystems;
...
void Update() {
Vector3 movement = Vector3.zero;
if (Input.GetMouseButton(0) && !EventSystem.current.IsPointerOverGameObject()) {
...
Note that the conditional checks whether or not the mouse is on the UI. That completes the overall structure of the interface, so now let’s deal with the inventory pop-up specifically.
The pop-up window is currently blank, but it should display the player’s inventory (depicted in figure 12.5). These steps will create the UI objects:
0 Y and X values -75, -25, 25, and 75.
Figure 12.5 Diagram of the inventory UI
50 Y and X values -75, -25, 25, and 75.60.x2 for all the text labels.-120, -55 and set Right alignment.Energy: for the text on this label60, then Position at -50 Y and X values 0 or 70.Equip on one button and Use on the other.These are the visual elements for the inventory pop-up; next is the code. Write the contents of the following into the InventoryPopup script.
Listing 12.14 Full script for InventoryPopup
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
using System.Collections.Generic;
public class InventoryPopup : MonoBehaviour {
[SerializeField] private Image[] itemIcons;
[SerializeField] private Text[] itemLabels;
[SerializeField] private Text curItemLabel;
[SerializeField] private Button equipButton;
[SerializeField] private Button useButton;
private string _curItem;
public void Refresh() {
List<string> itemList = Managers.Inventory.GetItemList();
int len = itemIcons.Length;
for (int i = 0; i < len; i++) {
if (i < itemList.Count) {
itemIcons[i].gameObject.SetActive(true);
itemLabels[i].gameObject.SetActive(true);
string item = itemList[i];
Sprite sprite = Resources.Load<Sprite>("Icons/"+item);
itemIcons[i].sprite = sprite;
itemIcons[i].SetNativeSize();
int count = Managers.Inventory.GetItemCount(item);
string message = "x" + count;
if (item == Managers.Inventory.equippedItem) {
message = "Equipped\n" + message;
}
itemLabels[i].text = message;
EventTrigger.Entry entry = new EventTrigger.Entry();
entry.eventID = EventTriggerType.PointerClick;
entry.callback.AddListener((BaseEventData data) => {
OnItem(item);
});
EventTrigger trigger = itemIcons[i].GetComponent<EventTrigger>();
trigger.triggers.Clear();
trigger.triggers.Add(entry);
}
else {
itemIcons[i].gameObject.SetActive(false);
itemLabels[i].gameObject.SetActive(false);
}
}
if (!itemList.Contains(_curItem)) {
_curItem = null;
}
if (_curItem == null) {
curItemLabel.gameObject.SetActive(false);
equipButton.gameObject.SetActive(false);
useButton.gameObject.SetActive(false);
}
else {
curItemLabel.gameObject.SetActive(true);
equipButton.gameObject.SetActive(true);
if (_curItem == "health") {
useButton.gameObject.SetActive(true);
} else {
useButton.gameObject.SetActive(false);
}
curItemLabel.text = _curItem+":";
}
}
public void OnItem(string item) {
_curItem = item;
Refresh();
}
public void OnEquip() {
Managers.Inventory.EquipItem(_curItem);
Refresh();
}
public void OnUse() {
Managers.Inventory.ConsumeItem(_curItem);
if (_curItem == "health") {
Managers.Player.ChangeHealth(25);
}
Refresh();
}
}
Whew, that was a long script! With this programmed, it’s time to link everything in the interface. The script component now has the various object references, including the two arrays; expand both arrays and set to a length of 4 (see figure 12.6). Drag the four images to the icons array, and drag the four text labels to the labels array.
Figure 12.6 Arrays displayed in the Inspector
Similarly, slots in the component reference the text label and buttons at the bottom of the pop-up. After linking those objects, you’ll add OnClick listeners for both buttons. Link these events to the pop-up object, and choose either OnEquip() or OnUse() as appropriate.
Finally, add an EventTrigger component to all four of the item images. The InventoryPopup script modifies this component on each icon, so they better have this component! You’ll find EventTrigger under Add Component > Event. (It may be more convenient to copy/paste the component by clicking the little gear button in the top corner of the component, select Copy Component from one object, and then Paste As New on the other.) Add this component but don’t assign event listeners, because that’s done in the InventoryPopup code.
That completes the inventory UI! Play the game to watch the inventory pop-up respond when you collect items and click buttons. We’re now finished assembling parts from previous projects; next I’ll explain how to build a more expansive game from this beginning.
Now that you have a functioning action RPG demo, we’re going to build the overarching structure of this game. By that I mean the overall flow of the game through multiple levels and progressing through the game by beating levels. What we got from chapter 9’s project was a single level, but the roadmap for this chapter specified three levels.
Doing this will involve decoupling the scene even further from the Managers backend, so you’ll broadcast messages about the managers (just as PlayerManager broadcasts health updates). Create a new script called StartupEvent (Listing 12.15); define these events in a separate script because these events go with the reusable Managers system, whereas GameEvent is specific to the game.
Listing 12.15 The StartupEvent script
public static class StartupEvent {
public const string MANAGERS_STARTED = "MANAGERS_STARTED";
public const string MANAGERS_PROGRESS = "MANAGERS_PROGRESS";
}
Now it’s time to start adjusting Managers, including broadcasting these new events!
Currently the project has only one scene, and the Game Managers object is in that scene. The problem with that is that every scene will have its own set of game managers, whereas you want a single set of game managers shared by all scenes. To do that, you’ll create a separate Startup scene that initializes the managers and then shares that object with the other scenes of the game.
We’re also going to need a new manager to handle progress through the game. Create a new script called MissionManager.
Listing 12.16 Creating MissionManager
using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;
using System.Collections.Generic;
public class MissionManager : MonoBehaviour, IGameManager {
public ManagerStatus status {get; private set;}
public int curLevel {get; private set;}
public int maxLevel {get; private set;}
private NetworkService _network;
public void Startup(NetworkService service) {
Debug.Log("Mission manager starting...");
_network = service;
curLevel = 0;
maxLevel = 1;
status = ManagerStatus.Started;
}
public void GoToNext() {
if (curLevel < maxLevel) {
curLevel++;
string name = "Level" + curLevel;
Debug.Log("Loading " + name);
SceneManager.LoadScene(name);
} else {
Debug.Log("Last level");
}
}
}
For the most part, there’s nothing unusual going on in this listing, but note the LoadScene() method near the end; although I mentioned that method before (in chapter 5), it wasn’t important until now. That’s Unity’s method for loading a scene file; in chapter 5 you used it to reload the one scene in the game, but you can load any scene by passing in the name of the scene file.
Attach this script to the Game Managers object in the scene. Also add the new component to the Managers script.
Listing 12.17 Adding a new component to the Managers script
...
[RequireComponent(typeof(MissionManager))]
public class Managers : MonoBehaviour {
public static PlayerManager Player {get; private set;}
public static InventoryManager Inventory {get; private set;}
public static MissionManager Mission {get; private set;}
...
void Awake() {
DontDestroyOnLoad(gameObject);
Player = GetComponent<PlayerManager>();
Inventory = GetComponent<InventoryManager>();
Mission = GetComponent<MissionManager>();
_startSequence = new List<IGameManager>();
_startSequence.Add(Player);
_startSequence.Add(Inventory);
_startSequence.Add(Mission);
StartCoroutine(StartupManagers());
}
private IEnumerator StartupManagers() {
...
if (numReady > lastReady) {
Debug.Log("Progress: " + numReady + "/" + numModules);
Messenger<int, int>.Broadcast(
StartupEvent.MANAGERS_PROGRESS, numReady, numModules);
}
yield return null;
}
Debug.Log("All managers started up");
Messenger.Broadcast(StartupEvent.MANAGERS_STARTED);
}
...
Most of this code should already be familiar to you (adding MissionManager is like adding other managers), but there are two new parts. One is the event that sends two integer values; you saw both generic valueless events and messages with a single number before, but you can send an arbitrary number of values with the same syntax.
The other new bit of code is the DontDestroyOnLoad() method. It’s a method provided by Unity for persisting an object between scenes. Normally, all objects in a scene are purged when a new scene loads, but by using DontDestroyOnLoad() on an object, you ensure that that object will still be there in the new scene.
Because the Game Managers object will persist in all scenes, you must separate the managers from individual levels of the game. In Project view, duplicate the scene file (Edit > Duplicate) and then rename the two files appropriately: one Startup and the other Level1. Open Level1 and delete the Game Managers object (it’ll be provided by Startup). Open Startup and delete everything other than Game Managers, Controller, Main Camera, HUD Canvas, and EventSystem. Adjust the camera by removing the OrbitCamera component, and changing the Clear Flags menu from Skybox to Solid Color. Remove the script components on Controller, and delete the UI objects (health label and InventoryPopup) parented to the Canvas.
The UI is currently empty, so create a new slider (see figure 12.7) and then turn off its Interactable setting. The Controller object also has no script components anymore, so create a new StartupController script (Listing 12.18) and attach that to the Controller object.
Figure 12.7 The Startup scene with everything unnecessary removed
Listing 12.18 The new StartupController script
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class StartupController : MonoBehaviour {
[SerializeField] private Slider progressBar;
void Awake() {
Messenger<int, int>.AddListener(StartupEvent.MANAGERS_PROGRESS,
OnManagersProgress);
Messenger.AddListener(StartupEvent.MANAGERS_STARTED,
OnManagersStarted);
}
void OnDestroy() {
Messenger<int, int>.RemoveListener(StartupEvent.MANAGERS_PROGRESS,
OnManagersProgress);
Messenger.RemoveListener(StartupEvent.MANAGERS_STARTED,
OnManagersStarted);
}
private void OnManagersProgress(int numReady, int numModules) {
float progress = (float)numReady / numModules;
progressBar.value = progress;
}
private void OnManagersStarted() {
Managers.Mission.GoToNext();
}
}
Next, link the Slider object to the slot in the Inspector. One last thing to do in preparation is add the two scenes to Build Settings. Building the app will be the topic of the next chapter, so for now choose File > Build Settings to see and adjust the list of scenes. Click the Add Current button to add a scene to the list (load both scenes and do this for each).
Now you can launch the game by clicking Play from the Startup scene. The Game Managers object will be shared in both scenes.
This structural change handles the sharing of game managers between different scenes, but you still don’t have any success or failure conditions within the level.
To handle level completion, you’ll put an object in the scene for the player to touch, and that object will inform MissionManager when the player reaches the objective. This will involve the UI responding to a message about level completion, so add another GameEvent.
Listing 12.19 Level Complete added to GameEvent.cs
public static class GameEvent {
public const string HEALTH_UPDATED = "HEALTH_UPDATED";
public const string LEVEL_COMPLETE = "LEVEL_COMPLETE";
}
Now add a new method to MissionManager in order to keep track of mission objectives and broadcast the new event message.
Listing 12.20 Objective method in MissionManager
...
public void ReachObjective() {
// could have logic to handle multiple objectives
Messenger.Broadcast(GameEvent.LEVEL_COMPLETE);
}
...
Adjust the UIController script to respond to that event.
Listing 12.21 New event listener in UIController
...
[SerializeField] private Text levelEnding;
...
void Awake() {
Messenger.AddListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
Messenger.AddListener(GameEvent.LEVEL_COMPLETE, OnLevelComplete);
}
void OnDestroy() {
Messenger.RemoveListener(GameEvent.HEALTH_UPDATED, OnHealthUpdated);
Messenger.RemoveListener(GameEvent.LEVEL_COMPLETE, OnLevelComplete);
}
...
void Start() {
OnHealthUpdated();
levelEnding.gameObject.SetActive(false);
popup.gameObject.SetActive(false);
}
...
private void OnLevelComplete() {
StartCoroutine(CompleteLevel());
}
private IEnumerator CompleteLevel() {
levelEnding.gameObject.SetActive(true);
levelEnding.text = "Level Complete!";
yield return new WaitForSeconds(2);
Managers.Mission.GoToNext();
}
...
You’ll notice that this listing has a reference to a text label. Open the Level1 scene to edit it, and create a new UI text object. This label will be a level completion message that appears in the middle of the screen. Set this text to Width 240, Height 60, Center for both Align and Vertical-align, and Font Size 22. Type Level Complete! in the text area and then link this text object to the levelEnding reference of UIController.
Finally, we’ll create an object that the player touches to complete the level (figure 12.8 shows what the objective looks like). This will be similar to collectible items: it needs a material and a script, and you’ll make the entire thing a prefab.
Figure 12.8 Objective object that the player touches to complete the level
Create a cube object at Position 18, 1, 0. Select the Is Trigger option of the Box Collider, turn off both Cast and Receive Shadows in Mesh Renderer, and set the object to the Ignore Raycast layer. Create a new material called objective; make it bright green and set the shader to Unlit > Color for a flat, bright look.
Next, create the ObjectiveTrigger script and attach that script to the objective object.
Listing 12.22 Code for ObjectiveTrigger to put on objective objects
using UnityEngine;
using System.Collections;
public class ObjectiveTrigger : MonoBehaviour {
void OnTriggerEnter(Collider other) {
Managers.Mission.ReachObjective();
}
}
Drag this object from the Hierarchy into Project view to turn it into a prefab; in future levels, you could put the prefab in the scene. Now play the game and go reach the objective. The completion message shows when you beat the level.
Next, let’s have a failure message to show when you lose.
The failure condition will be when the player runs out of health (because of the enemy attacking). First, add another GameEvent:
public const string LEVEL_FAILED = "LEVEL_FAILED";
Now adjust PlayerManager to broadcast this message when the player’s health drops to 0.
Listing 12.23 Broadcast Level Failed from PlayerManager
...
public void Startup(NetworkService service) {
Debug.Log("Player manager starting...");
_network = service;
UpdateData(50, 100);
status = ManagerStatus.Started;
}
public void UpdateData(int health, int maxHealth) {
this.health = health;
this.maxHealth = maxHealth;
}
public void ChangeHealth(int value) {
health += value;
if (health > maxHealth) {
health = maxHealth;
} else if (health < 0) {
health = 0;
}
if (health == 0) {
Messenger.Broadcast(GameEvent.LEVEL_FAILED);
}
Messenger.Broadcast(GameEvent.HEALTH_UPDATED);
}
public void Respawn() {
UpdateData(50, 100);
}
...
Add a method to MissionManager for restarting the level.
Listing 12.24 MissionManager, which can restart the current level
...
public void RestartCurrent() {
string name = "Level" + curLevel;
Debug.Log("Loading " + name);
SceneManager.LoadScene(name);
}
...
With that in place, add another event listener to UIController.
Listing 12.25 Responding to Level Failed in UIController
...
Messenger.AddListener(GameEvent.LEVEL_FAILED, OnLevelFailed);
...
Messenger.RemoveListener(GameEvent.LEVEL_FAILED, OnLevelFailed);
...
private void OnLevelFailed() {
StartCoroutine(FailLevel());
}
private IEnumerator FailLevel() {
levelEnding.gameObject.SetActive(true);
levelEnding.text = "Level Failed";
yield return new WaitForSeconds(2);
Managers.Player.Respawn();
Managers.Mission.RestartCurrent();
}
...
Play the game and let the enemy shoot you several times; the level failure message will appear. Great job—the player can now complete and fail levels! Building off that, the game must keep track of the player’s progress.
Right now, the individual level operates independently, without any relation to the overall game. You’ll add two things that will make progress through the game feel more complete: saving the player’s progress and detecting when the game (not just the level) is complete.
Saving and loading the game is an important part of most games. Unity and Mono provide I/O functionality that you can use for this purpose. Before you can start using that, though, you must add UpdateData() for both MissionManager and InventoryManager. That method will work like it does in PlayerManager and will enable code outside the manager to update data within the manager. Listing 12.26 and Listing 12.27 show the changed managers.
Listing 12.26 UpdateData() method in MissionManager
...
public void Startup(NetworkService service) {
Debug.Log("Mission manager starting...");
_network = service;
UpdateData(0, 1);
status = ManagerStatus.Started;
}
public void UpdateData(int curLevel, int maxLevel) {
this.curLevel = curLevel;
this.maxLevel = maxLevel;
}
...
Listing 12.27 UpdateData() method in InventoryManager
...
public void Startup(NetworkService service) {
Debug.Log("Inventory manager starting...");
_network = service;
UpdateData(new Dictionary<string, int>());
status = ManagerStatus.Started;
}
public void UpdateData(Dictionary<string, int> items) {
_items = items;
}
public Dictionary<string, int> GetData() {
return _items;
}
...
Now that the various managers all have UpdateData() methods, the data can be saved from a new code module. Saving the data will involve a procedure referred to as serializing the data.
You’ll save the game as binary data, but note that C# is also fully capable of saving text files. For example, the JSON strings you worked with in chapter 10 were data serialized as text. Previous chapters used PlayerPrefs but in this project, you’re going to save a local file; PlayerPrefs are only intended to save a handful of values, like settings, not an entire game. Create the DataManager script (Listing 12.28).
Listing 12.28 New script for DataManager
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;
public class DataManager : MonoBehaviour, IGameManager {
public ManagerStatus status {get; private set;}
private string _filename;
private NetworkService _network;
public void Startup(NetworkService service) {
Debug.Log("Data manager starting...");
_network = service;
_filename = Path.Combine(
Application.persistentDataPath, "game.dat");
status = ManagerStatus.Started;
}
public void SaveGameState() {
Dictionary<string, object> gamestate =
new Dictionary<string, object>();
gamestate.Add("inventory", Managers.Inventory.GetData());
gamestate.Add("health", Managers.Player.health);
gamestate.Add("maxHealth", Managers.Player.maxHealth);
gamestate.Add("curLevel", Managers.Mission.curLevel);
gamestate.Add("maxLevel", Managers.Mission.maxLevel);
FileStream stream = File.Create(_filename);
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(stream, gamestate);
stream.Close();
}
public void LoadGameState() {
if (!File.Exists(_filename)) {
Debug.Log("No saved game");
return;
}
Dictionary<string, object> gamestate;
BinaryFormatter formatter = new BinaryFormatter();
FileStream stream = File.Open(_filename, FileMode.Open);
gamestate = formatter.Deserialize(stream) as Dictionary<string, object>;
stream.Close();
Managers.Inventory.UpdateData((Dictionary<string, int>)gamestate["inventory"]);
Managers.Player.UpdateData((int)gamestate["health"], (int)gamestate["maxHealth"]);
Managers.Mission.UpdateData((int)gamestate["curLevel"], (int)gamestate["maxLevel"]);
Managers.Mission.RestartCurrent();
}
}
During Startup(), the full file path is constructed using Application.persistentDataPath, a location Unity provides to store data in. The exact file path differs on different platforms, but Unity abstracts it behind this static variable (incidentally, this path includes both Company Name and Product Name from Player Settings, so adjust those if needed). The File.Create() method will create a binary file; call File.CreateText() if you want a text file.
Open the Startup scene to find Game Managers. Add the DataManager script component to the Game Managers object, and then add the new manager to the Managers script.
Listing 12.29 Adding DataManager to Managers.cs
...
[RequireComponent(typeof(DataManager))]
...
public static DataManager Data {get; private set;}
...
void Awake() {
DontDestroyOnLoad(gameObject);
Data = GetComponent<DataManager>();
Player = GetComponent<PlayerManager>();
Inventory = GetComponent<InventoryManager>();
Mission = GetComponent<MissionManager>();
_startSequence = new List<IGameManager>();
_startSequence.Add(Player);
_startSequence.Add(Inventory);
_startSequence.Add(Mission);
_startSequence.Add(Data);
StartCoroutine(StartupManagers());
}
...
Finally, in Level1, add buttons to use functions in DataManager (figure 12.9 shows the buttons). Create two buttons parented to the HUD Canvas (not in the Inventory pop-up). Call them (set the attached text objects) Save Game and Load Game, set Anchor to bottom-right, and position them at -100,65 and -100,30.
Figure 12.9 Save and Load buttons on the bottom right of the screen
These buttons will link to functions in UIController, so write those methods.
Listing 12.30 Save and Load methods in UIController
...
public void SaveGame() {
Managers.Data.SaveGameState();
}
public void LoadGame() {
Managers.Data.LoadGameState();
}
...
Link these functions to OnClick listeners in the buttons (add a listing in the OnClick setting, drag in the UIController object, and select functions from the menu). Now play the game, pick up a few items, use a health pack to increase your health, and then save the game. Restart the game and check your inventory to verify that it’s empty. Click Load; you now have the health and items you had when you saved the game!
As implied by our saving of the player’s progress, this game can have multiple levels, not just the one level you’ve been testing. To properly handle multiple levels, the game must detect not only the completion of a single level, but also the completion of the entire game. First, add yet another GameEvent:
public const string GAME_COMPLETE = "GAME_COMPLETE";
Now modify MissionManager to broadcast that message after the last level.
Listing 12.31 Broadcasting Game Complete from MissionManager
...
public void GoToNext() {
...
} else {
Debug.Log("Last level");
Messenger.Broadcast(GameEvent.GAME_COMPLETE);
}
}
Respond to that message in UIController.
Listing 12.32 Adding an event listener to UIController
...
Messenger.AddListener(GameEvent.GAME_COMPLETE, OnGameComplete);
...
Messenger.RemoveListener(GameEvent.GAME_COMPLETE, OnGameComplete);
...
private void OnGameComplete() {
levelEnding.gameObject.SetActive(true);
levelEnding.text = "You Finished the Game!";
}
...
Try completing the level to see what happens: move the player to the level objective to complete the level as before. You’ll first see the Level Complete message, but after a couple of seconds it’ll change to a message about completing the game.
At this point, you can add an arbitrary number of additional levels, and MissionManager will watch for the last level. The final thing you’ll do in this chapter is add a few more levels to the project in order to demonstrate the game progressing through multiple levels.
Duplicate the Level1 scene file twice (Unity should automatically increment the numbers to Level2 and Level3) and add the new levels to Build Settings (so that they can be loaded during gameplay; remember to generate the lighting). Modify each scene so that you can tell the difference between levels; feel free to rearrange most of the scene, but there are several essential game elements that you must keep: the player object that’s tagged Player, the floor object set to the Ground layer, and the objective object, Controller, HUD Canvas, and EventSystem.
You also need to adjust MissionManager to load the new levels. Change maxLevel to 3 by changing the UpdateData(0, 1) call to UpdateData(0, 3). Now play the game and you’ll start on Level1 initially; reach the level objective and you'll move on to the next level! Incidentally, you can also save on a later level to see that the game will restore that progress.
You now know how to create a full game with multiple levels. The obvious next task is the final chapter: getting your game into the hands of players.
Application .persistentDataPath.