Search Unity

Feedback Architecture of a Spell System

Discussion in 'Scripting' started by RadRedPanda, Jul 1, 2021.

  1. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    I'm creating a spell system in my game, similar to the one in the Magicka series if you've played those. There's an explanation of them in the next paragraph, so if you know how it works you can skip it.

    In Magicka, you can combine different elements to cast spells, in a kind of recipe system. You use Earth and Fire and it'll create a flaming boulder which you can launch at enemies. Water and Cold will create ice shards, which you can machine gun out. There's even different contexts in the game, like you can cast Earth and Shield regularly to create a wall out of earth, or you can cast it on yourself to create armor out of it. There's even special combos which have unique spell effects in the game, where if you have the exact right combination it'll cast.

    I'm struggling to figure out what kind of architecture I want in order manage the spell system, and am a little hesitant to move forwards, as I don't want this to become a pain later on in development.

    I want one that has a recipe system similar to one in Magicka, where you can chain different elements together to cast a spell. Currently, I'm using ScriptableObjects for the recipes, which takes in multiple symbols and has a resulting Spell enum which it spits out.

    upload_2021-6-30_22-40-32.png

    So this seems pretty straight-forward right now, but I'm trying to figure out how to organize my spells, since each has a unique effect.

    My initial thought was to create a parent class of Spell in order for all of the spells to inherit some properties and methods from, like ManaCost and CastSpell(), however this seemed inefficient since it's creating a bunch of objects which need to be initialized and they're just sitting around most of the time. Not only that, I wouldn't be able to edit them in any way in the Inspector.

    My second thought was some kind of abstract static class, in which I would give in the Spell type and it would call the spell based on the class. This doesn't seem to be possible though, as you can't override a static method and a class can't be abstract and static, which makes sense.

    My third thought was to edit my Spell Recipe ScriptableObject to somehow hold a delegate, which could be easily edited, however it doesn't seem like I can reference a method from a ScriptableObject. I don't know enough about it to really say for sure though, so I'd be happy for information about this.

    Pretty much asking if there's a clean way to do this, or if anyone has any ideas on how they would go about it.

    td;lr, is there a good way to implement an Action/Event/delegate property in ScriptableObjects, to allow you to select a method in another script?
     
    Last edited: Jul 1, 2021
  2. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    7,842
    I'm not at home to test this, and I could be super wrong, but rather than having an enum for the output, you could instead have a field for a base abstract script class (I can't remember off the top of my head if you can do this)? Your base abstract class would outline the abstract/virtual methods required to fire off/perform the spell, and all your different spell effects inherit from this class, and can be used in the aforementioned field due to polymorphism, getting called by the spell recipe.
     
    RadRedPanda likes this.
  3. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    This is similar to the first idea I had, however I don't think ScriptableObjects can take a type, as an object needs to be initialized before it can be included in a ScriptableObject like that. This is also why I wanted some kind of abstract static class as it's guaranteed to be a singleton, but with polymorphism. But alas, not possible.
     
    Last edited: Jul 1, 2021
  4. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    38,686
    RadRedPanda and Lekret like this.
  5. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    Thanks for the input! I'm thinking the behavior I'm looking for is closer to abstract classes, as each different class I'm creating is sharing properties, but the problem I'm running in to is that there's no way to easily display something like this in the Inspector. I've seen threads where they create an abstract class which is a child of MonoBehavior, and have the SpellManager script just get children of it on Start(), but I don't want to do this if there are other options, as it's singleton behavior.

    I can add a UnityEvent to my SpellRecipe ScriptableObject, but it doesn't have access to the methods, but if this did work, it's the kind of Inspector shenanigans I'd want.

    upload_2021-7-1_1-50-43.png
     
  6. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    So, progress update on what I've found. Pretty much I wanted the method which created tools for me to put in less effort overall, incase that wasn't clear.

    I ended up creating a new Window which ran code whenever the Assembly was reloaded.

    Code (CSharp):
    1. using System.Collections;
    2. using System.IO;
    3. using System.Linq;
    4. using System.Reflection;
    5. using UnityEditor;
    6.  
    7. public class UpdateSpellEnum : EditorWindow
    8. {
    9.     [MenuItem("Test/Show My Window")]
    10.     static void Init()
    11.     {
    12.         GetWindow<UpdateSpellEnum>();
    13.     }
    14.  
    15.     void OnEnable()
    16.     {
    17.         AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
    18.     }
    19.  
    20.     void OnDisable()
    21.     {
    22.         AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload;
    23.     }
    24.  
    25.     public void OnBeforeAssemblyReload()
    26.     {
    27.         string enumName = "SpellType";
    28.         MethodInfo[] spellMethods = typeof(SpellManager).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(x => x.ReturnType == typeof(IEnumerator) && x.GetParameters().Length == 0).ToArray();
    29.  
    30.         string filePathAndName = "Assets/Scripts/Utility/Enums/" + enumName + ".cs";
    31.  
    32.         using (StreamWriter streamWriter = new StreamWriter(filePathAndName))
    33.         {
    34.             streamWriter.WriteLine("public enum " + enumName);
    35.             streamWriter.WriteLine("{");
    36.             foreach (MethodInfo method in spellMethods)
    37.                 streamWriter.WriteLine("\t" + method.Name + ",");
    38.             streamWriter.WriteLine("}");
    39.         }
    40.         AssetDatabase.Refresh();
    41.     }
    42. }
    Right before the Assemblies are reloaded, I go into my SpellType enum and update the file to contain the names of all of my coroutines with 0 parameters in my SpellManager class (there may be a better way to use BindingFlags and checking the MethodInfo to narrow down what methods you want). I then take those enums, which I still have in my ScriptableObject, and just call the StartCoroutine() method with string parameter. It should be noted that you have to open the window for the code to actually run, even though it's empty.

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. [CreateAssetMenu(fileName = "New Recipe", menuName = "Gameplay/Spell Recipe")]
    4. public class SpellRecipe : ScriptableObject
    5. {
    6.     public Symbol[] symbolOrder;
    7.     public SpellType spellType;
    8. }
    Code (CSharp):
    1.     /// <summary>
    2.     /// Invokes the result spell given in the recipe
    3.     /// </summary>
    4.     /// <param name="recipe">The recipe of the spell we're casting</param>
    5.     private void castSpell(SpellRecipe recipe)
    6.     {
    7.         StartCoroutine(recipe.spellType.ToString());
    8.     }
    9.  
    10.     private IEnumerator createEarth()
    11.     {
    12.         yield return null;
    13.     }
    This works pretty autonomously, so all I have to do is create a new coroutine with 0 parameters whenever I want to create a new spell. Of course, it's not pretty behind the curtains, but it should save me a bunch of work down the road.

    Unfortunately, there's a little more runtime overhead from having to get the coroutine from the string, but I think it's worth the sanity I don't lose in the future. One thing I'm not happy about is that I'm going to have all of my Spells defined inside of my SpellManager class, which will make that file pretty long unfortunately.
     
  7. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    7,842
    Admittedly not how I'd go about it. But suppose we all have our own different styles.

    Playing around for half an hour and I produced the following classes. Firstly, the class for a spell Scriptable Object:

    Code (CSharp):
    1.  
    2.     using System.Collections.Generic;
    3.     using UnityEngine;
    4.  
    5.     [CreateAssetMenu(menuName = "Modular Spells/New Spell Combination")]
    6.     public class SpellCombinationSO : ScriptableObject
    7.     {
    8.         //Inspector Variables
    9.         [SerializeField]
    10.         private string spellName;
    11.  
    12.         [SerializeField]
    13.         public List<SpellElementSO> elementsCombination = new List<SpellElementSO>();
    14.  
    15.         [SerializeReference]
    16.         private SpellEffectBaseClass spellEffect;
    17.  
    18.         //Properties:
    19.         public string SpellName { get { return spellName; } }
    20.         public List<SpellElementSO> ElementsCombination { get { return elementsCombination; } }
    21.         public SpellEffectBaseClass SpellEffect { get { return spellEffect; } }
    22.     }
    The Spell elements are just empty Scriptable Objects, though they could easily have some form of functionality added.

    The SerializedReference-ed SpellEffectBaseClass is for a base class script, which you can derive from to make a new spell effect, script below:
    Code (CSharp):
    1.  
    2.     using UnityEngine;
    3.  
    4.     /// <summary>
    5.     /// Class in inherit from for the creation of new spell effects.
    6.     /// </summary>
    7.     public abstract class SpellEffectBaseClass
    8.     {
    9.         public virtual void PrepareSpell(GameObject caster) { }
    10.  
    11.         public abstract void SpellEffect(GameObject caster);
    12.     }
    I just pass in a gameobject (likely the player casting it) as I imagine you'll need to GetComponent from the player, but there's likely other ways to structure what you pass in (possibly with overloads).

    And after a bit of OdinInspector voodoo, it looks like this:

    Then beyond that point, I suppose you could manage a list of all your spell combinations in some form (I would use another ScriptableObject with some editor functions to find all combinations in your assets). Thus whenever your player input some combination of elements, your system looks through the list and pulls of whatever spell matches the combination.

    Though do note this has the easy potential for you to double up on combinations, but perhaps more editor tools could be used to check for duplicates.

    Again, banged this together in about 30 minutes as I was curious if it worked.
     
    RadRedPanda likes this.
  8. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    That's actually super cool! I'm stumped on how you got your SerializedReference object to show in the Inspector like that though, I'm guessing that's part of the voodoo you were talking about. I had actually never realized SerializeReference was a thing until now, and was interesting learning about it from some quick Google searches.

    Curious how you got the ScriptableObject to be able to select a class type, does it Instantiate a new object when it gets selected? Or is there more voodoo in being able to reference a class like that without making a new one.
     
  9. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    7,842
    The SerializedReference thing is a bit weird, and I'm not quite sure I'm using it correctly myself I admit. But you can Right-Click on the field, select 'Change-Type', and a list appears with all the possible options to select. That's default behaviour too. My debug spell just as a Debug.Log line, and the button to to call SpellEffect does indeed produce a debug log.

    But as far as I understand, the SerializedReference is a pointer to the script itself. Not super sure, so anyone else is free to correct me here.

    OdinInspector doesn't seem to be able to use it's own wizardry on it admittedly, though I have asked in the plugin's discord if there's a way around that or if I'm missing something.
     
    RadRedPanda likes this.
  10. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    Interesting! I don't think the first part is true though, it doesn't seem to be default behavior, but maybe you know something I don't. I ended up replicating your code to see if I could get my ScriptableObject to reference an abstract class like that, but the field is just blank.

    upload_2021-7-1_6-58-0.png

    I'm right clicking all over the field area and no menu seems to be popping up. So for now, I'm just chalking it up to be part of the voodoo magic of OdinInspector.
     
  11. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    7,842
    Apologies, you seem to be right. Opening up a new project without OdinInspector added in produces the same results as you. Bit of a bugger then, and hopefully someone else can provide some input as to how to get that to work otherwise.

    Otherwise, next best option is to make spell effects with scriptable objects. At the end of the day, a fire-ball, ice-ball, et-al, are all the same thing with different skins. But that's just me as I code a lot of functionality through scriptable objects.
     
    RadRedPanda likes this.