Study/Unity

[Unity System Programming Part.1] 1주차

해달 2024. 9. 10. 10:45

개요

제자들과 함께 유니티 시스템 프로그래밍 Pt.1 - 상용 게임 구현을 위한 핵심 시스템 올인원 패키지 학습을 시작했다. Unity 엔진에 관한 지식은 매뉴얼과 유튜브 공식 자료를 통해 습득할 수 있는 반면, 효율적인 아키텍처에 대해서는 늘 고민이 많았기 때문이다.

Unit Of Work 패턴을 적용하려고 애쓰던 때가 새록새록하다.

 

사용하는 Unity 버전은 2022.3.45f1으로 글을 쓰고 있는 현 시점에서 가장 최신 LTS 버전이다. 단, 에디터는 계속 새로운 버전이 나올 때마다 업그레이드 할 예정이다. 스터디와 관련된 정보는 이 저장소에서 확인할 수 있다.

씬 설계

Unity 프로젝트에서 기능이 부여될 수 있는 객체를 게임오브젝트(GameObject)라 한다. 그리고 이러한 게임오브젝트를 묶는 단위가 바로 (Scene)이다. 따라서 Unity 프로젝트를 진행할 때, 씬을 어떻게 나눌 것인지, 각 씬마다 어떤 게임오브젝트를 포함시킬 것인지 결정하는 것이 가장 우선적으로 해야할 일이라고 할 수 있다. 추천하는 씬의 구성은 타이틀, 로비, 인게임이다.

타이틀 씬

타이틀(Title)씬은 게임에 필요한 정적 데이터를 로드하는 용도로 사용된다. 게임을 정상적으로 동작하기 위해 먼저 로드가 되어야 하는 객체가 있는데, 보통 사용자에게는 UI를 띄워두고 백그라운드로 이러한 처리를 한다. 어떤 게임은 타이틀 화면에서 동작하는 게임을 구현하기도 하는데, 개인적인 견해로는 게임을 제공한다고 하여 크게 사용자 경험이 개선되는 것 같진 않다.

로비 씬

다음은 로비(Lobby)씬이다. 보통 아웃게임 콘텐츠를 제공한다. 사용자는 로비 씬을 통해 우편함, 퀘스트, 배틀패스 등의 콘텐츠를 확인할 수 있다. 다른 스터디원은 허브(Hub)라는 표현을 했는데 적절한 비유라 생각했다.

인게임 씬

마지막으로 게임의 핵심 콘텐츠를 제공하는 인게임(InGame)씬이다. 스테이지가 있는 게임인 경우 스테이지마다 씬을 만드는 것은 권장되지 않는다. 추후 변경사항이 있을 시 꽤나 번거로운 과정이 많기 때문이다. 미니게임이 있는 경우 인게임 씬에 추가하기보다 별도의 씬으로 관리하는 것이 권장된다.

공통 모듈

모든 클래스에서 활용해야 하는 공통 모듈이 있다. 우선 소개된 것은 3가지이다.

Logger

세상의 모든 버그를 디버거를 이용해서만 잡을 수는 없다. 게임이 복잡해질수록 여러 로그를 남겨놔야 디버깅하기가 편하다. Unity에서는 Debug 클래스로 로그를 남길 수 있다.

public class Something : MonoBehaviour
{
    void Start()
    {
        // 결과는 콘솔 창을 통해 확인할 수 있다.
        UnityEngine.Debug.Log("Hello, Log");
    }
}

그러나 Debug.Log()는 단순히 메시지를 남기는 것에 불과하므로 프로젝트 내에서 공통된 포맷으로 로깅을 할 수 있게 래퍼(Wrapper) 클래스를 만드는 것이 좋다. 기본적으로 포함되어야 할 정보는 타임스탬프이고, 그 외 추가정보를 넣으면 된다.

