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 < 0) amountLeft = 0;
	}

	public bool isEmpty() {
		return amountLeft <= 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< Ore >().Length;
		resourceType = ResourceType.Ore;
	}

	protected override void Update () {
		base.Update();
		float percentLeft = (float)amountLeft / (float)capacity;
		if(percentLeft < 0) percentLeft = 0;
		int numBlocksToShow = (int)(percentLeft * numBlocks);
		Ore[] blocks = GetComponentsInChildren< Ore >();
		if(numBlocksToShow >= 0 && numBlocksToShow < 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<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 && player.human && currentlySelected) {
			if(hoverObject.name != "Ground") {
				Resource resource = hoverObject.transform.parent.GetComponent< Resource >();
				if(resource && !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 && player.human) {
			if(hitObject.name != "Ground") {
				Resource resource = hitObject.transform.parent.GetComponent< Resource >();
				if(resource && !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< WorldObject >();
	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<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 && !moving) {
		if(harvesting || emptying) {
			Arms[] arms = GetComponentsInChildren< Arms >();
			foreach(Arms arm in arms) arm.renderer.enabled = true;
			if(harvesting) {
				Collect();
				if(currentLoad >= 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 <= 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 > 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 >= 1) {
		if(deposit > 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, "");
	CalculateCurrentHealth();
	GUI.Label(new Rect(selectBox.x, selectBox.y - 7, selectBox.width * healthPercentage, 5), "", 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 > 0.65f) healthStyle.normal.background = ResourceManager.HealthyTexture;
	else if(healthPercentage > 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 && 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<ResourceType, Texture2D> 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<ResourceType, Texture2D> resourceHealthBarTextures;

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

Dictionary<ResourceType, Texture2D> resourceHealthBarTextures = new Dictionary<ResourceType, Texture2D>();
for(int i=0; i<resourceHealthBars.Length; i++) {
	switch(resourceHealthBars[i].name) {
	case "ore":
		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[] {"Harvester"};
	}

	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 && hitObject && hitObject.name != "Ground") {
		WorldObject worldObject = hitObject.transform.parent.GetComponent< WorldObject >();
		//clicked on another selectable object
		if(worldObject) {
			Resource resource = hitObject.transform.parent.GetComponent< Resource >();
			if(resource && 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 && player.human && currentlySelected) {
		bool moveHover = false;
		if(hoverObject.name == "Ground") {
			moveHover = true;
		} else {
			Resource resource = hoverObject.transform.parent.GetComponent< Resource >();
			if(resource && 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 && player.human && currentlySelected) {
		bool clickedOnEmptyResource = false;
		Resource resource = hitObject.transform.parent.GetComponent< Resource >();
		if(resource && resource.isEmpty()) clickedOnEmptyResource = true;
		if((hitObject.name == "Ground" || clickedOnEmptyResource) && 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< Resource >();
	if(resource && resource.isEmpty()) clickedOnEmptyResource = true;
}

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

Advertisements

13 thoughts on “Creating an RTS in Unity: Part XII

  1. Viktor says:

    Hey! Awesome tutorial. I have a problem. The Units i make in the buildings doesnt go as a child in Player so i can’t controll them and they also dont go to the rallypoint. I get this null error
    NullReferenceException: Object reference not set to an instance of an object
    Player.AddUnit (System.String unitName, Vector3 spawnPoint, Vector3 rallyPoint, Quaternion rotation, .Building creator) (at Assets/Player/Player.cs:57)
    Building.ProcessBuildQueue () (at Assets/WorldObject/Building/Building.cs:41)
    Building.Update () (at Assets/WorldObject/Building/Building.cs:28)

    And also the Harvester doesnt go to the ores instead he takes his own routes and mine where their aint no ores.

  2. First make sure that the building is attached to the player. I believe the null error is caused by the fact that the player object for the building is null – which happens when the building does not belong to a player. Fix that and then try again.

  3. Emil Rainero says:

    Thank you so much for the excellent series!!

    The units turn rate is not controlled properly. The rotation speed should be multiplied by TIme.deltaTime to guarantee the rotation is in degrees/second.

    I suggest the following change to Unit.cs

    private void TurnToTarget() {
    transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime);

    There are probably other places such as movement speed where the same issue applies.

    The reason why the rotate seems to work is that your project quality setting “VSync Count” is set to “Every VBlank”.

    By correcting for the Update time interval with Time.DeltaTime the rotate speed is guaranteed to be correct.

    • Emil Rainero says:

      Update: The camera movements correctly take into account Time.deltaTime in the Update().
      The Unit movement correctly takes into account Time.deltaTime

  4. RSS-Dev says:

    Found an interesting issue: When you empty a mineral patch, if you path any unit onto that now empty ground, and continue to attempt to path over it, your unit will rotate toward the y axis and move upward.

    • RSS-Dev says:

      Just a note for others: The easiest way around this issue is to destroy the ore deposit in OreDeposit.cs when the percentLeft == 0. It means you cant click the object anymore (but why would you want to click an invisible object?)

      if(percentLeft == 0) {
      Destroy (this.gameObject);
      }

  5. Great tutorial ! However sometimes you seem to “imply” too much of the inside-Unity stuff, which makes me always wonder if my errors come from code or simply mistakes in my prefabs or whatever. (I’m *completely* novice at Unity, I don’t really have the attention span for all the video tutorials so this one has been an excellent finding) Or what kind of objecs should be owned by the player and what kind should not (the ore deposit for instance). Perhaps a few screenshots would help especially for those long posts.

    Case in point : why does my Harvester run *away* from the deposit ? Both the one that’s already present in the world and the ones created at runtime do this. Before, I could at least get him to harvest once and then he would start to run away; now he doesn’t harvest at all. By “before” I mean that I destroyed the OreDeposit element and then used the prefab to create another one… and the reason why I did this to begin with is that I had a very spammy “incorrect input” error for the Int.Parse call. (code was triple-checked so both problems definitely come from the inside-Unity stuff)

    Thanks in advance for your help

    • Okay too bad I can’t edit on this website. Since I’ve run into this issue several more times while adding my own code/items, I now understand better what this was. This happens when the prefab is not created at (0,0,0) and thus the position and localPosition variables become different. Since this project only uses position (so far, at least) it causes the unit to go to weird destinations and THEN act as if it was near the item (say, the OreDeposit)
      Should probably add a warning about this somewhere or just insist a bit more on how important it is to create the prefab at a 0,0,0 position. Well it seems a bit obvious for me now but I’m a complete Unity newb so…

      • I do try to mention it, because you are right, Unity does some weird things with position while you are creating objects. I don’t know why it can’t just define that a prefab always has position at (0,0,0). I also wasn’t aware that there was such a difference between position and localPosition.

    • I’m sorry if it feels like I gloss over some stuff at times. I did state at the beginning of the tutorial that you should go and do the getting to know Unity tutorials. Sometimes I feel that I am writing so much that it is easy to forget about little things that Unity does. I try to do my best, but unfortunately that is not always perfect.

      At some point (once I have finished the core elements I want to put in the tutorial) I plan to create a page for each part in the tutorial, with much better navigation etc. While I do that I will also go through the whole lot (maybe even doing the tutorial myself), to iron out some of those parts that feel a little rushed. I know this does not help you for now, but long-term it should help more people.

      A player should only own objects that they need to control. An ore deposit, for instance, is a resource that any player on the map can collect from so it should not belong to a player. A power plant, on the other hand, provides power directly to the player, so it should belong to a player. I hope this clarification helps.

  6. anon says:

    I don’t think the calculatetargetdestination algorithm works perfectly. I have made a system where the harvester enters a mine and exits then goes back to the factory. When I just use the direct vectors it works fine, but with the algorithm it spazzes out completely. If you’re interested I could FRAPS a video of it.

    • anon says:

      Nevermind, here’s the fix: Move the call to CalculateTargetDestination to the beginning of Move() (the whole if-statement), and set destinationTarget=null after you call it. Fixes everything.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s