← 포트폴리오로 돌아가기

복싱스타 – 스탯 시스템 코드 뷰

라이브 서비스 중인 <복싱스타> 프로젝트에서 사용한 스탯 시스템 구조를 단순화한 발췌본입니다. Player / Bot / Replay 를 분리할 수 있는 베이스 시스템과, 계정/캐릭터 단위 스탯 테이블 구조를 중심으로 정리했습니다.

1. AccountStatTable – 계정 단위 스탯 테이블


public abstract class AccountStatTable : BaseClientDataTable
{
    public ClientStatData ClientStat { get; private set; }

    public AccountStatTable()
    {
        ClientStat = new ClientStatData(this);
    }

    public void Reset(GearOptionType type)
    {
        if (ClientStat.ResultStatDic.ContainsKey(type) == false)
            return;

        ClientStat.ResultStatDic[type].Clear();
    }

    public void Reset()
    {
        ClientStat.Reset();
    }

    public int GetLevel(StatType type)
    {
        return ClientStat.GetLevel(type);
    }

    public int GetTotalLevel()
    {
        int result = 0;
        foreach (var type in EnumUtil<StatType>.Instance.EnumValues)
        {
            result += ClientStat.GetLevel(type);
        }
        return result;
    }

    public float GetValue(GearOptionType type, int key)
    {
        return ClientStat.GetValue(type, key);
    }

    public void SetLevel(StatType type, int level)
    {
        ClientStat.SetLevel(type, level);
    }

    public void AddLevel(StatType type, int level)
    {
        var curLv = ClientStat.GetLevel(type);
        ClientStat.SetLevel(type, curLv + level);
    }
}
    

2. CharacterStatTable – 캐릭터 단위 스탯 테이블


public abstract class CharacterStatTable : BaseClientDataTable
{
    public Dictionary<int, ClientStatData> CharacterStatDic { get; private set; }
        = new Dictionary<int, ClientStatData>();

    public virtual void Reset()
    {
        foreach (var stat in CharacterStatDic)
        {
            stat.Value.Reset();
        }
    }

    public int GetLevel(int charId, StatType type)
    {
        ClientStatData target;
        if (CharacterStatDic.TryGetValue(charId, out target))
        {
            return target.GetLevel(type);
        }
        return 0;
    }

    public void SetLevel(int charId, StatType type, int level)
    {
        if (CharacterStatDic.ContainsKey(charId) == false)
            CharacterStatDic[charId] = new ClientStatData(this);

        CharacterStatDic[charId].SetLevel(type, level);
    }

    public void AddLevel(int charId, StatType type, int level)
    {
        var curLv = GetLevel(charId, type);
        SetLevel(charId, type, curLv + level);
    }
}
    

3. ClientStatData – 실제 수치 데이터 구조 (발췌)


public class ClientStatData
{
    public Dictionary<GearOptionType, Dictionary<int, float>> ResultStatDic
        { get; private set; } = new Dictionary<GearOptionType, Dictionary<int, float>>();

    public BaseClientDataTable OwnerTable { get; private set; }

    public ClientStatData(BaseClientDataTable owner)
    {
        OwnerTable = owner;
        foreach (var type in EnumUtil<GearOptionType>.Instance.EnumValues)
        {
            ResultStatDic.Add(type, new Dictionary<int, float>());
        }
    }

    public void Reset()
    {
        ResultStatDic.Clear();
    }

    public void SetValue(GearOptionType type, int key, float value)
    {
        if (ResultStatDic.ContainsKey(type) == false)
            ResultStatDic[type] = new Dictionary<int, float>();

        ResultStatDic[type][key] = value;

        if (OwnerTable != null && OwnerTable.OwnerSystem != null)
        {
            OwnerTable.OwnerSystem.RefreshMaxHP();
        }
    }

    public void SetLevel(StatType type, int lv)
    {
        SetValue(GearOptionType.STAT_LEVEL, (int)type, lv);
    }

    public float GetValue(GearOptionType type, int key)
    {
        float result = 0;
        Dictionary<int, float> kv = null;
        if (ResultStatDic.TryGetValue(type, out kv))
        {
            float value = 0;
            if (kv.TryGetValue(key, out value))
            {
                result = (int)value;
            }
        }
        return result;
    }

    public int GetLevel(StatType type)
    {
        return (int)GetValue(GearOptionType.STAT_LEVEL, (int)type);
    }
}
    

4. UserStatSystem – 인게임 유저 스탯 시스템 (발췌)


public class UserStatSystem : UserStatSystemBase
{
    public override float DownReducePercent
    {
        get
        {
            if (_downReducePercent == 0 && Constants.available)
            {
                return Constants.instance.playerHpDeducationRatio;
            }
            return _downReducePercent;
        }
        protected set
        {
            _downReducePercent = Mathf.Clamp(value, 0f, 100f);
        }
    }

    public void InitSystem(BaseCharacterData data)
    {
        if (data == null || data.Owner == null)
            return;

        var costumeData = new CostumeCollectionStatTable(data.Owner.CostumeCollectionList);
        var defaultCharData = new LevelStatTable(data.Owner.LevelStat);
        var healthClubData = new GymStatTable(data.ID, data.GymStatData);
        var gearOptionData = new GearStatTable(
            data.ID,
            data.GetEquippedAllGearItemList(),
            data.EquippedOmegaSet,
            data.Owner.GearPotentialList);

        SetTable(defaultCharData);
        SetTable(healthClubData);
        SetTable(costumeData);
        SetTable(gearOptionData);

        _isNPC = data is AICharacterData;
        CalcMaxHP(data.ID);
        DownReducePercent = data.GetDownReduceHp();
    }
}
    

5. UserStatSystemBase – 시스템 베이스 (Player, Bot, Replay 등 스텟시스템을 다르게 처리해야 되기 때문에 베이스화)




namespace BoxingStar.Stat
{
    /// 
    /// New BoxerStat
    /// BoxerStats -> StatSystem
    /// 
    public abstract class UserStatSystemBase
    {

        #region 결과(Result) 필터링
        /// 아래에 있는 타입들은 결과값에 스텟 수치 값들이 실시간으로 변동되어 들어갑니다.
        /// 아래에 없는 스텟의 경우 데이터가 세팅 될 때 한번 캐싱되어 사용됩니다.
        protected static eClientStatTableType[] _DynamicStatDataTypes = new eClientStatTableType[]
        {
        eClientStatTableType.KnockOutBuff,
        eClientStatTableType.ClanBuff,
        eClientStatTableType.Buff
        };

        #endregion

        #region 결과(Result) 필터링
        /// 
        /// UI에 표시될 데이터들을 필터링합니다.
        /// 
        protected Dictionary _staticStatDataDic = new Dictionary();

        /// 
        /// UI에 표시되지 않고 인게임에 반영할 타입을 분리합니다.
        /// 
        protected Dictionary _dynamicStatDataDic = new Dictionary();
          #endregion


        protected bool _isNPC = false;

        /// 
        /// 다운시 1초당 차감되는 HP 비율 (퍼센트 값)
        /// 플레이어냐 스토리모드 NPC냐에 따라 값이 달라짐
        /// 
        public virtual float DownReducePercent
        {
            get
            {
                return _downReducePercent;
            }
            protected set
            {
                ///clamp기능
                if (value < 0) value = 0;
                else if (value > 100f) value = 100f;
                _downReducePercent = value;
            }
        }
        protected float _downReducePercent;

        protected Dictionary _maxHpDic = new Dictionary();
        protected Dictionary _reciprocalMaxHpDic = new Dictionary();

        #region maxHP
        public virtual void CalcMaxHP(int charId)
        {
            int hp = CalcHealth(GetLevel(charId, StatType.HEALTH), UserStatUtility.GetTotalMaxStatLevel(_isNPC, StatType.HEALTH));
            var ratioVal = GetValue(charId, GearOptionType.STAT_PCT, (int)StatType.HEALTH);

   hp = ( int )Math.Ceiling( hp * ( ratioVal * 0.01f + 1f ) );

   SetMaxHP( charId, hp);
        }

        protected int CalcHealth(int curLevel, int maxLevel)
        {
            ///캐릭터 체력 증가하는 양
            float hpHandler = (float)ConstantTable.Instance[ConstantsType.ExpressCalculationHD_Health];
            float hpDefaultValue = (float)ConstantTable.Instance[ConstantsType.DefaultLv_Value_Health];
            return (int)Math.Floor(hpDefaultValue * (1 + (curLevel * hpHandler)));
        }

        public void RefreshMaxHP()
        {
            if (_maxHpDic == null) return;
            List ids = _maxHpDic.Keys.ToList();
            foreach(var charId in ids)
            {
                CalcMaxHP(charId);
            }
        }


        public void SetMaxHP(int charId, int value)
        {
            _maxHpDic[charId] = value;
            _reciprocalMaxHpDic[charId] = 1f / value;
        }

        public int GetMaxHP(int charId)
        {
#if UNITY
            if (_maxHpDic.TryGetValue(charId, out ev_int result))
            {
                return result;
            }
#endif
            return 0;
        }

        public float GetReciprocalMaxHP(int charId)
        {
#if UNITY
            if (_reciprocalMaxHpDic.TryGetValue(charId, out ev_float result))
            {
                return result;
            }
#endif
            return 0;
        }
#endregion

        public UserStatSystemBase()
        {
            Init();
        }
        public UserStatSystemBase(string name)
        {
            Init(name);
        }

        /// 
        /// 생성자 공통 초기화 부분
        /// 
        protected virtual void Init(string name = null)
        {
            /// 서버 데이터로 생성되지 않는 애들은 초기화 때 직접 생성해줍니다.
            SetTable(new SkillStatTable());
            SetTable(new ClanBuffStatTable());
            SetTable(new BuffStatTable());
            SetTable(new KnockoutBuffStatTable());
   SetTable( new InGameBuffStatTable() );
  }

  /// targetSystem의 값들을 현재 시스템에 동기화 시킵니다.
  /// 
  /// 
  public void ApplySystem(UserStatSystemBase targetSystem)
        {
            var tables = targetSystem.GetTables();
            foreach (var table in tables)
            {
                SetTable(table.Value);
            }
            DownReducePercent = targetSystem.DownReducePercent;
            _isNPC = targetSystem._isNPC;

            this._maxHpDic.Clear();
            this._reciprocalMaxHpDic.Clear();

            foreach (var item in targetSystem._maxHpDic)
            {
                this._maxHpDic[item.Key] = item.Value;
            }
            foreach (var item in targetSystem._reciprocalMaxHpDic)
            {
                this._reciprocalMaxHpDic[item.Key] = item.Value;
            }
        }



        public void InitSystem(UserProfileData data)
        {
            var costumeData = new CostumeCollectionStatTable(data.costume_stat);
            var healthClubData = new GymStatTable(data.userprofiledata.main_chatacter_id, data.characterStatList);
            var defaultCharData = new LevelStatTable(data.statList);
            var gearOptionData = new GearStatTable(data.userprofiledata.main_chatacter_id, data.gloves, data.userprofiledata.OmegaSet, data.GearPotentialInfoList);
            var bioGearOptionData = new BioGearStatTable(data.userprofiledata.main_chatacter_id, data.BioList);

   SetTable(gearOptionData);
            SetTable(defaultCharData);
            SetTable(costumeData);
            SetTable(healthClubData);
   SetTable(bioGearOptionData);

   _isNPC = false;
            CalcMaxHP(data.userprofiledata.main_chatacter_id);
        }
        public void InitSystem(MatchUserProfile data)
        {
            var costumeData = new CostumeCollectionStatTable(data.CostumeCollectionStatList);
            var healthClubData = new GymStatTable(data.Character_preset_data.Character_id, data.CharacterStatData);
            var defaultCharData = new LevelStatTable(data.StatData);
            var gearOptionData = new GearStatTable(data.Character_preset_data.Character_id, data.GearItems, data.omegaSet, data.GearPotentialInfoList);
            var bioGearOptionData = new BioGearStatTable(data.Character_preset_data.Character_id, data.BioList);

            SetTable(gearOptionData);
            SetTable(defaultCharData);
            SetTable(costumeData);
            SetTable(healthClubData);
   SetTable( bioGearOptionData );

   _isNPC = false;
            CalcMaxHP(data.Character_preset_data.Character_id);
        } 

        public void SetTable(BaseClientDataTable baseData)
        {
            if (baseData == null) return; // table이 null로 들어오면 암것도 안함

            baseData.OwnerSystem = this;

            if (_DynamicStatDataTypes.Contains(baseData.StatDataType))
            {
                _dynamicStatDataDic[baseData.StatDataType] = baseData;
            }
            else
            {
                _staticStatDataDic[baseData.StatDataType] = baseData;
            }

            RefreshMaxHP();
        }

        /// 
        /// 저장되어있는 모든 테이블들을 가져옵니다.
        /// 
        /// 
        public Dictionary GetTables()
        {
            Dictionary result = new Dictionary();

            foreach (var item in _dynamicStatDataDic)
            {
                result[item.Key] = item.Value;
            }
            foreach (var item in _staticStatDataDic)
            {
                result[item.Key] = item.Value;
            }


            return result;
        }

        public bool HasTable() where T : BaseClientDataTable
        {
            return _staticStatDataDic.Any(t => t.Value is T) || _staticStatDataDic.Any(t => t.Value is T);
        }

        public T GetTable() where T : BaseClientDataTable
        {
            foreach (var item in _staticStatDataDic)
            {
                if (item.Value is T)
                {
                    return item.Value as T;
                }
            }

            foreach (var item in _dynamicStatDataDic)
            {
                if (item.Value is T)
                {
                    return item.Value as T;
                }
            }

            ///여기 까지 코드가 접근하면 에러가 나는것이 맞다.
#if UNITY_EDITOR
            Debug.LogError($"에러!! 생성되지 않은 스텟 데이터에 접근을 시도합니다 타입 - {typeof(T).Name}");
#endif
            return null;
        }


        public int GetLevel(int charId, StatType type)
        {
            int lv = 0;

            foreach (var item in _staticStatDataDic)
            {
                if (item.Value is AccountStatTable)
                {
                    lv += (item.Value as AccountStatTable).GetLevel(type);
                }
                else if (item.Value is CharacterStatTable)
                {
                    lv += (item.Value as CharacterStatTable).GetLevel(charId, type);
                }
            }

            foreach (var item in _dynamicStatDataDic)
            {
                if (item.Value is AccountStatTable)
                {
                    lv += (item.Value as AccountStatTable).GetLevel(type);
                }
                else if (item.Value is CharacterStatTable)
                {
                    lv += (item.Value as CharacterStatTable).GetLevel(charId, type);
                }
            }

            return lv;
        }

        public float GetValue(int charId, GearOptionType type, int paramKey = 0)
        {
            float result = 0;
#if UNITY
            foreach (var item in _staticStatDataDic)
            {
                if (item.Value is AccountStatTable target)
                {
                    result += (item.Value as AccountStatTable).GetValue(type, paramKey);
                }
                else if (item.Value is CharacterStatTable)
                {
                    result += (item.Value as CharacterStatTable).GetValue(charId, type, paramKey);
                }
            }

            foreach (var item in _dynamicStatDataDic)
            {
                if (item.Value is AccountStatTable)
                {
                    result += (item.Value as AccountStatTable).GetValue(type, paramKey);
                }
                else if (item.Value is CharacterStatTable)
                {
                    result += (item.Value as CharacterStatTable).GetValue(charId, type, paramKey);
                }
            }
#endif
            return result;
        }

        /// 
        /// UI에서 표시될 해당 스텟 레벨 총합을 리턴합니다.
        /// 
        /// 
        /// 
        /// 
        public int GetTotalUIStatLevel(int charId, StatType type)
        {
            int result = 0;
            foreach (var _data in _staticStatDataDic.Values)
            {
                if (_data is AccountStatTable)
                {
                    var target = _data as AccountStatTable;
                    result += target.GetLevel(type);
                }
                else if (_data is CharacterStatTable)
                {
                    var target = _data as CharacterStatTable;
                    result += target.GetLevel(charId, type);
                }
                else
                {
                    continue;
                }
            }
            return result;
        }

        /// 
        /// 모든 스텟 레벨의 총합을 리턴합니다.
        /// 
        /// 
        /// 
        public int GetTotalAllStatLevel(int charId)
        {
            int result = 0;
            foreach (var stat in EnumUtil.Instance.EnumValues)
            {
                result += GetLevel(charId, stat);
            }
            return result;
        }

        /// 
        /// 해당 공격 타입과 관련된 레벨들의 총합
        /// 
        /// 
        /// 
        /// 
        public int GetAttackLevel(int charId, AttackType attackType)
        {
            int resultLv = 0;
            StatType type = UserStatUtility.GetPowerStatTypeWithAttackType(attackType);
            resultLv += GetLevel(charId, type);
            type = UserStatUtility.GetCriticalStatTypeWithAttackType(attackType);
            resultLv += GetLevel(charId, type);
            type = UserStatUtility.GetCombinationStatTypeWithAttackType(attackType);
            resultLv += GetLevel(charId, type);
            type = UserStatUtility.GetAttackSpeedStatTypeWithAttackType(attackType);
            resultLv += GetLevel(charId, type);
            return resultLv;
        }


        public virtual int GetAttackDamage(int charId, AttackType type,int addLv = 0)
        {
            StatType statType = UserStatUtility.GetPowerStatTypeWithAttackType(type);

            int level = GetLevel(charId, statType);
            level += addLv;
            var dmg = (int)CommonLogicFunc.GetStatLevelToValue(statType, level);


            var ratioVal = GetValue(charId, GearOptionType.STAT_PCT, (int)statType); /// 장비 옵션처리

            if (ratioVal > 0)
            {
                dmg = MathExt.Integer((double)dmg * (double)(100 + ratioVal) / 100d);
            }

            return dmg;
        }

        /// 
        /// 특정 타입의 레벨 값을 가져옵니다. (계정 + 캐릭터별)
        /// 
        /// ㅋ
        /// 
        /// 
        public virtual int GetLevel(int charId, eClientStatTableType cStatType, StatType type)
        {
            Dictionary targetDic;
            if (_staticStatDataDic.ContainsKey(cStatType) == false)
            {
                if (_dynamicStatDataDic.ContainsKey(cStatType) == false)
                {
                    return 0;
                }
                else
                {
                    targetDic = _dynamicStatDataDic;
                }
            }
            else
            {
                targetDic = _staticStatDataDic;
            }

            if (targetDic[cStatType] is AccountStatTable)
            {
                var target = targetDic[cStatType] as AccountStatTable;
                return target.GetLevel(type);
            }
            else if (targetDic[cStatType] is CharacterStatTable)
            {
                var target = targetDic[cStatType] as CharacterStatTable;
                return target.GetLevel(charId, type);
            }
            else
            {
                return 0;
            }
        }

        /// 
        /// 특정 타입의 레벨 값을 가져옵니다.
        /// 
        /// 
        /// 
        /// 
        public virtual float GetValue(int charId, eClientStatTableType cStatType, GearOptionType type, int key)
        {
            if (_staticStatDataDic.ContainsKey(cStatType) == false)
                return 0;

            if (_staticStatDataDic[cStatType] is AccountStatTable)
            {
                var target = _staticStatDataDic[cStatType] as AccountStatTable;
                return target.GetValue(type, key);
            }
            else if (_staticStatDataDic[cStatType] is CharacterStatTable)
            {
                var target = _staticStatDataDic[cStatType] as CharacterStatTable;
                return target.GetValue(charId, type, key);
            }
            else
            {
                return 0;
            }
        }
        
        public HashSet GetParams(int charId,GearOptionType type)
        {
            HashSet result = new HashSet();

            foreach (var item in _staticStatDataDic)
            {
                if (item.Value is CharacterStatTable)
                {
                    int[] keys = (item.Value as CharacterStatTable).GetParamKeys(charId, type);
                    for (int i = 0; i < keys.Length; i++)
                    {
                        result.Add(keys[i]);
                    }
                }
                else if (item.Value is AccountStatTable)
                {
                    int[] keys = (item.Value as AccountStatTable).GetParamKeys(type);
                    for (int i = 0; i < keys.Length; i++)
                    {
                        result.Add(keys[i]);
                    }
                }
            }

            foreach (var item in _dynamicStatDataDic)
            {
                if (item.Value is CharacterStatTable)
                {
                    int[] keys = (item.Value as CharacterStatTable).GetParamKeys(charId, type);
                    for (int i = 0; i < keys.Length; i++)
                    {
                        result.Add(keys[i]);
                    }
                }
                else if (item.Value is AccountStatTable)
                {
                    int[] keys = (item.Value as AccountStatTable).GetParamKeys(type);
                    for (int i = 0; i < keys.Length; i++)
                    {
                        result.Add(keys[i]);
                    }
                }
            }

            return result;
        }
    }
}
← 포트폴리오로 돌아가기