Dev Diaries - Building a JRPG - Part 15

I did that thing again. I forgot to log my progress this week during my development. Okay, I didn't "forget"... I just ended up so busy from handling a fussy baby, and didn't have much time to do anything but code 5 lines at a time every 15 minutes. 😂

Because of this, I unintentionally made lots of changes, which will now be very difficult to communicate through in a blog post. I'll do my best though, and will note down all the important stuff.

Life Update

First! Life update. I'm writing this in June 23, 2020, and Riley's already approaching her 8th week in this world, and she's starting to become more like a crazy little kid each day. 😎😰

Apparently her personality seems to be quite normal. For fun, I decided to search for the experience with others with respect to 6-8 week old babies. It turns out that peak fussiness happens at 6 weeks, and continues until about 10 weeks, where there begins to be quick drop off.

Fussiness Graph

Yeah, it's a random graph I found on the internet, and I'm not sure if that this fussy business is even scientific, or just purely perceptual to the parent craving sleep.

Riley's generally pretty good in my opinion, but she absolutely hates having a wet diaper. The littlest drop can kill her mood! It's just becoming a bit more difficult in trying to soothe her from these things. Holding her in order to soothe has become very difficult in that she no longer likes being held in any position aside from being on her belly. Perhaps it's due to some indigestion.

Because of this, her preference to sleep now is now on her tummy. My wife, and I really dislike this as there can be a risk of SIDS, so we keep nap time supervised where Riley is on her tummy.

The tough part is at night where we want our little girl to be sleeping on her back. As soon as she drops into the bassinet, there is absolute rage! I really want to ask Riley one day if all that crying makes her throat sore, or not. I tried copying her once, and I couldn't even last 30 seconds without the feeling of getting a sore throat. 🤣I suppose that would be a game she can beat me on.

Anyway, I can't wait for the newborn phase to pass. It is tough not being able to do much to satisfy her due to fussiness, and having her cry with no solution. My wife, and I are doing our best, and I think that's what sucks sometimes.

We will all get there together.

Tech Toy Update

I finally bit the bullet, and bought an eGPU. Specifically, the Sonnet eGFX Breakaway Puck RX 560. To point out the obvious, it is an AMD Radeon RX 560 in a Thunderbolt 3 enclosure.

The eGPU itself isn't directly connected to my computer. Instead, it is connected to my CalDigit 3 dock via TB3, and then in turn, my CalDigit 3 dock connects to my MacBook Pro. Yes, I am aware about the additional latency, but this provides a single-cable solution, and keeps the surface of my desk relatively clean.

From the data given by Geekbench 5, I am still seeing a significant performance increase going from the integrated Intel Iris 655 graphics to the external RX 560:

Intel Iris 655: Score - 7708

Integrated Geekbench

AMD Radeon RX 560 (External): Score - 18101

eGPU

This is a 2.34x performance increase! In my opinion, this is one of the more significant upgrades I have made on my computer set up in years.‌

I don't have many quantitative numbers, as I am satisfied with qualitative measurements, I have noticed the following within my everyday tasks:‌

  • Unity Editor is obviously faster. My goodness, I've been slowing myself down this whole time!
  • Playing a scene within the Unity Editor is also much faster.
  • Adobe Premiere Pro 2020 is faster when it comes to rendering video
  • I can run Dolphin pretty well, now. 😜
  • Moving things around my 4K monitor feels snappier
  • Visual Studio 2019, for some reason feels faster. I'm not sure why having an eGPU has anything to do with this...

Luigi

Game Development

Let's build some additional features to our demo. As of now, we only have the enemy following the player once the player has collided with the enemy. The enemy should be smarter, and should actually being pursuing the upon setting its sights on the player. When the enemy is close enough to the enemy, the enemy should attack the player. Finally, we want to be able to modularize all the logic we have in our enemy game object and make it reusable.

