Building a real-time strategy game in Unity 4.1 using C# scripting

<
>

Part 13: Constructing New Buildings

There is one very important aspect of expansion that we still do not have in place yet - the ability to construct new Buildings. We will fix that this time by creating a Worker Unit and using it for construction. We will allow the Player to choose where to place the new Buidling, making sure that it does not collide with any of the existing objects in our world. We will then present some simple graphical cues to indicate the progress of the construction.

Worker

Let's kick things off by creating a Worker Unit. Inside the Unit folder create a new folder called Worker, with a new C# script called Worker.cs inside that.

				using UnityEngine;

				public class Worker : Unit {

					public int buildSpeed;

					private Building currentProject;
					private bool building = false;
					private float amountBuilt = 0.0f;

					/*** Game Engine methods, all can be overridden by subclass ***/

					protected override void Start () {
						base.Start();
						actions = new string[] {"Refinery", "WarFactory"};
					}

					protected override void Update () {
						base.Update();
					}

					/*** Public Methods ***/

					public override void SetBuilding (Building project) {
						base.SetBuilding (project);
						currentProject = project;
						StartMove(currentProject.transform.position, currentProject.gameObject);
						building = true;
					}

					public override void PerformAction (string actionToPerform) {
						base.PerformAction (actionToPerform);
						CreateBuilding(actionToPerform);
					}

					protected override void StartMove(Vector3 destination) {
						base.StartMove(destination);
						amountBuilt = 0.0f;
					}

					private void CreateBuilding(string buildingName) {

					}
				}

This code will be the starting point for a Worker's behaviour. While we are at it we will also change the name of Init() in Unit.cs to SetBuilding(), since this is actually a more sensible name for what that method does. There are a couple of places we need to update for this change to be fully integrated.

With that out of the way, let us have a quick look at what are wanting our Worker to do. We are defining a public build speed, so that we can play around with how fast production should happen inside Unity. Each Worker then needs a reference to the current Building it is working on, whether that is currently in 'build' mode, and how much has been constructed so far.

In the Start() method we define the Buildings that the Worker can construct. For now this is all of the Buildings that we have defined so far. Remember that each of these needs to be a Prefab object added to GameObjectList, and that each Building needs a BuildImage set as well. This means that we should add a build image to the Refinery we created last time (I have used the image below) and make it into a Prefab, which we then need to add to the Buildings list in GameObjectList.

Refinery Build Image

Refinery Build Image

We will use SetBuilding() later on to allow us to click on a Building that is under construction and tell the Worker to move to it and start construction on that Building. This will allow us to have more than one Worker assigned to the construction of each Building.

The action we wish to perform when a button in the side bar is clicked is to create the specified Building. We will define the behaviour of CreateBuilding() shortly.

The one thing that we need to do to allow this code to compile is to change the StartMove() method in Unit.cs to virtual so that we can override it.

				public virtual void StartMove(Vector3 destination) {
				...
				}

With the basic behaviour defined we need to create an actual Worker object. Start by creating an empty object called Worker with Position (0, 0, 0). As usual, any objects currently located around (0, 0, 0) need to be shifted out of the way while we are constructing out Worker (in this case, our Refinery).

Add 5 cubes to the Worker called Body, Cab, ArmL, ArmR, Scoop. Now add 2 cylinders called SmokestackL and SmokeStackR and two capsules called TreadL and TreadR. Set the properties for each object as follows:

Finally attach Worker.cs to the Worker object and add the Worker object to the Units object belonging to your Player object. For this to interact correctly we also need to set some values for our Worker. I have used HitPoints = 100, MaxHitPoints = 100, MoveSpeed = 3, RotateSpeed = 2, and BuildSpeed = 5.

With this in place you should now be able to run your game and order your new Worker to move around the map.

Starting Building Construction

You should see that the Worker has two options showing up in the orders area representing the Buildings that it is able to construct. Clicking on one of these will call the ConstructBuilding() method in Worker.cs, but we have not yet defined what it is that this does. Let's do so now by adding the following code into that method.

				private void CreateBuilding(string buildingName) {
					Vector3 buildPoint = new Vector3(transform.position.x, transform.position.y, transform.position.z + 10);
					if(player) player.createBuilding(buildingName, buildPoint, this, playingArea);
				}

We define a point close to the Worker which will be the default place to put the Building. Then we tell the Player to create a Building with the name passed in, at the point specified, and being created by the selected Worker. It is up to the Player to decide how to proceed with creating the correct Building. We should add that method to Player.cs now.

				public void CreateBuilding(string buildingName, Vector3 buildPoint, Unit creator, Rect playingArea) {
					GameObject newBuilding = (GameObject)Instantiate(ResourceManager.GetBuilding(buildingName), buildPoint, new Quaternion());
					tempBuilding = newBuilding.GetComponent< Building >();
					if (tempBuilding) {
						tempCreator = creator;
						findingPlacement = true;
						tempBuilding.SetTransparentMaterial(notAllowedMaterial, true);
						tempBuilding.SetColliders(false);
						tempBuilding.SetPlayingArea(playingArea);
					} else Destroy(newBuilding);
				}

