D20 RPG – Combat Flow

In this lesson we will rough out the whole combat flow, including its sub flows, adding some new systems along the way.

Overview

The flow of combat is relatively complex, but can be broken down into multiple sub-flows. The goal of the combat flow is to return a combat result – in other words to say who won. Combat itself plays out in a series of rounds, where each round every combatant who can take a turn should take a turn. The turn itself can be handled by another flow, though the way the flow is handled would be different depending on if the current entity is controlled by the user or by the computer. Finally a turn is played out as one or more actions that the Entity can perform.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

Entity Set System

So far, many of our Entity component systems were based on a Dictionary, which mapped from an Entity to the particular bit of data that was relevant to it. I want another system that is merely a “Set” – so either the Entity is included in it or not.

Create a new C# script at Assets -> Scripts -> Component named EntitySetSystem and add the following:

public interface IEntitySetSystem
{
    CoreSet<Entity> Table { get; }

    void Add(Entity entity);
    bool Contains(Entity entity);
    void Remove(Entity entity);
}

public abstract class EntitySetSystem : IEntitySetSystem
{
    public abstract CoreSet<Entity> Table { get; }

    public virtual void Add(Entity entity)
    {
        Table.Add(entity);
    }

    public virtual bool Contains(Entity entity)
    {
        return Table.Contains(entity);
    }

    public virtual void Remove(Entity entity)
    {
        Table.Remove(entity);
    }
}

This system is pretty simple. It defines an interface and system that basically just wrap the methods of a Set that I would care about, such as the ability to add and remove an Entity from the collection, or to simply check if it is Contained in the table or not.

Combatant System

A combat round will generally consist of looping over each Entity which is considered a combatant, and giving them a turn. We will need a general way to flag which Entity’s should be considered for a turn, so we will make a new system to flag them. Create a new C# script at Assets -> Scripts -> Component named CombatantSystem and add the following:

public partial class Data
{
    public CoreSet<Entity> combatant = new CoreSet<Entity>();
}

public interface ICombatantSystem : IDependency<ICombatantSystem>, IEntitySetSystem
{

}

public class CombatantSystem : EntitySetSystem, ICombatantSystem
{
    public override CoreSet<Entity> Table => IDataSystem.Resolve().Data.combatant;
}

public partial struct Entity
{
    public bool IsCombatant
    {
        get { return ICombatantSystem.Resolve().Contains(this); }
        set
        {
            if (value)
                ICombatantSystem.Resolve().Add(this);
            else
                ICombatantSystem.Resolve().Remove(this);
        }
    }
}

This looks pretty similar to other systems we’ve done, such as the name system. The biggest difference is that this new system is a subclass of our EntitySetSystem whereas the other systems were subclasses of EntityTableSystem. A side effect of this appears in the way the Entity extension is handled. With the dictionary based systems, the extension would be based on the same data type as the value of the dictionary. With our set based system, we will always treat the extension as a bool – it is just a flag representing whether or not the Entity is included in the set. You can assign to that property, and it will automatically add or remove an entity from the system’s set as needed.

Combatant Provider

We will want a way to flag an Entity as being a combatant. For now we can do this using an attribute provider on the various hero and monster recipe objects. Create a new C# script at Assets -> Scripts -> AttributeProvider named CombatantProvider and add the following:

using UnityEngine;

public class CombatantProvider : MonoBehaviour, IAttributeProvider
{
    public void Setup(Entity entity)
    {
        entity.IsCombatant = true;
    }
}

Party System

The flow of a turn will depend on whether the Entity whose turn it is will be controlled by the human player or by the computer opponent. Therefore I will want another system to track this new type of data. Create a new C# script at Assets -> Scripts -> Component named PartySystem and add the following:

public enum Party
{
    None,
    Hero,
    Monster
}

public partial class Data
{
    public CoreDictionary<Entity, Party> party = new CoreDictionary<Entity, Party>();
}

public interface IPartySystem : IDependency<IPartySystem>, IEntityTableSystem<Party>
{

}

public class PartySystem : EntityTableSystem<Party>, IPartySystem
{
    public override CoreDictionary<Entity, Party> Table => IDataSystem.Resolve().Data.party;
}

public partial struct Entity
{
    public Party Party
    {
        get { return IPartySystem.Resolve().Get(this); }
        set { IPartySystem.Resolve().Set(this, value); }
    }
}

We created a new enum named Party. The default value is None, but there are also cases to support both the Hero and Monster party.

Party Provider

Naturally we will want a way to assign a party to each of the units in our battle. For now we can do this using an attribute provider on the various hero and monster recipe objects. Create a new C# script at Assets -> Scripts -> AttributeProvider named PartyProvider and add the following:

using UnityEngine;

public class PartyProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] Party party;

    public void Setup(Entity entity)
    {
        entity.Party = party;
    }
}

Recipe Additions

Let’s go ahead and update the recipe’s for our hero and monster. Start by selecting the “Hero” recipe asset. Then add the CombatantProvider. Next add the PartyProvider component and make sure to select the “Hero” party.

Select the “Rat” recipe asset. Add the CombatantProvider. Next add the PartyProvider component and make sure to select the “Monster” party.

Component Injector

Open the ComponentInjector script and add the following to its Inject method:

ICombatantSystem.Register(new CombatantSystem());
IPartySystem.Register(new PartySystem());

Round System

Now we will add a simple system that handles the concept of a “Round”. This means I will have a system that keeps track of a list of entities that need to be able to take a turn. Create a new C# script at Assets -> Scripts -> Combat named RoundSystem and add the following:

using System.Collections.Generic;

public interface IRoundSystem : IDependency<IRoundSystem>
{
    bool IsComplete { get; }

    void Begin(List<Entity> entities);
    Entity Next();
}

public class RoundSystem : IRoundSystem
{
    List<Entity> turnOrder;

    public bool IsComplete { get { return turnOrder.Count == 0; } }

    public void Begin(List<Entity> entities)
    {
        this.turnOrder = entities;
    }

    public Entity Next()
    {
        var result = turnOrder[0];
        turnOrder.RemoveAt(0);
        return result;
    }
}

I have provided an interface for our system. It has a property named IsComplete that will let us know when each of the entities have taken their turn. I provided a method called Begin that will mark the start of a new round. The list of entities that are passed as a parameter should already be sorted in the order that their turns should come. Finally, I have a method named Next which will provide the Entity next in line to take a turn. There is no error handling here, so we should always be careful to check IsComplete before calling Next, otherwise we could end up getting an index out of bounds error.

Turn System

Now we will add a system that handles the concept of a “Turn”. This means I will have a system that handles things like watching the number of actions a unit can take. A unit can take up to three basic actions. Some actions cost the same as two or three basic actions. Regardless, so long as a unit can act, it will still be his turn.

Create a new C# script at Assets -> Scripts -> Combat named TurnSystem and add the following:

public interface ITurnSystem : IDependency<ITurnSystem>
{
    bool IsComplete { get; }

    void Begin(Entity entity);
}

public class TurnSystem : ITurnSystem
{
    public bool IsComplete {
        get {
            // TEMP
            var result = isComplete;
            isComplete = true;
            return result;
        }
    }
    bool isComplete;

    public void Begin(Entity entity)
    {
        isComplete = false;
    }
}

For now, this is just a placeholder system. I am using a private field, isComplete to represent when a turn “should” be complete. When we Begin a turn, the flag will be set to false, and then whenever I “check” whether or not a turn is complete I will flip the flag to true so that the next pass through the loop it will simulate a turn having been taken (regardless of the action taken). This is just a temporary implementation for now so that the flow can be demonstrated. The real version should check whether or not a unit’s action allowance has been reached or is for some reason unable to act at all.

Combat Injector

Open the CombatInjector and add the following to its Inject method:

ICombatantSystem.Register(new CombatantSystem());
IRoundSystem.Register(new RoundSystem());
ITurnSystem.Register(new TurnSystem());

Combat Flow

Now we have enough of a foundation to start roughing out the flow of combat. Create a new C# script at Assets -> Scripts -> Flow named CombatFlow and add the following:

using Cysharp.Threading.Tasks;

public interface ICombatFlow : IDependency<ICombatFlow>
{
    UniTask<CombatResult> Play();
}

public struct CombatFlow : ICombatFlow
{
    public async UniTask<CombatResult> Play()
    {
        await Enter();
        CombatResult result = await Loop();
        await Exit();
        return result;
    }

    async UniTask Enter()
    {
        // TODO: initiative, surprise attacks, etc
        await UniTask.CompletedTask;
    }

    async UniTask<CombatResult> Loop()
    {
        CombatResult? result = null;
        while (!result.HasValue)
            result = await IRoundFlow.Resolve().Play();
        return result.Value;
    }

    async UniTask Exit()
    {
        // TODO: award experience, delete monster data, etc
        await UniTask.CompletedTask;
    }
}

This flow shows a high level idea of combat. When combat “enters” there is an opportunity to do things like roll for initiative, or mark things in case the battle should have a surprise round. Otherwise battle will play out as a series of rounds until the result of battle is finally determined. Then combat will “exit” and that can be an opportunity to do things like awarding experience and cleaning up after the battle such as by deleting the monster data. A lot of that functionality has merely been hinted at by “TODO” comments.

