Design Docs - Thinking about Characters - Part 5

I spent a bit of time this week thinking about skills and spells for characters, and I think I am confident enough to actually communicate some of the ideas I have to represent the components in my solution. 😄

An important note I would like to make is that I believe skills and spells can be generalized as a single concept called techniques. Wow, yeah, I know it is not earth-shattering, but it is much more marketable than "spells and skills", or "skills and spells", right? So, I'll be using the term techniques going forward.

The main challenge I had when thinking about techniques was in deciding the scope of their functionality.

  • What can techniques do?
  • When can techniques be used? Not all techniques may be appropriate for usage in various states of the game.
  • Can techniques leverage existing components that we have already built to make character management easier?

In thinking of all this, I also wanted to commit to making this area of character management simple architecturally. My preference in designing the components which ultimately forms the technique sub-system was to keep the cognitive load at a minimum. This is essentially "getting the job done" in a minimal sense.

Getting the Code

As usual, you'll find the code discussed here in the jrpg-system GitHub repository! Go here to find it:

https://github.com/urbanspr1nter/jrpg-system

The specific commit which will be discussed in this chapter can be find here: https://github.com/urbanspr1nter/jrpg-system/commit/7da7e62e39d5a61462858615cb01095b6855418b.

If you prefer referencing tags directly, I've created the techs tag which can be found here;.https://github.com/urbanspr1nter/jrpg-system/releases/tag/techs

Anyway...

Techniques

In games like Final Fantasy, techniques such as magic are separated into several categories such as black magic, white magic, red magic, etc.

All white mages

Uhh... probably not the best way to play Final Fantasy for the NES

In my opinion, these aren't really grouped into types which are associated with the techniques, but are logically grouped by the job, or class of the character.

For example, black magic techniques tend to be destructive, and are associated with black mages. While restorative and defensive techniques tend to be associated with white mages. However, nothing is stopping us from having a character which can natively do both white and black magic, while not necessarily having to be a white or black mage. The games Final Fantasy V, and Final Fantasy X are good examples of this realization.

Fira

Scoping out the possibilities of a technique, I believe a technique should only be performed at certain states of the game, and can only alter the character properties in these manners:

Action Valid States Related Components Example
Destructive Battle   Fire, Bio, Flare
Restorative Battle, Map, World Map, Menu Status Effect Cure, Esuna, Regen
Buff Battle Status Effect Shell, Protect
Nerf Battle Status Effect Poison, Blind

Techniques themselves have basic properties attached:

  • Id - string

    • This is the unique ID which will be used as a tag to identify the technique.
  • DisplayName - string

    • This is the name which will be used for display purposes in the game's menu
  • MpCost - int

    • The cost of MP which will be deducted when using this technique
  • AttackPower - int

    • The base attack power of the technique. Used for physical damage calculations.
  • MagicPower - int

    • The base magic power of the technnique. Used for magic damage calculations.

 

To perform some sort of event, or effect when the technique is used, we can define a common interface IPerformable which techniques can implement.

IPerformable.cs
using System;
using System.Collections.Generic;

namespace Jrpg.CharacterSystem.Techniques
{
    public interface IPerformable
    {
        void Perform(Character source, List<Character> targets);
    }
}

The interface method allows a technique to be performed on multiple characters. A quick and basic example I can think of is healing all members in the party during battle, or casting Firaga on a group of enemeies.

Our class architecture can look something like this:

Techniques Architecture

The diagram shows 4 different techniques being implemented: Fire, Fira, Firaga, and Regen. The commonality between them is that these concrete techniques all extend the basic Technique class. There is an association with StatusEffectManager within the Technique class to invoke status effects on characters should the technique require to do so.

Technique.cs
using System;
using System.Collections.Generic;
using Jrpg.CharacterSystem.StatusEffects;

namespace Jrpg.CharacterSystem.Techniques
{
    public abstract class Technique : IPerformable
    {
        protected StatusEffectManager StatusEffectManager;

        public string Id { get; protected set; }
        public string DisplayName { get; protected set; }
        public int MpCost { get; protected set; }
        public int AttackPower { get; protected set; }
        public int MagicPower { get; protected set; }

        public abstract void Perform(Character source, List<Character> targets);

        public Technique(StatusEffectManager statusEffectManager) {
            StatusEffectManager = statusEffectManager;
        }
    }
}

Since we only operate on a single type now, we can create a simple TechniqueFactory class to generate any new instances of these techniques whenever they are called to be used. 😎

To make this more declarative, we define an enum to make referencing a technique easier, TechniqueName.

using System;
namespace Jrpg.CharacterSystem.Techniques
{
    public enum TechniqueName
    {
        Regen,
        Fire,
        Fira,
        Firaga
    }
}

Our basic factory class can now be instantiated with a StatusEffectManager instance, and create Technique type objects when given the TechniqueName.

TechniqueFactory.cs
using System;
using Jrpg.CharacterSystem.StatusEffects;
using Jrpg.CharacterSystem.Techniques.Concrete;

namespace Jrpg.CharacterSystem.Techniques
{
    public class TechniqueFactory
    {
        private StatusEffectManager StatusEffectManager;

        public TechniqueFactory(StatusEffectManager statusEffectManager)
        {
            StatusEffectManager = statusEffectManager;
        }

        public Technique GetTech(TechniqueName name)
        {
            if (name == TechniqueName.Regen)
            {
                return new Regen(StatusEffectManager);
            }
            else if (name == TechniqueName.Fire) {
                return new Fire(StatusEffectManager);
            }
            else if(name == TechniqueName.Fira)
            {
                return new Fira(StatusEffectManager);
            }
            else if (name == TechniqueName.Firaga)
            {
                return new Firaga(StatusEffectManager);
            }
            else
            {
                throw new NotSupportedException("No technique available to instantiate.");
            }
        }
    }
}

As with statuses, we don't want characters operating directly on the technique just yet. So, in the Freelancer job class file, we have a List<TechniqueName> instead to use the Technique.

...
  public List<TechniqueName> TechniqueNames { get; private set; }
  public Freelancer() {
  	...
    TechniqueNames = new List<TechniqueName>();
	}

	...
    
  public void LevelUp() {
    ...
   	if(Statistics[StatisticType.Level].CurrentValue == 2)
    {
      TechniqueNames.Add(TechniqueName.Regen);
      TechniqueNames.Add(TechniqueName.Fire);
      TechniqueNames.Add(TechniqueName.Fira);
      TechniqueNames.Add(TechniqueName.Firaga);
    }
  }

The last bit there is just to ensure that there are existing techniques which can be used upon Freelancer reaching Level 2.

We're not completely there yet! Character will also need a new base method called UseTechnique to invoke these techniques!

public void UseTechnique(TechniqueName name, StatusEffectManager statusEffectManager, List<Character> targets)
{
  if(!currentClass.TechniqueNames.Exists(n => n == name))
  {
    return;
  }

  var technique = new TechniqueFactory(statusEffectManager).GetTech(name);

  technique.Perform(this, targets);
}

Implementing a Technique

So what do basic techniques actually look like? What is the typical implementation? An interesting example, I would like to show is Regen. With the Regen technique, we'll, have a set of requirements.

  • The character with the Regen status will have 2 HP applied to them at each turn of the game loop.
  • This effect in will only work in Battle
  • This effect will only last 3 turns of the game loop. (Basically, 3 turns in battle)

So, we'll have to make a change in the Regen status effect!

using System;
using Jrpg.GameState;
namespace Jrpg.CharacterSystem.StatusEffects.Effects
{
     class Regen : StatusEffect
    {
        private int turns;

        public override void OnApply(Character character, GameStateValue state)
        {
            turns = 0;
        }

        public override void PerformEffect(Character character, GameStateValue state)
        {
            if (state != GameStateValue.Battle)
            {
                return;
            }

            character.Statistics[StatisticType.HpCurrent].CurrentValue += 2;

            turns++;
        }

        public override bool ShouldDestroy(Character character, GameStateValue state)
        {
            if(state == GameStateValue.Battle && turns < 3)
            {
                return false;
            }

            return true;
        }

        public override StatusEffectType GetStatusEffectType()
        {
            return StatusEffectType.Regen;
        }
    }
}

Now, here is the implementation of the technique itself.

Regen.cs
using System;
using System.Collections.Generic;
using Jrpg.CharacterSystem.StatusEffects;

namespace Jrpg.CharacterSystem.Techniques.Concrete
{
    public class Regen : Technique
    {
        public Regen(StatusEffectManager statusEffectManager) : base(statusEffectManager)
        {
            Id = "Tech_Regen";
            DisplayName = "Regen";
            MpCost = 18;
            AttackPower = 0;
            MagicPower = 0;
        }

        public override void Perform(Character source, List<Character> targets)
        {
            foreach(var target in targets)
            {
                StatusEffectManager.ApplyEffect(target, StatusEffectType.Regen);
            }

            source.Statistics[StatisticType.MpCurrent].CurrentValue -= MpCost;
        }
    }
}

Right now, the technique attributes are hard-coded, but those will be addressed down the line upon introduction to proper character job classes.

The implemented Perform method here will take the list of targets and apply the Regen status on each character. Right now it isn't intelligent enough to vary the amount of HP accrued in each turn, but that can be a later enhancement.

The last thing we have to remember is that techniques aren't free! We have to subtract the MpCost of the technique from the caster's current MP pool. 😄

Unit Tests

Let's test some of our concepts even further by writing some new unit tests to test techniques. First, let's make a modification to MockGameLoop in our test project to clean up status effects immediately after a turn.

public void Step() {
  StatusEffectManager.BeforeEffects();
  StatusEffectManager.PerformEffects();
  StatusEffectManager.AfterEffects();
  StatusEffectManager.CleanUp();
}

Now, let's create a new test file, TestTechniques.cs to test each type of technique: Fire, Fira, Firaga, and Regen.

The character of choice today is Cloud from Final Fantasy VII!

Cloud

I chose to use Cloud today because I am actually excited for the release of Final Fantasy VII Remake for the PlayStation 4. I actually don't have a PS4, yet, but would like one in the future. I just barely convinced my wife that we should get a Nintendo Switch, so I'll have to wait for her to cool down first because I can cast another spell of persuasion.

Alright, the first test here is Regen. Each test case will perform the following setup pattern:

  • Create the party
  • Create the Cloud character, and add him to the party.
  • Set Cloud as the active character
  • Level Cloud up to 7 by adding 100 experience points.

With TestRegenTechnique, we will first transition the GameState to Battle and cast the Regen technique. Then we'll step through the battle sequence a few times.

Each time we will assert to see if Cloud has gained 2 HP from the previous step. At some point (3 turns), the Regen effect should automatically wear off, and no HP is gained from subsequent turns.

[Fact]
public void TestRegenTechnique()
{
  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");

  cloud.AddExperience(100);

  gameLoop.SetGameState(GameStateValue.Battle);
  Assert.Equal(30, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);
  cloud.UseTechnique(
    TechniqueName.Regen, 
    gameLoop.StatusEffectManager, 
    new List<Character> { party.GetActiveCharacter() }
  );
  Assert.True(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
              .Exists(effect => effect == StatusEffectType.Regen));

  gameLoop.Step();
  Assert.True(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
              .Exists(effect => effect == StatusEffectType.Regen));
  Assert.Equal(32, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);

  gameLoop.Step();
  Assert.True(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
              .Exists(effect => effect == StatusEffectType.Regen));
  Assert.Equal(34, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);

  gameLoop.Step();
  Assert.False(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
               .Exists(effect => effect == StatusEffectType.Regen));
  Assert.Equal(36, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);

  gameLoop.Step();
  Assert.False(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
               .Exists(effect => effect == StatusEffectType.Regen));
  Assert.Equal(36, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);

  gameLoop.Step();
  Assert.False(gameLoop.StatusEffectManager.StatusEffectTypes(cloud)
               .Exists(effect => effect == StatusEffectType.Regen));
  Assert.Equal(36, cloud.Statistics[StatisticType.HpCurrent].CurrentValue);
}

A pretty straightforward test!

Fire, Fira, and Firaga are all similar, so I'll just be going over Fire here.

[Fact]
public void TestFireTechnique()
{
  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");
  cloud.AddExperience(100);

  var goblin = new Character("Goblin");
  goblin.AddExperience(50);
  goblin.Statistics[StatisticType.HpCurrent].CurrentValue = 	
    goblin.Statistics[StatisticType.HpMax].CurrentValue;

  Assert.Equal(goblin.Statistics[StatisticType.HpMax].CurrentValue,
               goblin.Statistics[StatisticType.HpCurrent].CurrentValue);

  var enemies = new List<Character> { goblin };

  gameLoop.SetGameState(GameStateValue.Battle);

  cloud.UseTechnique(TechniqueName.Fire, gameLoop.StatusEffectManager, enemies);

  var damage = Math.Abs(goblin.Statistics[StatisticType.HpMax].CurrentValue -
                        goblin.Statistics[StatisticType.HpCurrent].CurrentValue);

  Console.WriteLine("Fire did " + damage + " damage");

  Assert.InRange(damage, 0, 20);
}

You will begin to notice from above that we are starting to develop a little primitive battle system with our character management system. 😄

We create a Goblin enemy which then has Fire casted by Cloud onto it in 1 turn. The assertion here is that some damage must have been taken by the Goblin after the Fire attack.

We do the same for both Fira, and Firaga.

Conclusion

This chapter was shorter in that I wanted to get myself familiarized with techniques and communicate the basics of the subsystem before going to developing the bigger part of the character management system... character job classes! But what has been interesting in this chapter is that you get to see that characters don't just have to be "playable" characters in your party.

In fact, they can also be enemies, just like the Goblin in our unit test. The use of the Character type has also been used for NPCs in our item key-lock example with the thirsty guard.

So far the multiple uses of the Character type has told us that this design has worked so far!

Until next time!