스탯은 게임에서 캐릭터의 능력치를 나타내며, 게임 플레이의 깊이와 다양성을 높여줍니다. 또한, 스탯을 구현함으로써 게임의 밸런스를 조절하고 유저들에게 성취감을 느끼게 합니다. 이러한 스탯 시스템을 구현하고 정리하였습니다. 본 글에서는 스탯 시스템의 디자인을 다뤄보겠습니다.
💬 목차
[📌현재 글] 1. [유니티] 스탯 시스템(1) - 디자인
2. [유니티] 스탯 시스템(2) - 플레이어 스탯관리
💬 서론
- 구현한 스탯 시스템은 Base Stat과 아이템, 버프 등 효과에 의해 변경될 스탯을 포함합니다.
- 해당 글에서는 BuffController(버프효과), EquipmentInventory(장비효과)를 함께 포함하여 디자인하였습니다.
- 이 디자인은 세이브 & 로드 시스템 및 여러 기능과 함께 작동합니다. 이 내용은 추후에 다루겠습니다.
✅ 구현
- 이번 글에서는 스탯 시스템을 위한 스탯 데이터를 어떻게 디자인했는지 다루겠습니다.
· StatData
[System.Serializable]
public class StatData
{
[field: Header("초기화 시 레벨")]
[field: SerializeField] public int level { private set; get; } = 1;
[field: Header("초기화 시 최대 체력")]
[field: SerializeField] public float hpMax { private set; get; }
[SerializeField][HideInInspector] private float mHpCurrent;
public float HpCurrent
{
get
{
return mHpCurrent;
}
}
[field: Header("초기화 시 최대 마나")]
[field: SerializeField] public float mpMax { private set; get; }
[SerializeField][HideInInspector] private float mMpCurrent;
public float MpCurrent
{
get
{
return mMpCurrent;
}
}
[field: Header("초기화 시 기본 공격력")]
[field: SerializeField] public float baseAttack { private set; get; }
/// <summary>
/// 현재 공격력
/// </summary>
public float AttackCurrent
{
get
{
return baseAttack + buffController.BuffStat.attack +
(equipmentInventory is not null ? equipmentInventory.CurrentEquipmentEffect.Attack : 0f);
}
}
[field: Header("초기화 시 기본 이동속도")]
[field: SerializeField] public float baseMovementSpeed { private set; get; }
/// <summary>
/// 현재 이동속도
/// </summary>
public float MovementSpeedCurrent
{
get
{
return baseMovementSpeed + buffController.BuffStat.movementSpeed +
(equipmentInventory is not null ? equipmentInventory.CurrentEquipmentEffect.MovementSpeed : 0f);
}
}
[field: Header("초기화 시 기본 방어력")]
[field: SerializeField] public float baseDefense { private set; get; }
/// <summary>
/// 현재 방어력
/// </summary>
public float DefenseCurrent
{
get
{
return baseDefense + buffController.BuffStat.defense +
(equipmentInventory is not null ? equipmentInventory.CurrentEquipmentEffect.Defense : 0f);
}
}
#region 외부 클래스
[HideInInspector] public BuffController buffController = new BuffController(); // 버프 컨트롤러 (모두에게 고유)
[Space(30)] [Header("외부 클래스를 참조하여 스탯에 추가 효과")]
[Header("해당 객체의 장비인벤토리, 없을경우 null 가능")]
[SerializeField] public EquipmentInventory? equipmentInventory = null; // 장비 인벤토리 (개별적으로 로드하여 사용 가능)
#endregion
/// <summary>
/// 스탯 정보를 초기값으로 초기화
/// </summary>
public void InitStatData()
{
mHpCurrent = mHpCurrent == 0 ? hpMax : mHpCurrent;
mMpCurrent = mMpCurrent == 0 ? mpMax : mMpCurrent;
}
/// <summary>
/// 현재 체력을 조정
/// </summary>
/// <param name="amount">조정할 값 (양수일경우 현재 체력 증가)</param>
/// <returns>체력이 0 미만인가?</returns>
public bool ModifyCurrentHp(float amount)
{
mHpCurrent += amount;
mHpCurrent = Mathf.Clamp(mHpCurrent, float.MinValue, hpMax);
return mHpCurrent < 0f;
}
/// <summary>
/// 현재 마나를 조정
/// </summary>
/// <param name="amount">조정할 값 (양수일경우 현재 마나 증가)</param>
public void ModifyCurrentMp(float amount)
{
mMpCurrent += amount;
mMpCurrent = Mathf.Clamp(mMpCurrent, float.MinValue, mpMax);
}
/// <summary>
/// BaseStat을 영구적으로 증가
/// </summary>
/// <param name="statIndex"></param>
public void UpgradeBaseStat(StatType statType)
{
switch (statType)
{
case StatType.LEVEL: // 레벨
++level;
break;
case StatType.HP: // 체력
hpMax += 50;
ModifyCurrentHp(50);
break;
case StatType.MP: // 마나
mpMax += 50;
ModifyCurrentMp(50);
break;
case StatType.ATTACK: // 공격력
baseAttack += 5;
break;
case StatType.MOVEMENT_SPEED: // 속도
baseMovementSpeed += 1;
break;
case StatType.DEFENSE: // 방어
baseDefense += 2.5f;
break;
default:
Debug.LogError($"인덱스 {statType}은 없음!");
break;
}
}
}
- 레벨을 기반으로 체력, 마나, 공격력, 방어력, 이동속도를 사용합니다.
[field: Header("초기화 시 최대 체력")]
[field: SerializeField] public float hpMax { private set; get; }
[SerializeField][HideInInspector] private float mHpCurrent;
public float HpCurrent
{
get
{
return mHpCurrent;
}
}
- hpMax는 [field: SerializeField]로 되어있어 인스펙터에서 초기화 시 최대 체력을 설정할 수 있습니다.
- mHpCurrent는 시리얼화 되어있지만, 현재 체력을 설정하는것은 현재 큰 의미가 없기에 HideInInspector로 설정할 수 없게 하였습니다. 이렇게 디자인 한 이유는 세이브 & 로드 기능을 사용하기위해 Json으로 변환할 때 포함되지 않기때문에 [SerializeField][HideInInspector]를 같이 사용하였습니다.
- hp뿐만 아니라 다른 변수들 또한 같은 이유로 디자인하였습니다.
{"statData":{"<level>k__BackingField":1,"<hpMax>k__BackingField":100.0,"mHpCurrent":100.0,"<mpMax>k__BackingField":100.0,"mMpCurrent":100.0,"<baseAttack>k__BackingField":15.0,"<baseMovementSpeed>k__BackingField":5.0,"<baseDefense>k__BackingField":1.5,"equipmentInventory":{"instanceID":798090}}
- 위와 같은 형태로 데이터가 저장되어 게임을 로드할 때 사용할 수 있게됩니다.
/// <summary>
/// 현재 공격력
/// </summary>
public float AttackCurrent
{
get
{
return baseAttack + buffController.BuffStat.attack +
(equipmentInventory is not null ? equipmentInventory.CurrentEquipmentEffect.Attack : 0f);
}
}
- 현재 공격력을 리턴합니다.
- 단순히 baseAttack만 리턴하는것이 아닌 현재 버프, 장비의 상태에 따라 공격력을 다르게 리턴할 수 있도록 의도하여 구현하였습니다.
#region 외부 클래스
[HideInInspector] public BuffController buffController = new BuffController(); // 버프 컨트롤러 (모두에게 고유)
[Space(30)] [Header("외부 클래스를 참조하여 스탯에 추가 효과")]
[Header("해당 객체의 장비인벤토리, 없을경우 null 가능")]
[SerializeField] public EquipmentInventory? equipmentInventory = null; // 장비 인벤토리 (개별적으로 로드하여 사용 가능)
#endregion
- 해당 객체가 단순히 base스탯만 사용하지 않고 다른 외부 클래스의 추가 스탯효과를 사용하고싶을때 사용합니다.
- BuffController은 모든 엔티티가 사용할 수 있도록 디자인 하였으며, 장비 효과는 현재 플레이어가 사용합니다.
/// <summary>
/// 스탯 정보를 초기값으로 초기화
/// </summary>
public void InitStatData()
{
mHpCurrent = mHpCurrent == 0 ? hpMax : mHpCurrent;
mMpCurrent = mMpCurrent == 0 ? mpMax : mMpCurrent;
}
- 현재 체력과 현재 마나를 초기화합니다.
- 일반적으로 현재 체력과 현재 마나를 최대치인 max로 설정하는 함수입니다.
- 하지만, 세이브 & 로드 시스템에 의해 현재 체력 및 마나가 특정한 값으로 설정되어있다면 해당 값으로 불러옵니다. 세이브 & 로드 시스템은 추후에 다루겠습니다.
/// <summary>
/// 현재 체력을 조정
/// </summary>
/// <param name="amount">조정할 값 (양수일경우 현재 체력 증가)</param>
/// <returns>체력이 0 미만인가?</returns>
public bool ModifyCurrentHp(float amount)
{
mHpCurrent += amount;
mHpCurrent = Mathf.Clamp(mHpCurrent, float.MinValue, hpMax);
return mHpCurrent < 0f;
}
/// <summary>
/// 현재 마나를 조정
/// </summary>
/// <param name="amount">조정할 값 (양수일경우 현재 마나 증가)</param>
public void ModifyCurrentMp(float amount)
{
mMpCurrent += amount;
mMpCurrent = Mathf.Clamp(mMpCurrent, float.MinValue, mpMax);
}
- 현재 체력과 마나를 조정합니다.
/// <summary>
/// BaseStat을 영구적으로 증가
/// </summary>
/// <param name="statIndex"></param>
public void UpgradeBaseStat(StatType statType)
{
switch (statType)
{
case StatType.LEVEL: // 레벨
++level;
break;
case StatType.HP: // 체력
hpMax += 50;
ModifyCurrentHp(50);
break;
case StatType.MP: // 마나
mpMax += 50;
ModifyCurrentMp(50);
break;
case StatType.ATTACK: // 공격력
baseAttack += 5;
break;
case StatType.MOVEMENT_SPEED: // 속도
baseMovementSpeed += 1;
break;
case StatType.DEFENSE: // 방어
baseDefense += 2.5f;
break;
default:
Debug.LogError($"인덱스 {statType}은 없음!");
break;
}
}
- 특정 조건에 의해 BaseStat이 변경되어야하면 이 함수를 이용하여 변경해야합니다.
- BaseStat은 외부에서 직접 참조하여 변경할 수 없어야합니다.
- 예를들어, 플레이어의 레벨업이 있을경우 스탯창에서 스탯 포인트를 이용하여 기본스탯을 영구적으로 증가시킬 수 있도록 디자인 하였습니다.
✅ 사용 예시
- 플레이어, 적 객체 등 스탯을 포함해야하는 모든 엔티티는 이제부터 StatData를 이용하여 동일한 규칙 내에서 관리를 할 수 있게 됩니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 씬 내의 모든 FSM을 적용받는 객체가 가지는 최상위 부모클래스
/// 게임의 내 캐릭터의 기본적인 정보를 가진다.
/// </summary>
abstract public class BaseFSM : BaseGameEntity
{
...
[Header("객체의 스탯 정보")]
[SerializeField] public StatSystem.StatData StatData;
...
}
- FSM 시스템을 이용하여 구현한 가장 기초적인 FSM인 BaseFSM에 구현한 StatData를 사용합니다.
- 이렇게 되면, 모든 엔티티는 StatData를 가지게 되어 해당 영역에서 스탯을 설정하고 계산하게 됩니다.
· 스탯 설정
- 위 사진과 같이 '늑대'는 StatData를 가지고, 이곳에서 BaseStat을 초기화할 수 있게 됩니다.
· 체력
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// 필드 타입 적 객체의 배이스
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
abstract public class EnemyFieldType : EnemyBase
{
...
#region Parent Override
override public bool ModifyCurrentHp(float amount)
{
bool isDead = base.StatData.ModifyCurrentHp(amount);
EnemyInfoDraw.UpdateHPBar(base.StatData.HpCurrent / base.StatData.hpMax);
// 데미지 인디케이터
DamageIndicatorPool.Instance.GetFromPool(transform.position, amount, amount < 0 ? Color.red : Color.green);
return isDead;
}
...
#endregion
}
- '늑대'가 가지는 EnemyFieldType 클래스에는 ModifyCurrentHP 함수가 있습니다.
- 이 함수에서 '늑대'의 statData에서 ModifyCurrentHP를 사용하여 statData의 현재 체력을 계산하여 전투 처리가 가능합니다.
· 공격력
public class ATTACK1 : State<EnemyWolf>
{
...
public override void Execute(EnemyWolf entity)
{
if(mIsAttacked) { return; }
mAttackDelay -= Time.deltaTime;
if(mAttackDelay < 0f)
{
mIsAttacked = true;
if(EnemyManager.CheckCollisionFromArch(entity.transform, GameManager.Instance.Player.transform, 3f, 60f))
{
MessageDispatcher.Instance.DispatchMessage(0, entity.EntityName, GameManager.Instance.Player.EntityName, "HitPlayer", new HitInfo(entity.StatData.AttackCurrent, entity));
}
}
}
...
}
- '늑대'가 플레이어를 공격하는 코드입니다.
- '늑대'가 공격을 할때 플레이어의 위치를 계산하여 범위내인경우 플레이어의 FSM에 메시지를 발송하여 공격처리를 하도록 합니다.
- 이때, 공격을 하기위해 들어가는 데미지가 스탯데이터의 AttackCurrent인것을 볼 수 있습니다.
'unity game modules' 카테고리의 다른 글
[유니티] 키 설정 시스템 (1) | 2023.03.25 |
---|---|
[유니티] 스탯 시스템(2) - 플레이어 스탯관리 (0) | 2023.03.24 |
[유니티] 레벨, 경험치 시스템 (0) | 2023.03.24 |
[유니티] 환경 사운드 (Ambient Area) (0) | 2023.03.23 |
[유니티] 자동 스크롤 (0) | 2023.03.22 |