Dev Diaries - Building a JRPG - Part 13

I'm not sure how long it really has been since we've been working on the same demo game together. It's been a great platform to demonstrate some of the various framework features we've developed together over the course of the year. Six months now, I believe?

It's been that long, huh! Time flies, and I haven't even touched on other topics like dialogues, status bars, randomly generated maps, and cut scene events yet!

Well, I have some bittersweet news, Enemy AI is the last bit of work I would like to work on for this demo. After that, I plan on starting a new demo, perhaps a very small game from the ground up to continue building up the Jrpg.System framework. We've not much left to do in making a framework which can help us create JRPGs in game engines which support .NET Standard 2.0 interoperability. My hands are shaking with excitement for the next thing, but I have to contain myself with some self control! I need to finish this demo!

Today, we'll be working on developing some basic Enemy AI. Enemies rendered on the map in this demo will be able to move around, follow, and attack a player. Think of it as a similar system to old hack-and-slash RPGs like Diablo, or Castle of the Winds.

The basic battle system for melee combat is already in place, so we can leverage some things that are already written. What is needed is actual logic to be able to detect whether the player is within sight of the enemy, and have the enemy follow, or attack the player as needed.

I would like to be up-front in that although we will be working on Enemy AI in this demo, we aren't focused on generating dungeons, or spawning enemies. Thus, we'll be using the same map we've been using, and will hard code enemies onto the map. I'll save these operations as a discussion for another day.

Detecting the Player

The first time I ever read about tile-based enemy AI design was in the book Tricks of the Mac Game Programming Gurus. The discussion programming a tile-based dungeon crawler with basic enemy AI was discussed in the very first chapter! I actually pulled out the book from my library the other day to re-read the chapter, and it was worth it.

Yes, the implementation discussed in the book is very simple, and the code written isn't the greatest, but the approach to dungeon crawler enemy behavior was exactly what I was looking for in a basic game demo. Therefore, the approach I am about to discuss in designing basic enemy AI will be based on that very same chapter I am fond of.

I believe a good starting point in approaching basic enemy AI is to think about the current system behavior. Our game processes events by implementing the logic in a GameObject's Update, or FixedUpdate methods.

The polling doesn't have to be on the Update, or FixedUpdate method calls, but it is just more simple to think that it will be for now. We can always process enemies through some other event, or fixed time interval. Anyway, let's just assume the former for simplicity...

On each time Update or FixedUpdateis called, we can perform a polling mechanism on enemies which currently exist (spawned) on the map. The fact that polling is needed to process each enemy on the map means that we definitely need a list of enemies still alive on the current map.

List<Enemy> Enemies = new List<Enemy>();

Declares an empty collection of enemies currently on the map. As enemies spawn, they are added to the Enemies collection. Enemies are not always static on the map. Their locations should change. Therefore, on every update, we can decide whether or not an enemy will stay at the current location, move to a new valid location on the map, or follow/attack the player if in proximity.

The player will eventually move within sight of the enemy. This will cause the enemy to determine whether to attack the player if they are within its own attack proximity. If the player is not within the attacking proximity of the enemy, but still within sight, the enemy will aggressively pursue the player to stay within sight, and reach attack proximity, or until the player is out of sight, for which the enemy is no longer pursing the player.

When the enemy is not pursuing the player the enemy will either stay at its current location, or pick a random, and valid location to move to.

Battle

Once battle is triggered by a player coming into sight of an enemy, there will be a chase which occurs until the player has moved far enough away from the enemy.

This means that the enemy no longer "sees" the player, and from here the enemy will process movement based on whether it should stay on the current location, or move to any free location near it.

Enemies should move at a pace which is calculated based on their statistics. For example, if the enemy has a higher speed stat than the player, then the enemy will have a higher chance in catching up to the player during a chase, if the player is running away from battle.

Enemies may also have a preference on how to attack the player when in proximity. For example, ranged enemies such as archers will prefer to stay further away from the player, maximizing the distance between themselves, and attack.

We may now need a few new properties belonging to a single enemy to be able to iterate through the collection of enemies, and process movement:

  • Proximities.Sight - This is the maximum Euclidean distance for which an enemy will be able to "see" the player. If the enemy has "good vision", then this will be a higher number, e.g. ranged enemies.
  • Proximities.Attack - This is the maximum Euclidean distance for which an enemy will be able to do damage to the player. If the enemy is a melee combat-type, then this value will be smaller. Ranged types then of course, will be able to attack further away.

We are now ready to describe a basic polling algorithm in pseudo code to process enemies in the map, to either move, or attack the player:

for each Enemy in EnemyList
    ShouldEnemyStay = ShouldStay(Enemy)    
    WithinSight = IsWithinSightOf(Enemy, Hero)
    
    if ShouldEnemyStay && !WithinSight then
        continue
    end if

    if !ShouldEnemyStay then
        NextLocation = GetNextLocationFor(Enemy)
        if IsLocationValid(NextLocation) then
            MoveTo(Enemy, NextLocation)
        end if
    else if WithinSight then
        NextLocationTowardsHero = GetNextLocationTowardsHeroFor(Enemy)
        MoveTo(Enemy, NextLocationTowardsHero)
    end if
    
    if IsWithinAttackOf(Enemy, Hero) then
        Enemy.Attack(Hero)
    end if
end for  

Note that for the above algorithm, an enemy is allowed to move within sight, and if within attacking range, will attack the player in the same iteration.

Movement

To determine locations for movement, the Euclidean distance will be calculated to determine the proximity.

In pseudo-code:

float Distance(Hero, Enemy) {
    var distance = Math.Sqrt(
        Math.Pow(Enemy.Location.X - Hero.Location.X, 2) +
        Math.Pow(Enemy.Location.Y - Hero.Location.Y, 2)
    );
    
    return distance;
}

When the enemy is not in sight of the player, it will either stay in place, or move in a random direction until an encounter with the player. The enemy though, will prefer to stay in place, so the randomness is more weighted towards staying in place.

To check whether or not an enemy is within sight, or attacking range, it can be simple comparisons:

float IsWithinSightOf(Enemy, Hero) {
    return Distance(Hero, Enemy) <= Enemy.Proximities.Sight;
}

float IsWithinAttackOf(Enemy, Hero) {
    rreturn Distance(Hero, Enemy) <= Enemy.Proximities.Attack;
}

Considerations

Here are some considerations I have in mind, and improvements which we can possibly make during our implementation phase:

  • Will having too many enemies spawned within the map make this polling approach too slow? What's the maximum number of enemies this approach can process without sacrificing player experience for this specific demo?

    • Taking performance metrics will be helpful in deciding whether or not it would be worth to optimize the algorithm. We want to avoid any premature, and over-optimization here
  • How much of a performance boost would we get if we just returned the squared distance instead of taking the square root?

  • How should we determine whether the next location is valid to move to?

    • Since the game is tile based, we can check to see if the tile location is passable, or contains some sort of collider which makes the location impassable.

Conclusion

I have thought of enough details to start on some implementation. In the next post, we'll try to get our Goblin to move around