RTS Tutorial – Part XII Bugfix

Bug-fix

I just noticed a small bug while playing around – none of our units are able to move to an arbitrary point on the map any more. The problem lies in MouseClick() in Unit.cs. We added some code to handle clicking on an empty resource to allow the player to still move there. Unfortunately this is resulting in a NullReferenceException. The problem is actually that our Ground object has no parent, and so the call to hitObject.transform.parent.getComponent() fails. To fix this, we need to check whether the parent object exists.

if(hitObject.transform.parent) {
	Resource resource = hitObject.transform.parent.GetComponent< Resource >();
	if(resource && resource.isEmpty()) clickedOnEmptyResource = true;
}

Add in the if statement above and the problem is fixed.

Advertisements

Creating an RTS in Unity: Part XII

Update: This can now also be found at stormtek.geek.nz/rts_tutorial/part12.php, where the entire tutorial is now being hosted.

It is surprising to look back and see how much we have working already. There is, however, one important thing missing – we have no way for our player to gain resources. The goal for this part is to remedy this shortcoming. First up we will define a resource and place an instance of that resource on our map. Then we will create a new Unit, a Harvester, whose one role is to collect our resource. Once we have a harvester we will tell it how to collect the resource and then how to deposit what it has collected in a special building. I apologize in advance, since this looks like it might turn into another long post …

Resource

The first stage is to create a resource that the Player can collect. To do this we will introduce a new type of WorldObject called Resource. Inside the Resource folder (located inside WorldObject) create a new C# script called Resource.cs. We want this to inherit from WorldObject, to be of a specified type, to have a capacity that we can vary within Unity, and to have a private reference to how much of the resource is left. The resulting class should look like this.

using UnityEngine;
using RTS;

public class Resource : WorldObject {

	//Public variables
	public float capacity;

	//Variables accessible by subclass
	protected float amountLeft;
	protected ResourceType resourceType;

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

	protected override void Start () {
		base.Start();
		amountLeft = capacity;
		resourceType = ResourceType.Unknown;
	}

	/*** Public methods ***/

	public void Remove(float amount) {
		amountLeft -= amount;
		if(amountLeft &lt; 0) amountLeft = 0;
	}

	public bool isEmpty() {
		return amountLeft &lt;= 0;
	}

	public ResourceType GetResourceType() {
		return resourceType;
	}
}

While we are at it we will also provide a public method to get what type the resource is as well as a way to remove a specified quantity from the resource. Notice that we are making sure that a resource cannot have a quantity of less than 0. Also notice that the default resource type is set to Unknown. This means we need to update our ResourceType enum

public enum ResourceType { Money, Power, Unknown }

to allow this. This class now nicely provides a base implementation for any resource we want to add.

The resource that we will collect by default for this tutorial is going to be Ore. This will be taken to a refinery where we will convert it to money that is then added to the player’s bank account. Create a new folder inside the Resource folder and call it OreDeposit. Inside this folder create a new C# script called OreDeposit.cs and a new C# script called Ore.cs. The idea is to create a pile of ore ‘blocks’ that the Player is harvesting. As the deposit shrinks we will begin to hide some of the individual blocks to give a visual sense of progress. This means that Ore.cs is actually just a wrapper file to allow us to access individual blocks of ore.

using UnityEngine;

public class Ore : MonoBehaviour {
	//wrapper class for getting access to a part of an ore deposit
}

All of the hard work for this resource will actually be done inside OreDeposit.cs.

using UnityEngine;
using RTS;

public class OreDeposit : Resource {

	private int numBlocks;

	protected override void Start () {
		base.Start();
		numBlocks = GetComponentsInChildren&lt; Ore &gt;().Length;
		resourceType = ResourceType.Ore;
	}

	protected override void Update () {
		base.Update();
		float percentLeft = (float)amountLeft / (float)capacity;
		if(percentLeft &lt; 0) percentLeft = 0;
		int numBlocksToShow = (int)(percentLeft * numBlocks);
		Ore[] blocks = GetComponentsInChildren&lt; Ore &gt;();
		if(numBlocksToShow &gt;= 0 &amp;&amp; numBlocksToShow &lt; blocks.Length) {
			Ore[] sortedBlocks = new Ore[blocks.Length];
			//sort the list from highest to lowest
			foreach(Ore ore in blocks) {
				sortedBlocks[blocks.Length - int.Parse(ore.name)] = ore;
			}
			for(int i=numBlocksToShow; i&lt;sortedBlocks.Length; i++) {
				sortedBlocks[i].renderer.enabled = false;
			}
			CalculateBounds();
		}
	}
}

The ore deposit keeps track of the number of visible blocks that it has. As the amount of ore left in the deposit goes down we hide particular blocks. The sneaky thing being done here is that we are actually going to give each block a number. We can then use the name of the block to define the order in which the blocks will be hidden – giving us a way to guarantee a sensible way in which the deposit will shrink. Once a block has been removed we then recalculate the bounds of the object to make sure that any collision detection we perform will be accurate at all times. Since the ResourceType for the OreDeposit is being set to Ore we need to make sure that we add this to our ResourceType enum.

public enum ResourceType { Money, Power, Ore, Unknown }

