It’s a bit funny to think about, but we’ve made it pretty far into our solo adventure project without actually creating the adventurer.
Overview
We have a variety of aspects that can be applied to a hero such as ability scores, level, and skills, so now we need to tie it all together and create the hero itself. The approach we will take is to create project assets (prefabs) that serve as a sort of recipe by which we can create heroes and creatures.
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.
Skill System
Our skill systems know how to calculate their value based on things like the entity level, ability score, and proficiency. Ideally we will have an easy way to tell all the skill systems to actually perform that calculation. In some cases we may wish only to recalculate a single skill. Open the script for this system and add the following to the interface:
void SetupAllSkills(Entity entity); void Setup(Entity entity, Skill skill);
Implement them in the class using the following:
public void SetupAllSkills(Entity entity) { foreach (Skill skill in Enum.GetValues(typeof(Skill))) GetSystem(skill).Setup(entity); } public void Setup(Entity entity, Skill skill) { GetSystem(skill).Setup(entity); }
Note that looping over the Enum values requires us to import System:
using System;
And because I am now calling the Setup method of each system, our GetSystem signature needs to be modified to return an IBaseSkillSystem instead of an IEntityTableSystem<int>
IBaseSkillSystem GetSystem(Skill skill)
Attribute Provider
We will be creating prefab assets for things like heroes and creatures just like we did for story entries. Each object will be a sort of recipe for building an entity instance based on whatever configuration components are present. I am calling these components “attribute providers” because they each provide some kind of information about the entity.
Create a new folder at Assets -> Scripts named AttributeProvider. Then create a new C# script in the same folder named IAttributeProvider and add the following:
public interface IAttributeProvider { void Setup(Entity entity); }
Ability Score Provider
Let’s create an example. We will start with one that can provide a fixed ability score. You might want to use this in the event that certain creatures didn’t have a particular type of score, and so want only to add attributes for specified scores.
Add a new C# script to the same folder named AbilityScoreProvider and add the following:
using UnityEngine; public class AbilityScoreProvider : MonoBehaviour, IAttributeProvider { [SerializeField] AbilityScore.Attribute attribute; [SerializeField] int value; public void Setup(Entity entity) { entity[attribute] = value; } }
Ability Scores Provider
In most cases, a creature should have each ability score attribute, and it would be tedious to add each individually. So let’s also create a provider where we can input all of the scores at once. Add another script, in the same folder, named AbilityScoresProvider (note that this one is plural). Add the following:
using UnityEngine; public class AbilityScoresProvider : MonoBehaviour, IAttributeProvider { [SerializeField] int strength; [SerializeField] int dexterity; [SerializeField] int constitution; [SerializeField] int intelligence; [SerializeField] int wisdom; [SerializeField] int charisma; public void Setup(Entity entity) { entity.Strength = strength; entity.Dexterity = dexterity; entity.Constitution = constitution; entity.Intelligence = intelligence; entity.Wisdom = wisdom; entity.Charisma = charisma; } }
Level Provider
Now add a provider for the initial level of an entity. Add a new C# script named LevelProvider and add the following:
using UnityEngine; public class LevelProvider : MonoBehaviour, IAttributeProvider { [SerializeField] int value; public void Setup(Entity entity) { entity.Level = value; } }
Skills Proficiency Provider
Now let’s add a provider that specifies initial proficiencies. Add a new C# script named SkillsProficiencyProvider and add the following:
using System.Collections.Generic; using UnityEngine; public class SkillsProficiencyProvider : MonoBehaviour, IAttributeProvider { [System.Serializable] public struct ValuePair { public Skill skill; public Proficiency proficiency; } [SerializeField] List<ValuePair> valuePairs; public void Setup(Entity entity) { var system = IProficiencySystem.Resolve(); foreach (var pair in valuePairs) system.Set(entity, pair.skill, pair.proficiency); } }
Since in most cases, I suspect more skills will be untrained than trained, I decided to let them be added as needed via the inspector.
Entity Recipe System
Add a new C# script to Assets -> Scripts -> Entity named EntityRecipeSystem and add the following:
using UnityEngine; using Cysharp.Threading.Tasks; public interface IEntityRecipeSystem : IDependency<IEntityRecipeSystem> { UniTask<Entity> Create(string assetName); } public class EntityRecipeSystem : IEntityRecipeSystem { public async UniTask<Entity> Create(string assetName) { var entity = IEntitySystem.Resolve().Create(); var assetManager = IAssetManager<GameObject>.Resolve(); var key = string.Format("Assets/Objects/EntityRecipe/{0}.prefab", assetName); var prefab = await assetManager.LoadAssetAsync(key); var providers = prefab.GetComponents<IAttributeProvider>(); for (int i = 0; i < providers.Length; ++i) providers[i].Setup(entity); return entity; } }
This simple script will create and configure an Entity based on one of our recipe prefab assets. It uses the entity system to create the entity, the asset manager to load the prefab asset, and the attribute providers to configure the entity. This process is an awaitable task, that returns the created Entity.
Entity Injector
I was a little lazy when injecting the EntitySystem because I didn't create an Injector for its folder. At the time there was only a single system to inject so it felt unnecessary, but now I have two systems, so we might as well fix it. Add an EntityInjector script:
public static class EntityInjector { public static void Inject() { IEntitySystem.Register(new EntitySystem()); IEntityRecipeSystem.Register(new EntityRecipeSystem()); } }
Edit the main Injector script by replacing the Entity System Registration statement with the following:
EntityInjector.Inject();
Solo Hero System
We have all of the pieces necessary to create a hero, now we just need a system that actually creates it, and keeps a reference to it. I am calling it the Solo Hero System because it keeps track of the "Solo" hero that is going on this adventure. As we continue to develop this game, we may want to focus less on the idea of a single hero, and more on a party of heroes, but for now, a single hero reference is just what we need.
Add a new C# script to Assets -> Scripts -> SoloAdventure named SoloHeroSystem and add the following:
using Cysharp.Threading.Tasks; public partial class Data { public Entity hero; } public interface ISoloHeroSystem : IDependency<ISoloHeroSystem> { Entity Hero { get; } UniTask CreateHero(); } public class SoloHeroSystem : ISoloHeroSystem { public Entity Hero { get { return IDataSystem.Resolve().Data.hero; } private set { IDataSystem.Resolve().Data.hero = value; } } public async UniTask CreateHero() { Hero = await IEntityRecipeSystem.Resolve().Create("Hero"); ISkillSystem.Resolve().SetupAllSkills(Hero); } }
I added a new piece of game data named "hero" - which of course is the Entity representing the main hero of our game. The system interface defines a read-only reference to this Hero. Next we have a task based method that creates the hero by using our entity recipe system. After creating the Hero, we need to call "SetupAllSkills" so that the ability scores and proficiencies it was configured with will be tallied up into skill values.
Solo Adventure Injector
Open the SoloAdventureInjector script and add the following to the Inject method:
ISoloHeroSystem.Register(new SoloHeroSystem());
Game System
Open the GameSystem script and change the last line of the NewGame method from awaiting a completed task, to awaiting the creation of our hero:
await ISoloHeroSystem.Resolve().CreateHero();
Create the Hero Asset
Now we need to create the prefab asset itself. Create a new folder at Assets -> Objects named EntityRecipe. In any scene, create an empty game object. Name the game object "Hero" and drag it to the EntityRecipe folder to create a prefab asset. Delete the instance from the scene, and then select the new asset to work with.
Click the "Addressable" check mark on.
Add a LevelProvider component with a Value of 1.
Add an AbilityScoresProvider component with the following starting values:
- Strength: 18
- Dexterity: 14
- Constitution: 14
- Intelligence: 12
- Wisdom: 10
- Charisma: 10
Add an SkillsProficiencyProvider component with some sample skills for the Hero to be trained in. I added "Trained" for Acrobatics, Athletics, Crafting, Diplomacy, Intimidation, Lore and Survival.
Make sure to save the project.
Main Menu Setup
The GameSystem will create our new Hero in the beginning of the GameFlow. The specific moment this occurs is when the user chooses "New Game" from the "Main Menu" Scene. Therefore, we will need to have a GameObjectAssetManager component in that scene as well.
Open the MainMenu scene. Select the "Demo" GameObject from the Hierarchy pane. Then add the GameObjectAssetManager to that object.
Unit Testing
While we have made important progress in this lesson, this is another point where we don't have much to "show". So let's go ahead and add more unit tests so we can at least see those nice green checkmarks!
JsonUtility & Unit Tests
If you've got a MonoBehaviour script where the fields are all private and marked with SerializeField, how exactly can you configure it for testing? Great question, thanks for asking. Let's work through it together.
Create a new folder at Assets -> Tests named AttributeProvider. Then create a new C# Test script within that folder named AbilityScoreProviderTests and add the following:
using NUnit.Framework; using UnityEngine; public class AbilityScoreProviderTests { [Test] public void AbilityScoreProviderTestsSimplePasses() { var asset = new GameObject("Hero"); var provider = asset.AddComponent<AbilityScoreProvider>(); var json = JsonUtility.ToJson(provider); Debug.Log(json); } }
This isn't "actually" a real unit test yet, but bear with me for a moment. Head over to Unity's test runner, and run this test anyway. You should see a log printed in the console with a handy json representation of our provider component:
{"attribute":0,"value":0}
What you see here is a comma separated list of key-value pairs. For example, "attribute":0 represents the field named attribute with its default value of 0. Remember an enum is really just a named number, so here the 0 represents the Strength attribute, which is the first entry in our enum.
JsonUtility.ToJson can be a helpful way to get yourself a json "template" that you can edit and then use to configure your components. For example, if I change the Json to look like this...
{"attribute":1,"value":12}
... then I am saying that the "attribute" should be Dexterity, and the "value" for that score should be 12. Once I have the configuration like I want, I can use JsonUtility.FromJsonOverwrite to configure our component.
Let's try that now. Replace the body of the class with this completed test:
[SetUp] public void SetUp() { IDataSystem.Register(new MockDataSystem()); IDataSystem.Resolve().Create(); AbilityScoreInjector.Inject(); } [Test] public void AbilityScoreProviderTestsSimplePasses() { // Arrange var asset = new GameObject("Hero"); var provider = asset.AddComponent<AbilityScoreProvider>(); var json = "{\"attribute\":1,\"value\":12}"; JsonUtility.FromJsonOverwrite(json, provider); var entity = new Entity(123); // Act provider.Setup(entity); // Assert Assert.AreEqual(12, entity.Dexterity.value); }
Here I have used the SetUp method to register the necessary systems for our test. Then in the Test itself I "Arrange" by creating a new GameObject that has an attached AbilityScoreProvider component. I use the JsonUtility to overwrite the components serialized fields using a json string and finally create a fake Entity.
The action of our test calls Setup on the provider component and passes the entity. Assuming everything works as expected, the Assertion will prove true, and our Entity will now have a Dexterity of 12. Run the test and verify for yourself.
If you like this approach, you could always expand on it by creating builder scripts that can allow you to add and configure your scripts in case you want something reusable. For example, the following method would allow you to add and configure the component, but do so in a more dynamic way so you can pass whatever attribute and value you want per test:
AbilityScoreProvider AddAbilityScore(GameObject asset, AbilityScore.Attribute attribute, int value) { var provider = asset.AddComponent<AbilityScoreProvider>(); var json = $"{{\"attribute\":{(int)attribute},\"value\":{value}}}"; JsonUtility.FromJsonOverwrite(json, provider); return provider; }
The great thing about this solution is that you can keep your fields private AND not need any extra public methods or properties apart from what you actually need for the script to do its job. When you limit what is "publicly" visible, it helps users focus on the proper use of the script and helps prevent the script from being used in a way that wasn't intended. On the other hand, trying to maintain the json could be a tedious and fragile process.
A second solution is to include a configuration method on the script you wish to test. This would avoid the need of the gnarly json code at the expense of adding an extra public method. For example, this could be added to the AbilityScoreProvider:
public void Configure(AbilityScore.Attribute attribute, int value) { this.attribute = attribute; this.value = value; }
One last solution... and I know some of my fellow programmers may judge me for this... but the easiest option with the least amount of boiler plate, would be to simply let the fields be public. I think it is important to remember that "best practices" are there to make your life easier, and when you have to go way out of your way to follow them, then it may be worth bending those rules. Food for thought.
Mock Attribute Provider
Next I would like to make a mock attribute provider so that I can easily test our Entity Recipe System. Ideally the mock would be created inside the "Tests" folder, though if I do that I encounter an error:
Can't add script behaviour MockAttributeProvider because it is an editor script. To attach a script it needs to be outside the 'Editor' folder.
So... I guess for now I will allow it even though I don't like it... create a new C# script at Assets -> Scripts -> AttributeProvider named MockAttributeProvider and add the following:
using UnityEngine; public class MockAttributeProvider : MonoBehaviour, IAttributeProvider { public bool DidSetup { get; private set; } public Entity SetupEntity { get; private set; } public void Setup(Entity entity) { DidSetup = true; SetupEntity = entity; } }
The purpose of this mock is to track that the Setup method is in fact called when I expect it to be and that it receives the parameter I expected as well. It doesn't actually do anything to the entity.
Mock Asset Manager
Before we can implement this class and the next tests, we must first add the "UniTask" Assembly Definition Reference to the Tests.asmdef asset.
Let's create a mock for our asset manager so that we can also test some of our other new classes. Create a new folder at Assets -> Tests named AssetManager. Then create a new C# script within that folder named MockAssetManager.
using Cysharp.Threading.Tasks; public class MockAssetManager<T> : IAssetManager<T> { public T fakeAsset; public async UniTask<T> InstantiateAsync(string key) { await UniTask.CompletedTask; return fakeAsset; } public async UniTask<T> LoadAssetAsync(string key) { await UniTask.CompletedTask; return fakeAsset; } }
This will allow us to create a fake asset to be provided by our asset manager, instead of having to let it actually load an asset from the project.
Entity Recipe System Tests
Add a new C# test script to Assets -> Tests -> Entity named EntityRecipeSystemTests and add the following:
using NUnit.Framework; using UnityEngine; public class EntityRecipeSystemTests { MockAssetManager<GameObject> mockAssetManager; [SetUp] public void SetUp() { IDataSystem.Register(new MockDataSystem()); IDataSystem.Resolve().Create(); IEntitySystem.Register(new EntitySystem()); IRandomNumberGenerator.Register(new RandomNumberGenerator()); mockAssetManager = new MockAssetManager<GameObject>(); IAssetManager<GameObject>.Register(mockAssetManager); } [Test] public async void EntityRecipeSystemTestsSimplePasses() { // Arrange var hero = new GameObject("Hero"); var provider1 = hero.AddComponent<MockAttributeProvider>(); var provider2 = hero.AddComponent<MockAttributeProvider>(); mockAssetManager.fakeAsset = hero; var sut = new EntityRecipeSystem(); // Act var entity = await sut.Create("Hero"); // Assert Assert.IsTrue(provider1.DidSetup); Assert.AreEqual(entity, provider1.SetupEntity); Assert.IsTrue(provider2.DidSetup); Assert.AreEqual(entity, provider2.SetupEntity); } }
With this test I am able to verify that:
- The system gets its asset from the Asset Manager (a mock in this case)
- The system creates a new Entity to configure (via the injected Entity System)
- The system finds all attribute providers attached to the loaded asset and calls Setup on each of them
- During Setup, the newly created Entity is passed along as the parameter
Mock Entity Recipe System
In order to greatly simplify the setup for testing our solo hero system, let's now create a mock of the entity recipe system. Create a new script named MockEntityRecipeSystem and add the following:
using Cysharp.Threading.Tasks; public class MockEntityRecipeSystem : IEntityRecipeSystem { public Entity fakeEntity; public async UniTask<Entity> Create(string assetName) { await UniTask.CompletedTask; return fakeEntity; } }
Solo Hero System Tests
For one last test, I want to verify that the Solo Hero System successfully calculates skill values after creating an entity from a recipe. Create a new folder at Assets -> Tests named SoloAdventure, and create a script inside that folder named SoloHeroSystemTests. Add the following:
using NUnit.Framework; using UnityEngine; public class SoloHeroSystemTests { MockEntityRecipeSystem mockEntityRecipeSystem; [SetUp] public void SetUp() { IDataSystem.Register(new MockDataSystem()); IDataSystem.Resolve().Create(); AbilityScoreInjector.Inject(); SkillsInjector.Inject(); ILevelSystem.Register(new LevelSystem()); mockEntityRecipeSystem = new MockEntityRecipeSystem(); IEntityRecipeSystem.Register(mockEntityRecipeSystem); } [Test] public async void SoloHeroSystemTestsSimplePasses() { // Arrange mockEntityRecipeSystem.fakeEntity = CreateSoloHero(); var sut = new SoloHeroSystem(); // Act await sut.CreateHero(); // Assert Assert.AreEqual(7, sut.Hero.Athletics); // (+4 str, +2 trained, +1 level) } Entity CreateSoloHero() { var result = new Entity(123); result.Level = 1; result.Strength = 18; IProficiencySystem.Resolve().Set(result, Skill.Athletics, Proficiency.Trained); return result; } }
Once again, I have chosen to use a "mock" (this time of the entity recipe system) to provide a "fake" entity. After awaiting the created hero, the test verifies that level, ability score and proficiency have all played a part in calculating the final skill value.
Summary
In this lesson we created some handy scripts that will allow us to create heroes and creatures by composing different combinations of attribute providers on a GameObject. We can control things such as their ability scores, level and proficiency in different kinds of skills. The GameObject is then saved as a prefab asset and can be loaded dynamically by name at a later point. We used this pattern in this lesson to actually create our solo hero for use in the adventure.
We also made some new unit tests in this lesson. In particular we used json to overwrite the serialized values of private fields on a MonoBehaviour script. Next we showed that Unity can also run async unit tests!
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!