So, we have three basic requirements:

  1. The enemy should begin approaching the player when sight proximity has been met.
  2. The enemy should perform an AttackAction when it is in attack proximity of the player.
  3. The enemy should be instantiated in a straightforward manner through code.‌

Since I changed way too much code without documenting the process, let's try to actually tackle each requirement one by one...‌

Chase the Player!

An EnemyClass definition is defined to have 3 types of proximities to be checked against. Attack, Sight, and KeepAway. As mentioned in the the initial design specification, these proximities define the required distances for the enemy take some action on the player.

{
    "Id": "Goblin",
    "Name": "Goblin",
    "Agent": "Goblin, Assembly-CSharp",
    
    ...,
    
    "Proximity": {
        "Attack": 1.5,
        "Sight": 3.0,
        "KeepAway":  1.4
    },
}

Proximitymay serve as a POCO that will be an attribute of the Enemy class. I have created this under Jrpg.CharacterSystem.Classes.Definitions.

namespace Jrpg.CharacterSystem.Classes.Definitions
{
    public class Proximity
    {
        public float Sight { get; set; }
        public float Attack { get; set; }
        public float KeepAway { get; set; }
    }
}

Now, we can modify Enemy to just receive a Proximity object as a parameter in the constructor.

BattleHelper.cs is a helper class I have created in Jrpg.Unity to detect whether or not the current conditions are correct for an enemy to begin pursuing, or attacking the player.

using Jrpg.BattleSystem.Enemies;
using UnityEngine;

namespace Jrpg.Unity
{
    public class BattleHelper
    {
        public static bool IsWithinSightOf(Enemy enemyCharacter, GameObject enemy, GameObject player)
        {
            var enemyRigidbody2D = enemy.GetComponent<Rigidbody2D>();
            var playerRigidbody2D = player.GetComponent<Rigidbody2D>();

            var distance = VectorUtil.Distance(enemyRigidbody2D.position, playerRigidbody2D.position);
            if (distance > enemyCharacter.Proximity.Sight)
                return false;

            return true;
        }

        public static bool IsWithinAttackOf(Enemy enemyCharacter, GameObject enemy, GameObject player)
        {
            var enemyRigidbody2D = enemy.GetComponent<Rigidbody2D>();
            var playerRigidbody2D = player.GetComponent<Rigidbody2D>();

            var distance = VectorUtil.Distance(enemyRigidbody2D.position, playerRigidbody2D.position);
            if (distance > enemyCharacter.Proximity.Attack)
                return false;

            return true;
        }

        public static bool IsAtMinimumDistanceFrom(Enemy enemyCharacter, GameObject enemy, GameObject player)
        {
            var enemyRigidbody2D = enemy.GetComponent<Rigidbody2D>();
            var playerRigidbody2D = player.GetComponent<Rigidbody2D>();

            var distance = VectorUtil.Distance(enemyRigidbody2D.position, playerRigidbody2D.position);
            if (distance > enemyCharacter.Proximity.KeepAway)
                return false;

            return true;
        }
    }
}

The approach is the same for all three of these helper methods. The distances are calculated, and checked against the value found in the current Proximity object within the enemy.

Also notice that Cloud, and Goblin have more or less the same properties, and methods which control the rendering of their respective sprites corresponding to the current direction of movement.

We can refactor these common properties and methods to a new class called MoveableCharacter in Jrpg.Unity. We can make MoveableCharacter an extension of the a regular game object. Then, we can have the Cloud, and Goblin game objects extend this MoveableCharacter class to get a lot of this movement management for "free".

using Jrpg.System;
using UnityEngine;

namespace Jrpg.Unity
{
    public abstract class MoveableCharacter : MonoBehaviour
    {
        protected class StateNames
        {
            public static string Idle = "Idle";
            public static string Up = "Up";
            public static string Down = "Down";
            public static string Left = "Left";
            public static string Right = "Right";
        }

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

        // Sprites
        public Sprite IdleDown;
        public Sprite IdleUp;
        public Sprite IdleLeft;
        public Sprite IdleRight;

        public string CurrentState { get; private set; }
        public string PreviousState { get; private set; }

        protected SpriteRenderer SpriteRenderer;
        protected Rigidbody2D RigidBody;
        protected Animator Animator;
        protected GameStore GameStore;

        protected void SetCurrentState()
        {
            var stateInfo = Animator.GetCurrentAnimatorStateInfo(0);

            if (stateInfo.IsName(StateNames.Up))
                CurrentState = StateNames.Up;
            else if (stateInfo.IsName(StateNames.Down))
                CurrentState = StateNames.Down;
            else if (stateInfo.IsName(StateNames.Left))
                CurrentState = StateNames.Left;
            else if (stateInfo.IsName(StateNames.Right))
                CurrentState = StateNames.Right;
        }

        protected void SetPreviousState()
        {
            var currentState = Animator.GetCurrentAnimatorStateInfo(0);

            if (currentState.IsName(StateNames.Up))
                PreviousState = StateNames.Up;
            else if (currentState.IsName(StateNames.Down))
                PreviousState = StateNames.Down;
            else if (currentState.IsName(StateNames.Left))
                PreviousState = StateNames.Left;
            else if (currentState.IsName(StateNames.Right))
                PreviousState = StateNames.Right;
        }

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

        protected void WalkDirection(string flagName)
        {
            Idle();
            Animator.SetBool(flagName, true);
        }

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

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

            if (PreviousState.Equals(StateNames.Up))
                SpriteRenderer.sprite = IdleUp;
            else if (PreviousState.Equals(StateNames.Down))
                SpriteRenderer.sprite = IdleDown;
            else if (PreviousState.Equals(StateNames.Left))
                SpriteRenderer.sprite = IdleLeft;
            else if (PreviousState.Equals(StateNames.Right))
                SpriteRenderer.sprite = IdleRight;
            else
                SpriteRenderer.sprite = IdleDown;

            OnLateUpdate();
        }

        protected void Awake()
        {
            GameStore = GameStore.GetInstance();

            OnAwake();
        }

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

            OnStart();
        }

        protected void OnCollisionEnter2D(Collision2D collision)
        {
            OnObjectCollisionEnter(collision);
        }

        protected void OnCollisionExit2D(Collision2D collision)
        {
            OnObjectCollisionExit(collision);
        }

        protected virtual void OnLateUpdate()
        {
            return;
        }

        protected virtual void OnObjectCollisionEnter(Collision2D collision)
        {
            return;
        }

        protected virtual void OnObjectCollisionExit(Collision2D collision)
        {
            return;
        }

        protected abstract void OnAwake();
        protected abstract void OnStart();
    }
}

A class which extends MoveableCharacter must override OnAwake, and OnStart . These will be called when Start, and Awake are called in MoveableCharacter by Unity. It gives a chance for the child game object to execute its own specific logic at the beginning of instantiation of the object.

Now, let's make changes in the game to make use of our shiny new code. We actually get rid of a lot of repetitive code in our demo as a result of refactoring! We have naturally cleaned up BaseEnemy by moving away all the code to MoveableCharacter. It now is pretty simple, and contains functionality a basic enemy will have:

using System;
using Jrpg.Unity;

public abstract class BaseEnemy : MoveableCharacter
{
    public enum AttackAction
    {
        Melee,
        Technique,
        Miss
    }

    // Check to see if this character is in battle
    public bool InBattle { get; set; }

    public AttackAction GetAttackAction()
    {
        int r = new System.Random((int)(DateTime.Now.Ticks)).Next(0, 100) + 1;

        if (r <= 15)
            return AttackAction.Miss;
        if (r <= 40)
            return AttackAction.Technique;

        return AttackAction.Melee;
    }
}

Now, GoblinCharacter will be much simpler and will contain a class property Character type which refers to the Jrpg.System character entity, and overridden OnStart, and OnAwake methods:

using Jrpg.BattleSystem.Enemies;
using Jrpg.CharacterSystem;
using Jrpg.Unity;
using Jrpg.Unity.Menus;
using UnityEngine;

public class GoblinCharacter : BaseEnemy
{
    // Concrete
    public Enemy Character { get; set; }

    protected override void OnAwake()
    {
        var Game = GameObject.Find("Game").GetComponent<Game>();

        Character = Game.GetEnemy("Goblin", "Goblin");
    }

    protected override void OnStart()
    {
        DebugHelper.Log($"Initialized: {Character.Name}, <{gameObject.name}>");
    }

    public void MeleeAttack(Character source)
    {
        // Implementation here...
    }

    protected void FixedUpdate()
    {
        // Implementation here...
    }
}

Now, for the most important part of this section: getting the enemy to chase the player! Most of the code is going to be in FixedUpdate, and the changes also include rewriting how the enemy follows the player:

protected void FixedUpdate()
{
    var cloud = GameObject.Find("Cloud");
    
    if (BattleHelper.IsWithinSightOf(Character, gameObject, cloud))
    {
        var cloudBody2D = cloud.GetComponent<Rigidbody2D>();

        if(cloudBody2D.velocity.y != 0)
        {
            if (RigidBody.position.y > cloudBody2D.position.y)
                WalkDirection(DirectionFlagNames.WalkingDown);
            else
                WalkDirection(DirectionFlagNames.WalkingUp);
        }

        if(cloudBody2D.velocity.x != 0)
        {
            if (RigidBody.position.x < cloudBody2D.position.x)
                WalkDirection(DirectionFlagNames.WalkingRight);
            else
                WalkDirection(DirectionFlagNames.WalkingLeft);
        }

        if (!BattleHelper.IsAtMinimumDistanceFrom(Character, gameObject, cloud))
            RigidBody.MovePosition(Vector2.MoveTowards(RigidBody.position, cloudBody2D.position, Time.fixedDeltaTime * 1.5f));

    }
}

An enemy will only follow the player if it the player is within sight. The enemy figures this out through invocation of IsWithinSightOf. Remember that every enemy has a different minimal distance value which may trigger the it to begin following the player through Proximity.Sight. So, if the current distance between the player, and enemy is less than the Proximity.Sight value, then the logic to have the enemy follow the player is executed.

But how does this logic work? We had used the velocity of the player to determine the directional change of the enemy. That works only if we don't care about the specific orientation of the enemy. Instead of checking the direction of the velocity of the player, and changing the direction of the enemy based on that, we will need to simply check if the velocity is non-zero, and adjust the enemy based on the position of the player.‌

If the player is walking down, and the enemy is behind the player, then the enemy should be facing the same direction. Otherwise, if the player is walking up, and the enemy is below the player in the X-Y plane, then the enemy should face the player. We follow the same approach for the left, and right directions.

Orientation

Now that we have somewhat nailed down the orientation of the enemy game object, we must ensure that the enemy is kept at a a fixed distance from the player. We actually don't want Unity's physics system to run, and modify the positions of the colliding game objects (player-to-player, or enemy-to-enemy). This is why we called IsAtMinimumDistanceFrom. If the distance between the player, and the enemy is at, or less than the value defined by Proximity.KeepAway, then the enemy should not move any closer to the player. This will prevent the colliders from trigger between the player, and the enemy, preventing the enemy from "pushing" the player out of the screen (ice skates).‌

How do we prevent the player from pushing away the enemy? Like the game object of the enemy, the player game object, in this case, WalkController will inherit MoveableCharacter. The WalkController will also override the OnObjectCollisionEnter, and OnObjectCollisionExit methods. Here, a collision can be checked, and see if if it is an enemy game object.

If it is an enemy game object, then we can "turn off" the physics calculation by setting the Rigidbody2D's isKinematic property to true, and stopping the movement of the enemy game object by setting its velocity to Vector2.zero. We can clean up by setting the isKinematic property to false when the collision between the player to the enemy game object exits.