Now that we have the behaviour for an ore deposit defined, we should actually create one. Start by creating a new empty object and calling it OreDeposit. Make sure that it’s position is set to (0, 0, 0). We are going to add 22 cubes to our OreDeposit object, named 1 through 22, with the scale for each of these set to (0.2, 0.2, 0.2). Create them all and then set the position and rotation for specific cubes as follows:

  1. Position = (0.11, 0.25, -0.13), Rotation = (337.5, 278.5, 18.5)
  2. Position = (0.1, 0.23, 0.05), Rotation = (31.5, 335, 33)
  3. Position = (0.25, 0.21, -0.1), Rotation = (307, 301.5, 339.5)
  4. Position = (0.25, 0.21, 0.01), Rotation = (21.5, 249, 23)
  5. Position = (0.27, 0.18, 0.15), Rotation = (355, 238.5, 58.5)
  6. Position = (0.06, 0.1, 0.22), Rotation = (0, 335, 57.5)
  7. Position = (0.39, 0.05, 0.15), Rotation = (304.5, 224, 342)
  8. Position = (0.34, 0.09, 0.03), Rotation = (332.5, 223, 35)
  9. Position = (0.29, -0.01, 0.25), Rotation = (348, 211, 295.5)
  10. Position = (0.35, -0.02, -0.07), Rotation = (322, 268, 59.5)
  11. Position = (0.19, 0, 0.25), Rotation = (5.5, 20.5, 51.5)
  12. Position = (-0.01, -0.03, 0.24), Rotation = (39.5, 336, 53)
  13. Position = (-0.05, 0.07, 0.14), Rotation = (317, 311, 123.5)
  14. Position = (0.2, 0.07, 0.1), Rotation = (19.5, 321.5, 290.5)
  15. Position = (0.27, 0.07, -0.09), Rotation = (11, 323.5, 308)
  16. Position = (0.18, 0.1, -0.04), Rotation = (0, 335, 0)
  17. Position = (-0.04, 0.17, -0.2), Rotation = (-5.5, 335, 25.5)
  18. Position = (0, 0.1, 0), Rotation = (0, 0, 0)
  19. Position = (-0.17, 0, 0.04), Rotation = (325.5, 25, 21)
  20. Position = (-0.07, 0.04, -0.13), Rotation = (11, 341, 9.5)
  21. Position = (0.09, 0.02, -0.21), Rotation = (337.5, 335, 340)
  22. Position = (0.2, -0.01, -0.2), Rotation = (313.5, 306, 324.5)

Now set the scale for the OreDepost to (5, 5, 5). The stock material looks really boring, so let’s create a new material that looks a bit more like iron ore. Inside the materials folder create a new Material and call it Ore. Set the shader to Specular, the main colour to (R: 35, G: 35, B:35), and the specular colour to (R: 130, G: 160, B: 175). Add this new Material to each of the numbered cubes along with the script Ore.cs. Finally add the script OreDepost.cs to the OreDeposit object. Make sure to set the capacity for the OreDeposit to something greater than 0 (e.g. 1000). Having finished creating our OreDeposit object drag it down into the OreDeposit folder to create an OreDeposit prefab. Make sure that you shift the OreDeposit out of the way before continuing.

Harvester

The next stage in collecting resources is to create a new Unit which can collect resources from the OreDeposit we just created. We will start by creating the object in Unity, beginning with a new empty object called Harvester with it’s position set to (0, 0, 0). To this object add 6 cubes (called ArmC, ArmL, ArmR, Cab, Body, and Tray) and 4 spheres (called WheelFL, WheelFR, WheelRL, WheelRR). Set the properties for each as follows:

  • ArmC: position = (0, 1, 3.2), rotation = (14, 0, 0), scale = (2.2, 0.5, 1)
  • ArmL: position = (-1.125, 1.25, 1.9), rotation = (14, 0, 0), scale = (0.25, 0.25, 3.9)
  • ArmR: position = (1.125, 1.25, 1.9), rotation = (14, 0, 0), scale = (0.25, 0.25, 3.9)
  • Body: position = (0, 1, 0), rotation = (0, 0, 0), scale = (1.5, 1, 3.75)
  • Cab: position = (0, 2, 1.15), rotation = (0, 0, 0), scale = (1, 1, 1)
  • Tray: position = (0, 2.25, -1), rotation = (0, 0, 0), scale = (2.5, 1.5, 3)
  • WheelFL: position = (-0.75, 0.5, 1.85), rotation = (0, 0, 0), scale = (1, 1, 1)
  • WheelFR: position = (0.75, 0.5, 1.85), rotation = (0, 0, 0), scale = (1, 1, 1)
  • WheelRL: position = (-0.75, 0.5, -1.85), rotation = (0, 0, 0), scale = (1, 1, 1)
  • WheelRR: position = (0.75, 0.5, -1.85), rotation = (0, 0, 0), scale = (1, 1, 1)

Set the material for the arms to Flag, the material for the wheels to Ore, and the material for the rest of the objects to Metal.

Now create a new folder inside the Unit folder called Harvester. Create a new C# script inside this folder called Harvester.cs. We will start the code with this framework (note that it inherits from Unit).

using UnityEngine;
using RTS;

public class Harvester : Unit {

	public float capacity;

	private bool harvesting = false, emptying = false;
	private float currentLoad = 0.0f;
	private ResourceType harvestType;

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

	protected override void Start () {
		base.Start();
		harvestType = ResourceType.Unknown;
	}

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

	/* Public Methods */

	public override void SetHoverState(GameObject hoverObject) {
		base.SetHoverState(hoverObject);
		//only handle input if owned by a human player and currently selected
		if(player &amp;&amp; player.human &amp;&amp; currentlySelected) {
			if(hoverObject.name != &quot;Ground&quot;) {
				Resource resource = hoverObject.transform.parent.GetComponent&lt; Resource &gt;();
				if(resource &amp;&amp; !resource.isEmpty()) player.hud.SetCursorState(CursorState.Harvest);
			}
		}
	}

