Adding interactive devices and items within the game - Getting comfortable - Unity in Action: Multiplatform game development in C# with Unity 5 (2015)

Unity in Action: Multiplatform game development in C# with Unity 5 (2015)

Part 2. Getting comfortable

Chapter 8. Adding interactive devices and items within the game

This chapter covers

· Programming doors that the player can open (triggered with a keypress or collision)

· Enabling physics simulations that scatter a stack of boxes

· Building collectible items that players store in their inventory

· Using code to manage game state, such as inventory data

· Equipping and using inventory items

Implementing functional items is the next topic we’re going to focus on. Previous chapters covered a number of different elements of a complete game: movement, enemies, the user interface, and so forth. But our projects have lacked anything to interact with other than enemies, nor have they had much in the way of game state. In this chapter, you’ll learn how to create functional devices like doors. We’ll also discuss collecting items, which involves both interacting with objects in the level and tracking game state. Games often have to track state like the player’s current stats, progress through objectives, and so on. The player’s inventory is an example of this sort of state, so you’ll build a code architecture that can keep track of items collected by the player. By the end of this chapter, you’ll have built a dynamic space that really feels like a game!

We’ll start by exploring devices (such as doors) that are operated with keypresses from the player. After that, you’ll write code to detect when the player collides with objects in the level, enabling interactions like pushing objects around or collecting inventory items. Then you’ll set up a robust MVC (Model-View-Controller)-style code architecture to manage data for the collected inventory. Finally, you’ll program interfaces to make use of the inventory for gameplay, such as requiring a key to open a door.

Warning

Previous chapters were relatively self-contained and didn’t technically require projects from earlier chapters, but this time some of the code listings make edits to scripts from chapter 7. If you skipped directly to this chapter, download the sample project for chapter 7 in order to build on that.

The example project will have these devices and items strewn about the level randomly. A polished game would have a lot of careful design behind the placement of items, but there’s no need to carefully plan out a level that only tests functionality. Even so, though the placement of objects will be haphazard, the chapter opening bullets lay out the order in which we’ll implement things.

As usual, the explanations build up the code step by step, but if you want to see all the finished code in one place, you can download the sample project.

8.1. Creating doors and other devices

Although levels in games mostly consist of static walls and scenery, they also usually incorporate a lot of functional devices as well. I’m talking about objects that the player can interact with and operate—things like lights that turn on or a fan that starts turning. The specific devices can vary a lot and are mostly limited only by your imagination, but they almost all use the same sort of code to have the player activate the device. We’ll implement a couple of examples in this chapter, and then you should be able to adapt this same code to work with all sorts of other devices.

8.1.1. Doors that open and close on a keypress

The first kind of device we’ll program is a door that opens and closes, and we’re going to start with operating the door by pressing a key. There are lots of different kinds of devices you could have in a game, and lots of different ways of operating those devices. We’re eventually going to look at a couple of variations, but doors are the most common interactive devices found in games, and using items with a keypress is the most straightforward approach to start with.

The scene has a few spots where a gap exists between walls, so place a new object that blocks the gap. I created a new cube object and then set its transform to Position 2.5 1.5 17 and Scale 5 3 .5, creating the door shown in figure 8.1.

Figure 8.1. Door object fit into a gap in the wall

Create a C# script, call it DoorOpenDevice, and put that script on the door object. This code (shown in the next listing) will cause the object to operate as a door.

Listing 8.1. Script that opens and closes the door on command

The first variable defines the offset that’s applied when the door opens. The door will move this amount when it opens, and then it will subtract this amount when it closes. The second variable is a private Boolean for tracking whether the door is open or closed. In the Operate() method, the object’s transform is set to a new position, adding or subtracting the offset depending on whether the door is already open; then _open is toggled on or off.

As with other serialized variables, dPos appears in the Inspector. But this is a Vector3 value, so instead of one input box there are three, all under the one variable name. Type in the relative position of the door when it opens; I decided to have the door slide down to open, so the offset was 0 -2.9 0 (because the door object has a height of 3, moving down 2.9 leaves just a tiny sliver of the door sticking up out of the floor).

Note

The transform is applied instantly, but you may prefer seeing the movement when the door opens. As mentioned back in chapter 3, you can use tweens to make objects move smoothly over time. The word tween means different things in different contexts, but in game programming it refers to code commands that cause objects to move around; appendix D mentions iTween, one good tweening system for Unity.

Now other code needs to call Operate() to make the door open and close (the single function call handles both cases). We don’t yet have that other script on the player; writing that is the next step.

8.1.2. Checking distance and facing before opening the door

Create a new script and name it DeviceOperator. The following listing implements a control key that operates nearby devices.

Listing 8.2. Device control key for the player

The majority of the script in this listing should look familiar, but a crucial new method is at the center of this code. First, establish a value for how far away to operate devices from. Then, in the Update() function, look for keyboard input; since the Jump key is already being used by the RelativeMovement script, this time we’ll respond to Fire3 (which is defined in the project’s input settings as the left Command key).

Now we get to the crucial new method: OverlapSphere(). This method returns an array of all objects that are within a given distance of a given position. By passing in the position of the player and the radius variable, this detects all objects near the player. What you actually do with this list can vary (for example, perhaps you just set off a bomb and want to apply an explosive force), but in this situation we want to attempt to call Operate() on all nearby objects.

That method is called via SendMessage() instead of the typical dot notation, an approach you also saw with UI buttons in previous chapters. As was the case there, the reason to use SendMessage() is because we don’t know the exact type of the target object and that command works on all GameObjects. But this time we’re going to pass the option DontRequireReceiver to the method. This is because most of the objects returned by OverlapSphere() won’t have an Operate() method; normally Send-Message() prints an error message if nothing in the object received the message, but in this case the error messages would be distracting because we already know most objects will ignore the message.

Once the code is written, you can attach this script to the player object. Now you can open and close the door by standing near it and pressing the key.

There’s one little detail we can fix. Currently it doesn’t matter which way the player is facing, as long as the player is close enough. But we could also adjust the script to only operate devices the player is facing, so let’s do that. Recall from chapter 7 that you can calculate the dot product for checking facing. That’s a mathematical operation done on a pair of vectors that returns a range between -1 and 1, with 1 meaning they point in exactly the same direction and -1 when they point in exactly opposite directions. The next listing shows the new code in the DeviceOperator script.

Listing 8.3. Adjusting DeviceOperator to only operate devices that the player is facing

To use the dot product, we first determine the direction to check against. That would be the direction from the player to the object; make a direction vector by subtracting the position of the player from the position of the object. Then call Vector3.Dot() with both that direction vector and the forward direction of the player. When the dot product is close to 1 (specifically, this code checks greater than .5), that means the two vectors are close to pointing in the same direction.

With this adjustment made, the door won’t open and close when the player faces away from it, even if the player is close. And this same approach to operating devices can be used with any sort of device. To demonstrate that flexibility, let’s create another example device.

8.1.3. Operating a color-changing monitor

We’ve created a door that opens and closes, but that same device-operating logic can be used with any sort of device. We’re going to create another device that’s operated in the same way; this time, we’ll create a color-changing display on the wall.

Create a new cube and place it so that one side is barely sticking out of the wall. For example, I went with Position 10.9 1.5 -5. Now create a new script called ColorChangeDevice and attach that script (shown in the next listing) to the wall display. Now run up to the wall monitor and hit the same “operate” key as used with the door; you should see the display change color, as figure 8.2 illustrates.

Figure 8.2. Color-changing display embedded in the wall

Listing 8.4. Script for a device that changes color

To start with, declare the same function name as the door script used. “Operate” is the function name that the device operator script uses, so we need to use that name in order for it to be triggered. Inside this function, the code assigns a random color to the object’s material (remember, color isn’t an attribute of the object itself, but rather the object has a material and that material can have a color).

Note

Although the color is defined with Red, Blue, and Green components as is standard in most computer graphics, the values in Unity’s Color object vary between 0 and 1, instead of 0 and 255, as is common in most places (including Unity’s color picker UI).

All right, so we’ve gone over one approach to interacting with devices in the game and have even implemented a couple of different devices to demonstrate. Another way of interacting with items is by bumping into them, so let’s go over that next.

8.2. Interacting with objects by bumping into them

In the previous section, devices were operated by keyboard input from the player, but that’s not the only way players can interact with items in the level. Another very straightforward approach is to respond to collisions with the player. Unity handles most of that for you, by having collision detection and physics built into the game engine. Unity will detect collisions for you, but you still need to program the object to respond.

We’ll go over three collision responses that are useful for games:

· Push away and fall over

· Trigger a device in the level

· Disappear on contact (for item pickups)

8.2.1. Colliding with physics-enabled obstacles

To start, we’re going to create a pile of boxes and then cause the pile to collapse when the player runs into it. Although the physics calculations involved are complicated, Unity has all of that built in and will scatter the boxes in a realistic way for us.

By default Unity doesn’t use its physics simulation to move objects around. That can be enabled by adding a Rigidbody component to the object. This concept was first discussed back in chapter 3, because the enemy’s fireballs also needed a Rigidbody component. As I explained in that chapter, Unity’s physics system will act only on objects that have a Rigidbody component. Click Add Component and look for Rigidbody under the Physics menu.

Create a new cube object and then add a Rigidbody component to it. Create several such cubes and position them in a neat stack. For example, in the sample download I created five boxes and stacked them into two tiers (see figure 8.3).

Figure 8.3. Stack of five boxes to collide with

The boxes are now ready to react to physics forces. To have the player apply a force to the boxes, make the small addition shown in the following listing to the RelativeMovement script (this is one of the scripts written in the previous chapter) that’s on the player.

Listing 8.5. Adding physics force to the RelativeMovement script

There’s not a ton to explain about this code: whenever the player collides with something, check if the collided object has a Rigidbody component. If so, apply a velocity to that Rigidbody.

Play the game and then run into the pile of boxes; you should see them scatter around realistically. And that’s all you had to do to activate physics simulation on a stack of boxes in the scene! Unity has physics simulation built in, so we didn’t have to write much code. That simulation can cause objects to move around in response to collisions, but another possible response is firing trigger events, so let’s use those trigger events to control the door.

8.2.2. Triggering the door with a pressure plate

Whereas previously the door was operated by a keypress, this time the door will open and close in response to the character colliding with another object in the scene. Create yet another door and place it in another wall gap (I duplicated the previous door and moved the new door to -2.5 1.5 -17). Now create a new cube to use for the trigger object, and select the Is Trigger check box for the collider (this step was illustrated when making the fireball in chapter 3). In addition, set the object to the Ignore Raycast layer; the top-right corner of the Inspector has a Layer menu. Finally, you should turn off shadow casting from this object (remember, this setting is under Mesh Renderer when you select the object).

Warning

These tiny steps are easy to miss but very important: to use an object as a trigger, be sure to turn on Is Trigger. In the Inspector, look for the check box in the Collider component. Also, change the layer to Ignore Raycast so that the trigger object won’t show up in raycasting.

Note

When trigger objects were first introduced in chapter 3, the object needed to have a Rigidbody component added. Rigidbody wasn’t required for the trigger this time because the trigger would be responding to the player (versus colliding with a wall, the earlier situation). In order for triggers to work, either the trigger or the object entering the trigger need to have Unity’s physics system enabled; a Rigidbody component fulfills this requirement, but so does the player’s CharacterController.

Position and scale the trigger object so that it both encompasses the door and surrounds an area around the door; I used Position -2.5 1.5 -17 (same as the door) and Scale 7.5 3 6. Additionally, you may want to assign a semitransparent material to the object so that you can visually distinguish trigger volumes from solid objects. Create a new material using the Assets menu, and select the new material in the Project view. Looking at the Inspector, the top setting is Rendering Mode (currently set to the default value of Opaque); select Transparent in this menu.

Now click its color swatch to bring up the Color Picker window. Pick green in the main part of the window, and lower the alpha using the bottom slider. Drag this material from Project onto the object; figure 8.4 shows the trigger with this material.

Figure 8.4. Trigger volume surrounding the door it will trigger

Definition

Triggers are often referred to as volumes rather than objects in order to conceptually differentiate solid objects from objects you can move through.

Play the game now and you can freely move through the trigger volume; Unity still registers collisions with the object, but those collisions don’t affect the player’s movement anymore. To react to the collisions, we need to write code. Specifically, we want this trigger to control the door. Create a new script called DeviceTrigger (see the following listing).

Listing 8.6. Code for a trigger that controls a device

This listing defines an array of target objects for the trigger; even though it’ll only be a list of one most of the time, it’s possible to have multiple devices controlled by a single trigger. Loop through the array of targets to send a message to all the targets. This loop happens inside theOnTriggerEnter() and OnTriggerExit() methods. These functions are called once when another object first enters and exits the trigger (as opposed to being called over and over while the object is inside the trigger volume).

Notice that the messages being sent are different than before; now we need to define the functions Activate() and Deactivate() on the door. Add the code in the next listing to the door script.

Listing 8.7. Adding activate and deactivate functions to the DoorOpenDevice script

The new Activate() and Deactivate() methods are much the same code as the Operate() method from earlier, except now there are separate functions to open and close the door instead of only one function that handles both cases.

With all the needed code in place you can now use the trigger volume to open and close the door. Put the DeviceTrigger script on the trigger volume and then link the door to the targets property of that script; in the Inspector, first set the size of the array and then drag objects from the Hierarchy view over to slots in the targets array. Because we have only one door that we want to control with this trigger, type 1 in the array’s Size field and then drag that door into the target slot.

With all of this done, play the game and watch what happens to the door when the player walks toward and away from it. It’ll open and close automatically as the player enters and leaves the trigger volume.

That’s another great way to put interactivity into levels! But this trigger volume approach doesn’t only work with devices like doors; you can also use this approach to make collectible items.

8.2.3. Collecting items scattered around the level

Many games include items that can be picked up by the player. These items include equipment, health packs, and power-ups. The basic mechanism of colliding with items to pick them up is simple; most of the complicated stuff happens after items are picked up, but we’ll get to that a bit later.

Create a sphere object and place it hovering at about waist height in an open area of the scene. Make the object small, like Scale .5 .5 .5, but otherwise prepare it like you did with the large trigger volume. Select the Is Trigger setting in the collider, set the object to the Ignore Raycast layer, and then create a new material to give the object a distinct color. Because the object is small, you don’t want to make it semitransparent this time, so don’t turn down the alpha slider at all. Also, as mentioned in chapter 7, there are settings for removing the shadows cast from this object; whether or not to use the shadows is a judgment call, but for small pickup items like this I prefer to turn them off.

Now that the object in the scene is ready, create a new script to attach to that object. Call the script CollectibleItem (see the following listing).

Listing 8.8. Script that makes an item delete itself on contact with the player

This script is extremely short and simple. Give the item a name value so that there can be different items in the scene. OnTriggerEnter()destroys itself. There’s also a debug message being printed to the console; eventually it will be replaced with useful code.

Warning

Be sure to call Destroy() on this.gameObject and not this! Don’t get confused between the two; this only refers to this script component, whereas this.gameObject refers to the object the script is attached to.

Back in Unity, the variable you added to the code should become visible in the Inspector. Type in a name to identify this item; I went with energy for my first item. Then duplicate the item a few times and change the name of the copies; I also created ore, health, and key (these names must be exact because they’ll be used in code later on). Also create separate materials for each item in order to give them distinct colors: I did light blue energy, dark gray ore, pink health, and yellow key.

Tip

Rather than a name like we’ve done here, items in more complex games often have an identifier used to look up further data. For example, one item might be assigned id 301, and id 301 correlates to such-and-such display name, image, description, and so forth.

Now make prefabs of the items so that you can clone them throughout the level. In chapter 3 I explained that dragging an object from the Hierarchy view down to the Project view will turn that object into a prefab; do that for all four items.

Note

The object’s name will turn blue in the Hierarchy list; blue names indicate objects that are instances of a prefab. Right-click a prefab instance to pick Select Prefab and select the prefab that the object is an instance of.

Drag out instances of the prefabs and place the items in open areas of the level; even drag out multiple copies of the same item to test with. Play the game and run into items to “collect” them. That’s pretty neat, but at the moment nothing happens when you collect an item. We’re going to start keeping track of the items collected; to do that, we need to set up the inventory code structure.

8.3. Managing inventory data and game state

Now that we’ve programmed the features of collecting items, we need background data managers (similar to web coding patterns) for the game’s inventory. The code we’ll write will be similar to the MVC architectures behind many web applications. Their advantage is in decoupling data storage from the objects that are displayed on screen, allowing for easier experimentation and iterative development. Even when the data and/or displays are complex, changes in one part of the application don’t affect other parts of the application.

That said, such structures vary a lot between different games. Not every game has the same data-management needs, so it wouldn’t make sense for Unity to enforce a rule that “Every game must use such-and-such design pattern.” It would’ve been counterproductive to introduce those sorts of concepts too soon, because people would be misled into thinking they need that before they can make any game.

For example, a roleplaying game will have very high data-management needs, so you probably want to implement something like an MVC architecture. A puzzle game, though, has little data to manage, so building a complex decoupled structure of data managers would be overkill. Instead, the game state can be tracked in the scene-specific controller objects (indeed, that’s how we handled game state in previous chapters).

In this project we need to manage the player’s inventory. Let’s set up the code structure for that.

8.3.1. Setting up player and inventory managers

The general idea here is to split up all the data management into separate, well-defined modules that each manages its own area of responsibility. We’re going to create separate modules to maintain player state in PlayerManager (things like the player’s health) and maintain the inventory list in InventoryManager. These data managers will behave like the Model in MVC; the Controller is an invisible object in most scenes (it wasn’t needed here, but recall SceneController in previous chapters), and the rest of the scene is analogous to the View.

There will be a higher-level “manager of managers” that keeps track of all the separate modules. Besides keeping a list of all the various managers, this higher-level manager will control the lifecycle of the various managers, especially initializing them at the start. All the other scripts in the game will be able to access these centralized modules by going through the main manager. Specifically, other code can use a number of static properties in the main manager in order to connect with the specific module desired.

Design patterns for accessing centralized shared modules

Over the years a variety of design patterns have emerged to solve the problem of connecting parts of a program to centralized modules that are shared throughout the program. For example, the Singleton pattern was enshrined in the original “Gang of Four” book about design patterns.

But that pattern has fallen out of favor with many software engineers, so they use alternative patterns like service locator and dependency injection. In my code I use a compromise between the simplicity of static variables and the flexibility of a service locator.

This design leaves the code simple to use while also allowing for swapping in different modules. For example, requesting InventoryManager using a singleton will always refer to the exact same class and thus will tightly couple your code to that class; on the other hand, requesting Inventory from a service locator leaves the option to return either InventoryManager or DifferentInventoryManager. Sometimes it’s handy to be able to switch between a number of slightly different versions of the same module (deploying the game on different platforms, for example).

In order for the main manager to reference other modules in a consistent way, these modules must all inherit properties from a common base. We’re going to do that with an interface; many programming languages (including C#) allow you to define a sort of blueprint that other classes need to follow. Both PlayerManager and InventoryManager will implement a common interface (called IGameManager in this case) and then the main Managers object can treat both PlayerManager and InventoryManager as type IGameManager. Figure 8.5 illustrates the setup I’m describing.

Figure 8.5. Diagram of the various modules and how they’re related

Incidentally, whereas all of the code architecture I’ve been talking about consists of invisible modules that exist in the background, Unity still requires scripts to be linked to objects in the scene in order to run that code. As we’ve done with the scene-specific controllers in previous projects, we’re going to create an empty GameObject to link these data managers to.

8.3.2. Programming the game managers

All right, so that explained all the concepts behind what we’ll do; it’s time to write the code. To start with, create a new script called IGameManager (see the next listing).

Listing 8.9. Base interface that the data managers will implement

Hmm, there’s barely any code in this file. Note that it doesn’t even inherit from MonoBehaviour; an interface doesn’t do anything on its own and exists only to impose structure on other classes. This interface declares one property (a variable that has a getter function) and one method; both need to be implemented in any class that implements this interface. The status property tells the rest of the code whether this module has completed its initialization. The purpose of Startup() is to handle initialization of the manager, so initialization tasks happen there and the function sets the manager’s status.

Notice that the property is of type ManagerStatus; that’s an enum we haven’t written yet, so create the script ManagerStatus.cs (see the next listing).

Listing 8.10. ManagerStatus: possible states for IGameManager status

public enum ManagerStatus {

Shutdown,

Initializing,

Started

}

This is another file with barely any code in it. This time we’re listing the different possible states that managers can be in, thereby enforcing that the status property will always be one of these listed values.

Now that IGameManager is written, we can implement it in other scripts. Listings 8.11 and 8.12 contain code for PlayerManager and InventoryManager.

Listing 8.11. InventoryManager

Listing 8.12. PlayerManager

For now, InventoryManager is a shell that will be filled in later, whereas Player-Manager has all the functionality needed for this project. These managers both inherit from the class MonoBehaviour and implement the interface IGameManager. That means the managers both gain all the functionality of MonoBehaviour while also needing to implement the structure imposed by IGameManager. The structure in IGame-Manager was one property and one method, so the managers define those two things.

The status property was defined so that the status could be read from anywhere (the getter is public) but only set within this script (the setter is private). The method in the interface is Startup(), so both managers define that function. In both managers initialization completes right away (InventoryManager doesn’t do anything yet, whereas PlayerManager sets a couple of values), so the status is set to Started. But data modules may have long-running tasks as part of their initialization (such as loading saved data), in which case Startup() will launch those tasks and set the manager’s status to Initializing. Change status to Started after those tasks complete.

Great—we’re finally ready to tie everything together with a main manager-of-managers! Create one more script and call it Managers (see the following listing).

Listing 8.13. The Manager-of-Managers!

The most important parts of this pattern are the static properties at the very top. Those enable other scripts to use syntax like Managers.Player or Managers.Inventory to access the various modules. Those properties are initially empty, but they’re filled immediately when the code runs in the Awake() method.

Tip

Just like Start() and Update(), Awake() is another method automatically provided by MonoBehaviour. It’s similar to Start(), running once when the code first starts running. But in Unity’s code-execution sequence, Awake() is even sooner than Start(), allowing for initialization tasks that absolutely must run before any other code modules.

The Awake() method also lists the startup sequence, and then launches the coroutine to start all the managers. Specifically, the function creates a List object and then uses List.Add() to add the managers.

Definition

List is a collection data structure provided by C#. List objects are similar to arrays: they’re declared with a specific type and store a series of entries in sequence. But a List can change size after being created, whereas arrays are created at a static size that can’t change later.

Warning

The collection data structures are contained in a new namespace that you must include in the script; notice the additional using statement at the top of the script. Don’t forget this detail in your scripts!

Because all the managers implement IGameManager, this code can list them all as that type and can call the Startup() method defined in each. The startup sequence is run as a coroutine so that it will run asynchronously, with other parts of the game proceeding too (for example, a progress bar animated on a startup screen).

The startup function first loops through the entire list of managers and calls Startup() on each one. Then it enters a loop that keeps checking whether the managers have started up and won’t proceed until they all have. Once all the managers are started, the startup function finally alerts us to this fact before finally completing.

Tip

The managers we wrote earlier have such simple initialization that there’s no waiting, but in general this coroutine-based startup sequence can elegantly handle long-running asynchronous startup tasks like loading saved data.

Now all of the code structure has been written. Go back to Unity and create a new empty GameObject; as usual with these sorts of empty code objects, position it at 0,0,0 and give the object a descriptive name like Game Managers. Attach the script components Managers,PlayerManager, and InventoryManager to this new object.

When you play the game now there should be no visible change in the scene, but in the console you should see a series of messages logging the progress of the startup sequence. Assuming the managers are starting up correctly, it’s time to start programming the inventory manager.

8.3.3. Storing inventory in a collection object: List vs. Dictionary

The actual list of items collected could also be stored as a List object. The next listing adds a List of items to InventoryManager.

Listing 8.14. Adding items to InventoryManager

Two key additions were made to InventoryManager. One, we added a List object to store items in. Two, we added a public method, AddItem(), that other code can call. This function adds the item to the list and then prints the list to the console. Now let’s make a slight adjustment in the CollectibleItem script to call the new AddItem() method (see the following list).

Listing 8.15. Using the new InventoryManager in CollectibleItem

...

void OnTriggerEnter(Collider other) {

Managers.Inventory.AddItem(name);

Destroy(this.gameObject);

}

...

Now when you run around collecting items, you should see your inventory growing in the console messages. This is pretty cool, but it does expose one limitation of List data structures: as you collect multiples of the same type of item (such as collecting a second Health item), you’ll see both copies listed, instead of aggregating all items of the same type (refer to figure 8.6). Depending on your game, you may want the inventory to track each item separately, but in most games the inventory should aggregate multiple copies of the same item. It’s possible to accomplish this using List, but it’s done more naturally and efficiently using Dictionary instead.

Figure 8.6. Console message with multiples of the same item listed multiple times

Definition

Dictionary is another collection data structure provided by C#. Entries in the dictionary are accessed by an identifier (or key) rather than by their position in the list. This is similar to a hash table but more flexible, because the keys can be literally any type (for example, “Return the entry for this GameObject”).

Change the code in InventoryManager to use Dictionary instead of List. Replace everything from listing 8.14 with the code from the following listing.

Listing 8.16. Dictionary of items in InventoryManager

Overall this code looks the same as before, but a few tricky differences exist. If you aren’t already familiar with Dictionary data structures, note that it was declared with two types. Whereas List was declared with only one type (the type of values that’ll be listed), a Dictionary declares both the type of keys (that is, what the identifiers will be) and the type of values.

A bit more logic exists in the AddItem() method. Whereas before every item was appended to the List, now we need to check if the Dictionary already contains that item; that’s what the ContainsKey() method is for. If it’s a new entry, then we’ll start the count at 1, but if the entry already exists, then increment the stored value.

Play with the new code and you’ll see the inventory messages have an aggregated count of each item (refer to figure 8.7).

Figure 8.7. Console message with multiples of the same item aggregated

Whew, finally, collected items are managed in the player’s inventory! This probably seems like a lot of code to handle a relatively simple problem, and if this were the entire purpose then, yeah, it was overengineered. The point of this elaborate code architecture, though, is to keep all the data in separate flexible modules, a useful pattern once the game gets more complex. For example, now we can write UI displays and the separate parts of the code will be much easier to handle.

8.4. Inventory UI for using and equipping items

The collection of items in your inventory can be used in multiple ways within the game, but all of those uses first rely on some sort of inventory UI so that players can see their collected items. Then, once the inventory is being shown to the player, you can program interactivity into the UI by enabling players to click on their items. Again, we’ll program a couple of specific examples (equipping a key and consuming health packs), and then you should be able to adapt this code to work with other types of items.

Note

As mentioned in chapter 6, Unity has both an older immediate mode GUI and a newer sprite-based UI system. We’ll use the immediate mode GUI in this chapter because that system is faster to implement and requires less setup; less setup is great for practice exercises. The sprite-based UI system is more polished, though, and for an actual game you’d want a more polished interface.

8.4.1. Displaying inventory items in the UI

To show the items in a UI display, we first need to add a couple more methods to InventoryManager. Right now the item list is private and only accessible within the manager; in order to display the list, though, that information must have public methods for accessing the data. Add two methods shown in the following listing to InventoryManager.

Listing 8.17. Adding data access methods to InventoryManager

The GetItemList() method returns a list of items in the inventory. You might be thinking, “Wait a minute, didn’t we just spend lots of effort to convert the inventory away from a List?” The difference now is that each type of item will only appear once in the list. If the inventory contains two health packs, for example, the name “health” will still only appear once in the list. That’s because the List was created from the keys in the Dictionary, not from every individual item.

The GetItemCount() method returns a count of how many of a given item are in the inventory. For example, call GetItemCount("health") to ask “How many health packs are in the inventory?” This way, the UI can display a number of each item along with displaying each item.

With these methods added to InventoryManager, we can create the UI display. Let’s display all the items in a horizontal row across the top of the screen. The items will be displayed using icons, so we need to import those images into the project. Unity handles assets in a special way if those assets are in a folder called Resources.

Tip

Assets placed into the Resources folder can be loaded in code using the method Resources.Load(). Otherwise, assets can only be placed in scenes through Unity’s editor.

Figure 8.8 shows the four icon images, along with the directory structure showing where to put those images. Create a folder called Resources and then create a folder called Icons inside it.

Figure 8.8. Image assets for equipment icons placed inside the Resources folder

The icons are all set up, so create a new empty GameObject named Controller and then assign it a new script called BasicUI (see the next listing).

Listing 8.18. BasicUI displays the inventory

This listing displays the collected items in a horizontal row (see figure 8.9) along with displaying the number collected. As mentioned in chapter 3, every MonoBehaviour automatically responds to an OnGUI() method. That function runs every frame right after the 3D scene is rendered.

Figure 8.9. UI display of the inventory

Inside OnGUI(), first define a bunch of values for positioning UI elements. These values are incremented when we loop through all the items in order to position UI elements in a row. The specific UI element drawn is GUI.Box; those are noninteractive displays that show text and images inside boxes.

The method Resources.Load() is used to load assets from the Resources folder. This method is a handy way to load assets by name; notice that the name of the item is passed as a parameter. We have to specify a type to load; otherwise, the return value for that method is a generic object.

The UI shows us what items have been collected. Now we can actually use the items.

8.4.2. Equipping a key to use on locked doors

Let’s go over a couple of examples of using inventory items so that you can extrapolate out to any type of item you want. The first example involves equipping a key required to open the door.

At the moment, the DeviceTrigger script doesn’t pay attention to your items (because that script was written before the inventory code). The next listing shows how to adjust that script.

Listing 8.19. Requiring a key in DeviceTrigger

...

public bool requireKey;

void OnTriggerEnter(Collider other) {

if (requireKey && Managers.Inventory.equippedItem != "key") {

return;

}

...

As you can see, all that’s needed is a new public variable in the script and a condition that looks for an equipped key. The requireKey Boolean appears as a check box in the Inspector so that you can require a key from some triggers but not others. The condition at the beginning ofOnTriggerEnter() checks for an equipped key in InventoryManager; that requires that you add the code from the next listing to InventoryManager.

Listing 8.20. Equipping code for InventoryManager

At the top add the equippedItem property that gets checked by other code. Then add the public method EquipItem() to allow other code to change which item is equipped. That method equips an item if it isn’t already equipped, or unequips if that item is already equipped.

Finally, in order for the player to equip an item, add that functionality to the UI. The following listing will add a row of buttons for that purpose.

Listing 8.21. Equip functionality added to BasicUI

GUI.Box() is used again to display the equipped item. But that element is noninteractive, so the row of Equip buttons is drawn using GUI.Button() instead. That method creates a button that executes the code inside the if statement when clicked.

With all the needed code in place, select the requireKey option in DeviceTrigger and then play the game. Try running into the trigger volume before equipping a key; nothing happens. Now collect a key and click the button to equip it; running into the trigger volume opens the door.

Just for fun, you could put a key at Position -11 5 -14 to add a simple gameplay challenge to see if you can figure out how to reach the key. Whether or not you try that, let’s move on to using health packs.

8.4.3. Restoring the player’s health by consuming health packs

Using items to restore the player’s health is another generally useful example. That requires two code changes: a new method in InventoryManager and a new button in the UI (see listings 8.22 and 8.23, respectively).

Listing 8.22. New method in InventoryManager

Listing 8.23. Adding a health item to Basic UI

The new ConsumeItem() method is pretty much the reverse of AddItem(); it checks for an item in the inventory and decrements if the item is found. It has responses to a couple of tricky cases, such as if the item count decrements to 0. The UI code calls this new inventory method, and it calls the ChangeHealth() method that PlayerManager has had from the beginning.

If you collect some health items and then use them, you’ll see health messages appear in the console. And there you go—multiple examples of how to use inventory items!

8.5. Summary

In this chapter you’ve learned that

· Both keypresses and collision triggers can be used to operate devices.

· Objects with physics enabled can respond to collision forces or trigger volumes.

· Complex game state is managed via special objects that can be accessed globally.

· Collections of objects can be organized in List or Dictionary data structures.

· Tracking the equip state of items can be used to affect other parts of the game.