Dev Diaries - Building a JRPG - Part 14

We'll divide the work into smaller pieces that will allow us to quickly get some feelings of accomplishment. Also, I think it's a good excuse for me to keep these posts shorter as my wife's parental leave is over, and I'm now having to prioritize watching over Riley even more (of course, I was before, but my wife was leading the charge here...). 😀

Today, we'll set some basic goals in implementing the Enemy AI. Here is our TODO list:‌

  • Given a spawned enemy game object, how do we translate the location of the game object to another location on the screen?
  • Once we have translated the game object, can we animate it like we do with our player (hero) game object?
  • Can we try to see if we can programmatically make the enemy game object move on its own with proper animations?

Translation of the Enemy Game Object‌

Translation of the enemy game object is an exercise in changing the position of the object itself. How do we do that? Taking a look at what has already been done with the player game object, we see that the object position is managed through the Rigidbody2D game component. In order to get the enemy game object to move, we will mimic the player game object by adding a Rigidbody2D component.

Rigidbody2D

We freeze the X, Y, and Z coordinates in the Rigidbody2D game component since we do not want a collision between the player, and enemy to force the enemy game object moving in a different direction.‌

Our enemy game object is the Goblin, which is controlled by the GoblinCharacter.cs script. In GoblinCharacter.cs we will create an instance variable RigidBody to reference the Rigidbody2D component, and instantiate it Start.

private Rigidbody2D RigidBody;

...

void Start()
{
    RigidBody = GetComponent<Rigidbody2D>();

    DebugHelper.Log($"Enemy: {character.CurrentClassName()}");
}

Our next challenge is to make the Goblin game object follow the player, which is represented by the Cloud game object. FixedUpdate will need to be implemented in the GoblinCharacter class.

The approach in making the enemy follow the player is to treat the enemy as one point, and the player as another point within a cartesian plane. When the distance between these two points is at a defined threshold, the enemy should begin following the player. This is a two part problem.

  1. How do we determine the distance between the enemy, and the player?
  2. How does the enemy know the direction to follow if the player is within the radius?

To answer, (1), we can implement the distance formula, to calculate the distance between the enemy game object, and the player.

Then based on the distance between the two objects, we can see if it is at most equal to the radius of sight defined by the enemy. If so, we being executing the following procedure.

Proximity

We can implement this simple formula in Jrpg.Unity. Given 2 Vector2 positions from the Rigidbody2D components, we can access the x, and y components to compute the Euclidean distance. A utility class, VectorUtil can be written:

using System;
using UnityEngine;

namespace Jrpg.Unity
{
    public class VectorUtil
    {
        public static float Distance(Vector2 first, Vector2 second)
        {
            return (float)Math.Sqrt(
                Math.Pow(first.x - second.x, 2) + Math.Pow(first.y - second.y, 2)
            );
        }
    }
}

Simple enough, now we can get the distance by calling VectorUtil.Distance. The next step is to compute move the enemy object towards the player if the player game object falls within the proximity of sight. For this example, let's set up the following parameters:

  • The maximum distance for which the enemy is allowed to continue to follow the player is 2 units. This is the radius of sight.
  • The minimum distance for which the enemy must stay from the player while following is 1.333 units. This is so that the game objects do not touch completely, or overlap.
void FixedUpdate() 
{
    var MaxDistanceAwayFrom = 2f;
    var MinimumDistanceAwayFrom = 1.3333f;
    
    var cloudBody2D = GameObject.Find("Cloud").GetComponent<Rigidbody2D>();
    var distance = VectorUtil.Distance(RigidBody.position, cloudBody2D.position);
    
    if (distance < MaxDistanceAwayFrom && distance > MinimumDistanceAwayFrom)
    {
        // Move the Enemy towards the player
    }
}

Now we need to fill in the conditional with the implementation to our second answer. How does the enemy game object know to move towards the player? We can easily find out if the player is near the enemy, but enemy isn't entirely sure of the direction where the player is coming from.

This is the problem. The enemy must know the direction to follow the player.

One solution is to treat the enemy game object as one point, and the player object as another point, and perform linear interpolation in each frame update, bringing the enemy closer, and closer to the player over time. As a refresher, here is the parametric equation to linearly interpolate values across two points through some time, .

