Stupid swarm agents in Unity3d for Global Game Jam 2017
Sebastian Pohl - 11. Februar 2017Recently i participated in the Global Game Jam 2017 at our University in Koblenz. With a team of five on location and another helpful friend that donated the awesome music we made a small strategy game within this years theme „waves“ in the unity3d engine. The name is „Tower Offense – Final Wave“ and you can find the executable and the full unity3d project here.
The basic idea is that each player has a base that constantly produces small autonomous robots. These robots are under your control as long as the radio waves of your base tower or the radio towers you can build reach it. As soon as it leaves the influence radius of your towers it losses signal strength and eventually you loose control. You have the option to change from building attack robots to build harvesters which bring in ressources you need to build more towers. The aggressive robots try to find the nearest enemy robot or building and engage it. The enemy can, like the player, build more towers and when a hostile robot stays in the range of an opposing tower for too long, the owner of that tower gains control of the robot. The winning condition is easy: Destroy the enemy base!
My part this time was the programming of the „artificial intelligence“. But because i like to work quick and see results fast my solution might (definitely) not be the most elegant or efficient solution.
Before i start, this is the rest of the team:
Enie Weis – eniestudio.de
Sebastian Koslik – Facebook
Dominik Ospelt
Steffen Plohmann – Twitter
Florian Schwehn – Steam Void Fraction (In-Game Music)
I started with all of the knowledge i have about simple swarm behaviour and came up with something like this for a way to describe what each robot-agent will do:
- Try to stay close to robots from the same player and type. Attack robots will group with attack robots. Harvesters will group with harvesters.
- Avoid collisions with other robots.
- Avoid collisions with buildings or the environment.
- If you are agressive: Find the nearest tower or robot that belongs to an opposing player and find a way to it.
If you are a harvester: Find the nearest ressource location. If you already carry ressources, bring them to the base. - Try to stay in range of the radio towers of the player owning the robot.
I then started from the top trying to implement each feature on its own and then combine it. I iteratet a lot over time how the things work and will try to go over the whole agent class i came up with now:
using System.Collections; using System.Linq; using System.Collections.Generic; using UnityEngine; public enum AgentType { aggressive,harvester }; public class RobotAgent : MonoBehaviour { public float maxTurnRate = 5.0f; public int flockSize = 5; public float health = 4.0f; public GameObject turret; public GameObject laser; public GameObject explosion; GameObject mainTarget; float updateInterval = 0.1f; float lastUpdate = 0.1f; public float signalStrength = 10.0f; public AgentType agentType; float minimumFlockDistance = 4.0f; float maximumFlockDistance = 8.0f; float minimumObjectDistance = 3.0f; float maximumTargetDistance = 3.0f; float weaponCooldown = 1.0f; float weaponState = 0.0f; float harvested; float agentSpeedMultiplier = 5.0f; float jitterAmount = 0.1f; GameObject[] friendlyRobots, targets; List<GameObject> closestObjects; Vector3 agentSpeed; Vector3 directionOfInterest;
This is the definition of my RobotAgent class with all the variables i am using. I will go into the details of what the variables are used for when they come into play in the next code snippets!
void Start () { agentSpeed = Vector3.forward; directionOfInterest = Vector3.forward; harvested = 0.0f; lastUpdate = Random.value * updateInterval; }
The initialisation of the class is pretty straight forward. agentSpeed and directionOfInterested are set to some arbitrary vectors just to have something that is not zero. harvested is the amount of ressources a robot is carrying and is initialized as zero. lastUpdate is a pretty important variable as it distributes the updates a robot will get over time. So not every robot is update each and every frame. This helps with distributing the computational load over time and makes it easier to have lots and lots of robots. It is initialized with a random value to spread out the initial load.
void Update () { lastUpdate += Time.deltaTime; Vector3 lastDOI = directionOfInterest; signalStrength = Mathf.Max (0.0f, signalStrength - (Time.deltaTime * 0.5f));
The Update function does most of the heavy lifting and we will go trough it in multiple steps.
lastUpdate is the time that passed since the last update to the behaviour and we just add the time passed since the last frame to it.
The vector lastDOI is a temporary copy of the directionOfInterest that points to the current target the robot is following.
signalStrength is also updated with time – as soon as this reaches zero, the current owner will loose control. The Mathf.Max keeps it from falling below 0.0f.
if (this.transform.parent == null) { health -= 0.25f * Time.deltaTime; } else if (signalStrength <= 0.1f && this.transform.parent != null) { this.transform.parent.GetComponent<RobotSpawner> ().detachRobot (this.gameObject); this.transform.parent = null; } if (health <= 0.0f) { Instantiate (explosion, this.transform.position, Quaternion.identity); if (this.transform.parent != null && this.transform.parent.GetComponent<RobotSpawner> ()) { this.transform.parent.GetComponent<RobotSpawner> ().killRobot (this.gameObject); } else { Destroy (this.gameObject); } } if (weaponState > 0.0f) weaponState -= Time.deltaTime;
This is part first checks if the agent is a child object to another gameobject by testing if the parent is null. If no parent is set, it looses a certain amount of hitpoints over time. It is important to do things like this by multiplying with Time.deltaTime to avoid unpredictable behaviour because of unstable frame rates!
If the agent is still attached to another object but its signalStrength has fallen below 0.1f then it will be detached. Also the parent object will be informed that the robot was detached so it can keep track of how many robots it owns.
If the agents health falls to or below 0.0f it will be destroyed. An instance of a particle system will be generated at the position of the agent to make it visually appealing.
The last check just handles the cooldown of the weapon. The agents are only allowed to fire in certain intervalls.
if (lastUpdate > updateInterval) { lastUpdate = 0.0f; friendlyRobots = GameObject.FindGameObjectsWithTag ("FriendlyRobot"); List<GameObject> robotFriends = new List<GameObject> (); List<GameObject> friendlyTowers = new List<GameObject> (); GameObject[] robotEnemies; foreach (GameObject fRobot in friendlyRobots) { if (fRobot.GetComponent<RobotAgent> ().GetAgentType () == agentType && fRobot.gameObject != this.gameObject) { robotFriends.Add (fRobot); } }
If the last update of the behaviour lies far enough in the past, we will update it now! After resetting the cooldown of the update it gathers some data.
friendlyRobots might be named a little bit misleading because every agent, even those owned by other players are tagged „FriendlyRobot“. robotFriends, friendlyTowers and robotEnemies should be straight forward. The first step is to separate the robots by type. This is done by checking the agentType against the agentType of the current object. All matches are saved into the robotFriends.
Vector3 flockCenter = Vector3.zero; int flockCount = 0; for (int i = 0; i < Mathf.Min (robotFriends.Count, flockSize); i++) { flockCenter += robotFriends [i].transform.position; flockCount++; } flockCenter /= flockCount;
Here i tried to find out where the center of the flock of robots is. The positions are all added up and divided by the number of robots in the current flock. A good idea here might be to first sort the robotFriends by distance but this takes a lot of time. And it works as it is!
To make the next step a little bit easier to understand i will take one of the sorting loops out as an example:
int minPosition = 9999; float minDistance = 9999.0f; for (int i = 0; i < robotFriends.Count; i++) { float currDist = Vector3.SqrMagnitude (this.transform.position - robotFriends [i].transform.position); if (currDist < minDistance) { minPosition = i; minDistance = currDist; } }
The option here would be to sort the list of robots by distance to find the nearest one easily. But as it turns out, this is really really costly and does not allow for a high count of robots. So instead of sorting everything i opted to run trough the list once and find only the one with the smallest distance. This makes the process much faster and allowed for the appropriate robot count. Also one might notice that i use Vector3.SquareMagnitude of the vector between two objects instead of Vector3.Distance because it should (might) be faster. Using the SquareMagnitude should avoid a few root calculations.
Vector3 flockInterest = Vector3.zero; Vector3 targetInterest = Vector3.zero; if (friendlyRobots [0]) { int minPosition = 9999; float minDistance = 9999.0f; for (int i = 0; i < robotFriends.Count; i++) { float currDist = Vector3.SqrMagnitude (this.transform.position - robotFriends [i].transform.position); if (currDist < minDistance) { minPosition = i; minDistance = currDist; } } float distance2; if (minPosition != 9999) { distance2 = Vector3.Distance (this.transform.position, robotFriends [minPosition].transform.position); } else { distance2 = minimumFlockDistance + 1.0f; } minPosition = 9999; minDistance = 9999.0f; for (int i = 0; i < friendlyRobots.Count (); i++) { float currDist = Vector3.SqrMagnitude (this.transform.position - friendlyRobots [i].transform.position); if (currDist < minDistance && friendlyRobots [i].gameObject != this.gameObject) { minPosition = i; minDistance = currDist; } } float distance1 = Vector3.Distance (this.transform.position, flockCenter); Vector3 jitter = new Vector3 ((Random.value * jitterAmount * 2.0f) - jitterAmount, 0.0f, (Random.value * jitterAmount * 2.0f) - jitterAmount); if (distance1 > maximumFlockDistance) { flockInterest = (flockCenter - this.transform.position + jitter).normalized; } if (distance2 < minimumFlockDistance) { //Debug.DrawLine (this.transform.position, friendlyRobots [minPosition].transform.position, Color.blue, 1.0f); float factor = 1.0f / (distance2 / minimumFlockDistance); flockInterest = -(friendlyRobots [minPosition].transform.position - this.transform.position + jitter).normalized * factor; } }
Ok, this is a lot of code, but most of it is sorting. Basically it looks for robots of the same type, determines the distance of the closest robot and if it is further away than it wants to be, the flockInterest vector points towards the center of the flock otherwise it points away.
In the example above the circled robot on the right is the current robot we are updateing. Depending on the distance to the nearest other friendly robot (in this case the one directly below it) it will decide to either steer towards the transparent red area of the flock or to move away from the closest robot.
if (this.transform.parent != null) { if (agentType == AgentType.aggressive) { targets = GameObject.FindGameObjectsWithTag ("Target"); robotEnemies = GameObject.FindGameObjectsWithTag ("FriendlyRobot"); List<GameObject> enemyRobots = new List<GameObject> (); for (int i = 0; i < robotEnemies.Count (); i++) { if (robotEnemies [i].transform.parent != this.transform.parent && robotEnemies [i].transform.parent != null && this.transform.parent != null) { enemyRobots.Add (robotEnemies [i]); } } List<GameObject> enemyTargets = new List<GameObject> (); if (this.transform.parent != null) { for (int i = 0; i < targets.Count (); i++) { if (targets [i].transform.parent != this.transform.parent && targets [i].transform != this.transform.parent.transform) { enemyTargets.Add (targets [i]); } else { //Debug.DrawRay (targets [i].transform.position, Vector3.up * 5.0f, Color.white, Time.deltaTime); friendlyTowers.Add (targets [i]); } } } targets = enemyTargets.ToArray (); targets = targets.Union (enemyRobots).ToArray (); } else if (agentType == AgentType.harvester) { if (harvested > 0.0f) { if (Vector3.Distance (this.transform.position, this.transform.parent.transform.position) < maximumTargetDistance) { this.transform.parent.GetComponent<RobotSpawner> ().ressources += harvested; harvested = 0.0f; } } if (harvested == 0.0f) { targets = GameObject.FindGameObjectsWithTag ("Ressource"); } else { targets = new GameObject[1]; targets [0] = transform.parent.gameObject; } }
The next step is finding a target. And a robot only tries to attack something if it is associated with a player. Robots that lost their signal just wander around until their health reaches zero.
The behaviour is divided into two possibilities, either a robot is an aggressive robot (on the left in the picture above) or a harvester. Let’s take a look at the attack robot first.
It starts with first finding enemy robots and towers. Unfortunately again, each tower (friendly and enemy) is tagged with „Target“ and all robots are tagged „FriendlyRobot“. We start by collecting all of them and checking if the are on our side. If they are not, add them to the targets list.
For the harvesters its somewhat similar. As long as it is not fully loaded with ressources it looks for all objects tagged „Ressource“ and adds them to the targets list. If it is already loaded, the players base is the only object in targets.
if (targets.Count () > 0) { int minPosition = 9999; float minDistance = 99999.0f; if (targets.Count () > 1) { for (int i = 0; i < targets.Count (); i++) { float currDist = Vector3.SqrMagnitude (this.transform.position - targets [i].transform.position); if (currDist < minDistance) { //Debug.Log ( i + " - " ); minPosition = i; minDistance = currDist; } } } if (minPosition == 9999) { minPosition = 0; } mainTarget = targets [minPosition]; if (agentType == AgentType.aggressive) { Vector3 targetNew = targets [minPosition].transform.position; targetNew.y = turret.transform.position.y; Vector3 towardsTarget = targetNew - turret.transform.position; towardsTarget.y = turret.transform.position.y; towardsTarget = Vector3.RotateTowards (turret.transform.forward, towardsTarget, 0.1f, 1.0f); turret.transform.LookAt (turret.transform.position + towardsTarget); Vector3 turretRotation = turret.transform.localRotation.eulerAngles; turretRotation.x = 0.0f; turret.transform.localRotation = Quaternion.Euler (turretRotation); } if (Vector3.Distance (mainTarget.transform.position, this.transform.position) <= maximumTargetDistance) { Attack (mainTarget); } targetInterest = (mainTarget.transform.position - this.transform.position).normalized; } else { targetInterest = (this.transform.parent.transform.position - this.transform.position).normalized; } }
Assuming there are no targets, the robot just goes back to its owners base. But if there are targets, it tries to find a way towards it. Again, its just sorting for the nearest target and at this step, just heading towards it. If the robot is an agressive one it also adjusts its turret to face towards the target and if it is close enough, it attacks it.
if (agentType == AgentType.aggressive) { directionOfInterest = (flockInterest * 0.5f + targetInterest * 0.5f).normalized; } else if (agentType == AgentType.harvester) { directionOfInterest = (flockInterest * 0.25f + targetInterest * 0.75f); }
And here the behaviours are mixed. Agressive robots are in equal parts trying to keep the optimal distance to its flock and trying to find a target. The harvesters have a slightly different ratio, its 25% flock and 75% target. But this is just the base behaviour, we still have to take into account the radio signals.
if (friendlyTowers.Count > 0) { friendlyTowers.Sort (delegate ( GameObject a, GameObject b) { return Vector3.SqrMagnitude (this.transform.position - a.transform.position).CompareTo (Vector3.SqrMagnitude (this.transform.position - b.transform.position)); }); List<GameObject> towersInFront = new List<GameObject> (); Vector3 waveInfluence = Vector3.zero; for (int i = 0; i < friendlyTowers.Count; i++) { Vector3 toFriendly = friendlyTowers [i].transform.position - this.transform.position; float distance = toFriendly.magnitude; float enemyDistance = distance * 2.0f; if (mainTarget) { enemyDistance = Vector3.Distance (mainTarget.transform.position, this.transform.position); } float influence = distance / friendlyTowers [i].GetComponent<Tower> ().range; if (Vector3.Distance (this.transform.position, friendlyTowers [0].transform.position) < (friendlyTowers [0].GetComponent<Tower> ().range * 0.25f) || distance > enemyDistance) { break; } if (Vector3.Dot (targetInterest, toFriendly) > 0) { waveInfluence = toFriendly.normalized * (influence * influence); break; } } if (waveInfluence != Vector3.zero) directionOfInterest = (directionOfInterest * 0.5f) + (waveInfluence * 0.5f); }
This is one of the calculations that are more or less guesswork. First we order the friendly towers by distance (Please notice that i am using a delegate function to sort them by distance here which is very cost intensive). The waveInfluence value determines how strongly a robot will steer towards a radio tower and is initialized as Vector3.zero. The distance to the target is calculated into enemyDistance and influence is calculated by dividing the distance by the range of the tower. If the distance to the tower is smaller than 25% of its range or if the robot is closer to the enemy as to the next tower the calculations are stopped here and the towers will get no influence. Otherwise we have to calculate how much the radio signal is steering the agent. We do this by first checking the Vector3.Dot of the direction the robot is heading (targetInterest) and the direction in which the closest tower is standing. Only if this is greater than 0 we know that the tower is somewhere in the frontal area of the robot. If it is, the influence is calculated by toFriendly.normalized * (influence * influence) – this is just a guessed formula which showed the nicest behaviour.
If we come out of this with a waveInfluence vector other than Vector3.zero we adjust the directionOfInterest to 50% its original direction and 50% waveInfluence. How this works out is shown in the following picture:
The red robot on the left is targeting the blue tower. The direct line to the target is shown in green. If the robot would engange in this way, it would risk to loose the signal on the way to the enemy. So instead the above code would result in a movement path like the one in blue. This would maximize the time the agent is under direct influence of the towers.
This completes the behaviour update! But what happens if it is not updated in this timestep?
} else { if (mainTarget && mainTarget.gameObject != this.gameObject && this.transform.parent != null) { if (Vector3.Distance (mainTarget.transform.position, this.transform.position) <= maximumTargetDistance) { Attack (mainTarget); } } }
If it gets close to its target, it attacks. Nothing else.
bool obstacle = false; RaycastHit rayhit; Vector3 avoidObstalce = Vector3.zero; if (Physics.Raycast (transform.position + agentSpeed * 0.1f + Vector3.up * 2f, Vector3.down, out rayhit, 4f)) { float slope = Vector3.Dot (rayhit.normal, Vector3.up); if (slope < 0.3f) { float directionSwitch = 1.0f; avoidObstalce = rayhit.normal; obstacle = true; } } else { directionOfInterest = -directionOfInterest; agentSpeed = -agentSpeed; } if (obstacle) { directionOfInterest = new Vector3 (avoidObstalce.x, 0.0f, avoidObstalce.z).normalized; } else { directionOfInterest = new Vector3 (directionOfInterest.x, 0.0f, directionOfInterest.z).normalized; } }
The closing lines of the update function are the product of only a few hours left and wild guessing. Basically what we needed was some sort of terrain awarenes so that the robots dont fall into deep holes or drive up steep hills. We managed to get some sort of nice looking obstacle avoidance by doing a raycast in front of the robot downwards and check the angle of the surface we are hitting. If it was outside of our allowed treshold we just steer in the direction the normal of the hit is pointing. If we hit nothing (this means we are at the edge of the map) we just turn around.
Now all that is left is to use the values we computed and apply them to the agent. The best place to do this is the FixedUpdate function:
void FixedUpdate () { agentSpeed = Vector3.RotateTowards ( agentSpeed, directionOfInterest, Mathf.Deg2Rad * maxTurnRate * Time.deltaTime, 0.0f); transform.LookAt (transform.position + agentSpeed); RaycastHit rayhit; if (Physics.Raycast (transform.position + Vector3.up * 2f, Vector3.down, out rayhit, 4f)) { transform.position = rayhit.point + Vector3.up * 0.333f + agentSpeed * Time.deltaTime * agentSpeedMultiplier * Mathf.Pow((Vector3.Dot(rayhit.normal, Vector3.up)), 2.0f); } else { transform.position += agentSpeed * Time.deltaTime * agentSpeedMultiplier; transform.LookAt (transform.position + agentSpeed); } }
To simply take the direction where the agent should be heading and use it as the new movement direction would result in relatively jaggy movement. So instead we just rotate the current direction in the direction of interest by turn rate that we specified before. And to place the robot on the terrain we use another raycast to get the normal of the geometry directly below the agent and orient it accordingly.
You can see how all of this stuff is working together in the YouTube Video at the top of the article or if you check out the game itself which you can download on the global game jam site that is linked at the top! If you are interested in the source code, it is linked there, too.
And i would like to end this with a short disclaimer: I know the code is not perfect or optimized. I know that there are libraries that would do exactly what i was doing here manually and i also know that there probably are thousands of ways to do this better. But that does not matter to me. This was a group effort for the global game jam. It is meant to be fun. And i like to just start to code and do thinks my own way. But this does not mean that i do not want to hear your ideas! Feel free to comment! I would love to hear your thoughts on this. 🙂