The idea here is to create a temporary version of the desired Building at the point specified and to use that to find the actual position in the world where the Player wishes to construct the Building. By setting the Building to be transparent we make it obvious to the Player that this is not the final copy of their Building. We are also going to disable all colliders that the Building has to make sure that it does not trigger any interactions with other objects in our world while it is in this temporary state.

Before we can carry on we need to define some more variables at the top of Player.cs.

				public Material notAllowedMaterial, allowedMaterial;

				private Building tempBuilding;
				private Unit tempCreator;
				private bool findingPlacement = false;

We also need to define the three methods that are called on the temporary Building. These are all methods that would actually be useful to have available to any of the objects that we define, so we will add them to WorldObject.cs. While we are at it we will also define a method to restore the original materials that the object had (even though we will not use it just yet).

				public void SetColliders(bool enabled) {
					Collider[] colliders = GetComponentsInChildren< Collider >();
					foreach(Collider collider in colliders) collider.enabled = enabled;
				}

				public void SetTransparentMaterial(Material material, bool storeExistingMaterial) {
					if(storeExistingMaterial) oldMaterials.Clear();
					Renderer[] renderers = GetComponentsInChildren< Renderers >();
					foreach(Renderer renderer in renderers) {
						if(storeExistingMaterial) oldMaterials.Add(renderer.material);
						renderer.material = material;
					}
				}

				public void RestoreMaterials() {
					Renderer[] renderers = GetComponentsInChildren< Renderers >();
					if(oldMaterials.Count == renderers.Length) {
						for(int i = 0; i < renderers.Length; i++) {
							renderers[i].material = oldMaterials[i];
						}
					}
				}

				public void SetPlayingArea(Rect playingArea) {
					this.playingArea = playingArea;
				}

Notice that we are specifying whether to store the existing materials that the object has set. This will turn out to be very useful later on. It does mean that we need to declare the list that we are going to store these in at the top of WorldObject.cs

				private List< Material > oldMaterials = new List< Material >();

as well as including

				using System.Collections.Generic;

at the top of the file so that we can use the list.

Finally we need to add the transparent material we are going to use to the Player. We will actually create two transparent materials at once, one for allowed and one for not allowed, and place them in our Materials folder. Set the shaders for both of these materials to be Transparent/Diffuse. We want the colour for Allowed to be (R=60, G=175, B=255, A=100) and the colour for NotAllowed to be (R=130, G=0, B=0, A=200). Now attach these materials to the appropriate variables in the Player object.

Play the game now and when you tell your Worker to construct a Building you should see a temporary version of that Building being placed in front of your Worker.

Building Placement

Now that we have the beginning of the creation process for a Building defined it is time to allow the Player to choose where to place that Building. This involves updating the behaviour of UserInput.cs slightly. The first thing we want to change is MouseHover().

				private void MouseHover() {
					if(player.hud.MouseInBounds()) {
						if(player.IsFindingBuildingLocation()) {
							player.FindBuildingLocation();
						} else {
							// existing behaviour goes here ...
						}
					}
				}

The idea here is that if the Player is currently trying to place a Building then handle that, otherwise we want to do perform the existing hover behaviour. This requires adding the definition of those two methods inside Player.cs.

				public bool IsFindingBuildingLocation() {
					return findingPlacement;
				}

				public void FindBuildingLocation() {
					Vector3 newLocation = WorkManager.FindHitPoint();
					newLocation.y = 0;
					tempBuilding.transform.position = newLocation;
				}

With this code we are also deciding that we want to shift FindHitPoint() and FindHitObject() from UserInput.cs into WorkManager.cs. We also want them to take a start position, rather than always using the mouse position. They need to become

				public static GameObject FindHitObject(Vector3 origin) {
					Ray ray = Camera.main.ScreenPointToRay(origin);
					RaycastHit hit;
					if(Physics.Raycast(ray, out hit)) return hit.collider.gameObject;
					return null;
				}

and

				public static Vector3 FindHitPoint(Vector3 origin) {
					Ray ray = Camera.main.ScreenPointToRay(origin);
					RaycastHit hit;
					if(Physics.Raycast(ray, out hit)) return hit.point;
					return ResourceManager.InvalidPosition;
				}

in order to compile and run nicely. Each reference to either of these methods in UserInput.cs now needs to reference WorkManager too, as well as passing

				Input.mousePosition

