Captain! Watch out! The enemy ship is re-attacking!
Objective: create an enemy that will periodically turn back toward the player.
Let’s face it. When the enemy ships simply run down your screen in a predictable manner, it doesn’t take long for the challenge to disappear. And when the challenge is gone, the player is no longer engaged in the gameplay.
The next few articles will explore how I implemented various behaviors in my enemy game objects to make them more aggressive, smarter, more difficult to defeat, or simply unpredictable.
Today, I will explain how I created an enemy that will periodically turn back toward the player to re-attack it.
Begin with setting up a new C# script (mine is called EnemyChasePlayer).
Set up a few variables so we can cache references to various scripts. Create a float to store the enemy’s speed, and a couple of booleans to determine if the enemy is maintaining course or turning towards the player. Add a few floats to adjust the delay in switching between behaviors and the rotation rate at which the enemy turns to its target (I set mine up with a range setting; these can be hard-coded if you prefer).
I’ll skip over the next few variables (from _laserNode1 to _stopUpdating) since they deal with logic outside of the scope of this article.
I then set up a float to store a radius value, and three Vector3 references for the worldCenter, enemy, and player positions.
In Start(), cache references to the Player, Game Manager, and Spawn Manager scripts, followed by a null check. Since my Spawn Manager generates my enemies, this is where I’ve set the radius value, which is the distance from the center of my world to the outer edges of the game screen. I used this to set the boundaries when game objects travel off-screen (either to be instantiated elsewhere or destroyed). I also put my worldCenter Vector3 reference to zero.
In Update(), get a reference to the player’s position by caching its transform position. Do the same with the enemy’s position. You then check to see if the enemy’s movement is normal, which means it’s translating on a constant heading, or if the enemy is turning towards the player. If the bool _normalEnemyMovement is true, _turningToTarget is set to false and the EnemyMovement() function is called. Otherwise, if _turningToTarget is true, then _normalEnemyMovement is set to false and the LookForTarget() function is called. While all this is happening, an additional function is called to fire the enemy weapon (again, not the focus of this article).
EnemyMovement() is called to translate the enemy ship on a constant heading. You start by caching a reference of the _enemySpeed from the Game Manager (in my project, the enemy speed varies by level, which is controlled in the Game Manager). You then translate the enemy game object at this referenced speed.
Regardless of the heading of the enemy game object, once that object reaches the limit of the radius away from the world center, as long as the player is still alive and the enemy itself has not been destroyed, it will be reset to the top of the game screen in a random position on the x-axis, facing down to begin translating towards the bottom of the screen. We confirm this by comparing the distance between the game object (enemyPos = transform.position) and the world center (worldCenter = Vector3.zero) to the set radius. If that distance is greater than the radius, we know that the enemy game object has reached the boundaries of its movement.
On the other hand, if the enemy reaches the limit set by the radius, and the player is destroyed (no more lives/”game over”), the enemy game object will also be destroyed.
We then call the SwitchEnemyMovement() coroutine to switch the enemy movement automatically.
LookForTarget() is how we make our enemy ship turn toward the player.
We subtract the player’s position from the enemy’s position to get a 2D vector (x, y) that points from the player to the enemy.
We then use the static method SignedAngle to return the signed angle (+ or -) in degrees between the “up” position (Vector2(0,1)) and the “direction” Vector2 calculated above. This float value, stored in “angle”, is then used to create the new Vector3 rotation along the z-axis which we then store in “targetRotation”.
We then use Quaternion.RotateTowards to rotate from the current transform.rotation of the enemy game object, to the Quaternion.Euler of the target rotation to face the player, multiplied by _huntingForTargetRotationSpeed (degrees per second) multiplied by Time.deltaTime.
I won’t pretend to understand everything that’s going on in this snippet. It took quite a bit of trial and error before getting it right. Playing around with Quaternions is still somewhat complicated for me.
Similar to the EnemyMovement() method, if the enemy reaches the limit set by the radius, and the player is destroyed (no more lives/”game over”), the enemy game object will also be destroyed. We then again call the coroutine to switch the enemy movement automatically.
The coroutine SwitchEnemyMovement() flips the booleans at a rate set by _delaySwitchingMovementState. This value is presently adjustable, switching the enemy movement from “maintaining course” to “turning toward the player”, every 3 to 5 seconds.
Note: The snippets above are the essential parts. Not covered in this article is the logic dealing with firing the enemy’s weapon, 2D collision detection, and game object destruction (player, weapons, enemy) upon collision detection.
As you can see, the enemy will keep turning back toward the player until it gets destroyed.
I hope you’ve found this article interesting and hopefully, it will help you in your game project. Thanks for reading :)