Here, , and are two-dimensional vectors consisting of , and components. In this context, is the enemy game object. Then given some , we can compute the next position using the parametric equation.

After some small delta to get time, , we have a new position, , and successively reuse that position as , thus bringing the enemy game object closer, and closer.

The approach is good, and Vector2 provides an implementation which can be called through Vector2.Lerp. An example usage of this is:

RigidBody.position = Vector2.Lerp(RigidBody.position, cloudBody2D.position, t);

Where is some small value we will define to use to parametrically determine the next position where the enemy game object should be with respect to the player game object's position.

Notice here how the position is very dependent on the value of chosen. Suppose we choose some arbitrary value of . What would that look like?

Big t step

Okay, that's too big of a step, how about ?

Little t step

Wow, too slow!

But what about ?

Just right t

Yeah, that looks better, but still this approach is not good enough. Notice that we had to "guess" our way to a .

When choosing , notice that the value must be sufficiently small in that it must generate a location small enough that each frame update will not produce an effect which looks like as if the enemy game object is "teleporting" to the next location. This puts us in a situation where we must guess the value of , and choose some value arbitrarily that "looks good". Well, what looks good on my machine won't necessarily look good on yours, right?‌

This effect in choosing the wrong could cause lateral inhibition to activate, and results in the eye to perceive the animation of the enemy game object as if frames are being dropped.

We effectively have to "guess" our way in choosing some that will interpolate positions that will end up looking good at each FixedUpdate call -- which by default, is 0.02 seconds.‌

The last problem with the approach, and which Scott had pointed out to me, and I got a kick out of is an example of the Achilles paradox. If we do not choose a proper value of to determine the next position of the enemy game object towards the player game object, then there is a chance that each new location which places the enemy game object will be too far to the point where the player will never be caught by the enemy to attack.

Wow, so there are a lot of issues with this approach. Thankfully, there is a better way.

We can also use MoveTowards in Vector2 to achieve the effect of linear interpolation using the "perfect" value. Except, we don't need a ! MoveTowards is good because it allows for finer control over how much the GameObject can move in one update through the last parameter, the maximum distance which the game object is allowed to move to that destination game object.‌

To calculate the maximum distance delta which one source object may move to another, we can just use a simple position function of time, and velocity to compute the displacement. . Here, the time is Time.deltaTime, and the velocity is the same as the player game object, of 1.5, but it can be chosen to match whatever the enemy should be moving as.

void FixedUpdate() 
{
    var MaxDistanceAwayFrom = 2f;
    var MinimumDistanceAwayFrom = 1.3333f;
    
    var cloudBody2D = GameObject.Find("Cloud").GetComponent<Rigidbody2D>();
    var distance = VectorUtil.Distance(RigidBody.position, cloudBody2D.position);
    
    if (distance < MaxDistanceAwayFrom && distance > MinimumDistanceAwayFrom)
    {
        RigidBody.position = Vector2.MoveTowards(RigidBody.position, cloudBody2D.position, Time.deltaTime * 1.5f);
    }
}

Now, in each FixedUpdate call, which occurs, by default, every 0.02 seconds, we check to see if the enemy and player are "close enough", if they are, then we move the enemy closer to the player in the direction of the player's position.

Here is the result, we can see that the enemy can now follow the player around!

Move towards

Animating the Enemy Game Object

We've gotten far enough now that How about animating the enemy game object when the direction of its position changes? Well, we can use the player game object as a template!

I won't detail the steps on how to create the AnimationController for the Goblin game object here. We've done that together many times in the past. I just want to touch on this briefly as the very last part of the implementation is the most important when it comes to displaying the proper direction of walking.

Basically, we need to set up the Goblin's animation states to be similar to that of Cloud.

GoblinCharacter.cs will need a few code changes. I used Cloud.cs to reference the main code changes needed to provide similar functionality.

First, we need to declare the instance variables to reference the idle Sprite representations.

public Sprite GoblinIdleDown;
public Sprite GoblinIdleUp;
public Sprite GoblinIdleLeft;
public Sprite GoblinIdleRight;

Then, in Unity Editor, we can assign the values:

Sprites

We will also need to define the state names of the Goblin animation, and the directional flags:

public class StateNames
{
    public static string GoblinIdle = "GoblinIdle";
    public static string GoblinUp = "GoblinUp";
    public static string GoblinDown = "GoblinDown";
    public static string GoblinLeft = "GoblinLeft";
    public static string GoblinRight = "GoblinRight";
}

public class DirectionFlagNames
{
    public static string WalkingUp = "WalkingUp";
    public static string WalkingDown = "WalkingDown";
    public static string WalkingLeft = "WalkingLeft";
    public static string WalkingRight = "WalkingRight";
}

Again, all this should be familiar so far, as the approach is similar to the Cloud game object.

Next, we'll need references to the SpriteRenderer, Animator, and the PreviousState.

private SpriteRenderer SpriteRenderer;
private Animator Animator;
private string PreviousState;

Start should then be modified to assign these variables:

// Start is called before the first frame update
void Start()
{
    RigidBody = GetComponent<Rigidbody2D>();
    Animator = GetComponent<Animator>();
    SpriteRenderer = GetComponent<SpriteRenderer>();

    DebugHelper.Log($"Enemy: {character.CurrentClassName()}");
}

Idle, WalkDirection, and LateUpdate are pretty much identical to the Cloud game object, except of course in the context of the Goblin animation states.

private void Idle()
{
    Animator.SetBool(DirectionFlagNames.WalkingLeft, false);
    Animator.SetBool(DirectionFlagNames.WalkingRight, false);
    Animator.SetBool(DirectionFlagNames.WalkingUp, false);
    Animator.SetBool(DirectionFlagNames.WalkingDown, false);
}

private void WalkDirection(string flagName)
{
    Idle();

    Animator.SetBool(flagName, true);
}


private void LateUpdate()
{
    var currentState = Animator.GetCurrentAnimatorStateInfo(0);

    if (!currentState.IsName(StateNames.GoblinIdle))
        return;

    if (PreviousState.Equals(StateNames.GoblinUp))
        SpriteRenderer.sprite = GoblinIdleUp;
    else if (PreviousState.Equals(StateNames.GoblinDown))
        SpriteRenderer.sprite = GoblinIdleDown;
    else if (PreviousState.Equals(StateNames.GoblinLeft))
        SpriteRenderer.sprite = GoblinIdleDown;
    else if (PreviousState.Equals(StateNames.GoblinRight))
        SpriteRenderer.sprite = GoblinIdleRight;
    else
        SpriteRenderer.sprite = GoblinIdleDown;
}

Finally, the new piece of code! We want to be able to change direction of the animation belonging to the Goblin based on the direction which the player is moving. An easy way to do this is to inspect the velocity direction of where the player is heading, and change the direction of the Goblin walking animation based on that.

If velocity has an x component that is negative, then the Goblin will be walking left. If the x component is positive, then the Goblin will be walking right. Similar with the y components, negative indicates downwards, while positive indicates an upwards direction.

// Update is called once per frame
void FixedUpdate()
{
    var MaxDistanceAwayFrom = 2f;
    var MinimumDistanceAwayFrom = 1.3333f;

    var cloudBody2D = GameObject.Find("Cloud").GetComponent<Rigidbody2D>();

    // Find where Cloud is and move to that direction
    var distance = VectorUtil.Distance(RigidBody.position, cloudBody2D.position);

    
    if (distance < MaxDistanceAwayFrom && distance > MinimumDistanceAwayFrom)
    {
        if (cloudBody2D.velocity.x < 0)
        {
            WalkDirection(DirectionFlagNames.WalkingLeft);
        }
        else if (cloudBody2D.velocity.x > 0)
        {
            WalkDirection(DirectionFlagNames.WalkingRight);
        }
        else if (cloudBody2D.velocity.y < 0)
        {
            WalkDirection(DirectionFlagNames.WalkingDown);
        }
        else if (cloudBody2D.velocity.y > 0)
        {
            WalkDirection(DirectionFlagNames.WalkingUp);
        }

        RigidBody.position = Vector2.MoveTowards(RigidBody.position, cloudBody2D.position, Time.deltaTime * 1.5f);
    }
}

Here is the final result!

Final

Conclusion

In the next post, I'll discuss in making the game object reusable so that we can spawn many of these in the map!