- Creating ELearning Games with Unity
- David Horachek
- 4519字
- 2021-07-16 12:22:51
Building an interactive object
With these requirements in mind, let's build the framework for an interactive object that can be collected by the player.
Implementing the CustomGameObj script
We will begin with the CustomGameObj
class. This class allows us to specify how an interactive object will behave when placed in the inventory, by giving it a unique type that is relevant for our game. Create the script by performing the following steps:
- Start from the codebase built in Chapter 1, Introduction to E-Learning and the Three Cs of 3D Games, to create a new subfolder in the
assets
folder namedChapter 2
. - Using the new script wizard, right-click on it and create a new C# script named
CustomGameObject
. - We will also add a public enumerated type to this class called
CustomObjectType
. If you recall, an enumeration is just a list of identifiers of the integer type that share a common logical relationship with one another, such as the types of an object! Not only will this make discerning the type of this object easy to read in the code, but it also serves as an interface to describe the classification of this object. We will use this information to determine some custom rules while adding GameObjects to the inventory. To begin, we will start with a few different types of objects, where an object of theCoin
type will accumulate in the same slot in the inventory. This holds true for objects of the typeRuby
,Diamond
, and so on as well. Unique objects will be added in their own slot inInventoryMgr
as follows:Public enum CustomObjectType { Invalid = -1, Unique = 0, Coin = 1, Ruby = 2, Emerald = 3, Diamond = 4 }
- A variable of the
CustomObject
type is added to this class to store the current type from the set discussed in the previous step. We use thepublic
keyword so that a user can directly set the value of this variable inside the Unity Editor on an instance of the object:public CustomObjectTypeobjectType CustomObjectType objectType;
- A public variable of the
string
type is added so that Unity users can add some descriptive text to the object while designing them, as shown in the following code; this can be very helpful while debugging or trying to identify the objects inside the editor:public string displayName;
- Declare a method named
validate()
, which will be used to assign theunnamed_object
string to thedisplayName
field if one has not been assigned in the editor, as shown in the following code:public void validate() { if (displayName == "") displayName = "unnamed_object"; }
Congratulations! We now have a container for the CustomGameObject
information that our inventory system will use. To continue, let's create the InteractiveObj
script.
Implementing the InteractiveObj script
The InteractiveObj
script declares a class that enables simple animation and permits player interactions. Perform the following steps to create the script:
- To use the new script wizard, right-click inside the
Chapter2
folder of the Project tab and add a C# script namedInteractiveObj
. - To enable our interactive object to rotate about its own axis at a user specified rate, we need to add two parameters: a rotation axis and a rotation speed, as shown in the following code:
public Vector3 rotAxis; public float rotSpeed;
- We will add a private reference to the
customGameObject
component for thisGameObject
so that we don't have to look it up at runtime. This can be done with the following line of code:private customGameObject gameObjectInfo;
- We will also add an
ObjectInteraction
member variable. This will be the code that specifies what will happen to ourgameObject
when the player interacts with it. There may be many interactions that an interactive object can implement; we will start our example withOnCloseEnough
and will complete this in theOnTriggerEnter
method, as shown in the following code:public objectInteraction OnCloseEnough;
- In the
Start()
method, we will search for theCustomGameObject
component attached togameObject
. If it is found, we will store the reference in thegameObjectInfo
private variable. Remember to always check thatgameObjectInfo
is not null so that debugging the code is a straightforward process, as shown in the following code:gameObjectInfo = this.gameObject.GetComponent<customGameObject>(); if (gameObjectInfo) gameObjectInfo.validate();
- In the
Update()
method, we will apply a simple rotation to the object around the specifiedrotAxis
parameter. We will rotate the object with the speed given inrotSpeed
multiplied byTime.deltaTime
so that the number of rotations is a function of the elapsed time rather than the frame time, as shown in the following code:transform.Rotate(rotAxis, rotSpeed * Time.deltaTime);
- The
OnTriggerEnter()
method will be invoked whenever the collider of this object collides with another collider in the world; incidentally, if we setIsTrigger=false
on ourgameObject
, theOnCollisionEnter()
method will be dispatched instead ofOnTriggerEnter()
. Note, for Unity to dispatch either of these callbacks, we must remember to add a Rigidbody component to the GameObject ofInteractiveObj
at the design time in the editor. - Note, when Unity dispatches this callback, it passes in another parameter of the
collider
type. This collider is the collider of the object that entered the trigger volume. Convenient! The signature looks as follows:OnTriggerEnter(other collider) { }
- In this method, we check that the other object (the
gameObject
that has just entered this collider) has a tag equal toPlayer
, as shown in the next line of code. This is how we ensure that our trigger only responds to entities that we specify (we must remember to set the tag on the playergameObject
toPlayer
):if (other.gameObject.tag == "Player")
- If the
OnCloseEnough
object interaction is not null, we dereference it and invoke thehandleInteraction()
method. In our example, this method does the work of inserting objects into the inventory as shown in the following code:if (OnCloseEnough != null) { OnCloseEnough.handleInteraction(); }
Congratulations! We now have a class that implements an interactive object. Let's continue further with an ObjectInteraction
script that this class can utilize.
Implementing the ObjectInteraction script
The ObjectInteraction
class defines how the interactive object will be manipulated when an interaction occurs between the object and the player. Perform the following steps to implement this:
- Two enumerations are required to specify the action and the type of action. The action will be what we do with the item (put in inventory and use) initially, as shown in the following code:
public enum InteractionAction { Invalid = -1, PutInInventory = 0, Use = 1, }
- The corresponding type specializes this behavior by determining if the object is unique or can be accumulated with other interactive objects of the similar type. A unique interaction specifies that
ObjectIneraction
will insert this interactive object in a unique slot inInventoryMgr
, while an accumulate interaction specifies thatObjectInteraction
will insert this item (and increase the quantity) in the first available slot that matches the type set inCustomGameObj
, as shown in the following code:public enum InteractionType { Invalid = -1, Unique = 0, Accumulate = 1, }
- We keep the following two public variables to store the two enumerations discussed in the previous step:
public InteractionAction interaction; public InteractionType interactionType;
- We also keep a
Texture
variable to store the icon that will be displayed in the inventory for this GameObject as follows:public Texture tex;
- The
HandleInteraction()
method of this class works on the interactive object that this script is attached to. To begin, we get theInventoryMgr
component off the player if it can be found. Don't worry that we haven't created theInventoryMgr
yet; we will!if (player) iMgr = player.GetComponent<InventoryMgr>();
- As we extend the number of interaction types that our game supports, this method will grow. For now, if
PutIninventory
is the type, we will delegatei=InventoryMgr
to add thisInteractiveObj
to its collection as follows:if (interaction == InteractionAction.PutInInventory) { if (iMgr) iMgr.Add(this.gameObject.GetComponent<interactiveObj (); }
Congratulations! You have implemented an ObjectInteraction
class that operates on the InteractiveObj
class. Let's continue by implementing the InventoryItem
class.
Implementing the InventoryItem script
The InventoryItem
class is the base item container that the InventoryMgr
collection is built from. It contains a reference to GameObject
that has been inserted in the inventory (via the ObjectInteraction
class). It also has a copy of the texture to display in the inventory as well as the number of objects that a particular InventoryItem
represents, as shown in the following code:
public Texture displayTexture; public GameObject item; public int quantity;
Note
Scripts that inherit from monobehavior
can be fully manipulated by the Unity3D Editor; they can be attached to GameObjects, have the property values saved, among other things. This class does not inherit from monobehavior
; as it is an internal helper class for InventoryMgr
. It never has to be attached to GameObject (a programmer or designer would not normally want to attach one of these to a 3D object because it doesn't need a Transform
component to do its work). This class only acts as the glue between the interactive objects that have been collected and the UI button that InventoryMgr
displays for these objects' custom type. Hence, this class does not derive from any base class. This allows us to declare a list of these objects directly inside InventoryMgr
.
To make the class show up in the inspector (in InventoryMgr
), we need to add back some of the functionality that would have been included, had we inherited from monobehavior
; namely, the serialization of its properties. Hence, we add the following code decoration before the class declaration:
[System.Serializable]

Implementing the InventoryMgr script
The InventoryMgr
class contains the system that manages the InteractiveObj
classes that the player collects. It displays inventory items in an inventory panel at the bottom of the screen. It has a method for adding inventory items and displaying inventory at the bottom of the screen. Perform the following steps to implement the InventoryMgr
script:
- To begin, recall that the class declaration for this system follows the same pattern as the others that were created with the new script wizard. Until this point, however, we haven't included any other namespaces than the default two:
UnityEngine
andSystem.Collections
. For this class, note that we addusing System.Collections.Generic
in the code. Doing this gives us access to theList<>
datatype in our scripts, which we will need to store the collection of inventory objects, as shown in the following code:using UnityEngine; using System.Collections; using System.Collections.Generic; public class InventoryMgr : MonoBehaviour { public List<InventoryItem> inventoryObjects = new List<InventoryItem>();
- The
InventoryMgr
class also has parameters that describe the way in which the constraints on the UI will be displayed, along with a reference to theMissionMgr
script, as shown in the following code:public int numCells; public float height; public float width; public float yPosition; private MissionMgr missionMgr;
- In the
Start()
method, when this class is first created, we will find the object in the scene named Game, and store a reference to theMissionMgr
script that is attached to it, as shown in the following code:void Start () { GameObject go = GameObject.Find ("Game"); if (go) missionMgr = go.GetComponent<MissionMgr>(); }
- The
Add()
method is used byObjectInteraction.handleInteraction()
to insert anInteractiveObj
in the inventory (when it is picked up). Recall that the signature looks as follows:public void Add(InteractiveObj iObj) { ObjectInteraction oi = iObj.OnCloseEnough;
- Based on the
ObjectInteraction
type specified in the interaction, theAdd()
method will behave in specialized ways, and aswitch
statement is used to select which specific behavior to use. If theObjectInteraction
type isUnique
, thenInventoryMgr
inserts thisInteractiveObj
in the first available slot, as shown in the following code:switch(oi.interactionType) { case(ObjectInteraction.interactionType.Unique): { // slot into first available spot Insert(iObj); } break;
- If the
ObjectInteraction
type isAccumulate
, thenInventoryMgr
will insert this in the first slot that matches theCustomGameObject
type on the interactive object. To determine this matching, we first store a reference to theCustomGameObject
script on the interactive object that is being inserted. If this object does not have aCustomGameObject
script, we assume the type isInvalid
, as shown in the following code:case(ObjectInteraction.InteractionType.Accumulate): { bool inserted = false; // find object of same type, and increase CustomGameObject cgo = iObj.gameObject.GetComponent<CustomGameObject>(); CustomGameObject.CustomObjectType ot = CustomGameObject.CustomObjectType.Invalid; if (cgo != null) ot = cgo.objectType;
- The
InventoryMgr
class then loops over all inventory objects in the list. If it finds an object that has a matchingCustomGameObject
type to the interactive object that is being inserted, it increases the quantity property on thatInventoryObj
. If a match is not found, thenInventoryObj
is permitted to be inserted in the list as if it were a unique item, as shown in the following code:for (int i = 0; i < inventoryObjects.Count; i++) { CustomGameObject cgoi = inventoryObjects[i].item.GetComponent <CustomGameObject>(); CustomGameObject.CustomObjectType io = CustomGameObject.CustomObjectType.Invalid; if (cgoi != null) io = cgoi.objectType; if (ot == io) { inventoryObjects[i].quantity++; // add token from this object to missionMgr // to track, if this obj as a token MissionToken mt = iObj.gameObject.GetComponent<MissionToken>(); if (mt != null) missionMgr.Add(mt); iObj.gameObject.SetActive(false); inserted = true; break; } }
- Note, if the types of the object match any existing object on the list, we do some book keeping. We increase its number as well as copy the texture reference that we will display in the inventory. We will also disable the object (to stop it from rendering and interacting with the world) by setting its active flag to
false
and then we leave the loop, as shown in the following code. We will declare theMissionToken
script later in this chapter:if (ot == io) { inventoryObjects[i].quantity++; missionTokenmt = iObj.gameObject.GetComponent<MissionToken>(); iObj.gameObject.SetActive (false); inserted = true; }
- An important aspect to note here is that we need to check if there is a
MissionToken
script attached to thisInteractiveObj
. If there is one, then we will add it toMissionMgr
. In this way, we will complete the communication between the two management systems in this chapter. Later, we will see howMissionMgr
searches for complete missions and rewards the player using mechanics similar to those discussed earlier:if (mt != null) missionMgr.Add(mt);
- The
Insert()
method ofInventoryMgr
is used to perform the actual insertion work in the list of inventory objects. It is declared with the following signature:void Insert(InteractiveObj iObj){ }
- This method first allocates a new
InventoryItem
with thenew
operator. We have to usenew
instead ofObject.Instantiate
to create a new instance of this class because this class does not inherit fromObject
. With a new instance ofInventoryItem
available for use, we will populate its properties with the data fromInteractiveObj
, as shown in the following code:InventoryItem ii = new InventoryItem(); ii.item = iObj.gameObject; ii.quantity = 1;
- Then, we will disable GameObject of
InteractiveObj
(just in case it is still enabled), and finally add theInventoryItem
to the list with a direct call toinventoryObjects.add
, as shown in the following code:ii.item.SetActive (false); inventoryObjects.Add (ii);
- Lastly, just in case there is
MissionToken
attached to this GameObject from some other code path, we will extract the token and add it toMissionMgr
for tracking, as shown in the following code:MissionToken mt = ii.item.GetComponent<MissionToken>(); if (mt != null) missionMgr.Add(mt);
And this completes the work on the Insert()
method.
Let's continue our work by developing InventoryMgr
as we program the method that will display all of the inventory objects on screen by performing the following steps:
- The
DisplayInventory()
method is declared with the following signature:void DisplayInventory() { }
- This method also walks through the collection, but instead of checking the type of object, it will display a series of GUI buttons on the screen. It will also show
displayTexture
for the item in each inventory. As the position of the inventory cells are relative to the screen, we need to calculate the button positions based on the screen width and height, as shown in the following code:float sw = Screen.width; float sh = Screen.height;
- We will also store a reference to the texture we will display in each cell, as shown in the following code:
Texture buttonTexture = null;
- Then, for clarity, we will store the number of cells in a local integer to display as shown in the following code:
int totalCellsToDisplay = inventoryObjects.Count;
- We will loop over all the cells and extract the texture and quantity in each
InventoryItem
in the collection, as shown in the following code:for (int i = 0; i<totalCellsToDisplay; i++) { InventoryItem ii = InventoryObjects[i]; t = ii.displayTexture; int quantity = ii.quantity;
The result of this code is shown as follows:
- We will compute the total length of all the cells that we want to display. This is used in the code to render the cells centered in the middle of the screen horizontally. Recall that the width and height hold the individual cell width and height:
float totalCellLength = sw – (numcells * width);
As
InventoryMgr
loops over allInventoryObjects
, we draw a new rectangle for each item to be displayed on the screen. To do this, we need to know the x, y coordinates of the upper-left corner of the rectangle, the height and width of an individual rectangle, and the texture. The y height doesn't vary since the inventory is a horizontal row on screen, and the cell height and width don't vary since the cells are uniform by design. The texture will change, but we can use the cached value. So, we need to focus our attention on the x coordinate for a centered array of varying length. - It turns out that we can use this formula. The
totalCellLength
parameter is the amount of white space horizontally when all the cells are aligned on one side. If we subtract half of this, we get the starting coordinate that will be positioned half on the right and half on the left equally. Considering that width and height are the dimensions of the individual buttons for display and that i is the loop index for the loop we are discussing, then adding(width*i)
ensures that the subsequent x coordinates vary horizontally across the array, as shown in the following code:float xcoord = totalCellLength – 0.5f*(totalCellLength) +(width*i);
- The rectangle that corresponds to the shape of the button we want to display is then calculated with the following formula. Note that its position on the screen is a function of i, the loop index, as well as y, the screen width and height, and the button width and height:
Rect r = new Rect(totalCellLength - 0.5f*(totalCellLength) + (width*i), yPosition*sh, width, height);
With all of these quantities now calculated, we will display the button with the
GUI.button(r, buttonTexture)
method, as shown in the following code. We will check for atrue
return value from this function because this is how we track when the user clicks on a button:if (GUI.Button(r, buttonTexture)) { // to do – handle clicks there }
- Recall that we need to display the number of items with each button. We do this with the
GUI.Label
method in a way analogous to the previous code. We will compute a second rectangle for the quantity that mirrors the cell for the button, but we will use half the cell width and height to position the rectangle in the upper-left corner for a nice effect! - We will convert the quantity field of the current
InventoryItem
class to a string with the built-in function calledToString()
that the integer implements, and we will pass this to theGUI.Label
method, as shown in the following code:Istring s = quantity.ToString() GUI.Label(r2, s);
- To display UI textures and elements on the screen, Unity provides a special callback method to place our UI code whenever the UI is refreshed. This method is called
OnGui()
and has the following signature:void OnGUI(){ }
- We invoke our
DisplayInventory()
method inside thevoid OnGUI()
method that Unity provides because this method draws theInventoryItems
list ofInventoryMgr
to the screen in the form of UI buttons. This callback is where all drawing and GUI-related processing occurs, as shown in the following code:void OnGUI() { DisplayInventory(); }
We could modify this code slightly to draw the maximum number of cells in the inventory rather than the total number of filled
InventoryMgr
cells. We must be careful to not dereference past the end of the collection if we have been doing so!
Congratulations! We now have a working InventoryMgr
system that can interface with interactive objects and collect them based on their custom type! While we touched briefly on the MissionToken
class in this explanation, we need to develop a system for tracking the abstract mission objectives and rewarding the player on achieving success. This requires multiple classes. The result of performing these steps is shown in the following screenshot:

Implementing the MissionMgr script
The MissionMgr
class tracks the user's progress through abstract objectives. These objectives will be learning objectives that satisfy the game design. The pattern we use to manage missions will be similar to InventoryMgr
; however, this system will be in charge of comparing the player's objectives with a master list of missions and with what is required to complete each one. To develop it, let's perform the following steps:
- To accomplish this work,
MissionMgr
will need two lists. One list to hold all of the missions that the player could try and solve and another for the current tokens that the player has acquired (through interacting with interactive objects, for instance). TheMissionTokens
collection is allocated at runtime and set to empty so that the player always starts having accomplished nothing; we could develop a way later to load and save the mission progress via this system. The missions' list will be populated at runtime in the editor and saved, so we don't want to initialize this at runtime:Public List<mission> missions; Public List<missionToken> missionTokens = new List<missionTokens>();
- The
MissionMgr
implements three methods that allow it to perform its role and interface with other game systems:Add(missionToken)
: This method adds a newly acquiredMissionToken
to the collection. This collection will be queried while trying to determine if a mission has been completed. InAdd()
, we use a similar methodology as theAdd()
method forInventoryMgr
. In this case, assume that the token is unique and search for a duplicate by iterating over all of the tokens for the current missionm
, as shown in the following code:bool uniqueToken = true; for (int i = 0; i<missionTokens.Count; i++) { //… }
If a duplicate is found, namely, a token is found in the collection with the same
id
field as the add candidate, we abort the operation as shown in the following code:if (missionTokens[i].id == mt.id) { // duplicate token found, so abort the insert uniqueToken = false; break; }
If a duplicate is not found, we add this token to the list as shown in the following code:
if (uniqueToken) { missionTokens.add(mt); }
Validate(mission)
: This method will comparecurrentToken
set to a specific mission. If it has been found to have been satisfied, the system is notified. To do this, we will use a search pattern similar to the one used in theAdd()
method and start by assuming the mission is complete; only this time we will use a double-nested loop! This is because to validate a mission means to search, individually, for each token in the current mission against all of the tokens the user has collected so far. This is done as shown in the following code:bool missionComplete = true; for (intinti = 0; I < m.tokens.Count; i++) { bool tokenFound = false; for (int j = 0; j < missionTokens.count ; j++) { // if tokens match, tokenFound = true } }
By assuming the token will not be found initially, we are required to search for a matching token ID. If we don't find it, it automatically means that the mission cannot be complete, and we don't have to process any more comparisons, as shown in the following code:
if (tokenFound == true)) { missionComplete = false; break; }
ValidateAll()
: This methods invokesValidate()
on all user-defined missions if they are not already complete. If any mission is found to be completed, a reward is instantiated for the player through theInvokeReward()
method, as shown in the following code:void ValidateAll() { for (int i = 0; i < missions.Count; i++) { Mission m = missions[i]; // validate missions… } }
We will sequentially search through all user-defined missions that have not already been completed (no need to do this once a mission is done). This enumeration will be defined in the
Mission
script, as shown in the following code:if (m.status != mission.missionStatus.MS_Completed) { bool missionSuccess = Validate(m);
If the mission has been validated as being complete, the mission implements an
InvokeReward()
method to reward the user, as shown in the following code:if (missionSuccess == true) { m.InvokeReward(); }
Implementing the Mission script
The Mission
class is the container for MissionTokens
. It implements a state that helps us specialize how a mission should be treated by the game (for instance, we may want to have the player acquire a mission but not start it). This class has a number of state variables for future extension such as activated
, visible
, and points
.
- As with the
InventoryItem
class, theMission
class is a helper class that only theMissionMgr
class uses. Hence, it does not inherit frommonobehavior
. Therefore, the class signature must include the[System.Serializable]
tag as before:using UnityEngine; using System.Collections; using System.Collections.Generic; [System.Serializable] public class Mission {
- This class also implements an enumeration to describe the state of a particular mission. This is used to encode whether a state is invalid, acquired, in progress, or solved so that
MissionMgr
can handle the mission appropriately, as shown in the following code:public enum missionStatus { MS_Invalid = -1, MS_Acquired = 0, MS_InProgress = 1, MS_Completed = 2 };
- The public variable status is an instance variable of the status enumerated type in this class. We will use this initially to make sure that once a mission is complete,
MissionMgr
no longer tries to validate it. This can be done with the following code:Public missionStatus status;
- The specific elements that comprise a mission are the mission tokens that the user puts in the tokens' collection. This is a collection of logical pieces that comprises a complete objective in the game. This list will be compared against the players' acquired tokens in
MissionMgr
, as shown in the following code:Public List<missionTokens> tokens;
- The
points
andreward
public variables are used to store the numerical score and the in-game rewards that are given to the user when a mission is completed. Note,GameObject reward
could be used as a completion callback, in addition to a reference to a Prefab item for the user to pick up, as shown in the following code:public int points; public GameObject reward;
- The
displayName
public variable is used by the user in the Unity3D Editor as a place for a helpful string to describe the mission's nature, as shown in the following code:public string displayName;
This class implements one method: the
InvokeReward()
method. This function will spawn a newgameObject
into the world that has been set in the editor. Through this mechanism, the player can be rewarded with points, a new object or objective can appear in the world, or any other outcome can be encapsulated in a UnityPrefab
object. - Once a mission has been validated and
InvokeReward
has been called, the mission itself is disabled and its status is set toCompleted
, as shown in the following code:this.status = missionStatus.MS_Completed;
Implementing the MissionToken script
The MissionToken
class stores the information for an individual mission component. This class acts as a container for this abstract data. We give it an ID, a title that is human readable, and a description. By giving each MissionToken
a unique ID, we give the Mission
class a powerful way of tracking the mission progress. This class is used in a way by which the user adds instances of this component to various interactive objects that can be manipulated by the player, as shown in the following code:
Public int id; Public string title; Public string description;
Implementing the SimpleLifespanScript
The SimpleLifespanScript
class is a simple helper class that can be used in conjunction with the Instantiate()
method to instantiate a GameObject in the world that will have a specified but finite lifespan. We use it to enable an instance of a Prefab that is live for a set period of time and then destroys itself as a reward for completing a mission. By attaching this to the reward that is displayed when a mission is completed, the prompt is given a set duration on the screen after which it disappears.
Specifically, the seconds
parameter is the time for which the object will survive before self destruction, as shown in the following code:
Public float seconds
In the update
method, we count this value by the actual time elapsed in each frame (from Timer.deltaTime
). Once this reaches zero, we destroy the object and all the scripts attached to it (including the simpleLifespanScript
), as shown in the following code:
seconds -= Time.deltaTime; if (seconds <= 0.0f) GameObject.Destroy(this.gameObject);
Congratulations! We now have a robust set of scripts for MissionMgr
, missions, tokens, and rewards. Let's apply what we have built in an example that exercises the mission system, the inventory system, and interactive objects.