Part 10: Creating New Units
The goal this time around is to introduce the ability to create new Units from Buildings. While we are at it we will also implement a build queue, enabling the Player to create multiple Units from a single Building. I apologize in advance for the length of this part. There is a lot to cover, but I do believe that it all needs to be presented at once. The end result is very cool though.
Build Queue
Let's start out by defining a build queue for our Buildings and providing methods for interacting with it. The first thing that we need to do is to add some variables to the top of Building.cs.
public float maxBuildProgress; protected Queue< string > buildQueue; private float currentBuildProgress = 0.0f; private Vector3 spawnPoint;
We will use currentBuildProgress and maxBuildProgress to make sure that a Unit takes time to build. This will be a standard production speed for all Units produced from a particular Building. We will probably want to upgrade this at a later date, but a flat production speed will be fine for now. By making maxBuildProgress public we can play around with it in Unity - set it to 10 for now.
To have access to Queue we need to add
using System.Collections.Generic;
to the very top of Building.cs. We also need to make sure that our Queue and spawn point are initialized so add
buildQueue = new Queue< string >(); float spawnX = selectionBounds.center.x + transform.forward.x * selectionBounds.extents.x + transform.forward.x * 10; float spawnZ = selectionBounds.center.z + transform.forward.z + selectionBounds.extents.z + transform.forward.z * 10; spawnPoint = new Vector3(spawnX, 0.0f, spawnZ);
to Awake(). We are setting the spawn point to be in the middle of the front wall of our Building, set out by a bit to make sure that the Unit is not created inside the Building. To actually create a Unit we need a method that will first add a Unit to our build queue.
protected void CreateUnit(string unitName) { buildQueue.Enqueue(unitName); }
Once we can add items to the build queue we want a way to make sure that it gets emptied too, so add the following line to Update()
ProcessBuildQueue();
and then create this method as well.
protected void ProcessBuildQueue() { if(buildQueue.Count > 0) { currentBuildProgress += Time.deltaTime * ResourceManager.BuildSpeed; if(currentBuildProgress > maxBuildProgress) { if(player) player.AddUnit(buildQueue.Dequeue(), spawnPoint, transform.rotation); currentBuildProgress = 0.0f; } } }
This method is saying that if we have items in our build queue then we need to update our build progress. If this is now greater than our maximum allowed build progress then we need to tell the Player that owns this Building to create the Unit at the front of the queue. Once this is done we need to reset our current build progress to 0 so that we can start building the next Unit in the queue. You will notice the reference to ResourceManager.BuildSpeed. This is used to make sure that all buildings update their progress for building Units at the same rate. By adjusting maxBuildProgress we can make some buildings take longer to complete their work than others. We should add that variable to ResourceManager.cs now
public static int BuildSpeed { get { return 2; } }
and then add
using RTS;
to the top of Building.cs to make sure that we can access it. We also need to add the method for the creation of a Unit to Player.cs too, which we will come back to later in this post.
public void AddUnit(string unitName, Vector3 spawnPoint, Quaternion rotation) { Debug.Log ("add " + unitName + " to player"); }
In order to draw things correctly in our HUD we also need to be able to retrieve some details about our build queue from Building.cs.
public string[] getBuildQueueValues() { string[] values = new string[buildQueue.Count]; int pos=0; foreach(string unit in buildQueue) values[pos++] = unit; return values; } public float getBuildPercentage() { return currentBuildProgress / maxBuildProgress; }
This will allow us to find out what the current entries in the build queue are as well as how far through constructing the current Unit the Building is.
Prefabs and GameObjectList
To actually create a new Unit we need to a way to create a fully fledged Unit. The last thing we want to have to do is to painstakingly define how to create each of our Units and Buildings at runtime. Thankfully Unity gives us some help in this regard through the concept of Prefabs. The basic overview is that a Prefab serves as a template for an object instance. So we can define the collection of objects and scripts that make up our complex object (as we have done for Unit and for Building already). We can then drag our object from the Hierarchy view down into a folder in the project view, which will create a Prefab object there. Once you have a Prefab you can then drag it from your folder into your scene and have multiple instances of the same object without having to create each of them from scratch. There is also a way to create an instance of a Prefab at runtime in our code (which we will get to later on in this post). Any changes you make to a Prefab are immediately copied to all instances of that Prefab in your scene. However, it is important to note that any changes made to an instance are not copied back to the Prefab - if you want to do so you need to resave the Prefab. This is a very good thing, but it means that you need to remember to make any development changes to the Prefab, not to an instance of it. If you want to know more details (which is always a good thing to want when using things like this) check out the Unity documentation.
Having Prefabs is very useful. However, what we also need is a list of available Units and Buildings which we can create at runtime. Having them stored nicely in folders within our project is good, but it will not help us when our game is actually running. Therefore, we will create a new object whose sole purpose is to contain a reference to all the Prefabs that we can use in our game. Inside your Assests directory create a new folder called Resources. Inside this folder create a new C# script called GameObjectList.cs. Now create a new empty object, rename it to GameObjectList, and attach the script to it.
Let us start off by defining the lists of objects that we wish to keep track of in GameObjectList.
public GameObject[] buildings; public GameObject[] units; public GameObject[] worldObjects; public GameObject player;
All of these arrays need to be of type GameObject, since we will be storing Prefabs in them. We are now able to drop appropriate Prefabs into these lists from within Unity. We will add a couple of Prefabs later in this tutorial, once we are ready to create some actual Units from an actual Building.
We want to make sure that we can only ever have one instance of our GameObjectList present on a map at all times. This will make more sense later on once we add in menu systems and the ability to save and load our maps. Add a boolean variable GameObjectList.cs to determine whether it has already been created or not.
private static bool created = false;
By defining this as a static variable we are making sure that it is not tied down to any particular instance of our GameObjectList. We now need to define the initialization logic inside the Unity Awake() method.
void Awake() { if(!created) { DontDestroyOnLoad(transform.gameObject); ResourceManager.SetGameObjectList(this); created = true; } else { Destroy(this.gameObject); } }
The Unity method call DontDestroyOnLoad() makes sure that if we load a new map this object will persist. This is good because it means we will only need to initialise this once, at the entry point for our game. The call to Destroy() when created is set to true guarantees that we only ever have one instance present on a map. We are also giving ResourceManager a reference to this object so that we have an easy way of calling any methods that we need to later on (and we will be adding some). We should add this method to ResourceManager.cs now
public static void SetGameObjectList(GameObjectList objectList) { gameObjectList = objectList; }
as well as the variable gameObjectList which is being set in this method.
private static GameObjectList gameObjectList;
We also need to add
using RTS;
to the top of GameObjectList.cs to make sure that we can reference our ResourceManager.
With that in place let us define some accessor methods within GameObjectList.cs to allow us to retrieve any object that we want.
public GameObject GetBuilding(string name) { for(int i = 0; i < buildings.Length; i++) { Building building = buildings[i].GetComponent< Building >(); if(building && building.name == name) return buildings[i]; } return null; } public GameObject GetUnit(string name) { for(int i = 0; i < units.Length; i++) { Unit unit = units[i].GetComponent< Unit >(); if(unit && unit.name == name) return units[i]; } return null; } public GameObject GetWorldObject(string name) { foreach(GameObject worldObject in worldObjects) { if(worldObject.name == name) return worldObject; } return null; } public GameObject GetPlayerObject() { return player; } public Texture2D GetBuildImage(string name) { for(int i = 0; i < buildings.Length; i++) { Building building = buildings[i].GetComponent< Building >(); if(building && building.name == name) return building.buildImage; } for(int i = 0; i < units.Length; i++) { Unit unit = units[i].GetComponent< Unit >(); if(unit && unit.name == name) return unit.buildImage; } return null; }
We also need to add some wrapper methods to ResourceManager.cs which we will use to access these methods in GameObjectList.
public static GameObject GetBuilding(string name) { return gameObjectList.GetBuilding(name); } public static GameObject GetUnit(string name) { return gameObjectList.GetUnit(name); } public static GameObject GetWorldObject(string name) { return gameObjectList.GetWorldObject(name); } public static GameObject GetPlayerObject() { return gameObjectList.GetPlayerObject(); } public static Texture2D GetBuildImage(string name) { return gameObjectList.GetBuildImage(name); }
This layer of abstraction also allows us to forget about how these values are being stored when it comes to the majority of our code base. All we care about is that our ResourceManager knows how to retrieve the items that we want whenever we want them.
Initiate Unit Creation
We now have a way to access all of the objects we might want to use in our world (even though this list is not yet populated). We have also defined a way for a Building to add Units to a build queue and then to initiate construction of those. What we are still lacking is a way to tell a Building when to add a Unit to it's build queue, as well as what that Unit is. To do this we will make use of actions, which we loosely defined quite a while back for a WorldObject.
But before we get onto that, we should first define our first actual Building. Once we have this we will define a build action for it along with how to handle that. We will start with a WarFactory, since we have what looks like a tank floating round already. Besides, what better way to advance a cause than by building an army? Let's first create some space to put our new WarFactory in by setting the z-position for our existing unit to 25. Now create a new empty object, rename it to WarFactory, and make sure that it's position is set to (0, 0, 0). Add 5 cubes to this object called GroundFloor, Corner1, Corner2, Corner3, and Corner4. Set their transform properties as follows:
- GroundFloor: position = (0, 1.5, 0), scale = (10, 3, 10)
- Corner1: position = (-4, 3.5, -4)
- Corner2: position = (-4, 3.5, 4)
- Corner3: position = (4, 3.5, 4)
- Corner4: position = (4, 3.5, -4)
public class WarFactory : Building { protected override void Start () { base.Start(); actions = new string[] { "Tank" }; } }
Here each action we want to perform is the name of a Unit to that can be constructed. These names will need to match the names of any Prefabs that we are wanting to build (and match perfectly, since things are case-sensitive here). Since we have Tank as part of this list we are going to need to create a Prefab later for a Tank unit. Now attach this script to the WarFactory object we just created. Finally, drag the WarFactory object down into the WarFactory folder in the project view. This will create a Prefab object called WarFactory in that folder. Remeber, any further changes that we make to WarFactory should be made to this Prefab.
For completeness we should also create a build image for our WarFactory. To satisfy Unity's import ratios we will make this to be 64x64, but we will make use of some transparency to give us a smaller image than that. (This is to help make sure that we do not get weird scaling of the image) I have gone with a transparent border of 13 pixels (which will make more sense by the end of this post) to give me the image below.
It is up to you to make your images as ornate or simplistic as you like. All we really want at this stage is a representation of the building so that by glancing at it Players will be able to tell what it is they are choosing to construct. Now drag this image onto the Build Image property of your WarFactory (remember to do this on the Prefab not the instance).
We should create our Tank object now too, but for this we will cheat slightly. Rename your Unit object to Tank. Now create a new folder inside the Unit folder called Tank and create a C# script called Tank.cs inside that. We want this to inherit from Unit and to override the Start() method (similar to what we did with our WarFactory just before).
public class Tank : Unit { protected override void Start () { base.Start (); } protected override void Update () { base.Update(); } }
Now we want to remove the Unit script from our Tank object and replace it with the Tank script that we just created. Dragging the Tank object into the Tank folder will give us a Tank Prefab. We also need a BuildImage for our Tank, something like the one below,
which we need to attach to the Build Image property of our Tank Prefab. Let's now add our two newly created Prefab's to our GameObjectList, making sure to add them to the appropriate lists.
Now that we finally have a Building which will create a type of Unit, it is time to display that available action to the Player and to allow them to initiate that action. This will be done through our HUD, but only if the Player has selected the WarFactory. We should first add a WarFactory to our Player, since we will only be displaying actions to them if they own the Building / Unit that is selected. The last thing we want is for all Players to have full control of all Buildings and Units on the map. Inside the DrawOrdersBar() method in HUD.cs we want to add the following code immediately after we have set the selectionName.
if(player.SelectedObject.IsOwnedBy(player)) { //reset slider value if the selected object has changed if(lastSelection && lastSelection != player.SelectedObject) sliderValue = 0.0f; DrawActions(player.SelectedObject.GetActions()); //store the current selection lastSelection = player.SelectedObject; }
The method IsOwnedBy() will allow us to determine whether to show actions etc. in the HUD. Create that method in WorldObject.cs now.
public bool IsOwnedBy(Player owner) { if(player && player.Equals(owner)) { return true; } else { return false; } }
We are wanting to call the method DrawActions() and pass it the actions that the currently selected object has. Since there may be more actions than can fit on screen we are going to implement a scroll bar for our list of actions. I mention this now, since if the selection changes we want to make sure that we are showing the list from the top, not from where a previous list may have been scrolled to, which is why we are making use of the variable lastSelected. The variable sliderValue is what we are using to determine what position the scrollbar should be drawn at. We should define these at the top of HUD.cs now.
private WorldObject lastSelection; private float sliderValue;
Obviously we now need to define DrawActions() in HUD.cs.
private void DrawActions(string[] actions) { GUIStyle buttons = new GUIStyle(); buttons.hover.background = buttonHover; buttons.active.background = buttonClick; GUI.skin.button = buttons; int numActions = actions.Length; //define the area to draw the actions inside GUI.BeginGroup(new Rect(0, 0, ORDERS_BAR_WIDTH, buildAreaHeight)); //draw scroll bar for the list of actions if need be if(numActions >= MaxNumRows(buildAreaHeight)) DrawSlider(buildAreaHeight, numActions / 2.0f); //display possible actions as buttons and handle the button click for each for(int i = 0; i < numActions; i++) { int column = i % 2; int row = i / 2; Rect pos = GetButtonPos(row, column); Texture2D action = ResourceManager.GetBuildImage(actions[i]); if(action) { //create the button and handle the click of that button if(GUI.Button(pos, action)) { if(player.SelectedObject) player.SelectedObject.PerformAction(actions[i]); } } } GUI.EndGroup(); }
There are a lot of things going on in this method, and a lot of things we need to define in order for it to work. First up we are defining a custom skin for just the buttons (this is actually just a modification of whatever skin happens to be active). Then we are making use of two textures to simulate a button hover and a button click for our build actions. I have used the following images
in my project. We then need fields for these at the top of HUD.cs,public Texture2D buttonHover, buttonClick;
and then we need to assign the textures we added to these fields. (Add the textures into the Images folder found inside the HUD folder) You may need to wait with adding the textures from inside Unity until we have added all the pieces of code that our method still needs ...
We need to define some constant values for the dimensions of the build image at the top of HUD.cs
private const int BUILD_IMAGE_WIDTH = 64, BUILD_IMAGE_HEIGHT = 64;
as well as a value for determining the height of the area we will draw the actions in.
private int buildAreaHeight = 0;
By using this value we can make sure that we always fill the available screen space with our build area. We need to initialize this in the Start() method, which is called when our HUD is created.
buildAreaHeight = Screen.height - RESOURCE_BAR_HEIGHT - SELECTION_NAME_HEIGHT - 2 * BUTTON_SPACING;
I have added this initialization immediately after we have added all of the resource types to the HUD. This initialization introduces another constant that we need to add to the top of HUD.cs.
private const int BUTTON_SPACING = 7;
With all of the variables declared at last, we are left with 3 methods that still need to be added to HUD.cs.
private int MaxNumRows(int areaHeight) { return areaHeight / BUILD_IMAGE_HEIGHT; } private Rect GetButtonPos(int row, int column) { int left = SCROLL_BAR_WIDTH + column * BUILD_IMAGE_WIDTH; float top = row * BUILD_IMAGE_HEIGHT - sliderValue * BUILD_IMAGE_HEIGHT; return new Rect(left, top, BUILD_IMAGE_WIDTH, BUILD_IMAGE_HEIGHT); } private void DrawSlider(int groupHeight, float numRows) { //slider goes from 0 to the number of rows that do not fit on screen sliderValue = GUI.VerticalSlider(GetScrollPos(groupHeight), sliderValue, 0.0f, numRows - MaxNumRows(groupHeight)); }
This in turn introduces one more method that we need to add
private Rect GetScrollPos(int groupHeight) { return new Rect(BUTTON_SPACING, BUTTON_SPACING, SCROLL_BAR_WIDTH, groupHeight - 2 * BUTTON_SPACING); }
which in turn has a final constant to add.
private const int SCROLL_BAR_WIDTH = 22;
We are drawing our build options from the top of the order bar area, so let's just change the display of the name to the bottom of that area (the rest of our draw code is actually already expecting this). We do this by changing the top position of the label for our selection name (found at the end of DrawOrdersBar()).
if(!selectionName.Equals("")) { int topPos = buildAreaHeight + BUTTON_SPACING; GUI.Label(new Rect(0, topPos, ORDERS_BAR_WIDTH, SELECTION_NAME_HEIGHT), selectionName); }
Of course, if we want to see a name for our WarFactory we are going to need to set a value for it's ObjectName field. We also need to make sure this is set for our Tank. If you now run your project from inside Unity and select your WarFactory you should see the build image for the Tank being displayed. To see that the scroll bar works (and to see the full two column display in action) we can change the number of times Tank is present in the actions list for our WarFactory up to something like 17. If you entered 17 instances of "Tank" to actions, you should see 17 images of a Tank in your orders bar, though you may need to scroll down to see them all.
To actually start the construction of the Unit we are going to make use of the PerformAction() method. If you recall from when we were writing DrawActions(), the actual click of the button that is being made from the build images is calling Player.SelectedObject.PerformAction(). So to initiate the build from our WarFactory add the following override for PerformAction() to WarFactory.cs.
public override void PerformAction(string actionToPerform) { base.PerformAction(actionToPerform); CreateUnit(actionToPerform); }
If we run this now we should see the debug message we added to Player.AddUnit() showing in the Console in Unity almost immediately after we click on the buildImage for Tank (once our WarFactory has been selected of course). If we change the value of maxBuildProgress for the WarFactory up to 5 we can see that it now takes 5 seconds for the message to be displayed.
Create Actual Unit
Now that we can add Units into the build queue, it is time to actually create an instance of that Unit and add it to the Player. This needs to take place in AddUnit(). We know that this is called once a Unit reaches the end of the build queue because of the Debug message being printed to the Console. We need to add this code to that method in Player.cs to enable the creation of a new Unit.
Units units = GetComponentInChildren< Units >(); GameObject newUnit = (GameObject)Instantiate(ResourceManager.GetUnit(unitName), spawnPoint, rotation); newUnit.transform.parent = units.transform;
Units is simply a wrapper object we will use within our Player to give them ready access to all of the Units that they currently have. Create a new C# script Units.cs inside the Unit folder and strip out all of the code in that class. You should be left with the following code.
public class Units : MonoBehaviour { //wrapper class for unit listing for a player }
We will do the same thing now for Buildings as well. Inside the Building folder create a new C# script called Buildings.cs and strip out all of the code in the class to give this.
public class Buildings : MonoBehaviour { //wrapper class for building listing for a player }
Now create two new empty objects inside Unity, one called Units and the other called Buildings. Add Units.cs to the Units object and add Buildings.cs to the Buildings object. Then add both of these objects to your Player object. Now shift the WarFactory object to the Buildings object and the Tank object to the Units object.
If we take a look back at the AddUnit() method now I will explain briefly where the magic happens. The Unity method Instantiate() takes a Prefab, a position, and a rotation and returns us an instance of the desired Prefab at that location with the specified rotation. We then tell the new object that it's parent object is to be the wrapper object for the Units that our Player owns. Just like that we have added the newly created Unit to our Player. Feel free to run this in Unity now. You should see a new Tank appearing in front of the WarFactory once it has finished it's time in the build queue (assuming you just told the WarFactory to make a new Tank of course). If you now look at the Units object for the Player you should see that it contains Tank and Tank(Clone).
Display Build Queue
There is one last thing that I want to add in this time (and I know this part has turned out really long ...) - display of our build queue. At the moment there is no indication that a Unit is being created, or how much longer the construction is going to take either, the Unit simply appears when it is done. This final addition needs to take place within HUD.cs. Add this code
Building selectedBuilding = lastSelection.GetComponent< Building >(); if(selectedBuilding) { DrawBuildQueue(selectedBuilding.getBuildQueueValues(), selectedBuilding.getBuildPercentage()); }
to DrawOrdersBar() right after we set lastSelection. Note that we are making sure that the Player has a Building selected before attempting to draw the build queue. And now for the definition of DrawBuildQueue().
private void DrawBuildQueue(string[] buildQueue, float buildPercentage) { for(int i = 0; i < buildQueue.Length; i++) { float topPos = i * BUILD_IMAGE_HEIGHT - (i+1) * BUILD_IMAGE_PADDING; Rect buildPos = new Rect(BUILD_IMAGE_PADDING, topPos, BUILD_IMAGE_WIDTH, BUILD_IMAGE_HEIGHT); GUI.DrawTexture(buildPos, ResourceManager.GetBuildImage(buildQueue[i])); GUI.DrawTexture(buildPos, buildFrame); topPos += BUILD_IMAGE_PADDING; float width = BUILD_IMAGE_WIDTH - 2 * BUILD_IMAGE_PADDING; float height = BUILD_IMAGE_HEIGHT - 2 * BUILD_IMAGE_PADDING; if(i==0) { //shrink the build mask on the item currently being built to give an idea of progress topPos += height * buildPercentage; height *= (1 - buildPercentage); } GUI.DrawTexture(new Rect(2 * BUILD_IMAGE_PADDING, topPos, width, height), buildMask); } }
Once again we need another constant value at the top of HUD.cs
private const int BUILD_IMAGE_PADDING = 8;
and access to two more textures.
public Texture2D buildFrame, buildMask;
Both of these textures need to be 64x64, and can look something like this.
These are used to provide a little more definition to the build queue - buildFrame provides a border around each buildImage, and buildMask is used to show the user how far through the current build the object at the front of the queue is. If you run this now you will see that the display works, it is just in a strange place. The idea is to have our build queue displayed just to the left of our orders area. But to do that we are going to need to tweak some of our existing positioning code for our orders bar. Basically we need to shift the start of the drawing area left by BUILD_IMAGE_WIDTH and then shift all of the other drawing right by that much. In DrawOrdersBar() we need to change these two lines for setup of the draw areaGUI.BeginGroup(new Rect(Screen.width - ORDERS_BAR_WIDTH - BUILD_IMAGE_WIDTH, RESOURCE_BAR_HEIGHT, ORDERS_BAR_WIDTH + BUILD_IMAGE_WIDTH, Screen.height - RESOURCE_BAR_HEIGHT)); GUI.Box(new Rect(BUILD_IMAGE_WIDTH + SCROLL_BAR_WIDTH, 0, ORDERS_BAR_WIDTH, Screen.height - RESOURCE_BAR_HEIGHT),
as well as the position of our selection name.
if(!selectionName.Equals("")) { int leftPos = BUILD_IMAGE_WIDTH + SCROLL_BAR_WIDTH / 2; int topPos = buildAreaHeight + BUTTON_SPACING; GUI.Label(new Rect(leftPos, topPos, ORDERS_BAR_WIDTH, SELECTION_NAME_HEIGHT), selectionName); }
This line then fixes the draw area in DrawActions().
GUI.BeginGroup(new Rect(BUILD_IMAGE_WIDTH, 0, ORDERS_BAR_WIDTH, buildAreaHeight));
There, that looks better. Feel free to run the game now and check it out. If you add lots of build actions to the WarFactory now (e.g about 17 entries of "Tank") you will also notice that the scroll bar now sits nicely outside our orders area as well.
Well, I think that finally does it for this time. Sorry for the length , but I do feel that all of these steps need to be presented together. If you want to enable construction of new Units from a Building it is now as simple as copying what we did with WarFactory. Just remember that each action should correspond to the name of a Prefab that you want to create, and that the Prefabs for the Units you want need to be added to GameObjectList. Make those small changes and all of the hard work of build queues and display etc. have been taken care of already. The full code for this post is up on github under the commit for Part10. Next time we will extend the capability of our Building some more by adding in a rally point and the ability to sell a Building.