Files

638 lines
40 KiB
C#

#nullable enable
using Cysharp.Threading.Tasks;
using System; // System.Type 사용을 위해 추가
using System.Threading;
using UnityEngine;
using UVC.Log;
using UVC.Util;
namespace UVC.UI.Modal
{
/// <summary>
/// ✨짠! 특별한 알림 상자를 보여주는 마법사, Modal 클래스예요! ✨
/// 게임을 하다가 갑자기 "레벨 업!" 메시지가 뜨거나, "정말 게임을 끌 건가요?" 하고 물어보는 창 있죠?
/// 그런 창들을 '모달'이라고 불러요. Modal 클래스는 이런 모달 창을 쉽게 만들고 보여줄 수 있게 도와준답니다.
/// 마치 요술봉처럼, 필요할 때 "모달 나와라, 뚝딱!" 하고 명령하면 화면에 알림 상자를 뿅! 하고 나타나게 할 수 있어요.
/// 그리고 다 봤으면 "모달 들어가라, 뿅!" 하고 사라지게 할 수도 있죠.
/// </summary>
public static class Modal
{
/// <summary>
/// 📦 현재 열려있는 모달의 내용물(ModalContent)을 가리키는 포인터예요.
/// 모달이 열릴 때 설정되고, 닫힐 때 null이 됩니다.
/// OnClose 호출 시 사용됩니다.
/// </summary>
private static ModalContent currentContent;
/// <summary>
/// 🧙‍♂️ 현재 화면에 떠 있는 모달 창 자체를 가리키는 비밀 포인터예요.
/// 모달이 열리면 여기에 그 모달 창이 저장되고, 닫히면 null(없음)이 돼요.
/// 한 번에 하나의 모달만 보여주기 위해 이 포인터를 사용해요.
/// </summary>
private static GameObject currentModalInstance;
/// <summary>
/// 🛡️ 모달 창이 뜰 때 뒤에 있는 다른 버튼들을 누르지 못하게 막아주는 '투명 방패'예요.
/// 이 방패도 모달이 열릴 때 나타났다가, 닫히면 사라져요.
/// </summary>
private static GameObject currentBlockerInstance;
/// <summary>
/// 📜 모달 창이 "네!" 또는 "아니오!" 같은 대답을 받을 때까지 기다리는 '약속 증서'예요.
/// UniTaskCompletionSource의 줄임말인 tcs는 'Task Completion Source'의 약자랍니다.
/// 모달이 열릴 때 이 약속 증서가 만들어지고, 사용자가 버튼을 누르면 여기에 결과가 적혀요.
/// </summary>
private static IUniTaskSource activeTcs;
/// <summary>
/// 🏷️ 모달이 돌려줄 대답의 종류(타입)를 기억하는 '이름표'예요.
/// 예를 들어, '예/아니오' 질문이면 bool 타입(참/거짓)이라는 이름표가 붙어요.
/// </summary>
private static Type activeResultType;
/// <summary>
/// 🖼️ 모달 뒤에 깔릴 기본 '투명 방패' 디자인 파일이 어디 있는지 알려주는 '주소'예요.
/// 특별히 다른 방패를 쓰고 싶다고 말하지 않으면 이 기본 방패를 사용해요.
/// </summary>
private const string DefaultBlockerPrefabPath = "Prefabs/UI/Modal/ModalBlocker";
private static GameObject? blockerPrefabObj;
/// <summary>
/// ✨ 모달아, 열려라! ✨
/// 이 마법 주문을 외치면 화면에 새로운 알림 상자(모달)가 뿅! 하고 나타나요.
/// 어떤 내용을 보여줄지, 버튼은 어떻게 할지 미리 정해서 알려줘야 해요.
///
/// 예를 들어, "정말 게임을 종료할까요?" 라는 질문과 함께 [예], [아니오] 버튼이 있는 모달을 보여주고 싶다고 해봐요.
/// 이럴 때 이 Open 마법을 사용하면 된답니다!
///
/// 모달이 닫힐 때 어떤 대답을 했는지 (예: '예' 버튼을 눌렀는지, '아니오' 버튼을 눌렀는지) 알려줄 수도 있어요.
/// 그 대답의 종류를 <typeparamref name="T"/> 여기에 적어주면 돼요. 예를 들어, <c>bool</c>이라고 적으면 '참' 또는 '거짓'으로 대답을 받을 수 있어요.
/// </summary>
/// <typeparam name="T">모달이 닫힐 때 돌려받을 대답의 종류예요. 예를 들어, '예'/'아니오' 선택은 bool 타입으로 받을 수 있어요.</typeparam>
/// <param name="content">모달에 보여줄 제목, 메시지, 버튼 모양 등을 정한 '모달 내용물 꾸러미'예요.</param>
/// <param name="blockerPrefabPath">모달이 뜰 때 뒤에 있는 화면을 살짝 가려주는 '가림막'의 디자인 파일 경로예요. 안 써주면 기본 가림막을 사용해요.</param>
/// <returns>모달이 닫힐 때 사용자가 선택한 결과(대답)를 돌려줘요. 예를 들어, '예' 버튼을 누르면 true를 돌려줄 수 있어요.</returns>
/// <example>
/// <code>
/// // "정말 게임을 종료할까요?" 모달을 열고, 사용자의 대답(true 또는 false)을 기다리는 예시예요.
/// async UniTaskVoid ShowExitConfirmModal()
/// {
/// // 1. 모달에 어떤 내용을 보여줄지 정해요.
/// // "MyConfirmModalPrefab" 부분에는 실제 만들어둔 모달 프리팹 파일의 경로를 적어주세요.
/// var myModalContent = new ModalContent("Prefabs/UI/MyConfirmModalPrefab")
/// {
/// Title = "게임 종료",
/// Message = "정말로 게임을 종료하시겠어요? 🥺",
/// ConfirmButtonText = "네! 끌래요",
/// CancelButtonText = "아니요! 더 할래요"
/// };
///
/// // 2. Modal.Open 마법으로 모달을 열어요! 사용자가 버튼을 누를 때까지 기다렸다가, 그 결과를 알려줘요.
/// // 여기서는 사용자가 '네! 끌래요'를 누르면 true, '아니요! 더 할래요'나 닫기 버튼을 누르면 false를 돌려받기로 약속했어요(<bool>).
/// bool userSaidYes = await Modal.Open<bool>(myModalContent);
///
/// // 3. 사용자의 대답에 따라 다른 행동을 해요.
/// if (userSaidYes)
/// {
/// Debug.Log("흑흑, 게임을 종료합니다... 다음에 또 만나요! 👋");
/// // Application.Quit(); // 진짜로 게임을 끄는 코드
/// }
/// else
/// {
/// Debug.Log("야호! 게임을 계속합니다! 🥳");
/// }
/// }
/// </code>
/// </example>
public static async UniTask<T> Open<T>(ModalContent content, string blockerPrefabPath = DefaultBlockerPrefabPath)
{
// 📜 이야기: 모달을 열기 전에, 이미 다른 모달이 열려있는지 확인해요.
// 만약 그렇다면, "앗! 이미 다른 모달이 열려있어요!"라고 알려주고 새 모달은 열지 않아요.
// 한 번에 하나의 모달만 보여주는 것이 규칙이거든요!
if (currentModalInstance != null)
{
ULog.Warning("[Modal] 다른 모달이 이미 열려있습니다. 새 모달을 열기 전에 기존 모달을 닫아주세요.");
return default(T); // 기본값(예: bool이면 false, 숫자면 0)을 돌려주고 끝내요.
}
// 📜 이야기: 새 모달을 위한 '약속 증서(tcs)'를 만들어요.
// 이 증서는 나중에 사용자가 버튼을 누르면 그 결과를 기록할 거예요.
// 그리고 이 증서와 결과 타입을 마법사의 비밀 도구함에 잘 보관해요.
var tcs = new UniTaskCompletionSource<T>();
activeTcs = tcs;
activeResultType = typeof(T);
currentContent = content; // 현재 content 저장
// --- 투명 방패(Blocker) 준비 ---
CanvasGroup blockerCanvasGroup = null;
// 📜 이야기: '투명 방패' 디자인 파일을 불러와요. Resources.LoadAsync는 비동기(기다리지 않고 다음 일 하기)로 파일을 불러오는 마법이에요.
if (blockerPrefabObj == null)
{
blockerPrefabObj = await Resources.LoadAsync<GameObject>(blockerPrefabPath) as GameObject;
}
if (blockerPrefabObj != null)
{
// 화면에서 가장 큰 그림판(Canvas)을 찾아서 그 위에 방패를 놓을 거예요.
Canvas mainCanvasForBlocker = CanvasUtil.GetOrCreate("ModalCanvas");
if (mainCanvasForBlocker != null)
{
// 방패를 복제해서(Instantiate) 그림판 위에 놓고, 가장 위로 오도록 순서를 조정해요.
currentBlockerInstance = UnityEngine.Object.Instantiate(blockerPrefabObj, mainCanvasForBlocker.transform);
currentBlockerInstance.transform.SetAsLastSibling();
// 방패가 부드럽게 나타나도록 CanvasGroup 컴포넌트를 사용해요. 없으면 새로 추가!
blockerCanvasGroup = currentBlockerInstance.GetComponent<CanvasGroup>();
if (blockerCanvasGroup == null) blockerCanvasGroup = currentBlockerInstance.AddComponent<CanvasGroup>();
blockerCanvasGroup.alpha = 0f;// 처음엔 완전히 투명하게
_ = FadeUI(blockerCanvasGroup, 0.7f, 0.3f, true);// 0.3초 동안 서서히 나타나게 (투명도 70%)
}
else ULog.Error("[Modal] UIBlocker를 표시할 Canvas를 찾을 수 없습니다."); // 그림판을 못 찾으면 에러!
}
else ULog.Warning($"[Modal] UIBlocker 프리팹을 다음 경로에서 찾을 수 없습니다: {blockerPrefabPath}");
// --- 모달 창(Modal) 준비 ---
// 📜 이야기: 이제 진짜 모달 창 디자인 파일을 불러올 차례예요. 이것도 비동기로!
GameObject? modalPrefabObj = await Resources.LoadAsync<GameObject>(currentContent.PrefabPath) as GameObject;
if (modalPrefabObj == null)
{
ULog.Error($"[Modal] 모달 프리팹을 다음 경로에서 찾을 수 없습니다: {currentContent.PrefabPath}");
await CleanupCurrentModalResources(currentContent, null, false); // 열려던 것들(방패 등)을 깨끗이 치우고,
tcs.TrySetResult(default(T)); // 약속 증서에는 "실패했어요" (기본값) 라고 적어요.
return await tcs.Task; // 그리고 결과를 돌려주고 끝.
}
// 모달 창도 가장 큰 그림판 위에 놓을 거예요.
Canvas mainCanvasForModal = CanvasUtil.GetOrCreate("ModalCanvas");
if (mainCanvasForModal == null) // 그림판을 못 찾으면,
{
ULog.Error("[Modal] 모달을 표시할 Canvas를 찾을 수 없습니다.");
await CleanupCurrentModalResources(currentContent, null, false); // 마찬가지로 정리하고,
tcs.TrySetResult(default(T)); // 실패 기록.
return await tcs.Task; // 결과 돌려주고 끝.
}
// 모달 창을 복제해서 그림판 위에 놓고, 가장 위로 오도록 순서를 조정해요. (방패보다도 위!)
currentModalInstance = UnityEngine.Object.Instantiate(modalPrefabObj, mainCanvasForModal.transform);
currentModalInstance.transform.SetAsLastSibling();
// 📜 이야기: 모달 창에는 'ModalView'라는 특별한 부품이 붙어있어야 해요.
// 이 부품이 모달 창의 글자, 버튼 등을 관리하거든요.
ModalView modalView = currentModalInstance.GetComponent<ModalView>();
if (modalView == null) // ModalView 부품이 없다면,
{
ULog.Error($"[Modal] 모달 프리팹에 ModalView 컴포넌트가 없습니다: {currentContent.PrefabPath}");
await CleanupCurrentModalResources(currentContent, null, false); // 정리하고,
tcs.TrySetResult(default(T)); // 실패 기록.
return await tcs.Task; // 결과 돌려주고 끝.
}
// 📜 이야기: 만약 모달 창이 (우리가 모르는 사이에) 갑자기 사라지면 어떻게 될까요?
// 그런 상황에 대비해서, 모달 창이 파괴될 때 자동으로 "취소"된 것처럼 처리하도록 등록해둬요.
// GetCancellationTokenOnDestroy()는 "이 게임 오브젝트가 파괴되면 알려줘!"라는 신호예요.
var modalDestroyToken = currentModalInstance.GetCancellationTokenOnDestroy();
modalDestroyToken.RegisterWithoutCaptureExecutionContext(async () =>
{
// 이 코드는 모달 인스턴스가 파괴될 때 실행돼요.
// 만약 우리가 아직 결과를 기다리고 있는(Pending) 모달이었다면,
if (Modal.activeTcs == tcs && tcs.Task.Status == UniTaskStatus.Pending)
{
ULog.Debug("[Modal] 활성 모달 인스턴스가 외부에서 파괴되어 취소로 처리합니다.");
// 파괴된 모달에서 ModalView를 가져오려고 시도해요 (없을 수도 있지만).
ModalView? viewOnDestroy = currentModalInstance != null ? currentModalInstance.GetComponent<ModalView>() : null;
// 그리고 "외부에서 파괴됐으니 취소할게요" 라고 알리면서 정리해요.
await CleanupCurrentModalResources(currentContent, viewOnDestroy, false, true, tcs, typeof(T));
}
});
// 📜 이야기: ModalView가 UI를 설정하도록 OnOpen 호출 전에 버튼 리스너만 설정합니다.
// ModalView.OnOpen 내부에서 content를 기반으로 title, message, 버튼 텍스트/활성화 등을 설정합니다.
// --- 버튼 리스너 설정 ---
// ModalView의 버튼 객체에 Modal 시스템의 액션을 연결합니다.
// 버튼의 텍스트나 활성화 상태는 ModalView.OnOpen에서 content를 기반으로 설정됩니다.
SetupButtonClickListeners(modalView, currentContent, tcs);
// 📜 이야기: 모달 창이 화면에 나타나기 직전에, ModalContent와 ModalView에게 "이제 열릴 거야!"라고 순서대로 알려줘요.
if (content != null) await currentContent.OnOpen();
await modalView.OnOpen(currentContent); // ModalView가 content를 기반으로 UI를 최종 구성합니다.
// 📜 이야기: 모든 준비가 끝났어요! 이제 사용자가 버튼을 누를 때까지 기다려요.
// tcs.Task는 '약속 증서'에 결과가 적힐 때까지 기다리는 마법이에요.
return await tcs.Task;
}
/// <summary>
/// ModalView의 버튼들에 클릭 리스너를 설정합니다.
/// 버튼의 텍스트나 활성화 상태는 ModalView.OnOpen에서 처리됩니다.
/// </summary>
private static void SetupButtonClickListeners<T>(ModalView modalView, ModalContent content, UniTaskCompletionSource<T> tcs)
{
if (modalView.confirmButton != null)
{
// 기존 리스너 제거 후 새 리스너 추가
modalView.confirmButton.onClick.RemoveAllListeners();
// content.ShowConfirmButton 여부는 ModalView.OnOpen에서 버튼 자체의 활성화로 처리
modalView.confirmButton.onClick.AddListener(() => _ = HandleModalActionAsync(content, tcs, true, modalView, "confirm"));
}
if (modalView.cancelButton != null)
{
modalView.cancelButton.onClick.RemoveAllListeners();
modalView.cancelButton.onClick.AddListener(() => _ = HandleModalActionAsync(content, tcs, false, modalView, "close"));
}
if (modalView.closeButton != null)
{
modalView.closeButton.onClick.RemoveAllListeners();
modalView.closeButton.onClick.AddListener(() => _ = HandleModalActionAsync(content, tcs, false, modalView, "close"));
}
}
/// <summary>
/// 🧑‍⚖️ 모달 버튼 클릭 판정 조수예요! 사용자가 모달의 버튼(확인, 취소, 닫기) 중 하나를 누르면 호출돼요.
/// 어떤 버튼을 눌렀는지에 따라 모달을 닫고 결과를 처리해요.
/// </summary>
/// <typeparam name="T">모달이 돌려줄 결과의 타입이에요.</typeparam>
/// <param name="tcs">결과를 기록할 '약속 증서'예요.</param>
/// <param name="isConfirmAction">'확인' 버튼을 눌렀으면 true, 그 외 (취소, 닫기)는 false예요.</param>
/// <param name="modalViewContext">현재 사용 중인 모달의 ModalView예요.</param>
private static async UniTaskVoid HandleModalActionAsync<T>(
ModalContent content,
UniTaskCompletionSource<T> tcs,
bool isConfirmAction,
ModalView modalViewContext,
string buttonType)
{
// 📜 이야기: 이 함수는 사용자가 버튼을 눌렀을 때 실행돼요.
// 그런데 만약 이전에 처리하던 약속 증서(activeTcs)와 지금 받은 증서(tcs)가 다르거나,
// 모달 창(currentModalInstance)이나 모달 뷰(modalViewContext)가 없다면, 뭔가 잘못된 상황이에요.
// 이럴 때는 아무것도 하지 않고 조용히 돌아가요. (예: 모달이 이미 닫히고 있는 중일 수 있어요)
if (tcs != activeTcs || currentModalInstance == null || modalViewContext == null)
{
return;
}
// 📜 이야기: 사용자가 버튼을 하나 눌렀으니, 다른 버튼들은 잠깐 못 누르게 막아요. (실수로 두 번 누르는 것 방지)
modalViewContext.SetAllButtonsInteractable(false);
if(buttonType == "confirm")
{
modalViewContext.OnConfirmButtonClicked();
}
else if (buttonType == "cancel")
{
modalViewContext.OnCancelButtonClicked();
}
else if (buttonType == "close")
{
modalViewContext.OnCloseButtonClicked();
}
// 📜 이야기: 이제 모달을 닫고 뒷정리를 할 시간이에요!
// CleanupCurrentModalResources 조수에게 "이 모달 뷰를 사용했고, 사용자는 '확인'(또는 '취소')을 눌렀어요" 라고 알려주며 뒷정리를 부탁해요.
// 이 뒷정리 과정에서 '약속 증서'에 최종 결과가 기록될 거예요.
await CleanupCurrentModalResources(content, modalViewContext, isConfirmAction, false, tcs, typeof(T));
}
/// <summary>
/// 🧹 모달 뒷정리 전문 조수예요! 모달 창과 투명 방패를 없애고, '약속 증서'에 결과를 적어줘요.
/// 모달이 정상적으로 닫힐 때 (버튼 클릭) 또는 예기치 않게 파괴되었을 때 모두 이 조수가 마무리해요.
/// </summary>
/// <param name="content">닫으려는 모달의 내용물(ModalContent)이에요. OnClose 호출 및 결과 반환에 사용될 수 있어요.</param>
/// <param name="modalViewToClose">닫으려는 모달의 ModalView예요. 결과값을 가져올 때 사용될 수 있어요.</param>
/// <param name="confirmOrCancel">사용자가 '확인'을 눌렀으면 true, '취소'나 '닫기'를 눌렀으면 false예요.</param>
/// <param name="wasExternallyDestroyed">모달이 버튼 클릭이 아니라 외부 요인으로 파괴되었으면 true예요.</param>
/// <param name="tcsToResolve">결과를 기록할 '약속 증서'예요. 지정하지 않으면 현재 활성화된 증서(activeTcs)를 사용해요.</param>
/// <param name="resultTypeForTcs">결과의 타입이에요. 지정하지 않으면 현재 활성화된 타입(activeResultType)을 사용해요.</param>
private static async UniTask CleanupCurrentModalResources(
ModalContent content,
ModalView modalViewToClose, // ModalView 인스턴스 전달
bool confirmOrCancel,
bool wasExternallyDestroyed = false,
IUniTaskSource tcsToResolve = null,
Type resultTypeForTcs = null)
{
// 📜 이야기: 뒷정리를 시작하기 전에, 지금 없애야 할 모달 창과 방패를 기억해둬요.
// 왜냐하면 뒷정리하는 동안 currentModalInstance 같은 전역 변수들이 null로 바뀔 거라서,
// 미리 지역 변수(local variable)에 저장해두지 않으면 나중에 어떤 걸 없애야 할지 헷갈릴 수 있거든요.
var blockerInstanceToDestroy = currentBlockerInstance;
var modalInstanceToDestroy = currentModalInstance; // 이 시점의 currentModalInstance 사용
// 📜 이야기: 만약 이 함수를 부를 때 '약속 증서'나 '결과 타입'을 따로 알려주지 않았다면,
// 마법사의 비밀 도구함에 보관된 현재 활성화된 것들을 사용해요.
if (tcsToResolve == null) tcsToResolve = activeTcs;
if (resultTypeForTcs == null) resultTypeForTcs = activeResultType;
// 📜 이야기: 뒷정리 시에는 현재 활성화된 content를 사용합니다.
// Open 시 설정된 currentContent가 이 content와 동일해야 합니다.
ModalContent contentToClose = content ?? currentContent;
// 📜 이야기: 이제 이 모달은 끝났으니, 마법사의 비밀 도구함에서 관련 정보들을 모두 지워요.
// 이렇게 해야 다음 모달을 열 때 깨끗한 상태에서 시작할 수 있어요.
currentModalInstance = null;
currentBlockerInstance = null;
activeTcs = null;
activeResultType = null;
currentContent = null; // 현재 content 참조도 초기화
// --- 투명 방패(Blocker) 제거 ---
if (blockerInstanceToDestroy != null) // 없앨 방패가 있다면,
{
var blockerCG = blockerInstanceToDestroy.GetComponent<CanvasGroup>();
if (blockerCG != null) await FadeUI(blockerCG, 0f, 0.3f, false);
UnityEngine.Object.Destroy(blockerInstanceToDestroy);
}
// --- '약속 증서(TaskCompletionSource)'에 결과 기록 ---
// 📜 이야기: 이제 '약속 증서'에 사용자의 선택을 기록할 시간이에요.
// 이 증서에 결과가 적히면, Modal.Open 마법을 쓰고 기다리던 곳에서 그 결과를 받고 다음 일을 할 수 있게 돼요.
if (tcsToResolve != null && tcsToResolve.GetStatus(0) == UniTaskStatus.Pending) // 증서가 있고, 아직 결과가 안 적혔다면,
{
// OnClose 호출을 TrySetResult 직전에 배치
if (contentToClose != null) await contentToClose.OnClose();
ModalView viewForOnClose = modalInstanceToDestroy?.GetComponent<ModalView>() ?? modalViewToClose;
if (viewForOnClose != null)
{
await viewForOnClose.OnClose(contentToClose);
}
// 만약 결과 타입이 bool (참/거짓) 이라면,
if (resultTypeForTcs == typeof(bool))
{
// 증서를 bool 타입용으로 바꿔서 confirmOrCancel 값을 직접 적어요.
if (tcsToResolve is UniTaskCompletionSource<bool> boolCompletionSource)
{
boolCompletionSource.TrySetResult(confirmOrCancel);
}
}
else // bool이 아닌 다른 타입의 경우 ModalView.GetResult() 사용
{
object? resultFromView = null;
// modalViewToClose는 파괴되었을 수도 있으므로, modalInstanceToDestroy에서 다시 가져오거나 null 체크 강화
ModalView viewForGetResult = modalInstanceToDestroy?.GetComponent<ModalView>() ?? modalViewToClose;
if (viewForGetResult != null)
{
resultFromView = await viewForGetResult.GetResult();
}
// 📜 이야기: 이제 가져온 결과를 '약속 증서'에 적어야 하는데, 타입이 맞는지 잘 확인해야 해요.
// C#의 제네릭(Generic)이라는 기능을 사용해서 어떤 타입의 증서든 처리할 수 있게 만들어요.
// GetType().GetGenericTypeDefinition() 이 부분은 "이 증서가 UniTaskCompletionSource<T> 형태인가요?" 라고 묻는 거예요.
if (tcsToResolve.GetType().IsGenericType &&
tcsToResolve.GetType().GetGenericTypeDefinition() == typeof(UniTaskCompletionSource<>))
{
// 증서의 제네릭 타입(T)을 알아내요.
var genericArg = tcsToResolve.GetType().GetGenericArguments()[0];
if (genericArg == resultTypeForTcs) // 우리가 예상한 결과 타입과 같다면,
{
object resultToSet;
// ModalView에서 가져온 결과가 있고, 그 타입이 우리가 원하는 타입과 호환된다면,
if (resultFromView != null && resultTypeForTcs.IsAssignableFrom(resultFromView.GetType()))
{
resultToSet = resultFromView; // 그 결과를 사용해요.
}
else // 그렇지 않다면 (결과가 없거나, 타입이 안 맞으면),
{
if (resultFromView != null) // 타입은 안 맞지만 결과가 있긴 할 때 경고를 남겨요.
{
ULog.Warning($"[Modal] GetResult() 반환 타입({resultFromView.GetType()})과 모달 결과 타입({resultTypeForTcs})이 일치하지 않습니다. 기본값을 사용합니다.");
}
resultToSet = GetDefault(resultTypeForTcs); // 해당 타입의 기본값을 사용해요.
}
// 📜 이야기: TrySetGenericResultHelper라는 또 다른 조수에게 "이 증서에 이 결과를 적어줘!" 라고 부탁해요.
// 이 조수는 어떤 타입(T)의 증서든 안전하게 결과를 적는 방법을 알고 있어요. (리플렉션 사용)
var trySetResultHelperMethod = typeof(Modal).GetMethod(nameof(TrySetGenericResultHelper), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
.MakeGenericMethod(resultTypeForTcs);
trySetResultHelperMethod.Invoke(null, new object[] { tcsToResolve, resultToSet });
}
}
}
}
// --- 모달 창(Modal Instance) 제거 ---
if (modalInstanceToDestroy != null) // 없앨 모달 창이 있다면,
{
// 만약 모달이 외부 요인으로 파괴된 게 아니라면 (즉, 버튼 클릭 등으로 정상 종료된 거라면),
// 게임 세상에서 완전히 없애요. (외부에서 이미 파괴됐다면 또 없앨 필요 없으니까요)
if (!wasExternallyDestroyed)
{
UnityEngine.Object.Destroy(modalInstanceToDestroy);
}
}
}
/// <summary>
/// ✍️ 제네릭 '약속 증서'에 결과 적기 도우미예요.
/// 어떤 타입(<typeparamref name="T"/>)의 UniTaskCompletionSource든 안전하게 결과를 설정해줘요.
/// 혹시라도 타입이 안 맞아서 오류가 나면, 에러 메시지를 보여주고 기본값을 대신 넣어줘요.
/// </summary>
/// <typeparam name="T">결과의 타입이에요.</typeparam>
/// <param name="tcs">결과를 적을 IUniTaskSource (UniTaskCompletionSource<T>여야 해요).</param>
/// <param name="result">증서에 적을 실제 결과값이에요.</param>
private static void TrySetGenericResultHelper<T>(IUniTaskSource tcs, object result)
{
if (tcs is UniTaskCompletionSource<T> genericTcs) // 받은 증서가 UniTaskCompletionSource<T> 타입이 맞는지 확인!
{
try
{
// 결과를 T 타입으로 바꿔서(캐스팅) 증서에 적어요.
genericTcs.TrySetResult((T)result);
}
catch (InvalidCastException ex)
{
// 에러를 기록하고, 대신 T 타입의 기본값을 넣어줘요. (프로그램이 멈추는 것 방지)
ULog.Error($"[Modal] 결과를 {typeof(T)}로 캐스팅하는데 실패했습니다: {ex.Message}. 기본값을 사용합니다.", ex);
genericTcs.TrySetResult(default(T));
}
}
}
/// <summary>
/// ✨ 모달아, 사라져라! ✨
/// 지금 화면에 떠 있는 모달 창을 닫고 싶을 때 이 마법 주문을 사용해요.
/// 모달 창이 스르륵 사라질 거예요.
///
/// 보통은 모달 창 안에 있는 [닫기] 버튼이나 [취소] 버튼을 누르면 자동으로 닫히지만,
/// 특별한 경우에 코드로 직접 모달을 닫아야 할 때 사용할 수 있어요.
/// </summary>
/// <returns>모달이 성공적으로 닫히면 알려줘요 (특별한 값을 돌려주진 않아요).</returns>
/// <example>
/// <code>
/// // 예를 들어, 5초 뒤에 자동으로 모달을 닫고 싶을 때
/// async UniTask CloseModalAfter5Seconds()
/// {
/// // (모달이 이미 열려있다고 가정해요)
/// await UniTask.Delay(TimeSpan.FromSeconds(5)); // 5초 기다리기
/// Debug.Log("시간이 다 되었어요! 모달을 닫습니다.");
/// await Modal.Close(); // 모달 닫기 마법!
/// }
/// </code>
/// </example>
public static async UniTask Close()
{
// 📜 이야기: 닫을 모달이 없거나, 이미 처리 중인 '약속 증서'가 없다면 할 일이 없어요.
if (currentModalInstance == null && activeTcs == null) return;
// 📜 이야기: 현재 활성화된 '약속 증서'와 '결과 타입', 그리고 모달 뷰를 가져와요.
// 이 정보들은 모달을 안전하게 닫고 결과를 처리하는 데 필요해요.
IUniTaskSource tcsToClose = activeTcs;
Type resultTypeToClose = activeResultType;
ModalView view = currentModalInstance?.GetComponent<ModalView>();
ModalContent contentToClose = currentContent; // 현재 저장된 content 사용
// 모달에 버튼들이 있다면, 실수로 또 누르지 못하게 잠시 비활성화해요.
if (view != null) view.SetAllButtonsInteractable(false); // ModalView의 메서드 사용
// 📜 이야기: 지금부터 모달을 본격적으로 닫을 거니까,
// 현재 모달 인스턴스와 블로커 인스턴스를 지역 변수에 잠시 저장해둬요.
// 그리고 전역 변수들은 null로 만들어서 "지금은 열린 모달 없음!" 상태로 만들어요.
var localModalInstance = currentModalInstance;
var localBlockerInstance = currentBlockerInstance;
// currentContent는 CleanupCurrentModalResources에서 처리되므로 여기서는 local로 옮기지 않음
currentModalInstance = null;
currentBlockerInstance = null;
activeTcs = null;
activeResultType = null;
currentContent = null; // 현재 content 참조도 초기화
// 📜 이야기: 만약 닫으려는 '약속 증서'가 있고, 아직 결과가 정해지지 않았다면,
// "취소"된 것으로 처리해요. Close() 마법은 항상 '취소'로 간주하거든요.
if (tcsToClose != null && tcsToClose.GetStatus(0) == UniTaskStatus.Pending)
{
// OnClose 호출을 결과 설정 직전에 배치
if (contentToClose != null) await contentToClose.OnClose();
ModalView viewForOnClose = localModalInstance?.GetComponent<ModalView>(); // localModalInstance 사용
if (viewForOnClose != null)
{
await viewForOnClose.OnClose(contentToClose);
}
if (resultTypeToClose == typeof(bool) && tcsToClose is UniTaskCompletionSource<bool> boolTcs)
{
// 결과 타입이 bool이면 false (취소)를 설정해요.
boolTcs.TrySetResult(false);
}
// 결과 타입이 bool이 아니고, 제네릭 UniTaskCompletionSource 형태라면,
else if (resultTypeToClose != null && tcsToClose.GetType().IsGenericType && tcsToClose.GetType().GetGenericTypeDefinition() == typeof(UniTaskCompletionSource<>))
{
// 해당 타입의 기본값으로 결과를 설정해요. (예: int면 0, string이면 null)
// TrySetDefaultResultHelper 조수에게 이 일을 맡겨요.
var genericArg = tcsToClose.GetType().GetGenericArguments()[0];
if (genericArg == resultTypeToClose)
{
var trySetDefaultResultMethod = typeof(Modal).GetMethod(nameof(TrySetDefaultResultHelper), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
.MakeGenericMethod(genericArg);
trySetDefaultResultMethod.Invoke(null, new object[] { tcsToClose });
}
}
}
// --- 투명 방패(Blocker)와 모달 창(Modal Instance) 제거 ---
// 이 부분은 CleanupCurrentModalResources와 비슷하게 동작해요.
if (localBlockerInstance != null)
{
var blockerCG = localBlockerInstance.GetComponent<CanvasGroup>();
if (blockerCG != null) await FadeUI(blockerCG, 0f, 0.3f, false); // 부드럽게 사라지게
UnityEngine.Object.Destroy(localBlockerInstance); // 완전히 제거
}
if (localModalInstance != null)
{
UnityEngine.Object.Destroy(localModalInstance); // 완전히 제거
}
}
/// <summary>
/// ✍️ 제네릭 '약속 증서'에 기본값 적기 도우미예요.
/// 어떤 타입(<typeparamref name="T"/>)의 UniTaskCompletionSource든 해당 타입의 기본값(default)으로 결과를 설정해줘요.
/// Close() 함수에서 모달을 강제로 닫을 때 사용돼요.
/// </summary>
/// <typeparam name="T">결과의 타입이에요.</typeparam>
/// <param name="tcs">결과를 적을 IUniTaskSource (UniTaskCompletionSource<T>여야 해요).</param>
private static void TrySetDefaultResultHelper<T>(IUniTaskSource tcs)
{
if (tcs is UniTaskCompletionSource<T> genericTcs) // 받은 증서가 UniTaskCompletionSource<T> 타입이 맞는지 확인!
{
genericTcs.TrySetResult(default(T)); // T 타입의 기본값 (예: bool은 false, int는 0, 클래스는 null)을 설정해요.
}
}
/// <summary>
/// ✨ UI를 부드럽게 나타나거나 사라지게 하는 마법이에요 (페이드 효과).
/// CanvasGroup의 투명도(alpha)를 조절해서 천천히 보이거나 안 보이게 만들어요.
/// </summary>
/// <param name="canvasGroup">투명도를 조절할 CanvasGroup 컴포넌트예요.</param>
/// <param name="targetAlpha">목표 투명도예요. (0.0 = 완전 투명, 1.0 = 완전 불투명)</param>
/// <param name="duration">페이드 효과에 걸리는 시간(초)이에요.</param>
/// <param name="fadeIn">true면 나타나게(페이드 인), false면 사라지게(페이드 아웃) 해요.</param>
private static async UniTask FadeUI(CanvasGroup canvasGroup, float targetAlpha, float duration, bool fadeIn)
{
if (canvasGroup == null) return; // CanvasGroup이 없으면 아무것도 안 해요.
// 📜 이야기: 이 CanvasGroup이 혹시라도 중간에 사라지면(파괴되면) 페이드 효과를 멈춰야 해요.
// 그래서 GetCancellationTokenOnDestroy()로 "파괴되면 알려줘!" 신호를 받아둬요.
CancellationToken cancellationToken;
try
{
cancellationToken = canvasGroup.GetCancellationTokenOnDestroy();
}
catch (Exception ex) // 아주 드물게 이 과정에서 오류가 날 수도 있어서 예외 처리를 해줘요.
{
ULog.Warning($"[Modal] CanvasGroup에 대한 취소 토큰을 가져오는 중 오류 발생: {ex.Message}", ex);
return;
}
float startAlpha = canvasGroup.alpha; // 현재 투명도에서 시작
float time = 0; // 시간 기록 변수
if (fadeIn) // 나타나는 효과(페이드 인)라면,
{
// 사용자가 이 UI와 상호작용(클릭 등)할 수 있게 하고, 마우스 클릭을 막지 않도록 설정해요.
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
}
// 📜 이야기: duration(지정된 시간) 동안 투명도를 조금씩 바꿔요.
while (time < duration)
{
// 만약 중간에 "파괴 신호"가 오거나 CanvasGroup이 사라지면 즉시 멈춰요.
if (cancellationToken.IsCancellationRequested || canvasGroup == null) return;
// Mathf.Lerp는 두 값 사이를 부드럽게 변화시키는 마법이에요.
// time / duration은 현재 진행률 (0.0 ~ 1.0)을 나타내요.
canvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, time / duration);
time += Time.deltaTime; // 지난 시간을 더해주고,
// 다음 프레임까지 잠깐 기다렸다가 다시 실행해요. (UniTask.Yield)
// SuppressCancellationThrow는 "파괴 신호"가 와도 오류를 내지 말고 조용히 멈추라는 뜻이에요.
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken).SuppressCancellationThrow();
}
// 시간이 다 되었거나 중간에 멈췄을 때, 최종적으로 목표 투명도로 설정해요.
if (cancellationToken.IsCancellationRequested || canvasGroup == null) return;
canvasGroup.alpha = targetAlpha;
if (!fadeIn) // 사라지는 효과(페이드 아웃)였다면,
{
// 이제 이 UI는 보이지 않으니 상호작용도 막고, 마우스 클릭도 막아요.
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
}
}
/// <summary>
/// 🎁 특정 타입의 '기본값'을 돌려주는 작은 도우미예요.
/// 값 타입(struct, int, bool 등)이면 Activator.CreateInstance로 기본 인스턴스를 만들고,
/// 참조 타입(class)이면 null을 돌려줘요.
/// </summary>
/// <param name="type">기본값을 알고 싶은 타입 정보예요.</param>
/// <returns>해당 타입의 기본값이에요.</returns>
private static object GetDefault(Type type)
{
if (type == null) return null; // 타입 정보가 없으면 null
if (type.IsValueType) return Activator.CreateInstance(type); // 값 타입이면 기본 인스턴스 생성 (예: int는 0, bool은 false)
return null; // 참조 타입이면 null
}
}
}