D20 RPG – Weapons

“Whoever said the pen is mightier than the sword obviously never encountered automatic weapons.” – Douglas MacArthur

Overview

Attack training doesn’t mean a whole lot without weapons, so I decided to extend my detour a bit further. In this lesson we will create a bunch of new weapons, provide systems to load and equip them, and even use them to determine stats in an attack. There’s a lot of material, so let’s jump in!

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.

Refactoring

Open and review IAttributeProvider.

In this lesson, we will create weapon assets similar to other assets (backgrounds etc). From these assets we will create and configure another Entity that will be “equipped” by our hero. I would like the starting equipment to be determined by an attribute provider, but I have a problem – the attribute providers do their “setup” synchronously, whereas an asset should be loaded asynchronously.

That leaves me some options… The first option would be to change the interface signature:

// From this:
void Setup(Entity entity);

// To this:
UniTask Setup(Entity entity);

As a result of this option, every implementing class would need a new using statement, would need to change their signatures to match, and would need to add some kind of “await” statement for the new async syntax. For a more complete example of this approach, the size attribute provider would be changed to look like this:

using UnityEngine;
using Cysharp.Threading.Tasks; // Added

public class SizeProvider : MonoBehaviour, IAttributeProvider
{
    public Size value;

    public async UniTask Setup(Entity entity) // Changed
    {
        entity.Size = value;
        await UniTask.CompletedTask; // Added
    }
}

Those changes aren’t too hard, but I have a couple complaints:

  1. There are a lot of attribute providers, so even a small change like this adds up.
  2. None of the current attribute providers (and most of the future ones) will benefit from the asynchronous handling. It just feels like extra boilerplate code.

Ideally, I want a solution that doesn’t require a ton of scripts to change, and that can also support both synchronous and asynchronous setup flows. That led me to the following decision. I changed the interface to look like this:

using Cysharp.Threading.Tasks;

public interface IAttributeProvider
{
    void Setup(Entity entity)
    {
        throw new System.NotImplementedException();
    }

    async UniTask SetupFlow(Entity entity)
    {
        Setup(entity);
        await UniTask.CompletedTask;
    }
}

Here I have added a second method. The original handled synchronous setup, and the new method handles asynchronous setup. The interface provides a default implementation for both methods. This allows me to take either approach.

All of my current attribute providers have already defined their own “Setup” method, and so they will overwrite the default “Setup” provided in the interface. They can all still be used in an async pipeline without additional effort thanks to the default second method. “SetupFlow” will invoke their custom “Setup” method automatically.

For any new attribute providers that need to overwrite the async method, I can simply ignore the original “Setup” method – they will already conform thanks to the provided default version which won’t be needed.

While this approach does meet my stated goals, there is a concern to keep in mind. In particular, whenever you have a new class conform to this interface, it may look like no other action is required. In the past, the compiler would have reminded us that we actually needed to implement the Setup code. As long as everything builds and runs, it becomes harder to troubleshoot missed steps like this one. To alleviate that problem, I made it so that the default “Setup” throws an exception. As long as you actually provide an implementation for one of the methods, your code will run fine. If you forget, you will see an error at runtime in the console: NotImplementedException: The method or operation is not implemented.

Async Attribute Providers

Now that we can use our attribute providers in an async way, we need to actually take advantage of the new feature. We can start with the EntityRecipeSystem where in its Create method we loop through all the attribute providers that are attached as a component to the prefab, and had called “Setup” on them (see line 19). Make the following change:

// From this:
providers[i].Setup(entity);

// To this:
await providers[i].SetupFlow(entity);

We need similar changes in CreateHeroPartyFlow, inside both of LoadAncestry and LoadBackground (see lines 42 and 52):

// From this:
provider.Setup(entity);

// To this:
await provider.SetupFlow(entity);

Entity Relation Table System

There are some parallels between Unity’s architecture and the ECS patterns we have used so far. As a quick recap, an Entity is similar to a GameObject – they are both a sort of container for component data. If you destroy a GameObject, all attached components are also destroyed. Likewise if you destroy an Entity, the EntityTableSystem subclasses will automatically observe the event and clear any data that had been associated with that Entity.

Now I want you to think of another level of Unity’s architecture – GameObject hierarchy. For example, you can have a root level GameObject representing a hero that has child objects (bones etc) and you could “parent” a somewhat unrelated object to something within that hierarchy, such as a sword to a hand. The process of parenting causes a relationship to occur between the different GameObjects. Now if you delete the root hero object, not only will it delete its own components, but it will also delete all child objects – including the sword.

This is a handy pattern to mimic, because I could easily hold a collection of monsters in an encounter- each with their own hierarchy of objects (weapons, armor, loot, etc), each of which should be separate Entities with their own component data. When the battle ends and I need to do cleanup, I want only to have to think about deleting the monsters and let the cleanup of everything else happen automatically.

Create a new script at Scripts -> Component named EntityRelationTableSystem and add the following:

public abstract class EntityRelationTableSystem : EntityTableSystem<Entity>
{
    protected override void OnEntityDestroyed(Entity entity)
    {
        Entity target;
        if (TryGetValue(entity, out target))
        {
            IEntitySystem.Resolve().Destroy(target);
        }
        base.OnEntityDestroyed(entity);
    }
}

This is another abstract subclass of EntityTableSystem, but has already resolved the generic type to always be an Entity. In other words, one Entity will map to another Entity in our table of data, thus creating a relationship between the two.

In order to handle the idea of a cascading deletion, we “override” the OnEntityDestroyed. Whenever we find a relationship based on the destroyed Entity, we now will also destroy the Entity it was related to. We also remove the data from the table by calling the superclass implementation.

Primary Hand System

Next we will create our first concrete example of an “Entity Relation Table System”. It will make an association with an Entity’s “Primary Hand”. So for example if you are right handed, it would be answering the question: “what, if anything, are you holding in your right hand?”.

Create a new script at Scripts -> Component -> Equipment named PrimaryHandSystem and add the following:

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

public interface IPrimaryHandSystem : IDependency<IPrimaryHandSystem>, IEntityTableSystem<Entity>
{

}

public class PrimaryHandSystem : EntityRelationTableSystem, IPrimaryHandSystem
{
    public override CoreDictionary<Entity, Entity> Table => IDataSystem.Resolve().Data.primaryHand;
}

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

This should look familiar as it follows the same pattern of other EntityTableSystem subclasses, except that this inherits from an even more specific subclass, EntityRelationTableSystem so that it benefits from cascading deletion.

Equipment Injector

Let’s go ahead and create an injector in the same folder named EquipmentInjector to handle the injection of our new system, and for any systems in nested directories:

public static class EquipmentInjector
{
    public static void Inject()
    {
        IPrimaryHandSystem.Register(new PrimaryHandSystem());
        WeaponInjector.Inject();
    }
}

We will need to trigger the injection from the ComponentInjector by adding this to its inject method:

EquipmentInjector.Inject();

And while we are there, remove this line from the ComponentInjector‘s inject method since it now exists in the EquipmentInjector:

// Remove this
WeaponInjector.Inject();

Weapon Creation

There is a lot of content in this lesson, so I would like to rush through much of the next parts. While the code is new, the patterns are not. You can always review previous lessons for a reminder if needed, or ask in a comment if something is unclear. Of course, the completed lesson will be available for download at the end of the lesson as well.

Start out by importing this package here. It includes a json file to represent a large collection of weapons used in Pathfinder. It has a structure that looks something like this:

{
    "datas": [
	... // more weapons
        {
            "name": "Shortsword",
            "weaponType": "Melee",
            "weaponCategory": "Martial",
            "weaponGroup": "Sword",
            "weaponTraits": "Agile, Finesse, Versatile S",
            "damage": "1d6 P",
            "hands": "1",
            "range": "",
            "reload": "",
            "bulk": "L",
            "price": "9 sp",
            "level": "0"
        },
        ... // more weapons
    ]
}

For this lesson, we will provide an implementation for these properties:

  • name
  • type
  • category
  • group
  • damage dice roll
  • level

