Inventory System (1)

JJW·2024년 12월 10일
0

Unity

목록 보기
14/34

Inventory System은 게임의 핵심적인 시스템 중 하나로, 플레이어가 아이템을 저장, 정렬, 관리할 수 있도록 도와줍니다. 이를 통해 플레이어는 게임 플레이 중 필요한 아이템을 효율적으로 사용할 수 있으며, 게임 내 자원 관리 및 전략적 선택에 중요한 역할을 합니다. 이번 글에서는 Unity를 활용하여 Inventory System의 기초적인 설계와 구현 방법에 대해 알아보겠습니다.

총 3편으로 제작되며 1편에서는 Stat 과 Item 구현에 대한 글입니다.

Inventory System

Inventory System (2)
Inventory System (3)

  • 해당 이미지와 비슷한 스타일의 인벤토리 구현 목적입니다.

목표

  • 스텟 구현
  • 아이템 구현
  • 인벤토리 구현
  • 캐릭터 장비창 구현

Stat 구현에 필요한 정보

  • 구현에 대해 필요한 정보를 먼저 설명하겠습니다.
    1. Stat은 Character과 Item이 공통적으로 사용합니다.
    1. Character가 Item을 장착하였을 때 해당 Item의 Stat이 더해지는 방식입니다.
    1. Stat은 ScriptableObject로 관리합니다.
    1. Stat은 Enum(능력치 이름) 값과 Float(능력치 수치) 값을 가지고 있습니다.

구현

1. Enums

/// <summary>
/// 장비 아이템 장착 부위
/// </summary>
public enum EquipType   
{
    Helmet,             // 헬멧
    Armor,              // 갑옷
    Weapon_1,           // 무기 1
    Weapon_2,           // 무기 2
    Weapon_3,           // 무기 3
    Spacial,            // 특수 아이템
    Ring,               // 반지
    Necklace,           // 목걸이
}

/// <summary>
/// 아이템 타입
/// </summary>
public enum ItemType
{
    Equipment,             // 장비
    Spacial,               // 아직 고민 중
    Food,                  // 음식
    Etc,                   // 기타
}

/// <summary>
/// 스텟 타입
/// </summary>
public enum StatType
{
    Attack,                 // 공격력
    Defence,                // 방어력
    WorkSpeed,              // 작업 속도    
    Weight,                 // 소지 중량
    HP,                     // 체력
    Stamina                 // 기력
}
  • Enums 클래스에 필요한 Enum 값들을 정리합니다.

2. Stat

[CreateAssetMenu(fileName = "Stat", menuName = "Public/Stat")]
public class Stat : ScriptableObject
{
    public StatType StatType;  
    public float DefaultValue;
}
  • ScritableObject를 상속 받고 StatType(Enum)과 float 형식의 변수를 사용합니다.

3. StatModifier

[System.Serializable]
public class StatModifier
{
    public Stat TargetStat;     // 영향을 받을 Stat
    public float ModifierValue; // 증가 또는 감소 값
}
  • Stat을 참조하여 해당 스텟에 대한 변동 값을 정의합니다. ItemData에 사용 될 클래스입니다.

4. ItemData

[CreateAssetMenu(fileName = "ItemData", menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    public ItemType ItemType;                                           // 아이템 타입
    public string ItemName;                                             // 아이템 이름
    [TextArea(3, 5)] public string Description;                         // 아이템 설명
    public Sprite Icon;                                                 // 아이템 아이콘
    public bool IsStackable;                                            // 중첩 가능 여부
    public int MaxStackSize;                                            // 최대 중첩 개수
    public List<StatModifier> StatModifiers = new List<StatModifier>(); // 스탯 수정 리스트
}
  • ScriptableObject로 구현하였습니다.
  • 각 필요한 변수와 중첩 가능 여부와 최대 중첩 개수를 가지고 있으며 StatModifier를 사용합니다.

5. CharacterStat

public class CharacterStats
{
    public Dictionary<Stat, float> _stats = new Dictionary<Stat, float>();
    private List<ItemData> _equippedItems = new List<ItemData>();

    public int StatusPoints { get; private set; } = 10;                 // 초기 스테이터스 포인트

    public event Action<Stat, float> OnStatChanged;                     // 스탯 변경 이벤트
    public event Action<int> OnStatusPointsChanged;                     // 스테이터스 포인트 변경 이벤트

    public CharacterStats(List<Stat> baseStats)
    {
        foreach (var stat in baseStats)
        {
            _stats[stat] = stat.DefaultValue; // 기본값 설정
        }
    }

    // 현재 스탯 가져오기
    public float GetStatValue(Stat stat)
    {
        if (_stats.ContainsKey(stat))
            return _stats[stat];
        return 0; // 해당 스탯이 없으면 0 반환
    }

    // 아이템 장착
    public void EquipItem(ItemData item)
    {
        _equippedItems.Add(item);
        ApplyModifiers(item.StatModifiers);
    }

    // 아이템 해제
    public void UnequipItem(ItemData item)
    {
        _equippedItems.Remove(item);
        RemoveModifiers(item.StatModifiers);
    }

    // 스탯 수정 적용
    private void ApplyModifiers(List<StatModifier> modifiers)
    {
        foreach (var modifier in modifiers)
        {
            if (_stats.ContainsKey(modifier.TargetStat))
            {
                _stats[modifier.TargetStat] += modifier.ModifierValue;
            }
            else
            {
                _stats[modifier.TargetStat] = modifier.ModifierValue;
            }
        }
    }

    // 스탯 수정 제거
    private void RemoveModifiers(List<StatModifier> modifiers)
    {
        foreach (var modifier in modifiers)
        {
            if (_stats.ContainsKey(modifier.TargetStat))
            {
                _stats[modifier.TargetStat] -= modifier.ModifierValue;

                // 기본값 이하로 내려가면 기본 값 표시
                if (_stats[modifier.TargetStat] < modifier.TargetStat.DefaultValue)
                {
                    _stats[modifier.TargetStat] = modifier.TargetStat.DefaultValue;
                }
            }
        }
    }

    // 능력치 증가
    public void IncreaseStatWithPoints(StatModifier stat)
    {
        if (StatusPoints > 0)
        {
            _stats[stat.TargetStat] += stat.ModifierValue;
            StatusPoints -= 1;

            OnStatChanged?.Invoke(stat.TargetStat, _stats[stat.TargetStat]);
            OnStatusPointsChanged?.Invoke(StatusPoints);
        }
    }

    // 능력치 감소
    public void DecreaseStatWithPoints(StatModifier stat)
    {
        float minValue = GetMinimumStatValue(stat.TargetStat);

        if (_stats[stat.TargetStat] > minValue) 
        {
            _stats[stat.TargetStat] -= stat.ModifierValue;
            StatusPoints += 1;

            OnStatChanged?.Invoke(stat.TargetStat, _stats[stat.TargetStat]);
            OnStatusPointsChanged?.Invoke(StatusPoints);
        }
    }

    // 최소값 계산
    private float GetMinimumStatValue(Stat stat)
    {
        float baseValue = stat.DefaultValue;
        float itemBonus = GetItemBonus(stat);
        return baseValue + itemBonus;
    }

    // 아이템으로 인한 스탯 보너스 계산
    private float GetItemBonus(Stat stat)
    {
        float totalBonus = 0;
        foreach (var item in _equippedItems)
        {
            foreach (var modifier in item.StatModifiers)
            {
                if (modifier.TargetStat == stat)
                {
                    totalBonus += modifier.ModifierValue;
                }
            }
        }
        return totalBonus;
    }
}
  • Character의 Stat을 관리하는 클래스입니다.
  • Item을 장착하였을 때 StatModifier에 있는 Stat이 존재하는지 검사한 뒤 값을 더해주거나 빼줍니다.
  • User가 Stat을 찍는 경우를 위한 함수와 아이템 장착 시 최소값을 계산하기 위한 함수를 추가해줍니다.

6. 장착 해제 테스트 코드

public class CharacterTest : MonoBehaviour
{
    public List<Stat> BaseStats;     
    public ItemData Sword;            
    public ItemData Shield;           

    [HideInInspector] public CharacterStats _characterStats;

    private void Awake()
    {
        _characterStats = new CharacterStats(BaseStats);

        PrintStats("초기 스텟 상태");

        _characterStats.EquipItem(Sword);
        PrintStats("무기 장착");

        _characterStats.EquipItem(Shield);
        PrintStats("갑옷 장착");

        _characterStats.UnequipItem(Shield);
        PrintStats("갑옷 해제");
    }

    private void PrintStats(string context)
    {
        Debug.Log($"{context} Stats:");
        foreach (var stat in BaseStats)
        {
            Debug.Log($"{stat.StatType}: {_characterStats.GetStatValue(stat)}");
        }
    }
}
  • 스텟의 변동을 테스트 하기 위한 클래스입니다.
  • 아이템 장착,해제시 로그로 현재 스텟을 보여줍니다.

7. 스텟 찍기 테스트 코드

public class CharacterStatsTest : MonoBehaviour
{
    public CharacterTest characterTest;

    public Text text_attack;
    public Text text_defance;
    public Text text_workspeed;
    public Text text_hp;
    public Text text_stamina;
    public Text text_weight;

    public Text text_StatusPoint;

    public Button button_atk_Increase;
    public Button button_def_Increase;
    public Button button_workspeed_Increase;
    public Button button_hp_Increase;
    public Button button_stamina_Increase;
    public Button button_weight_Increase;

    public Button button_atk_decrease;
    public Button button_def_decrease;
    public Button button_workspeed_decrease;
    public Button button_hp_decrease;
    public Button button_stamina_decrease;
    public Button button_weight_decrease;

    public StatModifier statModifier_atk;
    public StatModifier statModifier_def;
    public StatModifier statModifier_workspeed;
    public StatModifier statModifier_hp;
    public StatModifier statModifier_stamina;
    public StatModifier statModifier_weight;

    private void Start()
    {
        characterTest._characterStats.OnStatChanged += UpdateText;
        characterTest._characterStats.OnStatusPointsChanged += UpdateStatusPoint;

        button_atk_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_atk));
        button_def_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_def));
        button_workspeed_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_workspeed));
        button_hp_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_hp));
        button_stamina_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_stamina));
        button_weight_Increase.onClick.AddListener(() => OnClickIncreaseStat(statModifier_weight));

        button_atk_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_atk));
        button_def_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_def));
        button_workspeed_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_workspeed));
        button_hp_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_hp));
        button_stamina_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_stamina));
        button_weight_decrease.onClick.AddListener(() => OnClickDecreaseStat(statModifier_weight));

        InitUI();
    }

    private void InitUI()
    {
        foreach(var pair in characterTest._characterStats._stats)
        {
            UpdateText(pair.Key, pair.Value);
        }

        UpdateStatusPoint(characterTest._characterStats.StatusPoints);
    }

    private void OnClickIncreaseStat(StatModifier stat)
    {
        characterTest._characterStats.IncreaseStatWithPoints(stat);
    }

    private void OnClickDecreaseStat(StatModifier stat)
    {
        characterTest._characterStats.DecreaseStatWithPoints(stat);
    }

    private void UpdateText(Stat stat, float point)
    {
        switch(stat.StatType)
        {
            case StatType.Attack: text_attack.text = $"데미지 : {point.ToString()}"; break;
            case StatType.Defence: text_defance.text = $"방어력 {point.ToString()}"; break;
            case StatType.WorkSpeed: text_workspeed.text = $"작업 속도 : {point.ToString()}"; break;
            case StatType.HP: text_hp.text = $"체력 : {point.ToString()}"; break;
            case StatType.Stamina: text_stamina.text = $"기력 : {point.ToString()}"; break;
            case StatType.Weight: text_weight.text = $"최대 중량 {point.ToString()}"; break;
        }
    }

    private void UpdateStatusPoint(int value)
    {
        text_StatusPoint.text = $"StatusPoint : {value.ToString()}";
    }
}
  • CharacterStat의 Event에 UIUpdate 함수들을 연결해줍니다.
  • StatModifier를 변수로 갖고 있어 수치와 스텟을 미리 정의해놓았습니다.
  • 테스트를 위한 코드라 지저분합니다.. 양해 부탁드립니다

사용법 및 테스트

1. ScriptableObject 생성

  • Create -> Public -> Stat으로 ScriptableObject를 생성해줍니다.

  • Character의 기본 Stat을 만든 뒤 초기치를 생성해주고 Type을 정해줍니다.

  • Craete -> Inventory -> Item 으로 ScriptableObject를 생성해줍니다.
  • 아이템의 정보와 장착하였을 때 변동이 있어야 하는 스텟의 값들을 넣어줍니다.

2. 장착 해제 테스트

  • GameObject를 하나 생성한 뒤 Character Test 컴포넌트를 넣어줍니다.
  • 캐릭터의 기본 스텟들을 넣어주고 테스트를 위한 ItemData를 넣어줍시다.

  • 초기 상태

  • 무기 장착

  • 갑옷 장착

  • 갑옷 해제

3. 스텟 찍기 테스트


테스트 결과

  • 아이템을 장착,해제 시 스텟이 변동되면서 정상적으로 동작합니다.
  • 스텟을 업하고 다운하는 과정에서 StatusPoint가 제대로 반환되며 값도 제대로 반환됩니다.
  • UI 갱신도 이루어지며 아이템 장착한 값 이하로는 내려가지 않습니다.

느낀 점

예전에 Stat과 Item을 구현한 것 보다는 구조가 더 짜임새가 있는 것 같습니다.
UI 테스트 부분의 코드가 만족스럽진 않지만 Inventory 작업을 하면서 최종본을 낼 때 같이 수정해야 겠습니다.그거 말고는 개인적으로 만족하는 코드입니다.

  • 제가 작성한 내용이 잘못 되었거나 더 좋은 방법이 있는 경우에는 댓글로 알려주시면 감사합니다 ! (´._.`)
profile
Unity 게임 개발자를 준비하는 취업준비생입니다..

0개의 댓글