820 lines
44 KiB
C#
820 lines
44 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using UVC.Extension;
|
|
using UVC.Locale; // 다국어 처리 네임스페이스
|
|
|
|
namespace UVC.UI.Menu
|
|
{
|
|
/// <summary>
|
|
/// 상단 메뉴 UI의 표시 및 상호작용을 관리하는 클래스입니다.
|
|
/// MenuItemData를 기반으로 실제 Unity UI 요소들을 생성하고 배치하며, 사용자 입력을 처리합니다.
|
|
/// 이 클래스는 상속을 통해 특정 프로젝트의 요구사항에 맞게 커스터마이징될 수 있도록 설계되었습니다.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <code>
|
|
/// <summary>
|
|
/// TopMenuView를 상속받아 특정 기능을 커스터마이징하는 예제 클래스입니다.
|
|
/// 예를 들어, 다른 프리팹 경로를 사용하거나 메뉴 아이템 레이아웃 방식을 변경할 수 있습니다.
|
|
/// </summary>
|
|
/// public class CustomTopMenuView : TopMenuView
|
|
/// {
|
|
/// /// <summary>
|
|
/// /// MonoBehaviour의 Awake 메시지입니다.
|
|
/// /// 부모 클래스의 Awake를 호출한 후, 추가적인 커스텀 초기화 로직을 수행합니다.
|
|
/// /// </summary>
|
|
/// protected override void Awake()
|
|
/// {
|
|
/// // 부모 클래스(TopMenuView)의 Awake 로직을 먼저 실행합니다.
|
|
/// // 이렇게 하면 프리팹 로드, 기본 설정 등이 먼저 처리됩니다.
|
|
/// base.Awake();
|
|
///
|
|
/// // 여기에 CustomTopMenuView만의 추가적인 초기화 코드를 작성할 수 있습니다.
|
|
/// ULog.Debug("CustomTopMenuView Awake 실행됨. 커스텀 프리팹 경로가 사용됩니다.");
|
|
/// }
|
|
///
|
|
/// /// <summary>
|
|
/// /// 메뉴 아이템이 클릭되었을 때 호출되는 이벤트 핸들러를 커스터마이징 할 수 있습니다.
|
|
/// /// (참고: OnMenuItemClicked 이벤트는 TopMenuController에서 구독하여 주로 처리합니다.
|
|
/// /// View에서 직접 처리해야 하는 로직이 있다면 이와 유사한 방식으로 메서드를 오버라이드 하거나
|
|
/// /// Awake 등에서 이벤트 핸들러를 추가/제거 할 수 있습니다.)
|
|
/// /// 이 예제에서는 ToggleSubMenuDisplay를 오버라이드하여 하위 메뉴 토글 시 로그를 남깁니다.
|
|
/// /// </summary>
|
|
/// protected override void ToggleSubMenuDisplay(MenuItemData itemData, GameObject menuItemInstance)
|
|
/// {
|
|
/// ULog.Debug($"Custom ToggleSubMenuDisplay: {itemData.DisplayName} 클릭됨.");
|
|
/// base.ToggleSubMenuDisplay(itemData, menuItemInstance); // 부모의 원래 로직 실행
|
|
/// }
|
|
///
|
|
/// // 필요에 따라 다른 virtual 메서드들 (CreateMenuItems, ClearMenuItems 등)도
|
|
/// // 오버라이드하여 동작을 변경하거나 확장할 수 있습니다.
|
|
/// }
|
|
/// </code>
|
|
/// </example>
|
|
public class TopMenuView : MonoBehaviour
|
|
{
|
|
[Header("UI References")]
|
|
[Tooltip("메인 메뉴 아이템을 위한 프리팹입니다.")]
|
|
[SerializeField] public GameObject menuItemPrefab;
|
|
|
|
[Tooltip("하위 메뉴 아이템을 위한 프리팹입니다.")]
|
|
[SerializeField] public GameObject subMenuItemPrefab;
|
|
|
|
[Tooltip("메뉴 구분선을 위한 프리팹입니다. (선택 사항)")]
|
|
[SerializeField] public GameObject menuSeparatorPrefab;
|
|
|
|
[Tooltip("최상위 메뉴 아이템들이 배치될 부모 Transform입니다.")]
|
|
[SerializeField] public Transform menuContainer;
|
|
|
|
[Tooltip("하위 메뉴 아이템들이 배치될 부모 Transform입니다.")]
|
|
[SerializeField] public Transform subMenuContainer;
|
|
|
|
[Header("UI Element Names")]
|
|
|
|
[Tooltip("하위 메뉴가 있음을 나타내는 화살표 UI GameObject의 이름입니다. (메뉴 아이템 프리팹 내부에 존재)")]
|
|
[SerializeField] public string subMenuArrowName = "SubMenuArrow";
|
|
|
|
[Tooltip("단축키를 표시하는 TextMeshProUGUI의 이름입니다. (SubMenuItemPrefab 내부에 존재)")]
|
|
[SerializeField] public string shortcutTextName = "ShorcutText";
|
|
|
|
[Header("Layout Settings")]
|
|
[Tooltip("1차 메뉴 아이템과 그 하위 메뉴 컨테이너 간의 간격 (수평, 수직)")]
|
|
[SerializeField] public Vector2 menuDepthSpace = new Vector2(0, -5);
|
|
|
|
[Tooltip("하위 메뉴 아이템과 그 하위 메뉴 컨테이너 간의 간격 (수평, 수직)")]
|
|
[SerializeField] public Vector2 subMenuDepthSpace = new Vector2(-5, 0);
|
|
|
|
[Tooltip("1 depth 메뉴를 넓이를 글자 크기로 줄일지 여부")]
|
|
[SerializeField] public bool isShrinkMenuItemWidth = true;
|
|
|
|
[Tooltip("isShrinkMenuItemWidth가 활성화됐을 때, 텍스트 좌우에 추가될 여백입니다.")]
|
|
[SerializeField] public float menuItemWidthPadding = 20f;
|
|
|
|
/// <summary>
|
|
/// 메뉴 아이템이 클릭되었을 때 발생하는 이벤트입니다.
|
|
/// 클릭된 메뉴의 MenuItemData를 전달합니다.
|
|
/// </summary>
|
|
public event Action<MenuItemData> OnMenuItemClicked;
|
|
|
|
// --- 내부 상태 변수 ---
|
|
// 생성된 메뉴 아이템 GameObject들을 관리하는 딕셔너리 (키: ItemId, 값: GameObject)
|
|
protected Dictionary<string, GameObject> _menuItemObjects = new Dictionary<string, GameObject>();
|
|
protected Dictionary<string, GameObject> _subMenuContainerObjects = new Dictionary<string, GameObject>();
|
|
protected Dictionary<string, MenuItemData> _menuItemDataMap = new Dictionary<string, MenuItemData>();
|
|
protected LocalizationManager _locManager; // 다국어 처리를 위한 LocalizationManager 인스턴스
|
|
protected GameObject uiBlockerInstance; // 하위 메뉴가 열렸을 때 다른 UI 상호작용을 막기 위한 투명한 UI 요소
|
|
protected bool isAnySubMenuOpen = false; // 하나 이상의 하위 메뉴가 열려있는지 여부
|
|
|
|
/// <summary>
|
|
/// MonoBehaviour의 Awake 메시지입니다.
|
|
/// 메뉴 시스템 초기화에 필요한 리소스 로드 및 참조 설정을 수행합니다.
|
|
/// 상속 클래스에서 이 메서드를 오버라이드하여 추가적인 초기화 로직을 구현할 수 있습니다.
|
|
/// 오버라이드 시 `base.Awake()`를 호출하는 것을 권장합니다.
|
|
/// </summary>
|
|
protected virtual void Awake()
|
|
{
|
|
_locManager = LocalizationManager.Instance;
|
|
if (_locManager == null)
|
|
{
|
|
// LocalizationManager가 필수적인 경우, 여기서 에러를 발생시키거나 게임을 중단시킬 수 있습니다.
|
|
// 여기서는 경고만 기록하고 진행합니다.
|
|
Debug.LogWarning("LocalizationManager 인스턴스를 찾을 수 없습니다. 메뉴 텍스트가 제대로 표시되지 않을 수 있습니다.");
|
|
}
|
|
|
|
// Inspector에서 할당된 참조 확인
|
|
if (menuItemPrefab == null)
|
|
{
|
|
Debug.LogWarning("menuItemPrefab이 Inspector에서 할당되지 않았습니다.", this);
|
|
}
|
|
if (subMenuItemPrefab == null)
|
|
{
|
|
Debug.LogWarning("subMenuItemPrefab이 Inspector에서 할당되지 않았습니다.", this);
|
|
}
|
|
if (menuSeparatorPrefab == null)
|
|
{
|
|
// 구분선은 선택 사항일 수 있으므로, 경고 수준으로 처리합니다.
|
|
Debug.LogWarning("menuSeparatorPrefab이 Inspector에서 할당되지 않았습니다. 구분선 기능이 작동하지 않습니다.", this);
|
|
}
|
|
|
|
// 메뉴 컨테이너 확인
|
|
if (menuContainer == null)
|
|
{
|
|
Debug.LogWarning("menuContainer가 Inspector에서 할당되지 않았습니다. Inspector에서 참조를 설정해주세요.", this);
|
|
}
|
|
else
|
|
{
|
|
// 메뉴 컨테이너에 LayoutGroup이 있는지 확인하고, 없다면 경고를 표시합니다.
|
|
// 자동 배치를 위해 HorizontalLayoutGroup 또는 VerticalLayoutGroup이 필요합니다.
|
|
if (menuContainer.GetComponent<LayoutGroup>() == null)
|
|
{
|
|
Debug.LogWarning($"menuContainer '{menuContainer.name}'에 LayoutGroup 컴포넌트가 없습니다. 메뉴 아이템이 자동으로 배치되지 않을 수 있습니다. Inspector에서 HorizontalLayoutGroup 또는 VerticalLayoutGroup을 추가해주세요.");
|
|
}
|
|
}
|
|
|
|
// UI 블로커 생성
|
|
CreateUIBlocker();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 하위 메뉴가 열렸을 때 다른 UI 요소와의 원치 않는 상호작용을 막기 위한
|
|
/// 투명한 UI 블로커 GameObject를 생성하고 설정합니다.
|
|
/// 블로커는 최상위 Canvas의 자식으로 배치되어 전체 화면을 덮도록 설정됩니다.
|
|
/// </summary>
|
|
protected virtual void CreateUIBlocker()
|
|
{
|
|
uiBlockerInstance = new GameObject("TopMenuUIBlocker");
|
|
// Canvas를 찾아 그 자식으로 설정합니다. 씬에 여러 Canvas가 있다면, 적절한 Canvas를 찾는 로직이 필요할 수 있습니다.
|
|
Canvas canvas = GetComponentInParent<Canvas>();
|
|
Transform blockerParent = canvas != null ? canvas.transform : transform.parent; // Canvas가 없으면 TopMenuView의 부모를 사용
|
|
|
|
if (blockerParent == null) // 부모를 찾지 못한 극단적인 경우, TopMenuView 자신을 부모로 설정 (권장되지 않음)
|
|
{
|
|
blockerParent = transform;
|
|
Debug.LogWarning("TopMenuUIBlocker의 부모를 TopMenuView 자신으로 설정합니다. Canvas 하위에 배치하는 것을 권장합니다.");
|
|
}
|
|
uiBlockerInstance.transform.SetParent(blockerParent, false);
|
|
|
|
|
|
// 블로커의 RectTransform 설정 (화면 전체를 덮도록)
|
|
RectTransform blockerRect = uiBlockerInstance.AddComponent<RectTransform>();
|
|
blockerRect.anchorMin = Vector2.zero; // (0,0)
|
|
blockerRect.anchorMax = Vector2.one; // (1,1)
|
|
blockerRect.offsetMin = Vector2.zero; // left, bottom
|
|
blockerRect.offsetMax = Vector2.zero; // right, top
|
|
|
|
// 블로커의 이미지 설정 (클릭 이벤트를 받기 위함, 색상은 거의 투명하게)
|
|
Image blockerImage = uiBlockerInstance.AddComponent<Image>();
|
|
blockerImage.color = new Color(0, 0, 0, 0.001f); // 매우 낮은 알파값으로 거의 보이지 않게 설정
|
|
blockerImage.raycastTarget = true; // Raycast를 받아야 클릭 이벤트 감지 가능
|
|
|
|
// 블로커에 버튼 컴포넌트 추가 및 클릭 이벤트 리스너 설정
|
|
Button blockerButton = uiBlockerInstance.AddComponent<Button>();
|
|
blockerButton.transition = Selectable.Transition.None; // 시각적 변화 없음
|
|
blockerButton.onClick.AddListener(CloseAllOpenSubMenus); // 클릭 시 모든 하위 메뉴 닫기
|
|
|
|
// 블로커의 초기 상태는 비활성화
|
|
uiBlockerInstance.SetActive(false);
|
|
|
|
// 블로커의 렌더링 순서(Sibling Index) 조정: 메뉴 바로 뒤(UI상 바로 뒤)에 오도록 설정
|
|
// menuContainer가 blockerParent의 직계 자식인 경우를 기준으로 함
|
|
if (menuContainer != null && menuContainer.parent == blockerParent)
|
|
{
|
|
// menuContainer 바로 뒤에 오도록 (SiblingIndex가 작을수록 먼저 그려짐 - 뒤에 위치)
|
|
// 블로커가 메뉴보다 먼저 그려져야 메뉴를 가리지 않으면서 메뉴 외 영역 클릭을 막을 수 있음.
|
|
// 하지만 UI 이벤트는 앞쪽에 있는 것부터 받으므로, 블로커가 메뉴보다 뒤에 있어야 함.
|
|
// 따라서, 블로커는 메뉴 시스템 전체보다 뒤에 위치해야 클릭을 가로챌 수 있습니다.
|
|
// CreateUIBlocker 호출 시점에는 menuContainer의 sibling index가 확정되지 않았을 수 있으므로,
|
|
// UpdateBlockerVisibility에서 최종적으로 조정하는 것이 더 안전할 수 있습니다.
|
|
// 여기서는 일단 menuContainer의 뒤에 두도록 시도합니다.
|
|
// 여기서는 일단 menuContainer의 뒤에 두도록 시도합니다.
|
|
int siblingIndex = menuContainer.GetSiblingIndex();
|
|
uiBlockerInstance.transform.SetSiblingIndex(Math.Max(0, siblingIndex - 1));
|
|
}
|
|
else if (transform.parent == blockerParent)
|
|
{
|
|
// TopMenuView 자체가 blockerParent의 자식인 경우, TopMenuView 뒤에 블로커를 둡니다.
|
|
int siblingIndex = transform.GetSiblingIndex();
|
|
uiBlockerInstance.transform.SetSiblingIndex(Math.Max(0, siblingIndex - 1));
|
|
}
|
|
else
|
|
{
|
|
// 그 외의 경우, 블로커를 부모의 마지막 자식으로 보내 UI 요소들 중 가장 앞에 오도록 합니다.
|
|
// 이렇게 하면 다른 UI 요소들의 클릭을 막을 수 있습니다.
|
|
uiBlockerInstance.transform.SetAsLastSibling();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 제공된 MenuItemData 리스트를 기반으로 실제 UI GameObject들을 생성하고 배치합니다.
|
|
/// 이 메서드는 재귀적으로 호출되어 하위 메뉴들도 생성합니다.
|
|
/// </summary>
|
|
/// <param name="items">생성할 메뉴 아이템들의 데이터 리스트입니다.</param>
|
|
/// <param name="parentContainer">생성된 메뉴 아이템들이 자식으로 추가될 부모 Transform입니다.</param>
|
|
/// <param name="depth">현재 메뉴의 깊이입니다. 최상위 메뉴는 0입니다.</param>
|
|
public virtual void CreateMenuItems(List<MenuItemData> items, Transform parentContainer, int depth = 0)
|
|
{
|
|
if (items == null || parentContainer == null)
|
|
{
|
|
Debug.LogError("메뉴 아이템 생성에 필요한 'items' 또는 'parentContainer'가 null입니다.");
|
|
return;
|
|
}
|
|
|
|
if (items.Count == 0 && depth > 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < items.Count; i++)
|
|
{
|
|
MenuItemData itemData = items[i];
|
|
_menuItemDataMap[itemData.ItemId] = itemData;
|
|
GameObject prefabToUse = null;
|
|
|
|
if (itemData.IsSeparator) // 구분선 아이템인 경우
|
|
{
|
|
if (menuSeparatorPrefab == null)
|
|
{
|
|
Debug.LogError("menuSeparatorPrefab이 할당되지 않았습니다. 구분선을 생성할 수 없습니다.");
|
|
continue; // 다음 아이템으로 넘어감
|
|
}
|
|
GameObject separatorInstance = Instantiate(menuSeparatorPrefab, parentContainer);
|
|
separatorInstance.name = $"Separator_{itemData.ItemId}_Depth{depth}";
|
|
_menuItemObjects[itemData.ItemId] = separatorInstance; // 관리 목록에 추가
|
|
}
|
|
else // 일반 메뉴 아이템인 경우
|
|
{
|
|
// 메뉴 깊이에 따라 사용할 프리팹 결정
|
|
if (depth == 0) // 1차 깊이 메뉴 (최상위 메뉴)
|
|
{
|
|
prefabToUse = menuItemPrefab;
|
|
if (prefabToUse == null)
|
|
{
|
|
Debug.LogError("menuItemPrefab이 할당되지 않았습니다. 1차 메뉴 아이템을 생성할 수 없습니다.");
|
|
continue;
|
|
}
|
|
}
|
|
else // 2차 깊이 이상 하위 메뉴
|
|
{
|
|
prefabToUse = subMenuItemPrefab;
|
|
if (prefabToUse == null)
|
|
{
|
|
Debug.LogError("subMenuItemPrefab이 할당되지 않았습니다. 하위 메뉴 아이템을 생성할 수 없습니다.");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
GameObject menuItemInstance = Instantiate(prefabToUse, parentContainer);
|
|
menuItemInstance.name = $"MenuItem_{itemData.ItemId}_Depth{depth}";
|
|
_menuItemObjects[itemData.ItemId] = menuItemInstance; // 관리 목록에 추가
|
|
|
|
// 메뉴 아이템 버튼 기능 설정
|
|
Button button = menuItemInstance.GetComponent<Button>();
|
|
|
|
// 메뉴 아이템 텍스트 설정
|
|
// 먼저 "Text"라는 이름의 자식을 찾고, 없으면 GetComponentInChildren으로 찾기
|
|
Transform textTransform = menuItemInstance.transform.Find("Text");
|
|
TextMeshProUGUI? buttonText = null;
|
|
if (textTransform != null)
|
|
{
|
|
buttonText = textTransform.GetComponent<TextMeshProUGUI>();
|
|
}
|
|
else
|
|
{
|
|
// "Text" 이름의 자식이 없으면 자식 중에서 TextMeshProUGUI를 찾음
|
|
buttonText = menuItemInstance.GetComponentInChildren<TextMeshProUGUI>(true);
|
|
}
|
|
|
|
if (buttonText != null && !string.IsNullOrEmpty(itemData.DisplayName))
|
|
{
|
|
buttonText.alpha = itemData.IsEnabled ? 1 : 0.25f;
|
|
if (_locManager != null)
|
|
{
|
|
buttonText.text = _locManager.GetString(itemData.DisplayName);
|
|
}
|
|
else
|
|
{
|
|
// LocalizationManager가 없는 경우, 키 값을 그대로 표시 (개발 중 확인 용도)
|
|
Debug.LogWarning($"LocalizationManager 인스턴스가 없어 메뉴 아이템 텍스트를 키 값으로 설정합니다: {itemData.DisplayName}");
|
|
buttonText.text = itemData.DisplayName;
|
|
}
|
|
}
|
|
|
|
// 2차 메뉴 이상(depth > 0)일 때 단축키 텍스트 설정
|
|
if (depth > 0 && !string.IsNullOrEmpty(shortcutTextName))
|
|
{
|
|
Debug.Log($"Setting shortcut for menu item '{itemData.ItemId}': '{itemData.Shortcut}'");
|
|
Transform shortcutTransform = menuItemInstance.transform.Find(shortcutTextName);
|
|
if (shortcutTransform != null)
|
|
{
|
|
TextMeshProUGUI shortcutText = shortcutTransform.GetComponent<TextMeshProUGUI>();
|
|
if (shortcutText != null)
|
|
{
|
|
if (!string.IsNullOrEmpty(itemData.Shortcut))
|
|
{
|
|
shortcutText.text = itemData.Shortcut;
|
|
shortcutText.alpha = itemData.IsEnabled ? 1 : 0.25f;
|
|
}
|
|
else
|
|
{
|
|
shortcutText.text = string.Empty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// isShrinkMenuItemWidth가 true일 때 1차 메뉴 아이템의 너비를 텍스트에 맞게 조절합니다.
|
|
// 텍스트가 설정된 후에 이 로직을 실행해야 정확한 너비를 계산할 수 있습니다.
|
|
if (depth == 0 && isShrinkMenuItemWidth && buttonText != null)
|
|
{
|
|
buttonText.ForceMeshUpdate(); // 텍스트 변경 후 메쉬 강제 업데이트 (정확한 크기 계산 위함)
|
|
Vector2 textSize = buttonText.GetPreferredValues(buttonText.text); // 텍스트 내용에 따른 선호 크기 계산
|
|
RectTransform rect = menuItemInstance.GetComponent<RectTransform>();
|
|
textSize.x += menuItemWidthPadding; // 좌우 여백 추가
|
|
if (rect != null) rect.sizeDelta = new Vector2(textSize.x, rect.rect.height);
|
|
|
|
// LayoutElement가 없으면 추가
|
|
LayoutElement layoutElement = menuItemInstance.GetComponent<LayoutElement>() ?? menuItemInstance.AddComponent<LayoutElement>();
|
|
|
|
layoutElement.preferredWidth = buttonText.preferredWidth;
|
|
}
|
|
|
|
if (button != null)
|
|
{
|
|
button.onClick.RemoveAllListeners(); // 기존 리스너 제거 (프리팹에 설정된 것이 있을 수 있으므로)
|
|
button.onClick.AddListener(() =>
|
|
{
|
|
// 메뉴 아이템 클릭 시 등록된 이벤트 핸들러 호출
|
|
OnMenuItemClicked?.Invoke(itemData);
|
|
|
|
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
|
|
{
|
|
// 하위 메뉴가 있으면 해당 하위 메뉴의 표시 상태를 토글
|
|
ToggleSubMenuDisplay(itemData, menuItemInstance, depth);
|
|
}
|
|
else
|
|
{
|
|
// 하위 메뉴가 없는 아이템 클릭 시 모든 열린 하위 메뉴 닫기 (선택적 동작)
|
|
CloseAllOpenSubMenus();
|
|
}
|
|
});
|
|
// MenuItemData의 IsEnabled 상태에 따라 버튼의 상호작용 가능 여부 설정
|
|
button.interactable = itemData.IsEnabled;
|
|
}
|
|
|
|
// 하위 메뉴가 있는 경우, 하위 메뉴 관련 UI 처리
|
|
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
|
|
{
|
|
// 하위 메뉴 화살표 아이콘 표시
|
|
Transform subMenuArrowTransform = menuItemInstance.transform.Find(subMenuArrowName);
|
|
if (subMenuArrowTransform != null)
|
|
{
|
|
subMenuArrowTransform.gameObject.SetActive(true);
|
|
}
|
|
|
|
// 하위 메뉴 컨테이너 생성 및 설정
|
|
if (subMenuContainer != null)
|
|
{
|
|
GameObject subMenuContainerInstance = Instantiate(subMenuContainer.gameObject);
|
|
subMenuContainerInstance.name = $"SubMenuContainer_{itemData.ItemId}";
|
|
_subMenuContainerObjects[itemData.ItemId] = subMenuContainerInstance;
|
|
|
|
// 부모를 메뉴 아이템으로 설정하여 위치를 쉽게 계산
|
|
subMenuContainerInstance.transform.SetParent(menuItemInstance.transform, false);
|
|
|
|
RectTransform subMenuRect = subMenuContainerInstance.GetComponent<RectTransform>();
|
|
if (subMenuRect == null)
|
|
{
|
|
Debug.LogWarning($"{subMenuContainer.name} for '{menuItemInstance.name}' is missing RectTransform. Adding one.");
|
|
subMenuRect = subMenuContainerInstance.gameObject.AddComponent<RectTransform>();
|
|
}
|
|
|
|
// 하위 메뉴 컨테이너의 RectTransform 기본 설정 (앵커, 피벗)
|
|
subMenuRect.anchorMin = new Vector2(0, 1); // 좌상단 기준
|
|
subMenuRect.anchorMax = new Vector2(0, 1); // 좌상단 기준
|
|
subMenuRect.pivot = new Vector2(0, 1); // 좌상단 기준
|
|
|
|
RectTransform menuItemRect = menuItemInstance.GetComponent<RectTransform>();
|
|
|
|
|
|
// 최종 부모 설정
|
|
if (menuContainer != null && menuContainer.parent != null)
|
|
{
|
|
subMenuContainerInstance.transform.SetParent(menuContainer.parent, true);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning("menuContainer 또는 menuContainer.parent가 null이므로 하위 메뉴 컨테이너를 최상위로 설정합니다.");
|
|
}
|
|
|
|
// 재귀 호출을 통해 하위 메뉴 아이템들 생성 및 배치
|
|
CreateMenuItems(itemData.SubMenuItems, subMenuContainerInstance.transform, depth + 1);
|
|
|
|
// 하위 메뉴는 초기에 숨겨진 상태로 설정
|
|
subMenuContainerInstance.gameObject.SetActive(false);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning($"'subMenuContainer' 프리팹이 Inspector에서 할당되지 않았습니다. 하위 메뉴가 정상적으로 표시되지 않을 수 있습니다.");
|
|
}
|
|
}
|
|
else // 하위 메뉴가 없는 경우
|
|
{
|
|
// 하위 메뉴 화살표 아이콘 숨김 (프리팹에 기본적으로 활성화되어 있을 수 있으므로)
|
|
Transform existingArrow = menuItemInstance.transform.Find(subMenuArrowName);
|
|
if (existingArrow != null)
|
|
{
|
|
existingArrow.gameObject.SetActive(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 메뉴 아이템에 연결된 하위 메뉴의 표시 상태를 토글(열기/닫기)합니다.
|
|
/// 하위 메뉴를 열 때, 같은 레벨의 다른 열려있는 하위 메뉴들은 닫습니다.
|
|
/// </summary>
|
|
/// <param name="itemData">토글할 하위 메뉴를 가진 부모 메뉴의 MenuItemData입니다.</param>
|
|
/// <param name="menuItemInstance">부모 메뉴 아이템의 GameObject입니다.</param>
|
|
/// <param name="depth">클릭된 메뉴 아이템의 깊이입니다.</param>
|
|
protected virtual void ToggleSubMenuDisplay(MenuItemData itemData, GameObject menuItemInstance, int depth)
|
|
{
|
|
if (itemData.IsSeparator) return; // 구분선은 하위 메뉴를 가질 수 없음
|
|
|
|
if (_subMenuContainerObjects.TryGetValue(itemData.ItemId, out GameObject subMenuContainerObject))
|
|
{
|
|
bool isActive = subMenuContainerObject.activeSelf; // 현재 하위 메뉴의 활성화 상태
|
|
bool becomingActive = !isActive; // 토글 후 활성화될 상태
|
|
|
|
// 새로 메뉴를 여는 경우, 현재 메뉴와 관련된 다른 메뉴들을 먼저 닫습니다.
|
|
if (becomingActive)
|
|
{
|
|
// 클릭된 아이템의 부모 컨테이너(같은 레벨의 메뉴 아이템들이 있는 곳)를 기준으로
|
|
// 현재 열려는 하위 메뉴(subMenuContainerObject)를 제외한 다른 모든 하위 메뉴를 닫습니다.
|
|
CloseOtherSubMenusInParent(itemData, menuItemInstance.transform.parent, subMenuContainerObject);
|
|
|
|
// 만약 1차 메뉴(depth 0)를 클릭해서 2차 메뉴를 여는 경우라면,
|
|
// 현재 열려있는 모든 하위 메뉴를 닫아줍니다.
|
|
// 이렇게 하면 다른 메뉴 가지(branch)에 열려있던 하위 메뉴들이 모두 닫힙니다.
|
|
if (depth == 0)
|
|
{
|
|
CloseAllOpenSubMenus();
|
|
}
|
|
}
|
|
else // 하위 메뉴가 닫히려고 하는 경우
|
|
{
|
|
// 닫으려는 메뉴의 모든 자식 메뉴들도 함께 닫습니다.
|
|
CloseSubMenuAndDescendants(itemData);
|
|
}
|
|
|
|
// 하위 메뉴 컨테이너의 활성화 상태를 토글
|
|
subMenuContainerObject.SetActive(becomingActive);
|
|
|
|
if (becomingActive) subMenuContainerObject.GetComponent<LayoutGroup>()?.FitToChildren(); // 하위 메뉴 컨테이너의 크기를 자식 아이템에 맞게 조정
|
|
|
|
// isAnySubMenuOpen 상태 및 UI 블로커 업데이트
|
|
if (becomingActive)
|
|
{
|
|
isAnySubMenuOpen = true; // 하나라도 열리면 true
|
|
|
|
RectTransform menuItemRect = menuItemInstance.GetComponent<RectTransform>();
|
|
RectTransform menuItemRectparent = menuItemInstance.transform.parent?.GetComponent<RectTransform>();
|
|
RectTransform subMenuRect = subMenuContainerObject.transform?.GetComponent<RectTransform>();
|
|
if (menuItemRect != null && subMenuRect != null && menuItemRectparent != null)
|
|
{
|
|
// 하위 메뉴 위치 조정
|
|
if (depth == 0) // 1차 메뉴의 하위 메뉴는 부모 아이템의 아래쪽에 펼쳐짐
|
|
{
|
|
subMenuRect.anchoredPosition = new Vector2(menuItemRect.anchoredPosition.x + menuDepthSpace.x, -menuItemRect.rect.height + menuDepthSpace.y);
|
|
}
|
|
else // 2차 이상 메뉴의 하위 메뉴는 부모 아이템의 오른쪽에 펼쳐짐
|
|
{
|
|
subMenuRect.anchoredPosition = new Vector2(menuItemRectparent.anchoredPosition.x + menuItemRectparent.rect.width + subMenuDepthSpace.x, menuItemRectparent.anchoredPosition.y + menuItemRect.anchoredPosition.y + subMenuDepthSpace.y);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// 하위 메뉴가 닫혔으므로, 다른 열린 하위 메뉴가 있는지 다시 확인
|
|
CheckIfAnySubMenuRemainsOpen();
|
|
}
|
|
UpdateBlockerVisibility(); // UI 블로커 표시 여부 업데이트
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 메뉴 아이템의 모든 하위 메뉴들을 재귀적으로 닫습니다.
|
|
/// </summary>
|
|
/// <param name="parentItemData">하위 메뉴들을 닫을 부모 메뉴의 MenuItemData입니다.</param>
|
|
protected virtual void CloseSubMenuAndDescendants(MenuItemData parentItemData)
|
|
{
|
|
if (parentItemData?.SubMenuItems == null) return;
|
|
|
|
foreach (var subItem in parentItemData.SubMenuItems)
|
|
{
|
|
if (subItem.IsSeparator) continue;
|
|
|
|
if (_subMenuContainerObjects.TryGetValue(subItem.ItemId, out GameObject subContainer))
|
|
{
|
|
if (subContainer != null && subContainer.activeSelf)
|
|
{
|
|
subContainer.SetActive(false);
|
|
// 이 하위 메뉴의 자식들도 재귀적으로 닫습니다.
|
|
CloseSubMenuAndDescendants(subItem);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 부모 컨테이너 내에서, 지정된 하위 메뉴(subMenuToExclude)를 제외한 모든 다른 열려있는 하위 메뉴들을 닫습니다.
|
|
/// 이는 한 번에 하나의 하위 메뉴만 열려 있도록 보장하는 데 사용됩니다.
|
|
/// </summary>
|
|
/// <param name="itemData">토글할 하위 메뉴를 가진 메뉴의 MenuItemData입니다.</param>
|
|
/// <param name="currentMenuItemsParent">하위 메뉴들을 검사할 부모 Transform입니다. (예: TopMenu 또는 다른 SubMenuContainer)</param>
|
|
/// <param name="subMenuToExclude">닫지 않고 유지할 특정 하위 메뉴의 GameObject입니다. (보통 새로 열리려는 하위 메뉴)</param>
|
|
protected virtual void CloseOtherSubMenusInParent(MenuItemData itemData, Transform currentMenuItemsParent, GameObject subMenuToExclude)
|
|
{
|
|
if (currentMenuItemsParent == null)
|
|
{
|
|
Debug.LogWarning("CloseOtherSubMenusInParent 호출 시 currentMenuItemsParent가 null입니다. 다른 하위 메뉴를 닫을 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"CloseOtherSubMenusInParent 호출: 현재 부모 - {currentMenuItemsParent.name}, 제외할 하위 메뉴 - {subMenuToExclude?.name}");
|
|
|
|
string parentName = currentMenuItemsParent.name + "_";
|
|
|
|
// 현재 부모 컨테이너 내에서 열려있는 하위 메뉴들을 찾습니다.
|
|
MenuItemData? parentItemData = itemData.Parent;
|
|
|
|
if (parentItemData != null)
|
|
{
|
|
// ToList()를 사용하여 반복 중 컬렉션 수정 문제를 방지합니다.
|
|
var openSubMenus = _subMenuContainerObjects.Where(pair => pair.Value != null &&
|
|
pair.Value.activeSelf && pair.Value != subMenuToExclude && parentItemData.HasSubMenuItems(pair.Key)).ToList();
|
|
|
|
foreach (var pair in openSubMenus)
|
|
{
|
|
string itemId = pair.Key;
|
|
GameObject containerToClose = pair.Value;
|
|
|
|
// 닫아야 할 형제 하위 메뉴를 찾았습니다.
|
|
containerToClose.SetActive(false);
|
|
|
|
// 이 하위 메뉴의 자손 메뉴들도 모두 재귀적으로 닫습니다.
|
|
if (_menuItemDataMap.TryGetValue(itemId, out MenuItemData itemDataToClose))
|
|
{
|
|
CloseSubMenuAndDescendants(itemDataToClose);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 열려있는 모든 하위 메뉴들을 닫습니다.
|
|
/// 주로 메뉴 외부 영역 클릭(UI 블로커 클릭) 시 또는 최상위 메뉴 아이템 클릭 시 호출됩니다.
|
|
/// </summary>
|
|
public virtual void CloseAllOpenSubMenus()
|
|
{
|
|
bool anyActuallyClosed = false; // 실제로 닫힌 하위 메뉴가 있었는지 추적
|
|
|
|
foreach (GameObject subMenuContainer in _subMenuContainerObjects.Values)
|
|
{
|
|
if (subMenuContainer != null && subMenuContainer.activeSelf)
|
|
{
|
|
subMenuContainer.SetActive(false);
|
|
anyActuallyClosed = true;
|
|
}
|
|
}
|
|
|
|
// 실제로 메뉴가 닫혔거나, 이전에 어떤 메뉴든 열려 있었다면 상태 업데이트
|
|
if (anyActuallyClosed || isAnySubMenuOpen)
|
|
{
|
|
isAnySubMenuOpen = false; // 모든 하위 메뉴가 닫혔으므로 false로 설정
|
|
UpdateBlockerVisibility(); // UI 블로커 숨김 처리
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 열려있는 하위 메뉴가 있는지 확인하고, `isAnySubMenuOpen` 상태를 업데이트합니다.
|
|
/// </summary>
|
|
protected virtual void CheckIfAnySubMenuRemainsOpen()
|
|
{
|
|
isAnySubMenuOpen = false; // 일단 false로 가정
|
|
foreach (GameObject subMenuGO in _subMenuContainerObjects.Values)
|
|
{
|
|
if (subMenuGO != null && subMenuGO.activeSelf)
|
|
{
|
|
isAnySubMenuOpen = true; // 하나라도 열려있으면 true로 설정하고 반복 종료
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// `isAnySubMenuOpen` 상태에 따라 UI 블로커의 활성화/비활성화 상태를 업데이트합니다.
|
|
/// 블로커가 활성화될 때는 메뉴 시스템 바로 뒤(UI 상으로는 바로 앞)에 오도록 렌더링 순서를 조정합니다.
|
|
/// </summary>
|
|
protected virtual void UpdateBlockerVisibility()
|
|
{
|
|
if (uiBlockerInstance != null)
|
|
{
|
|
uiBlockerInstance.SetActive(isAnySubMenuOpen); // isAnySubMenuOpen 값에 따라 블로커 활성화/비활성화
|
|
|
|
if (isAnySubMenuOpen)
|
|
{
|
|
// 블로커가 활성화될 때, 렌더링 순서를 조정하여 다른 메뉴 요소들보다 뒤에 오도록 합니다.
|
|
// CreateUIBlocker에서 이미 부모와 기본적인 Sibling Index가 설정되었으므로,
|
|
// 여기서는 SetAsLastSibling을 호출하여 해당 부모 내에서 메뉴 바로 뒤에 오도록 설정합니다.
|
|
// 이렇게 하면 블로커가 다른 모든 UI 요소들의 클릭을 가로챌 수 있습니다.
|
|
Transform blockerParent = uiBlockerInstance.transform.parent;
|
|
if (menuContainer != null && menuContainer.parent == blockerParent)
|
|
{
|
|
int siblingIndex = menuContainer.GetSiblingIndex();
|
|
uiBlockerInstance.transform.SetSiblingIndex(Math.Max(0, siblingIndex - 1));
|
|
}
|
|
else if (transform.parent == blockerParent)
|
|
{
|
|
int siblingIndex = transform.GetSiblingIndex();
|
|
uiBlockerInstance.transform.SetSiblingIndex(Math.Max(0, siblingIndex - 1));
|
|
}
|
|
else
|
|
{
|
|
uiBlockerInstance.transform.SetAsLastSibling();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 화면에 표시된 모든 메뉴 아이템들을 제거하고 관련 내부 데이터도 초기화합니다.
|
|
/// </summary>
|
|
public virtual void ClearMenuItems()
|
|
{
|
|
CloseAllOpenSubMenus(); // 먼저 열려있는 모든 하위 메뉴를 닫습니다.
|
|
|
|
// _menuItemObjects에 저장된 모든 GameObject들을 파괴하고 딕셔너리를 비웁니다.
|
|
foreach (var pair in _menuItemObjects)
|
|
{
|
|
if (pair.Value != null) Destroy(pair.Value);
|
|
}
|
|
_menuItemObjects.Clear();
|
|
|
|
foreach (var pair in _subMenuContainerObjects)
|
|
{
|
|
if (pair.Value != null) Destroy(pair.Value);
|
|
}
|
|
_subMenuContainerObjects.Clear();
|
|
_menuItemDataMap.Clear();
|
|
|
|
// menuContainer의 모든 자식 GameObject들을 직접 파괴합니다.
|
|
if (menuContainer != null)
|
|
{
|
|
foreach (Transform child in menuContainer)
|
|
{
|
|
Destroy(child.gameObject);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 제공된 MenuItemData 리스트를 기반으로 화면에 표시된 모든 메뉴 아이템들의 텍스트를 업데이트합니다.
|
|
/// 주로 언어 변경 시 호출됩니다.
|
|
/// </summary>
|
|
/// <param name="items">최상위 메뉴 아이템들의 데이터 리스트입니다.</param>
|
|
public virtual void UpdateAllMenuTexts(List<MenuItemData> items)
|
|
{
|
|
if (_locManager == null)
|
|
{
|
|
Debug.LogWarning("LocalizationManager가 없어 메뉴 텍스트를 업데이트할 수 없습니다.");
|
|
return;
|
|
}
|
|
UpdateMenuTextsRecursive(items, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 재귀적으로 메뉴 아이템들의 텍스트를 업데이트하고, 필요에 따라 너비를 조절하는 내부 도우미 함수입니다.
|
|
/// </summary>
|
|
/// <param name="items">텍스트를 업데이트할 메뉴 아이템들의 데이터 리스트입니다.</param>
|
|
/// <param name="depth">현재 메뉴의 깊이입니다.</param>
|
|
protected virtual void UpdateMenuTextsRecursive(List<MenuItemData> items, int depth = 0)
|
|
{
|
|
if (items == null) return;
|
|
|
|
foreach (var itemData in items)
|
|
{
|
|
if (itemData.IsSeparator) continue; // 구분선은 텍스트가 없음
|
|
|
|
if (_menuItemObjects.TryGetValue(itemData.ItemId, out GameObject menuItemInstance))
|
|
{
|
|
TextMeshProUGUI buttonText = menuItemInstance.GetComponentInChildren<TextMeshProUGUI>(true);
|
|
if (buttonText != null && !string.IsNullOrEmpty(itemData.DisplayName))
|
|
{
|
|
buttonText.text = _locManager.GetString(itemData.DisplayName);
|
|
|
|
// isShrinkMenuItemWidth가 true일 때 1차 메뉴 아이템의 너비를 텍스트에 맞게 조절
|
|
if (depth == 0 && isShrinkMenuItemWidth)
|
|
{
|
|
LayoutElement layoutElement = menuItemInstance.GetComponent<LayoutElement>() ?? menuItemInstance.AddComponent<LayoutElement>();
|
|
// 텍스트의 preferredWidth를 기반으로 선호 너비 설정 (좌우 여백 포함)
|
|
layoutElement.preferredWidth = buttonText.preferredWidth + menuItemWidthPadding;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
|
|
{
|
|
UpdateMenuTextsRecursive(itemData.SubMenuItems, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메뉴 아이템의 고유 ID를 사용하여 해당 메뉴 아이템의 GameObject를 가져옵니다.
|
|
/// </summary>
|
|
/// <param name="itemId">찾고자 하는 메뉴 아이템의 고유 ID입니다.</param>
|
|
/// <param name="menuItemGO">찾은 경우, 해당 GameObject가 할당됩니다. 찾지 못한 경우 null입니다.</param>
|
|
/// <returns>메뉴 아이템을 찾았으면 true, 그렇지 않으면 false를 반환합니다.</returns>
|
|
public bool TryGetMenuItemGameObject(string itemId, out GameObject menuItemGO)
|
|
{
|
|
return _menuItemObjects.TryGetValue(itemId, out menuItemGO);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 메뉴 아이템의 단축키 텍스트를 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="itemId">업데이트할 메뉴 아이템의 고유 ID입니다.</param>
|
|
/// <param name="shortcut">새로운 단축키 문자열입니다.</param>
|
|
public virtual void UpdateShortcutText(string itemId, string shortcut)
|
|
{
|
|
if (_menuItemObjects.TryGetValue(itemId, out GameObject menuItemInstance))
|
|
{
|
|
Transform shortcutTransform = menuItemInstance.transform.Find(shortcutTextName);
|
|
if (shortcutTransform != null)
|
|
{
|
|
TextMeshProUGUI shortcutText = shortcutTransform.GetComponent<TextMeshProUGUI>();
|
|
if (shortcutText != null)
|
|
{
|
|
shortcutText.text = shortcut ?? string.Empty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 모든 메뉴 아이템의 단축키 텍스트를 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="items">최상위 메뉴 아이템들의 데이터 리스트입니다.</param>
|
|
public virtual void UpdateAllShortcuts(List<MenuItemData> items)
|
|
{
|
|
UpdateShortcutsRecursive(items);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 재귀적으로 메뉴 아이템들의 단축키 텍스트를 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="items">단축키를 업데이트할 메뉴 아이템들의 데이터 리스트입니다.</param>
|
|
protected virtual void UpdateShortcutsRecursive(List<MenuItemData> items)
|
|
{
|
|
if (items == null) return;
|
|
|
|
foreach (var itemData in items)
|
|
{
|
|
if (itemData.IsSeparator) continue;
|
|
|
|
UpdateShortcutText(itemData.ItemId, itemData.Shortcut);
|
|
|
|
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
|
|
{
|
|
UpdateShortcutsRecursive(itemData.SubMenuItems);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |