Check Your Six!

Objective: Implement a spawning routine to instantiate the Enemy game objects at random positions on the circumference of a circle, then aim and translate them towards the Player game object.

The first time I worked through the 2D Space Shooter project, my Enemy game objects would predictably attack from the top of the screen and lazily make their way to the bottom, looping the pattern until they were destroyed or the game ended. I had the odd mine layer who would occasionally traverse my screen, sometimes from the left, other times from the right, but that was the extent of the excitement. As I tackle the project with new ideas, I thought to myself how challenging it would be if the waves could spawn from any direction. By this, I mean a 360-degree threat arc! Now that would be cool 😎.

Part 1: Defining the variables.

We begin with the Spawn Manager script…

First, I needed a reference to the Enemy prefab (_enemyPrefab), as well as the game object used to “store” these enemies as they spawn to eliminate clutter in our Hierarchy (_enemyContainer). I then set a private bool to turn on/off the spawning of game objects (_stopSpawning).

Finally, I stored a reference to the Player’s position (Transform), and float variables to set the radius of our circle (radius) and the rate at which we wish to spawn the game objects (spawnRate).

The radius variable needs to be large enough to ensure that the circumference of the circle is set beyond the visible area of the Game Scene. Since the circle origin is set to (0, 0, 0), using a float of 13f guaranteed that any objects spawned were outside any of the four corners.

Part 2: Let’s get this Spawning going!

In this example, call the coroutine SpawnEnemyWave() in the Start() method. The coroutine will only run while the _stopSpawning bool is set to false, executing the CreateEnemyRandomPositionOnCircumference() method at a rate equal to the spawnRate. In the code snippet above, this will happen every 5 seconds.

Part 3: Spawning the Enemy along a circumference.

We begin by getting a reference to our instantiated _enemyPrefab and storing it into the enemy game object. The enemy game object’s parent is then set to the _enemyContainer. This will declutter our Hierarchy by sending all spawned enemy game objects into their own “container” which happens to be a child of the SpawnManager.

We then set the transform position of the stored enemy to a random position inside a circle. To do this, we use the Random class method insideUnitCircle. This will return a random Vector2 inside a circle with a radius = 1. To ensure the position is set on the circumference, we normalize this random value which will result in the Vector length (or magnitude) always being equal to 1. We finish off by multiplying by the set radius, resulting in the Enemy game objects always spawning on the circumference of the circle.

Part 4: Getting the Enemy to face the Player.

In order to turn the Enemy game object to face the Player, we first need to get the angle between these two objects. We do this by getting a direction vector between them, by subtracting the starting position (Enemy) from the target position (Player). We store the required information in the three following variables:

To work out the angle, next you’ll need to decide which way is the target’s forward direction. Since our Player (the target), by default, points to the top of the Game Scene, his forward direction is “up”. We indicate this by stating “Vector2.up”.

With this information in hand, we now need to find the angle between Vector2.up and the direction of the Enemy from the Player.

We can do this by using the Signed Angle function in Unity. Finally, to rotate the object, you set the enemy’s Z rotation to the angle value:

Angle vs Signed Angle ~ Angle and Signed Angle are Unity functions for getting an angle between two vectors. While they both work in similar ways, Angle returns an unsigned value, meaning it can only be positive, always between 0 and 180 degrees. This means that when the angle between the two vectors is less than 0 degrees, or more than 180, the object starts to rotate away from the target. Signed Angle returns a value between -180 and 180 degrees, which covers the full circle of rotation.

Instantiating Enemy game objects randomly on the circumference of the circle. The radius is set to 5 for demonstration purposes.

Once these Enemy game objects have been instantiated, we let the Enemy scripted behavior take over to move them forward. Understand that the Enemy objects will simply move in the direction of the Player’s last known position, meaning that if the Player moves, an Enemy already in motion will continue on its original course and not track the Player.

Part 5: What happens when the Enemy Game Object reaches the boundaries of the Game Scene?

If we now look at the script for the Enemy game objects, we will see two methods controlling their basic movement: EnemyMovement() and LookAtPlayer().

Moving the Enemy game object, and repositioning them to a new random position along the circle's circumference once they reach the limit of the radius of the circle.

In EnemyMovement(), we start by caching a reference to the SpawnManager radius variable and declare a new Vector3 setting the worldCenter to (0, 0, 0). We then translate the Enemy game object across the Game Scene.

As the Enemy prefab chugs along, it measures the distance between worldCenter and its own transform position. Once that distance exceeds the set radius (the game object has left the visible Game Scene), we reset its transform position to a new random point on the circumference as we did when the object was originally spawned.

The final step is to call the LookAtPlayer() method.

Turns the enemy game object to face the Player.

We first make sure that the Player is still active (hasn’t been destroyed) by doing a null check. If the _playerScript is not null, we get the present position of the player, the newly positioned Enemy game object, and run the code as we did in the Spawn Manager to turn the enemy to face the Player.

Three enemy ships (red, yellow, and green) move across the Game Scene. Upon reaching the outer boundary, they regenerate at a new random spot, turn to face the Player, and translate across for a new attack (Player collider is disabled in this demo).

If the Player was destroyed in the wave attack, the _playerScript will be null, and this will command the Enemy game object to destroy itself before it begins to translate once more across the screen. This will ensure that the Game Scene, as is the case in my current version of the game, will remain clutter-free and will prevent errors from being generated by preventing the LookAtPlayer() method from looking for the transform.position of the Player.

Example of what happens once the Player loses three lives following collisions with Enemy ships. The Player dies (disappears), and the Enemy ships subsequently get destroyed upon reaching the outer boundary.

In conclusion, we’ve looked at how we can instantiate game objects at random positions on the circumference of a circle, turn these game objects so they point towards a specific target, move them along that resulting trajectory path, and finally reposition them upon reaching the Game Scene boundaries.

I hope you found something useful in this article. As always, thanks for reading :)

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Michel Besnard

Michel Besnard

Military member with 35+ years of service, undertaking an apprenticeship with GameDevHQ with the objective of developing solid software engineering skills.