Modal 개발 완료. Toolbar 개발 중

This commit is contained in:
logonkhi
2025-06-13 17:10:58 +09:00
parent e8d52b3e90
commit 2ffe7abac6
37 changed files with 3278 additions and 466 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 788059ac67f211f42ad47ab6abbc0488
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -204,7 +204,7 @@ MonoBehaviour:
m_Calls: m_Calls:
- m_Target: {fileID: 632541408} - m_Target: {fileID: 632541408}
m_TargetAssemblyTypeName: SampleProject.AppMain, Assembly-CSharp m_TargetAssemblyTypeName: SampleProject.AppMain, Assembly-CSharp
m_MethodName: ShowAlertWithCustomConfirmText m_MethodName: ShowAlert
m_Mode: 1 m_Mode: 1
m_Arguments: m_Arguments:
m_ObjectArgument: {fileID: 0} m_ObjectArgument: {fileID: 0}
@@ -646,7 +646,7 @@ MonoBehaviour:
m_OnCullStateChanged: m_OnCullStateChanged:
m_PersistentCalls: m_PersistentCalls:
m_Calls: [] m_Calls: []
m_text: Alert.Show m_text: Confirm.Show
m_isRightToLeft: 0 m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2} m_fontAsset: {fileID: 11400000, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2}
m_sharedMaterial: {fileID: 3963494727631305252, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2} m_sharedMaterial: {fileID: 3963494727631305252, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2}
@@ -824,7 +824,6 @@ RectTransform:
- {fileID: 1694507572} - {fileID: 1694507572}
- {fileID: 19718907} - {fileID: 19718907}
- {fileID: 2037570841} - {fileID: 2037570841}
- {fileID: 1100357418}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
@@ -832,142 +831,6 @@ RectTransform:
m_AnchoredPosition: {x: 0, y: 0} m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0} m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 0} m_Pivot: {x: 0, y: 0}
--- !u!1 &502433643
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 502433644}
- component: {fileID: 502433646}
- component: {fileID: 502433645}
m_Layer: 5
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &502433644
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 502433643}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1100357418}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &502433645
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 502433643}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Alert.Show
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2}
m_sharedMaterial: {fileID: 3963494727631305252, guid: e938f39f708799f42bc6cb8f2d733c45, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 16
m_fontSizeBase: 16
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &502433646
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 502433643}
m_CullTransparentMesh: 1
--- !u!1 &632541406 --- !u!1 &632541406
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1122,139 +985,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: ec70649e0f460ce458cf6d62498ecf20, type: 3} m_Script: {fileID: 11500000, guid: ec70649e0f460ce458cf6d62498ecf20, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: m_EditorClassIdentifier:
--- !u!1 &1100357417
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1100357418}
- component: {fileID: 1100357421}
- component: {fileID: 1100357420}
- component: {fileID: 1100357419}
m_Layer: 5
m_Name: AlertButton (2)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1100357418
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1100357417}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 502433644}
m_Father: {fileID: 483439351}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -87}
m_SizeDelta: {x: 160, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1100357419
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1100357417}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1100357420}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 632541408}
m_TargetAssemblyTypeName: SampleProject.AppMain, Assembly-CSharp
m_MethodName: ShowSimpleAlert
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &1100357420
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1100357417}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1100357421
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1100357417}
m_CullTransparentMesh: 1
--- !u!1 &1101428664 --- !u!1 &1101428664
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1824,7 +1554,7 @@ GameObject:
- component: {fileID: 2037570843} - component: {fileID: 2037570843}
- component: {fileID: 2037570842} - component: {fileID: 2037570842}
m_Layer: 5 m_Layer: 5
m_Name: AlertButton (1) m_Name: ConfirmButton
m_TagString: Untagged m_TagString: Untagged
m_Icon: {fileID: 0} m_Icon: {fileID: 0}
m_NavMeshLayer: 0 m_NavMeshLayer: 0
@@ -1896,7 +1626,7 @@ MonoBehaviour:
m_Calls: m_Calls:
- m_Target: {fileID: 632541408} - m_Target: {fileID: 632541408}
m_TargetAssemblyTypeName: SampleProject.AppMain, Assembly-CSharp m_TargetAssemblyTypeName: SampleProject.AppMain, Assembly-CSharp
m_MethodName: ShowLocalizedAlert m_MethodName: ShowConfirm
m_Mode: 1 m_Mode: 1
m_Arguments: m_Arguments:
m_ObjectArgument: {fileID: 0} m_ObjectArgument: {fileID: 0}

View File

@@ -47,28 +47,22 @@ namespace SampleProject
//mqttService.Connect(); //mqttService.Connect();
} }
public async void ShowSimpleAlert() public async void ShowAlert()
{ {
Debug.Log("알림창을 엽니다...");
await Alert.Show("알림", "이것은 간단한 알림 메시지입니다."); await Alert.Show("알림", "이것은 간단한 알림 메시지입니다.");
Debug.Log("알림창이 닫혔습니다.");
}
public async void ShowAlertWithCustomConfirmText()
{
await Alert.Show("경고", "데이터를 저장할 수 없습니다.", "알겠습니다"); await Alert.Show("경고", "데이터를 저장할 수 없습니다.", "알겠습니다");
}
public async void ShowLocalizedAlert()
{
// locale.json에 다음 키들이 정의되어 있다고 가정:
// "alert_title_error": "오류", "Error"
// "alert_message_network": "네트워크 연결을 확인해주세요.", "Please check your network connection."
// "alert_button_retry": "재시도", "Retry"
await Alert.ShowLocalized("error", "error_network_not", "button_retry"); await Alert.ShowLocalized("error", "error_network_not", "button_retry");
// 또는 확인 버튼에 기본 키(modal_confirm_button)를 사용하려면: }
// await Alert.ShowLocalized("alert_title_error", "alert_message_network");
} public async void ShowConfirm()
{
bool result = await Confirm.Show("확인", "이것은 간단한 알림 메시지입니다.");
ULog.Debug($"사용자가 확인 버튼을 눌렀나요? {result}");
result = await Confirm.Show("경고", "데이터를 저장할 수 없습니다.", "알겠습니다", "아니요");
ULog.Debug($"사용자가 알림을 확인했나요? {result}");
result = await Confirm.ShowLocalized("error", "error_network_not", "button_retry", "button_cancel");
ULog.Debug($"사용자가 네트워크 오류 알림을 확인했나요? {result}");
}
} }
} }

View File

@@ -75,7 +75,7 @@ namespace UVC.Locale
{ {
if (_instance == null) if (_instance == null)
{ {
_instance = new LocalizationManager("en-US"); _instance = new LocalizationManager("ko-KR");
_instance.LoadDefaultLocalizationData(); // 기본 언어 데이터 로드 _instance.LoadDefaultLocalizationData(); // 기본 언어 데이터 로드
} }
} }

View File

