Study/Unity

[Unity System Programming Pt.1] 2주차

해달 2024. 9. 24. 15:30

개요

모든 프로그램에서 데이터는 중요하다. 결국 프로그램은 데이터를 조작하는 것에 불과하기 때문이다. 게임에서 사용하는 데이터는 크게 2가지로 나눌 수 있다. 유저 데이터게임 데이터다. 유저 데이터는 게임 설정 값, 인벤토리, 육성하고 있는 캐릭터 등이 되고, 기본 값이 있으며 수시로 저장이 이뤄지는 편이다.* 게임 데이터는 몬스터 정보, 던전 정보, 필드 정보 등이 되고, 데이터 테이블로 관리가 되며 불러오기만 있다. 그리고 유저가 게임에 몰입하기 위해서는 적절한 오디오도 필요하다. 게임에서 사용되는 오디오의 종류는 기본적으로 효과음과 배경음이 있고, 그 외에 게임에 따라 종류가 추가되는 편이다. 이번에는 유저 데이터 관리 모듈, 게임 데이터 관리 모듈, 오디오 관리 모듈 설계를 살펴보고자 한다.

* 당연한 얘기다. 저장이 잘 이뤄지지 않으면 유저에게 욕을 먹는다. 반면, 유저 데이터 관리를 잘못하면 버그가 일어나기도 한다. 이 영상을 참고하라.

직렬화

본격적으로 모듈을 제작하기 전, 직렬화(Serialization)에 대해서 알아보자. 직렬화란 통신을 위하여 인스턴스를 일정한 포맷의 데이터로 바꾸는 것이다. 반대는 역직렬화(Deserialization)라 한다. 인스턴스를 통째로 복사해서 통신에 사용하지 않고 직렬화를 하는 이유는 2가지가 있다. (1) 인스턴스 중 필요한 데이터만을 선별해서 통신에 사용하기 위함이고, (2) 데이터에 사용되는 주소는 가상 주소이기에 이를 다른 프로세스에서 그대로 사용할 수 없기 때문이다. 직렬화 된 데이터 포맷은 텍스트 혹은 바이너리로 분류할 수 있는데, 보통은 가독성이 더 요구되기에 텍스트가 선호되며 JSON이나 XML 등이 많이 쓰인다. 간혹 성능을 위해 바이너리를 사용할 수 있으며 Protobuf가 대표적이다. .NET에서의 직렬화는 여기, Unity에서의 스크립트 직렬화에 대한 정보는 여기서 확인할 수 있다.

게임 데이터 관리 모듈

기획서에 따라 게임 데이터 엔터티에 대한 모델을 먼저 정의해야 한다. 보통 이러한 데이터를 영속(Persistence) 데이터라고도 하며, 한 번 설계한 뒤 자주 수정되는 편이 아니기에 빌드 시간을 단축하기 위하여 따로 어셈블리를 분리해두는 편이다.* 어떤 것을 레퍼런스로 삼아서 만들 하다가 시의성을 생각해봤을 때, 최근 정상화 된 메이플스토리의 일반 몬스터로 결정했다. 메이플스토리의 일반 몬스터 정보는 나무위키를 참고했다. 모든 걸 다 할 필요는 없으니 아래 달팽이를 참고해 만들어보자.

* 어셈블리 분리에 대해서는 여기를 참고하라.

달팽이

몬스터의 모델 클래스를 설계한다. 위에 나와 있는 모든 데이터를 사용하지 않고 이름, 레벨, HP, MP, EXP, 물리 공격력 정도만 클래스에 나타내도록 하자.

using System;

[Serializable]
class MonsterModel
{
    public string name;
    public int level;
    public int hp;
    public int mp;
    public int exp;
    public int atk;
}

게임 데이터는 다양한 포맷으로 저장될 수 있다. 본 글에서는 외부 라이브러리에 의존하지 않고 Unity에서 JSON 직렬화 라이브러리를 지원하기에 JSON 포맷으로 게임 데이터가 저장되어 있다고 가정하겠다. 게임 데이터는 아래와 같이 저장되어 있는 형태라고 가정하자.*

* 혹시 외부 라이브러리를 사용하고 싶다면 BGDatabase 등을 고려해볼 수 있다.

// monster.json
{
  "data": [
    {
      "name": "Snail",
      "level": 1,
      "hp": 15,
      "mp": 0,
      "exp": 3,
      "atk": 2
    },
    {
      "name": "Blue Snail",
      "level": 2,
      "hp": 20,
      "mp": 0,
      "exp": 4,
      "atk": 3
    },
    {
      "name": "Shroom",
      "level": 3,
      "hp": 25,
      "mp": 0,
      "exp": 5,
      "atk": 6
    },
    {
      "name": "RedSnail",
      "level": 5,
      "hp": 50,
      "mp": 0,
      "exp": 8,
      "atk": 15
    }
  ]
}

// 혹은 파일의 크기를 줄이기 위해 아래처럼 되어 있을 수 있다.
{"data":[{"name":"Snail","level":1,"hp":15,"mp":0,"exp":3,"atk":2},{"name":"Blue Snail","level":2,"hp":20,"mp":0,"exp":4,"atk":3},{"name":"Shroom","level":3,"hp":25,"mp":0,"exp":5,"atk":6},{"name":"RedSnail","level":5,"hp":50,"mp":0,"exp":8,"atk":15}]}

모델과 데이터가 준비되었으니 게임 데이터 매니저를 제작하자.

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

public class DataTableManager : SingletonBehaviour<DataTableManager>
{
    [SerializeField]
    class Wrapper<T>
    {
        public T[] data;
    }

    private MonsterModel[] _monsters;

    protected override void Init()
    {
        base.Init();

        _monsters = LoadDataFromJson<MonsterModel>("monster");
    }

    private const string DATA_PATH = "DataTable";
    private T[] LoadDataFromJson<T>(string filename)
    {
        // NOTE : Log를 남기는 것도 고려해볼 수 있다.
        var path = Path.Combine(DATA_PATH, filename);
        var json = Resources.Load<TextAsset>(path);
        var wrapper = JsonUtility.FromJson<Wrapper<T>>(json.text);
        return wrapper.data;
    }
}

씬에 매니저를 배치한 후, 사용하면 된다.

유저 데이터 관리 모듈

유저 데이터는 아래와 같은 인터페이스를 설계하고 이를 상속받아 구현한다.

public interface IUserData  
{  
    /// <summary>  
    /// 기본값으로 설정한다.  
    /// </summary>
    void SetDefaultData();  

    /// <summary>  
    /// 데이터를 불러온다.  
    /// </summary>
    /// <returns>성공했다면 true, 실패했다면 false</returns>
    bool LoadData();  

    /// <summary>  
    /// 데이터를 저장한다.  
    /// </summary>
    /// <returns>성공했다면 true, 실패했다면 false</returns>
    bool SaveData();  
}

이번에도 마찬가지로 메이플스토리를 갖고 구현해보도록 하자. 최신 메이플스토리를 보니 한 월드에 기본 12개의 캐릭터를 생성할 수 있다고 한다. 너무 많으니까 1인당 한 캐릭터만 생성할 수 있다고 가정하겠다. 메이플스토리의 캐릭터 정보는 아래와 같다.

요즘 메이플스토리의 UI는 참 이쁘다.

닉네임, HP, MP, 공격력만 사용해서 아래와 같이 클래스를 만들어 보자.

using System;
using UnityEngine;

public class UserCharacterData : IUserData
{
    public string NickName { get; set; }
    public int Hp { get; set; }
    public int Mp { get; set; }
    public int Attack { get; set; }

    public void SetDefaultData()
    {
        Logger.Log($"{GetType()}::{nameof(SetDefaultData)}");

        NickName = string.Empty;
        Hp = 50;
        Mp = 0;
        Attack = 15;
    }

    public bool LoadData()
    {
        Logger.Log($"{GetType()}::{nameof(LoadData)}");

        // NOTE :
        // PlayerPrefs는 예외를 던지지 않는다.
        // 다만, 추후 유저 데이터 종류에 따라서는
        // 예외처리가 필요한 경우도 있으므로 일관된 코드를 위해서
        // 미리 try-catch 문을 작성해놓는다.
        bool result = false;
        try
        {
            NickName = PlayerPrefs.GetString(nameof(NickName));
            Hp = PlayerPrefs.GetInt(nameof(Hp));
            Mp = PlayerPrefs.GetInt(nameof(Mp));
            Attack = PlayerPrefs.GetInt(nameof(Attack));

            result = true;

            Logger.Log($"{nameof(NickName)} : {NickName}");
            Logger.Log($"{nameof(Hp)} : {Hp}");
            Logger.Log($"{nameof(Mp)} : {Mp}");
            Logger.Log($"{nameof(Attack)} : {Attack}");
        }
        catch (Exception e)
        {
            Logger.Log($"Fail to load : {e}");
        }

        return result;
    }

    public bool SaveData()
    {
        Logger.Log($"{GetType()}::{nameof(SaveData)}");

        bool result = false;
        try
        {
            PlayerPrefs.SetString(nameof(NickName), NickName);
            PlayerPrefs.SetInt(nameof(Hp), Hp);
            PlayerPrefs.SetInt(nameof(Mp), Mp);
            PlayerPrefs.SetInt(nameof(Attack), Attack);
            PlayerPrefs.Save();

            result = true;

            Logger.Log($"{nameof(NickName)}: {NickName}");
            Logger.Log($"{nameof(Hp)}  :  {Hp}");
            Logger.Log($"{nameof(Mp)} : {Mp}");
            Logger.Log($"{nameof(Attack)}: {Attack}");
        }
        catch (Exception e)
        {
            Logger.Log($"Fail to save : {e}");
        }

        return result;
    }
}

유저 데이터는 온라인 게임이라면 자체적으로 운영하는 DB 혹은 Firebase와 같은 외부 솔루션을 사용해 저장할 것이지만, 본 글에서는 간단하게 PlayerPrefs를 사용한다.

 

이제 이런 데이터를 관리하는 유저 데이터 매니저 클래스를 제작한다.

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

public class UserDataManger : SingletonBehaviour<UserDataManger>
{
    public bool ExistsSavedData { get; private set; } = false;
    public List<IUserData> UserDataList { get; private set; } = new();

    protected override void Init()
    {
        base.Init();

        ExistsSavedData = PlayerPrefs.GetInt(nameof(ExistsSavedData)) == 1;

        // 추후 데이터 종류가 많아지면 차례대로 넣어주면 된다.
        UserDataList.Add(new UserCharacterData());
    }

    public void SetDefaultData()
    {
        foreach (var data in UserDataList)
        {
            data.SetDefaultData();
        }
    }

    public void LoadUserData()
    {
#if DEV_VER
        // NOTE : 추후 PlayerPrefs 데이터를 지워가며 테스트를 할 수도 있기에 매번 체크한다.
        ExistsSavedData = (PlayerPrefs.GetInt(nameof(ExistsSavedData)) == 1);
#endif

        if (ExistsSavedData)
        {
            foreach (var data in UserDataList)
            {
                data.LoadData();
            }
        }
    }

    public void SaveUserData()
    {
        bool hasError = false;
        foreach (var data in UserDataList)
        {
            if (false == data.SaveData())
            {
                hasError = true;
                break;
            }
        }

        if (hasError == false)
        {
            PlayerPrefs.SetInt(nameof(ExistsSavedData), 1);
            PlayerPrefs.Save();

            ExistsSavedData = true;
        }
    }
}

오디오 관리 모듈

상술했듯 기본적으로 사용하는 배경음과 효과음에 대해서만 다루도록 한다. 아래와 같이 열거형을 정의한다.

public enum AudioType
{
    BGM,
    SFX
}

위의 열거형은 게임 내 전역으로 사용되는 오디오의 종류를 정의한다. 추가 데이터 예시를 들어보자면 대사를 고려해볼 수 있을 것이다. 이제 이 열거형을 사용해 아래와 같은 게임오브젝트 계층으로 오디오를 다룰 것이다.

AudioManager(AudioManager)
├── BGM(AudioSource)
└── SFX(AudioSource)

코드를 작성해보자.

public class AudioManager : SingletonBehaviour<AudioManager>
{
    private AudioSource[] _audioSources;

    protected override void Init()
    {
        base.Init();

        // AudioType 열거형을 기반으로 자식 오브젝트를 생성한다.
        string[] soundTypeNames = Enum.GetNames(typeof(AudioType));
        _audioSources = new AudioSource[soundTypeNames.Length];
        for (int i = 0; i < soundTypeNames.Length; ++i)
        {
            GameObject go = new GameObject(soundTypeNames[i]);
            go.transform.parent = transform;
            _audioSources[i] = go.AddComponent<AudioSource>();
        }

        // 각 오디오 타입마다 필요한 기본 세팅을 해둔다.
        AudioSource bgm = _audioSources[(int)AudioType.BGM];
        bgm.loop = true;
    }
}

그리고 한 번 불러온 오디오 클립은 캐싱하기 위해 DictionaryGetClip() 메소드를 추가한다.

private Dictionary<string, AudioClip> _clips = new();
private const string AUDIO_PATH = "Audio";
public AudioClip GetClip(string fileName)
{
    if (_clips.TryGetValue(fileName, out var clip))
    {
        return clip;
    }

    _clips[fileName] = Resources.Load<AudioClip>($"{AUDIO_PATH}/{fileName}");
    return _clips[fileName];
}

다음으로는 오디오를 조절하기 위한 메소드가 필요하다. 재생, 일시정지, 재개, 정지 등의 메소드를 만들어보자.

public void Play(AudioType audioType, string fileName, float volume = 1f, float pitch = 1f)
{
    AudioClip clip = GetClip(fileName);

    AudioSource audioSource = _audioSources[(int)audioType];
    audioSource.volume = volume;
    audioSource.pitch = pitch;

    switch (audioType)
    {
        case AudioType.BGM:
            if (audioSource.isPlaying)
            {
                audioSource.Stop();
            }

            audioSource.clip = clip;
            audioSource.Play();
            break;
        case AudioType.SFX:
            audioSource.PlayOneShot(clip);
            break;
        default:
            Logger.LogError($"Fail to play {audioType}");
            break;
    }
}

public void SetPitch(AudioType audioType, float pitch) => _audioSources[(int)audioType].pitch = pitch;

public void SetVolume(AudioType audioType, float volume) => _audioSources[(int)audioType].volume = volume;

public void Pause(AudioType audioType) => _audioSources[(int)audioType].Pause();

public void Resume(AudioType audioType) => _audioSources[(int)audioType].UnPause();

public void Stop(AudioType audioType) => _audioSources[(int)audioType].Stop();

public void StopAll()
{
    foreach (var source in _audioSources)
    {
        source.Stop();
    }
}

생각해볼 거리

  1. 모델 클래스를 Persistence 라는 이름의 어셈블리로 분리해보자. 빌드 시간이 단축될 것이다.
  2. 때에 따라서는 메모리 관리를 위해 무한하게 오디오를 캐싱해둘 순 없을 것이다. 어떻게하면 이를 해결할 수 있을까?

참고자료

'Study > Unity' 카테고리의 다른 글

[Unity System Programming Part.1] 1주차  (7) 2024.09.10
Unity에 싱글톤(Singleton) 패턴 적용하기  (0) 2022.07.17
Awake / OnEnable / Start의 차이  (0) 2022.07.17