오늘 특강으로 너무 많은게 머리에 들어와서 정리하는데 시간을 많이 사용했다.

 

그래서 가장 매력적이었던 UI시스템을 한번 에셋처럼 틀을 만들어보기로 했다.

(TIL겸 앞으로 사용하면서 개선도 하며 이 글을 계속 볼고 수정할 것이다.)

우선 제네릭 싱글톤을 만들자.

 

using UnityEngine;

public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    public static T Instance 
    {
        get
        {
            if (_instance == null)
            {
                _instance = FindFirstObjectByType<T>();
                if (_instance == null)
                {
                    GameObject singletonObject = new GameObject(typeof(T).Name);
                    _instance = singletonObject.AddComponent<T>();
                    DontDestroyOnLoad(singletonObject);
                }
                else
                {
                    DontDestroyOnLoad(_instance.gameObject);
                }
            }
            return _instance;
        }
    }

    protected virtual void Awake()
    {
        if (_instance == null)
        {
            _instance = this as T;
            DontDestroyOnLoad(this.gameObject);
        }
        else if (_instance != this)
        {
            Destroy(gameObject);
        }
    }
}

 

그리고 UIManager를 만들어주자

 

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class UIManager : Singleton<UIManager>
{
    // 현재 활성화된 캔버스를 참조하기 위한 변수
    private Canvas _currentCanvas;

    private void OnEnable()
    {
        // 씬 변경 시 이벤트 등록
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        // 이벤트 핸들러 해제
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        // 기존 UI 캐시 정리
        ResourceManager.Instance.ClearCache();

        // 씬에 대응하는 캔버스 로드
        LoadCanvasForScene(scene.name);
    }

    private void LoadCanvasForScene(string sceneName)
    {
        // 씬에 대응하는 캔버스 프리팹 로드
        Canvas canvasPrefab = Resources.Load<Canvas>($"UI/Canvas_{sceneName}");
        if (canvasPrefab != null)
        {
            // 기존 캔버스가 있다면 제거
            if (_currentCanvas != null)
            {
                Destroy(_currentCanvas.gameObject);
            }
            // 새로운 캔버스 인스턴스화
            _currentCanvas = Instantiate(canvasPrefab);
        }
        else
        {
            Debug.LogWarning($"현재 씬에 대응하는 Canvas 프리팹이 없음: {sceneName}. 새로운 프리팹을 생성하겠습니다.");

            // 기본 캔버스 생성
            if (_currentCanvas == null)
            {
                GameObject canvasObject = new GameObject("DefaultCanvas");
                _currentCanvas = canvasObject.AddComponent<Canvas>();
                _currentCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
                canvasObject.AddComponent<CanvasScaler>();
                canvasObject.AddComponent<GraphicRaycaster>();
                DontDestroyOnLoad(canvasObject);
            }
        }
    }

    public T Show<T>() where T : UIBase
    {
        if (_currentCanvas == null)
        {
            Debug.LogError("현재 캔버스가 설정되지 않았습니다.");
            return null;
        }

        T ui = ResourceManager.Instance.LoadUI<T>();
        if (ui != null)
        {
            // UI의 부모를 현재 캔버스로 설정
            ui.transform.SetParent(_currentCanvas.transform, false);
            ui.gameObject.SetActive(true);
            ui.Initialize();
        }
        return ui;
    }

    public void Hide<T>() where T : UIBase
    {
        T ui = ResourceManager.Instance.GetCachedUI<T>();
        if (ui != null)
        {
            ui.gameObject.SetActive(false);
        }
    }
}

 

다음으로 ResourceManager를 만들어주자

 

using System.Collections.Generic;
using UnityEngine;

public class ResourceManager : Singleton<ResourceManager>
{
    // UI 캐시 딕셔너리
    private Dictionary<string, UIBase> _uiCache = new Dictionary<string, UIBase>();

    // UI 로드 함수
    public T LoadUI<T>() where T : UIBase
    {
        string uiName = typeof(T).Name;

        if (_uiCache.ContainsKey(uiName))
        {
            return _uiCache[uiName] as T;
        }

        T uiPrefab = Resources.Load<T>($"UI/{uiName}");
        if (uiPrefab != null)
        {
            T uiInstance = Instantiate(uiPrefab);
            _uiCache.Add(uiName, uiInstance);
            return uiInstance;
        }

        Debug.LogError($"UI 프리팹을 찾을 수 없음 : {uiName}");
        return null;
    }

    // 캐싱된 UI 가져오기
    public T GetCachedUI<T>() where T : UIBase
    {
        string uiName = typeof(T).Name;
        if (_uiCache.ContainsKey(uiName))
        {
            return _uiCache[uiName] as T;
        }
        return null;
    }

    // 캐시 비우기 (씬 변경 시 호출 가능)
    public void ClearCache()
    {
        foreach (var ui in _uiCache.Values)
        {
            Destroy(ui.gameObject);
        }
        _uiCache.Clear();
    }
}

 

using UnityEngine;

public abstract class UIBase : MonoBehaviour
{
    // UI에서 공통적으로 필요한 기능들을 여기에 정의할 수 있음
    public virtual void Initialize() { }

    public virtual void Close()
    {
        // 기본적으로 UI를 비활성화하고, 필요하면 추가적인 정리 작업을 추가
        gameObject.SetActive(false);
    }
}

 

여기까지가 거의 동일한 틀이다.

 

이제부터 사용하기만 하면 된다.

 

게임을 일시정지 하는 기능을 구현한다고 생각하고 만들어보자.

 

using UnityEngine;

// ExampleUsage: UIManager를 사용하여 UI를 불러오고 사용하는 예시 클래스
public class ExampleUsage : MonoBehaviour
{
    private void Start()
    {
        // 게임 중 일시정지 메뉴를 동적으로 불러오기
        PauseMenuUI pauseMenu = UIManager.Instance.Show<PauseMenuUI>();
        
        // 일시정지 메뉴 초기화
        pauseMenu.Initialize();

        // 게임을 일시정지 상태로 전환
        Time.timeScale = 0f; // 게임 시간 정지
    }
}

 

대충 start에 구현했지만 버튼을 누른거라고 생각하자 

하지만 이 코드에서 볼것은 있다.

PauseMenuUI pauseMenu = UIManager.Instance.Show<PauseMenuUI>();

이부분을 호출하게 되면 Show를 호출하는데 이것은 리소스를 불러오지 않았다면 불러오고

만약 불러왔다면 캐싱된것을 사용하고 있다.

그러니 리소스를 불러오는걸 로딩에서 처리한다면 항상 캐싱된 UI를 사용하니 오브젝트 풀링과 비슷한 개념이라고 생각하면 된다.

그 아래의 Initialize부분도 보면 초기화가 필요한 부분을 초기화해주고 있다. 이부분도 로딩때 미리 해준다면 아주 좋다.

 

using UnityEngine;
using UnityEngine.UI;

public class PauseMenuUI : UIBase
{
    public Button ResumeButton;
    public Button SettingsButton;
    public Button MainMenuButton;

    public override void Initialize()
    {
        // 버튼 이벤트 초기화
        ResumeButton.onClick.RemoveAllListeners();
        ResumeButton.onClick.AddListener(OnResumeClicked);

        SettingsButton.onClick.RemoveAllListeners();
        SettingsButton.onClick.AddListener(OnSettingsClicked);

        MainMenuButton.onClick.RemoveAllListeners();
        MainMenuButton.onClick.AddListener(OnMainMenuClicked);
    }

    private void OnResumeClicked()
    {
        // UI를 닫는 로직 처리
        Close(); // Close() 메서드 호출
    }

    private void OnSettingsClicked()
    {
        // 설정 UI를 여는 로직을 여기에 추가할 수 있음
    }

    private void OnMainMenuClicked()
    {
        // 메인 메뉴로 이동하는 로직을 여기에 추가할 수 있음
    }

    public override void Close()
    {
        base.Close(); // 부모 클래스의 Close() 호출로 UI 비활성화

        // 게임 시간을 재개
        Time.timeScale = 1f;
    }
}

 

이제 사용에 문제가 없을것이다.

 

Close를 분리해놓은건 혹시 모를 확장을 고려하여 분리를 해두었다.

 

물론 문제가 없는 곳에는 그냥 BaseUI집어넣어서 오버라이드도 안하고 꺼버려도 된다.ㅋ

 

프리팹은 "Resources/UI/{name}" 방식으로 넣어주자.

 

캔버스는 "Canvas_{SceneName}"