스탯은 게임에서 캐릭터의 능력치를 나타내며, 게임 플레이의 깊이와 다양성을 높여줍니다. 또한, 스탯을 구현함으로써 게임의 밸런스를 조절하고 유저들에게 성취감을 느끼게 합니다. 이러한 스탯 시스템을 구현하고 정리하였습니다. 본 글에서는 스탯 시스템의 디자인을 다뤄보겠습니다.

 

💬 목차

[📌현재 글]  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인것을 볼 수 있습니다.
bonnate