2026.06.24

This commit is contained in:
2026-06-24 17:13:40 +09:00
parent b1e85a5b89
commit d601161390
86 changed files with 10287 additions and 336 deletions

View File

@@ -1,31 +1,158 @@
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>
/// 전체 인벤토리 UI를 갱신하는 스크립트입니다.
/// InventoryManager의 변경 이벤트를 받아 각 InventorySlotUI에 전달합니다.
/// 기능: 패널 열기/닫기, 슬롯 갱신, 획득 팝업, 부족 안내 메시지, 확인창, 상세보기, 획득 로그, 기억의 조각 진행도, 간단한 손목/타겟 추적.
/// InventoryUI가 붙은 오브젝트는 항상 켜두고, 실제 보이는 inventoryPanel만 켜고 끄는 구조를 권장합니다.
/// </summary>
[DisallowMultipleComponent]
public class InventoryUI : MonoBehaviour
{
[Header("References")]
[SerializeField] private InventoryManager inventoryManager;
[Tooltip("실제로 보이거나 숨겨질 자식 패널입니다. InventoryUI가 붙은 자기 자신을 넣지 않는 것을 권장합니다.")]
[SerializeField] private GameObject inventoryPanel;
[SerializeField] private InventorySlotUI[] slots;
[Header("Settings")]
[Header("Panel Settings")]
[SerializeField] private bool autoFindManager = true;
[SerializeField] private bool autoFindPanelChild = true;
[SerializeField] private string autoPanelChildName = "InventoryPanel";
[SerializeField] private bool visibleOnStart = false;
[SerializeField] private bool refreshOnEnable = true;
[SerializeField] private bool showNewBadgeOnIncrease = true;
[SerializeField] private bool preventDisablingSelf = true;
[Header("Filter")]
[SerializeField] private InventoryItemCategory currentFilter = InventoryItemCategory.All;
[SerializeField] private bool hideSlotsOutsideFilter = false;
[Header("Acquisition Popup")]
[SerializeField] private GameObject acquisitionPopupPanel;
[SerializeField] private CanvasGroup acquisitionPopupCanvasGroup;
[SerializeField] private Image acquisitionIconImage;
[SerializeField] private TMP_Text acquisitionText;
[SerializeField] private float acquisitionPopupTime = 1.4f;
[SerializeField] private string acquisitionFormat = "{0} x{1} 획득!";
[Header("Message Popup")]
[SerializeField] private GameObject messagePanel;
[SerializeField] private CanvasGroup messageCanvasGroup;
[SerializeField] private TMP_Text messageText;
[SerializeField] private float messageShowTime = 1.5f;
[Header("Confirmation UI")]
[SerializeField] private GameObject confirmationPanel;
[SerializeField] private TMP_Text confirmationTitleText;
[SerializeField] private TMP_Text confirmationBodyText;
[SerializeField] private Button confirmationYesButton;
[SerializeField] private Button confirmationNoButton;
[Header("Detail UI")]
[SerializeField] private GameObject detailPanel;
[SerializeField] private Image detailIconImage;
[SerializeField] private TMP_Text detailTitleText;
[SerializeField] private TMP_Text detailDescriptionText;
[SerializeField] private TMP_Text detailCountText;
[SerializeField] private TMP_Text detailGoalText;
[SerializeField] private Button detailUseButton;
[Header("Recent Log UI")]
[SerializeField] private TMP_Text[] recentLogTexts;
[Header("Memory Piece UI")]
[SerializeField] private TMP_Text memoryProgressText;
[SerializeField] private Slider memoryProgressSlider;
[SerializeField] private string memoryProgressFormat = "기억의 조각 {0} / {1}";
[Header("Audio")]
[SerializeField] private AudioSource uiAudioSource;
[SerializeField] private AudioClip defaultAcquisitionClip;
[SerializeField] private AudioClip defaultUseClip;
[SerializeField] private AudioClip defaultErrorClip;
[Header("Optional Follow Target / Wrist UI")]
[SerializeField] private bool followTarget = false;
[SerializeField] private Transform targetToFollow;
[SerializeField] private Vector3 localPositionOffset = new Vector3(0f, 0.08f, 0.12f);
[SerializeField] private Vector3 localEulerOffset = Vector3.zero;
[SerializeField] private bool faceMainCamera = false;
[Header("Editor Test Toggle")]
[SerializeField] private bool enableKeyboardToggleForTesting = false;
[SerializeField] private KeyCode keyboardToggleKey = KeyCode.I;
private bool subscribed;
private Coroutine acquisitionRoutine;
private Coroutine messageRoutine;
private InventoryItemType pendingUseItemType;
private int pendingUseAmount = 1;
private bool hasPendingUse;
private InventoryItemType currentDetailItemType;
private bool hasDetailItem;
private void Awake()
{
if (inventoryPanel == null)
inventoryPanel = gameObject;
if (inventoryManager == null && InventoryManager.Instance != null)
inventoryManager = InventoryManager.Instance;
if (inventoryManager == null && autoFindManager)
inventoryManager = FindFirstObjectByType<InventoryManager>();
if (inventoryPanel == null && autoFindPanelChild)
{
Transform panelTransform = transform.Find(autoPanelChildName);
if (panelTransform != null)
inventoryPanel = panelTransform.gameObject;
}
if (uiAudioSource == null)
uiAudioSource = GetComponent<AudioSource>();
if (acquisitionPopupPanel != null)
acquisitionPopupPanel.SetActive(false);
if (messagePanel != null)
messagePanel.SetActive(false);
if (confirmationPanel != null)
confirmationPanel.SetActive(false);
if (detailPanel != null)
detailPanel.SetActive(false);
if (confirmationYesButton != null)
{
confirmationYesButton.onClick.RemoveListener(ConfirmPendingUse);
confirmationYesButton.onClick.AddListener(ConfirmPendingUse);
}
if (confirmationNoButton != null)
{
confirmationNoButton.onClick.RemoveListener(CancelPendingUse);
confirmationNoButton.onClick.AddListener(CancelPendingUse);
}
if (detailUseButton != null)
{
detailUseButton.onClick.RemoveListener(UseCurrentDetailItem);
detailUseButton.onClick.AddListener(UseCurrentDetailItem);
}
ApplyItemDataToSlots();
}
private void Start()
{
SetVisible(visibleOnStart);
RefreshAllSlots();
RefreshRecentLogs();
RefreshMemoryProgress();
}
private void OnEnable()
@@ -41,18 +168,54 @@ private void OnDisable()
Unsubscribe();
}
private void Update()
{
if (enableKeyboardToggleForTesting && Input.GetKeyDown(keyboardToggleKey))
ToggleVisible();
}
private void LateUpdate()
{
if (!followTarget || targetToFollow == null)
return;
transform.position = targetToFollow.TransformPoint(localPositionOffset);
transform.rotation = targetToFollow.rotation * Quaternion.Euler(localEulerOffset);
if (faceMainCamera && Camera.main != null)
{
Vector3 direction = transform.position - Camera.main.transform.position;
if (direction.sqrMagnitude > 0.0001f)
transform.rotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
}
}
public void SetVisible(bool visible)
{
if (inventoryPanel != null)
inventoryPanel.SetActive(visible);
if (inventoryPanel == null)
return;
if (preventDisablingSelf && inventoryPanel == gameObject)
{
Debug.LogWarning("[InventoryUI] InventoryPanel에 자기 자신이 들어가 있습니다. InventoryRoot는 항상 켜두고 자식 InventoryPanel만 연결하세요.", this);
return;
}
inventoryPanel.SetActive(visible);
if (visible)
RefreshAllSlots();
}
public void ToggleVisible()
{
if (inventoryPanel != null)
inventoryPanel.SetActive(!inventoryPanel.activeSelf);
SetVisible(!inventoryPanel.activeSelf);
}
public void ShowInventory() => SetVisible(true);
public void HideInventory() => SetVisible(false);
public void SetManager(InventoryManager manager)
{
if (inventoryManager == manager)
@@ -61,9 +224,22 @@ public void SetManager(InventoryManager manager)
Unsubscribe();
inventoryManager = manager;
Subscribe();
ApplyItemDataToSlots();
RefreshAllSlots();
}
public void SetFilterAll() => SetFilter(InventoryItemCategory.All);
public void SetFilterConsumable() => SetFilter(InventoryItemCategory.Consumable);
public void SetFilterQuest() => SetFilter(InventoryItemCategory.Quest);
public void SetFilterKeyItem() => SetFilter(InventoryItemCategory.KeyItem);
public void SetFilterMaterial() => SetFilter(InventoryItemCategory.Material);
public void SetFilter(InventoryItemCategory category)
{
currentFilter = category;
ApplyFilterToSlots();
}
public void RefreshAllSlots()
{
if (inventoryManager == null || slots == null)
@@ -75,9 +251,16 @@ public void RefreshAllSlots()
if (slot == null)
continue;
InventoryItemDefinition definition = inventoryManager.GetDefinition(slot.ItemType);
slot.SetInventoryUI(this);
slot.SetDefinition(definition);
int count = inventoryManager.GetItemCount(slot.ItemType);
slot.SetCount(count, false);
int maxCount = inventoryManager.GetMaxCount(slot.ItemType);
slot.SetCount(count, false, maxCount);
}
ApplyFilterToSlots();
}
private void RefreshSlot(InventoryItemType itemType, int count)
@@ -85,13 +268,53 @@ private void RefreshSlot(InventoryItemType itemType, int count)
if (slots == null)
return;
int maxCount = inventoryManager != null ? inventoryManager.GetMaxCount(itemType) : 0;
for (int i = 0; i < slots.Length; i++)
{
InventorySlotUI slot = slots[i];
if (slot == null || slot.ItemType != itemType)
continue;
slot.SetCount(count, showNewBadgeOnIncrease);
slot.SetCount(count, showNewBadgeOnIncrease, maxCount);
}
if (hasDetailItem && currentDetailItemType == itemType)
ShowItemDetail(itemType);
}
private void ApplyItemDataToSlots()
{
if (inventoryManager == null || slots == null)
return;
for (int i = 0; i < slots.Length; i++)
{
InventorySlotUI slot = slots[i];
if (slot == null)
continue;
slot.SetInventoryUI(this);
slot.SetDefinition(inventoryManager.GetDefinition(slot.ItemType));
}
}
private void ApplyFilterToSlots()
{
if (slots == null)
return;
for (int i = 0; i < slots.Length; i++)
{
InventorySlotUI slot = slots[i];
if (slot == null)
continue;
bool visible = currentFilter == InventoryItemCategory.All || slot.Category == currentFilter;
if (hideSlotsOutsideFilter)
slot.gameObject.SetActive(visible);
else
slot.SetFilteredOut(!visible);
}
}
@@ -101,6 +324,11 @@ private void Subscribe()
return;
inventoryManager.ItemCountChanged += RefreshSlot;
inventoryManager.ItemAdded += HandleItemAdded;
inventoryManager.ItemUsed += HandleItemUsed;
inventoryManager.MessageRequested += ShowMessage;
inventoryManager.LogAdded += HandleLogAdded;
inventoryManager.MemoryPieceProgressChanged += HandleMemoryPieceProgressChanged;
subscribed = true;
}
@@ -110,6 +338,306 @@ private void Unsubscribe()
return;
inventoryManager.ItemCountChanged -= RefreshSlot;
inventoryManager.ItemAdded -= HandleItemAdded;
inventoryManager.ItemUsed -= HandleItemUsed;
inventoryManager.MessageRequested -= ShowMessage;
inventoryManager.LogAdded -= HandleLogAdded;
inventoryManager.MemoryPieceProgressChanged -= HandleMemoryPieceProgressChanged;
subscribed = false;
}
private void HandleItemAdded(InventoryItemType itemType, int addedAmount, int totalCount)
{
ShowAcquisitionPopup(itemType, addedAmount);
}
private void HandleItemUsed(InventoryItemType itemType, int count)
{
AudioClip clip = inventoryManager != null ? inventoryManager.GetUseClip(itemType) : null;
PlayUIClip(clip != null ? clip : defaultUseClip);
}
private void HandleLogAdded(InventoryLogEntry entry)
{
RefreshRecentLogs();
}
private void HandleMemoryPieceProgressChanged(int current, int target)
{
RefreshMemoryProgress(current, target);
}
public void ShowAcquisitionPopup(InventoryItemType itemType, int amount)
{
if (inventoryManager == null)
return;
string displayName = inventoryManager.GetDisplayName(itemType);
Sprite icon = inventoryManager.GetIcon(itemType);
if (acquisitionIconImage != null)
{
acquisitionIconImage.sprite = icon;
acquisitionIconImage.enabled = icon != null;
}
if (acquisitionText != null)
acquisitionText.text = string.Format(acquisitionFormat, displayName, Mathf.Max(1, amount));
AudioClip clip = inventoryManager.GetAcquisitionClip(itemType);
PlayUIClip(clip != null ? clip : defaultAcquisitionClip);
if (acquisitionRoutine != null)
StopCoroutine(acquisitionRoutine);
acquisitionRoutine = StartCoroutine(ShowPanelRoutine(acquisitionPopupPanel, acquisitionPopupCanvasGroup, acquisitionPopupTime));
}
public void ShowMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return;
if (messageText != null)
messageText.text = message;
if (messageRoutine != null)
StopCoroutine(messageRoutine);
messageRoutine = StartCoroutine(ShowPanelRoutine(messagePanel, messageCanvasGroup, messageShowTime));
}
private IEnumerator ShowPanelRoutine(GameObject panel, CanvasGroup canvasGroup, float showTime)
{
if (panel == null)
yield break;
panel.SetActive(true);
if (canvasGroup != null)
canvasGroup.alpha = 1f;
yield return new WaitForSecondsRealtime(Mathf.Max(0.05f, showTime));
if (canvasGroup != null)
canvasGroup.alpha = 0f;
panel.SetActive(false);
}
public void RequestUseItem(InventoryItemType itemType)
{
RequestUseItem(itemType, 1);
}
public void RequestUseItem(InventoryItemType itemType, int amount)
{
if (inventoryManager == null)
return;
if (!inventoryManager.IsUsable(itemType))
{
ShowMessage($"{inventoryManager.GetDisplayName(itemType)}은(는) 지금 사용할 수 없습니다.");
PlayUIClip(defaultErrorClip);
return;
}
if (!inventoryManager.HasItem(itemType, amount))
{
ShowMessage(inventoryManager.GetInsufficientMessage(itemType, amount));
PlayUIClip(defaultErrorClip);
return;
}
if (inventoryManager.RequiresUseConfirmation(itemType))
{
pendingUseItemType = itemType;
pendingUseAmount = Mathf.Max(1, amount);
ShowConfirmation(itemType, amount);
}
else
{
bool result = inventoryManager.UseItem(itemType, amount);
if (!result)
PlayUIClip(defaultErrorClip);
}
}
private void ShowConfirmation(InventoryItemType itemType, int amount)
{
pendingUseItemType = itemType;
pendingUseAmount = Mathf.Max(1, amount);
hasPendingUse = true;
if (confirmationPanel == null)
{
ConfirmPendingUse();
return;
}
string displayName = inventoryManager != null ? inventoryManager.GetDisplayName(itemType) : itemType.ToString();
if (confirmationTitleText != null)
confirmationTitleText.text = "아이템 사용";
if (confirmationBodyText != null)
confirmationBodyText.text = $"{displayName}을(를) 사용하시겠습니까?";
confirmationPanel.SetActive(true);
}
public void ConfirmPendingUse()
{
if (confirmationPanel != null)
confirmationPanel.SetActive(false);
if (!hasPendingUse)
return;
InventoryItemType itemType = pendingUseItemType;
int amount = Mathf.Max(1, pendingUseAmount);
hasPendingUse = false;
if (inventoryManager != null)
{
bool result = inventoryManager.UseItem(itemType, amount);
if (!result)
PlayUIClip(defaultErrorClip);
}
}
public void CancelPendingUse()
{
hasPendingUse = false;
if (confirmationPanel != null)
confirmationPanel.SetActive(false);
}
public void ShowItemDetail(InventoryItemType itemType)
{
hasDetailItem = true;
currentDetailItemType = itemType;
if (inventoryManager == null || detailPanel == null)
return;
InventoryItemDefinition definition = inventoryManager.GetDefinition(itemType);
int count = inventoryManager.GetItemCount(itemType);
int maxCount = inventoryManager.GetMaxCount(itemType);
Sprite icon = inventoryManager.GetIcon(itemType);
if (detailIconImage != null)
{
detailIconImage.sprite = icon;
detailIconImage.enabled = icon != null;
}
if (detailTitleText != null)
detailTitleText.text = inventoryManager.GetDisplayName(itemType);
if (detailDescriptionText != null)
detailDescriptionText.text = inventoryManager.GetDescription(itemType);
if (detailCountText != null)
detailCountText.text = maxCount > 0 ? $"보유: x{count} / {maxCount}" : $"보유: x{count}";
if (detailGoalText != null)
detailGoalText.text = inventoryManager.GetGoalHint(itemType);
if (detailUseButton != null)
{
bool usable = definition != null && definition.usable && count > 0;
detailUseButton.gameObject.SetActive(definition != null && definition.usable);
detailUseButton.interactable = usable;
}
detailPanel.SetActive(true);
}
public void HideItemDetail()
{
hasDetailItem = false;
if (detailPanel != null)
detailPanel.SetActive(false);
}
public void UseCurrentDetailItem()
{
if (!hasDetailItem)
return;
RequestUseItem(currentDetailItemType, 1);
}
private void RefreshRecentLogs()
{
if (recentLogTexts == null || recentLogTexts.Length == 0 || inventoryManager == null)
return;
IReadOnlyList<InventoryLogEntry> logs = inventoryManager.GetRecentLogs();
for (int i = 0; i < recentLogTexts.Length; i++)
{
if (recentLogTexts[i] == null)
continue;
recentLogTexts[i].text = i < logs.Count && logs[i] != null ? logs[i].ToString() : string.Empty;
}
}
private void RefreshMemoryProgress()
{
if (inventoryManager == null)
return;
RefreshMemoryProgress(inventoryManager.GetItemCount(inventoryManager.MemoryPieceItemType), inventoryManager.MemoryPieceTargetCount);
}
private void RefreshMemoryProgress(int current, int target)
{
target = Mathf.Max(1, target);
current = Mathf.Clamp(current, 0, target);
if (memoryProgressText != null)
memoryProgressText.text = string.Format(memoryProgressFormat, current, target);
if (memoryProgressSlider != null)
{
memoryProgressSlider.minValue = 0f;
memoryProgressSlider.maxValue = target;
memoryProgressSlider.value = current;
}
}
private void PlayUIClip(AudioClip clip)
{
if (clip == null)
return;
if (uiAudioSource != null)
uiAudioSource.PlayOneShot(clip);
else
AudioSource.PlayClipAtPoint(clip, transform.position);
}
public void CheckVRUISetup()
{
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas == null)
{
Debug.LogWarning("[InventoryUI] Canvas가 없습니다.", this);
return;
}
if (canvas.renderMode != RenderMode.WorldSpace)
Debug.LogWarning("[InventoryUI] VR UI에서는 Canvas Render Mode를 World Space로 권장합니다.", canvas);
if (EventSystem.current == null)
Debug.LogWarning("[InventoryUI] EventSystem이 없습니다. VR 포인터 UI가 동작하지 않을 수 있습니다.", this);
GraphicRaycaster graphicRaycaster = canvas.GetComponent<GraphicRaycaster>();
if (graphicRaycaster == null)
Debug.LogWarning("[InventoryUI] Canvas에 GraphicRaycaster 또는 Tracked Device Graphic Raycaster가 필요합니다.", canvas);
}
}