as the parameter. This method is going to have the temporary Building perfectly centred on the mouse cursor in the world, so we also want to hide the cursor if the Player is placing a Building. This can be done simply by adding another check into DrawMouseCursor() in HUD.cs.

				if(mouseOverHud) {
					Screen.showCursor = true;
				} else {
					Screen.showCursor = false;
					if(!player.IsFindingBuildingLocation()) {
						// existing draw cursor code goes here...
					}
				}

Run your game now and you should see the temporary Building following the mouse cursor around the world. It jumps slightly when we the mouse is over other objects since we are getting the position of the object, rather than the ground underneath / behind it, but that is a problem I will leave you to solve. For now, it is enough that we can move the Building around, thus allowing the Player to choose a build location that suits them.

Now that we can move the temporary Building around we need to determine whether the current position is legal - we don't want to allow the Player to construct a new Building in the middle of an existing Building, for example. Start by adding the following code to Update() in Player.cs.

				if(findingPlacement) {
					tempBuilding.CalculateBounds();
					if(CanPlaceBuilding()) tempBuilding.SetTransparentMaterial(allowedMaterial, false);
					else tempBuilding.SetTransparentMaterial(notAllowedMaterial, false);
				}

We want to change the transparent material if the Player is allowed to place the Building at it's current location. This provides a simple visual cue to them as to what they are allowed to do. We also need to make sure that we recalculate the bounds for the Building, since we will use these to determine if the location is already occupied. Now it is time to create the method CanPlaceBuilding() in Player.cs.

				public bool CanPlaceBuilding() {
					bool canPlace = true;

					Bounds placeBounds = tempBuilding.GetSelectionBounds();
					//shorthand for the coordinates of the center of the selection bounds
					float cx = placeBounds.center.x;
					float cy = placeBounds.center.y;
					float cz = placeBounds.center.z;
					//shorthand for the coordinates of the extents of the selection box
					float ex = placeBounds.extents.x;
					float ey = placeBounds.extents.y;
					float ez = placeBounds.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)));

					foreach(Vector3 corner in corners) {
						GameObject hitObject = WorkManager.FindHitObject(corner);
						if(hitObject && hitObject.name != "Ground") {
							WorldObject worldObject = hitObject.transform.parent.GetComponent< WorldObject >();
							if(worldObject && placeBounds.Intersects(worldObject.GetSelectionBounds())) canPlace = false;
						}
					}
					return canPlace;
				}

This large method is finding the screen coordinate for each corner of the bounding box for the temporary Building. We then fire a ray into the world from each corner to find the first object we would hit. If this object's bounding box intersects the bounding box for the Building then the space is already occupied and so it cannot be built in.

Building Construction

With the ability to select a valid build location defined we now need to allow the Player to actually start construction. We will begin this with a left mouse click, so we need to initiate that in UserInput.cs.

				private void LeftMouseClick() {
					if(player.hud.MouseInBounds()) {
						if(player.IsFindingBuildingLocation()) {
							if(player.CanPlaceBuilding()) player.StartConstruction();
						} else {
							// existing left click logic goes here...
						}
					}
				}

Next we will define StartConstruction() in Player.cs to handle the beginning of construction.

				public void StartConstruction() {
					findingPlacement = false;
					Buildings buildings = GetComponentInChildren< Buildings >();
					if(buildings) tempBuilding.transform.parent = buildings.transform;
					tempBuilding.SetPlayer();
					tempBuilding.SetColliders(true);
					tempCreator.SetBuilding(tempBuilding);
					tempBuilding.StartConstruction();
				}

It is here that we

We want to initialize the Player for the Building at this point too, so we will take the current initialization found in Start() in WorldObject.cs and put it in a new method called SetPlayer(). This gives us

				protected virtual void Start () {
					SetPlayer();
				}

and

				public void SetPlayer() {
					player = transform.root.GetComponentInChildren< Player >();
				}

in WorldObject.cs. We already defined the basic behaviour for our Worker, but we still need to define the behaviour of StartConstruction() in Building.cs.

				public void StartConstruction() {
					CalculateBounds();
					needsBuilding = true;
					hitPoints = 0;
				}

The call to CalculateBounds() makes sure that the bounds for the Building are correct once construction has started. We will make use of hitPoints to track build progress, so we need to set hitPoints to 0 to indicate no initial progress. We also need to define

				private bool needsBuilding = false;

at the top of Building.cs. Now add

				if(needsBuilding) DrawBuildProgress();

to the end of OnGUI() and define the method DrawBuildProgress()

				private void DrawBuildProgress() {
					GUI.skin = ResourceManager.SelectBoxSkin;
					Rect selectBox = WorkManager.CalculateSelectionBox(selectionBounds, playingArea);
					//Draw the selection box around the currently selected object, within the bounds of the main draw area
					GUI.BeginGroup(playingArea);
					CalculateCurrentHealth(0.5f, 0.99f);
					DrawHealthBar(selectBox, "Building ...");
					GUI.EndGroup();
				}