protected override void OnObjectCollisionEnter(Collision2D collision)
{
    if (EnemyInitializer.Enemies.Contains(collision.gameObject))
    {
        collision.gameObject.GetComponent<Rigidbody2D>().isKinematic = true;
        collision.gameObject.GetComponent<Rigidbody2D>().velocity = Vector2.zero;
    }
}

protected override void OnObjectCollisionExit(Collision2D collision)
{
    if (EnemyInitializer.Enemies.Contains(collision.gameObject))
    {
        collision.gameObject.GetComponent<Rigidbody2D>().isKinematic = false;
    }
}

Let's see how this works!

Gif

Nice! Okay, now let's move on to implementing attacking player objects when enemy game objects approaches close enough within Proximity.Attack.

Attacking the Player

We'll now modify MonsterCollision to modify logic for the enemy to attack the player when it is in the proximity for attacking. This is done by checking IsWithinAttackOf and if it it is within the proximity, the logic to enter BattleState.Battle is set within OnCollisionEnter2D. ‌

Then, on every FixedUpdate called, melee attack logic is run if the internal Character is within the battle state for this particular game object.


public class MonsterCollision : MonoBehaviour
{
    // ... Hidden

    protected void OnCollisionEnter2D(Collision2D collision)
    {
        var goblinEnemy = EnemyBehavior.Character;

        if (!BattleHelper.IsWithinAttackOf(goblinEnemy, gameObject, PlayerGameObject))
            return;

        EnemyBlinker.Reset();
        PlayerBlinker.Reset();

        DebugHelper.Log("The Goblin is in attack proximity.");

        EnemyBehavior.InBattle = true;

        AttackPeriod = -1;
    }

    protected void OnCollisionExit2D(Collision2D collision)
    {
        EnemyBehavior.InBattle = false;

        AttackPeriod = -1;
    }

    // ...

    protected void FixedUpdate()
    { 
        if (EnemyBehavior.InBattle)
        {
            var character = PlayerGameObject.GetComponent<Cloud>();

            if (AttackPeriod < 0)
            {
                AttackPeriod = (float)RandomDouble.Get(2.0, 4.0);

                var attackAction = EnemyBehavior.GetAttackAction();
                if (attackAction != GoblinCharacter.AttackAction.Miss)
                {
                    Character monsterCharacter = EnemyBehavior.Character;

                    if (attackAction == GoblinCharacter.AttackAction.Melee)
                        character.MeleeAttack(EnemyBehavior.Character);
                    else
                        monsterCharacter.UseTechnique(new TechniqueName("Bite"), GameStore.statusEffectManager, new List<Character> {
                            character.GetCharacter() 
                        });

                    PlayerBlinker.InBlink = true;

                    MenuCacheController.BustCachedMenu("menu-character-stats");
                }
                else
                {
                    DebugHelper.Log($"{EnemyBehavior.name} has missed!");
                }

                DebugHelper.Log("New Attack Period: " + AttackPeriod);
            }

            var clicked = Input.GetMouseButton(0);

            if (clicked && MouseUp && ClickedOnMonster())
            {
                EnemyBlinker.InBlink = true;

                EnemyBehavior.MeleeAttack(character.GetCharacter());

                MouseUp = false;
            }
            else if (!clicked)
                MouseUp = true;

            // Subtract from period
            AttackPeriod -= Time.fixedDeltaTime;
        }
    }
}

Note that the enemy will only attack if it's known to be in the battle state where InBattle is true. Most of the logic here stays the same as with previous demos.‌

Multiple Enemies Within the Scene‌

You may have guessed it right... the best way to first convert our Goblin game object to be a Unity prefab. Then we can create a new class EnemyInitializer to manage instantiation of game objects using the prefab. The example shows instantiation of three objects to the scene within the Start method.

I have also decided to give each of these objects a unique name so that it would be easier to search for specific game objects in the Enemies collection tied to the current scene.

using System;
using System.Collections.Generic;
using Jrpg.Unity.Menus;
using UnityEngine;

public class EnemyInitializer : MonoBehaviour
{
    public GameObject GoblinPrefab;

    public static List<GameObject> Enemies
        = new List<GameObject>();

    protected void Start()
    {
        DebugHelper.Log("Placing Goblins...");

        var gameobjectTransform = gameObject.transform;
        
        var goblin1 = Instantiate(GoblinPrefab, new Vector3(0.5f, 0.5f, gameobjectTransform.position.z), Quaternion.identity);
        goblin1.name = $"Goblin-{(DateTime.Now.Ticks << 4) & 0xffff}";

        var goblin2 = Instantiate(GoblinPrefab, new Vector3(2.1f, 1.2f, gameobjectTransform.position.z), Quaternion.identity);
        goblin2.name = $"Goblin-{(DateTime.Now.Ticks << 4) & 0xffff}";

        var goblin3 = Instantiate(GoblinPrefab, new Vector3(-2.75f, -1.1f, gameobjectTransform.position.z), Quaternion.identity);
        goblin3.name = $"Goblin-{(DateTime.Now.Ticks << 4) & 0xffff}";

        Enemies.Add(goblin1);
        Enemies.Add(goblin2);
        Enemies.Add(goblin3);
    }
}

As long as the EnemyInitializer has some priority in execution, the scene should be able to load the enemies on screen before the player has control over their game object.

MenuCacheController

We will also move the replacer classes for our our debug menu into a built in component in Jrpg.MenuSystem. it could be potentially handy in the future. MenuBuilder reads in JSON files with a structure for the MenuSystem to build menus. Since we don't expect the code to change too much for the game, and it doesn't refer to any game specific logic, we can safely refactor this code out into the Jrpg.Unity component. This refactor is easy as it is just a copy and paste.

Then, refactor MenuController to remove the caching functionality, and create a new class called MenuCacheController in Jrpg.Unity. This will manage all the caching operations for menu dialogs. We will basically maintain a single instance of a MenuStack, and access it through the methods provided by the MenuCacheController class.

using System.Collections.Generic;
using Jrpg.MenuSystem;
using UnityEngine;

namespace Jrpg.Unity.Menus
{
    public class MenuCacheController : MonoBehaviour
    {
        private static readonly string DIALOG_PREFIX = "dialog";

        public static readonly MenuStack Menus = new MenuStack();

        private static readonly Dictionary<string, GameObject> Cache
            = new Dictionary<string, GameObject>();

        public static bool HasActiveMenus
        {
            get
            {
                return Menus.Count() > 0;
            }
        }

        public static string DialogCacheKey(string menuKey)
        {
            return $"{DIALOG_PREFIX}-{menuKey}";
        }

        public static bool MaybeUpdateCache(string menuKey)
        {
            if (Cache.ContainsKey(DialogCacheKey(menuKey)))
                return false;

            // We will need to update as there could be no entry,
            // due to being new, or stale from busting.
            Menu newMenu = MenuBuilder.BuildFromDefinition(menuKey);
            Menus.Replace(menuKey, newMenu);

            Destroy(GameObject.Find(DialogCacheKey(menuKey)));

            return true;
        }

        public static bool BustCachedMenu(string menuKey)
        {
            var builtKey = DialogCacheKey(menuKey);
            var result = false;

            if (Cache.ContainsKey(builtKey))
                result = Cache.Remove(builtKey);

            return result;
        }

        public static string Set(string menuKey, GameObject dialog)
        {
            var key = DialogCacheKey(menuKey);

            Cache.Add(key, dialog);

            return key;
        }
    }

}

Demo‌

Here is a demo of what we have made!

Conclusion‌

Well, that about wraps it up for this demo. I think it's time we go deep in expanding our menu system to fully support dialogs, on-screen graphics, and other cool RPG-like things.

My plan going forward is to start development in a new demo. Let's make an action RPG like Diablo!