Part 22: World Object AI
Figuring out when a game is over, which we did last time, is an important step in making our game more meaningful. A win condition is, after all, necessary for determining progress. One of the final things we still need to do is to make our game interactive. It is all very well to be able to build more stuff, but if there is no opponent seeking to stop us then it becomes a rather trivial exercise. The goal for this part is to begin to introduce some interactivity by allowing our World Objects to make some decisions on what to do next. This will include attacking nearby targets, Harvesters seeking out nearby Resources, and Workers finding other Buildings to help construct. Obviously there are many more things we could do, but we want to keep it (relatively) simple for now. Feel free to build on this platform though.
Foundations
Before we do anything specific we need to make sure that our World Objects have a good framework in place for making decisions. Since we want this to apply to all of our World Objects, this needs to happen inside WorldObject.cs. Start by adding the following line to the top of Update()
if(ShouldMakeDecision()) DecideWhatToDo();
then add a basic definition for each of these methods using the following code.
/** * A child class should only determine other conditions under which a decision should * not be made. This could be 'harvesting' for a harvester, for example. Alternatively, * an object that never has to make decisions could just return false. */ protected virtual bool ShouldMakeDecision() { if(!attacking && !movingIntoPosition && !aiming) { //we are not doing anything at the moment if(timeSinceLastDecision > timeBetweenDecisions) { timeSinceLastDecision = 0.0f; return true; } timeSinceLastDecision += Time.deltaTime; } return false; } protected virtual void DecideWhatToDo() { //determine what should be done by the world object at the current point in time Vector3 currentPosition = transform.position; nearbyObjects = WorkManager.FindNearbyObjects(currentPosition, detectionRange); }
These are default definitions, so we make sure that the methods can be both accessed by and overridden by child classes if need be. At the moment the only time when we want to allow a World Object to make a decision is when it is not doing anything else. An important thing to note is that we do not wish our game to slow down because all of our inactive objects are always checking to see what they should do next. To help with this, we want to limit how often a World Object should make a decision. To make sure that we can do so we need to add these variables
//we want to restrict how many decisions are made to help with game performance //the default time at the moment is a tenth of a second private float timeSinceLastDecision = 0.0f, timeBetweenDecisions = 0.1f;
to the top of WorldObject.cs. These should only be used in this base implementation of ShouldMakeDecision() so we can make them private variables. For now we will go with a decision every 10th of a second. You are more than welcome to play around with this value, but it seems like a reasonable starting place - not too fast, but also not too slow.
The other important information that we want available when helping a World Object decide what to do next is a list of all of the nearby World Objects. The definition of what 'nearby' means is going to vary from object to object, which is why we are using the variable detectionRange. Add that to the top of WorldObject.cs now.
public float detectionRange = 20.0f
Notice that we have made this a public float. This is so that we can easily modify the detection range for particular World Objects from within Unity. Feel free to modify this value until it feels right for how close objects should be before they interact with each other (this is a fairly arbitrary value that I have selected). Since we are wanting to keep track of the nearby objects we also need to add
protected List< WorldObject > nearbyObjects;
to the top of WorldObject.cs. We want our Work Manager to handle populating this list for us, so we need to add the following method to WorkManager.cs.
public static List< WorldObject > FindNearbyObjects(Vector3 position, float range) { Collider[] hitColliders = Physics.OverlapSphere(position, range); HashSet< int > nearbyObjectIds = new HashSet< int >(); List< WorldObject > nearbyObjects = new List< WorldObject >(); for(int i = 0; i < hitColliders.Length; i++) { Transform parent = hitColliders[i].transform.parent; if(parent) { WorldObject parentObject = parent.GetComponent< WorldObject >(); if(parentObject && !nearbyObjectIds.Contains(parentObject.ObjectId)) { nearbyObjectIds.Add(parentObject.ObjectId); nearbyObjects.Add(parentObject); } } } return nearbyObjects; }
We are wanting to find all unique World Objects that are within the specified range of a given point in space. Thankfully Unity is able to step in and do the hardest part of this for us - finding nearby things. Physics.OverlapSphere() finds all of the colliders that are contained on or inside a sphere with a specified radius. This is a good starting point, but we do not actually care about colliders; we care about World Objects. The good things is that we know what the current layout of a World Object is - an Empty Object with a collection of child Objects that have colliders. Therefore, if the parent object of a collider has WorldObject.cs attached to it then it is a World Object. That is what the first part of our for loop is detecting. Once we have found a World Object we want to make sure that we have not already detected it. This will happen, because most of our World Objects have multiple children with colliders. That is where the HashSet comes into play. A Set is a collection of unique objects. In this case we are using integers, so the set will never contain two integers the same. Back when we were handling saving / loading we made sure that each World Object in a scene has a unique ObjectId. By putting the ObjectIds of the World Objects that we found into the set we can guarantee that we will find the collection of unique World Objects located around a point.
It actually turns out, however, that we are not always assigning unique ids to the World Objects in a scene. In particular, this is not being done when we launch our game from directly within a Map scene (which I know I do while testing, since it is so much quicker). You can easily test this by printing out the list of nearby objects (once you have found them) and seeing that it does not have many entries. This can be fixed easily enough by modifying LevelLoader.cs slightly. We want to take the code that assigns ObjectIds to World Objects and place it in a separate method, like so.
private void SetObjectIds() { WorldObject[] worldObjects = GameObject.FindObjectsOfType(typeof(WorldObject)) as WorldObject[]; foreach(WorldObject worldObject in worldObjects) { worldObject.ObjectId = nextObjectId++; if(nextObjectId >= int.MaxValue) nextObjectId = 0; } }
Then we want to replace this existing code with a call to the new method, giving us the following updated version of OnLevelWasLoaded().
void OnLevelWasLoaded() { if(initialised) { if(ResourceManager.LevelName != null && ResourceManager.LevelName != "") { LoadManager.LoadGame(ResourceManager.LevelName); } else { SetObjectIds(); } Time.timeScale = 1.0f; ResourceManager.MenuOpen = false; } }
Finally, we want to add a call to SetObjectIds() into Awake() - as the last code that is run inside the check if(!menu). With that done, all of the World Objects should now be getting an ObjectId set correctly.
Before we move on to actually making decisions, we should make sure that all of our child objects are correctly determining whether they should be making decisions or not. None of our Resources should ever be making a decision, so add
protected override bool ShouldMakeDecision () { return false; }
to the end of Resource.cs. Units should only be making a decision if they are not moving, so add
protected override bool ShouldMakeDecision () { if(moving || rotating) return false; return base.ShouldMakeDecision(); }
to Unit.cs. On top of this, a couple of the Units that we have defined so far have further limitations. Add
protected override bool ShouldMakeDecision () { if(building) return false; return base.ShouldMakeDecision(); }
to Worker.cs to make sure that our Worker does not make decisions while it is in the middle of construction and add
protected override bool ShouldMakeDecision () { if(harvesting || emptying) return false; return base.ShouldMakeDecision(); }
to Harvester.cs to make sure that our Harvester makes no decisions while either collecting Resources or depositing them. None of the Buildings in our project so far should be making any decisions either, although we will be adding in one shortly that will, so add
protected override bool ShouldMakeDecision () { return false; }
to Refinery.cs, WarFactory.cs, and Wonder.cs to make sure that only these Buildings make no decisions.
Making Decisions
Now that we have a good foundation in place it is time to turn our attention to actually making some decisions. We will start by allowing World Objects to decide on nearby targets to attack. Since we want to enable this for all of our World Objects, this should go into WorldObject.cs. Add the following code to the end of DecideWhatToDo().
if(CanAttack()) { List< WorldObject > enemyObjects = new List< WorldObject >(); foreach(WorldObject nearbyObject in nearbyObjects) { Resource resource = nearbyObject.GetComponent< Resource >(); if(resource) continue; if(nearbyObject.GetPlayer() != player) enemyObjects.Add(nearbyObject); } WorldObject closestObject = WorkManager.FindNearestWorldObjectInListToPosition(enemyObjects, currentPosition); if(closestObject) BeginAttack(closestObject); }
Once we have determined whether the World Object is currently capable of attacking we want to filter the list of nearby objects to only include valid targets. This means removing all Resources (since attacking those would be silly) and all objects that belong to the Player who owns the World Object making the decision. Once we have a filtered list we hand that over to our Work Manager and ask it to find the nearest object in that list. Once we know what this object is the World Object can begin attacking it. Of course, we need to add the following method to WorkManager.cs before we can compile and run our game.
public static WorldObject FindNearestWorldObjectInListToPosition(List< WorldObject > objects, Vector3 position) { if(objects == null || objects.Count == 0) return null; WorldObject nearestObject = objects[0]; float distanceToNearestObject = Vector3.Distance(position, nearestObject.transform.position); for(int i = 1; i < objects.Count; i++) { float distanceToObject = Vector3.Distance(position, objects[i].transform.position); if(distanceToObject < distanceToNearestObject) { distanceToNearestObject = distanceToObject; nearestObject = objects[i]; } } return nearestObject; }
This algorithm uses a simple distance check to find which of the objects in the list it was passed is closest to the specified position. The other thing that we need to add to WorldObject.cs before we can continue is
public Player GetPlayer() { return player; }
so that a World Object can determine whether the Player that owns it matches the Player that owns a nearby object. With these changes in place you should be able to launch your game and see your Tanks starting to find and attack nearby targets.
Next, let us enable our Workers to search for nearby Buildings which they can help to construct. This will follow a similar layout to what we just did for attacking things. Add
protected override void DecideWhatToDo () { base.DecideWhatToDo (); List< WorldObject > buildings = new List< WorldObject >(); foreach(WorldObject nearbyObject in nearbyObjects) { if(nearbyObject.GetPlayer() != player) continue; Building nearbyBuilding = nearbyObject.GetComponent< Building> (); if(nearbyBuilding && nearbyBuilding.UnderConstruction()) buildings.Add(nearbyObject); } WorldObject nearestObject = WorkManager.FindNearestWorldObjectInListToPosition(buildings, transform.position); if(nearestObject) { Building closestBuilding = nearestObject.GetComponent< Building >(); if(closestBuilding) SetBuilding(closestBuilding); } }
to Worker.cs to handle this process. We need to make sure that we ignore Buildings that belong to another Player, since otherwise the Worker will construct those as well, which is not what we want. This can easily be tested by getting your Worker to start construction of two Buildings close together. You might need to rearrange where your Units are on the Map to make sure that an enemy Tank does not kill your Worker before it can complete, though.
Finally, let's make sure that our Harvester will search for nearby Resources once it finishes the one that it is on. Add the following code to Harvester.cs.
protected override void DecideWhatToDo () { base.DecideWhatToDo (); List< WorldObject > resources = new List< WorldObject >(); foreach(WorldObject nearbyObject in nearbyObjects) { Resource resource = nearbyObject.GetComponent< Resource >(); if(resource && !resource.isEmpty()) resources.Add(nearbyObject); } WorldObject nearestObject = WorkManager.FindNearestWorldObjectInListToPosition(resources, transform.position); if(nearestObject) { Resource closestResource = nearestObject.GetComponent< Resource >(); if(closestResource) StartHarvest(closestResource); } else if(harvesting) { harvesting = false; if(currentLoad > 0.0f) { //make sure that we have a whole number to avoid bugs //caused by floating point numbers currentLoad = Mathf.Floor(currentLoad); emptying = true; Arms[] arms = GetComponentsInChildren< Arms >(); foreach(Arms arm in arms) arm.renderer.enabled = false; StartMove (resourceStore.transform.position, resourceStore.gameObject); } } }
Most of this is very similar to what we have already seen before with attacking and with our Worker. They key difference lies in what to do if there is no nearby Resource found. In this scenario we need to make sure that our Harvester takes the Resources that it has collected so far and empties those. Another thing we need to modify is the Update() method, since at the moment the Harvester is set to empty it's load once a Resource deposit is empty. We do not want this to happen any longer, so the check
if(currentLoad >= capacity || resourceDeposit.isEmpty())
should be replaced with
if(currentLoad >= capacity)
to make sure that this no longer happens. Finally, some other odd behaviour that I noticed is that it is possible sometimes for a Resource deposit to be left with a very small amount (e.g. 0.165f). If this happens to be in the last Resource deposit that the Harvester can see then it collects this and attempts to deposit it, which causes it to get stuck in a loop. To fix this, I added
currentLoad = Mathf.Floor(currentLoad);
to the top of Deposit() in Harvester.cs. It feels like a bit of a hack, but it seems to fix the problem since the Harvester is now making sure that it always has a whole number value as it's current load when it goes to deposit Resources. The final thing to adjust is in Collect(). We need to make sure that if we empty the Resource deposit that we decide what to do next, rather than continuing to collect from the empty Resource deposit. This can be achieved by exchanging the line
resourceDeposit.Remove(collect);
for the following block of code.
if(resourceDeposit.isEmpty()) { Arms[] arms = GetComponentsInChildren< Arms >(); foreach(Arms arm in arms) arm.renderer.enabled = false; DecideWhatToDo(); } else { resourceDeposit.Remove(collect); }
To test that this is all working as expected add a couple of copies of the OreDepost to your map, preferably close together near your Harvester. Make sure that the amount in each is quite low, so that you do not have to wait for ages before you see a result. I went with a mere 50 in each deposit. (Make sure that you set the y-position of these to 0, otherwise your Harvester will exhibit some weird behaviour) You may also need to adjust some of your Victory Conditions to make sure that they do not trigger in the middle of testing.
Turrets
The final thing I want to add is a Turret - a Building that shoots at anything that comes too close. The main reason for this is to show that a Building a) can attack things, and b) can react to things around it. Start by creating a new folder inside the Building folder called Turret. Inside this folder create two new C# scripts: Turret.cs and TurretProjectile.cs. The code for TurretProjectile.cs is really simple, and basically the same as that for TankProjectile.cs.
using UnityEngine; using System.Collections; public class TurretProjectile : Projectile {}
The code for Turret.cs is a bit more complicated, but not by a whole lot.
using UnityEngine; using System.Collections; using RTS; public class Turret : Building { private Quaternion aimRotation; protected override void Start () { base.Start (); detectionRange = weaponRange; } protected override void Update () { base.Update(); if(aiming) { transform.rotation = Quaternion.RotateTowards(transform.rotation, aimRotation, weaponAimSpeed); CalculateBounds(); //sometimes it gets stuck exactly 180 degrees out in the calculation and does nothing, this check fixes that Quaternion inverseAimRotation = new Quaternion(-aimRotation.x, -aimRotation.y, -aimRotation.z, -aimRotation.w); if(transform.rotation == aimRotation || transform.rotation == inverseAimRotation) { aiming = false; } } } public override bool CanAttack() { if(UnderConstruction() || hitPoints == 0) return false; return true; } protected override void UseWeapon () { base.UseWeapon(); Vector3 spawnPoint = transform.position; spawnPoint.x += (2.6f * transform.forward.x); spawnPoint.y += 1.0f; spawnPoint.z += (2.6f * transform.forward.z); GameObject gameObject = (GameObject)Instantiate(ResourceManager.GetWorldObject("TurretProjectile"), spawnPoint, transform.rotation); Projectile projectile = gameObject.GetComponentInChildren< Projectile >(); projectile.SetRange(0.9f * weaponRange); projectile.SetTarget(target); } protected override void AimAtTarget () { base.AimAtTarget(); aimRotation = Quaternion.LookRotation (target.transform.position - transform.position); } }
We are using the same method for aiming at a target as our Tank currently uses - that is, rotate the entire object to face that way. Firing the weapon is done in almost the same way too, with the only real differences being that we are using TurretProjectile rather than TankProjectile and that the positioning is different because of a different structure (which we will see very shortly). We are also defining the detectionRange to be the same as the weaponRange, since it does not really make any sense to detect targets that are too far away to react to. Finally, we want to make sure that the Turret does not begin to engage nearby targets while it is still being built. To help make sure that this will happen as expected we need to add
tempBuilding.hitPoints = 0
into Player.cs in the middle of CreateBuilding(), along with the other values that are being set on our temporary Building.
The only thing we need to do now to get things working is to create the objects, attach scripts, and then create Prefabs. For the Projectile we are going to continue cheating. Add an instance of the Tank Projectile to the Map, rename it TurretProjectile, swap the TankProjectile.cs script which is attached for the newly created TurretProjectile.cs script, and then drag it into the Turret folder to create a Prefab. Finally, add this Prefab to the World Objects part of our GameObjectList (remembering to do this for all scenes in which you have added a GameObjectList). Creating a Turret is almost as simple. Create a new EmptyObject called Turret, making sure that it is located at (0, 0, 0). Add to this a Cube called Body with position of (0, 1, 0) and scale of (2, 2, 2). Finally attach a cylinder called Muzzle with position of (0, 1, 1.5), rotation of (0, 90, 90), and a scale of (0.75, 0.75, 0.75). Attach TeamColor.cs to the Body and Turret.cs to the Turret object. Once the scripts are attached add the relevant sounds to the appropriate fields of the Turret, along with a build image (I have used the image below) and the Rally Point and Sell images. Once this is done, drag the Turret object into the Turret folder to create a Prefab and add the Prefab to the Buildings field of the GameObjectLists. If you wish your workers to be able to create new Turrets all you need to do is to add "Turret" to the list of actions that the Worker has.
To test that the Turret is behaving as expected all you should now need to do is to add a Turret to the scene (making sure that it belongs to a Player by adding it to the Buildings object for that Player). When a Unit belonging to the other Player moves close to the Turret you should see it rotate and begin attacking that Unit.
And that brings us neatly to the end of this part. We now have interactive World Objects that will do things without us having to explicitly tell them to do so. All of the code can be found on github under the commit for Part 22. Have fun watching things happen by themselves.