// UnityEngine.Debug와 비슷하게 3가지 메소드를 제공한다.
public static class Logger  
{
    // Debug.Log()는 IO 작업 때문에 병목 지점이 된다.
    // 따라서 릴리즈 빌드 시에는 제외하는 것이 좋다.
    [Conditional("DEV_VER")]  
    public static void Log(string message) =>  
        Debug.LogFormat("[{0}] {1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), message);  

    [Conditional("DEV_VER")]  
    public static void LogWarning(string message) =>  
        Debug.LogWarningFormat("[{0}] {1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), message);  

    // 단, 오류용 로그는 디버그를 위해 남겨놔야 한다.
    public static void LogError(string message) =>  
        Debug.LogErrorFormat("[{0}] {1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), message);  
}

여기에 ZLogger 라이브러리를 사용하는 것도 좋아보인다.

SingletonBehaviour

싱글톤(Singleton)은 나쁘다는 얘기가 많지만, 적절히 사용하면 나쁘지 않다.* 싱글톤 패턴에 대한 것은 과거에 정리한 바가 있으니, 그것을 참고하자.
* 어떤 책에서는 싱글톤을 제대로 사용하는 방법은 DI 프레임워크를 쓰는 것이라고도 한다.

public class SingletonBehaviour<T> : MonoBehaviour where T : SingletonBehaviour<T>  
{  
    protected bool IsDestroyOnLoad { get; set; } = false;  
    private static T s_instance;  

    public static T Instance => s_instance;  

    private void Awake()  
    {  
        Init();  
    }  

    // 클래스를 초기화하는 용도로 사용한다.
    protected virtual void Init()  
    {  
        if (s_instance != null)  
        {  
            Destroy(gameObject);  
            return;  
        }  

        s_instance = this as T;  
        if (IsDestroyOnLoad == false)  
        {  
            DontDestroyOnLoad(gameObject);  
        }  
    }  

    private void OnDestroy()  
    {  
        Dispose();  
    }  

    // 자원을 정리하는 용도로 사용한다.
    protected virtual void Dispose()  
    {  
        s_instance = null;  
    }  
}

과거에 이런 글쓴 적도 있는데, 위의 코드가 이해하기도 편하고 오류도 빨리 잡을 수 있다고 생각한다.

SceneLoader

Unity에서 새로운 씬을 로드하고 싶다면 SceneManager를 사용하면 된다. 그러나, (1) 문자열 혹은 정수를 인수로 받기 때문에 오류를 일으킬 수 있고, (2) 씬을 로드하는 시퀀스가 존재할 수 있기 때문에 Logger와 마찬가지로 래퍼 클래스를 만든다.

public enum ESceneType  
{  
    Title,  
    Lobby,  
    InGame  
}  

public class SceneLoader : SingletonBehaviour<SceneLoader>  
{  
    public void LoadScene(ESceneType sceneType)  
    {  
        Logger.Log($"Load {sceneType} scene.");  

        // 다른 씬에서 타임 스케일을 건드릴 수도 있으므로 로드 전, 기본값으로 설정한다.
        Time.timeScale = 1f;  
        SceneManager.LoadScene(sceneType.ToString());  
    }  

    public void ReloadScene()  
    {  
        Logger.Log($"Load {SceneManager.GetActiveScene().name} scene.");  

        Time.timeScale = 1f;  
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);  
    }

    public AsyncOperation LoadSceneAsync(ESceneType sceneType)  
    {  
        Logger.Log($"Load {sceneType} scene async.");  

        Time.timeScale = 1f;  
        return SceneManager.LoadSceneAsync(sceneType.ToString());  
    }
}

로딩 시퀀스를 구현할 때, AsyncOperation을 이용할 수 있다.

public class Something : MonoBehaviour
{
    bool _isLoading = false;
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space) & _isLoading == false)
        {
            StartCoroutine(LoadingSequence());
        }
    }

    IEnumerator LoadingSequence()
    {
        _isLoading = true;

        var loadingOperation = SceneLoader.Instance.LoadSceneAsync(ESceneType.Lobby);

        // 해당 프로퍼티를 false로 하면 씬의 로드가 끝났을 때,
        // 자동으로 씬으로 전환되는 것을 방지할 수 있다.
        // 의도한 시퀀스를 보여주기 위해서 필수다.
        loadingOperation.allowSceneActivation = false;

        // 씬의 로드가 생각보다 빠를 수 있다.
        // 자연스러운 사용자 경험을 위해 의도적으로 이때, 대기할 수 있다.

        while (loadingOperation.isDone == false)
        {
            // progress는 [0, 1]사이의 값을 가진다.
            Debug.Log($"{loadingOperation.progress}%");

            // allowSceneActivation이 false면 0.9에서 멈춘다.
            if (loadingOperation.progress >= 0.9f)
            {
                // 다시 true로 바꿔서 씬을 전환한다.
                loadingOperation.allowSceneActivation = true;
            }

            yield return null;
        }
    }
}

UI 카메라

UI 작업과 씬 작업을 분할하기 위해 메인 카메라와 UI 카메라를 따로 나눠 놓는 것이 좋다. 렌더 파이프라인에 따라 적용하는 방법이 다른데, 빌트인(Built-In)에서는 카메라 깊이(Camera Depth)를 사용하고, URP에서는 카메라 스택(Camera Stack)을 사용한다. 빌트인에서의 적용 방법은 이 글매뉴얼을 참고하고, URP는 이 튜토리얼을 참고하면 좋다.

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

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