so that we can display the current build progress to the Player. I have chosen to show this at all times the Building is under construction, but we could also choose to only display this if the Building is currently selected. We actually want to rewrite some of the code in WorldObject.cs to make things easier to control. Replace the existing code in WorldObject.cs for DrawSelectionBox() and CalculateCUrrentHealth() with the code below.

				protected virtual void DrawSelectionBox(Rect selectBox) {
					GUI.Box(selectBox, "");
					CalculateCurrentHealth(0.35f, 0.65f);
					DrawHealthBar(selectBox, "");
				}

				protected virtual void CalculateCurrentHealth(float lowSplit, float highSplit) {
					healthPercentage = (float)hitPoints / (float)maxHitPoints;
					if(healthPercentage > highSplit) healthStyle.normal.background = ResourceManager.HealthyTexture;
					else if(healthPercentage > lowSplit) healthStyle.normal.background = ResourceManager.DamagedTexture;
					else healthStyle.normal.background = ResourceManager.CriticalTexture;
				}

				protected void DrawHealthBar(Rect selectBox, string label) {
					healthStyle.padding.top = -20;
					healthStyle.fontStyle = FontStyle.Bold;
					GUI.Label(new Rect(selectBox.x, selectBox.y - 7, selectBox.width * healthPercentage, 5), label, healthStyle);
				}

The negative padding on healthStyle in DrawHealthBar() makes sure that the text is drawn above the health bar. We then need to add the parameters to CalculateCurrentHealth() in Resource.cs, even though we are not planning on using them.

				protected override void CalculateCurrentHealth (float lowSplit, float highSplit) {
					// existing code ...
				}

Now we need to make it so that our Worker can actually complete construction of the Building. Add the following code to Update() in Worker.cs.

				if(!moving && !rotating) {
					if(building && currentProject && currentProject.UnderConstruction()) {
						amountBuilt += buildSpeed * Time.deltaTime;
						int amount = Mathf.FloorToInt(amountBuilt);
						if(amount > 0) {
							amountBuilt -= amount;
							currentProject.Construct(amount);
							if(!currentProject.UnderConstruction()) building = false;
						}
					}
				}

If the Worker has been told to construct a certain Building, and that Building is still under construction, then the Worker needs to add the amount of work it has done since last update to the Building. This requires that we add two new methods to Building.cs.

				public bool UnderConstruction() {
					return needsBuilding;
				}

				public void Construct(int amount) {
					hitPoints += amount;
					if(hitPoints >= maxHitPoints) {
						hitPoints = maxHitPoints;
						needsBuilding = false;
						RestoreMaterials();
					}
				}

Run your game now and you will see that the Worker is now able to construct new Buildings, and that these take time to complete.

Tidy Up

Things are working well, but there are still a couple of things that we need to tidy up before the Player can interact with their Worker properly.

The first case is actually really easy to fix. Add

				building = false;

to the end of the overridden version of StartMove() in Worker.cs. This is now telling our Worker that if we start a move towards a location on the map, rather than to a Building, it is no longer constructing something.

To implement the second case we need to override MouseClick() in Worker.cs.

				public override void MouseClick (GameObject hitObject, Vector3 hitPoint, Player controller) {
					bool doBase = true;
					//only handle input if owned by a human player and currently selected
					if(player && player.human && currentlySelected && hitObject && hitObject.name!="Ground") {
						Building building = hitObject.transform.parent.GetComponent< Building >();
						if(building) {
							if(building.UnderConstruction()) {
								SetBuilding(building);
								doBase = false;
							}
						}
					}
					if(doBase) base.MouseClick(hitObject, hitPoint, controller);
				}

Notice that we are only executing the base behaviour for MouseClick() if we do not click on a Building under construction.

Implementing the final case requires adding an extra check into RightMouseClick() in UserInput.cs to give the following code.

				private void RightMouseClick() {
					if(player.hud.MouseInBounds() && !Input.GetKey(KeyCode.LeftAlt) && player.SelectedObject) {
						if(player.IsFindingBuildingLocation()) {
							player.CancelBuildingPlacement();
						} else {
							player.SelectedObject.SetSelection(false, player.hud.GetPlayingArea());
							player.SelectedObject = null;
						}
					}
				}

We also need to add the method CancelBuildingPlacement() to Player.cs.

				public void CancelBuildingPlacement() {
					findingPlacement = false;
					Destroy(tempBuilding.gameObject);
					tempBuilding = null;
					tempCreator = null;
				}

Awesome. I do believe that wraps things up for this time. We have successfully added a Worker that can create Buildings in the location specified by the Player. The sourcecode for this part can be found on github under the commit for Part 13. Next time we get on to the fun part of destroying things.

<
>