Systems already exist for several of these, though we will still be adding a couple more. We will also need to create some new attribute providers, and to work with the resulting assets I will add a new asset system specific for loading and spawning weapons (creating the configured Entity based on the asset).

Weapon Parser

Create at path: Scripts/PreProduction/WeaponParser.cs

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;

public static class WeaponParser
{
    [System.Serializable]
    public class WeaponList
    {
        public List<WeaponData> datas;
    }

    [System.Serializable]
    public class WeaponData
    {
        public string name;
        public string weaponType;
        public string weaponCategory;
        public string weaponGroup;
        public string weaponTraits;
        public string damage;
        public string hands;
        public string range;
        public string reload;
        public string bulk;
        public string price;
        public string level;
    }

    [MenuItem("Pre Production/Generate/Weapons")]
    public static void GenerateAll()
    {
        if (!AssetDatabase.IsValidFolder("Assets/AutoGeneration"))
            AssetDatabase.CreateFolder("Assets", "AutoGeneration");
        if (!AssetDatabase.IsValidFolder("Assets/AutoGeneration/Weapons"))
            AssetDatabase.CreateFolder("Assets/AutoGeneration", "Weapons");

        string filePath = "Assets/Docs/Weapons.json";
        TextAsset asset = AssetDatabase.LoadAssetAtPath<TextAsset>(filePath);
        var result = JsonUtility.FromJson<WeaponList>(asset.text);
        foreach (var data in result.datas)
        {
            GenerateAsset(data);
        }
    }

    static void GenerateAsset(WeaponData data)
    {
        var asset = new GameObject(data.name);
        AddType(asset, data);
        AddCategory(asset, data);
        AddGroup(asset, data);
        AddTraits(asset, data);
        AddDamage(asset, data);
        AddHands(asset, data);
        AddRange(asset, data);
        AddReload(asset, data);
        AddBulk(asset, data);
        AddPrice(asset, data);
        AddLevel(asset, data);
        CreatePrefab(asset, data);
        GameObject.DestroyImmediate(asset);
    }

    static void AddType(GameObject asset, WeaponData data)
    {
        if (string.IsNullOrEmpty(data.weaponType))
            return;
        WeaponType weaponType;
        if (Enum.TryParse<WeaponType>(data.weaponType, out weaponType))
        {
            var provider = asset.AddComponent<WeaponTypeProvider>();
            provider.value = weaponType;
        }
    }

    static void AddCategory(GameObject asset, WeaponData data)
    {
        if (string.IsNullOrEmpty(data.weaponCategory))
            return;
        WeaponCategory weaponCategory;
        if (Enum.TryParse<WeaponCategory>(data.weaponCategory, out weaponCategory))
        {
            var provider = asset.AddComponent<WeaponCategoryProvider>();
            provider.value = weaponCategory;
        }
    }

    static void AddGroup(GameObject asset, WeaponData data)
    {
        if (string.IsNullOrEmpty(data.weaponGroup))
            return;
        WeaponGroup weaponGroup;
        if (Enum.TryParse<WeaponGroup>(data.weaponGroup, out weaponGroup))
        {
            var provider = asset.AddComponent<WeaponGroupProvider>();
            provider.value = weaponGroup;
        }
    }

    static void AddTraits(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddDamage(GameObject asset, WeaponData data)
    {
        if (string.IsNullOrEmpty(data.damage))
            return;

        var components = data.damage.Split(" ");
        if (components.Length != 2)
            return;

        AddDamageDiceRoll(asset, components[0]);
        AddDamageType(asset, components[1]);
    }

    static void AddDamageDiceRoll(GameObject asset, string data)
    {
        var components = data.Split("d");
        if (components.Length != 2)
            return;

        int count, sides;
        if (int.TryParse(components[0], out count) && int.TryParse(components[1], out sides))
        {
            var provider = asset.AddComponent<DamageRollProvider>();
            provider.value = new DiceRoll(count, sides);
        }
    }

    static void AddDamageType(GameObject asset, string data)
    {
        // TODO
    }

    static void AddHands(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddRange(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddReload(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddBulk(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddPrice(GameObject asset, WeaponData data)
    {
        // TODO
    }

    static void AddLevel(GameObject asset, WeaponData data)
    {
        if (string.IsNullOrEmpty(data.level))
            return;

        int level;
        if (int.TryParse(data.level, out level))
        {
            var provider = asset.AddComponent<LevelProvider>();
            provider.value = level;
        }
    }

    static void CreatePrefab(GameObject asset, WeaponData data)
    {
        string path = string.Format("Assets/AutoGeneration/Weapons/{0}.prefab", data.name);
        PrefabUtility.SaveAsPrefabAsset(asset, path);
    }
}

Damage Roll System

Create at path: Scripts/Combat/DamageRollSystem.cs

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

public interface IDamageRollSystem : IDependency<IDamageRollSystem>, IEntityTableSystem<DiceRoll>
{

}

public class DamageRollSystem : EntityTableSystem<DiceRoll>, IDamageRollSystem
{
    public override CoreDictionary<Entity, DiceRoll> Table => IDataSystem.Resolve().Data.damageRoll;
}

public partial struct Entity
{
    public DiceRoll DamageRoll
    {
        get { return IDamageRollSystem.Resolve().Get(this); }
        set { IDamageRollSystem.Resolve().Set(this, value); }
    }
}

Inject via the DamageInjector

IDamageRollSystem.Register(new DamageRollSystem());

Weapon Type System

Create at path: Scripts/Component/Equipment/Weapon/WeaponTypeSystem.cs

public enum WeaponType
{
    Melee,
    Ranged
}

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

public interface IWeaponTypeSystem : IDependency<IWeaponTypeSystem>, IEntityTableSystem<WeaponType>
{

}

public class WeaponTypeSystem : EntityTableSystem<WeaponType>, IWeaponTypeSystem
{
    public override CoreDictionary<Entity, WeaponType> Table => IDataSystem.Resolve().Data.weaponType;
}

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

Inject via the WeaponInjector

IWeaponTypeSystem.Register(new WeaponTypeSystem());

Level Provider

LevelProvider.cs already exists, but we need to change its field to be public to use it with the parser:

// Change from this
[SerializeField] int value;

// To this
public int value;

Damage Roll Provider

Create at path: Scripts/AttributeProvider/DamageRollProvider.cs

using UnityEngine;

public class DamageRollProvider : MonoBehaviour, IAttributeProvider
{
    public DiceRoll value;

    public void Setup(Entity entity)
    {
        entity.DamageRoll = value;
    }
}

Primary Weapon Provider

This is our first async attribute provider. It has a setup flow that requires us to first load a project asset, then instantiate it as our ECS data, then assign the result.

Create at path: Scripts/AttributeProvider/PrimaryWeaponProvider.cs

using UnityEngine;
using Cysharp.Threading.Tasks;

public class PrimaryWeaponProvider : MonoBehaviour, IAttributeProvider
{
    public string recipeName;

    public async UniTask SetupFlow(Entity entity)
    {
        var weapon = await IWeaponAssetSystem.Resolve().Spawn(recipeName);
        entity.PrimaryHand = weapon;
    }
}

Weapon Category Provider

Create at path: Scripts/AttributeProvider/WeaponCategoryProvider.cs

using UnityEngine;

public class WeaponCategoryProvider : MonoBehaviour, IAttributeProvider
{
    public WeaponCategory value;

    public void Setup(Entity entity)
    {
        entity.WeaponCategory = value;
    }
}

Weapon Group Provider

Create at path: Scripts/AttributeProvider/WeaponGroupProvider.cs

using UnityEngine;

public class WeaponGroupProvider : MonoBehaviour, IAttributeProvider
{
    public WeaponGroup value;

    public void Setup(Entity entity)
    {
        entity.WeaponGroup = value;
    }
}

Weapon Training Provider

Create at path: Scripts/AttributeProvider/WeaponTrainingProvider.cs

using UnityEngine;

public class WeaponTrainingProvider : MonoBehaviour, IAttributeProvider
{
    public WeaponTraining value;

    public void Setup(Entity entity)
    {
        IWeaponProficiencySystem.Resolve().AddWeaponTraining(value, entity);
    }
}

Weapon Type Provider

Create at path: Scripts/AttributeProvider/WeaponTypeProvider.cs

using UnityEngine;

public class WeaponTypeProvider : MonoBehaviour, IAttributeProvider
{
    public WeaponType value;

    public void Setup(Entity entity)
    {
        entity.WeaponType = value;
    }
}

Weapon Asset System

Create at path: Scripts/AssetManager/WeaponAssetSystem.cs

using UnityEngine;
using Cysharp.Threading.Tasks;

public interface IWeaponAssetSystem : IDependency<IWeaponAssetSystem>
{
    UniTask<GameObject> Load(string name);
    UniTask<Entity> Spawn(string name);
}

public class WeaponAssetSystem : IWeaponAssetSystem
{
    public async UniTask<GameObject> Load(string name)
    {
        var assetManager = IAssetManager<GameObject>.Resolve();
        var key = string.Format("Assets/AutoGeneration/Weapons/{0}.prefab", name);
        var prefab = await assetManager.LoadAssetAsync(key);
        return prefab;
    }

    public async UniTask<Entity> Spawn(string name)
    {
        var prefab = await Load(name);
        var providers = prefab.GetComponents<IAttributeProvider>();
        var weapon = IEntitySystem.Resolve().Create();
        foreach (var provider in providers)
            await provider.SetupFlow(weapon);
        return weapon;
    }
}

Inject via the AssetManagerInjector:

IWeaponAssetSystem.Register(new WeaponAssetSystem());

Run The Editor Tool

At this point everything should compile, so if you return to Unity you can run the new parser to create our new weapon assets. From the menu bar, run Pre Production -> Generate -> Weapons. You should have 288 new weapon assets created. Make sure to check the box to make them addressable!

Demo

Since there is no visual representation of an equipped weapon, it can be difficult to demonstrate that it works. I decided the best way to see it, is to implement it as part of the attack action. What I am doing is creating a copy of SoloAdventureAttack that will be a bit more feature complete. We will start by making a base class for an attack action, then add a subclass that further defines the action based on attacking with a weapon.

Attack

Create at path: Scripts/Combat/Actions/Attack.cs

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

public abstract class Attack : MonoBehaviour, ICombatAction
{
    protected abstract int AttackRollBonus(Entity entity);
    protected abstract int ComboCost(Entity entity);
    protected abstract DiceRoll Damage(Entity entity);
    protected abstract string DamageType(Entity entity);
    protected abstract string Material(Entity entity);
    protected abstract EntityFilter TargetFilter(Entity entity);

    public bool CanPerform(Entity entity)
    {
        return TargetFilter(entity).Apply(entity, ITurnSystem.Resolve().InReach).Count > 0;
    }

    public async UniTask Perform(Entity entity)
    {
        var targets = TargetFilter(entity).Apply(entity, ITurnSystem.Resolve().InReach);
        if (targets.Count == 0)
            return;

        var attacker = entity;
        var target = await SelectTarget(entity, targets);

        // Perform the Attack Roll
        var attackInfo = new AttackRollInfo
        {
            attacker = attacker,
            target = target,
            attackRollBonus = AttackRollBonus(entity),
            comboCost = ComboCost(entity)
        };
        var attackRoll = IAttackRollSystem.Resolve().Perform(attackInfo);

        // Present the Attack
        IAttackPresenter presenter;
        if (IAttackPresenter.TryResolve(out presenter))
        {
            var presentInfo = new AttackPresentationInfo
            {
                attacker = attacker,
                target = target,
                result = attackRoll
            };
            await presenter.Present(presentInfo);
        }

        // Determine Damage
        DamageInfo damageInfo = new DamageInfo
        {
            target = target,
            damage = 0,
            criticalDamage = 0,
            type = DamageType(entity),
            material = Material(entity)
        };
        switch (attackRoll)
        {
            case Check.CriticalSuccess:
                var critRoll = Damage(entity).Roll();
                damageInfo.damage = critRoll;
                damageInfo.criticalDamage = critRoll;
                Debug.Log(string.Format("Critical Hit for {0} Damage!", critRoll * 2));
                break;
            case Check.Success:
                var roll = Damage(entity).Roll();
                damageInfo.damage = roll;
                Debug.Log(string.Format("Hit for {0} Damage!", roll));
                break;
            default:
                Debug.Log("Miss");
                break;
        }

        // Apply Damage
        var damageAmount = IDamageSystem.Resolve().Apply(damageInfo);
        var healthInfo = new HealthInfo
        {
            target = target,
            amount = -damageAmount
        };
        await IHealthSystem.Resolve().Apply(healthInfo);
    }

    async UniTask<Entity> SelectTarget(Entity entity, List<Entity> targets)
    {
        if (entity.Party == Party.Monster)
            return targets[0];
        else
            return await IEntitySelectionSystem.Resolve().Select(targets);
    }
}

Weapon Attack

Create at path: Scripts/Combat/Actions/WeaponAttack.cs

using UnityEngine;

public class WeaponAttack : Attack
{
    [SerializeField] EntityFilter targetFilter;

    protected override int AttackRollBonus(Entity entity)
    {
        var weapon = entity.PrimaryHand;
        var statBonus = StatBonus(entity, weapon);
        var profBonus = ProficiencyBonus(entity, weapon);
        return statBonus + profBonus;
    }

    protected override int ComboCost(Entity entity)
    {
        // TODO: base on weapon
        return 5;
    }

    protected override DiceRoll Damage(Entity entity)
    {
        var weapon = entity.PrimaryHand;
        var roll = weapon.DamageRoll;
        roll.bonus += StatBonus(entity, weapon);
        return roll;
    }

    protected override string DamageType(Entity entity)
    {
        // TODO: base on weapon
        return "slashing";
    }

    protected override string Material(Entity entity)
    {
        // TODO: base on weapon
        return "";
    }

    protected override EntityFilter TargetFilter(Entity entity)
    {
        return targetFilter;
    }

    int StatBonus(Entity entity, Entity weapon)
    {
        AbilityScore.Attribute attribute;
        switch (weapon.WeaponType)
        {
            case WeaponType.Melee:
                attribute = AbilityScore.Attribute.Strength;
                break;
            default: // WeaponType.Ranged:
                attribute = AbilityScore.Attribute.Dexterity;
                break;
        }
        var stat = IAbilityScoreSystem.Resolve().Get(entity, attribute);
        return stat.Modifier;
    }

    int ProficiencyBonus(Entity entity, Entity weapon)
    {
        var result = 0;
        var proficiency = IWeaponProficiencySystem.Resolve().GetProficiency(entity, weapon);
        if (proficiency != Proficiency.Untrained)
            result = (int)proficiency * 2 + entity.Level;
        return result;
    }
}

Attack (action prefab)

Create a new prefab at path: Objects/CombatAction/Attack.prefab. It should have one attached component, WeaponAttack, with a “Target Filter” of “Living, Opponent”. It also needs to be Addressable.

Hero (entity recipe prefab)

Since we still haven’t finished the initial hero setup (such as from his hero class), our hero’s have underwhelming stats. This is extra obvious when using our new weapon based attack action because some of our modifiers may even end up negative (if the relevant ability score is low enough). As a temporary solution – I went ahead and bumped all the starting ability scores from 10 to 20 in the Ability Scores Provider.

Next, change the Encounter Actions Provider first entry from “Strike (Shortsword)” to “Attack”.

Add a Weapon Training Provider with a “Category” of “Martial, Simple, Unarmed”, a “Group” of “None”, and a “Proficiency” of “Expert”.

Finally, add a Primary Weapon Provider with a “Recipe Name” of “Shortsword”.

Play!

Try out the game (beginning from the “LoadingScreen”). The hero party may actually feel a bit too powerful at the moment, but it is exciting to know that combat has evolved enough that it now can take into account an equipped weapon!

Summary

We started out with a little refactoring so that attribute providers could be used asynchronously. We made them flexible with default implementations of interface methods and discussed pros and cons of the decisions I made. Next we discussed how to make relationships between Entities, and include things like cascading deletion. Then we created a new parser to generate a bunch of weapons assets, which we learned how to spawn and equip, then even used it in battle!

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 *