	public override void MouseClick(GameObject hitObject, Vector3 hitPoint, Player controller) {
		base.MouseClick(hitObject, hitPoint, controller);
		//only handle input if owned by a human player
		if(player &amp;&amp; player.human) {
			if(hitObject.name != &quot;Ground&quot;) {
				Resource resource = hitObject.transform.parent.GetComponent&lt; Resource &gt;();
				if(resource &amp;&amp; !resource.isEmpty()) {
					//make sure that we select harvester remains selected
					if(player.SelectedObject) player.SelectedObject.SetSelection(false, playingArea);
					SetSelection(true, playingArea);
					player.SelectedObject = this;
					StartHarvest(resource);
				}
			} else StopHarvest();
		}
	}

	/* Private Methods */

	private void StartHarvest(Resource resource) {

	}

	private void StopHarvest() {

	}
}

Here we are defining the base behaviour for a harvester. If we have our Harvester selected and we hover over a Resource that is not empty then we want to change the cursor. If we then left click with the mouse on a Resource we want to start harvesting. We do need to make sure that the harvester remains selected, since it will be deselected by the default behaviour of a Unit when we click on the resource.

Attach this script to the Harvester object we created and then attach the Harvester to our player (under Units) to make sure that we can control it. Since this is a Unit we need to set the rotate speed and move speed if we want the Harvester to be able to move. We should also set a name for the Harvester (I have just used Harvester), as well as a build image (I have used the one below).

Harvester build image

Harvester

Collect Resources

Now that we have a Harvester we need to define how it collects resources. We will start by adding this code to the StartHarvest() method that we created just before.

private void StartHarvest(Resource resource) {
	resourceDeposit = resource;
	StartMove(resource.transform.position, resource.gameObject);
	//we can only collect one resource at a time, other resources are lost
	if(harvestType == ResourceType.Unknown || harvestType != resource.GetResourceType()) {
		harvestType = resource.GetResourceType();
		currentLoad = 0.0f;
	}
	harvesting = true;
	emptying = false;
}

We want to keep track of the resource deposit, which requires a reference to be added to the top of Harvester.cs.

private Resource resourceDeposit;

We then want the Harvester to move over to the Resource, since we will only collect the resource when we are next to the Resource. We then make sure that the harvest type for the Harvester is the same as that of the Resource. If the harvest type changes we empty all resources we might have had of another type. Finally we set the state of the Harvester to be harvesting rather than emptying.

Before we can carry on with our Harvester behaviour we need to make some changes to Unit.cs. We need to add another version of the StartMove() method

public void StartMove(Vector3 destination, GameObject destinationTarget) {
	StartMove(destination);
	this.destinationTarget = destinationTarget;
}

which then requires that we add a reference to destinationTarget to the top of Unit.cs.

private GameObject destinationTarget;

We also need to add

destinationTarget = null;

to our original version of StartMove() to make sure that we remove any reference to a target object if it is not needed. We now need to adjust the destination position for our Unit if it is moving towards a world object – this makes sure that we do not run into it. We start by adding this line

if(destinationTarget) CalculateTargetDestination();

to the end of TurnToTarget(), where we are changing the Unit state from rotating to moving. And now we also need to define the method being called.

private void CalculateTargetDestination() {
	//calculate number of unit vectors from unit centre to unit edge of bounds
	Vector3 originalExtents = selectionBounds.extents;
	Vector3 normalExtents = originalExtents;
	normalExtents.Normalize();
	float numberOfExtents = originalExtents.x / normalExtents.x;
	int unitShift = Mathf.FloorToInt(numberOfExtents);

	//calculate number of unit vectors from target centre to target edge of bounds
	WorldObject worldObject = destinationTarget.GetComponent&lt; WorldObject &gt;();
	if(worldObject) originalExtents = worldObject.GetSelectionBounds().extents;
	else originalExtents = new Vector3(0.0f, 0.0f, 0.0f);
	normalExtents = originalExtents;
	normalExtents.Normalize();
	numberOfExtents = originalExtents.x / normalExtents.x;
	int targetShift = Mathf.FloorToInt(numberOfExtents);

	//calculate number of unit vectors between unit centre and destination centre with bounds just touching
	int shiftAmount = targetShift + unitShift;

	//calculate direction unit needs to travel to reach destination in straight line and normalize to unit vector
	Vector3 origin = transform.position;
	Vector3 direction = new Vector3(destination.x - origin.x, 0.0f, destination.z - origin.z);
	direction.Normalize();

	//destination = center of destination - number of unit vectors calculated above
	//this should give us a destination where the unit will not quite collide with the target
	//giving the illusion of moving to the edge of the target and then stopping
	for(int i=0; i&lt;shiftAmount; i++) destination -= direction;
 	destination.y = destinationTarget.transform.position.y;
}

The algorithm being used for the calculation is outlined in the comments inside the method. The one thing we need to add is the method GetSelectionBounds() in WorldObject.cs.

public Bounds GetSelectionBounds() {
	return selectionBounds;
}

With these additions in place we need to make some adjustments to Update() inside Harvester.cs.

protected override void Update () {
	base.Update();
 	if(!rotating &amp;&amp; !moving) {
		if(harvesting || emptying) {
			Arms[] arms = GetComponentsInChildren&lt; Arms &gt;();
			foreach(Arms arm in arms) arm.renderer.enabled = true;
			if(harvesting) {
				Collect();
				if(currentLoad &gt;= capacity || resourceDeposit.isEmpty()) {
					//make sure that we have a whole number to avoid bugs
					//caused by floating point numbers
					currentLoad = Mathf.Floor(currentLoad);
					harvesting = false;
					emptying = true;
					foreach(Arms arm in arms) arm.renderer.enabled = false;
					StartMove (resourceStore.transform.position, resourceStore.gameObject);
				}
			} else {
				Deposit();
				if(currentLoad &lt;= 0) {
  					emptying = false;
  					foreach(Arms arm in arms) arm.renderer.enabled = false;
  					if(!resourceDeposit.isEmpty()) {
  						harvesting = true;
  						StartMove (resourceDeposit.transform.position, resourceDeposit.gameObject);
  					}
  				}
  			}
  		}
  	}
 }

We will adjust our Harvester slightly to give some more visual feedback on what it is currently up to. We will reveal the arms when we are collecting or depositing resources and hide them when the Harvester is moving or idle. In order to enable this we need to add a new wrapper script Arms.cs inside our Harvester folder

using UnityEngine;  

public class Arms : MonoBehaviour {
  	//wrapper class for the arms of a harvester
}

and attach it to each of the arm elements in our Harvester object. We then need to turn off the renderer for each of the arm elements by default. We now need to add two new methods to Harvester.cs for collecting and deposting resources. We will leave them both empty for the moment.

private void Collect() {
}

private void Deposit() {
}

The other thing we need to add to our Harvester is a Building that it will deposit the resources it collects in.

  public Building resourceStore;  

For now we will set our WarFactory to be this building, but we will change that by the end of this post. We should also set the capacity of our harvester to be something greater than 0, say 20 for now. If you now run your game, select your Harvester, and click on the ResourceDeposit you should see it turn and move to the edge of the ResourceDeposit and then extend its arms. In order to actually collect resources we need to fill in the Collect() method we created.

private void Collect() {
  	float collect = collectionAmount * Time.deltaTime;
  	//make sure that the harvester cannot collect more than it can carry
  	if(currentLoad + collect &gt; capacity) collect = capacity - currentLoad;
	resourceDeposit.Remove(collect);
	currentLoad += collect;
}

We need to do something very similar for depositing resources.

private void Deposit() {
	currentDeposit += depositAmount * Time.deltaTime;
	int deposit = Mathf.FloorToInt(currentDeposit);
	if(deposit &gt;= 1) {
		if(deposit &gt; currentLoad) deposit = Mathf.FloorToInt(currentLoad);
		currentDeposit -= deposit;
		currentLoad -= deposit;
		ResourceType depositType = harvestType;
		if(harvestType == ResourceType.Ore) depositType = ResourceType.Money;
		player.AddResource(depositType, deposit);
	}
}

We need to add a few variables to the top of Harvester.cs in order for these methods to work.

public float collectionAmount, depositAmount;

private float currentDeposit = 0.0f;

We are using the combination of collectionAmount / depositAmount and deltaTime to give a constant rate for collecting or depositing resources. Make sure that you set collectionAmount and depositAmount from inside Unity – I have used 5 and 10 for these, to give a quicker deposit time. Notice also that we are converting Ore to Money when we go to deposit it.

If you run your game now you should be able to tell your Harvester to start collecting Ore and see the Money that the Player owns go up as the Harvester deposits the resources it has collected.

Health Bar and Resource Bar

This whole process works very nicely, but it is hard for the Player to determine how much the harvester has currently collected. We have all the information that we need, but we need to display some more of this to the Player. We will do this by adding some more details to our SelectionBox being drawn around a selected WorldObject. For all of our WorldObjects we want to display a health bar along the top of the selection box. For our Harvester we want to display the current load along one side. We will start with the health bar by modifying DrawSelectionBox() in WorldObject.cs.

protected virtual void DrawSelectionBox(Rect selectBox) {
	GUI.Box(selectBox, &quot;&quot;);
	CalculateCurrentHealth();
	GUI.Label(new Rect(selectBox.x, selectBox.y - 7, selectBox.width * healthPercentage, 5), &quot;&quot;, healthStyle);
}

All of the calculation of the details for the health bar will be done inside CalculateCurrentHealth(). Once we know these we can draw the health bar along the top of the selection box.

protected virtual void CalculateCurrentHealth() {
	healthPercentage = (float)hitPoints / (float)maxHitPoints;
	if(healthPercentage &gt; 0.65f) healthStyle.normal.background = ResourceManager.HealthyTexture;
	else if(healthPercentage &gt; 0.35f) healthStyle.normal.background = ResourceManager.DamagedTexture;
	else healthStyle.normal.background = ResourceManager.CriticalTexture;
}

We also need to define a couple more variables at the top of WorldObject.cs

protected GUIStyle healthStyle = new GUIStyle();
protected float healthPercentage = 1.0f;

and some extra textures inside ResourceManager.cs

private static Texture2D healthyTexture, damagedTexture, criticalTexture;
public static Texture2D HealthyTexture { get { return healthyTexture; } }
public static Texture2D DamagedTexture { get { return damagedTexture; } }
public static Texture2D CriticalTexture { get { return criticalTexture; } }

We will modify the method in ResourceManager.cs used to store select box items to initialize these textures.

public static void StoreSelectBoxItems(GUISkin skin, Texture2D healthy, Texture2D damaged, Texture2D critical) {
	selectBoxSkin = skin;
	healthyTexture = healthy;
	damagedTexture = damaged;
	criticalTexture = critical;
}

As we change this method we also need to change the call to this method from within Start() in HUD.cs

ResourceManager.StoreSelectBoxItems(selectBoxSkin, healthy, damaged, critical);

and declare these textures at the top of HUD.cs.

public Texture2D healthy, damaged, critical;

I have used these images for those 3 textures

  • Healthy

    Healthy

  • Damaged

    Damaged

  • Critical

    Critical

and stored them in the Images folder located inside the HUD folder.

Set the hit points and max hit points for all the objects currently in our world to 100. Now if you run your game and select a WorldObject you should see a green bar sitting neatly along the top of the selection box. As you drop the value of hit points you should see the colour of the bar change as well as the length of the bar shrink. This is a very simple way of giving a rough indication of the amount of damage that a WorldObject has sustained so far.

For our resource we want to handle the display of the health bar slightly differently. Let’s override the CalculateCurrentHealth() method in Resource.cs, so that any new Resource we might add later on behaves the same way.

protected override void CalculateCurrentHealth () {
	healthPercentage = amountLeft / capacity;
	healthStyle.normal.background = ResourceManager.GetResourceHealthBar(resourceType);
}

We have actually changed the way that health percentage is calculated, along with the image being used. We need to add this method to ResourceManager.cs

public static Texture2D GetResourceHealthBar(ResourceType resourceType) {
	if(resourceHealthBarTextures != null &amp;&amp; resourceHealthBarTextures.ContainsKey(resourceType)) return resourceHealthBarTextures[resourceType];
	return null;
}

along with the method that stores the images in ResourceManager in the first place

public static void SetResourceHealthBarTextures(Dictionary&lt;ResourceType, Texture2D&gt; images) {
	resourceHealthBarTextures = images;
}

We also need to add

using System.Collections.Generic;

to the top of ResourceManager.cs and declare the dictionary we are using to store the textures in.

private static Dictionary&lt;ResourceType, Texture2D&gt; resourceHealthBarTextures;

Then we need to make sure that we call this method as part of the initialization process in HUD. Add this code

Dictionary&lt;ResourceType, Texture2D&gt; resourceHealthBarTextures = new Dictionary&lt;ResourceType, Texture2D&gt;();
for(int i=0; i&lt;resourceHealthBars.Length; i++) {
	switch(resourceHealthBars[i].name) {
	case &quot;ore&quot;:
		resourceHealthBarTextures.Add(ResourceType.Ore, resourceHealthBars[i]);
		break;
	default: break;
	}
}
ResourceManager.SetResourceHealthBarTextures(resourceHealthBarTextures);

to the end of Start() in HUD.cs and declare the array for textures at the top of the file.

public Texture2D[] resourceHealthBars;

Make sure that each image you add to this array through Unity matches a case in the switch statement we just added. If you add more resource types (and want to display a health bar for them) you need to remember to add a texture to the array and a new case in the switch statment.

I have used the image below for our ore deposit, stored in the Images folder inside the HUD folder.

Ore Health Bar

Ore Health Bar

Running your game now should show a different health bar for your ore deposit from the health bars for your units and buildings (assuming you have attached your image to the HUD correctly).

The last adjustment to make here is to the selection box for our Harvester. It would be really nice to have a visual cue as to how full the Harvester is getting. To achieve this we will add another bar along the right-hand side of the selection box. This can be done by overriding DrawSelectionBox() in Harvester.cs.

protected override void DrawSelectionBox (Rect selectBox) {
	base.DrawSelectionBox(selectBox);
	float percentFull = currentLoad / capacity;
	float maxHeight = selectBox.height - 4;
	float height = maxHeight * percentFull;
	float leftPos = selectBox.x + selectBox.width - 7;
	float topPos = selectBox.y + 2 + (maxHeight - height);
	float width = 5;
	Texture2D resourceBar = ResourceManager.GetResourceHealthBar(harvestType);
	if(resourceBar) GUI.DrawTexture(new Rect(leftPos, topPos, width, height), resourceBar);
}

It is amazing to see how much more engaging a simple addition like this makes when playing the game.

Harvester Creation

The final thing to cover this time is the creation of a new Harvester. We will do this from a dedicated refinery building. As part of the creation we will also register the refinery as the resourceStore for the Harvester. Inside the Building folder create a new folder called Refinery and create a new C# script inside there called Refinery.cs.

using UnityEngine;

public class Refinery : Building {

	protected override void Start () {
		base.Start();
		actions = new string[] {&quot;Harvester&quot;};
	}

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

Now create a new empty object called Refinery and set it’s position to (0, 0, 0). Add to this a cube and a capsule, called Floor and Tower respectively. Set the transform settings for each as follows:

  • Floor: position = (0, 1, 0), scale = (3, 2, 8)
  • Tower: position = (0, 3, 2), scale = (3, 2, 3)

Now attach the Refinery.cs script to the Refinery object. Set the object name to Refinery, max build progress to 5, hit points and max hit points to 50, and attach the rally point and sell images. Now add the Refinery object to the Buildings for your Player. In order to be able to build our Harvester from the Refinery we need to make it into a prefab (stored in the Harvester folder) and then attach this to the GameObjectList under Units. Run the game now and you should be able to create a Harvester from the Refinery.

The last thing to do is to assign the Refinery to the Harvester. We will start by adding the Building creator as a parameter to AddUnit() in Player.cs

public void AddUnit(string unitName, Vector3 spawnPoint, Vector3 rallyPoint, Quaternion rotation, Building creator) {
...
}

and adjusting the call to this in ProcessBuildQueue() in Building.cs.

if(player) player.AddUnit(buildQueue.Dequeue(), spawnPoint, rallyPoint, transform.rotation, this);

Now we want to add an initialization method to Unit.cs that will be called after a Unit is created,

public virtual void Init(Building creator) {
	//specific initialization for a unit can be specified here
}

add an override for this method in Harvester.cs,

public override void Init (Building creator) {
	base.Init (creator);
	resourceStore = creator;
}

and then call it from the end of AddUnit() in Player.cs.

public void AddUnit(string unitName, Vector3 spawnPoint, Vector3 rallyPoint, Quaternion rotation, Building creator) {
	...
	if(unitObject) {
		unitObject.Init(creator);
		if(spawnPoint != rallyPoint) unitObject.StartMove(rallyPoint);
	}
}

Running your game now you should be able to create a Harvester from your Refinery, set it collecting resources from the ore deposit, and see it return to the Refinery to deposit the resources it has collected.

Final Fix

There is one situation we need to handle before we close – the case where the OreDeposit is empty. Our Harvester is already stopping collection once the resources are all gone. However, as our project stands at the moment we can still select the OreDeposit even when it is empty. We can fix this by changing MouseClick() in WorldObject.cs

public virtual void MouseClick(GameObject hitObject, Vector3 hitPoint, Player controller) {
	//only handle input if currently selected
	if(currentlySelected &amp;&amp; hitObject &amp;&amp; hitObject.name != &quot;Ground&quot;) {
		WorldObject worldObject = hitObject.transform.parent.GetComponent&lt; WorldObject &gt;();
		//clicked on another selectable object
		if(worldObject) {
			Resource resource = hitObject.transform.parent.GetComponent&lt; Resource &gt;();
			if(resource &amp;&amp; resource.isEmpty()) return;
			ChangeSelection(worldObject, controller);
		}
	}
}

to stop the Player from being able to select the Resource when it is empty.

Change SetHoverState() in Unit.cs

public override void SetHoverState(GameObject hoverObject) {
	base.SetHoverState(hoverObject);
	//only handle input if owned by a human player and currently selected
	if(player &amp;&amp; player.human &amp;&amp; currentlySelected) {
		bool moveHover = false;
		if(hoverObject.name == &quot;Ground&quot;) {
			moveHover = true;
		} else {
			Resource resource = hoverObject.transform.parent.GetComponent&lt; Resource &gt;();
			if(resource &amp;&amp; resource.isEmpty()) moveHover = true;
		}
		if(moveHover) player.hud.SetCursorState(CursorState.Move);
	}
}

to make sure that we show the move cursor over the space where an empty resource sits, since it is empty ground now.

Finally change MouseClick() in Unit.cs

public override void MouseClick(GameObject hitObject, Vector3 hitPoint, Player controller) {
	base.MouseClick(hitObject, hitPoint, controller);
	//only handle input if owned by a human player and currently selected
	if(player &amp;&amp; player.human &amp;&amp; currentlySelected) {
		bool clickedOnEmptyResource = false;
		Resource resource = hitObject.transform.parent.GetComponent&lt; Resource &gt;();
		if(resource &amp;&amp; resource.isEmpty()) clickedOnEmptyResource = true;
		if((hitObject.name == &quot;Ground&quot; || clickedOnEmptyResource) &amp;&amp; hitPoint != ResourceManager.InvalidPosition) {
			float x = hitPoint.x;
			//makes sure that the unit stays on top of the surface it is on
			float y = hitPoint.y + player.SelectedObject.transform.position.y;
			float z = hitPoint.z;
			Vector3 destination = new Vector3(x, y, z);
			StartMove(destination);
		}
	}
}

to make sure that we can tell a Unit to move to the space occupied by an empty resource.

And that wraps it up for this time. We have created a Resource, a Harvester and a Refinery. The Harvester can collect resources from the Resource and return them to the Refinery. While we were at it we added display of health bars to the selection box. A long post, but I hope that it has been worthwhile. The full code for this project can be found at github under the commit for Part 12.

Bug-fix

I just noticed a small bug while playing around – none of our units are able to move to an arbitrary point on the map. The problem lies in MouseClick() in Unit.cs. We added some code to handle clicking on an empty resource to allow the player to still move there. Unfortunately this is resulting in a NullReferenceException. The problem is actually that our Ground object has no parent, and so the call to hitObject.transform.parent.getComponent() fails. To fix this, we need to check whether the parent object exists.

if(hitObject.transform.parent) {
	Resource resource = hitObject.transform.parent.GetComponent&lt; Resource &gt;();
	if(resource &amp;&amp; resource.isEmpty()) clickedOnEmptyResource = true;
}

Add in the if statement above and the problem is fixed.

Creating an RTS in Unity: Part XI

Update: This can now also be found at stormtek.geek.nz/rts_tutorial/part11.php, where the entire tutorial is now being hosted.

The plan this time is to add in a rally point (rather than just having a hard-coded spawn point) and then give the player the ability to change that. While we are at it we will add in the ability to sell a Building.

Rally Point

Having a rally point means we need some way to portray where that is to the user. To do so, let’s create a flag that can easily be repositioned. We will then show / hide that depending on whether we have a building selected or not. Inside the Player folder create a new folder and call it RallyPoint. Inside that folder create a new C# script called RallyPoint. All we want this to be able to do is to show and hide our RallyPoint when required.

public void Enable () {
	Renderer[] renderers = GetComponentsInChildren();
	foreach(Renderer renderer in renderers) renderer.enabled = true;
}

public void Disable () {
	Renderer[] renderers = GetComponentsInChildren();
	foreach(Renderer renderer in renderers) renderer.enabled = false;
}

These methods find all of the child objects which can be rendered and enable or disable them. Now create a new empty object and call it RallyPoint. Once again create some space by shifting the WarFactory belonging to our Player to (-30, 0, 30). (While you are at it make sure that the position for the Buildings and Units wrapper objects to (0, 0, 0) as well) Add two cubes to the RallyPoint object called Flag and Pole. Set their transform properties as follows:

  • Flag: position = (0, 3, 2), scale = (0.1, 2, 4)
  • Pole: position = (0, 1.5, 0), scale = (0.1, 6, 0.1)

To make our RallyPoint more distinguishable we will add some custom materials to each part of it. Inside the Assets folder create a new folder and call it Materials. Inside this folder create two new Materials called Metal and Flag. Now we need to customise our materials by changing some of their properties.

Flag

  • Shader: Specular
  • Main Colour: (125, 170, 220, 255) – for (RGBA)
  • Specular Colour: (200, 245, 255)
  • Shininess: about 25%

Metal

  • Shader: Specular
  • Main Colour: (120, 120, 120, 255)
  • Specular Colour: (150, 140, 140, 255)
  • Shininess: about 50%

Now add the Metal Material to the Mesh Renderer for the Pole object that is part of our new RallyPoint object and the Flag Material to the Mesh Renderer for the Flag object. If you run your game now you should see a flag in the middle of the map. By default we actually don’t want to see this RallyPoint. To do this we can turn off the renderer for both the Flag and the Pole objects (uncheck the check box next to the text MeshRenderer).

We have created the RallyPoint object, now attach the RallyPoint script we created to it so that we can issue commands to the RallyPoint. Then drag the the RallyPoint object down into the RallyPoint folder to create a prefab that we can use at will. The idea is to have one RallyPoint object per player (since they can only have one Building selected at a time), so add an instance of the RallyPoint prefab to our Player.

The control for displaying the RallyPoint object is going to happen inside the SetSelection method for a Building. This means that we are going to need to provide Building an overridden version of this method.

public override void SetSelection(bool selected, Rect playingArea) {
	base.SetSelection(selected, playingArea);
	if(player) {
		RallyPoint flag = player.GetComponentInChildren();
		if(selected) {
			if(flag && player.human && spawnPoint != ResourceManager.InvalidPosition && rallyPoint != ResourceManager.InvalidPosition) {
				flag.transform.localPosition = rallyPoint;
				flag.transform.forward = transform.forward;
				flag.Enable();
			}
		} else {
			if(flag && player.human) flag.Disable();
		}
	}
}

The only special thing to note here is that we are setting the position of the RallyPoint just before we enable it. This makes sure that the RallyPoint is always in the correct position for the selected building. We are also doing so only if the Building is owned by a human Player. This does mean that we need to add a variable to the Building to track the rally point

protected Vector3 rallyPoint;

and then initialize it in Awake() by setting the default rally point to be the spawn point for that building.

rallyPoint = spawnPoint;

To allow this method to be overridden we must also change the method declaration of SetSelection() in WorldObject.

public virtual void SetSelection(bool selected, Rect playingArea) {
...
}

Now when you run your game you should only see the RallyPoint when you have the WarFactory selected, since it is the only Building that our Player owns at the moment.

Shifting the Rally Point

Now that we have a rally point defined, it would be really useful from the player’s perspective to be able to place that wherever they like. Our units will then be created at the spawn point and told to move towards the rally point. This allows the player to create units from a variety of buildings and then have them automatically group together as an army. At the moment we are not handling collisions, so this army will end up literally in the same space, but that is a problem for another day (probably not as part of this tutorial).

But before we can move the rally point we need a way of receiving this input from the user. This will involve creating a new action that the Player can initiate from a Building and changing some state accordingly for when we are dealing with user input (and for drawing the cursor as well). We will initiate this action from our HUD, so we need to draw some more ‘buttons’ in our actions bar. We will handle the drawing of this by adding

DrawStandardBuildingOptions(selectedBuilding);

into DrawOrdersBar immediately after our call to DrawBuildQueue() and then defining this method as follows.

private void DrawStandardBuildingOptions(Building building) {
	GUIStyle buttons = new GUIStyle();
	buttons.hover.background = smallButtonHover;
	buttons.active.background = smallButtonClick;
	GUI.skin.button = buttons;
	int leftPos = BUILD_IMAGE_WIDTH + SCROLL_BAR_WIDTH + BUTTON_SPACING;
	int topPos = buildAreaHeight - BUILD_IMAGE_HEIGHT / 2;
	int width = BUILD_IMAGE_WIDTH / 2;
	int height = BUILD_IMAGE_HEIGHT / 2;
	if(building.hasSpawnPoint()) {
		if(GUI.Button(new Rect(leftPos, topPos, width, height), building.rallyPointImage)) {
			if(activeCursorState != CursorState.RallyPoint) SetCursorState(CursorState.RallyPoint);
			else {
				//dirty hack to ensure toggle between RallyPoint and not works ...
				SetCursorState(CursorState.PanRight);
				SetCursorState(CursorState.Select);
			}
		}
	}
}

This basically just defines a button located at the bottom of our Orders bar (above the selection name) and what to do when that button is clicked. In this case we are saying that if the button is clicked we want to enable / disable the ability to change the rally point for our building. But before we can test this there are some things we need to add … First up, we need some more textures. Add

public Texture2D smallButtonHover, smallButtonClick;

to the top of HUD.cs and

public Texture2D rallyPointImage;

to the top of Building.cs. I have used the textures below in my project.

  • Small button click

    SmallButtonClick

  • Small button hover

    SmallButtonHover

  • Rally point

    RallyPoint

Add smallButtonClick and smallButtonHover into the Images folder located inside your HUD folder. These then need to be added to your HUD (once we have fixed all the errors and Unity will allow you to). Add RallyPoint into your Buildings folder and add it to any Buildings which can have their rally point changed (remember to add these to the prefab, not to instances you have in game).

Now add the method hasSpawnPoint() to Building.cs

public bool hasSpawnPoint() {
	return spawnPoint != ResourceManager.InvalidPosition && rallyPoint != ResourceManager.InvalidPosition;
}

so that we can check whether we need to draw this button or not. Finally, we need to add another value to our CursorState enum to give us these states.

public enum CursorState { Select, Move, Attack, PanLeft, PanRight, PanUp, PanDown, Harvest, RallyPoint }

If you run your code now you should see the newly created button when you select your WarFactory.

In order to display our new cursor state we need to add a new cursor texture to our HUD

public Texture2D rallyPointCursor;

and assign an image to it. I have used the image below, stored in the Cursors folder.

Rally point image

RallyPointImage

We then need to add the following case to the switch statement in SetCursorState() in HUD.cs

case CursorState.RallyPoint:
	activeCursor = rallyPointCursor;
	break;

to make sure that we can draw that image when necessary. Add

else if(activeCursorState == CursorState.RallyPoint) topPos -= activeCursor.height;

to the end of the if…else block in GetCursorDrawPosition() to make sure that the cursor is drawn where we are expecting it to be (in this case we expect the click point for the cursor to be at the base of the flag pole).

There is one more piece of the puzzle needed to allow us to see our cursor. At the moment the default behaviour for hovering over the ground is to display the select cursor. We need to modify this for our building by overriding SetHoverState() in Building.

public override void SetHoverState(GameObject hoverObject) {
	base.SetHoverState(hoverObject);
	//only handle input if owned by a human player and currently selected
	if(player && player.human && currentlySelected) {
		if(hoverObject.name == "Ground") {
			if(player.hud.GetPreviousCursorState() == CursorState.RallyPoint) player.hud.SetCursorState(CursorState.RallyPoint);
		}
	}
}

We check to see if the previous cursor state for the HUD was set to RallyPoint, since the current state would have been set to Select (or something else) by now. If it was we want to change it back to RallyPoint so that our rally point cursor is shown (which will then enable the player to change the rally point for the selected building). We need to implement this method

public CursorState GetPreviousCursorState() {
	return previousCursorState;
}

in HUD.cs, add this variable to the top of the class

private CursorState previousCursorState;

and then make sure that it gets set at the top of SetCursorState().

if(activeCursorState != newState) previousCursorState = activeCursorState;

Now that we are storing the previous cursor state we should also update the check in DrawStandardBuildingOptions() that handles the button click to this

if(activeCursorState != CursorState.RallyPoint && previousCursorState != CursorState.RallyPoint) SetCursorState(CursorState.RallyPoint);

to enable us to cancel the RallyPoint state by clicking the button again. We should really change the state of this button in some way if we are currently allowing the player to change the rally point, but I will leave it up to you to figure that one out for yourself. Run your game now and you should be able to see the cursor is changing state correctly now (when it is inside the playing area of course).

The only thing left to do now is to enable the actual change of the RallyPoint location once the player clicks in a new spot on the ground. To do so we need to override the MouseClick() method inside Building.

public override void MouseClick(GameObject hitObject, Vector3 hitPoint, Player controller) {
	base.MouseClick(hitObject, hitPoint, controller);
	//only handle iput if owned by a human player and currently selected
	if(player && player.human && currentlySelected) {
		if(hitObject.name == "Ground") {
			if((player.hud.GetCursorState() == CursorState.RallyPoint || player.hud.GetPreviousCursorState() == CursorState.RallyPoint) && hitPoint != ResourceManager.InvalidPosition) {
				SetRallyPoint(hitPoint);
			}
		}
	}
}

This brings up two new methods that we need to create. The first is GetCursorState() in HUD.cs

public CursorState GetCursorState() {
	return activeCursorState;
}

and the second is SetRallyPoint() in Building.cs.

public void SetRallyPoint(Vector3 position) {
	rallyPoint = position;
	if(player && player.human && currentlySelected) {
		RallyPoint flag = player.GetComponentInChildren< RallyPoint >();
		if(flag) flag.transform.localPosition = rallyPoint;
	}
}

This adjusts the rally point for the building and then moves the RallyPoint object if the Building is selected.

Now that we have a spawn point and a rally point we need to get newly created units moving from one to the other, otherwise there is no point to all the work we have just done. We will do this by adding the rally point as a parameter to AddUnit() in Player and then telling the Unit that has been added to move towards the rally point, but only if it is not the same as the spawn point. This is the resulting method.

public void AddUnit(string unitName, Vector3 spawnPoint, Vector3 rallyPoint, Quaternion rotation) {
	Units units = GetComponentInChildren< Units >();
	GameObject newUnit = (GameObject)Instantiate(ResourceManager.GetUnit(unitName),spawnPoint, rotation);
	newUnit.transform.parent = units.transform;
	Unit unitObject = newUnit.GetComponent< Unit >();
	if(unitObject && spawnPoint != rallyPoint) unitObject.StartMove(rallyPoint);
}

Of course, now we need to update the call to AddUnit() in ProcessBuildQueue() for Building.

if(player) player.AddUnit(buildQueue.Dequeue(), spawnPoint, rallyPoint, transform.rotation);

Run your game now, select the WarFactory, change the RallyPoint, create a new Tank, and watch as it moves from the spawn point to the new rally point. Now that is what I call progress!

Sell Building

While we are on the topic of specific actions that a building can perform, let’s grant the player the ability to sell each of their buildings. With all the work that we have done already this is actually almost trivial. Add

if(GUI.Button(new Rect(leftPos, topPos, width, height), building.sellImage)) {
	building.Sell();
}

to DrawStandardBuildingOptions() in HUD immediately above the check to draw the button for moving the rally point. Then add the extra texture to the top of Building

public Texture2D sellImage;

along with the method Sell().

public void Sell() {
	if(player) player.AddResource(ResourceType.Money, sellValue);
	if(currentlySelected) SetSelection(false, playingArea);
	Destroy(this.gameObject);
}

Here we grant the player whatever value we have set for the building, we make sure that we deselect it if necessary, and then we destroy it. I have used this image for the sell image for my buildings.

Sell image

SellImage

Since we are now always showing a sell button for our building, we should make sure that we change the left position of the button we created for shifting the rally point. Add this code

leftPos += width + BUTTON_SPACING;

to DrawStandardBuildingOptions() in HUD, as the first thing to do if we detect the building has the option to change the rally point. Add the sell image to the WarFactory, set a sell value, and then you can test selling it. It should disappear when you click the button and the player should receive the amount of money that you specified.

Right, I think that wraps things up for this time. We have added two standard options for buildings – the ability to change the rally point for created units and the ability to sell the building. The full source code for this time can be found on github under the commit for part 11.