#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 { /// /// ✨짠! 특별한 알림 상자를 보여주는 마법사, Modal 클래스예요! ✨ /// 게임을 하다가 갑자기 "레벨 업!" 메시지가 뜨거나, "정말 게임을 끌 건가요?" 하고 물어보는 창 있죠? /// 그런 창들을 '모달'이라고 불러요. Modal 클래스는 이런 모달 창을 쉽게 만들고 보여줄 수 있게 도와준답니다. /// 마치 요술봉처럼, 필요할 때 "모달 나와라, 뚝딱!" 하고 명령하면 화면에 알림 상자를 뿅! 하고 나타나게 할 수 있어요. /// 그리고 다 봤으면 "모달 들어가라, 뿅!" 하고 사라지게 할 수도 있죠. /// public static class Modal { /// /// 📦 현재 열려있는 모달의 내용물(ModalContent)을 가리키는 포인터예요. /// 모달이 열릴 때 설정되고, 닫힐 때 null이 됩니다. /// OnClose 호출 시 사용됩니다. /// private static ModalContent currentContent; /// /// 🧙‍♂️ 현재 화면에 떠 있는 모달 창 자체를 가리키는 비밀 포인터예요. /// 모달이 열리면 여기에 그 모달 창이 저장되고, 닫히면 null(없음)이 돼요. /// 한 번에 하나의 모달만 보여주기 위해 이 포인터를 사용해요. /// private static GameObject currentModalInstance; /// /// 🛡️ 모달 창이 뜰 때 뒤에 있는 다른 버튼들을 누르지 못하게 막아주는 '투명 방패'예요. /// 이 방패도 모달이 열릴 때 나타났다가, 닫히면 사라져요. /// private static GameObject currentBlockerInstance; /// /// 📜 모달 창이 "네!" 또는 "아니오!" 같은 대답을 받을 때까지 기다리는 '약속 증서'예요. /// UniTaskCompletionSource의 줄임말인 tcs는 'Task Completion Source'의 약자랍니다. /// 모달이 열릴 때 이 약속 증서가 만들어지고, 사용자가 버튼을 누르면 여기에 결과가 적혀요. /// private static IUniTaskSource activeTcs; /// /// 🏷️ 모달이 돌려줄 대답의 종류(타입)를 기억하는 '이름표'예요. /// 예를 들어, '예/아니오' 질문이면 bool 타입(참/거짓)이라는 이름표가 붙어요. /// private static Type activeResultType; /// /// 🖼️ 모달 뒤에 깔릴 기본 '투명 방패' 디자인 파일이 어디 있는지 알려주는 '주소'예요. /// 특별히 다른 방패를 쓰고 싶다고 말하지 않으면 이 기본 방패를 사용해요. /// private const string DefaultBlockerPrefabPath = "Prefabs/UI/Modal/ModalBlocker"; private static GameObject? blockerPrefabObj; /// /// ✨ 모달아, 열려라! ✨ /// 이 마법 주문을 외치면 화면에 새로운 알림 상자(모달)가 뿅! 하고 나타나요. /// 어떤 내용을 보여줄지, 버튼은 어떻게 할지 미리 정해서 알려줘야 해요. /// /// 예를 들어, "정말 게임을 종료할까요?" 라는 질문과 함께 [예], [아니오] 버튼이 있는 모달을 보여주고 싶다고 해봐요. /// 이럴 때 이 Open 마법을 사용하면 된답니다! /// /// 모달이 닫힐 때 어떤 대답을 했는지 (예: '예' 버튼을 눌렀는지, '아니오' 버튼을 눌렀는지) 알려줄 수도 있어요. /// 그 대답의 종류를 여기에 적어주면 돼요. 예를 들어, bool이라고 적으면 '참' 또는 '거짓'으로 대답을 받을 수 있어요. /// /// 모달이 닫힐 때 돌려받을 대답의 종류예요. 예를 들어, '예'/'아니오' 선택은 bool 타입으로 받을 수 있어요. /// 모달에 보여줄 제목, 메시지, 버튼 모양 등을 정한 '모달 내용물 꾸러미'예요. /// 모달이 뜰 때 뒤에 있는 화면을 살짝 가려주는 '가림막'의 디자인 파일 경로예요. 안 써주면 기본 가림막을 사용해요. /// 모달이 닫힐 때 사용자가 선택한 결과(대답)를 돌려줘요. 예를 들어, '예' 버튼을 누르면 true를 돌려줄 수 있어요. /// /// /// // "정말 게임을 종료할까요?" 모달을 열고, 사용자의 대답(true 또는 false)을 기다리는 예시예요. /// async UniTaskVoid ShowExitConfirmModal() /// { /// // 1. 모달에 어떤 내용을 보여줄지 정해요. /// // "MyConfirmModalPrefab" 부분에는 실제 만들어둔 모달 프리팹 파일의 경로를 적어주세요. /// var myModalContent = new ModalContent("Prefabs/UI/MyConfirmModalPrefab") /// { /// Title = "게임 종료", /// Message = "정말로 게임을 종료하시겠어요? 🥺", /// ConfirmButtonText = "네! 끌래요", /// CancelButtonText = "아니요! 더 할래요" /// }; /// /// // 2. Modal.Open 마법으로 모달을 열어요! 사용자가 버튼을 누를 때까지 기다렸다가, 그 결과를 알려줘요. /// // 여기서는 사용자가 '네! 끌래요'를 누르면 true, '아니요! 더 할래요'나 닫기 버튼을 누르면 false를 돌려받기로 약속했어요(). /// bool userSaidYes = await Modal.Open(myModalContent); /// /// // 3. 사용자의 대답에 따라 다른 행동을 해요. /// if (userSaidYes) /// { /// Debug.Log("흑흑, 게임을 종료합니다... 다음에 또 만나요! 👋"); /// // Application.Quit(); // 진짜로 게임을 끄는 코드 /// } /// else /// { /// Debug.Log("야호! 게임을 계속합니다! 🥳"); /// } /// } /// /// public static async UniTask Open(ModalContent content, string blockerPrefabPath = DefaultBlockerPrefabPath) { // 📜 이야기: 모달을 열기 전에, 이미 다른 모달이 열려있는지 확인해요. // 만약 그렇다면, "앗! 이미 다른 모달이 열려있어요!"라고 알려주고 새 모달은 열지 않아요. // 한 번에 하나의 모달만 보여주는 것이 규칙이거든요! if (currentModalInstance != null) { ULog.Warning("[Modal] 다른 모달이 이미 열려있습니다. 새 모달을 열기 전에 기존 모달을 닫아주세요."); return default(T); // 기본값(예: bool이면 false, 숫자면 0)을 돌려주고 끝내요. } // 📜 이야기: 새 모달을 위한 '약속 증서(tcs)'를 만들어요. // 이 증서는 나중에 사용자가 버튼을 누르면 그 결과를 기록할 거예요. // 그리고 이 증서와 결과 타입을 마법사의 비밀 도구함에 잘 보관해요. var tcs = new UniTaskCompletionSource(); activeTcs = tcs; activeResultType = typeof(T); currentContent = content; // 현재 content 저장 // --- 투명 방패(Blocker) 준비 --- CanvasGroup blockerCanvasGroup = null; // 📜 이야기: '투명 방패' 디자인 파일을 불러와요. Resources.LoadAsync는 비동기(기다리지 않고 다음 일 하기)로 파일을 불러오는 마법이에요. if (blockerPrefabObj == null) { blockerPrefabObj = await Resources.LoadAsync(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(); if (blockerCanvasGroup == null) blockerCanvasGroup = currentBlockerInstance.AddComponent(); 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(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(); 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() : 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; } /// /// ModalView의 버튼들에 클릭 리스너를 설정합니다. /// 버튼의 텍스트나 활성화 상태는 ModalView.OnOpen에서 처리됩니다. /// private static void SetupButtonClickListeners(ModalView modalView, ModalContent content, UniTaskCompletionSource 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")); } } /// /// 🧑‍⚖️ 모달 버튼 클릭 판정 조수예요! 사용자가 모달의 버튼(확인, 취소, 닫기) 중 하나를 누르면 호출돼요. /// 어떤 버튼을 눌렀는지에 따라 모달을 닫고 결과를 처리해요. /// /// 모달이 돌려줄 결과의 타입이에요. /// 결과를 기록할 '약속 증서'예요. /// '확인' 버튼을 눌렀으면 true, 그 외 (취소, 닫기)는 false예요. /// 현재 사용 중인 모달의 ModalView예요. private static async UniTaskVoid HandleModalActionAsync( ModalContent content, UniTaskCompletionSource 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)); } /// /// 🧹 모달 뒷정리 전문 조수예요! 모달 창과 투명 방패를 없애고, '약속 증서'에 결과를 적어줘요. /// 모달이 정상적으로 닫힐 때 (버튼 클릭) 또는 예기치 않게 파괴되었을 때 모두 이 조수가 마무리해요. /// /// 닫으려는 모달의 내용물(ModalContent)이에요. OnClose 호출 및 결과 반환에 사용될 수 있어요. /// 닫으려는 모달의 ModalView예요. 결과값을 가져올 때 사용될 수 있어요. /// 사용자가 '확인'을 눌렀으면 true, '취소'나 '닫기'를 눌렀으면 false예요. /// 모달이 버튼 클릭이 아니라 외부 요인으로 파괴되었으면 true예요. /// 결과를 기록할 '약속 증서'예요. 지정하지 않으면 현재 활성화된 증서(activeTcs)를 사용해요. /// 결과의 타입이에요. 지정하지 않으면 현재 활성화된 타입(activeResultType)을 사용해요. 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(); 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() ?? modalViewToClose; if (viewForOnClose != null) { await viewForOnClose.OnClose(contentToClose); } // 만약 결과 타입이 bool (참/거짓) 이라면, if (resultTypeForTcs == typeof(bool)) { // 증서를 bool 타입용으로 바꿔서 confirmOrCancel 값을 직접 적어요. if (tcsToResolve is UniTaskCompletionSource boolCompletionSource) { boolCompletionSource.TrySetResult(confirmOrCancel); } } else // bool이 아닌 다른 타입의 경우 ModalView.GetResult() 사용 { object? resultFromView = null; // modalViewToClose는 파괴되었을 수도 있으므로, modalInstanceToDestroy에서 다시 가져오거나 null 체크 강화 ModalView viewForGetResult = modalInstanceToDestroy?.GetComponent() ?? modalViewToClose; if (viewForGetResult != null) { resultFromView = await viewForGetResult.GetResult(); } // 📜 이야기: 이제 가져온 결과를 '약속 증서'에 적어야 하는데, 타입이 맞는지 잘 확인해야 해요. // C#의 제네릭(Generic)이라는 기능을 사용해서 어떤 타입의 증서든 처리할 수 있게 만들어요. // GetType().GetGenericTypeDefinition() 이 부분은 "이 증서가 UniTaskCompletionSource 형태인가요?" 라고 묻는 거예요. 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); } } } /// /// ✍️ 제네릭 '약속 증서'에 결과 적기 도우미예요. /// 어떤 타입()의 UniTaskCompletionSource든 안전하게 결과를 설정해줘요. /// 혹시라도 타입이 안 맞아서 오류가 나면, 에러 메시지를 보여주고 기본값을 대신 넣어줘요. /// /// 결과의 타입이에요. /// 결과를 적을 IUniTaskSource (UniTaskCompletionSource여야 해요). /// 증서에 적을 실제 결과값이에요. private static void TrySetGenericResultHelper(IUniTaskSource tcs, object result) { if (tcs is UniTaskCompletionSource genericTcs) // 받은 증서가 UniTaskCompletionSource 타입이 맞는지 확인! { try { // 결과를 T 타입으로 바꿔서(캐스팅) 증서에 적어요. genericTcs.TrySetResult((T)result); } catch (InvalidCastException ex) { // 에러를 기록하고, 대신 T 타입의 기본값을 넣어줘요. (프로그램이 멈추는 것 방지) ULog.Error($"[Modal] 결과를 {typeof(T)}로 캐스팅하는데 실패했습니다: {ex.Message}. 기본값을 사용합니다.", ex); genericTcs.TrySetResult(default(T)); } } } /// /// ✨ 모달아, 사라져라! ✨ /// 지금 화면에 떠 있는 모달 창을 닫고 싶을 때 이 마법 주문을 사용해요. /// 모달 창이 스르륵 사라질 거예요. /// /// 보통은 모달 창 안에 있는 [닫기] 버튼이나 [취소] 버튼을 누르면 자동으로 닫히지만, /// 특별한 경우에 코드로 직접 모달을 닫아야 할 때 사용할 수 있어요. /// /// 모달이 성공적으로 닫히면 알려줘요 (특별한 값을 돌려주진 않아요). /// /// /// // 예를 들어, 5초 뒤에 자동으로 모달을 닫고 싶을 때 /// async UniTask CloseModalAfter5Seconds() /// { /// // (모달이 이미 열려있다고 가정해요) /// await UniTask.Delay(TimeSpan.FromSeconds(5)); // 5초 기다리기 /// Debug.Log("시간이 다 되었어요! 모달을 닫습니다."); /// await Modal.Close(); // 모달 닫기 마법! /// } /// /// public static async UniTask Close() { // 📜 이야기: 닫을 모달이 없거나, 이미 처리 중인 '약속 증서'가 없다면 할 일이 없어요. if (currentModalInstance == null && activeTcs == null) return; // 📜 이야기: 현재 활성화된 '약속 증서'와 '결과 타입', 그리고 모달 뷰를 가져와요. // 이 정보들은 모달을 안전하게 닫고 결과를 처리하는 데 필요해요. IUniTaskSource tcsToClose = activeTcs; Type resultTypeToClose = activeResultType; ModalView view = currentModalInstance?.GetComponent(); 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(); // localModalInstance 사용 if (viewForOnClose != null) { await viewForOnClose.OnClose(contentToClose); } if (resultTypeToClose == typeof(bool) && tcsToClose is UniTaskCompletionSource 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(); if (blockerCG != null) await FadeUI(blockerCG, 0f, 0.3f, false); // 부드럽게 사라지게 UnityEngine.Object.Destroy(localBlockerInstance); // 완전히 제거 } if (localModalInstance != null) { UnityEngine.Object.Destroy(localModalInstance); // 완전히 제거 } } /// /// ✍️ 제네릭 '약속 증서'에 기본값 적기 도우미예요. /// 어떤 타입()의 UniTaskCompletionSource든 해당 타입의 기본값(default)으로 결과를 설정해줘요. /// Close() 함수에서 모달을 강제로 닫을 때 사용돼요. /// /// 결과의 타입이에요. /// 결과를 적을 IUniTaskSource (UniTaskCompletionSource여야 해요). private static void TrySetDefaultResultHelper(IUniTaskSource tcs) { if (tcs is UniTaskCompletionSource genericTcs) // 받은 증서가 UniTaskCompletionSource 타입이 맞는지 확인! { genericTcs.TrySetResult(default(T)); // T 타입의 기본값 (예: bool은 false, int는 0, 클래스는 null)을 설정해요. } } /// /// ✨ UI를 부드럽게 나타나거나 사라지게 하는 마법이에요 (페이드 효과). /// CanvasGroup의 투명도(alpha)를 조절해서 천천히 보이거나 안 보이게 만들어요. /// /// 투명도를 조절할 CanvasGroup 컴포넌트예요. /// 목표 투명도예요. (0.0 = 완전 투명, 1.0 = 완전 불투명) /// 페이드 효과에 걸리는 시간(초)이에요. /// true면 나타나게(페이드 인), false면 사라지게(페이드 아웃) 해요. 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; } } /// /// 🎁 특정 타입의 '기본값'을 돌려주는 작은 도우미예요. /// 값 타입(struct, int, bool 등)이면 Activator.CreateInstance로 기본 인스턴스를 만들고, /// 참조 타입(class)이면 null을 돌려줘요. /// /// 기본값을 알고 싶은 타입 정보예요. /// 해당 타입의 기본값이에요. 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 } } }