+-------------------+ +----------------------+
| Core Layer | | Data Management |
| - GameManager |<----->| - AddressablesManager|
+-------------------+ | - AddressablePather |
+----------------------+
+-------------------+ +----------------------+
| Service Layer | | Implementations |
| (Interfaces) | | (Classes) |
| - IAudioManager | | - AudioManager |
| - ISceneManager | | - SceneManager |
| - IInputManager |<------| - InputManager |
| - IUIManager | | - UIManager |
| - IPoolManager | | - PoolManager |
+-------------------+ +----------------------+
^
| (Uses Services)
+-------------------+
| Scene Logic | ------> (Uses) AddressablePather
| - BaseScene | ------> (Uses) AddressablesManager (runtime)
| - ChannelScene |
| - GameScene |
| - ... |
+-------------------+
1. 코어 레이어 (싱글톤 패턴 기반)
앱 전반에 걸쳐 유일하게 존재하며 핵심 기능을 관리하는 레이어입니다. 싱글톤 패턴을 사용하여 전역적인 접근점을 제공하고, 게임의 생명주기와 필수 리소스 관리를 담당합니다.
1.1. 게임 매니저 (GameManager)
- 특징: 싱글톤 패턴으로 구현된 게임의 중앙 제어 허브입니다.
- 책임:
- 게임 애플리케이션의 전체 생명주기 관리 (시작, 종료 등).
- 필수적인 서비스(매니저)들의 생성 및 ServiceLocator를 통한 등록/초기화 수행.
- 구현:
- DontDestroyOnLoad를 사용하여 씬 전환 시에도 인스턴스가 파괴되지 않고 유지됩니다.
- 게임 시작 시 필요한 서비스들을 ServiceLocator에 등록하여 의존성 주입의 기반을 마련합니다.
- 정적 Instance 프로퍼티를 통해 어디서든 GameManager의 유일한 인스턴스에 접근할 수 있도록 합니다.
더보기
using System;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
private const string GAME_MANAGER_NAME = "@GameManager";
private static GameManager _instance;
public static GameManager Instance
{
get
{
if (_instance == null)
{
_instance = FindAnyObjectByType<GameManager>();
if (_instance == null)
{
GameObject gameManagerObject = new GameObject(GAME_MANAGER_NAME);
_instance = gameManagerObject.AddComponent<GameManager>();
DontDestroyOnLoad(gameManagerObject);
}
}
return _instance;
}
}
private readonly List<IUpdatableService> _updatableServices = new List<IUpdatableService>();
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
RegisterServices();
}
private void Update()
{
float deltaTime = Time.deltaTime;
foreach (var service in _updatableServices)
{
service.OnUpdate(deltaTime);
}
}
private void OnDestroy()
{
if (_instance == this)
{
ServiceLocator.ClearAllServices();
}
}
private void RegisterServices()
{
if (!ServiceLocator.HasService<IPoolManager>())
{
ServiceLocator.RegisterService<IPoolManager>(new PoolManager());
}
if (!ServiceLocator.HasService<ISceneManager>())
{
ServiceLocator.RegisterService<ISceneManager>(new SceneManager());
}
if (!ServiceLocator.HasService<IInputManager>())
{
ServiceLocator.RegisterService<IInputManager>(new InputManager());
}
if (!ServiceLocator.HasService<IUIManager>())
{
ServiceLocator.RegisterService<IUIManager>(new UIManager());
}
if (!ServiceLocator.HasService<IAudioManager>())
{
ServiceLocator.RegisterService<IAudioManager>(new AudioManager());
}
}
}
1.2. 어드레서블 매니저 (AddressableManager)
- 특징: 싱글톤 패턴으로 구현된 Addressable Assets 시스템 기반의 리소스 로딩 및 관리자입니다.
- 책임:
- Addressable 에셋의 비동기적 로딩 (LoadAssetAsync) 및 해제 (ReleaseAsset).
- Addressable 프리팹의 비동기적 인스턴스화 (InstantiateAsync) 및 해당 인스턴스 해제 (ReleaseInstance).
- 로드된 에셋 및 인스턴스화된 게임 오브젝트의 참조 관리 및 추적.
- Addressables 시스템을 활용한 메모리 관리 최적화 지원.
- 구현:
- 로드된 에셋 핸들(AsyncOperationHandle)과 인스턴스화된 게임 오브젝트 핸들을 내부 Dictionary를 통해 추적합니다.
- 비동기 로드/인스턴스화 작업 시 CancellationToken을 지원하여 작업 취소 기능을 제공합니다.
- Addressables 시스템의 참조 카운팅 메커니즘을 활용하여 에셋 캐싱 및 재사용을 효율적으로 관리합니다.
더보기
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using Object = UnityEngine.Object;
public class AddressablesManager : MonoBehaviour
{
// 싱글톤 패턴
public static AddressablesManager Instance { get; private set; }
// 로드된 인스턴스와 핸들 추적
private readonly Dictionary<GameObject, AsyncOperationHandle<GameObject>> _instantiatedObjects = new();
// 로드된 에셋 핸들 추적
private readonly Dictionary<string, AsyncOperationHandle> _loadedAssets = new();
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
/// <summary>
/// 주소(Address)를 사용하여 에셋을 비동기적으로 로드하고 결과를 반환합니다.
/// 실패 시 null을 반환합니다.
/// </summary>
/// <typeparam name="T">로드할 에셋 타입</typeparam>
/// <param name="address">에셋 주소 또는 레이블</param>
/// <param name="cancellationToken">작업 취소를 위한 토큰 (선택 사항)</param>
/// <returns>로드된 에셋 또는 실패 시 null</returns>
public async Task<T> LoadAssetAsync<T>(string address, CancellationToken cancellationToken = default) where T : Object
{
try
{
if (_loadedAssets.TryGetValue(address, out var existingHandle))
{
return await HandleExistingAssetAsync<T>(address, existingHandle, cancellationToken);
}
return await LoadNewAssetAsync<T>(address, cancellationToken);
}
catch (OperationCanceledException)
{
Debug.Log($"[AddressablesManager] Loading of asset '{address}' was canceled.");
CleanupAssetHandle(address);
return null;
}
catch (Exception ex)
{
Debug.LogError($"[AddressablesManager] Failed to load asset at address '{address}'. Error: {ex}");
CleanupAssetHandle(address);
return null;
}
}
private async Task<T> HandleExistingAssetAsync<T>(string address, AsyncOperationHandle existingHandle, CancellationToken cancellationToken) where T : Object
{
try
{
cancellationToken.ThrowIfCancellationRequested();
if (!existingHandle.IsDone)
{
while (!existingHandle.IsDone)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
}
}
if (existingHandle.Status == AsyncOperationStatus.Succeeded)
{
return existingHandle.Result as T;
}
else
{
Debug.LogError($"[AddressablesManager] Existing handle for asset '{address}' failed. Error: {existingHandle.OperationException}");
return null;
}
}
catch (OperationCanceledException)
{
// 작업 취소 시 재던지기 (외부 catch 블록에서 처리)
throw;
}
catch (Exception ex)
{
Debug.LogError($"[AddressablesManager] Error waiting for existing handle for '{address}'. Error: {ex}");
return null;
}
}
private async Task<T> LoadNewAssetAsync<T>(string address, CancellationToken cancellationToken) where T : Object
{
cancellationToken.ThrowIfCancellationRequested();
AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(address);
_loadedAssets.Add(address, handle);
try
{
// WaitAsync 대신 주기적으로 취소 토큰을 확인하는 방식으로 구현
while (!handle.IsDone)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
}
if (handle.Status == AsyncOperationStatus.Succeeded)
{
return handle.Result;
}
else
{
throw new Exception($"Asset load failed with status: {handle.Status}");
}
}
catch
{
// 예외 발생 시 재던지기 (외부 catch 블록에서 처리)
throw;
}
}
private void CleanupAssetHandle(string address)
{
if (_loadedAssets.TryGetValue(address, out var handle) && handle.IsValid())
{
Addressables.Release(handle);
}
_loadedAssets.Remove(address);
}
/// <summary>
/// 주소(Address)를 사용하여 프리팹을 비동기적으로 인스턴스화하고 결과를 반환합니다.
/// 실패 시 null을 반환합니다.
/// </summary>
/// <param name="address">프리팹 주소 또는 레이블</param>
/// <param name="parent">부모 Transform (선택 사항)</param>
/// <param name="cancellationToken">작업 취소를 위한 토큰 (선택 사항)</param>
/// <returns>생성된 GameObject 또는 실패 시 null</returns>
public async Task<GameObject> InstantiateAsync(string address, Transform parent = null, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
AsyncOperationHandle<GameObject> handle = Addressables.InstantiateAsync(address, parent);
while (!handle.IsDone)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
}
if (handle.Status == AsyncOperationStatus.Succeeded)
{
GameObject instance = handle.Result;
_instantiatedObjects.Add(instance, handle);
return instance;
}
else
{
throw new Exception($"Instantiation failed with status: {handle.Status}");
}
}
catch (OperationCanceledException)
{
Debug.Log($"[AddressablesManager] Instantiation of '{address}' was canceled.");
return null;
}
catch (Exception ex)
{
Debug.LogError($"[AddressablesManager] Failed to instantiate GameObject from address '{address}'. Error: {ex}");
return null;
}
}
/// <summary>
/// 로드된 모든 에셋과 인스턴스를 비동기적으로 해제합니다.
/// </summary>
public async Task ReleaseAllAsync()
{
await ReleaseAllAssetsAsync();
ReleaseAllInstances();
}
/// <summary>
/// 로드된 모든 에셋을 비동기적으로 해제합니다.
/// </summary>
public async Task ReleaseAllAssetsAsync()
{
List<string> assetKeys = new(_loadedAssets.Keys);
foreach (var key in assetKeys)
{
ReleaseAsset(key);
// 다른 작업이 처리될 수 있도록 프레임 간 양보
await Task.Yield();
}
}
/// <summary>
/// 생성된 모든 인스턴스를 해제합니다.
/// </summary>
public void ReleaseAllInstances()
{
List<GameObject> instanceKeys = new(_instantiatedObjects.Keys);
foreach (var instance in instanceKeys)
{
ReleaseInstance(instance);
}
}
/// <summary>
/// 로드된 일반 에셋을 해제합니다. (InstantiateAsync로 생성된 것은 ReleaseInstance 사용)
/// 이 작업은 동기적으로 처리됩니다.
/// </summary>
/// <param name="address">해제할 에셋의 주소</param>
/// <returns>해제 성공 여부</returns>
public bool ReleaseAsset(string address)
{
if (string.IsNullOrEmpty(address))
{
Debug.LogWarning("[AddressablesManager] Attempted to release asset with null or empty address.");
return false;
}
if (!_loadedAssets.TryGetValue(address, out AsyncOperationHandle handle))
{
Debug.LogWarning($"[AddressablesManager] No loaded asset found with address: {address} to release.");
return false;
}
if (!handle.IsValid())
{
Debug.LogWarning($"[AddressablesManager] Handle for address '{address}' is invalid. Removing from tracking.");
_loadedAssets.Remove(address);
return false;
}
Addressables.Release(handle);
_loadedAssets.Remove(address);
// Debug.Log($"[AddressablesManager] Released asset with address: {address}");
return true;
}
/// <summary>
/// InstantiateAsync로 생성된 GameObject 인스턴스를 해제합니다.
/// 이 작업은 동기적으로 처리됩니다.
/// </summary>
/// <param name="instanceToRelease">해제할 GameObject 인스턴스</param>
/// <returns>해제 성공 여부</returns>
public bool ReleaseInstance(GameObject instanceToRelease)
{
if (instanceToRelease == null)
{
Debug.LogWarning("[AddressablesManager] Attempted to release a null instance.");
return false;
}
if (!_instantiatedObjects.TryGetValue(instanceToRelease, out AsyncOperationHandle<GameObject> handle))
{
Debug.LogWarning($"[AddressablesManager] Instance '{instanceToRelease.name}' not found in instantiated list. Could not release via Addressables.");
return false;
}
if (!handle.IsValid())
{
Debug.LogWarning($"[AddressablesManager] Handle for instance '{instanceToRelease.name}' is invalid. Removing from tracking.");
_instantiatedObjects.Remove(instanceToRelease);
return false;
}
Addressables.ReleaseInstance(handle);
_instantiatedObjects.Remove(instanceToRelease);
// Debug.Log($"[AddressablesManager] Released instance: {instanceToRelease.name}");
return true;
}
/// <summary>
/// 해당 주소의 에셋이 이미 로드되었는지 확인합니다.
/// </summary>
/// <param name="address">확인할 에셋 주소</param>
/// <returns>로드 여부와 로드 상태</returns>
public (bool isLoaded, AsyncOperationStatus? status) IsAssetLoaded(string address)
{
if (_loadedAssets.TryGetValue(address, out var handle))
{
return (true, handle.Status);
}
return (false, null);
}
/// <summary>
/// 해당 게임오브젝트가 이 매니저를 통해 인스턴스화되었는지 확인합니다.
/// </summary>
/// <param name="instance">확인할 게임오브젝트</param>
/// <returns>관리 여부</returns>
public bool IsInstanceManaged(GameObject instance)
{
return instance != null && _instantiatedObjects.ContainsKey(instance);
}
private void OnDestroy()
{
// Debug.Log("[AddressablesManager] Releasing all tracked assets and instances on destroy.");
// 추적된 일반 에셋 해제
List<string> assetKeys = new(_loadedAssets.Keys);
foreach (var key in assetKeys)
{
if (_loadedAssets.TryGetValue(key, out var handle) && handle.IsValid())
{
Addressables.Release(handle);
}
}
_loadedAssets.Clear();
// 추적된 인스턴스 해제
List<GameObject> instanceKeys = new(_instantiatedObjects.Keys);
foreach (var instance in instanceKeys)
{
if (instance != null && _instantiatedObjects.TryGetValue(instance, out var handle) && handle.IsValid())
{
Addressables.ReleaseInstance(handle);
}
}
_instantiatedObjects.Clear();
if (Instance == this)
{
Instance = null;
}
}
}
2. 게임 데이터 관리
게임 실행에 필요한 에셋 데이터, 특히 Addressable 에셋의 업데이트 및 런타임 로딩을 관리하는 부분입니다.
2.1. 어드레서블 패쳐 (AddressablePather)
- 특징: Addressable 콘텐츠의 업데이트 및 패치 프로세스를 담당하는 컴포넌트입니다.
- 책임:
- 업데이트 필요한 Addressable 에셋 그룹(레이블 기반)의 총 다운로드 크기 확인.
- 사용자 확인 후 필요한 콘텐츠 카탈로그 및 에셋 번들 다운로드 처리.
- 다운로드 진행 상황을 UI(Slider, Text 등)에 표시.
- 구현:
- Addressables.GetDownloadSizeAsync, Addressables.DownloadDependenciesAsync 등 Addressables API를 활용한 비동기 다운로드 프로세스 관리.
- CancellationTokenSource를 이용한 취소 가능한 다운로드 작업 구현.
- BGM, SFX, Scene, ScreenUI 등 정의된 레이블 별로 업데이트 확인 및 다운로드 수행.
- 업데이트 성공/실패 시 Action 이벤트를 발생시켜 후속 처리(예: 씬 전환)를 트리거합니다.
더보기