Round Flow

Create a new C# script in the same folder named RoundFlow and add the following:

using System.Collections.Generic;
using Cysharp.Threading.Tasks;

public interface IRoundFlow : IDependency<IRoundFlow>
{
    UniTask<CombatResult?> Play();
}

public class RoundFlow : IRoundFlow
{
    public async UniTask<CombatResult?> Play()
    {
        // TODO: These entities will be provided based on their initiative
        var entities = new List<Entity>(ICombatantSystem.Resolve().Table);

        var system = IRoundSystem.Resolve();
        system.Begin(entities);

        CombatResult? result = null;
        while (!system.IsComplete)
        {
            var entity = system.Next();
            result = await ITurnFlow.Resolve().Play(entity);
            if (result.HasValue)
                break;
        }
        return result;
    }
}

When this flow enters, it will grab a list of the entities that can take turns. For now, this list is being taken based on the Combatant System, but in the future, we will use other systems that can determine an order based on initiative. Next, we loop until the round is complete, or can break out of the loop if any given turn causes the result of combat to be determined. After the round has completed, we exit the flow and return whatever combat result is (even if it is still null).

Turn Flow

Create a new C# script in the same folder named TurnFlow and add the following:

using Cysharp.Threading.Tasks;

public interface ITurnFlow : IDependency<ITurnFlow>
{
    UniTask<CombatResult?> Play(Entity entity);
}

public struct TurnFlow : ITurnFlow
{
    public async UniTask<CombatResult?> Play(Entity entity)
    {
        var system = ITurnSystem.Resolve();
        system.Begin(entity);

        CombatResult? result = null;
        while (!system.IsComplete)
        {
            if (entity.Party == Party.Hero)
                result = await IHeroActionFlow.Resolve().Play();
            else
                result = await IMonsterActionFlow.Resolve().Play();

            if (result.HasValue)
                break;
        }
        return result;
    }
}

When the turn flow enters, we will let the Turn System know that a new Turn should begin, and with which Entity. Then we loop until our system tells us that the turn has completed. Within the loop, we look at the party assigned to the Entity whose turn it is. A unit in the Hero party will have its own flow, and a unit in the Monster party will also have its own flow. If any given action within a turn allows the result of combat to be determined we can also exit the loop early. At the end of a turn, we exit the flow by returning whatever our combat result is, even if it is still null.

Monster Action Flow

Create a new C# script in the same folder named MonsterActionFlow and add the following:

using Cysharp.Threading.Tasks;

public interface IMonsterActionFlow : IDependency<IMonsterActionFlow>
{
    UniTask<CombatResult?> Play();
}

public class MonsterActionFlow : IMonsterActionFlow
{
    public async UniTask<CombatResult?> Play()
    {
        await UniTask.CompletedTask;
        // TODO: Handle A.I. for monster to take a turn
        UnityEngine.Debug.Log("Monster turn skipped...");
        return ICombatResultSystem.Resolve().CheckResult();
    }
}

This is just a placeholder flow for now, but in the future we will add code here that would handle A.I. so that the monsters can make actions too.

Encounter Flow

Now we need to connect our new flows to the EncounterFlow. Open that script and replace the implementation of the Loop method with the following:

async UniTask<CombatResult> Loop()
{
    CombatResult? combatResult = null;
    while (!combatResult.HasValue)
    {
        await UniTask.NextFrame();
        combatResult = await ICombatFlow.Resolve().Play();
    }
    return combatResult.Value;
}

Flow Injector

Open the FlowInjector script and add the following to its Inject method:

ICombatFlow.Register(new CombatFlow());
IRoundFlow.Register(new RoundFlow());
ITurnFlow.Register(new TurnFlow());
IMonsterActionFlow.Register(new MonsterActionFlow());

Demo

Play the game from the beginning, and after your hero takes a turn, you will see a message printed to the console – the monster had a turn! It didn’t take any actions because I haven’t implemented A.I. yet, but at least it was considered in the combat flow. Regardless of the number of combatants and which party they are included in, our current setup will give each combatant a turn, in order, and continue looping rounds until combat completes.

Summary

In this lesson we added a new type of Entity system that is based on a Set rather than on a Dictionary. We added a couple of new component systems: one to track which entities should be included in combat, and another that helps determine which side they are on. Then we created a bunch of new “flows” to handle combat and its various sub flows like how to handle the flow of a round, turn, and action. Much of the new code is placeholder, but there is already enough in place to allow combat to alternate control between our hero and monster.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *