280 lines
12 KiB
C#
280 lines
12 KiB
C#
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using UVC.Core; // SingletonScene을 사용하기 위함
|
|
using UVC.Locale;
|
|
using UVC.Pool; // GameObjectPool을 사용하기 위함
|
|
|
|
namespace UVC.UI.Menu
|
|
{
|
|
/// <summary>
|
|
/// 컨텍스트 메뉴의 표시와 관리를 총괄하는 싱글톤 클래스입니다.
|
|
/// SingletonScene을 상속받아 해당 씬 내에서 유일한 인스턴스를 보장합니다.
|
|
/// 이 매니저는 코드 기반으로 동적으로 메뉴를 생성하고 표시하는 데 사용됩니다.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <b>사용 예시: 플레이어 캐릭터를 클릭했을 때 상호작용 메뉴를 표시하는 경우</b>
|
|
/// <code>
|
|
/// // 1. 메뉴 항목을 클릭했을 때 실행될 동작을 정의하는 Command 클래스를 구현합니다.
|
|
/// public class LogCommand : ICommand
|
|
/// {
|
|
/// public void Execute(object parameter)
|
|
/// {
|
|
/// if (parameter != null)
|
|
/// {
|
|
/// Debug.Log("명령 실행: " + parameter.ToString());
|
|
/// }
|
|
/// else
|
|
/// {
|
|
/// Debug.Log("명령이 파라미터 없이 실행되었습니다.");
|
|
/// }
|
|
/// }
|
|
/// }
|
|
///
|
|
/// // 2. 메뉴를 표시할 스크립트(예: PlayerInteraction.cs)를 작성합니다.
|
|
/// public class PlayerInteraction : MonoBehaviour
|
|
/// {
|
|
/// void Update()
|
|
/// {
|
|
/// // 이 게임 오브젝트가 마우스 오른쪽 버튼으로 클릭되면 메뉴를 표시합니다.
|
|
/// if (Input.GetMouseButtonDown(1))
|
|
/// {
|
|
/// // Raycast 등으로 이 오브젝트가 클릭되었는지 확인하는 로직이 필요합니다.
|
|
/// // 여기서는 설명을 위해 바로 호출합니다.
|
|
/// ShowPlayerMenu(Input.mousePosition);
|
|
/// }
|
|
/// }
|
|
///
|
|
/// public void ShowPlayerMenu(Vector2 screenPosition)
|
|
/// {
|
|
/// // 3. 메뉴에 표시할 항목 리스트를 동적으로 생성합니다.
|
|
/// var menuItems = new List<ContextMenuItemData>
|
|
/// {
|
|
/// // ContextMenuItemData 생성자를 사용하여 각 메뉴 항목을 정의합니다.
|
|
/// // 생성자: (itemId, displayName, command, commandParameter)
|
|
/// new ContextMenuItemData("player_attack", "공격", new LogCommand(), "플레이어가 '공격'을 선택했습니다."),
|
|
/// new ContextMenuItemData("player_talk", "대화", new LogCommand(), "플레이어가 '대화'를 선택했습니다."),
|
|
/// new ContextMenuItemData(isSeparator: true), // 구분선 추가
|
|
/// new ContextMenuItemData("player_inspect", "조사", new LogCommand(), "플레이어가 '조사'를 선택했습니다.")
|
|
/// };
|
|
///
|
|
/// // 4. ContextMenuManager 싱글톤 인스턴스를 통해 메뉴를 표시합니다.
|
|
/// ContextMenuManager.Instance.ShowMenu(menuItems, screenPosition);
|
|
/// }
|
|
/// }
|
|
/// </code>
|
|
/// <b>참고:</b>
|
|
/// - `ContextMenuTrigger` 컴포넌트를 사용하면 Inspector에서 정적으로 메뉴를 설정할 수도 있습니다.
|
|
/// - `DisplayName`에는 다국어 처리를 위한 키 값을 사용할 수 있습니다. (예: "menu_attack")
|
|
/// </example>
|
|
public class ContextMenuManager : SingletonScene<ContextMenuManager>
|
|
{
|
|
[Header("프리팹 설정")]
|
|
[SerializeField]
|
|
[Tooltip("컨텍스트 메뉴의 배경이 되는 Panel 프리팹입니다.")]
|
|
private GameObject contextMenuPrefab;
|
|
|
|
[SerializeField]
|
|
[Tooltip("컨텍스트 메뉴의 각 항목으로 사용될 Button 프리팹입니다.")]
|
|
private GameObject contextMenuItemPrefab;
|
|
|
|
[SerializeField]
|
|
[Tooltip("컨텍스트 메뉴의 구분선으로 사용될 UI 프리팹입니다.")]
|
|
private GameObject contextMenuSeparatorPrefab;
|
|
|
|
[Header("캔버스 설정")]
|
|
[SerializeField]
|
|
[Tooltip("UI가 그려질 최상위 Canvas입니다. 메뉴가 다른 UI 위에 그려지도록 합니다.")]
|
|
private Canvas mainCanvas;
|
|
|
|
// 현재 활성화된 컨텍스트 메뉴의 RectTransform
|
|
private RectTransform _activeMenuRect;
|
|
// 메뉴 항목들을 담을 부모 객체
|
|
private Transform _menuItemContainer;
|
|
|
|
// 메뉴 항목 버튼들을 관리하기 위한 오브젝트 풀
|
|
private ItemPool<Button> _menuItemPool;
|
|
|
|
private LocalizationManager _locManager; // 다국어 처리를 위한 LocalizationManager 인스턴스
|
|
|
|
/// <summary>
|
|
/// 싱글톤이 초기화될 때 호출됩니다.
|
|
/// </summary>
|
|
protected override void Init()
|
|
{
|
|
base.Init();
|
|
|
|
_locManager = LocalizationManager.Instance;
|
|
if (_locManager == null)
|
|
{
|
|
// LocalizationManager가 필수적인 경우, 여기서 에러를 발생시키거나 게임을 중단시킬 수 있습니다.
|
|
// 여기서는 경고만 기록하고 진행합니다.
|
|
Debug.LogWarning("LocalizationManager 인스턴스를 찾을 수 없습니다. 메뉴 텍스트가 제대로 표시되지 않을 수 있습니다.");
|
|
}
|
|
|
|
// 메뉴 항목을 담을 컨테이너를 생성하고 비활성화해 둡니다.
|
|
var itemContainerGo = new GameObject("ContextMenuItemContainer");
|
|
itemContainerGo.transform.SetParent(transform);
|
|
_menuItemContainer = itemContainerGo.transform;
|
|
itemContainerGo.SetActive(false);
|
|
|
|
// 메뉴 항목 프리팹을 사용하여 오브젝트 풀을 초기화합니다.
|
|
// [성능 최적화]
|
|
// 메뉴 항목을 반복적으로 생성/파괴하는 대신 풀을 사용하여 재활용합니다.
|
|
// 이는 가비지 컬렉션을 줄여 성능을 향상시킵니다.
|
|
_menuItemPool = new ItemPool<Button>(contextMenuItemPrefab, _menuItemContainer);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 컨텍스트 메뉴를 화면에 표시합니다.
|
|
/// </summary>
|
|
/// <param name="items">표시할 메뉴 항목 데이터 리스트입니다.</param>
|
|
/// <param name="position">메뉴가 표시될 화면 좌표입니다.</param>
|
|
public void ShowMenu(List<ContextMenuItemData> items, Vector2 position)
|
|
{
|
|
// 기존 메뉴가 열려있으면 닫습니다.
|
|
HideMenu();
|
|
|
|
if(mainCanvas == null)
|
|
{
|
|
mainCanvas = FindAnyObjectByType<Canvas>();
|
|
}
|
|
|
|
// 메뉴 프리팹으로부터 인스턴스를 생성하고 Canvas 자식으로 설정합니다.
|
|
var menuObject = Instantiate(contextMenuPrefab, mainCanvas.transform);
|
|
_activeMenuRect = menuObject.GetComponent<RectTransform>();
|
|
|
|
// 메뉴 위치 설정
|
|
_activeMenuRect.position = position;
|
|
|
|
// 메뉴 항목 채우기
|
|
PopulateMenu(items);
|
|
|
|
// 메뉴가 화면 밖으로 나가지 않도록 위치를 조정합니다.
|
|
AdjustMenuPosition();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 열려있는 컨텍스트 메뉴를 닫습니다.
|
|
/// </summary>
|
|
public void HideMenu()
|
|
{
|
|
if (_activeMenuRect != null)
|
|
{
|
|
// 사용했던 모든 메뉴 항목 버튼을 풀에 반환합니다.
|
|
_menuItemPool.ReturnAll(true);
|
|
|
|
// 활성화된 메뉴 오브젝트를 파괴합니다.
|
|
Destroy(_activeMenuRect.gameObject);
|
|
_activeMenuRect = null;
|
|
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 전달받은 데이터로 메뉴 항목들을 생성합니다.
|
|
/// </summary>
|
|
/// <param name="items">메뉴 항목 데이터 리스트</param>
|
|
private void PopulateMenu(List<ContextMenuItemData> items)
|
|
{
|
|
foreach (var itemData in items)
|
|
{
|
|
if (itemData.IsSeparator)
|
|
{
|
|
// 이 항목이 구분선인 경우, 구분선 프리팹을 인스턴스화합니다.
|
|
if (contextMenuSeparatorPrefab != null)
|
|
{
|
|
Instantiate(contextMenuSeparatorPrefab, _activeMenuRect);
|
|
}
|
|
continue; // 다음 항목으로 넘어갑니다.
|
|
}
|
|
|
|
// 풀에서 메뉴 항목 버튼을 가져옵니다.
|
|
Button menuItemButton = _menuItemPool.GetItem(true, _activeMenuRect);
|
|
|
|
// 버튼의 텍스트를 설정합니다.
|
|
TMP_Text buttonText = menuItemButton.GetComponentInChildren<TMP_Text>();
|
|
if (buttonText != null)
|
|
{
|
|
if (_locManager != null)
|
|
{
|
|
buttonText.text = _locManager.GetString(itemData.DisplayName);
|
|
}
|
|
else
|
|
{
|
|
buttonText.text = itemData.DisplayName;
|
|
}
|
|
}
|
|
|
|
// 버튼 클릭 이벤트를 설정합니다.
|
|
// 기존 리스너를 모두 제거한 후 새로 추가하여 중복 호출을 방지합니다.
|
|
menuItemButton.onClick.RemoveAllListeners();
|
|
menuItemButton.onClick.AddListener(() =>
|
|
{
|
|
// 항목을 클릭하면 메뉴를 닫고, 설정된 액션을 실행합니다.
|
|
HideMenu();
|
|
itemData.Command?.Execute(itemData.CommandParameter);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메뉴가 화면 경계를 벗어나지 않도록 위치를 조정합니다.
|
|
/// </summary>
|
|
private void AdjustMenuPosition()
|
|
{
|
|
// 레이아웃이 업데이트되도록 한 프레임 강제로 기다립니다.
|
|
Canvas.ForceUpdateCanvases();
|
|
|
|
Vector3[] corners = new Vector3[4];
|
|
_activeMenuRect.GetWorldCorners(corners);
|
|
|
|
float menuWidth = corners[2].x - corners[0].x;
|
|
float menuHeight = corners[1].y - corners[3].y;
|
|
|
|
Vector2 anchoredPosition = _activeMenuRect.anchoredPosition;
|
|
|
|
// 오른쪽 경계 확인
|
|
if (_activeMenuRect.position.x + menuWidth > Screen.width)
|
|
{
|
|
anchoredPosition.x -= menuWidth;
|
|
}
|
|
|
|
// 아래쪽 경계 확인
|
|
if (_activeMenuRect.position.y - menuHeight < 0)
|
|
{
|
|
anchoredPosition.y += menuHeight;
|
|
}
|
|
|
|
_activeMenuRect.anchoredPosition = anchoredPosition;
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
// 메뉴가 열려있지 않으면 아무 작업도 수행하지 않습니다.
|
|
if (_activeMenuRect == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// ESC 키를 누르면 메뉴를 닫습니다.
|
|
if (Input.GetKeyDown(KeyCode.Escape))
|
|
{
|
|
HideMenu();
|
|
return; // 메뉴가 닫혔으므로 아래 로직은 실행할 필요가 없습니다.
|
|
}
|
|
|
|
// 메뉴가 열려있고, 마우스 왼쪽/오른쪽 버튼이 눌렸을 때
|
|
// 커서가 메뉴 위에 있지 않다면 메뉴를 닫습니다.
|
|
if (_activeMenuRect != null && (Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1)))
|
|
{
|
|
if (!RectTransformUtility.RectangleContainsScreenPoint(_activeMenuRect, Input.mousePosition, mainCanvas.worldCamera))
|
|
{
|
|
HideMenu();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|