It works, but there's got to be a better way - computing final result of stats

I’m not sure what to call this, but basically, I’m trying to build a system that looks at the same stat values of two different items, and computes a final result based on instructions on how to combine each stat.

For example, say you have:

  • Weapon
  • Ammo - paired with a weapon

Each of these can contribute to the final stat values when combined. As stripped down example, say those stats are:

  • float damage

  • float accuracy

  • int maxAmmoCount

Now, in the past I was separating the stats into things only the weapon could affect, and things only the ammo could affect. But the thing is:

  • There are plenty of thematic ways to justify why each of these could modify any of the stats.
  • It’s much more convenient to see the final result on the weapon script, than having to find multiple parts.

So, the weapon script and the ammo script each have the same three stats, with instructions on how to apply the value.
The intent was to have the weapon version inherit the base stat class, and add combo instruction and final value.

So, the base class would have a float or int, and an enum saying the stat is to be used as a flat Value, or a Multiplier.
The derived class has the final value, and an enum to determine how to combine:

  • WeaponOnly - Only use the weapon stats
  • AmmoOnly - Only use the ammo stats
  • WeaponBase - Starts with the baseValue stat of the weapon and either adds or multiplies the baseValue stat of the ammo, based on the ValueType of the ammo stat
  • AmmoBase - Starts with the baseValue stat of the ammo and either adds or multiplies the baseValue stat of the weapon, based on the ValueType of theweapon stat

The final result is stored in the derived class as StatItem.value

To compute the final result, I have the weapon able to accept a reference to an ammo script prefab.
The the resulting final values are generated by calling:

public StatBlock weaponStats;
public AmmoClass ammo;

void Awake() {
        weaponStats.ComputeStats( ammo.ammoStats );
}

What I came up with, works… but… it feels convoluted, and I thinks there’s got to be a better way.

Problems:

  • I entirely failed to make StatBlock inherit from BaseStatBlock, at least while also having the individual stat items inherit. Duplicating each stat seems wasteful.

  • Needing a seperate class for floats and ints. More duplication.

  • Needing a way to convert between float and int during evaluation, and StatItem_int not inheriting the converter from the base class. Yet more duplication.

Stripped down version:

public enum ComboType { WeaponOnly, AmmoOnly, WeaponBase, AmmoBase }
public enum ValueType { Value, Multiplier }

[System.Serializable]
    public class BaseStatBlock {
        public BaseStatItem damage;
        public BaseStatItem accuracy;
        public BaseStatItem_int maxAmmoCount;
    }

    [System.Serializable]
    public class StatBlock {
        public StatItem damage;
        public StatItem accuracy;
        public StatItem_int maxAmmoCount;

        public void ComputeStats ( BaseStatBlock ammoBlock ) {
            damage.value = ComputeStat( damage, ammoBlock.damage );
            accuracy.value = ComputeStat( accuracy, ammoBlock.accuracy );
            maxAmmoCount.value = ComputeStat( maxAmmoCount, maxAmmoCount.maxRange );
        }

        int ComputeStat ( StatItem_int weap, BaseStatItem_int ammo ) {
            if ( weap == null || ammo == null ) { return 0; }
            return (int)ComputeStat( weap.ToFloat(), ammo.ToFloat() );
        }
        float ComputeStat ( StatItem weap, BaseStatItem ammo ) {
            if ( weap == null || ammo == null ) { return 0f; }
            switch ( weap.combo ) {
                case ComboType.WeaponOnly:
                        return weap.baseValue;
                case ComboType.AmmoOnly:
                       return ammo.baseValue;
                case ComboType.WeaponBase:
                    if ( ammo.type == ValueType.Multiplier ) {
                        return weap.baseValue * ammo.baseValue;
                    } else {
                        return weap.baseValue + ammo.baseValue;
                    }
                case ComboType.AmmoBase:
                    if ( weap.type == ValueType.Multiplier ) {
                        return ammo.baseValue * weap.baseValue;
                    } else {
                        return ammo.baseValue + weap.baseValue;
                    }
                default: return 0f;
            }
        }
    }

    [System.Serializable]
    public class BaseStatItem {
        public float baseValue;
        public ValueType type;
    }

    [System.Serializable]
    public class StatItem : BaseStatItem {
        public float value;
        public ComboType combo;
    }

    [System.Serializable]
    public class BaseStatItem_int {
        public int baseValue;
        public ValueType type;

        public BaseStatItem ToFloat () {
            BaseStatItem f = new() {
                baseValue = this.baseValue,
                type = this.type,
            };
            return f;
        }
    }

    [System.Serializable]
    public class StatItem_int : BaseStatItem_int {
        public int value;
        public ComboType combo;

        public new StatItem ToFloat () {
            StatItem f = new() {
                baseValue = this.baseValue,
                type = this.type,
            };
            return f;
        }
    }

What you’ve done does seem a bit overkill. For one the enums are limiting you heavily. That sort of functionality can just be handles in classes themselves without it caring about anything else, though to do that cleanly would require set ups with SerializeReference and custom inspector work.

This is how I would do this:

[SerializeReference]
private AmmoTypeBase _ammoType;

public float GetWeaponDamage()
{
    float damage = //work out damage;
  
    damage = _ammoType?.GetAmmoDamage(damage) ?? damage;
  
    return damage;
}

[System.Serializable]
public abstract class AmmoTypeBase
{
    public abstract float GetAmmoDamage(int damage);
}

[System.Serializable]
public class AmmoTypeMultiplier : AmmoTypeBase
{
    [SerializeField]
    private int _multiplier = 1;
  
  
    public override float GetAmmoDamage(int damage)
    {
        return damage * _multiplier;
    }  
}

If you’re not keen on addons or custom inspector work to make use of SerializeReference, you can just swap out the plain classes with ScriptableObjects.

Also your syntax is a nightmare to read.

I usually do end up making custom editors and property drawers. It really helps development.

But I don’t think this does what I need.
If I understand you correctly, it looks a lot like you’re hardcoding how an individual stat is applied, where I’m allowing the choice to be made in the inspector on a case by case basis, (and instantly updating with OnValidate() for ease of development. Not shown.)

I’ll see if I can describe my usage better:

You’ve got a prefab weapon and ammo. The weapon is in the scene and has a link to the ammo. In this case, the ammo grants damage, and the weapon provides accuracy. But this particular weapon also grants bonus damage - and this is shown in the inspector all at once. A different weapon might not.

Then you decide to link it to a different ammo, which has less base damage, but a bonus to accuracy. Again, this information is instantly updated in the inspector.

Furthermore, how each weapon and ammo prefab contributes to the overall stat is completely independent of each prefab and stat item.

One weapon might use ammo damage as the base, another might ignore it completely. One ammo might grant fixed damage, while another might give a multiplier.

Well, no. It’s just a small example to show a concept, namely using bog standard OOP design.

SerializeReference lets you serialise polymorphic reference types. With the right inspector tools, you could select any class derived from a base class or interface, either on a single field or collection, each with their own internal logic.

Then when you need the damage you require, you just calculate it. This could be done on a per use basis, or cached, if the performance is needed (however unlikely that is). Personally I think keep values ‘up to date’ with OnValidate is a super fragile idea.

Right now you’re limited to what I assume is an array of a single type, which is heavily limiting.

Consider it akin to Unity’s components. You can mix and match them how you please. And we know how flexible Unity’s components are.

And honestly I was making a bit of a guess as to what you want as your code is basically unreadable in its current formatting. Space it out bro.