Organisation of classes and stats

I am making a top down shooter for android and I am just looking for some advice on the best way to organise classes etc.

Currently, for characters and enemies I have a BaseStat : Monobehaviour class (holds all common variables for both enemies and players), EnemyStat : BaseStat class (variables common to only all enemies), PlayerStat : BaseStat (variables common to only all enemies). Then each individual enemy would have attached their own script, inheriting from EnemyStat, and the same for each player. The only problem with this at the moment is that the inspector of each player/enemy is just a long list of variables and isn’t very easy to read, or nice to look at (could this be made better using arrays of lists? Not to savvy with either of them).

Is this a good design idea? It seems pretty flexible in terms of adding new stats, players and enemies and I’m just wondering if i’ll run into any issues further down the line because of it.

I’ll probably be doing a similar inheritance system for my weapon stats also,but when it comes to my weapons and enemy/player behaviours I was thinking a component based approach would be the best way, as it allows for easy customisation.

I’m new to developing and to Unity so I would just like some advice now rather than later when it becomes more of a pain to change my system, if necessary. I have heard about scriptable objects, but don’t really know how I could apply the to my own game.

Base Class

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class BaseStat : MonoBehaviour {

    public int maxLvl = 100;
    public int curLvl;
    public float maxHP;
    public float curHP;
    public float maxArmor;
    public float minArmor;
    public float maxMoveSpd;                           //Base
    public float curMoveSpd;    //Set to max, can be altered for buffs/debuffs

}

Enemy Class

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyStat : BaseStat {

    protected string atkType;

    public float maxAtk;
    public float minAtk;
    public float maxAtkSpd; //Base
    /*[HideInInspector]*/public float curAtkSpd; //Set to max, can be altered for buffs/debuffs
    public float atkRng;

    private static float atkSpdTimer = 0f; //Compared to AtkSpd

    public int expVal;         //Not sure if these should stay here
    public int scoreVal;    //Not sure if these should stay here

    void Start () {
        curHP = maxHP;
        curMoveSpd = maxMoveSpd;
        curAtkSpd = maxAtkSpd;
    }
}

Player Class

All stats not finished yet:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerStat : BaseStat {
   
    public float proLvl; //Proficiency - Directly affects reload spd and gun dmg

    //public int maxStatPnts;
    //public int curStatPnts;

    void Awake () {
        curHP = maxHP;
        curMoveSpd = maxMoveSpd;
    }
}

I don’t think inheritance is the way to go for this.

Furthermore, you may want to break up your stats class into multiple classes.

Like for instance… maxHP and curHP, this is health stuff. I’m willing to bet that there’s also going to be logic for health, a ‘strike’ function, a ‘replenish’ function, and maybe even some UnityEvents put on it for ‘OnStruck’ and ‘OnHealed’. You could easily create a single ‘HealthMeter’ script, and then anything that needs health gets a ‘HealthMeter’ script.

For example, here’s my ‘HealthMeter’:

using UnityEngine;
using System.Collections.Generic;

using com.spacepuppy;
using com.spacepuppy.Scenario;
using com.spacepuppy.Utils;

using com.mansion.Entities.Weapons;

namespace com.mansion
{

    [UniqueToEntity()]
    public class HealthMeter : SPComponent
    {

        public enum StatusType
        {
            Healthy,
            Injured,
            Critical,
            Dead
        }

        #region Fields

        [SerializeField()]
        private float _health;
        [SerializeField()]
        [Tooltip("0 or negative means infinite.")]
        private float _maxHealth;
        [SerializeField()]
        [Range(0f, 1f)]
        private float _injuredRatio = 0.7f;
        [SerializeField()]
        private float _criticalRatio = 0.35f;

        [SerializeField()]
        private bool _destroyEntityOnOutOfHealth;

        [SerializeField()]
        [Tooltip("Occurs on any strike that does not cause death.")]
        private Trigger _onStrike;
        [SerializeField()]
        [Tooltip("Occurs when health reaches 0")]
        private Trigger _onDeath;


        [System.NonSerialized]
        private IEntity _entity;

        #endregion

        #region CONSTRUCTOR

        protected override void Awake()
        {
            base.Awake();

            _entity = IEntity.Pool.GetFromSource<IEntity>(this);
        }

        #endregion

        #region Properties

        public float Health
        {
            get { return _health; }
            set
            {
                this.SetHealth(value, true);
            }
        }

        public float MaxHealth
        {
            get { return _maxHealth; }
            set
            {
                _maxHealth = value;
                if(_maxHealth > 0f && _maxHealth < _health)
                {
                    _health = _maxHealth;
                }
            }
        }

        public bool DestroyEntityOnOutOfHealth
        {
            get { return _destroyEntityOnOutOfHealth; }
            set { _destroyEntityOnOutOfHealth = value; }
        }

        public StatusType Status
        {
            get
            {
                if(_maxHealth <= 0f || float.IsInfinity(_maxHealth) || float.IsNaN(_maxHealth))
                {
                    return (_health > 0f) ? StatusType.Healthy : StatusType.Dead;
                }
                else
                {
                    var ratio = _health / _maxHealth;
                    if (ratio > _injuredRatio)
                        return StatusType.Healthy;
                    else if (ratio > _criticalRatio)
                        return StatusType.Injured;
                    else if (ratio > 0f)
                        return StatusType.Critical;
                    else
                        return StatusType.Dead;
                }
            }
        }

        public Trigger OnStrike
        {
            get { return _onStrike; }
        }

        public Trigger OnDeath
        {
            get { return _onDeath; }
        }

        #endregion

        #region Methods

        public void SetHealth(float health, bool signalDeath = false)
        {
            bool wasAlive = (_health > 0f);
            if (_maxHealth > 0f)
                _health = Mathf.Clamp(health, 0f, _maxHealth);
            else
                _health = Mathf.Max(health, 0f);

            if (signalDeath && wasAlive && _health <= 0f)
                this.OnDie(null);

        }

        public bool StrikeWouldKill(float damage)
        {
            return _health > 0f && (_health - damage) <= 0f;
        }

        public bool StrikeWouldKill(IWeapon wpn)
        {
            return _health > 0f && wpn != null && (_health - wpn.Damage) <= 0f;
        }

        /// <summary>
        /// Strike the health meter, returns true if died.
        /// </summary>
        /// <param name="wpn"></param>
        /// <returns></returns>
        public bool Strike(float damage)
        {
            if (_health <= 0f) return false;

            _health = Mathf.Max(_health - damage, 0f);
            if (_maxHealth > 0f && _health > _maxHealth)
            {
                _health = _maxHealth;
            }


            if (_health == 0f)
            {
                this.OnDie(null);
                return true;
            }
            else
            {
                this.OnStruck(null);
                return false;
            }
        }

        /// <summary>
        /// Strike the health meter, returns true if died.
        /// </summary>
        /// <param name="wpn"></param>
        /// <returns></returns>
        public bool Strike(IWeapon wpn)
        {
            if (wpn == null) return false;
            if (_health <= 0f) return false;

            _health = Mathf.Max(_health - wpn.Damage, 0f);
            if (_maxHealth > 0f && _health > _maxHealth)
            {
                _health = _maxHealth;
            }

            if (_health == 0f)
            {
                this.OnDie(wpn);
                return true;
            }
            else
            {
                this.OnStruck(wpn);
                return false;
            }
        }

        private void OnDie(object implementOfDeath)
        {
            if (_onDeath.Count > 0) _onDeath.ActivateTrigger(this, implementOfDeath);

            if(_destroyEntityOnOutOfHealth)
            {
                var e = SPEntity.Pool.GetFromSource(this);
                GameObjectUtil.KillEntity(e.gameObject);
            }

            //adjust stats
            if (_entity != null && Game.CurrentGameStatistics != null)
            {
                switch(_entity.Type)
                {
                    case IEntity.EntityType.Player:
                        Game.CurrentGameStatistics.AdjustStatRanking(GameStatistics.StatType.TakeDamageStrikes, 1);
                        Game.CurrentGameStatistics.AdjustStatRanking(GameStatistics.StatType.Deaths, 1);
                        break;
                    case IEntity.EntityType.Mob:
                        var entityKiller = IEntity.Pool.GetFromSource<IEntity>(implementOfDeath);
                        if (entityKiller != null && entityKiller.Type == IEntity.EntityType.Player)
                            Game.CurrentGameStatistics.AdjustStatRanking(GameStatistics.StatType.MobsKilled, 1);
                        break;
                }
            }
        }

        private void OnStruck(object implementOfStrike)
        {
            if (_onStrike.Count > 0)
            {
                _onStrike.ActivateTrigger(this, implementOfStrike);
            }

            //adjust stats
            if (_entity != null && Game.CurrentGameStatistics != null)
            {
                switch (_entity.Type)
                {
                    case IEntity.EntityType.Player:
                        Game.CurrentGameStatistics.AdjustStatRanking(GameStatistics.StatType.TakeDamageStrikes, 1);
                        break;
                }
            }
        }

        #endregion

    }

}

(note - I use my own custom ‘Trigger’ class, but it’s very similar to UnityEvent)

Then for things like ‘minArmor’ and ‘maxArmor’, that’s more like a defense thing. So maybe you have a ‘Armor’ script which has those properties. (though personally I’d probably have a IArmor interface, then implement that on a case by case… but I don’t know what skill level you are, interfaces might be getting a little overboard right now)

And so on down the line.

Basically… in unity we use a ‘component’ based design pattern. Which is an extension of the ‘composite design pattern’. You should be favoring composition over inheritance.

This doesn’t mean you shouldn’t inherit, just saying that inheritance shouldn’t be that common.

Thanks for the reply and the valuable insight. I don’t know too much about interfaces but I will look at using them. But I don’t think I understand the concept of them…

Why would I have an IArmor which requires each character to have their own say Defend() method, when you could just have a parent class with Defend()?

What if 2 different entities ‘Defend’ differently?

And why have it a ‘parent class’?

Rather you could have ‘HeavyArmor’ and ‘LightArmor’ that both implement the ‘IArmor’ interface. Each implement ‘Defend’ uniquely, since they act differently defensively. But they both have a ‘Defend’ method.

This means you can reference them as ‘IArmor’, while having different implementations for each.

Ah okay. Also, if you used an IArmor interface, would you also then have to declare and set the variables for min/max armor for each enemy/player individually?

You’d have to declare it for each class type that implement IArmor.

As for setting those variables, they need to be set for every enemy/player no matter what.

Using a system like this will require lots of Get component calls will it not? I thought this was considered bad design. For example, if I break down each ‘stat’ into its own script, when it comes to my interface methods and damage calculations referencing each stat will require a getcomponent every time will it not? Is there a better or cleaner way to reference between these different classes?

GetComponent isn’t bad design.

Using GetComponent repeatedly in like update or something, when you could have cached it in Start. That’s considered inefficient.

But GetComponent is a perfectly reasonable thing to do.

Think of it this way. All GameObjects have a ‘Transform’, yet ‘Transform’ is its own component. You literally have to ‘GetComponent’ the Transform to access it (of course, unity shortcuts it with the ‘transform’ property, and even caches it now in unity 5… but it’s still technically a GetComponent). Unity themselves embrace the component design pattern.

Perfect, I must have just misunderstood what I read regarding get component. Thank you for the help!