어드레서블 프로파일

어드레서블 에셋 세팅

어드레서블 에셋 그룹

Remote.LoadPath: 서버주소/[BuildTarget]


using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UI;
public class AddressablePather : MonoBehaviour
{
[Header("UI Elements")]
[SerializeField] private Text statusText;
[SerializeField] private Text sizeText;
[SerializeField] private Text progressText;
[SerializeField] private Slider progressBar;
[SerializeField] private Button confirmButton;
[SerializeField] private Button cancelButton;
[SerializeField] private Button nextSceneButton;
public Action OnUpdateSuccess;
public Action<string> OnUpdateFailed;
private long _totalDownloadSize;
private readonly List<string> _assetLabels = new() { "BGM", "SFX", "Scene", "ScreenUI" };
private const float BytesToMB = 1f / (1024f * 1024f);
private CancellationTokenSource _cancellationTokenSource;
private void Awake()
{
InitUI();
}
private async void Start()
{
BindUIEvents();
await CheckDownloadSizeAsync();
}
private void InitUI()
{
statusText.text = "업데이트 확인 중...";
sizeText.text = string.Empty;
progressText.text = string.Empty;
progressBar.value = 0f;
SetButtonsVisibility(false, false, false);
}
private void BindUIEvents()
{
confirmButton.onClick.AddListener(HandleConfirmClick);
cancelButton.onClick.AddListener(HandleCancelClick);
nextSceneButton.onClick.AddListener(HandleNextSceneClick);
}
private async void HandleConfirmClick()
{
_cancellationTokenSource = new CancellationTokenSource();
await PatchUpdateFilesAsync(_cancellationTokenSource.Token);
}
private void HandleCancelClick() => OnCancelDownload();
private void HandleNextSceneClick() => OnUpdateSuccess?.Invoke();
private void OnCancelDownload()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
statusText.text = "다운로드가 취소되었습니다.";
SetButtonsVisibility(false, false, false);
}
private async Task CheckDownloadSizeAsync()
{
_totalDownloadSize = 0;
if (!await TryCalculateTotalDownloadSizeAsync())
return;
if (_totalDownloadSize > 0)
{
DisplayUpdateRequired();
}
else
{
DisplayUpToDate();
}
}
private async Task<bool> TryCalculateTotalDownloadSizeAsync()
{
foreach (var label in _assetLabels)
{
var handle = Addressables.GetDownloadSizeAsync(label);
await handle.Task;
if (handle.Status != AsyncOperationStatus.Succeeded)
{
statusText.text = $"다운로드 크기 확인 중 오류 발생: {label}";
return false;
}
_totalDownloadSize += handle.Result;
Addressables.Release(handle);
}
return true;
}
private void DisplayUpdateRequired()
{
float sizeInMB = _totalDownloadSize * BytesToMB;
sizeText.text = $"예상 다운로드 크기: {sizeInMB:F2} MB";
statusText.text = "업데이트가 필요합니다. 다운로드하시겠습니까?";
SetButtonsVisibility(true, true, false);
}
private void DisplayUpToDate()
{
statusText.text = "모든 콘텐츠가 최신 상태입니다.";
progressBar.value = 1f;
progressText.text = "100%";
sizeText.text = string.Empty;
SetButtonsVisibility(false, false, true);
}
private async Task PatchUpdateFilesAsync(CancellationToken cancellationToken = default)
{
statusText.text = "다운로드를 시작합니다...";
UpdateProgressUI(0f);
List<object> keysToDownload = new();
await CollectLabelsToDownloadAsync(keysToDownload);
if (keysToDownload.Count == 0 || cancellationToken.IsCancellationRequested)
{
CompleteDownloadWithNoItems();
return;
}
try
{
await DownloadAssetsAsync(keysToDownload, cancellationToken);
HandleDownloadSuccess();
}
catch (OperationCanceledException)
{
// 취소 처리는 이미 OnCancelDownload에서 처리되므로 무시
}
catch (Exception ex)
{
HandleDownloadFailure(ex.Message);
}
}
private async Task CollectLabelsToDownloadAsync(List<object> keysToDownload)
{
foreach (var label in _assetLabels)
{
var checkHandle = Addressables.GetDownloadSizeAsync(label);
await checkHandle.Task;
if (checkHandle.Status == AsyncOperationStatus.Succeeded && checkHandle.Result > 0)
{
keysToDownload.Add(label);
}
Addressables.Release(checkHandle);
}
}
private void CompleteDownloadWithNoItems()
{
statusText.text = "다운로드할 항목이 없습니다.";
SetButtonsVisibility(false, false, true);
}
private void HandleDownloadSuccess()
{
statusText.text = "다운로드가 완료되었습니다.";
SetButtonsVisibility(false, false, true);
}
private void HandleDownloadFailure(string errorMessage)
{
statusText.text = "다운로드 중 오류가 발생했습니다.";
OnUpdateFailed?.Invoke(errorMessage);
}
private async Task DownloadAssetsAsync(List<object> keys, CancellationToken cancellationToken)
{
var handle = Addressables.DownloadDependenciesAsync(keys, Addressables.MergeMode.Union, false);
try
{
while (!handle.IsDone)
{
cancellationToken.ThrowIfCancellationRequested();
UpdateProgressUI(handle.PercentComplete);
await Task.Yield();
}
if (handle.Status == AsyncOperationStatus.Succeeded)
{
CompleteProgressUI();
}
else
{
throw new Exception("Addressable 다운로드 실패");
}
}
finally
{
if (handle.IsValid())
Addressables.Release(handle);
}
}
private void UpdateProgressUI(float progress)
{
progressBar.value = progress;
progressText.text = $"{Mathf.RoundToInt(progress * 100f)}%";
}
private void CompleteProgressUI()
{
progressBar.value = 1f;
progressText.text = "100%";
sizeText.text = string.Empty;
}
private void SetButtonsVisibility(bool showConfirm, bool showCancel, bool showNextScene)
{
confirmButton.gameObject.SetActive(showConfirm);
cancelButton.gameObject.SetActive(showCancel);
nextSceneButton.gameObject.SetActive(showNextScene);
}
private void OnDestroy()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
}
}
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
/// <summary>
/// Unity 에디터 메뉴를 통해 Addressable Asset의 주소를 기반으로
/// 상수 C# 스크립트 파일을 자동으로 생성하는 클래스입니다.
/// Addressable 키 관리를 용이하게 합니다.
/// </summary>
public class AddressableKeyGenerator
{
// --- 설정 ---
private const string OUTPUT_DIRECTORY = "Assets/Scripts/Generated";
private const string OUTPUT_FILENAME = "AddressableKeys.cs";
private const string CLASS_NAME = "AddressableKeys";
// !!!!! 중요 !!!!!
// 제거할 경로 접두사 목록을 설정합니다.
// 키는 주소 시작 부분을 나타내고, 값은 해당 주소에서 제거할 접두사입니다.
// 더 구체적인 경로(긴 경로)를 먼저 정의해야 올바르게 처리됩니다.
// 타입을 List<string, string>에서 Dictionary<string, string>으로 수정했습니다.
private static readonly Dictionary<string, string> PathPrefixesToRemove = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase)
{
{ "Assets/GameAssets/UI/", "Assets/GameAssets/UI/" },
{ "Assets/GameAssets/Audio/", "Assets/GameAssets/Audio/" },
{ "Assets/GameAssets/Scene/", "Assets/GameAssets/Scene/" },
// 필요에 따라 여기에 더 많은 규칙을 추가하세요.
};
[MenuItem("Tools/Addressables/Generate Addressable Constants")]
public static void GenerateKeys()
{
AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
Debug.LogError("[AddressableKeyGenerator] Addressable Asset Settings를 찾을 수 없습니다. Window > Asset Management > Addressables > Groups 에서 설정을 확인하세요.");
return;
}
StringBuilder sb = new StringBuilder();
HashSet<string> generatedConstantNames = new HashSet<string>();
// 파일 헤더 작성
sb.AppendLine("//------------------------------------------------------------------------------");
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// 이 코드는 도구를 사용하여 생성되었습니다.");
sb.AppendLine("// AddressableKeyGenerator에 의해 생성됨");
sb.AppendLine("//");
sb.AppendLine($"// 파일 생성 시각: {System.DateTime.Now}");
sb.AppendLine("//");
sb.AppendLine("// 파일 내용을 수동으로 변경하면 예기치 않은 동작이 발생할 수 있으며,");
sb.AppendLine("// 코드를 다시 생성하면 변경 내용이 손실됩니다.");
sb.AppendLine("// </auto-generated>");
sb.AppendLine("//------------------------------------------------------------------------------");
sb.AppendLine();
sb.AppendLine($"public static class {CLASS_NAME}");
sb.AppendLine("{");
// 모든 Addressable 그룹 및 항목 순회
foreach (AddressableAssetGroup group in settings.groups)
{
if (group == null) continue;
sb.AppendLine($" // --- Group: {group.Name} ---");
foreach (AddressableAssetEntry entry in group.entries)
{
if (string.IsNullOrEmpty(entry.address) || entry.address.Contains(" "))
{
continue;
}
string address = entry.address;
string prefixToRemove = null;
// 설정된 접두사 목록을 기반으로 현재 주소에 맞는 접두사를 찾습니다.
// 가장 긴 경로 (가장 구체적인 경로)부터 검사합니다.
foreach (var kvp in PathPrefixesToRemove.OrderByDescending(x => x.Key.Length))
{
// 대소문자 무시하고 시작 문자열 비교
if (address.StartsWith(kvp.Key, System.StringComparison.OrdinalIgnoreCase))
{
prefixToRemove = kvp.Value;
break; // 가장 먼저 일치하는 (가장 긴) 접두사를 사용
}
}
// GenerateConstantName 호출 시 찾은 접두사(또는 null)를 전달
string constantName = GenerateConstantName(address, prefixToRemove);
if (string.IsNullOrEmpty(constantName))
{
Debug.LogWarning($"[AddressableKeyGenerator] 주소 '{address}'에 대한 유효한 상수 이름을 생성할 수 없습니다. 건너<0xEB><0x84><0x89>니다.");
continue;
}
// 중복 상수 이름 체크
if (generatedConstantNames.Contains(constantName))
{
Debug.Log($"[AddressableKeyGenerator] 중복된 상수 이름이 생성되었습니다: {constantName} (주소: {address}). 건너<0xEB><0x84><0x89>니다. 다른 주소에서 이미 사용 중입니다.");
continue;
}
generatedConstantNames.Add(constantName);
// 상수 정의 코드 추가
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Address: \"{address}\"");
sb.AppendLine($" /// </summary>");
sb.AppendLine($" public static readonly string {constantName} = @\"{address}\";"); // 원본 주소 유지
sb.AppendLine();
}
sb.AppendLine(); // 그룹 간 간격
}
sb.AppendLine("}"); // 클래스 닫기
// 파일 쓰기
try
{
Directory.CreateDirectory(OUTPUT_DIRECTORY);
string outputPath = Path.Combine(OUTPUT_DIRECTORY, OUTPUT_FILENAME);
File.WriteAllText(outputPath, sb.ToString(), System.Text.Encoding.UTF8);
Debug.Log($"[AddressableKeyGenerator] Addressable 상수 파일 생성 완료: {outputPath}");
AssetDatabase.Refresh(); // 에디터에 변경 사항 반영
}
catch (System.Exception ex)
{
Debug.LogError($"[AddressableKeyGenerator] Addressable 상수 파일 쓰기 실패: {ex.Message}\n{ex.StackTrace}");
}
}
/// <summary>
/// Addressable 주소 문자열을 C# 상수 이름 규칙에 맞게 변환합니다.
/// 지정된 경로 접두사를 이름 생성에서 제외합니다.
/// </summary>
/// <param name="address">변환할 Addressable 주소</param>
/// <param name="prefixToRemove">상수 이름 생성 시 제외할 경로 접두사 (null일 수 있음)</param>
/// <returns>생성된 상수 이름</returns>
private static string GenerateConstantName(string address, string prefixToRemove)
{
if (string.IsNullOrEmpty(address)) return null;
string nameSource = address; // 상수 이름 생성의 기반이 될 문자열
// 1. 지정된 경로 접두사 제거 (prefixToRemove가 null이 아니고, 주소가 해당 접두사로 시작하는 경우)
if (!string.IsNullOrEmpty(prefixToRemove) && nameSource.StartsWith(prefixToRemove, System.StringComparison.OrdinalIgnoreCase))
{
// 대소문자 무시하고 접두사 일치 시 제거
nameSource = nameSource.Substring(prefixToRemove.Length);
}
// 2. 파일 확장자 제거 (마지막 '.' 이후 부분)
int lastDotIndex = nameSource.LastIndexOf('.');
if (lastDotIndex > 0 && nameSource.LastIndexOf('/') < lastDotIndex)
{
nameSource = nameSource.Substring(0, lastDotIndex);
}
// 3. '/' 경로 구분자를 '_'로 변경
nameSource = nameSource.Replace('/', '_');
// 4. 전체 대문자화 (상수 이름 규칙)
nameSource = nameSource.ToUpperInvariant(); // 문화권 독립적인 대문자화
// 5. 유효하지 않은 문자(알파벳 대문자, 숫자, 밑줄 제외)를 '_'로 변경
nameSource = Regex.Replace(nameSource, @"[^A-Z0-9_]+", "_");
// 6. 연속된 '_'를 단일 '_'로 정리
nameSource = Regex.Replace(nameSource, "_+", "_");
// 7. 시작 또는 끝에 있는 '_' 제거
nameSource = nameSource.Trim('_');
// 8. 이름이 비어있으면 처리 불가
if (string.IsNullOrEmpty(nameSource)) return null;
// 9. 이름이 숫자로 시작하는 경우 앞에 '_' 추가 (C# 변수명 규칙)
if (char.IsDigit(nameSource[0]))
{
nameSource = "_" + nameSource;
}
// 10. C# 예약어와 충돌하는 경우 앞에 '@' 추가 ( verbatim 식별자 )
if (IsCSharpKeyword(nameSource)) // 이미 대문자화 되었으므로 ToUpperInvariant() 불필요
{
nameSource = "@" + nameSource;
}
// 11. 최종 이름이 여전히 비어있거나 유효하지 않으면 null 반환
if (string.IsNullOrEmpty(nameSource) || nameSource == "_") // 추가 검사
{
return null;
}
return nameSource;
}
// C# 예약어 목록 (대문자)
private static readonly HashSet<string> CSharpKeywords = new HashSet<string>
{
"ABSTRACT", "AS", "BASE", "BOOL", "BREAK", "BYTE", "CASE", "CATCH", "CHAR", "CHECKED",
"CLASS", "CONST", "CONTINUE", "DECIMAL", "DEFAULT", "DELEGATE", "DO", "DOUBLE", "ELSE", "ENUM",
"EVENT", "EXPLICIT", "EXTERN", "FALSE", "FINALLY", "FIXED", "FLOAT", "FOR", "FOREACH", "GOTO",
"IF", "IMPLICIT", "IN", "INT", "INTERFACE", "INTERNAL", "IS", "LOCK", "LONG", "NAMESPACE",
"NEW", "NULL", "OBJECT", "OPERATOR", "OUT", "OVERRIDE", "PARAMS", "PRIVATE", "PROTECTED",
"PUBLIC", "READONLY", "REF", "RETURN", "SBYTE", "SEALED", "SHORT", "SIZEOF", "STACKALLOC",
"STATIC", "STRING", "STRUCT", "SWITCH", "THIS", "THROW", "TRUE", "TRY", "TYPEOF", "UINT",
"ULONG", "UNCHECKED", "UNSAFE", "USHORT", "USING", "VIRTUAL", "VOID", "VOLATILE", "WHILE",
"ADD", "ALIAS", "ASCENDING", "ASYNC", "AWAIT", "BY", "DESCENDING", "DYNAMIC", "EQUALS", "FROM",
"GET", "GLOBAL", "GROUP", "INIT", "INTO", "JOIN", "LET", "NAMEOF", "ON", "ORDERBY", "PARTIAL",
"REMOVE", "SELECT", "SET", "UNMANAGED", "VALUE", "VAR", "WHEN", "WHERE", "YIELD"
};
/// <summary>
/// 주어진 문자열(대문자)이 C# 예약어(대문자)인지 확인합니다.
/// </summary>
private static bool IsCSharpKeyword(string upperCaseName)
{
return !string.IsNullOrEmpty(upperCaseName) && CSharpKeywords.Contains(upperCaseName);
}
}
에디터 코드를 활용하여 레이블별 자동 키 생성
2.2. 게임 데이터 관리 흐름
- AddressablePather (콘텐츠 준비 단계):
- 게임 시작 초기 단계(예: 로딩 씬)에서 실행됩니다.
- 지정된 레이블들의 업데이트를 확인하고 필요한 다운로드 크기를 계산합니다.
- 사용자에게 다운로드 여부를 확인받고, 승인 시 비동기 다운로드를 시작합니다.
- 다운로드 진행률을 실시간으로 모니터링하여 UI에 반영합니다.
- 다운로드가 성공적으로 완료되면 OnUpdateSuccess 이벤트를 호출합니다.
- AddressableManager (런타임 에셋 사용 단계):
- AddressablePather를 통해 최신 상태로 준비된 에셋들을 게임 내에서 실제로 사용할 때 관여합니다.
- 요청된 에셋(오디오 클립, 프리팹 등)을 비동기적으로 로드하거나 인스턴스화합니다.
- 로드된 에셋과 생성된 인스턴스의 참조를 추적하여, 더 이상 필요 없을 때 적절히 해제(ReleaseAsset, ReleaseInstance)하여 메모리를 관리합니다.
3. 서비스 레이어 (서비스 로케이터 패턴)
게임의 다양한 기능(UI, 오디오, 입력 등)을 담당하는 모듈화된 서비스(매니저)들을 관리하는 레이어입니다. 서비스 로케이터 패턴을 사용하여 서비스 간의 직접적인 결합도를 낮추고 유연성을 높입니다.
3.1. 서비스 로케이터 (ServiceLocator)
- 특징: 정적 클래스로 구현된 서비스 컨테이너 및 접근 지점입니다.
- 책임:
- 게임 내 각종 서비스(매니저)의 등록 (RegisterService), 검색 (GetService), 제거 (RemoveService, ClearAllServices) 기능 제공.
- 서비스 등록/제거 시 IService 인터페이스를 통해 서비스의 생명주기(Initialize, Shutdown)를 관리.
- 구현:
- 제네릭 메서드 (RegisterService<T>, GetService<T>)를 사용하여 타입 안정성을 보장합니다.
- 내부적으로 Dictionary<Type, object>를 사용하여 서비스 타입과 인스턴스를 매핑하여 저장 및 관리합니다.
더보기
using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 서비스 로케이터 패턴을 구현한 정적 클래스입니다.
/// 모든 서비스 매니저를 등록하고 접근할 수 있는 중앙 저장소 역할을 합니다.
/// </summary>
public static class ServiceLocator
{
// 모든 서비스를 저장하는 딕셔너리
private static readonly Dictionary<Type, object> Services = new Dictionary<Type, object>();
/// <summary>
/// 서비스를 등록합니다.
/// </summary>
/// <typeparam name="T">등록할 서비스의 타입</typeparam>
/// <param name="service">서비스 인스턴스</param>
public static void RegisterService<T>(T service) where T : class
{
Type type = typeof(T);
if (!Services.TryAdd(type, service))
{
return;
}
// 서비스가 IService를 구현하고 있다면 초기화 메서드 호출
if (service is IService serviceInstance)
{
serviceInstance.Initialize();
}
}
/// <summary>
/// 등록된 서비스를 가져옵니다.
/// </summary>
/// <typeparam name="T">가져올 서비스의 타입</typeparam>
/// <returns>서비스 인스턴스</returns>
public static T GetService<T>() where T : class
{
Type type = typeof(T);
if (!Services.TryGetValue(type, out var service))
{
return null;
}
return (T)service;
}
/// <summary>
/// 특정 서비스가 등록되어 있는지 확인합니다.
/// </summary>
/// <typeparam name="T">확인할 서비스의 타입</typeparam>
/// <returns>서비스 등록 여부</returns>
public static bool HasService<T>() where T : class
{
return Services.ContainsKey(typeof(T));
}
/// <summary>
/// 등록된 서비스를 제거합니다.
/// </summary>
/// <typeparam name="T">제거할 서비스의 타입</typeparam>
public static void RemoveService<T>() where T : class
{
Type type = typeof(T);
if (!Services.TryGetValue(type, out var service))
{
return;
}
// 서비스가 IService를 구현하고 있다면 종료 메서드 호출
if (service is IService serviceInstance)
{
serviceInstance.Shutdown();
}
Services.Remove(type);
}
/// <summary>
/// 모든 서비스를 제거합니다.
/// </summary>
public static void ClearAllServices()
{
// 모든 서비스를 순회하며 종료 처리
foreach (var service in Services.Values)
{
if (service is IService serviceInstance)
{
serviceInstance.Shutdown();
}
}
Services.Clear();
}
}
3.2. 게임플레이 매니저 (각종 서비스)
각 매니저(서비스)는 특정 도메인의 책임을 가지며, GameManager에 의해 ServiceLocator에 등록됩니다.
- UIManager: UI 요소(팝업, 화면, 월드 UI)의 생성, 표시, 제거 및 관리 담당.
더보기
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using Object = UnityEngine.Object;
public class UIManager : IUIManager
{
private const string UI_ROOT_NAME = "@UI_Root";
private const string EVENT_SYSTEM_NAME = "@EventSystem";
private const int POPUP_BASE_SORTING_ORDER = 10;
// UI 타입별 추적 리스트
private readonly List<BaseUI> _popupList = new List<BaseUI>();
private readonly List<BaseUI> _screenList = new List<BaseUI>(); // <<< 스크린 UI 리스트 추가
private readonly List<BaseUI> _worldUIList = new List<BaseUI>(); // <<< 월드 UI 리스트 추가
private Transform _uiRoot;
public Transform UIRoot
{
get
{
// UI Root가 아직 없으면 생성
if (_uiRoot == null)
{
GameObject root = GameObject.Find(UI_ROOT_NAME);
if (root == null)
{
root = new GameObject(UI_ROOT_NAME);
Canvas canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
CanvasScaler scaler = root.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
root.AddComponent<GraphicRaycaster>();
_uiRoot = root.transform;
Object.DontDestroyOnLoad(root);
}
}
return _uiRoot;
}
}
public void Initialize()
{
EnsureEventSystem();
}
private void EnsureEventSystem()
{
if (Object.FindAnyObjectByType<EventSystem>() == null)
{
GameObject go = new GameObject(EVENT_SYSTEM_NAME);
go.AddComponent<EventSystem>();
go.AddComponent<StandaloneInputModule>(); // PC/Mac 입력 모듈
Object.DontDestroyOnLoad(go);
}
}
public void Shutdown()
{
CloseAllUI();
}
public async Task<T> ShowPopup<T>(string name) where T : BaseUI
{
if (string.IsNullOrEmpty(name)) return null;
GameObject instance = null;
try
{
instance = await AddressablesManager.Instance.InstantiateAsync(name, UIRoot);
if (instance == null) return null;
T component = instance.GetOrAddComponent<T>(); // 확장 메서드 사용 또는 표준 방식으로 대체
if (component == null)
{
Debug.LogError($"[UIManager] {typeof(T).Name} 컴포넌트를 {name}에서 찾거나 추가할 수 없습니다. 인스턴스를 해제합니다.");
AddressablesManager.Instance.ReleaseInstance(instance); // 생성된 인스턴스 정리
return null;
}
_popupList.Add(component);
RefreshPopupOrder();
return component;
}
catch (System.OperationCanceledException)
{
Debug.LogWarning($"[UIManager] 팝업 인스턴스화 취소: {name}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
catch (System.Exception ex)
{
Debug.LogError($"[UIManager] 팝업 표시 중 예외 발생 ({name}): {ex}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
}
public async Task<T> ShowScreen<T>(string name) where T : BaseUI
{
if (string.IsNullOrEmpty(name)) return null;
GameObject instance = null;
try
{
instance = await AddressablesManager.Instance.InstantiateAsync(name, UIRoot);
if (instance == null) return null;
T component = instance.GetOrAddComponent<T>();
if (component == null)
{
Debug.LogError($"[UIManager] {typeof(T).Name} 컴포넌트를 {name}에서 찾거나 추가할 수 없습니다. 인스턴스를 해제합니다.");
AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
_screenList.Add(component);
// 스크린 UI는 UIManager가 별도의 Sorting Order를 관리하지 않음
SetCanvas(instance, false);
return component;
}
catch (System.OperationCanceledException)
{
Debug.LogWarning($"[UIManager] 스크린 인스턴스화 취소: {name}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
catch (System.Exception ex)
{
Debug.LogError($"[UIManager] 스크린 표시 중 예외 발생 ({name}): {ex}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
}
public async Task<T> ShowWorldUI<T>(string name, Transform parent = null) where T : BaseUI
{
if (string.IsNullOrEmpty(name)) return null;
Transform targetParent = parent ?? UIRoot; // 부모가 지정되지 않으면 UI Root 사용
GameObject instance = null;
try
{
instance = await AddressablesManager.Instance.InstantiateAsync(name, targetParent);
if (instance == null) return null;
T component = instance.GetOrAddComponent<T>();
if (component == null)
{
Debug.LogError($"[UIManager] {typeof(T).Name} 컴포넌트를 {name}에서 찾거나 추가할 수 없습니다. 인스턴스를 해제합니다.");
AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
_worldUIList.Add(component);
SetWorldCanvas(instance); // 월드 공간 Canvas로 설정
return component;
}
catch (System.OperationCanceledException)
{
Debug.LogWarning($"[UIManager] 월드 UI 인스턴스화 취소: {name}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
catch (System.Exception ex)
{
Debug.LogError($"[UIManager] 월드 UI 표시 중 예외 발생 ({name}): {ex}");
if (instance != null) AddressablesManager.Instance.ReleaseInstance(instance);
return null;
}
}
public void ClosePopup(BaseUI popup)
{
if (popup == null || popup.gameObject == null) return;
if (_popupList.Remove(popup))
{
AddressablesManager.Instance.ReleaseInstance(popup.gameObject);
RefreshPopupOrder();
}
else
{
// 리스트에 해당 팝업이 없는 경우 (이미 닫혔거나, 팝업 리스트로 관리되지 않는 UI)
Debug.LogWarning($"[UIManager] 팝업 '{popup.name}'을 관리 리스트에서 찾을 수 없습니다. UIManager를 통해 닫을 수 없습니다.");
}
}
public void CloseTopPopup()
{
if (_popupList.Count == 0) return;
int lastIndex = _popupList.Count - 1;
BaseUI topPopup = _popupList[lastIndex];
if (topPopup != null && topPopup.gameObject != null)
{
_popupList.RemoveAt(lastIndex); // 리스트에서 제거
AddressablesManager.Instance?.ReleaseInstance(topPopup.gameObject);
}
else
{
_popupList.RemoveAt(lastIndex);
RefreshPopupOrder();
}
}
public void CloseAllPopups()
{
// 리스트를 뒤에서부터 순회하며 제거 (Index 문제 방지)
for (int i = _popupList.Count - 1; i >= 0; i--)
{
BaseUI popup = _popupList[i];
if (popup != null && popup.gameObject != null)
{
AddressablesManager.Instance?.ReleaseInstance(popup.gameObject);
}
}
_popupList.Clear();
}
public void CloseAllUI()
{
// 팝업 닫기
CloseAndClearList(_popupList);
// 스크린 닫기
CloseAndClearList(_screenList);
// 월드 UI 닫기
CloseAndClearList(_worldUIList);
}
/// <summary>
/// 팝업 리스트 순서에 따라 Sorting Order를 재설정합니다. (리스트 뒷쪽이 위)
/// </summary>
private void RefreshPopupOrder()
{
_popupList.RemoveAll(popup => popup == null || popup.gameObject == null);
for (int i = 0; i < _popupList.Count; i++)
{
BaseUI popup = _popupList[i];
SetCanvas(popup.gameObject, true, POPUP_BASE_SORTING_ORDER + i);
}
}
/// <summary>
/// UI GameObject에 Canvas 컴포넌트를 설정합니다 (Screen Space 또는 Overlay 용).
/// </summary>
/// <param name="go">대상 GameObject</param>
/// <param name="applySortOrder">Sorting Order를 적용할지 여부</param>
/// <param name="sortOrder">적용할 Sorting Order 값</param>
private void SetCanvas(GameObject go, bool applySortOrder, int sortOrder = 0)
{
if (go == null) return;
Canvas canvas = go.GetOrAddComponent<Canvas>();
if (canvas.renderMode != RenderMode.WorldSpace)
{
if (applySortOrder)
{
canvas.overrideSorting = true;
canvas.sortingOrder = sortOrder; // 지정된 값으로 설정
}
}
// UI 상호작용을 위해 GraphicRaycaster 확인/추가
go.GetOrAddComponent<GraphicRaycaster>();
}
/// <summary>
/// UI GameObject에 월드 공간 Canvas 컴포넌트를 설정합니다.
/// </summary>
/// <param name="go">대상 GameObject</param>
private void SetWorldCanvas(GameObject go)
{
if (go == null) return;
Canvas canvas = go.GetOrAddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = Camera.main; // 필요시 다른 카메라 할당
canvas.overrideSorting = false;
go.GetOrAddComponent<GraphicRaycaster>();
}
/// <summary>
/// 지정된 UI 리스트의 모든 항목을 닫고 리스트를 비우는 헬퍼 메서드.
/// </summary>
private void CloseAndClearList(List<BaseUI> uiList)
{
if (uiList == null) return;
// 리스트 복사본 생성 또는 역순 반복 (리스트 수정 중 순회 오류 방지)
for (int i = uiList.Count - 1; i >= 0; i--)
{
BaseUI ui = uiList[i];
if (ui != null && ui.gameObject != null)
{
ReleaseUIInstance(ui.gameObject);
}
}
uiList.Clear();
}
/// <summary>
/// UI 인스턴스를 Addressables를 통해 해제하거나 직접 파괴하는 헬퍼 메서드.
/// </summary>
private void ReleaseUIInstance(GameObject instance)
{
if (instance == null) return;
// AddressablesManager로 관리되는지 확인하고 해제 시도
if (AddressablesManager.Instance.IsInstanceManaged(instance))
{
AddressablesManager.Instance.ReleaseInstance(instance);
}
else // Addressables로 관리되지 않거나 Manager가 없으면 직접 파괴
{
Object.Destroy(instance);
}
}
}
- PoolManager: 게임 오브젝트 풀링(생성, 반환, 관리) 담당.
더보기
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using Object = UnityEngine.Object;
public class PoolManager : IPoolManager
{
// 각 풀의 내부 관리를 위한 중첩 클래스
private class Pool
{
/// <summary>
/// 이 풀이 관리하는 Addressable 프리팹 키
/// </summary>
public string AddressableKey { get; }
/// <summary>
/// 이 풀의 비활성 오브젝트들을 담아둘 부모 Transform
/// </summary>
public Transform ContainerRoot { get; }
// 사용 가능한 오브젝트들을 저장하는 스택
private readonly Stack<PoolableObject> _poolStack = new Stack<PoolableObject>();
// 로드된 원본 프리팹 참조
private GameObject _loadedPrefab;
// 비동기 초기화 작업 상태를 추적하는 Task
private Task<bool> _initializationTask;
// Addressables 로딩/해제를 위한 매니저 참조
private readonly AddressablesManager _addressablesManager;
/// <summary>
/// 새 풀 인스턴스를 생성합니다.
/// </summary>
/// <param name="addressableKey">관리할 프리팹의 Addressable 키</param>
/// <param name="poolRoot">모든 풀 컨테이너들의 부모 Transform</param>
/// <param name="addrManager">Addressables 매니저 인스턴스</param>
public Pool(string addressableKey, Transform poolRoot, AddressablesManager addrManager) // 생성자에 addrManager 추가
{
AddressableKey = addressableKey;
_addressablesManager = addrManager; // 참조 저장
// '/' 문자는 GameObject 이름에 유효하지 않을 수 있으므로 '_'로 치환
string containerName = $"Pool_{AddressableKey.Replace('/', '_')}";
ContainerRoot = new GameObject(containerName).transform;
ContainerRoot.SetParent(poolRoot); // PoolManager의 루트 아래에 배치
}
/// <summary>
/// 풀을 비동기적으로 초기화합니다. (프리팹 로드 및 초기 오브젝트 생성)
/// 이미 초기화 중이거나 완료된 경우 기존 Task를 반환합니다.
/// </summary>
/// <param name="count">미리 생성할 초기 오브젝트 수</param>
/// <returns>초기화 성공 여부를 나타내는 Task</returns>
public Task<bool> InitializeAsync(int count = 5)
{
// 이미 초기화 Task가 있다면 재사용 (중복 실행 방지)
if (_initializationTask != null) return _initializationTask;
// 새 초기화 Task 생성 및 시작
_initializationTask = InitializeInternalAsync(count);
return _initializationTask;
}
/// <summary>
/// 실제 비동기 초기화 로직을 수행합니다.
/// </summary>
private async Task<bool> InitializeInternalAsync(int count)
{
try
{
// 1. Addressables를 사용하여 원본 프리팹 로드
_loadedPrefab = await _addressablesManager.LoadAssetAsync<GameObject>(AddressableKey);
if (_loadedPrefab == null)
{
// 프리팹 로드 실패 (AddressablesManager에서 오류 로그 출력 가정)
Debug.LogError($"[PoolManager] 프리팹 로드 실패: {AddressableKey}");
return false; // 초기화 실패
}
// PoolableObject 컴포넌트 존재 확인
if (_loadedPrefab.GetComponent<PoolableObject>() == null)
{
Debug.LogError($"[PoolManager] 프리팹 '{AddressableKey}'에 PoolableObject 컴포넌트가 없습니다!");
// 로드는 성공했지만 컴포넌트가 없으므로 로드한 에셋 해제
_addressablesManager.ReleaseAsset(AddressableKey);
_loadedPrefab = null; // 내부 참조 제거
return false; // 초기화 실패
}
// 2. 지정된 수량만큼 미리 오브젝트 생성하여 풀에 추가
for (int i = 0; i < count; i++)
{
PoolableObject instance = Create(); // 새 인스턴스 생성 (동기)
if (instance != null)
{
Push(instance); // 생성된 인스턴스를 풀에 반환 (비활성화 및 스택 추가)
}
else
{
// 인스턴스 생성 실패 로그 (초기화 자체는 계속 진행될 수 있음)
Debug.LogWarning($"[PoolManager] 풀({AddressableKey}) 초기 인스턴스 생성 실패 ({i + 1}/{count}).");
}
}
return true;
}
catch (System.Exception ex) // 초기화 중 예외 발생 처리
{
Debug.LogError($"[PoolManager] 풀({AddressableKey}) 초기화 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
// 예외 발생 시, 로드된 프리팹이 있다면 해제 시도
if (_loadedPrefab != null)
{
_addressablesManager.ReleaseAsset(AddressableKey);
_loadedPrefab = null;
}
return false;
}
}
/// <summary>
/// 로드된 프리팹을 기반으로 새 게임 오브젝트 인스턴스를 생성합니다. (내부 동기 메서드)
/// </summary>
private PoolableObject Create()
{
// 프리팹이 로드되지 않았으면 생성 불가
if (_loadedPrefab == null)
{
Debug.LogError($"[PoolManager] 인스턴스 생성 불가, 프리팹({AddressableKey})이 로드되지 않았습니다.");
return null;
}
// Object.Instantiate는 이미 로드된 프리팹에 대해 동기적으로 작동
GameObject go = Object.Instantiate(_loadedPrefab);
// go.name = _loadedPrefab.name; // 이름은 자동으로 프리팹 이름(Clone)으로 설정됨
PoolableObject poolable = go.GetComponent<PoolableObject>();
// InitializeAsync에서 컴포넌트 존재를 확인했으므로 여기서는 null 가능성 낮음
if (poolable == null)
{
Debug.LogError($"[PoolManager] 인스턴스화 후 PoolableObject 컴포넌트를 찾을 수 없습니다: {AddressableKey}. 프리팹 설정을 확인하세요.");
Object.Destroy(go); // 컴포넌트 없으면 생성된 인스턴스 파괴
return null;
}
return poolable;
}
/// <summary>
/// 사용 완료된 오브젝트를 풀에 반환합니다. (비활성화 및 부모 변경)
/// </summary>
public void Push(PoolableObject poolable)
{
// null 또는 이미 파괴된 오브젝트 처리
if (poolable == null || poolable.gameObject == null)
{
Debug.LogWarning($"[PoolManager] null 또는 파괴된 오브젝트를 풀({AddressableKey})에 반환 시도.");
return;
}
// 오브젝트 상태 초기화 (PoolableObject 내부 메서드 호출)
poolable.OnReturnToPool();
// 풀 컨테이너 하위로 이동
poolable.transform.SetParent(ContainerRoot);
// 비활성화
poolable.gameObject.SetActive(false);
// 사용 중 플래그 해제
poolable.isUsing = false;
// poolable.OriginAddressableKey 는 PopAsync에서 설정되었으므로 여기서 건드리지 않음
// 풀 스택에 추가
_poolStack.Push(poolable);
}
/// <summary>
/// 풀에서 사용 가능한 오브젝트를 가져옵니다. 없으면 새로 생성합니다. (동기 메서드)
/// 이 메서드는 외부의 PopAsync 내부에서 호출되며, 해당 풀이 이미 초기화되었다고 가정합니다.
/// </summary>
/// <param name="parent">가져온 오브젝트의 부모로 설정할 Transform (null이면 월드 루트)</param>
public PoolableObject Pop(Transform parent)
{
// 안전 장치: 프리팹 로드 확인 (InitializeAsync가 완료되었어야 함)
if (_loadedPrefab == null)
{
Debug.LogError($"[PoolManager] 풀({AddressableKey})이 초기화되지 않았습니다. Pop을 호출할 수 없습니다.");
return null;
}
PoolableObject poolable;
// 스택에 사용 가능한 오브젝트가 있는지 확인
if (_poolStack.Count > 0)
{
poolable = _poolStack.Pop();
// 스택에서 꺼냈지만 그 사이 파괴되었을 경우 대비
if (poolable == null || poolable.gameObject == null)
{
Debug.LogWarning($"[PoolManager] 풀({AddressableKey})에서 파괴된 오브젝트 발견. 새로 생성합니다.");
poolable = Create(); // 새로 생성
if (poolable == null) return null; // 생성 실패
}
}
else // 풀이 비어있으면 새로 생성
{
// Debug.Log($"[PoolManager] 풀({AddressableKey})이 비어있어 새로 생성합니다.");
poolable = Create();
if (poolable == null) return null; // 생성 실패
}
// 오브젝트 활성화
poolable.gameObject.SetActive(true);
// 부모 설정 (null이면 월드 루트 바로 아래)
poolable.transform.SetParent(parent);
// 사용 중 플래그 설정
poolable.isUsing = true;
// poolable.OriginAddressableKey 는 외부 PopAsync에서 설정
// 오브젝트 사용 시작 시 상태 초기화 (PoolableObject 내부 메서드 호출)
poolable.OnPopFromPool();
return poolable;
}
/// <summary>
/// 이 풀과 관련된 로드된 원본 프리팹 에셋을 Addressables에서 해제합니다.
/// </summary>
public void ReleasePrefab()
{
if (_loadedPrefab != null && _addressablesManager != null)
{
Debug.Log($"[PoolManager] 프리팹 에셋 해제 요청: {AddressableKey}");
// AddressablesManager를 통해 참조 카운트 기반 해제 요청
_addressablesManager.ReleaseAsset(AddressableKey); // AddressablesManager 사용
_loadedPrefab = null; // 내부 참조 제거
}
// 초기화 Task도 리셋하여 다음에 다시 초기화 가능하도록 함
_initializationTask = null;
}
/// <summary>
/// 풀 컨테이너와 그 안의 모든 게임 오브젝트들을 파괴합니다.
/// </summary>
public void DestroyPoolContents()
{
// 스택에 남아있는 오브젝트들을 명시적으로 파괴 (선택적이지만 안전함)
while (_poolStack.Count > 0)
{
PoolableObject obj = _poolStack.Pop();
if (obj != null && obj.gameObject != null)
{
Object.Destroy(obj.gameObject);
}
}
_poolStack.Clear(); // 스택 비우기
// 풀 컨테이너 루트 GameObject 파괴 (하위의 모든 오브젝트 포함)
if (ContainerRoot != null)
{
Object.Destroy(ContainerRoot.gameObject);
}
}
}
// --- PoolManager 멤버 변수 및 메서드 ---
private const string POOL_ROOT_NAME = "@Pool_Root"; // 모든 풀 컨테이너의 부모 오브젝트 이름
private readonly Dictionary<string, Pool> _pools = new Dictionary<string, Pool>(); // 풀 딕셔너리 (Key: AddressableKey)
private Transform _poolRoot; // 풀 컨테이너들의 부모 Transform
public void Initialize()
{
GetOrCreateRoot();
}
public void Shutdown()
{
ClearAllPools();
_poolRoot = null;
}
/// <summary>
/// 풀들을 담을 최상위 루트 GameObject를 찾거나 생성합니다.
/// </summary>
private void GetOrCreateRoot()
{
if (_poolRoot == null)
{
GameObject root = GameObject.Find(POOL_ROOT_NAME);
if (root == null)
{
root = new GameObject(POOL_ROOT_NAME);
Object.DontDestroyOnLoad(root); // 씬 전환 시 유지
}
_poolRoot = root.transform;
}
}
/// <summary>
/// 지정된 키의 풀을 가져오거나, 없으면 비동기적으로 생성 및 초기화하는 내부 헬퍼 메서드.
/// </summary>
/// <param name="addressableKey">Addressable 키</param>
/// <param name="count">새 풀 생성 시 초기화할 오브젝트 수</param>
/// <returns>준비된 Pool 인스턴스 (실패 시 null)</returns>
private async Task<Pool> GetOrCreatePoolAsync(string addressableKey, int count = 5)
{
// 키 유효성 검사
if (string.IsNullOrEmpty(addressableKey))
{
Debug.LogError("[PoolManager] Addressable 키는 null이거나 비어 있을 수 없습니다.");
return null;
}
// 이미 풀이 존재하는지 확인
if (_pools.TryGetValue(addressableKey, out Pool pool))
{
// 풀이 존재하면, 비동기 초기화가 완료되었는지 확인 (InitializeAsync는 내부적으로 상태 관리)
bool initialized = await pool.InitializeAsync(count); // count는 기존 풀에는 영향 없을 수 있음
// 초기화가 성공했거나 이미 완료된 경우 풀 반환
return initialized ? pool : null;
}
else // 풀이 존재하지 않으면 새로 생성
{
// AddressablesManager 인스턴스 확인 (Pool 생성 시 필요)
if (AddressablesManager.Instance == null)
{
Debug.LogError("[PoolManager] 새 풀 생성 불가: AddressablesManager가 없습니다.");
return null;
}
// 새 Pool 객체 생성
pool = new Pool(addressableKey, _poolRoot, AddressablesManager.Instance); // AddressablesManager 전달
// 딕셔너리에 즉시 추가 (초기화 실패 시 제거됨)
_pools.Add(addressableKey, pool);
// 새 풀 비동기 초기화 시작 및 대기
bool success = await pool.InitializeAsync(count);
if (!success) // 초기화 실패 시
{
Debug.LogError($"[PoolManager] 풀 초기화 실패: {addressableKey}. 풀을 제거합니다.");
_pools.Remove(addressableKey); // 관리 목록에서 제거
// 생성된 풀 컨테이너 GameObject도 파괴
if (pool.ContainerRoot != null) Object.Destroy(pool.ContainerRoot.gameObject);
return null; // 실패 반환
}
// 초기화 성공 시 풀 반환
return pool;
}
}
public async Task<bool> PreparePoolAsync(string addressableKey, int count = 5)
{
// 내부 헬퍼를 호출하여 풀을 가져오거나 생성/초기화 시도
Pool pool = await GetOrCreatePoolAsync(addressableKey, count);
// 풀 객체가 null이 아니면 성공
return pool != null;
}
public async Task<PoolableObject> PopAsync(string addressableKey, Transform parent = null)
{
// 1. 해당 키의 풀을 가져오거나 생성/초기화 (비동기)
// GetOrCreatePoolAsync 내부에서 초기화 완료를 보장함
Pool pool = await GetOrCreatePoolAsync(addressableKey);
if (pool == null)
{
// 풀 준비 실패 (GetOrCreatePoolAsync에서 로그 출력됨)
Debug.LogError($"[PoolManager] 풀을 준비할 수 없습니다: {addressableKey}");
return null;
}
// 2. 준비된 풀에서 오브젝트 가져오기 (동기 작업)
// 풀 초기화(프리팹 로드)는 위에서 완료되었으므로 Pop 자체는 빠름
PoolableObject poolable = pool.Pop(parent);
if (poolable != null)
{
// 3. 가져온 오브젝트에 원본 키 정보 설정 (Push에서 사용)
poolable.OriginAddressableKey = addressableKey;
}
else
{
// pool.Pop 내부에서 오류 로그 출력 가정
Debug.LogError($"[PoolManager] 풀에서 오브젝트를 가져오지 못했습니다: {addressableKey}");
}
return poolable;
}
public void Push(PoolableObject poolable)
{
// 유효하지 않은 오브젝트 처리
if (poolable == null) { Debug.LogWarning($"[PoolManager] null 오브젝트를 Push 시도."); return; }
// OriginAddressableKey가 설정되지 않은 오브젝트 처리
if (string.IsNullOrEmpty(poolable.OriginAddressableKey))
{
Debug.LogWarning($"[PoolManager] OriginAddressableKey가 설정되지 않은 오브젝트({poolable.name})를 Push 시도. 오브젝트를 파괴합니다.");
Object.Destroy(poolable.gameObject); // 관리 불가 오브젝트는 파괴
return;
}
// Origin Key를 사용하여 적절한 풀 찾기
if (_pools.TryGetValue(poolable.OriginAddressableKey, out var pool))
{
// 찾은 풀에 오브젝트 반환 (Pool 클래스의 Push 호출)
pool.Push(poolable);
}
else // 해당 키의 풀이 존재하지 않는 경우
{
Debug.LogWarning($"[PoolManager] 키 '{poolable.OriginAddressableKey}'에 해당하는 풀을 찾을 수 없어 오브젝트({poolable.name})를 파괴합니다.");
Object.Destroy(poolable.gameObject); // 관리할 풀이 없으므로 파괴
}
}
public void ClearAllPools()
{
// AddressablesManager 없으면 에셋 해제 불가, 경고 후 오브젝트만 정리 시도
if (AddressablesManager.Instance == null)
{
Debug.LogWarning("[PoolManager] 풀 정리 중 경고: AddressablesManager 인스턴스가 없습니다. 에셋 해제는 건너뜁니다.");
}
// 풀 루트 없으면 정리할 대상 없음
if (_poolRoot == null && _pools.Count == 0) return; // 풀 루트도 없고 관리하는 풀도 없으면 종료
// 딕셔너리 순회 중 수정을 피하기 위해 키 목록 복사
List<string> keys = new List<string>(_pools.Keys);
foreach (string key in keys)
{
if (_pools.TryGetValue(key, out Pool pool))
{
// 1. 로드된 프리팹 에셋 해제 요청 (AddressablesManager 사용, null이어도 내부 처리)
pool.ReleasePrefab();
// 2. 풀 컨테이너 GameObject 및 내부 오브젝트들 파괴
pool.DestroyPoolContents(); // Pool 클래스 내부에서 처리
}
}
// 관리 목록 비우기
_pools.Clear();
}
}
- AudioManager: 배경음악(BGM), 효과음(SFX) 재생 및 관리 담당.
더보기
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
public enum SoundType
{
BGM,
SFX,
MaxCount, // 배열 크기 지정용
}
public class AudioManager : IAudioManager
{
private const string AUDIO_ROOT_NAME = "@Audio_Root";
private readonly AudioSource[] _audioSources = new AudioSource[(int)SoundType.MaxCount];
private readonly Dictionary<string, AudioClip> _audioClipCache = new Dictionary<string, AudioClip>();
private float _masterVolume = 1.0f;
private float _bgmVolume = 1.0f;
private float _sfxVolume = 1.0f;
public void Initialize()
{
GetOrCreateRoot();
}
public void Shutdown()
{
StopAudio();
ClearAudioCache();
}
private void GetOrCreateRoot()
{
GameObject root = GameObject.Find(AUDIO_ROOT_NAME);
if (root == null)
{
root = new GameObject(AUDIO_ROOT_NAME);
Object.DontDestroyOnLoad(root);
for (int i = 0; i < (int)SoundType.MaxCount; i++)
{
GameObject go = new GameObject(((SoundType)i).ToString());
_audioSources[i] = go.AddComponent<AudioSource>();
go.transform.SetParent(root.transform);
}
}
}
public async Task PlayBGM(string soundId, float volume = 1.0f, bool loop = true)
{
if (string.IsNullOrEmpty(soundId)) return;
// 1. 캐시 확인
if (_audioClipCache.TryGetValue(soundId, out AudioClip cachedClip))
{
PlayBGMInternal(cachedClip, volume, loop);
return; // 캐시 히트 시 즉시 반환 (동기적 완료)
}
else
{
_audioClipCache.Remove(soundId);
}
try
{
AudioClip loadedClip = await AddressablesManager.Instance.LoadAssetAsync<AudioClip>(soundId);
if (loadedClip != null)
{
_audioClipCache[soundId] = loadedClip; // 캐시에 추가
PlayBGMInternal(loadedClip, volume, loop);
}
else
{
Debug.LogError($"[AudioManager] BGM 클립 로드 실패: {soundId}. LoadAssetAsync 결과가 null입니다.");
}
}
catch (System.OperationCanceledException)
{
Debug.LogWarning($"[AudioManager] BGM 로딩이 취소되었습니다: {soundId}");
}
catch (System.Exception ex)
{
Debug.LogError($"BGM 클립 {soundId} 로드 중 예외 발생: {ex}");
}
}
private void PlayBGMInternal(AudioClip audioClip, float volume, bool loop)
{
AudioSource source = _audioSources[(int)SoundType.BGM];
if (source == null)
{
Debug.LogError("[AudioManager] BGM용 null AudioClip을 재생할 수 없습니다");
return;
}
_bgmVolume = Mathf.Clamp01(volume); // 볼륨 업데이트
// 현재 재생 중인 클립과 같으면 볼륨만 조절
if (source.isPlaying && source.clip == audioClip)
{
source.volume = _bgmVolume * _masterVolume;
source.loop = loop;
return;
}
// 새로운 클립 재생
source.Stop();
source.clip = audioClip;
source.volume = _bgmVolume * _masterVolume;
source.loop = loop;
source.pitch = 1.0f;
source.Play();
}
public async Task PlaySFX(string soundId, float volume = 1.0f)
{
if (string.IsNullOrEmpty(soundId)) return;
// 캐시 확인
if (_audioClipCache.TryGetValue(soundId, out AudioClip cachedClip))
{
PlaySfxInternal(cachedClip, volume);
return;
}
else
{
_audioClipCache.Remove(soundId);
}
try
{
AudioClip loadedClip = await AddressablesManager.Instance.LoadAssetAsync<AudioClip>(soundId);
if (loadedClip != null)
{
_audioClipCache[soundId] = loadedClip;
PlaySfxInternal(loadedClip, volume);
}
else
{
Debug.LogError($"[AudioManager] SFX 클립 로드 실패: {soundId}. LoadAssetAsync 결과가 null입니다.");
}
}
catch (System.OperationCanceledException)
{
Debug.LogWarning($"[AudioManager] SFX 로딩이 취소되었습니다: {soundId}");
}
catch (System.Exception ex)
{
Debug.LogError($"SFX 클립 {soundId} 로드 중 예외 발생: {ex}");
}
}
private void PlaySfxInternal(AudioClip audioClip, float volume)
{
if (audioClip == null)
{
Debug.LogError($"[AudioManager] SFX로 null AudioClip을 재생할 수 없습니다");
return;
}
AudioSource source = _audioSources[(int)SoundType.SFX];
_sfxVolume = Mathf.Clamp01(volume);
source.PlayOneShot(audioClip, _sfxVolume * _masterVolume);
}
public void ClearAudioCache()
{
// Dictionary를 순회하며 수정하는 것을 피하기 위해 키 목록 복사
List<string> keysToRelease = new List<string>(_audioClipCache.Keys);
foreach (var key in keysToRelease)
{
AddressablesManager.Instance.ReleaseAsset(key);
_audioClipCache.Remove(key);
}
}
public void StopAudio()
{
foreach (var audioSource in _audioSources)
{
if (audioSource != null)
{
audioSource.Stop();
audioSource.clip = null;
}
}
}
public void StopBGM()
{
AudioSource bgmSource = _audioSources[(int)SoundType.BGM];
if (bgmSource != null)
{
bgmSource.Stop();
bgmSource.clip = null;
Debug.Log("[AudioManager] 배경 음악(BGM)이 중지되었습니다.");
}
}
public void StopSFX()
{
AudioSource sfxSource = _audioSources[(int)SoundType.SFX];
if (sfxSource != null)
{
sfxSource.Stop();
sfxSource.clip = null;
Debug.Log("[AudioManager] 효과음(SFX)이 중지되었습니다. (참고: PlayOneShot으로 재생된 소리는 중지되지 않을 수 있음)");
}
}
public void SetMasterVolume(float volume)
{
_masterVolume = Mathf.Clamp01(volume);
SetBGMVolume(_bgmVolume);
SetSFXVolume(_sfxVolume);
Debug.Log($"[AudioManager] 마스터 볼륨이 설정되었습니다: {_masterVolume}");
}
public void SetBGMVolume(float volume)
{
AudioSource bgmSource = _audioSources[(int)SoundType.BGM];
if (bgmSource != null)
{
_bgmVolume = Mathf.Clamp01(volume);
bgmSource.volume = _bgmVolume * _masterVolume;
Debug.Log($"[AudioManager] BGM 볼륨이 설정되었습니다: {_bgmVolume} (실제 적용 값: {bgmSource.volume})");
}
}
public void SetSFXVolume(float volume)
{
AudioSource sfxSource = _audioSources[(int)SoundType.SFX];
if (sfxSource != null)
{
_sfxVolume = Mathf.Clamp01(volume);
sfxSource.volume = Mathf.Clamp01(volume) * _masterVolume;
Debug.Log($"[AudioManager] SFX 기본 볼륨이 설정되었습니다: {_sfxVolume} (실제 적용 값: {sfxSource.volume})");
}
}
}
- InputManager: 키보드, 마우스, 터치 등 사용자 입력 처리 및 관련 이벤트 제공 담당.
더보기
// // 어디서든 서비스에 접근
// IInputManager inputManager = ServiceLocator.GetService<IInputManager>();
//
// // 키보드 입력 확인
// if (inputManager.GetKeyDown(KeyCode.Space))
// {
// // 스페이스바 입력 처리
// }
//
// // 사용자 정의 액션 사용
// if (inputManager.GetActionDown("Jump"))
// {
// // 점프 액션 처리
// }
//
// // 이동 벡터 가져오기
// Vector2 movement = inputManager.GetMovementVector();
//
// // 스와이프 감지
// if (inputManager.DetectSwipe(out Vector2 swipeDirection))
// {
// Debug.Log($"스와이프 감지: {swipeDirection}");
// }
using System;
using System.Collections.Generic;
using UnityEngine;
public class InputManager : IInputManager
{
[Serializable]
public class InputAction
{
public string name;
public KeyCode primaryKey;
public KeyCode alternateKey;
}
[Serializable]
public class InputAxis
{
public string name;
public KeyCode positive;
public KeyCode negative;
public KeyCode alternatePositive;
public KeyCode alternateNegative;
}
private readonly Dictionary<string, InputAction> _actions = new Dictionary<string, InputAction>();
private readonly Dictionary<string, InputAxis> _axes = new Dictionary<string, InputAxis>();
private Vector2 _fingerDownPosition;
private Vector2 _fingerUpPosition;
private bool _isTrackingSwipe = false;
private bool _swipeDetected = false;
private Vector2 _lastSwipeDirection;
private float _swipeThreshold = 50f;
private bool _detectSwipeOnlyAfterRelease = false;
// 움직임 축 이름
private const string HORIZONTAL_AXIS = "Horizontal";
private const string VERTICAL_AXIS = "Vertical";
public void Initialize()
{
// 기본 움직임 축 등록
RegisterAxis(HORIZONTAL_AXIS, KeyCode.D, KeyCode.A, KeyCode.RightArrow, KeyCode.LeftArrow);
RegisterAxis(VERTICAL_AXIS, KeyCode.W, KeyCode.S, KeyCode.UpArrow, KeyCode.DownArrow);
// 기본 액션 등록 예시
RegisterAction("Jump", KeyCode.Space);
RegisterAction("Interact", KeyCode.E);
RegisterAction("Pause", KeyCode.Escape);
}
public void Shutdown()
{
// 정리 작업
_actions.Clear();
_axes.Clear();
}
public void OnUpdate(float deltaTime)
{
// 스와이프 감지
UpdateSwipeDetection();
}
#region 키보드 및 마우스 입력
public bool GetKeyDown(KeyCode key)
{
return Input.GetKeyDown(key);
}
public bool GetKeyUp(KeyCode key)
{
return Input.GetKeyUp(key);
}
public bool GetKey(KeyCode key)
{
return Input.GetKey(key);
}
public Vector2 GetMousePosition()
{
return Input.mousePosition;
}
public bool GetMouseButtonDown(int button)
{
return Input.GetMouseButtonDown(button);
}
public bool GetMouseButtonUp(int button)
{
return Input.GetMouseButtonUp(button);
}
public bool GetMouseButtonHold(int button)
{
return Input.GetMouseButton(button);
}
#endregion
#region 터치 입력
public bool IsTouchSupported()
{
return Input.touchSupported;
}
public int GetTouchCount()
{
return Input.touchCount;
}
public Vector2 GetTouchPosition(int touchIndex = 0)
{
if (Input.touchCount > touchIndex)
{
return Input.GetTouch(touchIndex).position;
}
return Vector2.zero;
}
#endregion
#region 사용자 정의 입력 액션
public void RegisterAction(string actionName, KeyCode primaryKey, KeyCode alternateKey = KeyCode.None)
{
if (_actions.ContainsKey(actionName))
{
Debug.LogWarning($"Action '{actionName}' already registered. Overriding.");
}
InputAction action = new InputAction
{
name = actionName,
primaryKey = primaryKey,
alternateKey = alternateKey
};
_actions[actionName] = action;
}
public bool GetActionDown(string actionName)
{
if (!_actions.TryGetValue(actionName, out InputAction action))
{
Debug.LogWarning($"Action '{actionName}' not registered.");
return false;
}
return Input.GetKeyDown(action.primaryKey) ||
(action.alternateKey != KeyCode.None && Input.GetKeyDown(action.alternateKey));
}
public bool GetActionUp(string actionName)
{
if (!_actions.TryGetValue(actionName, out InputAction action))
{
Debug.LogWarning($"Action '{actionName}' not registered.");
return false;
}
return Input.GetKeyUp(action.primaryKey) ||
(action.alternateKey != KeyCode.None && Input.GetKeyUp(action.alternateKey));
}
public bool GetAction(string actionName)
{
if (!_actions.TryGetValue(actionName, out InputAction action))
{
Debug.LogWarning($"Action '{actionName}' not registered.");
return false;
}
return Input.GetKey(action.primaryKey) ||
(action.alternateKey != KeyCode.None && Input.GetKey(action.alternateKey));
}
#endregion
#region 축 입력
public void RegisterAxis(string axisName, KeyCode positive, KeyCode negative, KeyCode altPositive = KeyCode.None,
KeyCode altNegative = KeyCode.None)
{
if (_axes.ContainsKey(axisName))
{
Debug.LogWarning($"Axis '{axisName}' already registered. Overriding.");
}
InputAxis axis = new InputAxis
{
name = axisName,
positive = positive,
negative = negative,
alternatePositive = altPositive,
alternateNegative = altNegative
};
_axes[axisName] = axis;
}
public float GetAxis(string axisName)
{
if (!_axes.TryGetValue(axisName, out InputAxis axis))
{
// 유니티 내장 축 사용 시도
try
{
return Input.GetAxis(axisName);
}
catch
{
Debug.LogWarning($"Axis '{axisName}' not registered.");
return 0f;
}
}
float value = 0f;
if (Input.GetKey(axis.positive) ||
(axis.alternatePositive != KeyCode.None && Input.GetKey(axis.alternatePositive)))
{
value += 1f;
}
if (Input.GetKey(axis.negative) ||
(axis.alternateNegative != KeyCode.None && Input.GetKey(axis.alternateNegative)))
{
value -= 1f;
}
return value;
}
public Vector2 GetMovementVector()
{
return new Vector2(GetAxis(HORIZONTAL_AXIS), GetAxis(VERTICAL_AXIS));
}
#endregion
#region 스와이프 감지
private void UpdateSwipeDetection()
{
_swipeDetected = false;
// 모바일 터치 스와이프
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
_fingerDownPosition = touch.position;
_fingerUpPosition = touch.position;
_isTrackingSwipe = true;
}
// 터치 이동 중에도 스와이프 감지 (설정에 따라)
if (!_detectSwipeOnlyAfterRelease && _isTrackingSwipe && touch.phase == TouchPhase.Moved)
{
_fingerUpPosition = touch.position;
CheckSwipe();
}
// 터치 종료 시 스와이프 감지
if (_isTrackingSwipe && (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled))
{
_fingerUpPosition = touch.position;
CheckSwipe();
_isTrackingSwipe = false;
}
}
// 마우스 스와이프 (PC 환경)
else
{
if (Input.GetMouseButtonDown(0))
{
_fingerDownPosition = Input.mousePosition;
_fingerUpPosition = Input.mousePosition;
_isTrackingSwipe = true;
}
if (!_detectSwipeOnlyAfterRelease && _isTrackingSwipe && Input.GetMouseButton(0))
{
_fingerUpPosition = Input.mousePosition;
CheckSwipe();
}
if (_isTrackingSwipe && Input.GetMouseButtonUp(0))
{
_fingerUpPosition = Input.mousePosition;
CheckSwipe();
_isTrackingSwipe = false;
}
}
}
private void CheckSwipe()
{
Vector2 swipeDelta = _fingerUpPosition - _fingerDownPosition;
if (swipeDelta.magnitude > _swipeThreshold)
{
// 스와이프 감지됨
_swipeDetected = true;
_lastSwipeDirection = swipeDelta.normalized;
// 스와이프 리셋 (연속 스와이프 감지를 위해)
if (!_detectSwipeOnlyAfterRelease)
{
_fingerDownPosition = _fingerUpPosition;
}
}
}
public bool DetectSwipe(out Vector2 swipeDirection)
{
swipeDirection = _lastSwipeDirection;
return _swipeDetected;
}
#endregion
#region 추가 유틸리티 메서드
// 월드 좌표로 마우스 위치 변환
public Vector3 GetMouseWorldPosition(Camera camera = null)
{
if (camera == null)
{
camera = Camera.main;
}
if (camera != null)
{
Vector3 mousePos = Input.mousePosition;
mousePos.z = camera.nearClipPlane;
return camera.ScreenToWorldPoint(mousePos);
}
return Vector3.zero;
}
// 레이캐스트를 통한 오브젝트 선택
public bool TryGetMouseOverObject<T>(out T component, Camera camera = null, float maxDistance = Mathf.Infinity)
where T : Component
{
component = null;
if (camera == null)
{
camera = Camera.main;
}
if (camera != null)
{
Ray ray = camera.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, maxDistance))
{
component = hit.collider.GetComponent<T>();
return component != null;
}
}
return false;
}
// 2D 레이캐스트
public bool TryGetMouseOver2DObject<T>(out T component, Camera camera = null) where T : Component
{
component = null;
if (camera == null)
{
camera = Camera.main;
}
if (camera != null)
{
Vector2 mousePos = camera.ScreenToWorldPoint(Input.mousePosition);
RaycastHit2D hit = Physics2D.Raycast(mousePos, Vector2.zero);
if (hit.collider != null)
{
component = hit.collider.GetComponent<T>();
return component != null;
}
}
return false;
}
// 스와이프 임계값 설정
public void SetSwipeThreshold(float threshold)
{
_swipeThreshold = threshold;
}
// 스와이프 감지 방식 설정
public void SetSwipeDetectionMode(bool detectOnlyAfterRelease)
{
_detectSwipeOnlyAfterRelease = detectOnlyAfterRelease;
}
#endregion
}
- SceneManager: Addressables 기반 씬 로딩, 전환, 언로딩 및 관련 이벤트 관리 담당.
더보기
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;
/// <summary>
/// 씬 로딩 및 전환을 관리하는 매니저 클래스입니다. async/await 기반 비동기 로딩을 지원합니다.
/// </summary>
public class SceneManager : ISceneManager
{
private const float LOADING_PERCENTAGE_BEFORE_ACTIVATION = 0.9f;
public bool IsLoading { get; private set; }
public string CurrentSceneName { get; private set; }
public event Action<string> OnSceneLoadStarted;
public event Action<float> OnLoadingProgressChanged;
public event Action<string> OnSceneLoadCompleted;
private SceneInstance _loadedSceneInstance;
public void Initialize()
{
CurrentSceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name;
}
public void Shutdown()
{
// 필요한 경우 리소스 정리
}
public async Task LoadSceneAsync(string sceneAddress, LoadSceneMode loadMode = LoadSceneMode.Single)
{
if (IsLoading)
{
Debug.LogWarning("[SceneManager] 이미 씬 로딩이 진행 중입니다.");
return;
}
if (string.IsNullOrEmpty(sceneAddress))
{
Debug.LogError("[SceneManager] 씬 주소는 null이거나 비어 있을 수 없습니다.");
return;
}
IsLoading = true;
OnSceneLoadStarted?.Invoke(sceneAddress);
OnLoadingProgressChanged?.Invoke(0f);
// 이전 씬 인스턴스 임시 저장 (실패 시 롤백 대비)
SceneInstance previousSceneInstance = _loadedSceneInstance;
_loadedSceneInstance = default;
try
{
// 1. 이전 씬 언로드 (Single 모드이고, 유효한 이전 씬 인스턴스가 있을 경우)
if (loadMode == LoadSceneMode.Single && previousSceneInstance.Scene.IsValid())
{
await UnloadSceneInternal(previousSceneInstance);
}
// 2. 새 씬 로드 시작 (아직 활성화하지 않음)
AsyncOperationHandle<SceneInstance> loadHandle = Addressables.LoadSceneAsync(sceneAddress, loadMode, activateOnLoad: false);
if (!loadHandle.IsValid())
{
throw new InvalidOperationException($"씬 로딩 시작 실패: {sceneAddress}. Addressables 핸들이 유효하지 않습니다.");
}
// 3. 로딩 완료 대기 및 진행률 보고
SceneInstance newSceneInstance = await WaitForSceneLoading(loadHandle, sceneAddress);
// 4. 로드된 씬 활성화
await ActivateSceneInstance(newSceneInstance, sceneAddress);
// 성공!
OnLoadingProgressChanged?.Invoke(1.0f);
OnSceneLoadCompleted?.Invoke(sceneAddress);
}
catch (Exception ex)
{
Debug.LogError($"[SceneManager] 씬 로딩/활성화 중 오류 발생 ({sceneAddress}): {ex.Message}\n{ex.StackTrace}");
OnLoadingProgressChanged?.Invoke(0f);
}
finally
{
IsLoading = false;
}
}
private async Task<SceneInstance> WaitForSceneLoading(AsyncOperationHandle<SceneInstance> loadHandle, string sceneAddress)
{
while (!loadHandle.IsDone)
{
if (loadHandle.Status == AsyncOperationStatus.Failed)
{
throw loadHandle.OperationException ?? new Exception($"씬 로딩 중 실패: '{sceneAddress}'.");
}
float loadingProgress = loadHandle.PercentComplete * LOADING_PERCENTAGE_BEFORE_ACTIVATION;
OnLoadingProgressChanged?.Invoke(loadingProgress);
await Task.Yield();
}
if (loadHandle.Status != AsyncOperationStatus.Succeeded)
{
throw loadHandle.OperationException ?? new Exception($"씬 로딩 완료 상태 오류: {loadHandle.Status}");
}
OnLoadingProgressChanged?.Invoke(LOADING_PERCENTAGE_BEFORE_ACTIVATION);
return loadHandle.Result;
}
private async Task ActivateSceneInstance(SceneInstance sceneInstance, string sceneAddress)
{
AsyncOperation activationOperation = sceneInstance.ActivateAsync();
await WaitForAsyncOperation(activationOperation);
if (activationOperation.isDone && sceneInstance.Scene.isLoaded && sceneInstance.Scene.IsValid())
{
_loadedSceneInstance = sceneInstance;
UnityEngine.SceneManagement.SceneManager.SetActiveScene(_loadedSceneInstance.Scene);
CurrentSceneName = sceneAddress;
}
else
{
Debug.LogError($"[SceneManager] 씬 활성화 실패 또는 활성화 후 씬이 유효하지 않음: '{sceneAddress}'!");
await UnloadSceneInternal(sceneInstance);
throw new Exception("씬 활성화 실패.");
}
}
public async Task UnloadCurrentScene()
{
if (IsLoading)
{
Debug.LogWarning("[SceneManager] 씬 로딩 중에 현재 씬을 언로드할 수 없습니다.");
return;
}
if (_loadedSceneInstance.Scene.IsValid())
{
string sceneToUnloadName = _loadedSceneInstance.Scene.name;
Debug.Log($"[SceneManager] 현재 씬 언로드 요청: {sceneToUnloadName}");
SceneInstance instanceToUnload = _loadedSceneInstance;
_loadedSceneInstance = default;
CurrentSceneName = null;
await UnloadSceneInternal(instanceToUnload);
}
else
{
Debug.LogWarning("[SceneManager] 언로드할 유효한 Addressable 씬 인스턴스가 없습니다.");
}
}
private async Task UnloadSceneInternal(SceneInstance sceneInstanceToUnload)
{
if (!sceneInstanceToUnload.Scene.IsValid())
{
Debug.LogWarning("[SceneManager] UnloadSceneInternal: 제공된 SceneInstance가 유효하지 않습니다.");
return;
}
string sceneNameToUnload = sceneInstanceToUnload.Scene.name;
Debug.Log($"[SceneManager] 씬 인스턴스 언로드 시작: {sceneNameToUnload}");
try
{
AsyncOperationHandle<SceneInstance> unloadHandle = Addressables.UnloadSceneAsync(sceneInstanceToUnload, autoReleaseHandle: true);
if (!unloadHandle.IsValid())
{
Debug.LogError($"[SceneManager] 씬 언로드 시작 실패: {sceneNameToUnload}. 핸들이 유효하지 않습니다.");
return;
}
await WaitForSceneUnloading(unloadHandle, sceneNameToUnload);
Debug.Log($"[SceneManager] 씬 '{sceneNameToUnload}' 언로드 성공.");
}
catch (Exception ex)
{
Debug.LogError($"[SceneManager] 씬 언로드 중 예외 발생 ('{sceneNameToUnload}'): {ex.Message}\n{ex.StackTrace}");
}
}
private async Task WaitForSceneUnloading(AsyncOperationHandle<SceneInstance> unloadHandle, string sceneName)
{
var tcs = new TaskCompletionSource<bool>();
unloadHandle.Completed += op => {
if (op.Status == AsyncOperationStatus.Succeeded)
{
tcs.TrySetResult(true);
}
else
{
string errorMsg = op.IsValid() ? (op.OperationException?.Message ?? "N/A") : "N/A";
Debug.LogError($"[SceneManager] 씬 언로드 콜백 실패: '{sceneName}'. 상태: {op.Status}, 오류: {errorMsg}");
tcs.TrySetException(op.OperationException ?? new Exception($"씬 언로드 실패 (상태: {op.Status})"));
}
};
await tcs.Task;
}
private async Task WaitForAsyncOperation(AsyncOperation operation)
{
var tcs = new TaskCompletionSource<bool>();
operation.completed += op => tcs.TrySetResult(true);
await tcs.Task;
}
}
4. 서비스 확장 구조 (인터페이스 기반 설계)
서비스(매니저)들이 일관된 규약을 따르고 유연하게 확장될 수 있도록 인터페이스 기반으로 설계합니다.
4.1. 핵심 인터페이스
- IService: 모든 서비스가 구현해야 하는 기본 인터페이스입니다. 서비스의 초기화 및 종료 로직을 정의합니다.
- Initialize(): 서비스가 ServiceLocator에 등록될 때 호출되어 필요한 초기 설정을 수행합니다.
- Shutdown(): 서비스가 ServiceLocator에서 제거될 때 호출되어 사용 중인 리소스를 정리합니다.
- IUpdatableService: 매 프레임 업데이트 로직이 필요한 서비스(예: InputManager)가 추가로 구현하는 인터페이스입니다.
- OnUpdate(float deltaTime): 매 프레임 호출되어 시간 경과에 따른 로직을 처리합니다.
4.2. 서비스별 인터페이스 구현
각 구체적인 매니저 클래스는 자신의 역할에 맞는 인터페이스(예: IUIManager, IPoolManager, IAudioManager, IInputManager, ISceneManager)를 정의하고 구현합니다. 이 인터페이스들은 IService를 상속받아 기본적인 생명주기 관리를 따릅니다. ServiceLocator에는 이 인터페이스 타입으로 서비스가 등록되어, 구현 세부 정보에 의존하지 않고 서비스를 사용할 수 있습니다.
5. 씬 구조 (BaseScene)
각 게임 씬의 공통 기반 구조를 제공하는 추상 클래스입니다.
- 특징: 모든 게임 씬 스크립트가 상속받는 추상 클래스(abstract class)입니다.
- 책임:
- 씬 시작 시 필요한 공통 초기화 로직 수행 (예: 필요한 서비스 참조 획득).
- 씬별 고유 초기화, 업데이트, 정리 로직을 위한 추상/가상 메서드 제공.
- ServiceLocator를 통해 필요한 서비스(매니저)들에 대한 접근점(프로퍼티) 제공.
- 씬 전환 시 로딩 UI 표시 및 진행률 업데이트 로직 연동.
- 구현:
- Start() 또는 Awake()에서 InitializeManagers()를 호출하여 ServiceLocator로부터 필요한 서비스 인스턴스를 가져와 멤버 변수에 할당합니다.
- abstract void InitializeScene(): 각 파생 씬 클래스에서 반드시 구현해야 하는 씬 고유 초기화 로직을 정의합니다.
- virtual void UpdateScene(), virtual void ClearScene(): 필요에 따라 파생 씬 클래스에서 재정의하여 사용할 수 있는 업데이트 및 정리 로직을 정의합니다.
- SceneManager의 씬 로딩 관련 이벤트(OnSceneLoadStarted, OnLoadingProgressChanged)를 구독하여 로딩 UI를 제어합니다.
더보기
using UnityEngine;
public abstract class BaseScene : MonoBehaviour
{
protected IPoolManager PoolManager { get; private set; }
protected IInputManager InputManager { get; private set; }
protected IUIManager UIManager { get; private set; }
protected IAudioManager AudioManager { get; private set; }
protected ISceneManager SceneManager { get; private set; }
private LoadingUI _loadingUI;
private void Start()
{
InitializeManagers();
InitializeScene();
}
private void Update()
{
UpdateScene();
}
private void OnDestroy()
{
ClearScene();
}
private void InitializeManagers()
{
PoolManager = ServiceLocator.GetService<IPoolManager>();
InputManager = ServiceLocator.GetService<IInputManager>();
UIManager = ServiceLocator.GetService<IUIManager>();
AudioManager = ServiceLocator.GetService<IAudioManager>();
SceneManager = ServiceLocator.GetService<ISceneManager>();
}
/// <summary>
/// 이 씬이 시작될 때 필요한 초기화 작업을 수행합니다. (하위 클래스에서 반드시 구현)
/// 예: BGM 재생, UI 표시, 오브젝트 스폰 등
/// </summary>
protected abstract void InitializeScene();
/// <summary>
/// 이 씬이 활성화되어 있는 동안 매 프레임 호출될 수 있는 로직입니다. (하위 클래스에서 선택적 구현)
/// 예: 게임 규칙 처리, 클리어/실패 조건 확인 등
/// </summary>
protected virtual void UpdateScene()
{
}
/// <summary>
/// 이 씬이 종료되거나 전환될 때 필요한 정리 작업을 수행합니다. (하위 클래스에서 선택적 구현)
/// 예: 생성된 오브젝트 제거, 리소스 해제 요청 등
/// </summary>
protected virtual void ClearScene()
{
AudioManager.ClearAudioCache();
UIManager.CloseAllUI();
}
6. 개발 규칙
- 공용 Enum 타입 관리 (EnumTypes 네임스페이스)
- 핵심: 프로젝트 전반에서 사용될 공통 열거형(Enum) 타입을 별도의 파일과 네임스페이스(EnumTypes)로 관리합니다.
- 필요성:
- 중앙 관리: 여러 클래스에서 공통으로 사용되는 상태, 유형 등을 한 곳에서 정의하여 일관성을 유지합니다.
- 가독성 및 유지보수: 어떤 타입들이 전역적으로 사용되는지 파악하기 쉽고, 수정이 필요할 때 해당 파일만 확인하면 됩니다.
- 네임 충돌 방지: 네임스페이스를 사용하여 다른 클래스나 타입과의 이름 충돌 가능성을 줄입니다.
- 자동 완성 활용: IDE의 자동 완성 기능을 통해 편리하게 타입을 참조할 수 있습니다.
-
공용 Struct 타입 관리 (Structs 네임스페이스)
- 핵심: 여러 곳에서 사용될 수 있는 복합 데이터 구조체(Struct)를 별도의 파일과 네임스페이스(Structs)로 관리합니다.
- 필요성: Enum과 유사하게, 공통 데이터 구조를 중앙에서 관리하여 재사용성을 높이고 일관성을 확보합니다.
- 주의사항:
- [Serializable] 어트리뷰트는 유니티 인스펙터에서 해당 구조체 필드를 노출시키거나 직렬화가 필요할 때 사용합니다. 모든 구조체에 필수적인 것은 아니며, 사용 목적에 맞게 적용해야 합니다.
- Struct는 값 타입(Value Type)이므로, 크기가 크거나 자주 복사될 경우 성능 저하를 유발할 수 있습니다. 작은 데이터 묶음에 사용하는 것이 일반적입니다. (클래스와의 차이점 고려)
- 유틸리티 함수 관리 (Utils 클래스)
- 핵심: 프로젝트 전반에서 재사용될 수 있는 범용 함수들을 정적(static) 클래스 및 정적 메서드로 정의하여 관리합니다.
- 필요성:
- 코드 재사용: 특정 로직에 종속되지 않는 일반적인 기능(시간 조절, ID 생성, 수학 계산 등)을 한 곳에 모아 중복 코드를 방지합니다.
- 접근 용이성: 클래스 인스턴스화 없이 Utils.MethodName() 형태로 어디서든 쉽게 호출 가능합니다.
- 캡슐화: 유틸리티 함수들을 논리적으로 그룹화하여 관리합니다.
- 설계 원칙:
- static class와 static 메서드로 구성하여 인스턴스화 없이 사용합니다.
- 특정 게임 로직이나 데이터에 대한 의존성을 최소화하여 범용성을 유지합니다.
- 특정 타입에 대한 확장 기능은 C#의 확장 메서드(Extension Methods)를 고려해볼 수도 있습니다.
- 전역 상수 및 설정값 관리 (Globals 클래스)
- 핵심: 게임 전체에서 사용되며, 런타임 중에 변하지 않는 상수 값(특히 문자열, 고정 숫자 등)을 정적(static) 클래스와 const 또는 static readonly 필드로 관리합니다.
- 필요성:
- 오타 방지 및 일관성: 문자열 리터럴(e.g., 레이어 이름, 태그, 애니메이션 파라미터 이름) 등을 상수로 관리하여 오타로 인한 버그를 줄이고 일관성을 유지합니다.
- 중앙 관리: 전역 설정값을 한 곳에서 관리하여 변경이 필요할 때 용이합니다.
- 가독성: Globals.LayerName.UI와 같이 의미 있는 이름으로 값에 접근하여 코드 가독성을 높입니다.
- 설계 원칙:
- 불변성(Immutability): const(컴파일 타임 상수) 또는 static readonly(런타임 초기화 후 불변)를 사용하여 런타임 중 값이 변경되지 않도록 보장합니다.
- Mutable 전역 변수 지양: 런타임 중에 값이 변할 수 있는 전역 변수(단순 public static)는 상태 관리를 복잡하게 만들고 예측 불가능한 버그를 유발할 수 있으므로, 사용을 최대한 피하거나 신중하게 접근해야 합니다. (싱글턴 패턴이나 다른 상태 관리 메커니즘 고려)