Design Docs - Thinking about Characters - Part 7

Merry Christmas! I'm writing this post a lot earlier than usual because first, I would like to avoid the Part 6 situation in having to be very considerate about the presentation of content as a consequence in trying to cover too much.

Santa Dog lol

Second, I have to do this thing called "work-life balance" where I should be seeing family and friends over the holidays. Because, it is the responsible thing to do. Okay I'll be less sarcastic, and serious.

Everyone should try to have a healthy balance of work, life, and play.

Work and play are usually easy for me, but keeping up with life tends to be more of a challenge. I have had issues keeping up with friends, and the interesting people I meet in my life over the years. It sucks because these are chances where I get to hear about their interesting stories that happen in their life. If you want me to put it in a slightly more motivating manner -- it is ripe JRPG story material. See: Write Who You Know.

Mechanical keyboard

I also got this new wireless mechanical keyboard. It's pretty cool. It's a K2 Keychron wired/wireless keyboard with brown switches. I really like the color scheme, and the feel of the key travel when I type. Nothing beats an IBM Model M, though. I do have a few (3) laying around, but I'm not using one of my IBM Model M keyboards because of the bulkiness. Okay, I lie, I do have one tucked under my desk for my Win98 retro-gaming PC.

My wife has a wireless mechanical keyboard and one of those pretty/minimalist setups you see in pictures of people showing off their set ups. Seeing her work productively in such a clean desk has made me a bit jealous. So I've been copying her, but that isn't the only reason.

Another reason why I have been trying to keep my work space cleaner lately as I would prefer to have a comfortable workspace to pull out notebooks and write. After all, it's what keeps this series alive. These posts all start out with the classic model of paper, and pen.

I've actually went from having 2 monitors to just a single 4K (Dell Ultrasharp) screen directly in front of me. My older Windows 98 desktop is on the same desk along with a now unused Xeon workstation right under me which I had used more when I was working at Microsoft. (I used to do a lot of number crunching, and benchmarking for POC software.)

Anyway, I'll slowly put together my awesome workspace, and will probably give an overview and picture when I can!

Disciplines

One feedback that has been given to me by Scott is that Discipline would probably be the more appropriate word for describing character job classes within the context of the project we are working on together.

However, I do feel like discipline as a term to describe character job classes in general for JRPGs, is pretty neat. It is short, memorable, and marketable. Therefore, I'll start to use discipline and character job classes interchangeably.

As I get used to referring character job classes as discipline, you'll start to see the term be used entirely. Until then, we'll just have to get used to using it --- so forgive me if I switch between the use of discipline, and character job classes. 😄

Switching Disciplines and Maintaining Character State

Part 6 left us off in a state where we had basic disciplines working. We were able to allow characters to be either a White Mage, or a Black Mage in our tests. However, there were a few things that were incomplete.

  1. Switching between disciplines would force the character to lose their current HP, MP, Level, and accrued Experience. For example, going from a Level 13 White Mage to a Black Mage results in a character falling back to Level 1, with the starting stats of a Black Mage! Not worth switching!
  2. A discipline switch also required the developer to manually re-load techniques associated with the discipline. This is very incovenient in the perspective of developer experience.
  1. We had no real tests that could excite us when demonstrating how a discipline can make a character's behavior in battle distinctly different across disciplines which they hold.

The aim for this post is to address them here, and provide a better experience when it comes to managing disciplines.

Getting the Code

As usual, you can find all code within the jrpg-system code repository in GitHub.

Or, you can directly go to the specific commit discussed in this post here:

https://github.com/urbanspr1nter/jrpg-system/commit/a38d53303fcaa19ab21083bc12744b3f3fc49429#diff-24940a74082b867daae0293c8a3ce27

BaseScaler and Scalers

Remember that each discipline contains a set of Scaler objects which determines the next value and growth rate a particular statistic. For the Freelancer, the scalers were linear which grew only by an offset of 1 after every level.

Each Scaler had this snippet of code to determine the next value of a specific statistic:

public virtual Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic)
{
  var offset = 1;
  var copy = Copy(statistic[type]);

  copy.CurrentValue += offset;

  return copy;
}

