2
\$\begingroup\$

A rather simple question, how do you check equality of a scriptable object without references?

For example, I have a weapon class that holds a list of damage types the weapon deals, which consists of a float and a ScriptableObject class (Element).

[System.Serializable]
public class DamageInfo
{
    public float damage;
    public Element damageType;

    public DamageInfo(float damage, Element damageType)
    {
        this.damage = damage;
        this.damageType = damageType;
    }
}

Elsewhere in my code on the weapon I have my DoDamage function.

private IEnumerator DoDamage(WeaponAttack attack, Health otherHealth, Vector3 position)
{
    OnHitObject?.Invoke(this, otherHealth.gameObject, position);
    foreach (var damageInfo in Damage)
    {
        //if(damageInfo == Fire) etc
       
        yield return new WaitForSeconds(0.1f);
    }
    yield break;
}

What I would like to do is check what type of Element damage I am currently iterating in order to apply different modifiers, I know this can be done through setting up editor references and comparing against them, but that would require far too much editor setup each time and seems to me using a simple enum while less flexible would be less work overall(even to change in future).

Even setting all the references one time on a globally accessible class, removes the modularity/easy extensibility of using ScriptableObjects in the first place.

Is there any better way I can check for equality without having to use editor set references? Seems to me to be a huge downside of ScriptableObjects.

Thanks

\$\endgroup\$
5
\$\begingroup\$

One of the advantages of defining your elements/damage types/etc. in data as ScriptableObjects is that you can add/remove/update the collection of elements with pure data changes — no new code, refactoring, or re-compilation required. You might even be able to ship new elements in bonus content packs or mods.

But by checking for specific elements explicitly inside your DoDamage() function, you lose those benefits. Now even if you can add a new element purely with data, you still need code changes to imbue it with its special characteristics.

There are a few ways we can respond to this:

  1. Accept that new elements will always require new code anyway, and hard-code the set of elements into an enumeration that's easy to check against.

  2. Invert the dependency, so instead of DoDamage() handling the special characteristics for each element, that behaviour instead lives in the element data, and DoDamage() can ask the element to apply its special effects on its behalf, without needing to know which element is which.

  3. Create an abstraction, where DoDamage() doesn't need to know about specific elements, just common categories of damage response.


Option 1 is the simplest. Create an enum that lists your elements...

public enum ElementType {
    Normal,
    Fire,
    Ice,
    // etc...
}

Then your Element class can expose its type as a read-only member you can set in the Inspector:

[field:SerializeField]
public ElementType elementType {get; private set;}

And DoDamage() can use a switch statement to split out different behaviours for each type, without comparing them against a cached reference:

foreach (var damageInfo in Damage)
{
    switch (damageInfo.damageType.elementType) {
         case ElementType.Fire:
            // TODO...
            break;
    }
}

This assumes you're still using your Element ScriptableObjects to hold other properties - like say localized text, icons, colours, effects to use for the element. If they're just an empty object used as an ID, then you can remove them and completely replace them with the ElementType enum instead.


Option 2 can take different forms depending on your needs. The common thread is that we've removed the handling of element-specific effects from the Weapon class and pushed them into the Element class or something it references. The weapon's DoDamage() method might then look like...

foreach (var damageInfo in Damage)
{
    damageInfo.damageType.OnDoDamage(attack, otherHealth, position);
}

Nice and tidy, and never needs to change as we edit our collection of elements. How we implement this on the element side can be a bit more varied...

Maybe you have only a few specific kinds of special effect you might want to mix and match. You could code those behaviours into your Element class along with some member variables to enable/disable or tune them. Something like...

public void OnDoDamage(WeaponAttack attack, Health otherHealth, Vector3 position) {
    if (_statusEffect != null) {
        _statusEffect.ApplyTo(otherHealth.gameObject);
    }

    if (_damageOverTime > 0f) {
        otherHealth.ApplyDamageOverTime(_damageOverTime);
    }

    // ...
}

Or maybe you want more bespoke behaviour for some elements, without turning all elements into a giant mess of optional effect toggles and parameters with tons of conditional checks in your combat code. You could make OnDoDamage virtual, and then make subclasses of Element that override it with just the behaviour you need for each subtype.

That doesn't need to mean a new code file for every new element. I implemented a system like this recently and found a lot of elements I wanted to use shared similar behaviours, so I could often stamp out a handful of different elements from a single derived type. And although this does mean we're still changing code to add new elements, those code changes are localized to the new element's class itself, not spread like tendrils through the weapon or other scripts that use elements.

If you need even more flexibility, you could create an abstract class that represents one "building block" of an effect to apply on damage, then derive that to make building blocks that spawn prefabs or change stats or manipulate AI, etc... Then each element ScriptableObject can have a [SerializeReference] collection of these building blocks assigned to it, letting you build up complex behaviour purely through data. Its OnDoDamage() method then iterates that collection, invoking each building block as it goes.


Option 3 is a bit of a hybrid, useful if you want a large and flexible collection of elements, but have a relatively controlled vocabulary of what elements are allowed to do.

For instance, we could create a flags enumeration that lets us tag an element with various different properties it should have...

[System.Flags]
public enum ElementEffect {
    None        = 0,
    Loud        = 1 << 0,
    Bright      = 1 << 1,
    Ignite      = 1 << 2,
    Paralyze    = 1 << 3,
    // etc...
}

Then expose such a set of tags on the Element ScriptableObject.

Your DoDamage method can then check, for each damage type, does it have any relevant behaviours to trigger. Something like...

foreach (var damageInfo in Damage)
{
    if ((damageInfo.damageType.effects & ElementEffect.Loud) != ElementEffect.None) {
        // TODO: Alert nearby agents with hearing.
    }

    if ((damageInfo.damageType.effects & ElementEffect.Ignite) != ElementEffect.None) {
        if (otherHealth.IsFlammable) {
            // TODO: Set target on fire.
        }
    }

    // Etc.
}

Here, the weapon code processing these behaviours doesn't need to know whether the flammable target is being ignited by a "Fire" element or an "Explosion" element or by a "Lightning" element - as long as they use the common stimulus ElementEffect.Ignite then we trigger the corresponding behaviour. And we can add new elements that share this behaviour without modifying the weapon code. (Though of course, we still need to write new code when we want to introduce a new behaviour).

\$\endgroup\$
1
  • \$\begingroup\$ Thank you! This is very helpful and has given me some things to chew over, I have something similar to option 2 already implemented for the character motor and option 3 with the behaviour flags is something I definitely want to implement. Thanks again! \$\endgroup\$ – Pheonix2105 May 13 at 18:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.