Our adventurers are arguably looking for trouble, and it is high time that they found it! In this lesson we will be laying the foundations so that the game can begin a combat encounter!
Overview
There is a lot of foundational work to do in this lesson so that we can start to piece together the entire game flow surrounding encounters. Normally I might break this into a few lessons, but the code you will see is so similar to what we have already done for the exploration side of the game that I thought we could cover all of the setup material in one pass with minimal commentary needed.
We have a lot of ground to cover including creating the encounter asset and scene, a flow to handle encounters, systems to load and configure encounters, a way to determine when an encounter has ended and what the result was, and a new entry option that leads to the encounter.
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.
Encounter
Just like we have created prefab assets for each Entry, we will also be creating prefab assets to represent each Encounter. In the future this will hold all of the custom data each battle should know about, whether it be what background to fight on, what locations heroes and enemies spawn, what kind of enemies can appear, or any other related battle information we will need. In the immediate, it will serve merely as a sort of book mark that indicates where in the story we are, and where we will go depending on the outcome of our battle.
Create a new folder at Assets -> Scripts -> SoloAdventure named Encounter. Then create a new C# script within the folder that is also named Encounter. Add the following:
using UnityEngine; public interface IEncounter { string VictoryEntry { get; } string DefeatEntry { get; } } public class Encounter : MonoBehaviour, IEncounter { public string VictoryEntry { get { return victoryEntry; } } [SerializeField] string victoryEntry; public string DefeatEntry { get { return defeatEntry; } } [SerializeField] string defeatEntry; }
We started out by defining an interface named IEncounter. If we implement our feature according to this interface, then we are not restricted to implementing the encounters as components on a GameObject. Maybe we will decide we would rather use a scriptable object, or even a native C# object or struct – who knows?
For now though, we are implementing this as a component on a GameObject, so we also created the concrete class as a subclass of MonoBehaviour. It uses serialized fields to hold the entries to target based on a victory or defeat, and then exposes them with public properties to conform to the interface.
Encounter System
Similar to how we have an Entry System to specify the name of an Entry which should be active, we will now add an Encounter System that will let us specify the name of an Encounter which should be active.
Create a new C# script in the same folder named EncounterSystem and add the following:
public partial class Data { public string encounterName; } public interface IEncounterSystem : IDependency<IEncounterSystem> { void SetName(string name); string GetName(); } public class EncounterSystem : IEncounterSystem { public void SetName(string name) { IDataSystem.Resolve().Data.encounterName = name; if (!string.IsNullOrEmpty(name)) IEntrySystem.Resolve().SetName(string.Empty); } public string GetName() { return IDataSystem.Resolve().Data.encounterName; } }
Apart from the names this system looks nearly identical to the EntrySystem with one slight change. In the SetName method we added a new check. When we determine that the game should be showing an encounter, by assigning an encounter name which isn’t null or empty, then the system will take the opportunity to clear out any name that may be held by the EntrySystem. This makes sure that we are always in EITHER an Encounter or Entry, but not both at the same time.
Open the SoloAdventureInjector and add the following to its Inject method:
IEncounterSystem.Register(new EncounterSystem());
Entry System
Open the EntrySystem script and edit the SetName method so it has a similar check as our new EncounterSystem. That is, when we set a non-empty Entry name, then we should clear any Encounter name.
public void SetName(string name) { IDataSystem.Resolve().Data.entryName = name; if (!string.IsNullOrEmpty(name)) IEncounterSystem.Resolve().SetName(string.Empty); }
Encounter Asset System
Next we will create a system which can load our GameObject assets by name, and return the attached component. Create a new C# script at Assets -> Scripts -> AssetManager named EncounterAssetSystem and add the following:
using UnityEngine; using Cysharp.Threading.Tasks; public interface IEncounterAssetSystem : IDependency<IEncounterAssetSystem> { UniTask<IEncounter> Load(); UniTask<IEncounter> Load(string entryName); } public class EncounterAssetSystem : IEncounterAssetSystem { public async UniTask<IEncounter> Load() { var encounterName = IEncounterSystem.Resolve().GetName(); return await Load(encounterName); } public async UniTask<IEncounter> Load(string encounterName) { var assetManager = IAssetManager<GameObject>.Resolve(); var key = string.Format("Assets/Objects/Encounters/{0}.prefab", encounterName); var prefab = await assetManager.LoadAssetAsync(key); return prefab.GetComponent<Encounter>(); } }
Open the AssetManagerInjector and add the following to the Inject method:
IEncounterAssetSystem.Register(new EncounterAssetSystem());
Combat Result
Create a new folder at Assets -> Scripts named Combat. Then create a new C# script within the folder named CombatResultSystem and add the following:
using UnityEngine; public enum CombatResult { Victory, Defeat } public interface ICombatResultSystem : IDependency<ICombatResultSystem> { CombatResult? CheckResult(); } public class CombatResultSystem : ICombatResultSystem { public CombatResult? CheckResult() { if (Input.GetKeyUp(KeyCode.V)) return CombatResult.Victory; if (Input.GetKeyUp(KeyCode.D)) return CombatResult.Defeat; return null; } }
Every encounter should produce a CombatResult, which is an enum representing the outcome of the battle. We can poll our CombatResultSystem for an optional result – so when the combat is undecided it will return null. Otherwise, it can tell us whether or not the adventurer was victorious or not.
The implementation of our system is just a quick placeholder because there is still quite a lot to do before we have implemented a full battle. In the meantime, you can simply press the ‘V’ key to simulate the combat resulting in a victory condition, or press the ‘D’ key to represent the combat resulting in a defeat condition.
Add a new script in the same folder named CombatInjector and add the following:
public static class CombatInjector { public static void Inject() { ICombatResultSystem.Register(new CombatResultSystem()); } }
Open the main Injector script and add the following to its Inject method:
CombatInjector.Inject();
Encounter Flow
We will use a new flow to handle encounters. This includes things such as loading the proper scene, loading the encounter asset and any other combat related assets that need to appear, and then waiting for combat resolution and routing the game based on the combat result.
Create a new C# script at Assets -> Scripts -> Flow named EncounterFlow and add the following:
using Cysharp.Threading.Tasks; using UnityEngine.SceneManagement; public interface IEncounterFlow : IDependency<IEncounterFlow> { UniTask Play(); } public class EncounterFlow : IEncounterFlow { public async UniTask Play() { var encounter = await Enter(); var combatResult = await Loop(); await Exit(encounter, combatResult); } async UniTask<IEncounter> Enter() { await SceneManager.LoadSceneAsync("Encounter"); var asset = await IEncounterAssetSystem.Resolve().Load(); return asset; } async UniTask<CombatResult> Loop() { CombatResult? combatResult = null; while (!combatResult.HasValue) { await UniTask.NextFrame(); combatResult = ICombatResultSystem.Resolve().CheckResult(); } return combatResult.Value; } async UniTask Exit(IEncounter asset, CombatResult result) { switch (result) { case CombatResult.Victory: IEntrySystem.Resolve().SetName(asset.VictoryEntry); break; case CombatResult.Defeat: IEntrySystem.Resolve().SetName(asset.DefeatEntry); break; } await UniTask.CompletedTask; } }
Open the FlowInjector and add the following to the Inject method:
IEncounterFlow.Register(new EncounterFlow());
Game Flow
Open the GameFlow script and edit the Loop method to handle starting our Encounter Flow, when the game has an Encounter name:
async UniTask Loop() { while (true) { var entryName = IEntrySystem.Resolve().GetName(); var encounterName = IEncounterSystem.Resolve().GetName(); if (!string.IsNullOrEmpty(entryName)) await IEntryFlow.Resolve().Play(); else if (!string.IsNullOrEmpty(encounterName)) await IEncounterFlow.Resolve().Play(); else break; await UniTask.NextFrame(); } }
Encounter Entry Option
Create a new C# script at Assets -> Scripts -> SoloAdventure -> Explore -> EntryOptions named EncounterEntryOption and add the following:
using UnityEngine; public class EncounterEntryOption : MonoBehaviour, IEntryOption { public string Text { get { return text; } } [SerializeField] string text; [SerializeField] string encounterName; public void Select() { IEncounterSystem.Resolve().SetName(encounterName); } }
Encounter Entry
Open Entry_02, the entry which should lead to our first encounter, and remove the placeholder Explore Entry Option that we had been using to bypass the battle. Instead, add a new EncounterEntryOption with Text of “Fight” and an Entry Name of “Encounter_01”.
Defeat Entry
Create a new Entry asset named Entry_04 to handle a defeat scenario. Use the following Entry Text:
You actually died? I’m sure you’re probably blaming unlucky rolls right now. Just accept that you need to do better and try another round of the game.
Also add an Explore Entry Option with Text: “Game Over” and no target Entry Name.
Encounter Asset
Create a new folder at Assets -> Objects named Encounters. Create an Empty GameObject in any scene. Name it Encounter_01 and then drag it to the new asset folder to create a prefab asset. Delete the instance from the scene, and select the project asset to continue building it.
Add an Encounter component to our asset with the following setup:
- Victory Entry: Entry_05
- Defeat Entry: Entry_04
Enable the Addressable toggle for the asset, then save the project.
Encounter Scene
Create a new Scene in the Assets -> Scenes folder. I used the basic template so that a camera and light would be included. Name the scene Encounter, and add it to the Build Settings: Scenes In Build.
Add a new Empty GameObject named “Demo” to serve as the root object in the scene. Then attach a GameObjectAssetManager component. Make sure to save the scene.
Demo Time
Run the game from the LoadingScreen scene. When you reach the Encounter screen, it will just be blank for now, but that’s ok, we can move on by using the keyboard. Press the “V” key to simulate a victory, or a “D” key to simulate a defeat and the game should continue with the rest of the story.
Summary
We covered a lot in this one lesson, including creating new assets, systems and a flow to tie it all together. Since it all looks similar to other processes we’ve already followed I figured it was manageable – and hey, if you’re reading to this point, it probably was! As a result, we were able to have something to “show” for our efforts this lesson. Granted, it is merely going to a blank scene and then being able to fake a battle with whatever result we wanted, but the foundational work was important, and we can take our time fleshing out the battle from here on out.
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!