Unity

Unity <스크립터블오브젝트(ScriptableObject) 사용하여 몬스터의 아이템 드랍 구현하기>

회민짱짱 2024. 1. 13. 22:18
직렬화, 역직렬화 개념 이해하기

 

 

직렬화데이터 구조, 오브젝트 상태를 동일하게 하여 어디서든 읽을 수 있도록 변환하는 과정입니다.

직렬화가 되는 방식에는 문자열(string)형식 혹은 바이너리(binary)형식이 있습니다. 

 

반대로, 역직렬화직렬화가 되어있는 데이터로부터 그 구조를 추출하는 과정을 말합니다.

 

기본적으로 유니티 인스펙터창에는 직렬화가 가능한 혹은 직렬화가 되어있는 데이터들만 표시될 수 있습니다.

 

예를 들어, 플레이어의 능력치를 저장해둘 클래스를 만들고 플레이어가 이를 참조하는 형태를 만들어보겠습니다.

 

public class PlayerStats
{
    public int currentHealth;
    public int maxHealth;
    public int attackPower;
}

public class PlayerTest : MonoBehaviour
{
    public PlayerStats stats;

    private void Start()
    {
        stats = new PlayerStats();
        stats.maxHealth = 100;
        stats.currentHealth = 100;
        stats.attackPower = 30;
    }
}

 

만약, 유니티 내부의 어떠한 오브젝트의 컴포넌트로 Player 스크립트를 부착시킨다면 인스펙터창에서 PlayerStats 는 보이지 않습니다. 

인스펙터 창에서 확인이 불가함

 

이는 PlayerStats 클래스를 선언했다 하더라도 유니티는 그것을 알지 못하고 직렬화되어야만 표시할 수 있게됩니다.

따라서, PlayerStats 클래스를 직렬화가 가능한 클래스로 만들어주어야합니다. 

 

// [System.Serializable] : 직렬화가 가능한 클래스로 만들어주는 어트리뷰트
[System.Serializable]
public class PlayerStats
{
    public int currentHealth;
    public int maxHealth;
    public int attackPower;
}

 

이렇게 어트리뷰트를 추가하게되면 인스펙터창에서는 직렬화 된 데이터를 인식하고 이를 다시 유니티 엔진에서 역직렬화 하여 사용자에게 표시해주게 되는 것입니다. 

인스펙터창에 표시됨

 

유니티를 실행시키면 값도 초기화 된 형태로 저장되어 나타납니다. 

 

실행시 데이터가 초기화된 모습

 

 

스크립터블 오브젝트란?

 

스크립터블 오브젝트는 유니티에서 제공하는 직렬화가 가능한 클래스로 일종의 데이터 컨테이너로 사용할 수 있습니다. 

 

이미 직렬화가 가능하기 때문에 객체 생성시 인스펙터 창에 곧바로 표시되어 데이터를 쓰고 저장할 수 있는 기능을 제공해줍니다. 

 

 

스크립터블 오브젝트를 사용하지 않고 드랍 아이템 만들기

 

 

RPG 게임에서 몬스터를 처치할 때 아이템을 랜덤으로 드랍하는 상황을 가정해봅시다. 

 

이를 구현하기위해 몬스터와 아이템 클래스를 작성하면 다음과 같습니다. 

 

public class Item : MonoBehaviour
{
    public GameObject itemPrefab;

    private void Start()
    {
        itemPrefab = this.gameObject;
    }
}

public class Monster : MonoBehaviour
{
    // dropItemList는 아이템 클래스 객체를 담을 리스트
    // 몬스터 객체는 생성될 때마다 모두 동일한 dropItemList를 하나씩 갖게되므로 비효율적
    // 이런 문제를 해결하기 위해 스크립터블 오브젝트를 사용할 수 있음
    
    public List<Item> dropItemList;

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
            Die();
    }

    public void Die()
    {
        Destroy(gameObject);
        DropItem();
    }

    public void DropItem()
    {
        GameObject dropItem = dropItemList[Random.Range(0, dropItemList.Count)].itemPrefab;
        Instantiate(dropItem,transform.position, Quaternion.identity);
    }
}

 

주석에 설명해 두었듯 이렇게 코드를 작성하면 작동은 잘 되겠지만 게임 내에는 몬스터가 적지 않습니다. 

 

즉, 많은 수의 몬스터들이 각자 같은 드랍아이템리스트를 가지고 있어야하는 비효율적인 상황이 생깁니다. 

 

스크립터블 오브젝트로 드랍 아이템 목록 만들기

 

위 상황을 해결하고자 스크립터블 오브젝트를 사용하여 구현해보겠습니다.

 

스크립터블 오브젝트를 이용하여 DropITemList 클래스를 작성하겠습니다. 

 

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace SOTest
{
    // ScriptableObject 상속받고 [CreateAssetMenu] 어트리뷰트 추가
    [CreateAssetMenu]
    public class DropList : ScriptableObject
    {
        public List<GameObject> itemList;
        
        public GameObject this[int index]
        {
            get
            {
                return itemList[index];
            }
        }

        public int Count => itemList.Count;

    }

}

 

 

스크립터블 오브젝트를 만들기위해서는 ScriptableObject를 상속받은 클래스 여야하며 [CreateAssetMenu] 어트리뷰트를 추가해주어야합니다. 

 

그렇게 되면 유니티 내의 에셋 폴더에서 마우스 우클릭을하고 Create 메뉴를 들어가게되면 상단에 클래스 명이 보이게됩니다. 

 

 

이를 클릭하면 다음과 같은 아이콘으로 에셋 폴더에 생성되는 것을 확인 할 수 있습니다. 

 

스크립터블 오브젝트 아이콘

 

 

아이콘을 클릭하게되면 인스펙터 창에서 직접 데이터를 추가할 수 있도록 표시되게 됩니다. 

 

 

 

이제 몬스터 클래스를 수정해줍니다. 

 

using SOTest;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace SOTest
{
    public class Monster : MonoBehaviour
    {
        // 스크립터블 오브젝트 객체
        public DropList dropList;

        private void Update()
        {
            if (Input.GetKeyDown(KeyCode.Space))
                Die();
        }

        public void Die()
        {
            dropList.testValue--;
            DropItem();
            Destroy(gameObject);
        }

        public void DropItem()
        {
            // 아래 코드와 동일
            // GameObject dropPrefab = dropList.itemList[Random.Range(0,dropList.itemList.Count)];
            
            // 인덱서 사용하여 가독성을 향상 시킴
            GameObject dropPrefab = dropList.itemList[Random.Range(0, dropList.Count)];
            Instantiate(dropPrefab, transform.position, Quaternion.identity);
        }

    }
}

 

해당 스크립트를 오브젝트에 추가하고 DropList 변수에 생성했던 스크립터블 오브젝트를 넣어줍니다. 

 

 

 

코드를 실행하면 아까와 동일한 기능이 가능하지만 이제 몬스터들은 스크립터블 오브젝트로 생성된 하나의 드랍리스트만을 공유하게 되어 훨씬 효율적입니다. 

 

스크립터블 오브젝트 사용시 주의사항

 

스크립터블 오브젝트는 이미 직렬화가 되어있는 클래스라고 설명했습니다.

 

이러한 특징으로 인하여 런타임 중에 실시간으로 바뀌어야하는 데이터를 스크립터블 오브젝트로 만들었을 시 발생할 수 있는 문제가 있습니다. 

 

DropList 클래스를 조금 수정하여 소개하면서 설명하겠습니다. 

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace SOTest
{
    [CreateAssetMenu]
    public class DropList : ScriptableObject, ISerializationCallbackReceiver
    {
        // 주의사항! 
        public int testValue;
        // 초기화 안일어남
        // 만약 초기화 하고싶어?
        // 직렬화 되어있는 결과가 이미 바뀌어있는것임 이미 바뀐 값을 역직렬화하니까 초기화 안된체로 됨
        // 해결은? ISerializationCallbackReceiver 인터페이스 상속

        [NonSerialized] // 직렬화 하지 마라는 어트리뷰트
        public readonly int initValue = 100;

        public void OnAfterDeserialize()
        {
            testValue = initValue;
        }

        public void OnBeforeSerialize()
        {
            
        }
    }

}

 

주의 사항만 설명 드리기위해 리스트 코드는 전부 지웠습니다. 

 

using SOTest;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace SOTest
{
    public class Monster : MonoBehaviour
    {

        private void Update()
        {
            if (Input.GetKeyDown(KeyCode.Space))
                Die();
        }

        public void Die()
        {
            dropList.testValue--;
        }

    }
}

 

마찬가지로 몬스터 클래스도 testValue 값을 변경시키는 코드 외에 전부 지웠습니다. 

 

이 상태에서 유니티를 실행시키고 스페이스바를 누르면 씬 내에 몬스터 클래스 객체의 수만큼 testValue 가 줄어들 것입니다. 

 

여기까진 정상 작동합니다만 다음이 문제입니다. 

 

실행되던 유니티를 끄면 일반적인 클래스 내의 데이터는 실행시키기 전으로 초기화됩니다. 

 

하지만 testValue 값은 값이 변경되면 이미 직렬화된 값이 변경되는 것이기에 실행을 종료하고 인스펙터에서 역직렬화를 하여 데이터를 표시하여도 초기화가 되지 않습니다. 

 

이러한 문제는 ISerializationCallbackReceiver 인터페이스를 추가하여 해결해 줄 수 있습니다.