Status Update for Tutorial

Just a quick update on where I am at. Unfortunately I am in a slightly busy patch with life, and so I have been unable to work on another post just yet. Shifting house this week is not helping with that either. Never fear though, I am committed to finishing this tutorial. I have already sketched out where I want to take things. The only problem is finding time to write things up. Hopefully I will be able to complete the next part some time in the next couple of week.s

I also want to give a thank you to everyone who has posted positive feedback. It is incredible to see the number of people who are interested in what I have been writing. I started the tutorial because I thought it was something I would be interested in completing as a user. I figured that, because it was on the Internet, some people would be able to find it at some point. I certainly never thought to see so many people checking it out as quickly as you all have. While I did not start out doing this for anyone in particular, interest that has been shown so far has certainly encouraged me to keep writing. I am also surprising myself with how much I actually do know about things. We live and we learn, and my hope is that what I have written will help others to learn also.

Advertisements

Creating an RTS in Unity: Part XIII

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

There is one very important aspect of expansion that we still do not have – 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 user to choose where to place the new structure, making sure that it does not collide with any of the existing objects in our world, and then present some simple graphical cues to indicate the progress of the building.

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) {

	}
}

We will use this as a 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 this is used.

  • In AddUnit() located in Player.cs
  • The override in Harvester.cs

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 it is currently in ‘build’ mode, and how much it has 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

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 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 that 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) {
...
}

Now that we have 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:

  • Body: Position = (0, 0.8, 0), Rotation = (0, 0, 0), Scale = (1.5, 0.2, 2)
  • Cab: Position = (0, 1.5, 0), Rotation = (0, 0, 0), Scale = (1.2, 1, 1.2)
  • ArmL: Position = (-0.7, 0.8, 1), Rotation = (0, 0, 0), Scale = (0.2, 0.2, 0.8)
  • ArmR: Position = (0.7, 0.8, 1), Rotation = (0, 0, 0), Scale = (0.2, 0.2, 0.8)
  • Scoop: Position = (0, 0.6, 1.5), Rotation¬† = (340, 0, 0), Scale = (1.7, 1, 0.1)
  • SmokestackL: Position = (-0.4, 2.3, -0.4), Rotation = (0, 0, 0), Scale = (0.2, 0.4, 0.2)
  • SmokestackR: Positiion = (0.4, 2.3, -0.4), Rotation = (0, 0, 0), Scale = (0.2, 0.4, 0.2)
  • TreadL: Position = (-0.7, 0.25, 0), Rotation = (0, 90, 90), Scale = (0.5, 0.9, 0.5)
  • TreadR: Position = (0.7, 0.25, 0), Rotation = (0, 90, 90), Scale = (0.5, 0.9, 0.5)

Now attach Worker.cs to the Worker object and add the Worker object to the Units object that your Player object has. 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 Creation

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 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 us. It is now 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 then to use that to find the actual position in the world where the Player wishes to construct that 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 on 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 shortly. 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;

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 add these materials to the appropriate variables in the Player object.

Play the game now and you should see a temporary 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 put 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 here ...
		}
	}
}

The idea here is that if the Player is currently trying to place a Building then do that, otherwise we want to do perform the existing hover behaviour. This requires 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;
}

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 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 ...
	}
}

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 location that suits them.

Legal Build Position

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. We then fire a ray into the world 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 now defined we need to allow the Player to actually start construction. We will start 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 ...
		}
	}
}

Now we can 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 assign the new Building to the Player (since they are actually building it now), reinitialize any colliders the Building has, tell the worker that initiated the construction that it is assigned to the new Building, and tell the Building that it is now under construction. 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. 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 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 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 DrawSelectionBox() and CalculateCurrentHealth() in WorldObject.cs, as well as adding in DrawHealthBar(). Replace the existing code in WorldObject.cs 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 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 we need it to add the amount of work done since last update to the Building. This requires that we add two new methods to Building.

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 your 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.

  • We want to stop construction if the Worker is told to move away from the Building they are working on
  • We want to start construction on a specified Building if we click on it while a Worker is selected (reallocate which Building it is working on)
  • We want to allow the Player to cancel the process of finding a location for a new Building by right-clicking the mouse

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 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.