Part 6: Units and Selection Box
Last time we created a Building and made sure that we could select it and display the name of the selected Building to the Player. This time we will create our first Unit. Then we will present some more information to the Player on what they have selected by displaying a selection box around the selected Unit / Building.
Basic Unit
Our approach for creating a basic Unit will follow a very similar format to what we did last time with our basic Building. Create a new C# script inside your Unit folder (rather than the Building folder used last time) called Unit.cs. Once again we want to have this class inherit from WorldObject and to provide an override for the Unity methods. The resulting class should look the same as I have below.
public class Unit : WorldObject { /*** Game Engine methods, all can be overridden by subclass ***/ protected override void Awake() { base.Awake(); } protected override void Start () { base.Start(); } protected override void Update () { base.Update(); } protected override void OnGUI() { base.OnGUI(); } }
Now let us create a basic Unit, modelled very loosely on a tank. Remember, this is just a placeholder to see that things are working - a perfectly acceptable way of doing things for early production. Create a new empty object, call it Unit, and set its transform properties as follows:
- position = (0, 0, 0)
- rotation = (0, 0, 0)
- scale = (1, 1, 1)
- LeftTread: position = (-0.75, 0.25, 0), rotation = (0, 90, 90), scale = (0.5, 1.85, 0.5)
- RightTread: position = (0.75, 0.25, 0), rotation = (0, 90, 90), scale = (0.5, 1.85, 0.5)
- Body: position = (0, 0.65, 0), rotation = (0, 90, 0), scale = (3, 0.5, 1.5)
- Turret: position = (0, 1.35, 0), rotation = (0, 0, 0), scale = (1, 1, 1)
- Muzzle: position = (0, 1.4, 1), rotation = (0, 90, 90), scale = (0.5, 0.75, 0.5)
Selection Box
So we now have a Building and a Unit in our world, along with a random object (which is useful to show that we can only select objects which we have defined as WorldObjects). The Player can see the name of what was selected, but currently has no way of knowing which of the objects in the world that is - which will quickly become annoying as the number of objects in the world increases. Let's fix that little problem for the Player by drawing a selection box around the object the Player currently has selected. Since we want to be able to draw a selection box around any WorldObject that is selected, we will let WorldObject handle the drawing of the selection box when it is required. To begin, let us add the following code to the OnGUI() method of WorldObject.cs.
if(currentlySelected) DrawSelection();
Note that we only want to draw a selection box if the WorldObject has been selected. Obviously to do anything we are going to need to create the appropriate method. Do so now, making sure that it is a private method, and add this code to it.
private void DrawSelection() { GUI.skin = ResourceManager.SelectBoxSkin; Rect selectBox = WorkManager.CalculateSelectionBox(selectionBounds, playingArea); //Draw the selection box around the currently selected object, within the bounds of the playing area GUI.BeginGroup(playingArea); DrawSelectionBox(selectBox); GUI.EndGroup(); }
We will store the skin to be used for drawing the selection box in our ResourceManager, since we only wish to have one reference to it (rather than one for every WorldObject). We will add that one reference to our HUD and then use the HUD to set that value in our ResourceManager when the HUD starts up. The other advantage that this gives us is the ability (if we wanted) to customise the selection box for each Player. We will not look into that here, but it is an option that our project should support quite easily. To create a reference to our skin add the following code to ResourceManager.cs.
private static GUISkin selectBoxSkin; public static GUISkin SelectBoxSkin { get { return selectBoxSkin; } } public static void StoreSelectBoxItems(GUISkin skin) { selectBoxSkin = skin; }
This provides a public accessor for the GUISkin and a public method for storing any items to be used with a selection box. At the moment this is just a skin, but that will change later on. Now we need to add another public skin to our HUD giving us
public GUISkin resourceSkin, ordersSkin, selectBoxSkin;
at the top of HUD.cs now. To access our ResourceManager from the HUD we need to add
using RTS;
to HUD.cs above the class definition (along with the other using statements there). Now we can add this line
ResourceManager.StoreSelectBoxItems(selectBoxSkin);
to the end of the Start() method to make sure that the ResourceManager has a reference to the skin. With that in place our WorldObject can now access that skin when it needs to draw the selection box. Before we go any further with this code, let's create the skin (inside the skins folder in our HUD directory) and call it SelectBoxSkin. Create a new 128x128 image called selectionBox.png and store it in the Images folder for the HUD. Make this image completely transparent apart from a 1 pixel thick line which wraps around each corner. It should look similar to my image below.
Set the background for box in our SelectBoxSkin to be this image. Then change the following settings for box:
- Border: (3, 3, 3, 3)
- Margin: (0, 0, 0, 0)
- Padding: (0, 0, 0, 0)
If we now look back at the code we added to WorldObject we can see that there is a reference to selectionBounds. We need to define that by adding this code to the top of WorldObject.cs.
protected Bounds selectionBounds;
Before we look at calculating the rectangle to draw we should make sure that the bounds of our object are being calculated correctly. Create a public method called CalculateBounds() with the following code in it
public void CalculateBounds() { selectionBounds = new Bounds(transform.position, Vector3.zero); foreach(Renderer r in GetComponentsInChildren< Renderer >()) { selectionBounds.Encapsulate(r.bounds); } }
and then call that from the Awake() method of WorldObject.cs with this code.
selectionBounds = ResourceManager.InvalidBounds; CalculateBounds();
Note that the first thing we are doing inside Awake() is to set the bounds to an invalid selection, so we better add that to ResourceManager.cs like so
private static Bounds invalidBounds = new Bounds(new Vector3(-99999, -99999, -99999), new Vector3(0, 0, 0)); public static Bounds InvalidBounds { get { return invalidBounds; } }
and then make sure that we add
using RTS;
to the top of WorldObject.cs so that we can access it. The actual method CalculateBounds() is making use of the fact that Unity is keeping track of the bounds of each primitive object in our world. So we set the bounds to be centred on the position of our object and extending out exactly zero units in all directions. We then find all the child objects which have bounds defined and add their bounds to ours. By calling this from the Awake() method we can guarantee that each object knows the bounds it was created with. If we move an object we will need to call CalculateBounds() again to recalculate the bounds.
We will get to working out the rectangle in just a moment. However, there is one other variable that we need to add first: playingArea. This is a variable that our WorldObject needs to know about, and which we will set whenever we select the object (since it is actually the area of the screen that is not covered by our HUD). To get this set up will take a number of small steps. First, declare the following variable at the top of WorldObject.cs.
protected Rect playingArea = new Rect(0.0f, 0.0f, 0.0f, 0.0f);
Second, update the SetSelection() method to the following
public void SetSelection(bool selected, Rect playingArea) { currentlySelected = selected; if(selected) this.playingArea = playingArea; }
to make sure that the playingArea is being set whenever the the object is selected. Now we need to go and change every reference to SetSelection() to make sure that our code will compile and run again. Most of these happen in the ChangeSelection() method of WorldObject.cs, so update it to have the following code.
private void ChangeSelection(WorldObject worldObject, Player controller) { //this should be called by the following line, but there is an outside chance it will not SetSelection(false, playingArea); if(controller.SelectedObject) controller.SelectedObject.SetSelection(false, playingArea); controller.SelectedObject = worldObject; worldObject.SetSelection(true, controller.hud.GetPlayingArea()); }
Note: if we are setting the selection to true then we find out what the playing area is from the HUD. There are two other places where this code is being called, both in UserInput.cs - once in LeftMouseClick() and once in RightMouseClick(). In both cases add the parameter
player.hud.GetPlayingArea()
to the SetSelection() method call. The last thing to do for this is to create the method GetPlayingArea() inside HUD.cs.
public Rect GetPlayingArea() { return new Rect(0, RESOURCE_BAR_HEIGHT, Screen.width - ORDERS_BAR_WIDTH, Screen.height - RESOURCE_BAR_HEIGHT); }
Okay. That was a little fiddly, but it is a good process to go through at the same time. The reality is that often when you add changes to your code you realize there are a number of other places that need to be changed at the same time to make sure that your code will still run. If you do not update references like these then the compiler will complain about all sorts of things to try and let you know what you are missing.
Now it is time (at last) to calculate the rectangle on screen inside which we wish to draw our selection box. First up, we are going to create another static helper class called WorkManager. We will use this to provide a number of useful methods that can then be accessed anywhere in our code. Inside the RTS folder create a new C# script called WorkManager.cs. We want to set this up similar to ResourceManager - so wrapped in the namespace RTS and not inheriting from anything. Your new class should look like this.
using UnityEngine; using System.Collections.Generic; namespace RTS { public static class WorkManager { } }
With this in place we can add a static method CalculateSelectionBox() to our new WorkManager which will calculate the rectangle we want.
public static Rect CalculateSelectionBox(Bounds selectionBounds, Rect playingArea) { //shorthand for the coordinates of the centre of the selection bounds float cx = selectionBounds.center.x; float cy = selectionBounds.center.y; float cz = selectionBounds.center.z; //shorthand for the coordinates of the extents of the selection bounds float ex = selectionBounds.extents.x; float ey = selectionBounds.extents.y; float ez = selectionBounds.extents.z; //Determine the screen coordinates for the corners of the selection bounds List< Vector3 > corners = new List< Vector3 >(); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx+ex, cy+ey, cz+ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx+ex, cy+ey, cz-ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx+ex, cy-ey, cz+ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx-ex, cy+ey, cz+ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx+ex, cy-ey, cz-ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx-ex, cy-ey, cz+ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx-ex, cy+ey, cz-ez))); corners.Add(Camera.mainCamera.WorldToScreenPoint(new Vector3(cx-ex, cy-ey, cz-ez))); //Determine the bounds on screen for the selection bounds Bounds screenBounds = new Bounds(corners[0], Vector3.zero); for(int i = 1; i < corners.Count; i++) { screenBounds.Encapsulate(corners[i]); } //Screen coordinates start in the bottom left corner, rather than the top left corner //this correction is needed to make sure the selection box is drawn in the correct place float selectBoxTop = playingArea.height - (screenBounds.center.y + screenBounds.extents.y); float selectBoxLeft = screenBounds.center.x - screenBounds.extents.x; float selectBoxWidth = 2 * screenBounds.extents.x; float selectBoxHeight = 2 * screenBounds.extents.y; return new Rect(selectBoxLeft, selectBoxTop, selectBoxWidth, selectBoxHeight); }
This is easily the biggest single piece of code I have posted so far ... However, most of it breaks down into the commented sections. First up, some local variables to reduce the amount of typing needed as well as to reduce the number of references to object variables. We then use Unity methods to find the screen coordinates of each corner of the bounds which surround our object. Once we have those we can create a bounding object for those corners (in screen coordinates). We can then use this bounding object to create and return the rectangle that we are after.
The final thing to do for our selection box is to implement the method DrawSelectionBox() in WorldObject.cs. We are splitting this off into a separate method so that child objects can easily draw any extra elements they want on top of the basic selection box (for example a health bar).
protected virtual void DrawSelectionBox(Rect selectBox) { GUI.Box(selectBox, ""); }
This simply draws a box (with no text inside it) within the specified rectangle. Since we have set the background image for box in our skin, this will display the image that we want.
If you save all of your changes and then run your project from inside Unity you should be able to select your Building and then your Unit and see the selection box appearing around each of those. Notice that as you move the camera around it maintains this rectangle around your object correctly too.
Well, I think that brings us nicely to the end of another part. We now have a basic Unit in place. And we have just succeeded in adding a selection box to display around our selected objects. If you had any problems the full code from this part can be found on github under the commit for Part 6. In the next part we will look at adding improving the HUD a bit by adding in some custom mouse cursors.