복싱스타 – 스탯 시스템 코드 뷰
라이브 서비스 중인 <복싱스타> 프로젝트에서 사용한 스탯 시스템 구조를 단순화한 발췌본입니다. 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;
}
}
}
← 포트폴리오로 돌아가기