@@ -1,74 +1,138 @@
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using UVC.Locale; // ConfirmButtonText의 기본값을 위해 추가 using UVC.Locale; // ConfirmButtonText의 기본값을 위해 추가
using UVC.Log; // ULog 사용 예시를 위해 추가 (필요에 따라)
namespace UVC.UI.Modal namespace UVC.UI.Modal
{ {
/// <summary> /// <summary>
/// 간단한 알림 메시지를 표시하는 정적 클래스입니다. /// 📢 간단한 알림 메시지를 화면에 보여주는 친구예요.
/// 확인 버튼만 있으며, 사용자의 확인을 기다립니다. /// 이 친구는 "확인" 버튼만 가지고 있어서, 사용자가 내용을 읽고 확인 버튼을 누를 때까지 기다려줘요.
/// 복잡한 선택 없이, 간단한 정보 전달이나 경고를 보여줄 때 사용하면 좋아요.
/// </summary> /// </summary>
/// <example>
/// <code>
/// // 가장 기본적인 알림창 사용법
/// async UniTask ShowMyFirstAlert()
/// {
/// await Alert.Show("알림", "게임 데이터가 성공적으로 저장되었습니다!");
/// ULog.Debug("사용자가 알림을 확인했습니다.");
/// }
///
/// // 버튼 글자도 바꿔볼까요?
/// async UniTask ShowCustomButtonAlert()
/// {
/// await Alert.Show("레벨 업!", "축하합니다! 레벨 5를 달성했어요!", confirmButtonText: "야호!");
/// }
/// </code>
/// </example>
public static class Alert public static class Alert
{ {
/// <summary> /// <summary>
/// Alert 모달 프리팹의 기본 경로입니다. /// 🎨 알림창의 기본 디자인(프리팹) 파일이 어디 있는지 알려주는 경로예요.
/// 특별히 다른 디자인을 쓰고 싶지 않으면 이 기본 디자인을 사용해요.
/// </summary> /// </summary>
private const string DefaultAlertPrefabPath = "Prefabs/UI/Modal/Alert"; private const string DefaultPrefabPath = "Prefabs/UI/Modal/Alert";
/// <summary> /// <summary>
/// 지정된 제목과 메시지로 알림창을 표시합니다. /// 지정된 제목과 메시지로 알림창을 화면에 뿅! 하고 보여줘요.
/// 사용자가 확인 버튼을 누를 때까지 기다립니다. /// 사용자가 "확인" 버튼을 누를 때까지 코드는 여기서 잠시 멈춰 기다려요.
/// </summary> /// </summary>
/// <param name="title">알림창의 제목입니다.</param> /// <param name="title">알림창 맨 위에 크게 보일 '제목'이에요.</param>
/// <param name="message">알림창에 표시될 메시지입니다.</param> /// <param name="message">알림창에 보여줄 '메시지 내용'이에요.</param>
/// <param name="confirmButtonText">확인 버튼에 표시될 텍스트입니다. null일 경우 LocalizationManager에서 "modal_confirm_button" 키로 조회합니다.</param> /// <param name="confirmButtonText">"확인" 버튼에 보여줄 글자예요. 아무것도 안 적으면 기본 글자("확인" 또는 설정된 언어)가 나와요.</param>
/// <param name="customPrefabPath">사용자 정의 알림 프리팹 경로입니다. null일 경우 기본 경로를 사용합니다.</param> /// <param name="customPrefabPath">만약 특별히 만들어둔 알림창 디자인이 있다면, 그 파일 경로를 여기에 적어주세요. 없으면 기본 디자인을 사용해요.</param>
/// <returns>모달이 닫힐 때 완료되는 UniTask입니다.</returns> /// <returns>사용자가 확인 버튼을 누르면 완료되는 작업(UniTask)이에요. 특별한 값을 돌려주진 않아요.</returns>
/// <example>
/// <code>
/// public class GameManager : MonoBehaviour
/// {
/// public async void OnPlayerSaveGame()
/// {
/// // (게임 저장 로직...)
/// bool success = true; // 저장 성공했다고 가정
///
/// if (success)
/// {
/// await Alert.Show("저장 완료", "게임 진행 상황이 안전하게 저장되었습니다.", "알겠습니다");
/// ULog.Debug("저장 완료 알림을 플레이어가 확인했습니다.");
/// }
/// else
/// {
/// await Alert.Show("저장 실패", "오류가 발생하여 저장하지 못했습니다.", "다시 시도");
/// // (다시 시도 로직 또는 다른 처리...)
/// }
/// }
/// }
/// </code>
/// </example>
public static async UniTask Show( public static async UniTask Show(
string title, string title,
string message, string message,
string confirmButtonText = null, string confirmButtonText = null,
string customPrefabPath = null) string customPrefabPath = null)
{ {
string prefabPath = string.IsNullOrEmpty(customPrefabPath) ? DefaultAlertPrefabPath : customPrefabPath; string prefabPath = string.IsNullOrEmpty(customPrefabPath) ? DefaultPrefabPath : customPrefabPath;
// ModalContent 설정 // ModalContent 레시피를 만들어요. 알림창은 취소 버튼이 필요 없으니 숨겨요.
ModalContent content = new ModalContent(prefabPath) ModalContent content = new ModalContent(prefabPath)
{ {
Title = title, Title = title,
Message = message, Message = message,
ShowCancelButton = false, // Alert에서는 취소 버튼 숨김 ShowCancelButton = false, // Alert에서는 취소 버튼 숨김
// ConfirmButtonText는 null이면 ModalContent의 getter가 LocalizationManager를 사용함
}; };
// 확인 버튼 글자를 따로 정해줬다면 그걸로 설정해요.
if (!string.IsNullOrEmpty(confirmButtonText)) if (!string.IsNullOrEmpty(confirmButtonText))
{ {
content.ConfirmButtonText = confirmButtonText; content.ConfirmButtonText = confirmButtonText;
} }
// else인 경우, ModalContent의 ConfirmButtonText getter가 // 아니면 ModalContent의 기본 설정(다국어 지원 "button_confirm" 키)을 따라요.
// LocalizationManager.Instance.GetString("modal_confirm_button")을 사용합니다. // 만약 다른 기본 키를 쓰고 싶다면 여기서 설정할 수도 있어요.
// 만약 이 키가 아닌 다른 키를 기본값으로 사용하고 싶다면 여기서 설정할 수 있습니다. // 예: content.ConfirmButtonText = LocalizationManager.Instance.GetString("alert_default_ok_button");
// 예: content.ConfirmButtonText = LocalizationManager.Instance.GetString("alert_ok_button");
// Modal.Open<T> 호출. Alert은 별도의 결과를 반환하지 않으므로 T는 bool 또는 object 같은 기본 타입을 사용할 수 있습니다. // Modal 시스템에게 "이 레시피대로 모달 창 열어줘!" 라고 부탁해요.
// 여기서는 bool을 사용하고, 확인 버튼은 true를 반환하도록 Modal 시스템이 되어있다고 가정합니다. // Alert은 사용자의 선택 결과(true/false)가 중요하지 않으므로, bool 타입으로 받고 결과는 무시해요.
// 실제 반환값은 사용하지 않으므로, UniTask<bool>을 받고 무시합니다.
await Modal.Open<bool>(content); await Modal.Open<bool>(content);
} }
/// <summary> /// <summary>
/// 다국어 키를 사용하여 제목과 메시지를 표시하는 알림창을 엽니다. /// 🌍 다국어(여러 나라 언어)를 지원하는 알림창을 보여줘요.
/// 미리 준비된 '언어 키'를 알려주면, 게임 설정 언어에 맞는 글자를 자동으로 찾아서 보여줘요.
/// </summary> /// </summary>
/// <param name="titleLocalizationKey">제목으로 사용할 다국어 키입니다.</param> /// <param name="titleLocalizationKey">제목 사용할 '언어 키'예요. (예: "alert_title_welcome")</param>
/// <param name="messageLocalizationKey">메시지 사용할 다국어 키입니다.</param> /// <param name="messageLocalizationKey">메시지 내용에 사용할 '언어 키'예요. (예: "alert_message_item_acquired")</param>
/// <param name="confirmButtonLocalizationKey">확인 버튼 텍스트로 사용할 다국어 키입니다. null일 경우 "modal_confirm_button"을 사용합니다.</param> /// <param name="confirmButtonLocalizationKey">확인 버튼 글자에 사용할 '언어 키'예요. 안 적으면 기본 키("button_confirm")를 사용해요.</param>
/// <param name="customPrefabPath">사용자 정의 알림 프리팹 경로입니다. null일 경우 기본 경로를 사용합니다.</param> /// <param name="customPrefabPath">특별한 알림창 디자인 파일 경로예요. 없으면 기본 디자인을 사용해요.</param>
/// <returns>모달이 닫힐 때 완료되는 UniTask입니다.</returns> /// <returns>사용자가 확인 버튼을 누르면 완료되는 작업(UniTask)이에요.</returns>
/// <example>
/// <code>
/// public class QuestManager : MonoBehaviour
/// {
/// public async void OnQuestCompleted(string questNameKey)
/// {
/// // 예시: 퀘스트 완료 메시지를 현재 설정된 언어로 보여줍니다.
/// // titleLocalizationKey: "quest_completed_title" -> "퀘스트 완료" (한국어), "Quest Complete" (영어)
/// // messageLocalizationKey: "quest_completed_message" -> "{0} 퀘스트를 완료했습니다!" (한국어), "You have completed the {0} quest!" (영어)
/// // 여기서 {0} 부분은 실제 퀘스트 이름으로 바뀔 수 있도록 LocalizationManager에서 처리한다고 가정합니다.
///
/// string localizedQuestName = LocalizationManager.Instance.GetString(questNameKey); // 예: "main_quest_01" -> "첫 번째 임무"
/// string formattedMessageKey = "quest_completed_message"; // 실제로는 메시지 포맷팅이 필요할 수 있음
///
/// // 실제 메시지는 LocalizationManager에서 포맷팅을 지원해야 함
/// // 여기서는 간단히 키만 전달하는 것으로 가정
/// await Alert.ShowLocalized("quest_completed_title", formattedMessageKey, confirmButtonLocalizationKey: "ui_button_great");
/// ULog.Debug("퀘스트 완료 알림을 플레이어가 확인했습니다.");
/// }
/// }
/// </code>
/// </example>
public static async UniTask ShowLocalized( public static async UniTask ShowLocalized(
string titleLocalizationKey, string titleLocalizationKey,
string messageLocalizationKey, string messageLocalizationKey,
string confirmButtonLocalizationKey = null, string confirmButtonLocalizationKey = null,
string customPrefabPath = null) string customPrefabPath = null)
{ {
// 언어 키를 사용해서 실제 보여줄 글자들을 가져와요.
string title = LocalizationManager.Instance.GetString(titleLocalizationKey); string title = LocalizationManager.Instance.GetString(titleLocalizationKey);
string message = LocalizationManager.Instance.GetString(messageLocalizationKey); string message = LocalizationManager.Instance.GetString(messageLocalizationKey);
string confirmText = null; string confirmText = null;
@@ -77,8 +141,9 @@ namespace UVC.UI.Modal
{ {
confirmText = LocalizationManager.Instance.GetString(confirmButtonLocalizationKey); confirmText = LocalizationManager.Instance.GetString(confirmButtonLocalizationKey);
} }
// confirmText가 null이면 Show 메서드 내부에서 ModalContent의 기본 로직(modal_confirm_button 키 사용)이 적용됩니다. // confirmText가 null (따로 안 정해줬으면)이면, Show() 메서드 에서 ModalContent의 기본 글자 로직이 알아서 처리해줘요.
// 준비된 글자들로 알림창을 보여달라고 Show()에게 다시 부탁해요.
await Show(title, message, confirmText, customPrefabPath); await Show(title, message, confirmText, customPrefabPath);
} }
} }

View File

@@ -0,0 +1,188 @@
using Cysharp.Threading.Tasks;
using UVC.Locale; // ButtonText의 기본값을 위해 추가
using UVC.Log; // ULog 사용 예시를 위해 추가 (필요에 따라)
namespace UVC.UI.Modal
{
/// <summary>
/// 🤔 사용자에게 "정말 ~하시겠어요?" 하고 물어보고, "예" 또는 "아니오" 선택을 받는 친구예요.
/// 중요한 결정이나 되돌릴 수 없는 행동 전에 한 번 더 확인받을 때 사용하면 좋아요.
/// 사용자가 어떤 버튼을 눌렀는지 (확인 또는 취소) 알려줘요.
/// </summary>
/// <example>
/// <code>
/// // 게임 종료 전에 정말 끌 건지 물어보는 예시
/// public async void TryExitGame()
/// {
/// bool wantsToExit = await Confirm.Show("게임 종료", "정말로 게임을 종료하시겠습니까?", "네, 종료합니다", "아니요, 계속할래요");
/// if (wantsToExit)
/// {
/// ULog.Debug("사용자가 게임 종료를 확인했습니다.");
/// // Application.Quit(); // 실제 게임 종료 코드
/// }
/// else
/// {
/// ULog.Debug("사용자가 게임 종료를 취소했습니다.");
/// }
/// }
///
/// // 아이템 삭제 확인
/// public async void TryDeleteItem(string itemName)
/// {
/// string title = "아이템 삭제";
/// string message = $"정말로 '{itemName}' 아이템을 삭제하시겠습니까? 이 행동은 되돌릴 수 없습니다.";
///
/// // 기본 버튼 텍스트 사용 (ModalContent에서 설정된 "확인", "취소")
/// bool confirmed = await Confirm.Show(title, message);
///
/// if (confirmed)
/// {
/// ULog.Debug($"'{itemName}' 아이템 삭제를 진행합니다.");
/// // (아이템 삭제 로직...)
/// }
/// }
/// </code>
/// </example>
public static class Confirm
{
/// <summary>
/// 🎨 확인창의 기본 디자인(프리팹) 파일이 어디 있는지 알려주는 경로예요.
/// </summary>
private const string DefaultPrefabPath = "Prefabs/UI/Modal/Confirm"; // 실제 프로젝트 경로에 맞게 수정하세요.
/// <summary>
/// ✨ 지정된 제목과 메시지로 확인창을 화면에 뿅! 하고 보여줘요.
/// 사용자가 "확인" 또는 "취소" 버튼을 누를 때까지 코드는 여기서 잠시 멈춰 기다려요.
/// </summary>
/// <param name="title">확인창 맨 위에 크게 보일 '제목'이에요.</param>
/// <param name="message">확인창에 보여줄 '질문 또는 메시지 내용'이에요.</param>
/// <param name="confirmButtonText">"확인" 버튼에 보여줄 글자예요. 안 적으면 기본 글자("확인")가 나와요.</param>
/// <param name="cancelButtonText">"취소" 버튼에 보여줄 글자예요. 안 적으면 기본 글자("취소")가 나와요.</param>
/// <param name="customPrefabPath">특별히 만들어둔 확인창 디자인 파일 경로예요. 없으면 기본 디자인을 사용해요.</param>
/// <returns>사용자가 "확인"을 누르면 true, "취소"를 누르면 false를 돌려주는 작업(UniTask&lt;bool&gt;)이에요.</returns>
/// <example>
/// <code>
/// public class ShopManager : MonoBehaviour
/// {
/// public async void OnPurchaseItem(string itemName, int price)
/// {
/// string purchaseTitle = "구매 확인";
/// string purchaseMessage = $"{itemName} 아이템을 {price} 골드에 구매하시겠습니까?";
///
/// bool confirmed = await Confirm.Show(purchaseTitle, purchaseMessage, "구매", "나중에");
///
/// if (confirmed)
/// {
/// ULog.Debug($"{itemName} 구매를 진행합니다.");
/// // (구매 처리 로직...)
/// }
/// else
/// {
/// ULog.Debug($"{itemName} 구매를 취소했습니다.");
/// }
/// }
/// }
/// </code>
/// </example>
public static async UniTask<bool> Show(
string title,
string message,
string confirmButtonText = null,
string cancelButtonText = null,
string customPrefabPath = null)
{
string prefabPath = string.IsNullOrEmpty(customPrefabPath) ? DefaultPrefabPath : customPrefabPath;
// ModalContent 레시피를 만들어요. 확인창은 확인/취소 버튼이 모두 필요해요.
ModalContent content = new ModalContent(prefabPath)
{
Title = title,
Message = message,
ShowConfirmButton = true, // Confirm에서는 확인 버튼 항상 표시
ShowCancelButton = true // Confirm에서는 취소 버튼 항상 표시
};
// 확인 버튼 글자를 따로 정해줬다면 그걸로 설정해요.
if (!string.IsNullOrEmpty(confirmButtonText))
{
content.ConfirmButtonText = confirmButtonText;
}
// 취소 버튼 글자를 따로 정해줬다면 그걸로 설정해요.
if (!string.IsNullOrEmpty(cancelButtonText))
{
content.CancelButtonText = cancelButtonText;
}
// Modal 시스템에게 "이 레시피대로 모달 창 열어줘!" 라고 부탁하고, 사용자의 선택(true/false)을 기다려요.
return await Modal.Open<bool>(content);
}
/// <summary>
/// 🌍 다국어(여러 나라 언어)를 지원하는 확인창을 보여줘요.
/// 미리 준비된 '언어 키'를 알려주면, 게임 설정 언어에 맞는 글자를 자동으로 찾아서 보여줘요.
/// </summary>
/// <param name="titleLocalizationKey">제목에 사용할 '언어 키'예요. (예: "confirm_title_exit_game")</param>
/// <param name="messageLocalizationKey">메시지 내용에 사용할 '언어 키'예요. (예: "confirm_message_are_you_sure")</param>
/// <param name="confirmButtonLocalizationKey">확인 버튼 글자에 사용할 '언어 키'예요. 안 적으면 기본 키("button_confirm")를 사용해요.</param>
/// <param name="cancelButtonLocalizationKey">취소 버튼 글자에 사용할 '언어 키'예요. 안 적으면 기본 키("button_cancel")를 사용해요.</param>
/// <param name="customPrefabPath">특별한 확인창 디자인 파일 경로예요. 없으면 기본 디자인을 사용해요.</param>
/// <returns>사용자가 "확인"을 누르면 true, "취소"를 누르면 false를 돌려주는 작업(UniTask&lt;bool&gt;)이에요.</returns>
/// <example>
/// <code>
/// public class SettingsManager : MonoBehaviour
/// {
/// public async void OnResetSettings()
/// {
/// // 예시: 설정 초기화 전에 다국어로 확인을 받습니다.
/// // titleLocalizationKey: "settings_reset_title" -> "설정 초기화" (한국어), "Reset Settings" (영어)
/// // messageLocalizationKey: "settings_reset_confirm_message" -> "모든 설정을 초기화하시겠습니까?" (한국어), "Are you sure you want to reset all settings?" (영어)
///
/// bool confirmed = await Confirm.ShowLocalized(
/// "settings_reset_title",
/// "settings_reset_confirm_message",
/// confirmButtonLocalizationKey: "ui_button_reset", // "초기화"
/// cancelButtonLocalizationKey: "ui_button_keep_current" // "유지"
/// );
///
/// if (confirmed)
/// {
/// ULog.Debug("설정을 초기화합니다.");
/// // (설정 초기화 로직...)
/// }
/// else
/// {
/// ULog.Debug("설정 초기화를 취소했습니다.");
/// }
/// }
/// }
/// </code>
/// </example>
public static async UniTask<bool> ShowLocalized(
string titleLocalizationKey,
string messageLocalizationKey,
string confirmButtonLocalizationKey = null,
string cancelButtonLocalizationKey = null,
string customPrefabPath = null)
{
// 언어 키를 사용해서 실제 보여줄 글자들을 가져와요.
string title = LocalizationManager.Instance.GetString(titleLocalizationKey);
string message = LocalizationManager.Instance.GetString(messageLocalizationKey);
string confirmText = null;
string cancelText = null;
if (!string.IsNullOrEmpty(confirmButtonLocalizationKey))
{
confirmText = LocalizationManager.Instance.GetString(confirmButtonLocalizationKey);
}
if (!string.IsNullOrEmpty(cancelButtonLocalizationKey))
{
cancelText = LocalizationManager.Instance.GetString(cancelButtonLocalizationKey);
}
// 준비된 글자들로 확인창을 보여달라고 Show()에게 다시 부탁하고, 사용자의 선택을 기다려요.
return await Show(title, message, confirmText, cancelText, customPrefabPath);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64b63f405dcd3d24fb7253329e140efe

View File

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

View File

@@ -1,24 +1,47 @@
using UVC.Locale; // LocalizationManager 사용을 위해 추가 using Cysharp.Threading.Tasks;
using UVC.Locale;
using UVC.Log; // LocalizationManager 사용을 위해 추가
namespace UVC.UI.Modal namespace UVC.UI.Modal
{ {
/// <summary>
/// 📜 모달 창에 어떤 내용을 보여줄지, 어떻게 행동할지 정하는 '레시피' 또는 '주문서' 같은 친구예요.
/// 이 클래스의 객체를 만들어서 Modal.Open()에 전달하면, 여기에 적힌 대로 모달 창이 만들어져요.
/// </summary>
/// <example>
/// <code>
/// // "MyAwesomeModalPrefab"이라는 디자인을 사용하는 모달을 위한 레시피를 만들어요.
/// var myRecipe = new ModalContent("Prefabs/UI/MyAwesomeModalPrefab")
/// {
/// Title = "새로운 모험!", // 모달 창 제목
/// Message = "모험을 시작할 준비가 되었나요?", // 모달 창 메시지
/// ConfirmButtonText = "네, 갑시다!", // 확인 버튼 글자
/// ShowCancelButton = false // 취소 버튼은 안 보여줄래요.
/// };
///
/// // 이렇게 만든 레시피(myRecipe)를 Modal.Open()에 전달하면 모달이 뿅 나타나요!
/// // bool userChoseConfirm = await Modal.Open&lt;bool&gt;(myRecipe);
/// </code>
/// </example>
public class ModalContent public class ModalContent
{ {
/// <summary> /// <summary>
/// 모달 창의 제목입니다. 다국어 지원을 위해 직접 설정하거나, 생성 후 별도로 설정할 수 있습니다. /// 🏷️ 모달 창의 제목이에요. 여기에 글자를 적으면 모달 창 맨 위에 크게 보여요.
/// 예: "알림", "게임 저장", "친구 요청"
/// </summary> /// </summary>
public string Title { get; set; } public string Title { get; set; }
/// <summary> /// <summary>
/// 모달 창에 표시될 메시지 또는 본문 내용입니다. 다국어 지원을 위해 직접 설정하거나, 생성 후 별도로 설정할 수 있습니다. /// 💬 모달 창에 보여줄 주요 메시지 내용이에요. 사용자에게 전달하고 싶은 말을 여기에 적어요.
/// 예: "게임 설정을 저장했습니다.", "정말로 아이템을 구매하시겠어요?"
/// </summary> /// </summary>
public string Message { get; set; } public string Message { get; set; }
private string _confirmButtonText; private string _confirmButtonText;
/// <summary> /// <summary>
/// 확인 버튼에 표시될 텍스트입니다. /// ✅ '확인' 버튼에 보여줄 글자예요.
/// 기본적으로 "modal_confirm_button" 키를 사용하여 <see cref="LocalizationManager"/>에서 번역된 문자열을 가져옵니다. /// 특별히 정해주지 않으면 기본적으로 "확인" (또는 설정된 언어에 맞게)이라고 나와요.
/// 직접 설정하여 이 기본값을 재정의할 수 있습니다. /// 직접 "네", "저장하기", "출발!" 처럼 원하는 글자로 바꿀 수 있어요.
/// </summary> /// </summary>
public string ConfirmButtonText public string ConfirmButtonText
{ {
@@ -37,9 +60,9 @@ namespace UVC.UI.Modal
private string _cancelButtonText; private string _cancelButtonText;
/// <summary> /// <summary>
/// 취소 버튼에 표시될 텍스트입니다. /// ❌ '취소' 버튼에 보여줄 글자예요.
/// 기본적으로 "modal_cancel_button" 키를 사용하여 <see cref="LocalizationManager"/>에서 번역된 문자열을 가져옵니다. /// 특별히 정해주지 않으면 기본적으로 "취소" (또는 설정된 언어에 맞게)이라고 나와요.
/// 직접 설정하여 이 기본값을 재정의할 수 있습니다. /// 직접 "아니요", "닫기", "나중에" 처럼 원하는 글자로 바꿀 수 있어요.
/// </summary> /// </summary>
public string CancelButtonText public string CancelButtonText
{ {
@@ -57,24 +80,43 @@ namespace UVC.UI.Modal
} }
/// <summary> /// <summary>
/// 확인 버튼 표시 여부를 결정합니다. (기본값: true) /// 👍 '확인' 버튼을 화면에 보여줄지 말지 결정해요. (기본값: true, 보여줌)
/// false로 바꾸면 '확인' 버튼이 사라져요.
/// </summary> /// </summary>
public bool ShowConfirmButton { get; set; } = true; public bool ShowConfirmButton { get; set; } = true;
/// <summary> /// <summary>
/// 취소 버튼 표시 여부를 결정합니다. (기본값: true) /// 👎 '취소' 버튼을 화면에 보여줄지 말지 결정해요. (기본값: true, 보여줌)
/// false로 바꾸면 '취소' 버튼이 사라져요.
/// </summary> /// </summary>
public bool ShowCancelButton { get; set; } = true; public bool ShowCancelButton { get; set; } = true;
/// <summary> /// <summary>
/// 모달 콘텐츠로 사용될 프리팹의 경로입니다. /// 👎 '닫기' 버튼을 화면에 보여줄지 말지 결정해요. (기본값: true, 보여줌)
/// false로 바꾸면 '닫기' 버튼이 사라져요.
/// </summary>
public bool ShowCloseButton { get; set; } = true;
/// <summary>
/// 🎨 모달 창의 '디자인 도면' 파일이 어디 있는지 알려주는 경로예요.
/// Unity 에디터에서 만들어둔 프리팹(Prefab) 파일의 경로를 적어줘야 해요.
/// 예: "Prefabs/UI/MyCustomModal"
/// </summary> /// </summary>
public string PrefabPath { get; private set; } public string PrefabPath { get; private set; }
/// <summary> /// <summary>
/// ModalContent 생성자입니다. /// 🧑‍🍳 ModalContent 레시피를 만드는 방법이에요. (생성자)
/// 모달 창을 어떤 디자인으로 만들지 알려줘야 해요.
/// </summary> /// </summary>
/// <param name="prefabPath">로드할 프리팹의 경로입니다. 이 프리팹 내부에 UI 요소들이 구성되어 있어야 합니다.</param> /// <param name="prefabPath">사용할 모달 창 디자인(프리팹) 파일의 경로예요.</param>
/// <example>
/// <code>
/// // "CommonModalPrefab" 디자인을 사용하는 레시피 만들기
/// var content = new ModalContent("Prefabs/UI/CommonModalPrefab");
/// content.Title = "안내";
/// content.Message = "이것은 일반적인 안내 모달입니다.";
/// </code>
/// </example>
public ModalContent(string prefabPath) public ModalContent(string prefabPath)
{ {
PrefabPath = prefabPath; PrefabPath = prefabPath;
@@ -83,9 +125,10 @@ namespace UVC.UI.Modal
} }
/// <summary> /// <summary>
/// 특정 다국어 키를 사용하여 확인 버튼 텍스트를 설정합니다. /// 🔑 '확인' 버튼의 글자를 다국어 키를 사용해서 설정해요.
/// 게임이 여러 언어를 지원할 때 유용해요.
/// </summary> /// </summary>
/// <param name="localizationKey">사용할 다국어 키입니다.</param> /// <param name="localizationKey">미리 정해둔 다국어 키 (예: "ui_button_yes", "action_save")</param>
public void SetConfirmButtonTextFromKey(string localizationKey) public void SetConfirmButtonTextFromKey(string localizationKey)
{ {
// 직접 값을 설정하는 대신, 키를 저장하고 getter에서 처리하도록 할 수도 있으나, // 직접 값을 설정하는 대신, 키를 저장하고 getter에서 처리하도록 할 수도 있으나,
@@ -105,9 +148,9 @@ namespace UVC.UI.Modal
} }
/// <summary> /// <summary>
/// 특정 다국어 키를 사용하여 취소 버튼 텍스트를 설정합니다. /// 🔑 '취소' 버튼의 글자를 다국어 키를 사용해서 설정해요.
/// </summary> /// </summary>
/// <param name="localizationKey">사용할 다국어 키입니다.</param> /// <param name="localizationKey">미리 정해둔 다국어 키 (예: "ui_button_no", "action_cancel")</param>
public void SetCancelButtonTextFromKey(string localizationKey) public void SetCancelButtonTextFromKey(string localizationKey)
{ {
if (LocalizationManager.Instance != null) if (LocalizationManager.Instance != null)
@@ -119,5 +162,65 @@ namespace UVC.UI.Modal
_cancelButtonText = $"[{localizationKey}]"; _cancelButtonText = $"[{localizationKey}]";
} }
} }
/// <summary>
/// 🚀 모달 창이 화면에 나타나기 *직전*에 이 레시피가 할 일을 정해요. (비동기 작업 가능)
/// 예를 들어, 모달에 필요한 데이터를 미리 불러오거나 특별한 준비를 할 수 있어요.
/// `ModalView.OnOpen()` 보다 먼저 호출돼요.
/// </summary>
/// <example>
/// <para>이 메서드를 상속받아서 특별한 준비 작업을 추가할 수 있어요:</para>
/// <code>
/// public class MySpecialModalContent : ModalContent
/// {
/// public MySpecialModalContent(string prefabPath) : base(prefabPath) { }
///
/// public override async UniTask OnOpen()
/// {
/// await base.OnOpen(); // 부모 클래스의 OnOpen도 호출해주는 게 좋아요.
/// ULog.Debug("나만의 특별한 모달 내용 준비 시작!");
/// // 예: await LoadSomeDataForTheModal();
/// ULog.Debug("나만의 특별한 모달 내용 준비 완료!");
/// }
/// }
/// </code>
/// </example>
public virtual async UniTask OnOpen()
{
// 기본적으로는 아무것도 하지 않아요. 필요하면 상속받아서 내용을 채워주세요!
//ULog.Debug($"[ModalContent] '{Title}' OnOpen called.");
await UniTask.CompletedTask;
}
/// <summary>
/// 🎬 모달 창이 화면에서 사라지기 *직전*에 이 레시피가 할 일을 정해요. (비동기 작업 가능)
/// 예를 들어, 모달에서 사용했던 자원을 정리하거나 특별한 마무리를 할 수 있어요.
/// `ModalView.OnClose()` 보다 먼저 호출돼요.
/// </summary>
/// <example>
/// <para>이 메서드를 상속받아서 특별한 마무리 작업을 추가할 수 있어요:</para>
/// <code>
/// public class MySpecialModalContent : ModalContent
/// {
/// public MySpecialModalContent(string prefabPath) : base(prefabPath) { }
///
/// // ... OnOpen 재정의 ...
///
/// public override async UniTask OnClose()
/// {
/// ULog.Debug("나만의 특별한 모달 내용 마무리 시작!");
/// // 예: await ReleaseSomeDataUsedInModal();
/// ULog.Debug("나만의 특별한 모달 내용 마무리 완료!");
/// await base.OnClose(); // 부모 클래스의 OnClose도 호출해주는 게 좋아요.
/// }
/// }
/// </code>
/// </example>
public virtual async UniTask OnClose()
{
// 기본적으로는 아무것도 하지 않아요. 필요하면 상속받아서 내용을 채워주세요!
//ULog.Debug($"[ModalContent] '{Title}' OnClose called.");
await UniTask.CompletedTask;
}
} }
} }

View File

@@ -1,8 +1,4 @@
using System; using Cysharp.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
@@ -11,62 +7,275 @@ using UVC.Log;
namespace UVC.UI.Modal namespace UVC.UI.Modal
{ {
/// <summary> /// <summary>
/// 모달 프리팹에 부착되어 UI 요소들을 관리하는 컴포넌트입니다. /// 🖼️ 모달 창의 실제 '모습'을 담당하는 친구예요. Unity 에디터에서 만든 UI 요소들(버튼, 글상자 등)을 가지고 있어요.
/// `ModalContent`라는 레시피를 받아서, 그 내용대로 화면에 그림을 그려주는 역할을 해요.
/// 이 스크립트는 모달로 사용할 프리팹(Prefab)의 가장 바깥쪽 부모 게임 오브젝트에 붙여줘야 해요.
/// </summary> /// </summary>
/// <example>
/// <para>만약 입력창이 있는 특별한 모달을 만들고 싶다면, 이 ModalView를 상속받아서 만들 수 있어요:</para>
/// <code>
/// public class InputModalView : ModalView
/// {
/// [Header("My Special UI")]
/// public TMP_InputField myInputField; // Unity 에디터에서 연결해줘야 해요.
/// private string _inputValue = "";
///
/// public override async UniTask OnOpen(ModalContent content)
/// {
/// await base.OnOpen(content); // 부모의 OnOpen을 먼저 호출해서 기본 UI를 설정해요.
/// ULog.Debug("입력 모달이 열렸어요! 입력창을 초기화합니다.");
/// if (myInputField != null)
/// {
/// myInputField.text = ""; // 입력창 비우기
/// // 입력창에 변화가 있을 때마다 _inputValue를 업데이트하도록 설정할 수 있어요.
/// myInputField.onValueChanged.AddListener(OnInputChanged);
/// }
/// }
///
/// private void OnInputChanged(string newValue)
/// {
/// _inputValue = newValue;
/// ULog.Debug($"입력된 값: {newValue}");
/// }
///
/// public override object GetResult()
/// {
/// // 이 모달이 닫힐 때, 입력된 글자를 결과로 돌려줘요.
/// return _inputValue;
/// }
///
/// public override async UniTask OnClose(ModalContent content)
/// {
/// ULog.Debug("입력 모달이 닫힙니다.");
/// if (myInputField != null)
/// {
/// myInputField.onValueChanged.RemoveListener(OnInputChanged); // 리스너 정리
/// }
/// await base.OnClose(content);
/// }
/// }
///
/// // 이 InputModalView를 사용하는 방법:
/// // 1. InputModalView 스크립트가 붙어있는 프리팹을 만들고, myInputField를 연결해요.
/// // 2. ModalContent 만들 때 이 프리팹 경로를 사용해요.
/// // var inputContent = new ModalContent("Prefabs/UI/MyInputModalPrefab") { Title = "이름 입력", Message = "이름을 입력해주세요." };
/// // string enteredName = await Modal.Open<string>(inputContent);
/// // if (!string.IsNullOrEmpty(enteredName)) { ULog.Debug($"환영합니다, {enteredName}님!"); }
/// </code>
/// </example>
public class ModalView : MonoBehaviour public class ModalView : MonoBehaviour
{ {
[Header("UI Elements")] [Header("UI Elements")]
/// <summary>
/// 🏷️ 모달 창의 제목을 보여줄 글상자(TextMeshProUGUI)예요.
/// Unity 에디터의 인스펙터 창에서 실제 UI 요소를 끌어다 연결해줘야 해요.
/// </summary>
public TextMeshProUGUI titleText; public TextMeshProUGUI titleText;
/// <summary>
/// 💬 모달 창의 주요 메시지를 보여줄 글상자예요. 이것도 연결해주세요!
/// </summary>
public TextMeshProUGUI messageText; public TextMeshProUGUI messageText;
/// <summary>
/// ✅ '확인' 버튼이에요. 연결 필수!
/// </summary>
public Button confirmButton; public Button confirmButton;
public TextMeshProUGUI confirmButtonText; // confirmButton 내의 Text 컴포넌트 /// <summary>
/// 확인 버튼 안에 있는 글상자예요. 확인 버튼 글자를 바꿀 때 사용돼요.
/// </summary>
public TextMeshProUGUI confirmButtonText;
/// <summary>
/// ❌ '취소' 버튼이에요. 이것도 연결해주세요!
/// </summary>
public Button cancelButton; public Button cancelButton;
public TextMeshProUGUI cancelButtonText; // cancelButton 내의 Text 컴포넌트 /// <summary>
/// 취소 버튼 안에 있는 글상자예요. 취소 버튼 글자를 바꿀 때 사용돼요.
/// </summary>
public TextMeshProUGUI cancelButtonText;
/// <summary>
/// ✖️ 모달 창을 닫는 (보통 오른쪽 위에 있는 X 모양) 버튼이에요.
/// </summary>
public Button closeButton; // 닫기 버튼 public Button closeButton; // 닫기 버튼
// 필요에 따라 다른 UI 요소들을 추가할 수 있습니다. // 필요에 따라 다른 UI 요소들을 추가할 수 있습니다.
// 예: public Image backgroundImage; // 예: public Image backgroundImage;
// 예: public InputField inputField; // 예: public InputField inputField;
// Modal.cs에서 호출 가능하도록 public으로 변경 (또는 protected internal) /// <summary>
public virtual void OnOpen() // virtual로 변경하여 하위 클래스에서 재정의 가능하도록 함 /// 🚀 모달 창이 화면에 나타날 때 `Modal` 클래스가 호출하는 마법이에요! (비동기 작업 가능)
/// `ModalContent` 레시피를 받아서, 이 `ModalView`의 UI 요소들(제목, 메시지, 버튼 등)을 레시피대로 설정해요.
/// `ModalContent.OnOpen()`이 호출된 *후에* 실행돼요.
/// </summary>
/// <param name="content">모달에 보여줄 내용과 설정을 담은 '레시피' (`ModalContent` 객체)예요.</param>
public virtual async UniTask OnOpen(ModalContent content)
{ {
// 모달이 열릴 때 실행할 로직 //ULog.Debug($"[ModalView] {gameObject.name} OnOpen called.");
ULog.Debug($"[ModalView] {gameObject.name} OnOpen called.");
//confirmButton, cancelButton 버튼 위치 가운 대로 설정. // ModalContent 레시피에 적힌 대로 UI 요소들을 설정해요.
if (confirmButton.IsActive() && !cancelButton.IsActive()) if (titleText != null && content != null)
{
titleText.text = content.Title;
}
if (messageText != null && content != null)
{
messageText.text = content.Message;
}
// 확인 버튼 설정
if (confirmButton != null && content != null)
{
confirmButton.gameObject.SetActive(content.ShowConfirmButton);
if (content.ShowConfirmButton && confirmButtonText != null && !string.IsNullOrEmpty(content.ConfirmButtonText))
{
confirmButtonText.text = content.ConfirmButtonText;
}
}
// 취소 버튼 설정
if (cancelButton != null && content != null)
{
cancelButton.gameObject.SetActive(content.ShowCancelButton);
if (content.ShowCancelButton && cancelButtonText != null && !string.IsNullOrEmpty(content.CancelButtonText))
{
cancelButtonText.text = content.CancelButtonText;
}
}
// 닫기 버튼 설정
if (closeButton != null && content != null)
{
closeButton.gameObject.SetActive(content.ShowCloseButton);
}
// 버튼 위치를 예쁘게 조정해요 (예: 버튼이 하나만 있으면 가운데로).
AdjustButtonPositions();
await UniTask.CompletedTask; // 비동기 메서드라서 마지막에 이걸 붙여줘요.
}
/// <summary>
/// 📐 활성화된 버튼(확인/취소)의 수에 따라 버튼 위치를 보기 좋게 조정해요.
/// 예를 들어, 버튼이 하나만 있다면 화면 가운데에 오도록 할 수 있어요.
/// 이 메서드는 `OnOpen`에서 호출돼요.
/// </summary>
protected virtual void AdjustButtonPositions()
{
bool isConfirmActive = confirmButton != null && confirmButton.gameObject.activeSelf;
bool isCancelActive = cancelButton != null && cancelButton.gameObject.activeSelf;
// 확인 버튼만 활성화되어 있다면,
if (isConfirmActive && !isCancelActive)
{ {
RectTransform confirmButtonRect = confirmButton.GetComponent<RectTransform>(); RectTransform confirmButtonRect = confirmButton.GetComponent<RectTransform>();
if (confirmButtonRect != null) if (confirmButtonRect != null)
{ {
// 예시: 확인 버튼을 부모 UI 요소의 가로 중앙으로 이동시켜요.
// (정확한 값은 여러분의 UI 레이아웃에 따라 달라질 수 있어요!)
confirmButtonRect.anchorMin = new Vector2(0.5f, confirmButtonRect.anchorMin.y);
confirmButtonRect.anchorMax = new Vector2(0.5f, confirmButtonRect.anchorMax.y);
confirmButtonRect.pivot = new Vector2(0.5f, confirmButtonRect.pivot.y);
confirmButtonRect.anchoredPosition = new Vector2(0, confirmButtonRect.anchoredPosition.y); confirmButtonRect.anchoredPosition = new Vector2(0, confirmButtonRect.anchoredPosition.y);
} }
} }
else if (!confirmButton.IsActive() && cancelButton.IsActive()) // 취소 버튼만 활성화되어 있다면,
else if (!isConfirmActive && isCancelActive)
{ {
RectTransform cancelButtonRect = cancelButton.GetComponent<RectTransform>(); RectTransform cancelButtonRect = cancelButton.GetComponent<RectTransform>();
if (cancelButtonRect != null) if (cancelButtonRect != null)
{ {
// 예시: 취소 버튼도 부모 UI 요소의 가로 중앙으로 이동시켜요.
cancelButtonRect.anchorMin = new Vector2(0.5f, cancelButtonRect.anchorMin.y);
cancelButtonRect.anchorMax = new Vector2(0.5f, cancelButtonRect.anchorMax.y);
cancelButtonRect.pivot = new Vector2(0.5f, cancelButtonRect.pivot.y);
cancelButtonRect.anchoredPosition = new Vector2(0, cancelButtonRect.anchoredPosition.y); cancelButtonRect.anchoredPosition = new Vector2(0, cancelButtonRect.anchoredPosition.y);
} }
} }
// 두 버튼이 모두 보이거나 모두 안 보일 때는 특별히 위치를 바꾸지 않아요 (기본 레이아웃 사용).
// 필요하다면 이 부분에 다른 정렬 로직을 추가할 수도 있어요!
} }
// Modal.cs에서 호출 가능하도록 public으로 변경 (또는 protected internal)
public virtual void OnClose() // virtual로 변경하여 하위 클래스에서 재정의 가능하도록 함 /// <summary>
/// 🎬 모달 창이 화면에서 사라질 때 `Modal` 클래스가 호출하는 마법이에요! (비동기 작업 가능)
/// `ModalContent.OnClose()`가 호출된 *후에* 실행돼요.
/// 모달이 닫히면서 특별히 정리해야 할 작업이 있다면 여기에 작성해요.
/// </summary>
/// <param name="content">이 모달을 열 때 사용했던 '레시피' (`ModalContent` 객체)예요.</param>
public virtual async UniTask OnClose(ModalContent content)
{ {
// 모달이 닫힐 때 실행할 로직 //ULog.Debug($"[ModalView] {gameObject.name} OnClose called.");
ULog.Debug($"[ModalView] {gameObject.name} OnClose called."); // 예: 모달에서 사용했던 리소스를 해제하거나, UI 상태를 초기화하는 코드를 여기에 넣을 수 있어요.
await UniTask.CompletedTask;
} }
public virtual object GetResult() // virtual로 변경하여 하위 클래스에서 재정의 가능하도록 함 /// <summary>
/// 🎁 모달 창이 닫힐 때, 이 모달이 어떤 '결과'를 만들었는지 알려주는 함수예요.
/// 기본적으로는 아무것도 안 알려줘요 (`null` 반환).
/// 만약 모달에서 사용자가 뭔가를 선택하거나 입력했다면, 이 함수를 **재정의(override)**해서
/// 그 선택/입력 값을 돌려주도록 만들 수 있어요.
/// `Modal.Open<T>()`를 호출할 때 `T`에 지정한 타입으로 이 결과가 변환돼요.
/// </summary>
/// <returns>모달의 처리 결과예요. (예: 사용자가 입력한 글자, 선택한 아이템, 또는 그냥 true/false)</returns>
/// <example>
/// <para>예를 들어, '예'/'아니오'를 선택하는 간단한 확인 모달이라면 이렇게 할 수 있어요:</para>
/// <code>
/// public class ConfirmModalView : ModalView
/// {
/// private bool _wasConfirmed = false;
///
/// // (OnOpen 등 다른 메서드들은 필요에 따라 구현)
///
/// // 확인 버튼이 눌렸을 때 호출될 메서드 (Modal.cs에서 연결해줌)
/// public void HandleConfirm()
/// {
/// _wasConfirmed = true;
/// // 실제로는 Modal.cs의 HandleModalActionAsync가 호출되어 모달이 닫힙니다.
/// }
///
/// // 취소 버튼이 눌렸을 때 호출될 메서드
/// public void HandleCancel()
/// {
/// _wasConfirmed = false;
/// }
///
/// public override object GetResult()
/// {
/// // 사용자가 '확인'을 눌렀는지 여부를 bool 값으로 반환해요.
/// return _wasConfirmed;
/// }
/// }
/// // Modal.Open<bool>(...) 이렇게 호출하면, GetResult()가 반환한 bool 값을 받을 수 있어요.
/// </code>
/// <para>또는, 입력 필드가 있는 모달이라면 입력된 텍스트를 반환할 수 있어요:</para>
/// <code>
/// public class InputModalView : ModalView
/// {
/// public TMP_InputField inputField;
/// // (OnOpen에서 inputField 초기화 및 이벤트 연결)
///
/// public override object GetResult()
/// {
/// return inputField != null ? inputField.text : string.Empty;
/// }
/// }
/// // Modal.Open<string>(...) 이렇게 호출하면, 입력된 문자열을 받을 수 있어요.
/// </code>
/// </example>
public virtual object GetResult()
{ {
// 기본적으로는 null을 반환. 특정 모달 뷰에서 이 메서드를 재정의하여
// 입력 필드의 값, 선택된 항목 등 구체적인 결과 데이터를 반환하도록 구현합니다.
// 예를 들어, 확인 버튼 시 true, 취소 버튼 시 false를 반환하도록 할 수도 있습니다.
// 이 값은 Modal.Open<T>의 T 타입으로 변환 시도됩니다.
return null; return null;
} }
/// <summary>
/// 💡 모달 안에 있는 모든 주요 버튼들(확인, 취소, 닫기)을 한꺼번에 클릭할 수 있게 하거나 못하게 만들어요.
/// 예를 들어, 모달이 뭔가 중요한 작업을 처리하는 동안에는 버튼을 잠시 못 누르게 할 때 사용해요.
/// </summary>
/// <param name="interactable">true로 설정하면 버튼들을 누를 수 있고, false면 못 눌러요.</param>
public virtual void SetAllButtonsInteractable(bool interactable)
{
if (confirmButton != null) confirmButton.interactable = interactable;
if (cancelButton != null) cancelButton.interactable = interactable;
if (closeButton != null) closeButton.interactable = interactable;
}
} }
} }

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 48af2b9eac1a64f438a95b8145971a9b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
namespace UVC.UI.ToolBar
{
/// <summary>
/// 툴바에 추가될 수 있는 모든 항목(버튼, 구분선 등)의 기본 인터페이스입니다.
/// </summary>
public interface IToolbarItem { }
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b28bebc44774c6f4f95cb5e612baef9f

View File

@@ -0,0 +1,77 @@
namespace UVC.UI.ToolBar
{
/// <summary>
/// 툴바의 전체적인 컨테이너 및 관리 클래스입니다.
/// IToolbarItem 객체들을 동적으로 추가하고 관리합니다.
/// </summary>
public class Toolbar
{
public System.Collections.Generic.List<IToolbarItem> Items { get; private set; }
private System.Collections.Generic.Dictionary<string, ToolbarRadioButtonGroup> _radioGroups;
public Toolbar()
{
Items = new System.Collections.Generic.List<IToolbarItem>();
_radioGroups = new System.Collections.Generic.Dictionary<string, ToolbarRadioButtonGroup>();
}
public void AddItem(IToolbarItem item)
{
Items.Add(item);
if (item is ToolbarRadioButton radioButton)
{
if (!_radioGroups.TryGetValue(radioButton.GroupName, out var group))
{
group = new ToolbarRadioButtonGroup();
_radioGroups.Add(radioButton.GroupName, group);
}
group.RegisterButton(radioButton);
}
// UI 갱신 로직 호출
}
public ToolbarStandardButton AddStandardButton(string text, UnityEngine.Sprite icon = null, System.Action onClick = null, string tooltipKey = null)
{
var button = new ToolbarStandardButton { Text = text, Icon = icon, OnClick = onClick, TooltipKey = tooltipKey };
AddItem(button);
return button;
}
public ToolbarToggleButton AddToggleButton(string text, bool initialState = false, UnityEngine.Sprite icon = null, System.Action<bool> onToggle = null, string tooltipKey = null)
{
var button = new ToolbarToggleButton { Text = text, IsSelected = initialState, Icon = icon, OnToggle = onToggle, TooltipKey = tooltipKey };
AddItem(button);
return button;
}
public ToolbarRadioButton AddRadioButton(string groupName, string text, bool initialState = false, UnityEngine.Sprite icon = null, System.Action<bool> onToggle = null, string tooltipKey = null)
{
var button = new ToolbarRadioButton(groupName) { Text = text, IsSelected = initialState, Icon = icon, OnToggle = onToggle, TooltipKey = tooltipKey };
// AddItem 내에서 그룹 처리가 되므로, 여기서는 IsSelected 초기값만 주의 (그룹 내 하나만 true여야 함)
AddItem(button);
// 그룹의 초기 선택 상태를 설정하는 로직이 추가로 필요할 수 있습니다.
// 예를 들어, 첫 번째로 추가된 라디오 버튼을 기본 선택으로 하거나, 명시적으로 설정.
if (initialState && _radioGroups.TryGetValue(groupName, out var group))
{
group.SetSelected(button);
}
return button;
}
public ToolbarExpandableButton AddExpandableButton(string text, UnityEngine.Sprite icon = null, System.Action onClick = null, string tooltipKey = null)
{
var button = new ToolbarExpandableButton { Text = text, Icon = icon, OnClick = onClick, TooltipKey = tooltipKey };
AddItem(button);
return button;
}
public void AddSeparator()
{
AddItem(new ToolbarSeparator());
}
// 실제 UI 렌더링 및 상호작용 로직은 이 클래스 또는 별도의 UI View 클래스에서 처리됩니다.
// (예: Unity UI GameObject 생성, 이벤트 연결 등)
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d72ec0e29e760440b1badbdf4b051f2

View File

@@ -0,0 +1,82 @@
using System;
using UnityEngine;
namespace UVC.UI.ToolBar
{
/// <summary>
/// 모든 버튼의 기본 추상 클래스입니다.
/// 공통적인 속성 (예: 텍스트, 아이콘, 활성화 상태) 및 동작을 정의합니다.
/// </summary>
public abstract class ToolbarButtonBase : IToolbarItem
{
public event Action OnStateChanged; // 상태 변경 알림 이벤트
private string _text;
public string Text
{
get => _text;
set
{
if (_text != value)
{
_text = value;
OnStateChanged?.Invoke();
}
}
}
private Sprite _icon;
public Sprite Icon
{
get => _icon;
set
{
if (_icon != value)
{
_icon = value;
OnStateChanged?.Invoke();
}
}
}
private bool _isEnabled = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnStateChanged?.Invoke();
}
}
}
private string _tooltipKey; // 툴팁 다국어 키
public string TooltipKey
{
get => _tooltipKey;
set
{
if (_tooltipKey != value)
{
_tooltipKey = value;
// TooltipKey 변경 시 OnStateChanged를 호출할 필요는 일반적으로 없으나,
// 만약 UI가 TooltipKey 자체를 표시하는 등의 로직이 있다면 필요할 수 있습니다.
// 여기서는 툴팁 내용이 동적으로 변경되는 경우가 적다고 가정하고 생략합니다.
}
}
}
public Action OnClick { get; set; }
public abstract void ExecuteClick();
// OnStateChanged 이벤트를 외부에서 강제로 발생시켜야 할 때 사용 (예: 복합적인 상태 변경 후)
public void NotifyStateChanged()
{
OnStateChanged?.Invoke();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 49e156554491e3c4fb49243701695feb

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace UVC.UI.ToolBar
{
/// <summary>
/// 클릭 시 하위 버튼 그룹을 확장하여 보여주는 버튼입니다.
/// 하위 버튼 선택 시, 주 버튼의 내용이 업데이트될 수 있습니다.
/// </summary>
public class ToolbarExpandableButton : ToolbarButtonBase
{
public enum ExpansionDirection { Horizontal, Vertical }
public List<ToolbarButtonBase> SubButtons { get; private set; }
public ExpansionDirection Direction { get; set; } = ExpansionDirection.Vertical;
public Action<ToolbarButtonBase> OnSubButtonSelected { get; set; }
public ToolbarExpandableButton()
{
SubButtons = new List<ToolbarButtonBase>();
}
public override void ExecuteClick()
{
if (IsEnabled)
{
OnClick?.Invoke();
}
}
public void SelectSubButton(ToolbarButtonBase selectedSubButton)
{
if (selectedSubButton != null && selectedSubButton.IsEnabled)
{
bool changed = false;
if (this.Text != selectedSubButton.Text)
{
this.Text = selectedSubButton.Text; // Setter가 OnStateChanged 호출 (단, Text가 실제로 변경되어야 함)
changed = true;
}
if (this.Icon != selectedSubButton.Icon)
{
this.Icon = selectedSubButton.Icon; // Setter가 OnStateChanged 호출
changed = true;
}
OnSubButtonSelected?.Invoke(selectedSubButton);
// selectedSubButton.ExecuteClick(); // 하위 버튼의 클릭 로직 실행은 선택 사항
if (changed) // Text나 Icon이 실제로 변경된 경우에만 명시적으로 호출하거나, 각 setter에 맡김
{
// NotifyStateChanged(); // Text, Icon setter가 이미 호출하므로 중복될 수 있음.
// 만약 Text, Icon 외 다른 상태도 변경된다면 필요.
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c71e02eeca4e94e4b8dedd8f9fb7e4a7

View File

@@ -0,0 +1,100 @@
using UnityEngine;
using UVC.Locale;
namespace UVC.UI.ToolBar
{
public class ToolbarManager : MonoBehaviour
{
public Toolbar mainToolbar;
public ToolbarView mainToolbarView; // Unity 에디터에서 할당
public Transform toolbarContainer; // Unity 에디터에서 할당 (툴바 UI가 생성될 부모)
// 여기에 HorizontalLayoutGroup 또는 VerticalLayoutGroup을 추가하는 것을 권장합니다.
// 버튼 프리팹들은 ToolbarView로 옮겨서 관리하는 것이 더 깔끔할 수 있습니다.
// 현재는 ToolbarManager에서 할당하여 ToolbarView로 전달하는 방식입니다.
public GameObject standardButtonPrefab;
public GameObject toggleButtonPrefab;
public GameObject radioButtonPrefab;
public GameObject expandableButtonPrefab;
public GameObject separatorPrefab;
public GameObject subMenuPanelPrefab;
void Start()
{
mainToolbar = new Toolbar();
// ToolbarView에 프리팹 설정
if (mainToolbarView != null)
{
mainToolbarView.standardButtonPrefab = standardButtonPrefab;
mainToolbarView.toggleButtonPrefab = toggleButtonPrefab;
mainToolbarView.radioButtonPrefab = radioButtonPrefab;
mainToolbarView.expandableButtonPrefab = expandableButtonPrefab;
mainToolbarView.separatorPrefab = separatorPrefab;
mainToolbarView.subMenuPanelPrefab = subMenuPanelPrefab;
}
else
{
Debug.LogError("ToolbarView가 할당되지 않았습니다.");
return;
}
// --- 툴바 모델 구성 ---
// "저장" 대신 다국어 키 "button_save" 사용
mainToolbar.AddStandardButton("button_save", null, () => Debug.Log("저장 버튼 클릭됨"), "tooltip_save_button");
// "음소거" 대신 다국어 키 "button_mute" 사용
mainToolbar.AddToggleButton("button_mute", false, null, (isSelected) => Debug.Log($"음소거: {isSelected}"), "tooltip_mute_button");
mainToolbar.AddSeparator();
// "펜" 대신 다국어 키 "tool_pen" 사용
mainToolbar.AddRadioButton("ToolGroup", "tool_pen", true, null, (isSelected) => { if (isSelected) Debug.Log("펜 도구 선택됨"); }, "tooltip_pen_tool");
// "지우개" 대신 다국어 키 "tool_eraser" 사용
mainToolbar.AddRadioButton("ToolGroup", "tool_eraser", false, null, (isSelected) => { if (isSelected) Debug.Log("지우개 도구 선택됨"); }, "tooltip_eraser_tool");
mainToolbar.AddSeparator();
// "브러시 크기" 대신 다국어 키 "button_brush_size" 사용
var expandableBtnModel = mainToolbar.AddExpandableButton("button_brush_size", null, null, "tooltip_brush_size");
// 하위 버튼도 다국어 키 사용
var smallBrush = new ToolbarStandardButton { Text = "brush_size_small", TooltipKey = "tooltip_brush_small" };
expandableBtnModel.SubButtons.Add(smallBrush);
expandableBtnModel.SubButtons.Add(new ToolbarStandardButton { Text = "brush_size_medium", TooltipKey = "tooltip_brush_medium" });
expandableBtnModel.OnSubButtonSelected = (selectedSubButton) => {
// selectedSubButton.Text 에는 이제 다국어 키가 들어있습니다.
// 실제 표시된 텍스트를 로그로 남기려면 LocalizationManager를 사용해야 합니다.
string localizedSubButtonText = LocalizationManager.Instance != null ? LocalizationManager.Instance.GetString(selectedSubButton.Text) : selectedSubButton.Text;
Debug.Log($"브러시 크기 '{localizedSubButtonText}' 선택됨 (주 버튼 업데이트)");
};
// --- 툴바 모델 구성 끝 ---
// ToolbarView 초기화 및 렌더링
if (toolbarContainer != null)
{
mainToolbarView.Initialize(mainToolbar, toolbarContainer);
}
else
{
Debug.LogError("ToolbarContainer가 할당되지 않았습니다.");
}
// 예시: 모델 상태를 코드로 변경하고 UI가 업데이트되는지 테스트
// StartCoroutine(TestModelChange(saveBtnModel, muteToggleModel));
}
// System.Collections.IEnumerator TestModelChange(ToolbarStandardButton standard, ToolbarToggleButton toggle)
// {
// yield return new WaitForSeconds(2f);
// Debug.Log("모델 변경 테스트: 저장 버튼 비활성화 및 텍스트 변경");
// standard.Text = "저장됨";
// standard.IsEnabled = false;
//
// yield return new WaitForSeconds(2f);
// Debug.Log("모델 변경 테스트: 음소거 토글 상태 변경");
// toggle.IsSelected = true;
// }
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2c8047638e9a7ca4495254682609d580

View File

@@ -0,0 +1,32 @@
namespace UVC.UI.ToolBar
{
/// <summary>
/// 라디오 버튼 그룹 내에서 사용되는 버튼입니다.
/// 그룹 내 하나의 버튼만 선택될 수 있습니다.
/// </summary>
public class ToolbarRadioButton : ToolbarToggleButton
{
public string GroupName { get; private set; }
internal ToolbarRadioButtonGroup RadioGroup { get; set; }
public ToolbarRadioButton(string groupName)
{
GroupName = groupName;
}
public override void ExecuteClick()
{
if (IsEnabled)
{
// 라디오 버튼은 직접 IsSelected를 토글하지 않고, 그룹에 의해 상태가 결정됩니다.
// 그룹이 SetSelected를 호출하면, 해당 버튼의 IsSelected가 true로 설정되고,
// 다른 버튼들은 false로 설정되면서 각자의 OnStateChanged 이벤트가 발생합니다.
RadioGroup?.SetSelected(this);
// OnClick은 그룹에 의해 선택이 확정되었을 때 호출되도록 RadioGroup.SetSelected 내부에서 처리하거나,
// 여기서 IsSelected 상태를 확인 후 호출할 수 있습니다.
// 현재 구조에서는 RadioGroup.SetSelected가 IsSelected를 변경하고, IsSelected의 setter가 OnStateChanged를 호출합니다.
// OnClick은 ToolbarToggleButton의 ExecuteClick에서 이미 호출될 수 있으므로 중복 호출을 피하거나 의도에 맞게 조정합니다.
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 451c776768fed71479e8c7a4a73818ea

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace UVC.UI.ToolBar
{
/// <summary>
/// 라디오 버튼들을 그룹으로 관리하여 하나만 선택되도록 합니다.
/// ToolbarRadioButtonGroup, ToolbarExpandableButton 클래스는 이전 제안과 거의 동일하게 유지하되,
/// 상태 변경 시 NotifyStateChanged() 호출을 고려할 수 있습니다.
/// 예를 들어 ToolbarExpandableButton에서 SelectSubButton 후 주 버튼의 Text, Icon이 변경되면 NotifyStateChanged() 호출
/// </summary>
public class ToolbarRadioButtonGroup
{
private List<ToolbarRadioButton> _buttons = new List<ToolbarRadioButton>();
public ToolbarRadioButton SelectedButton { get; private set; }
public void RegisterButton(ToolbarRadioButton button)
{
if (!_buttons.Contains(button))
{
_buttons.Add(button);
button.RadioGroup = this;
}
}
public void SetSelected(ToolbarRadioButton buttonToSelect)
{
if (!_buttons.Contains(buttonToSelect) || !buttonToSelect.IsEnabled) return;
SelectedButton = buttonToSelect;
foreach (var btn in _buttons)
{
bool isNowSelected = (btn == SelectedButton);
if (btn.IsSelected != isNowSelected) // 실제 상태 변경이 있을 때만
{
btn.IsSelected = isNowSelected; // 이 setter가 OnStateChanged를 호출
// btn.OnClick?.Invoke(); // OnClick은 버튼 자체의 ExecuteClick에서 관리하는 것이 더 적절할 수 있음
// 또는 선택 변경 시 항상 호출하고 싶다면 여기에 둠
// btn.OnToggle?.Invoke(isNowSelected); // OnToggle은 IsSelected setter에서 OnToggleStateChanged로 대체 가능
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a6099bb327827504c8c4e36c74c7c507

View File

@@ -0,0 +1,7 @@
namespace UVC.UI.ToolBar
{
/// <summary>
/// 툴바 구분선을 나타냅니다.
/// </summary>
public class ToolbarSeparator : IToolbarItem { }
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41943a25123704b4f82ec6417863d158

View File

@@ -0,0 +1,16 @@
namespace UVC.UI.ToolBar
{
/// <summary>
/// 일반적인 클릭 버튼입니다.
/// </summary>
public class ToolbarStandardButton : ToolbarButtonBase
{
public override void ExecuteClick()
{
if (IsEnabled && OnClick != null)
{
OnClick.Invoke();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0d7cc1da90c7117449ff98ba4600c3ce

View File

@@ -0,0 +1,38 @@
using System;
namespace UVC.UI.ToolBar
{
/// <summary>
/// 클릭할 때마다 선택/해제 상태가 변경되는 토글 버튼입니다.
/// </summary>
public class ToolbarToggleButton : ToolbarButtonBase
{
public event Action<bool> OnToggleStateChanged; // IsSelected 변경 시 IsSelected 값을 전달하는 이벤트
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
OnToggleStateChanged?.Invoke(_isSelected); // IsSelected 값과 함께 이벤트 발생
NotifyStateChanged(); // 일반 상태 변경 이벤트도 발생
}
}
}
public Action<bool> OnToggle { get; set; }
public override void ExecuteClick()
{
if (IsEnabled)
{
IsSelected = !IsSelected; // IsSelected의 setter가 OnStateChanged를 호출
OnClick?.Invoke(); // OnClick은 상태 변경과 별개로 클릭 시 항상 호출되도록 할 수 있음
OnToggle?.Invoke(IsSelected); // 기존 OnToggle 로직 유지
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6407c881188c7c04c9cb4efb1dd7b4ce

View File

@@ -0,0 +1,453 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UVC.Locale;
namespace UVC.UI.ToolBar
{
public class ToolbarView : MonoBehaviour
{
public Toolbar ToolbarModel { get; private set; }
public GameObject standardButtonPrefab;
public GameObject toggleButtonPrefab;
public GameObject radioButtonPrefab;
public GameObject expandableButtonPrefab;
public GameObject separatorPrefab;
public GameObject subMenuPanelPrefab;
public GameObject tooltipPrefab; // 툴팁 UI 프리팹
private Transform toolbarContainer;
// UI 요소와 모델을 매핑하여 상태 업데이트 시 사용
private Dictionary<ToolbarButtonBase, GameObject> _modelToGameObjectMap = new Dictionary<ToolbarButtonBase, GameObject>();
private GameObject _activeTooltipInstance;
private TextMeshProUGUI _tooltipTextElement;
private RectTransform _tooltipRectTransform;
public void Initialize(Toolbar toolbarModel, Transform container)
{
ToolbarModel = toolbarModel;
toolbarContainer = container;
// UI 레이아웃: toolbarContainer에 VerticalLayoutGroup 또는 HorizontalLayoutGroup 컴포넌트를 추가하고
// 자식 크기 제어 (Child Force Expand 등) 옵션을 조정하면 UI 요소들이 자동으로 정렬됩니다.
// 예: var layoutGroup = toolbarContainer.GetComponent<HorizontalLayoutGroup>();
// if (layoutGroup == null) layoutGroup = toolbarContainer.gameObject.AddComponent<HorizontalLayoutGroup>();
// layoutGroup.childControlHeight = true; layoutGroup.childControlWidth = false; // 예시 설정
RenderToolbar();
if (tooltipPrefab != null)
{
_activeTooltipInstance = Instantiate(tooltipPrefab, transform); // ToolbarView의 자식으로 생성 (Canvas 내 다른 곳이어도 됨)
_tooltipTextElement = _activeTooltipInstance.GetComponentInChildren<TextMeshProUGUI>();
_tooltipRectTransform = _activeTooltipInstance.GetComponent<RectTransform>();
_activeTooltipInstance.SetActive(false); // 처음에는 숨김
}
}
private void ClearToolbar()
{
foreach (var pair in _modelToGameObjectMap)
{
if (pair.Key != null)
{
pair.Key.OnStateChanged -= () => UpdateItemVisuals(pair.Key); // 이벤트 구독 해제
if (pair.Key is ToolbarToggleButton toggleButton)
{
toggleButton.OnToggleStateChanged -= (isSelected) => UpdateToggleVisuals(toggleButton, isSelected);
}
}
if (pair.Value != null)
{
// TooltipHandler 이벤트 구독 해제 (필요 시)
TooltipHandler handler = pair.Value.GetComponent<TooltipHandler>();
if (handler != null)
{
handler.OnPointerEnterAction = null;
handler.OnPointerExitAction = null;
}
Destroy(pair.Value);
}
}
_modelToGameObjectMap.Clear();
_toggleGroups.Clear(); // 토글 그룹도 정리
if (currentSubMenu != null) Destroy(currentSubMenu);
HideTooltip(); // 툴바가 클리어될 때 툴팁도 숨김
}
private void RenderToolbar()
{
ClearToolbar(); // 기존 UI 및 이벤트 구독 정리
if (ToolbarModel == null || ToolbarModel.Items == null) return;
foreach (var item in ToolbarModel.Items)
{
GameObject itemObj = null;
if (item is ToolbarSeparator)
{
itemObj = Instantiate(separatorPrefab, toolbarContainer);
}
else if (item is ToolbarButtonBase buttonModel) // 모든 버튼 타입의 기본 처리
{
// 적절한 프리팹 선택
if (buttonModel is ToolbarRadioButton) itemObj = Instantiate(radioButtonPrefab, toolbarContainer);
else if (buttonModel is ToolbarToggleButton) itemObj = Instantiate(toggleButtonPrefab, toolbarContainer);
else if (buttonModel is ToolbarExpandableButton) itemObj = Instantiate(expandableButtonPrefab, toolbarContainer);
else if (buttonModel is ToolbarStandardButton) itemObj = Instantiate(standardButtonPrefab, toolbarContainer);
// else // 다른 커스텀 버튼 타입이 있다면 추가
if (itemObj != null)
{
_modelToGameObjectMap[buttonModel] = itemObj;
buttonModel.OnStateChanged += () => UpdateItemVisuals(buttonModel); // 모델 상태 변경 시 UI 업데이트 구독
// 초기 UI 설정 및 이벤트 바인딩
SetupButtonVisualsAndInteractions(buttonModel, itemObj);
// 툴팁 핸들러 추가 및 설정
if (!string.IsNullOrEmpty(buttonModel.TooltipKey))
{
TooltipHandler tooltipHandler = itemObj.GetComponent<TooltipHandler>();
if (tooltipHandler == null) tooltipHandler = itemObj.AddComponent<TooltipHandler>();
tooltipHandler.TooltipKey = buttonModel.TooltipKey;
tooltipHandler.OnPointerEnterAction = HandlePointerEnter;
tooltipHandler.OnPointerExitAction = HandlePointerExit;
}
}
}
}
}
private void HandlePointerEnter(string tooltipKey, Vector3 mousePosition)
{
if (LocalizationManager.Instance != null && _tooltipTextElement != null)
{
string tooltipText = LocalizationManager.Instance.GetString(tooltipKey);
if (string.IsNullOrEmpty(tooltipText) || tooltipText == $"[{tooltipKey}]") // 번역 실패 또는 키 그대로 반환 시
{
// 번역이 없거나 실패한 경우 툴팁을 표시하지 않거나, 기본 메시지를 표시할 수 있습니다.
// 여기서는 표시하지 않도록 합니다.
HideTooltip();
return;
}
ShowTooltip(tooltipText, mousePosition);
}
}
private void HandlePointerExit()
{
HideTooltip();
}
private void ShowTooltip(string text, Vector3 mousePosition)
{
if (_activeTooltipInstance == null || _tooltipTextElement == null) return;
_tooltipTextElement.text = text;
_activeTooltipInstance.SetActive(true);
// 툴팁 위치 설정 (마우스 커서 기준, 화면 가장자리 넘어가지 않도록 조정 필요)
// Canvas Render Mode에 따라 위치 계산 방식이 달라질 수 있습니다.
// Screen Space - Overlay 예시:
if (_tooltipRectTransform != null)
{
// TextMeshPro의 preferredWidth/Height를 사용하여 크기 조절
_tooltipTextElement.ForceMeshUpdate(); // 텍스트 변경 후 메시 업데이트 강제
Vector2 textSize = _tooltipTextElement.GetRenderedValues(false);
Vector2 padding = new Vector2(10, 5); // 툴팁 내부 여백
_tooltipRectTransform.sizeDelta = textSize + padding * 2;
// 화면 가장자리 처리 (간단한 예시)
Vector2 localPoint;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
transform.root as RectTransform, // Canvas의 최상위 RectTransform
mousePosition,
transform.root.GetComponent<Canvas>().worldCamera, // Screen Space - Camera 경우 필요
out localPoint
);
// 툴팁을 마우스 오른쪽 아래에 표시 (오프셋 조정 가능)
_tooltipRectTransform.localPosition = localPoint + new Vector2(_tooltipRectTransform.sizeDelta.x * 0.5f + 10f, -_tooltipRectTransform.sizeDelta.y * 0.5f - 5f);
// 화면 경계 체크 및 위치 조정 (더 정교한 로직 필요)
Vector3[] corners = new Vector3[4];
_tooltipRectTransform.GetWorldCorners(corners);
float screenWidth = Screen.width;
float screenHeight = Screen.height;
// 오른쪽 경계 넘어감
if (corners[2].x > screenWidth)
{
Vector3 currentPos = _tooltipRectTransform.position;
currentPos.x -= (corners[2].x - screenWidth);
_tooltipRectTransform.position = currentPos;
}
// 왼쪽 경계 넘어감
if (corners[0].x < 0)
{
Vector3 currentPos = _tooltipRectTransform.position;
currentPos.x -= corners[0].x;
_tooltipRectTransform.position = currentPos;
}
// 아래쪽 경계 넘어감 (툴팁을 위로 표시하도록 변경 가능)
if (corners[0].y < 0)
{
Vector3 currentPos = _tooltipRectTransform.position;
currentPos.y -= corners[0].y; // 위로 올림
// 또는 마우스 위쪽으로 위치 변경
// _tooltipRectTransform.localPosition = localPoint + new Vector2(_tooltipRectTransform.sizeDelta.x * 0.5f + 10f, _tooltipRectTransform.sizeDelta.y * 0.5f + 5f);
_tooltipRectTransform.position = currentPos;
}
// 위쪽 경계 넘어감
if (corners[1].y > screenHeight)
{
Vector3 currentPos = _tooltipRectTransform.position;
currentPos.y -= (corners[1].y - screenHeight);
_tooltipRectTransform.position = currentPos;
}
}
}
private void HideTooltip()
{
if (_activeTooltipInstance != null)
{
_activeTooltipInstance.SetActive(false);
}
}
// 버튼 모델과 게임 오브젝트를 받아 초기 시각적 요소 설정 및 UI 상호작용을 연결합니다.
private void SetupButtonVisualsAndInteractions(ToolbarButtonBase model, GameObject itemObj)
{
// 공통 UI 요소 업데이트 (Text, Icon, Enabled)
UpdateCommonButtonVisuals(model, itemObj);
// 타입별 UI 요소 및 이벤트 설정
if (model is ToolbarRadioButton radioModel)
{
Toggle toggle = itemObj.GetComponent<Toggle>();
if (toggle != null)
{
ToggleGroup toggleGroup = GetOrCreateToggleGroup(radioModel.GroupName);
toggle.group = toggleGroup;
toggle.SetIsOnWithoutNotify(radioModel.IsSelected); // 초기 상태 설정 (이벤트 발생 방지)
toggle.onValueChanged.AddListener((isSelected) =>
{
// UI에서 사용자가 직접 토글한 경우 모델 업데이트
// 중요: 라디오 버튼은 그룹에 의해 선택이 관리되므로, isSelected가 true일 때만 모델 업데이트 요청
if (isSelected) radioModel.ExecuteClick(); // 모델의 ExecuteClick -> RadioGroup.SetSelected 호출
});
// IsSelected 변경은 OnStateChanged를 통해 UpdateItemVisuals에서 처리되거나,
// 좀 더 명시적인 OnToggleStateChanged 이벤트를 사용할 수 있습니다.
radioModel.OnToggleStateChanged += (isSelected) => UpdateToggleVisuals(radioModel, isSelected);
}
}
else if (model is ToolbarToggleButton toggleModel)
{
Toggle toggle = itemObj.GetComponent<Toggle>();
if (toggle != null)
{
toggle.SetIsOnWithoutNotify(toggleModel.IsSelected);
toggle.onValueChanged.AddListener((isSelected) =>
{
toggleModel.ExecuteClick(); // 모델의 ExecuteClick이 IsSelected를 변경하고 OnStateChanged 호출
});
toggleModel.OnToggleStateChanged += (isSelected) => UpdateToggleVisuals(toggleModel, isSelected);
}
}
else if (model is ToolbarExpandableButton expandableModel)
{
Button uiButton = itemObj.GetComponent<Button>();
if (uiButton != null)
{
uiButton.onClick.AddListener(() =>
{
expandableModel.ExecuteClick();
ToggleSubMenu(expandableModel, itemObj); // itemObj 전달하여 위치 기준으로 삼기
});
}
}
else if (model is ToolbarStandardButton standardModel)
{
Button uiButton = itemObj.GetComponent<Button>();
if (uiButton != null)
{
uiButton.onClick.AddListener(() =>
{
standardModel.ExecuteClick();
});
}
}
}
// 모델의 상태가 변경되었을 때 호출되어 모든 관련 UI를 업데이트합니다.
private void UpdateItemVisuals(ToolbarButtonBase model)
{
if (_modelToGameObjectMap.TryGetValue(model, out GameObject itemObj))
{
UpdateCommonButtonVisuals(model, itemObj); // 공통 부분 업데이트
// 타입별 특화된 부분 업데이트 (예: Toggle의 isOn 상태)
if (model is ToolbarToggleButton tb) // ToolbarRadioButton도 여기에 해당
{
Toggle toggle = itemObj.GetComponent<Toggle>();
if (toggle != null && toggle.isOn != tb.IsSelected) // UI와 모델 상태가 다를 때만 업데이트
{
toggle.SetIsOnWithoutNotify(tb.IsSelected);
}
}
// 다른 버튼 타입에 대한 추가적인 시각적 업데이트 로직
}
}
// 특정 토글 버튼/라디오 버튼의 IsSelected 상태가 모델에서 변경되었을 때 호출됩니다.
private void UpdateToggleVisuals(ToolbarToggleButton model, bool isSelected)
{
if (_modelToGameObjectMap.TryGetValue(model, out GameObject itemObj))
{
Toggle toggle = itemObj.GetComponent<Toggle>();
if (toggle != null && toggle.isOn != isSelected)
{
toggle.SetIsOnWithoutNotify(isSelected);
}
}
}
// 공통 버튼 시각적 요소(텍스트, 아이콘, 활성화 상태)를 업데이트합니다.
private void UpdateCommonButtonVisuals(ToolbarButtonBase model, GameObject itemObj)
{
// 프리팹 구조에 대한 가정:
// - 텍스트는 TextMeshProUGUI 컴포넌트를 가진 자식 오브젝트에 표시됩니다.
// - 아이콘은 "Icon"이라는 이름의 자식 오브젝트에 Image 컴포넌트로 표시됩니다.
// 이러한 구조는 프로젝트의 프리팹 표준에 맞게 조정해야 합니다.
TextMeshProUGUI buttonTextComponent = itemObj.GetComponentInChildren<TextMeshProUGUI>(true);
if (buttonTextComponent != null)
{
if (LocalizationManager.Instance != null && !string.IsNullOrEmpty(model.Text))
{
// model.Text에는 이제 다국어 키가 저장되어 있습니다.
buttonTextComponent.text = LocalizationManager.Instance.GetString(model.Text);
}
else
{
// LocalizationManager가 없거나 Text(키)가 비어있는 경우, 키를 그대로 표시하거나 기본값 처리
buttonTextComponent.text = model.Text;
}
}
Transform iconTransform = itemObj.transform.Find("Icon"); // 프리팹에 "Icon" 자식 오브젝트가 있다고 가정
if (iconTransform != null)
{
Image buttonIcon = iconTransform.GetComponent<Image>();
if (buttonIcon != null)
{
buttonIcon.sprite = model.Icon;
buttonIcon.gameObject.SetActive(model.Icon != null);
}
}
// 상호작용 가능 상태 업데이트
Selectable selectable = itemObj.GetComponent<Selectable>(); // Button, Toggle 등
if (selectable != null)
{
selectable.interactable = model.IsEnabled;
}
}
private Dictionary<string, ToggleGroup> _toggleGroups = new Dictionary<string, ToggleGroup>();
private ToggleGroup GetOrCreateToggleGroup(string groupName)
{
if (!_toggleGroups.TryGetValue(groupName, out ToggleGroup group))
{
GameObject groupObj = new GameObject($"ToggleGroup_{groupName}");
groupObj.transform.SetParent(toolbarContainer);
group = groupObj.AddComponent<ToggleGroup>();
group.allowSwitchOff = false; // 라디오 버튼 그룹은 일반적으로 하나는 선택되어 있도록 함
_toggleGroups.Add(groupName, group);
}
return group;
}
private GameObject currentSubMenu = null;
// expandableButtonObj는 확장 메뉴의 위치를 잡기 위해 사용될 수 있습니다.
private void ToggleSubMenu(ToolbarExpandableButton expandableButton, GameObject expandableButtonObj)
{
if (currentSubMenu != null)
{
Destroy(currentSubMenu);
currentSubMenu = null;
return;
}
if (subMenuPanelPrefab == null || expandableButton.SubButtons.Count == 0) return;
currentSubMenu = Instantiate(subMenuPanelPrefab, transform); // ToolbarView의 자식으로 생성 후 위치 조정
// 위치 조정 로직: expandableButtonObj의 위치를 기준으로 currentSubMenu의 RectTransform을 조정합니다.
// 예: currentSubMenu.transform.position = expandableButtonObj.transform.position + offset;
RectTransform panelRect = currentSubMenu.GetComponent<RectTransform>();
// 하위 메뉴 패널에 LayoutGroup이 있다면 자식 버튼들이 자동으로 정렬됩니다.
foreach (var subItemBase in expandableButton.SubButtons)
{
if (subItemBase is ToolbarButtonBase subItem) // 모든 하위 아이템은 ToolbarButtonBase라고 가정
{
// 하위 버튼도 적절한 프리팹을 사용해야 합니다. 여기서는 standardButtonPrefab을 예시로 사용합니다.
// 실제로는 subItem의 타입에 따라 다른 프리팹을 선택할 수 있습니다.
GameObject subButtonObj = Instantiate(standardButtonPrefab, panelRect); // 패널의 자식으로 생성
// 하위 버튼의 시각적 요소 설정 및 상호작용 연결
UpdateCommonButtonVisuals(subItem, subButtonObj); // 공통 시각 요소 업데이트
Button subUiButton = subButtonObj.GetComponent<Button>();
if (subUiButton != null)
{
subUiButton.interactable = subItem.IsEnabled; // 상호작용 상태 설정
subUiButton.onClick.AddListener(() =>
{
expandableButton.SelectSubButton(subItem); // 모델 업데이트 및 주 버튼 외형 변경 요청
// 주 버튼 UI는 expandableButton의 OnStateChanged 이벤트에 의해 자동으로 업데이트됩니다.
Destroy(currentSubMenu);
currentSubMenu = null;
});
}
// 하위 버튼 툴팁 처리
if (!string.IsNullOrEmpty(subItem.TooltipKey))
{
TooltipHandler tooltipHandler = subButtonObj.GetComponent<TooltipHandler>();
if (tooltipHandler == null) tooltipHandler = subButtonObj.AddComponent<TooltipHandler>();
tooltipHandler.TooltipKey = subItem.TooltipKey;
tooltipHandler.OnPointerEnterAction = HandlePointerEnter;
tooltipHandler.OnPointerExitAction = HandlePointerExit;
}
// 하위 버튼 모델의 OnStateChanged도 구독하여 하위 버튼 자체의 상태 변경(예: 텍스트)도 반영할 수 있습니다.
// subItem.OnStateChanged += () => UpdateCommonButtonVisuals(subItem, subButtonObj);
// _modelToGameObjectMap에 하위 버튼도 추가하여 ClearToolbar에서 정리되도록 해야 합니다. (선택적 확장)
}
}
}
void OnDestroy()
{
// 씬 전환 등으로 ToolbarView가 파괴될 때 모든 이벤트 구독 해제
ClearToolbar();
if (_activeTooltipInstance != null)
{
Destroy(_activeTooltipInstance); // 툴팁 인스턴스도 파괴
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 60240c8d04420604681084344d3a0253

View File

@@ -0,0 +1,26 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace UVC.UI.ToolBar
{
public class TooltipHandler : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
public System.Action<string, Vector3> OnPointerEnterAction; // 툴팁 내용(키), 마우스 위치
public System.Action OnPointerExitAction;
public string TooltipKey { get; set; }
public void OnPointerEnter(PointerEventData eventData)
{
if (!string.IsNullOrEmpty(TooltipKey) && gameObject.GetComponent<Selectable>()?.interactable == true) // 버튼이 활성화 상태일 때만
{
OnPointerEnterAction?.Invoke(TooltipKey, Input.mousePosition);
}
}
public void OnPointerExit(PointerEventData eventData)
{
OnPointerExitAction?.Invoke();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 266dd70132eff3d4eb32c995c009634a

View File

@@ -43,6 +43,8 @@
"button_submit": "제출", "button_submit": "제출",
"button_reset": "초기화", "button_reset": "초기화",
"button_retry": "재시도", "button_retry": "재시도",
"button_mute": "음소거",
"button_brush_size": "브러시 크기",
"menu": "메뉴", "menu": "메뉴",
"menu_file": "파일", "menu_file": "파일",
"menu_file_new": "새로 만들기", "menu_file_new": "새로 만들기",
@@ -59,10 +61,21 @@
"menu_language": "언어", "menu_language": "언어",
"menu_lang_korean": "한국어", "menu_lang_korean": "한국어",
"menu_lang_english": "영어", "menu_lang_english": "영어",
"project_settings_key": "프로젝트 설정",
"menu_help": "도움말", "menu_help": "도움말",
"menu_about": "정보", "menu_about": "정보",
"warning": "경고" "tool_pen": "펜",
"tool_eraser": "지우개",
"project_settings_key": "프로젝트 설정",
"warning": "경고",
"brush_size_small": "작은 브러시",
"brush_size_medium": "중간 브러시",
"tooltip_save_button": "저장 버튼을 클릭하여 현재 작업을 저장합니다.",
"tooltip_mute_button": "음소거 버튼을 클릭하여 소리를 끕니다.",
"tooltip_pen_tool": "펜 도구를 선택하여 그림을 그립니다.",
"tooltip_eraser_tool": "지우개 도구를 선택하여 그림을 지웁니다.",
"tooltip_brush_size": "브러시 크기를 조정합니다.",
"tooltip_brush_small": "작은 브러시를 선택합니다.",
"tooltip_brush_medium": "중간 브러시를 선택합니다."
}, },
"en-US": { "en-US": {
"error": "Error", "error": "Error",
@@ -108,6 +121,8 @@
"button_submit": "Submit", "button_submit": "Submit",
"button_reset": "Reset", "button_reset": "Reset",
"button_retry": "Retry", "button_retry": "Retry",
"button_mute": "Mute",
"button_brush_size": "Brush Size",
"menu": "Menu", "menu": "Menu",
"menu_file": "File", "menu_file": "File",
"menu_file_new": "New", "menu_file_new": "New",
@@ -124,9 +139,20 @@
"menu_language": "Language", "menu_language": "Language",
"menu_lang_korean": "Korean", "menu_lang_korean": "Korean",
"menu_lang_english": "English", "menu_lang_english": "English",
"project_settings": "Project Setting",
"menu_help": "Help", "menu_help": "Help",
"menu_about": "About", "menu_about": "About",
"warning": "Warning" "tool_pen": "Pen",
"tool_eraser": "Eraser",
"project_settings": "Project Setting",
"warning": "Warning",
"brush_size_small": "Small Brush",
"brush_size_medium": "Medium Brush",
"tooltip_save_button": "Click the save button to save your current work.",
"tooltip_mute_button": "Click the mute button to turn off the sound.",
"tooltip_pen_tool": "Select the pen tool to draw.",
"tooltip_eraser_tool": "Select the eraser tool to erase drawings.",
"tooltip_brush_size": "Adjust the brush size.",
"tooltip_brush_small": "Select small brush.",
"tooltip_brush_medium": "Select medium brush."
} }
} }