Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Question Basic AI: "Modular" Conditions

Discussion in 'Scripting' started by ckamarga, Nov 1, 2022.

  1. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Hello,

    Context
    I'm currently working on a very simple RPG and I want to design a basic AI system. To help give context, my battles are essentially like old-school Pokemon:
    • Turn-based
    • 1v1
    • You choose your action and then combat resolves both you and the enemy's action
    • For now, the enemy can either Attack, use an Ability, or Escape
    I'm not trying to get help on the entire design in this one post and want to approach this one step at a time. The first is creating conditions.

    What I'm aiming for
    So each action (attack, ability, and escape) has a weighting value. Depending on certain conditions, these values will change overtime when certain conditions are met. Some examples of conditions are:
    • If HP <= Max HP / 2
    • If Turns >= 5
    • If inflicted with Poison
    • If MP >= 10
    Now, obviously it will be inefficient to write an IF statement for every possible combination since for one condition there could be up to six different operators used ==, !=, <, >, >=, or <=.

    So I want to know how I can make conditions modular so that I can choose a primary condition (HP, Turns, Status Effect, MP), choose an operator, then choose a value, and have it all work out. I keep looking at it like I'm structuring a sentence but you obviously can't turn strings into variables or operators... right?

    How I plan on using this
    The idea is that this basic AI system will be on a script which I then attach to an enemy and then I would set its conditions, values, and effects (for now, they would just give different weightings for each action) all in the same script and an enemy can have multiple copies of these scripts (each one with their own parameters). But only one AI script can be active at any given time so once a script's condition has been met, any previous scripts will be disabled.

    I hope that's all clear. If not, I'll be more than happy to elaborate or add more information.

    Thanks,
     
  2. kruskal21

    kruskal21

    Joined:
    May 17, 2022
    Posts:
    68
    Hi. I gather that what you are looking for is a code-free way of assigning evaluable conditions in the inspector. Let me know if my understanding of what you want is correct or not.

    Here are my thoughts on this. First, I would recommend to structure it so that you use a single script for each enemy object, rather than one script for each condition. The main reason for this is that the scripts can't easily find out how they have been ordered in the inspector (I believe there is a way, but it's a bit hacky).

    Then we can break this problem down to two parts. One: how to represent a condition in the inspector. Two: how to take the representation and evaluate it as a real condition.

    My idea is as follows, define two enum to represent the subject and the comparison operation:
    Code (CSharp):
    1. public enum Subject
    2. {
    3.     Turns,
    4.     HP,
    5.     MP,
    6.     IsPosisoned,
    7.     ...
    8. }
    Code (CSharp):
    1. public enum Comparison
    2. {
    3.     Equal,
    4.     NotEqual,
    5.     Less,
    6.     LessEqual,
    7.     Greater,
    8.     GreaterEqual,
    9. }
    10.  
    Note that you may want to use ScriptableObject to represent Subject instead. Unity serializes enums as ints, which makes it tricky to change or reorder enum values between saving and loading.

    Then, we can represent a condition using a custom class:
    Code (CSharp):
    1. [System.Serializable]
    2. public class Condition
    3. {
    4.     public Subject subject;
    5.     public Comparison comparison;
    6.     public float value;
    7. }
    Finally, you can start creating your AI script:
    Code (CSharp):
    1. public class EnemyAI : MonoBehaviour
    2. {
    3.     public Condition[] conditions;
    4. }
    Drag this script to a gameobject. You can freely add, remove, change, and reorder conditions in the inspector. Now we go on to the second part, which is to take this data and make it evaluable. This is more dependent on your code, but could look something like this inside the EnemyAI script:
    Code (CSharp):
    1. public bool Evaluate(Condition condition)
    2. {
    3.     float subjectValue = (condition.subject) switch
    4.     {
    5.         Subject.Turns => (float)myGameData.turnNumber,
    6.         Subject.HP => (float)hp,
    7.         Subject.IsPoisoned => isPoisoned ? 1f : 0f,
    8.         ...
    9.     }
    10.  
    11.     return (condition.comparison) switch
    12.     {
    13.         Comparison.Equal => subjectValue == condition.value,
    14.         Comparison.NotEqual => subjectValue != condition.value,
    15.         ...
    16.     }
    17. }
    And that should be it. You can use the Evaluate method to evaluate each of the conditions in the list. Depending on how exactly you want to structure your enemy AI, you can extend the code in several ways, for example, by mapping conditions to actions using a Dictionary.
     
    Last edited: Nov 1, 2022
  3. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,862
    What you're looking for is socketable behaviour, and in Unity you have two effective options: scriptable objects, or getting an addon that lets you make use of SerializeReference.

    With scriptable objects you can work with a base class and make child types:
    Code (CSharp):
    1. public abstract LogicObjectBase : ScriptableObject
    2. {
    3.     //just an example, but you could pass through anything that gives you access to what you need
    4.     public abstract bool Evaulate(EntityController controller);
    5. }
    6.  
    7. //example use case that evaulates whether a target health is below a certain %
    8. [CreateAssetMenu(menuName = "Logic Objects/Health Threshold")]
    9. public HealthThresholdObject : LogicObjectBase
    10. {
    11.     [SerializeField, Range(0, 1)]
    12.     private float healthThreshold = 0.5f;  
    13.  
    14.     public override bool Evaulate(EntityController controller)
    15.     {
    16.         return controller.Target.HealthPercentage <= healthThreshold;
    17.     }
    18. }
    Then you can just reference them in however structure you like to determine an entities course of action.

    Upside of this is that it works out of the box with Unity. Downside is you end up making lots of scriptable objects, more than you really need to.

    The alternative, and my preference, would be to use SerializeReference, which lets you serialise polymorphic reference types, even via interfaces (so long as they don't inherit from UnityEngine.Object). This does require 3rd party inspector support (there are good free and paid options out there), but means you don't have to make tons of scriptable objects and can just serialise all the logic into whatever object you need it in:
    Code (CSharp):
    1. public interface ILogic
    2. {
    3.     public bool Evaulate(EntityController controller);
    4. }
    5.  
    6. [System.Serializable]
    7. public LogicHealthThreshold: ILogic
    8. {
    9.     [SerializeField, Range(0, 1)]
    10.     private float healthThreshold = 0.5f;  
    11.  
    12.     public bool Evaulate(EntityController controller)
    13.     {
    14.         return controller.Target.HealthPercentage <= healthThreshold;
    15.     }
    16. }
    17.  
    18. //usage
    19. [SerializeReference]
    20. private List<ILogic> enemyLogics = new List<ILogic>();
    Whenever you need a different kind of logic, you can just make more plain classes implementing ILogic. You can also use an abstract base class too.
     
    Kurt-Dekker likes this.
  4. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Thanks for the replies guys.

    After reading both, I think I'll try kruskal21's approach first because that is closer to what I was thinking so it's more understandable for me. I appreciate your approach too spiney199 but I think your method may be too advanced for me. I don't actually quite follow your approach/logic.

    I probably should have mentioned my programming level is still beginning/intermediate but hey, what better time to learn!

    Anyway, I'll start working on this soon but I do have a couple of questions for you kruskal21:

    1. What do you mean I may use ScriptableObjects to represent the Subject instead? Do you mean that each Subject would be its own ScriptableObject? I also don't understand what you mean regarding the saving and loading (I'm not familiar with saving and loading yet).

    Code (CSharp):
    1. float subjectValue = (condition.subject) switch
    2.     {
    3.         Subject.Turns => (float)myGameData.turnNumber,
    4.         Subject.HP => (float)hp,
    5.         Subject.IsPoisoned => isPoisoned ? 1f : 0f,
    6.         ...
    7.     }
    2. I've never see this before. I thought it was a switch statement but it doesn't have cases. From the looks of it, you're assigning values to each of those Subject types (grabbed from their relevant places like the turnNumber) but you are only returning or storing the Subject that is referenced in (condition.subject)?

    Code (CSharp):
    1. return (condition.comparison) switch
    2.     {
    3.         Comparison.Equal => subjectValue == condition.value,
    4.         Comparison.NotEqual => subjectValue != condition.value,
    5.         ...
    6.     }
    3. So this is similar to before where you are basically only looking at the relevant comparison that's referenced in (condition.comparison)?

    EDIT: While I was working on this and learning it at the same time, I get a green line under both switches saying:

    The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(Comparison)6' is not covered.

    But it looks like I have because, for example, I did all the Comparison types so not sure what this error means?
     
    Last edited: Nov 2, 2022
  5. kruskal21

    kruskal21

    Joined:
    May 17, 2022
    Posts:
    68
    No problem. I will answer your questions 2 and 3 first.

    What you are looking at is a switch expression, which is another way to use switch, that often can achieve the same result as a switch statement, but using less code.

    Code (CSharp):
    1.     float subjectValue = (condition.subject) switch
    2.     {
    3.         Subject.Turns => (float)myGameData.turnNumber,
    4.         Subject.HP => (float)hp,
    5.         ....
    6.     };
    The meaning of these lines is: I want to know what the value of condition.subject is.
    If it is Subject.Turns, then please cast myGameData.turnNumber as a float, then save it into the subjectValue variable.
    If it is Subject.HP, then please cast hp as a float, then save it into the subjectValue variable.
    If it is ..., then please give me ..., then save it into the subjectValue variable.

    The reason you are getting green lines is because C# wants you to cover all possibilities. It may look like you already have by giving a switch branch for every enum value you have. But enums can actually contain values outside of that. This is because enums are just ints under the hood.

    Code (CSharp):
    1. public enum Subject
    2. {
    3.     Turns,
    4.     HP,
    5.     MP,
    6.     IsPoisoned,
    7.     ...
    8. }
    In this enum, Turns will be 0, HP will be 1, MP will be 2, and so on. You can turn any enum into an int by doing (int)myEnum, and the reverse by doing (MyEnum)myInt. This leaves open the possibility that the switch will encounter a value outside of what you expect. So C# want you to cover that possibility too. Most of the time, you can just do this:

    Code (CSharp):
    1.     float subjectValue = (condition.subject) switch
    2.     {
    3.         Subject.Turns => (float)myGameData.turnNumber,
    4.         Subject.HP => (float)hp,
    5.         _ => throw new System.Exception("Subject value out of range");
    6.     };
    Which says that, I don't think that's actually possible, just throw an error if it actually happens.

    This leads very well to answering your question on saving and loading. Serialisation is the act of turning data into text, and is the primary way saving data in games. Because enums are just ints under the hood, Unity by default serialise them into ints. This creates a problem. Let's say that in your game version 0.1.0, your Subject enum looks like this:

    Code (CSharp):
    1. public enum Subject
    2. {
    3.     Turns, // Serialised as 0
    4.     HP, // Serialised as 1
    5.     MP, // Serialised as 2
    6.     IsPoisoned, // Serialised as 3
    7.     ...
    8. }
    And a player plays the game and saves it. In the save text file, one of the enemies had a condition of IsPoisoned, so it has been serialised as an int, 3. Great, no issues here.

    Now the player loads the game. It has been a while since they last played, and by now you have launched the biggest update yet, which adds a brand new stat, Special Points, SP, into the game.

    Code (CSharp):
    1. public enum Subject
    2. {
    3.     Turns, // Serialised as 0
    4.     HP, // Serialised as 1
    5.     MP, // Serialised as 2
    6.     SP, // Serialised as 3
    7.     IsPoisoned, // Serialised as 4
    8.     ...
    9. }
    10.  
    And Unity loads the player's save file by deserialising it, turning text back into data. It sees a 3.
    3 is... it was IsPoisoned, but Unity doesn't know that. It sees only how your enum is now, which means 3 is clearly SP. Suddenly, that particular enemy has a condition about SP, and not IsPoisoned.

    I hope all of these explanations make sense to you. Do reply here if something confuses you. Since this post is already long, I plan to address your question on ScriptableObject in another post.
     
    Last edited: Nov 2, 2022
  6. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,862
    Worth familiarising yourself with the core of C# which is Object Oriented Programming, which is all my example uses. Just some inheritance/composition.

    I'm not a fan of the other example as enums are an anti-pattern that introduce scalability issues and turn your code into a mess.
     
  7. kruskal21

    kruskal21

    Joined:
    May 17, 2022
    Posts:
    68
    Now, for your question on ScriptableObject. You see that enums are tricky when you expect to add, remove, or change values in the future, as it makes previous save data vulnerable to being interpreted incorrectly. ScriptableObject is a great alternative. They are like GameObject, but instead of living in your scene hierarchy, they live in your Assets folder. Now, see what we do if want to represent Subject using scriptable objects.

    Code (CSharp):
    1. abstract class SubjectBase : ScriptableObject
    2. {
    3.     public abstract float GetValue(Enemy enemy, MyGameData myGameData);
    4. }
    We are now entering territory spiney199 has been referring to. We created an abstract class called SubjectBase. An abstract class is a class that cannot be instantiated, its sole purpose is to be inherited from.
    Since SubjectBase is useless alone, we now create some classes that inherit from it.

    Code (CSharp):
    1. [CreateAssetMenu]
    2. class HPSubject : SubjectBase
    3. {
    4.     public override float GetValue(Enemy enemy, MyGameData myGameData);
    5.     {
    6.         return (float)enemy.hp;
    7.     }
    8. }
    9.  
    10. [CreateAssetMenu]
    11. class TurnsSubject : SubjectBase
    12. {
    13.     public override float GetValue(Enemy enemy, MyGameData myGameData);
    14.     {
    15.         return (float)myGameData.turnNumber;
    16.     }
    17. }
    18.  
    19. [CreateAssetMenu]
    20. class IsPoisonedSubject : SubjectBase
    21. {
    22.     public override float GetValue(Enemy enemy, MyGameData myGameData);
    23.     {
    24.         return enemy.isPoisoned ? 1f : 0f;
    25.     }
    26. }
    Can you see the similarity between this code and the switch expression in my previous example? The code used is the same. "return (float)enemy.hp;" "return (float)myGameData.turnNumber;", and "enemy.isPoisoned ? 1f : 0f;" were all branches of the switch expression. But how do we use the new code?

    It's simple. First, change the Condition class to use SubjectBase:
    Code (CSharp):
    1. [System.Serializable]
    2. public class Condition
    3. {
    4.     public SubjectBase subject;
    5.     public Comparison comparison;
    6.     public float value;
    7. }
    8.  
    Then, we change the Evaluation method in the EnemyAI script to do this:
    Code (CSharp):
    1. public bool Evaluate(Condition condition)
    2. {
    3.     // myEnemy is the MonoBehaviour that contains the data like hp, isPoisoned, etc.
    4.     float subjectValue = Condition.subject.GetValue(myEnemy, myGameData);
    5.  
    6.     return (condition.comparison) switch
    7.     {
    8.         Comparison.Equal => subjectValue == condition.value,
    9.         Comparison.NotEqual => subjectValue != condition.value,
    10.         ...
    11.     }
    12. }
    And that should be it. Go to your Assets folder, right-click anywhere and create one of each HPSubject, TurnsSubject, and IsPoisonedSubject. You can then these scriptable objects in the conditions list.

    I understand where you are coming from. I would say that enums are valuable in cases where you want to represent something with no additional data or behaviour, and you don't expect to change its values.
     
    Last edited: Nov 2, 2022
  8. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,862
    Agreed, but this isn't one of those situations.
     
  9. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Thanks very much for explaining everything kruskal21, I really appreciate it.

    I understand the switch expression much clearer now so that's great and I understand what you mean about the saving and loading, and it makes sense yeah as it helps future-proof the system.

    Alright, I think I have enough now to finish my basic AI system. So I'm gonna continue implementing your advice and see if I can smoothly integrate everything with the rest of my game.

    This might take a few days for me haha but I will report back here if I have more questions or if I am successful.

    Wish me luck :D
     
    kruskal21 likes this.
  10. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Hi again,

    So I managed to integrate your system into my game and so far it's all working well. Again, thanks so much for the help!

    To make things easier for me when I end up creating my enemies, I decided to make each enemy behaviour its own EnemyAI script (i.e. I made conditions not an array). So if an enemy has 3 types of behaviours, it would have 3 EnemyAI scripts attached to it, each with their own unique conditions and effects (this makes it easier for me to read).

    So for example, an enemy could have a behaviour where if their health is 50% or lower, then they have access to a new ability and their action weights will change so that they are more likely to cast that ability.

    What I'm thinking of doing is during battle, my CombatController will go through the enemy's list of behaviours each turn to see which one should be active for that turn. If it finds a behaviour whose condition is true, then it will pick that one for the enemy. If a behaviour becomes obsolete, then it gets ignored (probably by deactivating them or flagging them). I will probably need to find a way to prioritize these behaviours but I figure on the enemy prefab, I can just order them from hardest to easiest so that the game checks the harder behaviours first. I'm still kinda working on this logic.

    Also, while I was implementing the EnemyAI and re-reading this thread every now and then, I realized that I should also make ScriptableObjects of other things that can change in the future like StatusEffects (Burn, Poison, etc.), Elements (Fire, Water, etc.) and perhaps even the combat actions themselves.

    Anyway, so far so good. I'm currently trying to clean up my code and implementing other aspects of combat like the aforementioned status effects, elements, and some actions like escape and block.

    I do have a couple of questions though:

    Code (CSharp):
    1.  
    2. abstract class SubjectBase : ScriptableObject
    3. {
    4.     public abstract float GetValue(Enemy enemy, MyGameData myGameData);
    5. }
    6.  
    1. This is a function that returns a float yes? How come it can have no curly brackets?
    2. I understand what abstract means for a class, but what does abstract mean for a function? Is this why it doesn't need curly brackets?

    Thanks,
     
  11. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,862
    I mean the docs kinda explain it: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/abstract

    An abstract has an incomplete implementation, and may contain abstract methods and properties that themselves are incomplete. When you derive from the abstract type, you must complete the implementation (granted your derived type is no abstract itself).
     
  12. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Hmm OK. So that explains why it's incomplete but what is the benefit of creating that incomplete method in the abstract class?

    It looks like it just acts as a reminder so that if someone derives from that abstract class, they need to define that method for the new class?
     
  13. MelvMay

    MelvMay

    Unity Technologies

    Joined:
    May 24, 2013
    Posts:
    10,511
    spiney199 likes this.
  14. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,862
    Polymorphism. You can treat derived types as their base type. Calling a base type method will run the code of the derived type, simply speaking.

    Same with interfaces. A type implementing an interface can be treated as that interface.

    This is the basic concept behind my original example of a way to achieve what you wanted. Have a base class or interface, make types that derive from/implement said type, and use polymorphic serialisation to swap behaviour in and out.

    It's simple yet rather powerful, especially in the context of Unity.
     
    MelvMay likes this.
  15. ckamarga

    ckamarga

    Joined:
    Aug 31, 2013
    Posts:
    15
    Hmm I think I will need to read more documentation on that and maybe some tutorial videos. I understand things better when I put them into practice. But I'm slowly getting there haha

    Now that I have a bit more information, I'll check out your original example again spiney199. Thanks you two!