The implemented examples of the Black and White Mages also lazily used this approach.

In order to make these disciplines more interesting, we have to allow these disciplines to decorate the character with a different set of statistics, and growth rates to make them distinctively different from another discipline a character can take on.

This can be easily solved by implementing different types of scalers specifically tied to the discipline.

  • A Black Mage will possess statistic scalers which favor offensive, and speed-related stats.

    • Magic attack, Speed, Evasion, Magic Evasion
  • A White Mage will possess statistic scalers which favor defensive, and stamina-related stats.

    • Stamina, Defense, Magic Defense

The above is not a general law when it comes to Black and White mages in JRPGs, but I have decided on it simply because it is an easy way to get the point across for what I am about to walk through. 😄 Every game does it differently, and so point noted.

Notice that every single scaler implements a IStatisticScaler which has the following two methods:

  • public Statistic Copy(Statistic toCopy)
  • public Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic)

We can actually make it easier to create these Scaler type objects by defining a base class BaseScaler which will hold a default implementation. Then, every Scaler can override either, or both of the methods. This provides a slightly quicker, and more convenient experience in building new disciplines.

BaseScaler.cs
using System;
using System.Collections.Generic;

namespace Jrpg.CharacterSystem.Scalers
{
    public abstract class BaseScaler : IStatisticScaler
    {
        protected StatisticType type;

        public virtual Statistic Copy(Statistic toCopy)
        {
            var statistic = new Statistic(type, toCopy.MaxValue);
            statistic.CurrentValue = toCopy.CurrentValue;

            return statistic;
        }

        public virtual Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic)
        {
            var offset = 1;
            var copy = Copy(statistic[type]);

            copy.CurrentValue += offset;

            return copy;
        }
    }
}

To quickly show an implemented example, here is what a EvasionScaler looks like for a Black Mage:

using System;
using Jrpg.CharacterSystem.Scalers;
using Jrpg.CharacterSystem;
using System.Collections.Generic;

namespace Jrpg.SampleGame.Characters.Scalers.BlackMage
{
    public class EvasionScaler : BaseScaler
    {
        public EvasionScaler()
        {
            type = StatisticType.Evasion;
        }

        public override Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic)
        {
            var offset = new Random().Next(2, 4);

            var copy = Copy(statistic[type]);

            copy.CurrentValue += offset;

            return copy;
        }
    }
}

Note that we just have to do 2 things:

  1. Assign the StatisticType within the constructor.
  2. Provide an overridden implementation for NewStatistic with the appropriate logic for that particular stat.

We can see that our Black Mage has Evasion that can grow either 2, or 3 points after each level. Let's contrast this to the Evasion stat of the White Mage:

using System;
using Jrpg.CharacterSystem.Scalers;
using Jrpg.CharacterSystem;
using System.Collections.Generic;

namespace Jrpg.SampleGame.Characters.Scalers.WhiteMage
{
    public class EvasionScaler : BaseScaler
    {
        public EvasionScaler()
        {
            type = StatisticType.Evasion;
        }
    }
}

Whooo! Looks pretty simple right? It's because for the White Mage, we can just use the default implementation of NewStatistic found in the base class! In a real game, it would probably be in our best interest to provide meaningful logic.

Now, we'll just need to update the custom BlackMage and WhiteMage implementations to initialize their custom scalers and override methods that will use them.

For example in the constructor of the BlackMage:

        private IStatisticScaler hpScaler;
        private IStatisticScaler mpScaler;
        private IStatisticScaler strengthScaler;
        private IStatisticScaler speedScaler;
        private IStatisticScaler staminaScaler;
        private IStatisticScaler magicScaler;
        private IStatisticScaler attackScaler;
        private IStatisticScaler defenseScaler;
        private IStatisticScaler evasionScaler;
        private IStatisticScaler magicDefenseScaler;
        private IStatisticScaler magicEvasionScaler;    
....    
		{
            hpScaler = new HpScaler();
            mpScaler = new MpScaler();
            strengthScaler = new StrengthScaler();
            speedScaler = new SpeedScaler();
            staminaScaler = new StaminaScaler();
            magicScaler = new MagicScaler();
            attackScaler = new AttackScaler();
            defenseScaler = new DefenseScaler();
            evasionScaler = new EvasionScaler();
            magicDefenseScaler = new MagicDefenseScaler();
            magicEvasionScaler = new MagicEvasionScaler();
        }

....
  
        public override Statistic NextEvasion()
        {
            return evasionScaler.NewStatistic(Statistics);
        }

        public override Statistic NextMagicDefense()
        {
            return magicDefenseScaler.NewStatistic(Statistics);
        }
...

Of course then, the same is done across all custom implementations of BaseCharacterClass.

Maintaining Character "Achievements" After a Discipline Switch

Sphere Grid

Would totally suck if you lost all this progress, huh?

 

As noted previously, the biggest flaw about switching discipline was that a switch caused the character to lose the current experience accrued along with their current HP, MP, and Level.

We can easily fix this by adding more logic within Character::ChangeClass.

All that is needed to be done is to:

  • Maintain the current HP, MP, Level, and Experience
  • Switch the discipline.
  • Level up the character until the level is the same as the previous level before the swtich
  • Reassign the current experience to be the value before the switch
  • And finally, if the previous HP or MP was higher before the switch, then assign the current HP and MP to be the maximum for that current discipline and level.

The logic is implemented like this:

public void ChangeClass(BaseCharacterClass jobClass)
{
  var currentLevel = currentClass.Statistics[StatisticType.Level].CurrentValue;
  var currentExperience = currentClass.Statistics[StatisticType.Experience].CurrentValue;
  var currentHp = currentClass.Statistics[StatisticType.HpCurrent].CurrentValue;
  var currentMp = currentClass.Statistics[StatisticType.MpCurrent].CurrentValue;

  currentClass = jobClass;

  for (var i = 1; i  < currentLevel; i++)
  {
  	currentClass.LevelUp();
  }

  if(currentHp > currentClass.Statistics[StatisticType.HpMax].CurrentValue)
  {
    currentClass.Statistics[StatisticType.HpCurrent].CurrentValue =
      currentClass.Statistics[StatisticType.HpMax].CurrentValue;
  }

  if(currentMp > currentClass.Statistics[StatisticType.MpMax].CurrentValue)
  {
    currentClass.Statistics[StatisticType.MpCurrent].CurrentValue =
    	currentClass.Statistics[StatisticType.MpMax].CurrentValue;
  }

  currentClass.Statistics[StatisticType.HpCurrent].CurrentValue = currentHp;
  currentClass.Statistics[StatisticType.MpCurrent].CurrentValue = currentMp;
  currentClass.Statistics[StatisticType.Experience].CurrentValue = currentExperience;
}

I am aware this could probably impact performance when a character switches discipline. For example, what if the character is switching at Level 200? We would have to level up 200 times in order to achieve parity. I am aware this can be improved, but it is the easiest implementation right now to keep moving along on our adventure. ⏳

Techniques

Techniques are declaratively defined against the class in a JSON file like this:

{
  "Name": "Black Mage",
  "Agent": "Jrpg.SampleGame.Characters.JobClasses.BlackMage, Jrpg.SampleGame",
  "Techniques": [
    {
      "Level": 1,
      "Name": "Fire"
    },
    {
      "Level": 10,
      "Name": "Fira"
    },
    {
      "Level": 30,
      "Name": "Firaga"
    }
  ],
  "StartingStatistics": []
}

Then there is no reason why we are doing this now:

var TechniqueDefinitions = new TechniqueFactory(StatusEffectManager)
	.FromJsonDefinition(File.ReadAllText("Resources/Techniques.json"));

BlackMage = (BlackMage)ClassManager.GetCharacterClassInstance("Black Mage");	
BlackMage.TechniqueDefinitions.Add(TechniqueDefinitions["Fire"]);	
BlackMage.TechniqueDefinitions.Add(TechniqueDefinitions["Fira"]);	
BlackMage.TechniqueDefinitions.Add(TechniqueDefinitions["Firaga"]);

Instead, we can make things easier by expecting to have all techniques loaded by using the information found in the definition file. Then, we can create a discipline by simply initializing it:

BlackMage = (BlackMage)ClassManager.GetCharacterClassInstance("Black Mage");

We'll need to change how the BaseCharacterClass itself is initialized. It currently takes in a single parameter, Dictionary<StatisticType, Statistic> to load in the default starting set of statistics for the specific discipline. However, now we will need to add two additional parameters:

  1. The list of technique definitions to register against the discipline (List<TechniqueDefinition>)
  2. The list of technique definition mappings to associate a technique definition by name against the level value appropriate which the discipine can use.

The top of the BaseCharacterClass then looks like:

public Dictionary<StatisticType, Statistic> Statistics { get; set; }
public List<ClassTechniqueDefinition> TechniqueDefinitionMapping { get; }
public List<TechniqueDefinition> TechniqueDefinitions { get; private set; }

public BaseCharacterClass(Dictionary<StatisticType, Statistic> statistics,
  List<TechniqueDefinition> techniqueDefinitions,
  List<ClassTechniqueDefinition> techniqueDefinitionMapping)
{
  Statistics = statistics;
  TechniqueDefinitions = techniqueDefinitions;
  TechniqueDefinitionMapping = techniqueDefinitionMapping;
}

Then in ClassManager, we can make use of this new information by modifying the class to maintain the the entire registry of available TechniqueDefinition found within the entire game.

public ClassManager(Dictionary<string, TechniqueDefinition> techDefs)
{
	registered = new Dictionary<string, ClassDefinition>();
	techniqueDefinitions = techDefs;
}

The next modification in ClassManager involves modifying the GetCharacterClassInstance() factory method to intiailize the character class instance with the two new additional parameters involving techniques.

public BaseCharacterClass GetCharacterClassInstance(string characterClassTag)
{
  if(!registered.ContainsKey(characterClassTag))
  {
    throw new KeyNotFoundException(
      $"Couldn't find the character class type with key {characterClassTag}."
    );
  }

  var classDefinition = registered[characterClassTag];
  var jobClassAgent = classDefinition.Agent;
  var jobClassStartingStatistics = new Dictionary<StatisticType, Statistic>();

  foreach(var classStatistic in classDefinition.StartingStatistics)
  {
    var statisticType = CommonUtils.ToStatisticType(classStatistic.Name);
    var statistic = CommonUtils.ToStatistic(classStatistic);

    jobClassStartingStatistics.Add(statisticType, statistic);
  }

  List<ClassTechniqueDefinition> techniqueDefinitionMappings = classDefinition.Techniques;
  List<TechniqueDefinition> techniqueDefinitionsForClass = new List<TechniqueDefinition>();

  foreach(var classTechniqueDefinition in techniqueDefinitionMappings)
  {
  	techniqueDefinitionsForClass.Add(techniqueDefinitions[classTechniqueDefinition.Name]);
  }

  var jobClassInstance = (BaseCharacterClass)Activator
  	.CreateInstance(
  		Type.GetType(jobClassAgent),
  		new object[] { 
  			jobClassStartingStatistics, 
  			techniqueDefinitionsForClass, 
  			techniqueDefinitionMappings 
      }
  );

  return jobClassInstance;
}

We are not totally done yet. We will have to modify the constructor signatures of the custom discplines. Shown below is an example for White Mage. Notice the call to the base class constructor.

        public WhiteMage(Dictionary<StatisticType, Statistic> statistics,
            List<TechniqueDefinition> techniqueDefinitions,
            List<ClassTechniqueDefinition> techniqueDefinitionMapping)
            : base(statistics, techniqueDefinitions, techniqueDefinitionMapping)

The last thing to remember is to provide a means to validate whether, or not the character is allowed to use a specific technique relating to their discipline. This handles the case where a lower level character is prevented from using a higher level technique.

We can provide a helper method in the Character class for convenience. Here is Character::CanUseTechnique.

public bool CanUseTechnique(string techniqueName)
{
  var techniqueDef = currentClass.TechniqueDefinitionMapping
  	.Find(def => def.Name.Equals(techniqueName));

  if (techniqueDef == null || 
  		techniqueDef.Level > Statistics[StatisticType.Level].CurrentValue)
  	return false;
  
  return true;
}

Unit Tests

We will now add two new tests to the TestCharacterClass.cs file to demonstrate our changes.

  1. TestSwitchingCharacterClassesWithStatCheck - This test will verify that switching disciplines will yield the correct statistics. That is, a character should have favorability of a set of stats in a certain discipline as opposed to another. In this test we will switch from Freelancer -> White Mage -> Black Mage and compare thes stats against each other.

    1. We will also check to see if the current HP, MP, Level and Experience are maintained across discipline switches.
  2. TestEnsureTechniquesAreAppropriateForClass - This test verifies that switching discplines will yield the correct set of techniques available for use. Not only the availability is tested, but the case of whether or not, the character is qualified to use the technique found within the discipline due to being at the appropriate level.

TestSwitchingCharacterClassesWithStatCheck
[Fact]
public void TestSwitchingClassesWithStatCheck()
{
  InventoryManager inventoryManager = new InventoryManager();
  Party party = new Party(inventoryManager);

  gameLoop = new MockGameLoop();

  party.AddMember("Cloud", new Character("Cloud"));
  party.SetActiveCharacter("Cloud");

  var cloud = party.GetMember("Cloud");

  Assert.Equal("Freelancer", cloud.CurrentClassName());

  while(cloud.Statistics[StatisticType.Level].CurrentValue < 11)
  {
  	cloud.AddExperience(100);
  }

  PrintStatistics(cloud);

  var flLevel = cloud.Statistics[StatisticType.Level].CurrentValue;
  var flExperience = cloud.Statistics[StatisticType.Experience].CurrentValue;
  var flCurrentHp = cloud.Statistics[StatisticType.HpCurrent].CurrentValue;
  var flCurrentMp = cloud.Statistics[StatisticType.MpCurrent].CurrentValue;

  cloud.ChangeClass(gameLoop.WhiteMage);

  var wmSpeed = cloud.Statistics[StatisticType.Speed].CurrentValue;
  var wmStamina = cloud.Statistics[StatisticType.Stamina].CurrentValue;
  var wmMagic = cloud.Statistics[StatisticType.Magic].CurrentValue;
  var wmDefense = cloud.Statistics[StatisticType.Defense].CurrentValue;
  var wmEvasion = cloud.Statistics[StatisticType.Evasion].CurrentValue;
  var wmMagicDefense = cloud.Statistics[StatisticType.MagicDefense].CurrentValue;
  var wmMagicEvasion = cloud.Statistics[StatisticType.MagicEvasion].CurrentValue;
  var wmLevel = cloud.Statistics[StatisticType.Level].CurrentValue;
  var wmExperience = cloud.Statistics[StatisticType.Experience].CurrentValue;
  var wmCurrenHp = cloud.Statistics[StatisticType.HpCurrent].CurrentValue;
  var wmCurrentMp = cloud.Statistics[StatisticType.MpCurrent].CurrentValue;

  PrintStatistics(cloud);

  cloud.ChangeClass(gameLoop.BlackMage);

  var bmSpeed = cloud.Statistics[StatisticType.Speed].CurrentValue;
  var bmStamina = cloud.Statistics[StatisticType.Stamina].CurrentValue;
  var bmMagic = cloud.Statistics[StatisticType.Magic].CurrentValue;
  var bmDefense = cloud.Statistics[StatisticType.Defense].CurrentValue;
  var bmEvasion = cloud.Statistics[StatisticType.Evasion].CurrentValue;
  var bmMagicDefense = cloud.Statistics[StatisticType.MagicDefense].CurrentValue;
  var bmMagicEvasion = cloud.Statistics[StatisticType.MagicEvasion].CurrentValue;
  var bmLevel = cloud.Statistics[StatisticType.Level].CurrentValue;
  var bmExperience = cloud.Statistics[StatisticType.Experience].CurrentValue;
  var bmCurrentHp = cloud.Statistics[StatisticType.HpCurrent].CurrentValue;
  var bmCurrentMp = cloud.Statistics[StatisticType.MpCurrent].CurrentValue;

  PrintStatistics(cloud);

  Assert.Equal(flLevel, wmLevel);
  Assert.Equal(flLevel, bmLevel);
  Assert.Equal(flCurrentHp, wmCurrenHp);
  Assert.Equal(flCurrentHp, bmCurrentHp);
  Assert.Equal(flCurrentMp, wmCurrentMp);
  Assert.Equal(flCurrentMp, bmCurrentMp);
  Assert.Equal(flExperience, wmExperience);
  Assert.Equal(flExperience, bmExperience);

  // White Mages should have higher defensive/stamina stats
  // Black Mages should have higher offensive/sped stats
  Assert.True(wmStamina > bmStamina);
  Assert.True(wmDefense > bmDefense);
  Assert.True(wmMagicDefense > bmMagicDefense);
  Assert.True(wmMagic < bmMagic);
  Assert.True(wmSpeed < bmSpeed);
  Assert.True(wmEvasion < bmEvasion);
  Assert.True(wmMagicEvasion < bmMagicEvasion);
}

Definitely a large test here, but don't be intimidated! Most of it is just more or less variable assignments to help make understanding the test easier.

First, we create our favorite character, Cloud, and have him start out as a Freelancer. Then we just continuously add experience points until he is at Level 11.

We'll then save the current Level, Experience, HP and MP to be checked later.

After that, we switch the discipline for Cloud to be a White Mage, and save those values. Then finally, we switch to Black Mage and save those values.

The test assertion then involves checking against the values saved from when Cloud was a Freelancer to the values found when Cloud was a White, and Black Mage. The expectation here is that regardless of the discipline, the saved Level, Experience, HP, and MP from each discipline should all be equal to each other.

The next set of checks involve seeing whether or not as a White Mage, Cloud had higher defensive/stamina-related stats. We should expect then:

  • Stamina as a White Mage should be higher than when as a Black Mage
  • Defense as a White Mage should be higher than when as a Black Mage
  • Magic Defense as a White Mage should be higher than when as Black Mage

For the Black Mage

  • Magic as a Black Mage should be higher than when as a White Mage
  • Speed as a Black Mage should be higher than when as a White Mage
  • Evasion as a Black Mage should be higher than when as a White Mage
  • Magic Evasion as a Black Mage should be higher than when as a White Mage
TestEnsureTechniquesAreAppropriateForClass
[Fact]
public void TestEnsureTechniquesAreAppropriateForClass()
{
  InventoryManager inventoryManager = new InventoryManager();
  Party party = new Party(inventoryManager);

  gameLoop = new MockGameLoop();

  party.AddMember("Cloud", new Character("Cloud"));
  party.SetActiveCharacter("Cloud");

  var cloud = party.GetMember("Cloud");
  while (cloud.Statistics[StatisticType.Level].CurrentValue < 11)
  {
  	cloud.AddExperience(100);
  }

  cloud.ChangeClass(gameLoop.WhiteMage);
  Assert.True(cloud.CanUseTechnique("Regen"));
  Assert.False(cloud.CanUseTechnique("Fire"));
  Assert.False(cloud.CanUseTechnique("Fira"));
  Assert.False(cloud.CanUseTechnique("Firaga"));


  cloud.ChangeClass(gameLoop.BlackMage);
  Assert.False(cloud.CanUseTechnique("Regen"));
  Assert.True(cloud.CanUseTechnique("Fire"));
  Assert.True(cloud.CanUseTechnique("Fira"));
  Assert.False(cloud.CanUseTechnique("Firaga"));

  cloud.AddExperience(100000);
  Assert.True(cloud.CanUseTechnique("Firaga"));
}

A smaller test here, and probably much easier to digest. Basically, we test to see if Cloud is able to use the techniques assigned to the discipline. We also take care in checking against to see if Cloud is qualified enough to use higher level techniques.

Most notably, when as a Black Mage, at Level 11, Cloud should not be allowed to use Firaga. It is until we level up past the minimum required level that Cloud learns Firaga.

Conclusion

Alright! I hope that turned out to be a more digestable overview of the set of changes I have made to the character system. Next, we'll be taking a look at NPCs, and Enemies. We're getting closer to interacting with characters through dialog, and battle. Hint, hint.