diff --git a/Assets/My project/Fonts/Pretendard-Black SDF.asset b/Assets/My project/Fonts/Pretendard-Black SDF.asset
index 77056f6e..0c0f60d5 100644
--- a/Assets/My project/Fonts/Pretendard-Black SDF.asset
+++ b/Assets/My project/Fonts/Pretendard-Black SDF.asset
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:416ee431de75a308492db33c9dfd1ac9327fa2de91f3b6237de8675601c4fd0e
-size 41425872
+oid sha256:8bd3b336696b6ef91fd7881fc4e2b0f7bd00b231ae68b0ef4a9108fd4c3b669f
+size 7883524
diff --git a/Assets/My project/Inventory/Prefabs/InventoryMiniUI.prefab.meta b/Assets/My project/Inventory/Prefabs/InventoryMiniUI.prefab.meta
index 4ff5fb16..02c69322 100644
--- a/Assets/My project/Inventory/Prefabs/InventoryMiniUI.prefab.meta
+++ b/Assets/My project/Inventory/Prefabs/InventoryMiniUI.prefab.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: b48b2192c45050d4592c9211d7817e28
+guid: f0db9ef8810ed2e4fad874ce8a39d817
PrefabImporter:
externalObjects: {}
userData:
diff --git a/Assets/My project/Inventory/Scripts/InventoryItemType.cs b/Assets/My project/Inventory/Scripts/InventoryItemType.cs
deleted file mode 100644
index 00aea677..00000000
--- a/Assets/My project/Inventory/Scripts/InventoryItemType.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using UnityEngine;
-
-///
-/// 인벤토리에서 관리할 아이템 종류입니다.
-/// 문자열 대신 enum을 사용하면 오타로 인한 버그를 줄일 수 있습니다.
-///
-public enum InventoryItemType
-{
- Fish,
- OldCompass,
- Trash,
- Bottle,
- MemoryPiece
-}
diff --git a/Assets/My project/Inventory/Scripts/InventoryItemType.cs.meta b/Assets/My project/Inventory/Scripts/InventoryItemType.cs.meta
deleted file mode 100644
index 709ea638..00000000
--- a/Assets/My project/Inventory/Scripts/InventoryItemType.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 9fc654a578c829244936abec2cf40091
\ No newline at end of file
diff --git a/Assets/My project/Inventory/Scripts/InventoryManager.cs b/Assets/My project/Inventory/Scripts/InventoryManager.cs
index 2d5c0c14..eeacaa8b 100644
--- a/Assets/My project/Inventory/Scripts/InventoryManager.cs
+++ b/Assets/My project/Inventory/Scripts/InventoryManager.cs
@@ -3,6 +3,33 @@
using UnityEngine;
using UnityEngine.Events;
+///
+/// 인벤토리에서 관리할 아이템 종류입니다.
+/// 아이템을 추가하려면 여기에 enum 값을 추가하고, InventoryManager의 itemDefinitions에도 데이터를 추가하세요.
+///
+public enum InventoryItemType
+{
+ Fish,
+ OldCompass,
+ Trash,
+ Bottle,
+ MemoryPiece
+}
+
+///
+/// 인벤토리 UI 필터/정렬에 사용할 간단한 카테고리입니다.
+///
+public enum InventoryItemCategory
+{
+ All,
+ Consumable,
+ Quest,
+ Collectible,
+ KeyItem,
+ Material,
+ Other
+}
+
[Serializable]
public class InventoryItemStack
{
@@ -11,40 +38,209 @@ public class InventoryItemStack
}
[Serializable]
-public class InventoryItemChangedEvent : UnityEvent
+public class InventoryItemDefinition
{
+ [Header("Identity")]
+ public InventoryItemType itemType;
+ public string displayName;
+ [TextArea(2, 5)] public string description;
+ [TextArea(1, 3)] public string goalHint;
+
+ [Header("Visuals")]
+ public Sprite icon;
+ public Sprite slotBackground;
+ public bool importantItem;
+
+ [Header("Rules")]
+ public InventoryItemCategory category = InventoryItemCategory.Other;
+ [Tooltip("0 이하이면 InventoryManager의 기본 최대 소지 수량을 사용합니다.")]
+ public int maxCount = 99;
+ public bool usable = false;
+ public bool consumeOnUse = true;
+ public bool requireUseConfirmation = false;
+ public string useLabel = "사용";
+ public string useSuccessMessage;
+ public string useFailMessage;
+
+ [Header("Audio")]
+ public AudioClip acquisitionClip;
+ public AudioClip useClip;
+
+ [Header("Slot Display Override")]
+ public bool overrideSlotDisplaySettings = false;
+ public bool hideWhenZero = false;
+ public bool dimWhenZero = true;
+ [Range(0f, 1f)] public float zeroAlpha = 0.35f;
+ [Range(0f, 1f)] public float ownedAlpha = 1f;
+
+ public string SafeDisplayName => string.IsNullOrWhiteSpace(displayName) ? itemType.ToString() : displayName;
}
+[Serializable]
+public class InventoryLogEntry
+{
+ public InventoryItemType itemType;
+ public int amount;
+ public string action;
+ public string displayName;
+ public string timeText;
+
+ public InventoryLogEntry() { }
+
+ public InventoryLogEntry(InventoryItemType itemType, int amount, string action, string displayName)
+ {
+ this.itemType = itemType;
+ this.amount = amount;
+ this.action = action;
+ this.displayName = displayName;
+ timeText = DateTime.Now.ToString("HH:mm:ss");
+ }
+
+ public override string ToString()
+ {
+ string amountText = amount > 0 ? $" x{amount}" : string.Empty;
+ return $"[{timeText}] {displayName}{amountText} {action}";
+ }
+}
+
+[Serializable] public class InventoryItemChangedEvent : UnityEvent { }
+[Serializable] public class InventoryItemAmountEvent : UnityEvent { }
+[Serializable] public class InventoryStringEvent : UnityEvent { }
+[Serializable] public class InventoryProgressEvent : UnityEvent { }
+
///
/// 실제 아이템 개수를 관리하는 중심 스크립트입니다.
-/// UI를 직접 수정하지 않고, 아이템 개수 변경 이벤트만 알려줍니다.
+/// 기능: 싱글톤, 저장/불러오기, 최대 소지 수량, 사용, 부족 안내, 획득 로그, 기억의 조각 진행도 이벤트.
///
[DisallowMultipleComponent]
public class InventoryManager : MonoBehaviour
{
+ public static InventoryManager Instance { get; private set; }
+
+ [Header("Singleton")]
+ [SerializeField] private bool useSingleton = true;
+ [SerializeField] private bool dontDestroyOnLoad = true;
+ [SerializeField] private bool destroyDuplicateManagers = true;
+
[Header("Initial Items")]
[SerializeField] private List initialItems = new List();
+ [Header("Item Definitions")]
+ [SerializeField] private List itemDefinitions = new List()
+ {
+ new InventoryItemDefinition { itemType = InventoryItemType.Fish, displayName = "생선", description = "고양이 합창단이 좋아합니다.", goalHint = "고양이에게 줄 수 있습니다.", category = InventoryItemCategory.Consumable, maxCount = 99, usable = true, consumeOnUse = true, useSuccessMessage = "생선을 사용했습니다." },
+ new InventoryItemDefinition { itemType = InventoryItemType.OldCompass, displayName = "낡은 나침반", description = "미로에서 길을 찾는 데 도움이 됩니다.", goalHint = "미로에서 힌트를 볼 때 필요합니다.", category = InventoryItemCategory.KeyItem, maxCount = 1, usable = true, consumeOnUse = false, importantItem = true, useSuccessMessage = "나침반이 방향을 가리킵니다." },
+ new InventoryItemDefinition { itemType = InventoryItemType.Trash, displayName = "쓰레기", description = "낚시터를 정화하는 데 필요합니다.", goalHint = "정화 장치에 넣을 수 있습니다.", category = InventoryItemCategory.Material, maxCount = 99, usable = true, consumeOnUse = true, useSuccessMessage = "쓰레기를 사용했습니다." },
+ new InventoryItemDefinition { itemType = InventoryItemType.Bottle, displayName = "마법병", description = "바다 속에서 발견한 수상한 병입니다.", goalHint = "특정 이벤트에서 사용할 수 있습니다.", category = InventoryItemCategory.KeyItem, maxCount = 1, usable = true, consumeOnUse = false, importantItem = true, requireUseConfirmation = true, useSuccessMessage = "마법병을 사용했습니다." },
+ new InventoryItemDefinition { itemType = InventoryItemType.MemoryPiece, displayName = "기억의 조각", description = "제페토를 구출하기 위한 중요한 조각입니다.", goalHint = "모든 조각을 모으면 중요한 이벤트가 열립니다.", category = InventoryItemCategory.Quest, maxCount = 5, usable = false, consumeOnUse = false, importantItem = true }
+ };
+
+ [Header("Limits")]
+ [Tooltip("0 이하이면 기본 최대 수량 제한을 사용하지 않습니다. 아이템 정의의 maxCount가 있으면 그것이 우선됩니다.")]
+ [SerializeField] private int defaultMaxCount = 999;
+
+ [Header("Memory Piece Progress")]
+ [SerializeField] private InventoryItemType memoryPieceItemType = InventoryItemType.MemoryPiece;
+ [Min(1)] [SerializeField] private int memoryPieceTargetCount = 5;
+
+ [Header("Save / Load")]
+ [SerializeField] private bool loadOnAwake = true;
+ [SerializeField] private bool saveOnChange = true;
+ [Tooltip("Persistent Pickup/Reward 완료 상태는 아이템 수량 자동 저장을 꺼도 저장하는 것을 권장합니다.")]
+ [SerializeField] private bool forceSavePersistentState = true;
+ [SerializeField] private string saveKey = "Inventory_SaveData";
+
+ [Header("Recent Log")]
+ [SerializeField] private bool keepRecentLogs = true;
+ [SerializeField] private int maxRecentLogCount = 5;
+
[Header("Events")]
public InventoryItemChangedEvent onItemCountChanged;
+ public InventoryItemAmountEvent onItemAdded;
+ public InventoryItemAmountEvent onItemRemoved;
+ public InventoryItemChangedEvent onItemUsed;
+ public InventoryStringEvent onMessageRequested;
+ public InventoryProgressEvent onMemoryPieceProgressChanged;
+ public UnityEvent onMemoryPieceCompleted;
public UnityEvent onInventoryChanged;
[Header("Debug")]
[SerializeField] private bool showDebugLog = true;
private readonly Dictionary itemCounts = new Dictionary();
+ private readonly HashSet consumedPersistentKeys = new HashSet();
+ private readonly List recentLogs = new List();
public event Action ItemCountChanged;
+ public event Action ItemAdded;
+ public event Action ItemRemoved;
+ public event Action ItemUsed;
public event Action InventoryChanged;
+ public event Action MessageRequested;
+ public event Action LogAdded;
+ public event Action MemoryPieceProgressChanged;
+ public event Action MemoryPieceCompleted;
+
+ private bool memoryPieceCompletedNotified;
+
+ public int MemoryPieceTargetCount => memoryPieceTargetCount;
+ public InventoryItemType MemoryPieceItemType => memoryPieceItemType;
private void Awake()
{
+ if (useSingleton)
+ {
+ if (Instance != null && Instance != this)
+ {
+ if (showDebugLog)
+ Debug.LogWarning($"[InventoryManager] 중복 InventoryManager 발견: {name}");
+
+ if (destroyDuplicateManagers)
+ {
+ Destroy(gameObject);
+ return;
+ }
+ }
+ else
+ {
+ Instance = this;
+
+ if (dontDestroyOnLoad)
+ DontDestroyOnLoad(gameObject);
+ }
+ }
+
+ EnsureItemDefinitions();
InitializeFromInspector();
+
+ if (loadOnAwake)
+ LoadInventory(false);
}
private void Start()
{
NotifyAllItemsChanged();
+ NotifyMemoryPieceProgress();
+ }
+
+ private void EnsureItemDefinitions()
+ {
+ if (itemDefinitions == null)
+ itemDefinitions = new List();
+
+ foreach (InventoryItemType itemType in Enum.GetValues(typeof(InventoryItemType)))
+ {
+ if (GetDefinitionInternal(itemType) == null)
+ {
+ itemDefinitions.Add(new InventoryItemDefinition
+ {
+ itemType = itemType,
+ displayName = itemType.ToString(),
+ maxCount = defaultMaxCount,
+ category = InventoryItemCategory.Other
+ });
+ }
+ }
}
private void InitializeFromInspector()
@@ -60,7 +256,7 @@ private void InitializeFromInspector()
if (stack == null)
continue;
- itemCounts[stack.itemType] = Mathf.Max(0, stack.count);
+ itemCounts[stack.itemType] = ClampCount(stack.itemType, stack.count);
}
}
@@ -71,14 +267,43 @@ public void AddItem(InventoryItemType itemType)
public void AddItem(InventoryItemType itemType, int amount)
{
- if (amount <= 0)
- return;
+ AddItemAndGetAddedAmount(itemType, amount);
+ }
- int newCount = GetItemCount(itemType) + amount;
- SetItemCount(itemType, newCount);
+ ///
+ /// 실제로 추가된 개수를 반환합니다. 최대 소지 수량에 막히면 요청량보다 적을 수 있습니다.
+ ///
+ public int AddItemAndGetAddedAmount(InventoryItemType itemType, int amount)
+ {
+ if (amount <= 0)
+ return 0;
+
+ int previousCount = GetItemCount(itemType);
+ int newCount = ClampCount(itemType, previousCount + amount);
+ int addedAmount = Mathf.Max(0, newCount - previousCount);
+
+ if (addedAmount <= 0)
+ {
+ RequestMessage($"{GetDisplayName(itemType)}은(는) 더 이상 가질 수 없습니다.");
+
+ if (showDebugLog)
+ Debug.Log($"[InventoryManager] {itemType} 최대 소지 수량 도달: {previousCount}");
+
+ return 0;
+ }
+
+ itemCounts[itemType] = newCount;
+ NotifyItemChanged(itemType, newCount);
+ NotifyItemAdded(itemType, addedAmount, newCount);
+ AddLog(itemType, addedAmount, "획득");
+
+ if (saveOnChange)
+ SaveInventory();
if (showDebugLog)
- Debug.Log($"[InventoryManager] {itemType} +{amount} => {newCount}");
+ Debug.Log($"[InventoryManager] {itemType} +{addedAmount} => {newCount}");
+
+ return addedAmount;
}
public bool RemoveItem(InventoryItemType itemType)
@@ -95,19 +320,84 @@ public bool RemoveItem(InventoryItemType itemType, int amount)
if (current < amount)
{
+ RequestMessage(GetInsufficientMessage(itemType, amount));
+
if (showDebugLog)
Debug.LogWarning($"[InventoryManager] {itemType} 부족: 현재 {current}, 필요 {amount}");
return false;
}
- SetItemCount(itemType, current - amount);
+ int newCount = current - amount;
+ itemCounts[itemType] = newCount;
+ NotifyItemChanged(itemType, newCount);
+ NotifyItemRemoved(itemType, amount, newCount);
+ AddLog(itemType, amount, "소모");
+
+ if (saveOnChange)
+ SaveInventory();
+
+ return true;
+ }
+
+ public bool UseItem(InventoryItemType itemType)
+ {
+ return UseItem(itemType, 1);
+ }
+
+ public bool UseItem(InventoryItemType itemType, int amount)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ bool consume = definition == null || definition.consumeOnUse;
+ return TryUseItem(itemType, amount, consume, true, true);
+ }
+
+ ///
+ /// 아이템을 사용합니다. 실제 게임 효과는 성공 이벤트나 외부 스크립트에서 처리하세요.
+ /// consume=false이면 보유 여부만 확인하고 개수는 줄이지 않습니다.
+ /// showMessages=false이면 부족/성공 안내를 표시하지 않습니다.
+ ///
+ public bool TryUseItem(InventoryItemType itemType, int amount, bool consume, bool showMessages = true, bool addUseLog = true)
+ {
+ amount = Mathf.Max(1, amount);
+
+ if (!HasItem(itemType, amount))
+ {
+ if (showMessages)
+ RequestMessage(GetInsufficientMessage(itemType, amount));
+
+ return false;
+ }
+
+ if (consume && !RemoveItem(itemType, amount))
+ return false;
+
+ ItemUsed?.Invoke(itemType, GetItemCount(itemType));
+ onItemUsed?.Invoke(itemType, GetItemCount(itemType));
+
+ // consume=true인 경우 RemoveItem에서 이미 "소모" 로그가 남습니다.
+ // consume=false인 빠른 사용(예: 나침반 확인)은 별도 "사용" 로그를 남깁니다.
+ if (addUseLog && !consume)
+ AddLog(itemType, amount, "사용");
+
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ if (showMessages)
+ {
+ string message = definition != null && !string.IsNullOrWhiteSpace(definition.useSuccessMessage)
+ ? definition.useSuccessMessage
+ : $"{GetDisplayName(itemType)}을(를) 사용했습니다.";
+ RequestMessage(message);
+ }
+
+ if (saveOnChange)
+ SaveInventory();
+
return true;
}
public void SetItemCount(InventoryItemType itemType, int count)
{
- int clampedCount = Mathf.Max(0, count);
+ int clampedCount = ClampCount(itemType, count);
int previousCount = GetItemCount(itemType);
if (previousCount == clampedCount)
@@ -115,6 +405,9 @@ public void SetItemCount(InventoryItemType itemType, int count)
itemCounts[itemType] = clampedCount;
NotifyItemChanged(itemType, clampedCount);
+
+ if (saveOnChange)
+ SaveInventory();
}
public int GetItemCount(InventoryItemType itemType)
@@ -135,17 +428,331 @@ public bool HasItem(InventoryItemType itemType, int requiredAmount)
return GetItemCount(itemType) >= Mathf.Max(1, requiredAmount);
}
+ public int GetMaxCount(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+
+ if (definition != null && definition.maxCount > 0)
+ return definition.maxCount;
+
+ return Mathf.Max(0, defaultMaxCount);
+ }
+
+ ///
+ /// 현재 소지 수량과 최대 소지 수량을 기준으로 실제로 더 넣을 수 있는 개수를 반환합니다.
+ /// maxCount가 0 이하이면 제한 없음으로 취급합니다.
+ ///
+ public int GetAddableAmount(InventoryItemType itemType, int requestedAmount)
+ {
+ requestedAmount = Mathf.Max(0, requestedAmount);
+ if (requestedAmount <= 0)
+ return 0;
+
+ int maxCount = GetMaxCount(itemType);
+ if (maxCount <= 0)
+ return requestedAmount;
+
+ int current = GetItemCount(itemType);
+ return Mathf.Clamp(maxCount - current, 0, requestedAmount);
+ }
+
+ public bool CanAddItem(InventoryItemType itemType, int amount = 1, bool requireFullAmount = false)
+ {
+ amount = Mathf.Max(1, amount);
+ int addableAmount = GetAddableAmount(itemType, amount);
+ return requireFullAmount ? addableAmount >= amount : addableAmount > 0;
+ }
+
+ public InventoryItemDefinition GetDefinition(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinitionInternal(itemType);
+ return definition;
+ }
+
+ private InventoryItemDefinition GetDefinitionInternal(InventoryItemType itemType)
+ {
+ if (itemDefinitions == null)
+ return null;
+
+ for (int i = 0; i < itemDefinitions.Count; i++)
+ {
+ if (itemDefinitions[i] != null && itemDefinitions[i].itemType == itemType)
+ return itemDefinitions[i];
+ }
+
+ return null;
+ }
+
+ public string GetDisplayName(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null && !string.IsNullOrWhiteSpace(definition.displayName)
+ ? definition.displayName
+ : itemType.ToString();
+ }
+
+ public string GetDescription(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.description : string.Empty;
+ }
+
+ public string GetGoalHint(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.goalHint : string.Empty;
+ }
+
+ public Sprite GetIcon(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.icon : null;
+ }
+
+ public Sprite GetSlotBackground(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.slotBackground : null;
+ }
+
+ public AudioClip GetAcquisitionClip(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.acquisitionClip : null;
+ }
+
+ public AudioClip GetUseClip(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.useClip : null;
+ }
+
+ public bool IsImportantItem(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null && definition.importantItem;
+ }
+
+ public InventoryItemCategory GetCategory(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null ? definition.category : InventoryItemCategory.Other;
+ }
+
+ public bool IsUsable(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null && definition.usable;
+ }
+
+ public bool RequiresUseConfirmation(InventoryItemType itemType)
+ {
+ InventoryItemDefinition definition = GetDefinition(itemType);
+ return definition != null && definition.requireUseConfirmation;
+ }
+
+ public string GetInsufficientMessage(InventoryItemType itemType, int requiredAmount)
+ {
+ int current = GetItemCount(itemType);
+ return $"{GetDisplayName(itemType)}이(가) 부족합니다. 필요: {Mathf.Max(1, requiredAmount)}개 / 현재: {current}개";
+ }
+
+ public IReadOnlyList GetRecentLogs()
+ {
+ return new List(recentLogs);
+ }
+
+ public IReadOnlyDictionary GetAllItemCounts()
+ {
+ return new Dictionary(itemCounts);
+ }
+
public void ClearInventory()
{
foreach (InventoryItemType itemType in Enum.GetValues(typeof(InventoryItemType)))
itemCounts[itemType] = 0;
NotifyAllItemsChanged();
+ NotifyMemoryPieceProgress();
+
+ if (saveOnChange)
+ SaveInventory();
}
- public IReadOnlyDictionary GetAllItemCounts()
+ public void ResetToInitialItems()
{
- return itemCounts;
+ InitializeFromInspector();
+ NotifyAllItemsChanged();
+ NotifyMemoryPieceProgress();
+
+ if (saveOnChange)
+ SaveInventory();
+ }
+
+ public bool IsPersistentKeyConsumed(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return false;
+
+ return consumedPersistentKeys.Contains(key);
+ }
+
+ public void MarkPersistentKeyConsumed(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return;
+
+ if (consumedPersistentKeys.Add(key) && (saveOnChange || forceSavePersistentState))
+ SaveInventory();
+ }
+
+ public void ResetPersistentKey(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return;
+
+ if (consumedPersistentKeys.Remove(key) && (saveOnChange || forceSavePersistentState))
+ SaveInventory();
+ }
+
+ public void RequestMessage(string message)
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ return;
+
+ MessageRequested?.Invoke(message);
+ onMessageRequested?.Invoke(message);
+
+ if (showDebugLog)
+ Debug.Log($"[InventoryManager] Message: {message}");
+ }
+
+ private void AddLog(InventoryItemType itemType, int amount, string action)
+ {
+ if (!keepRecentLogs)
+ return;
+
+ InventoryLogEntry entry = new InventoryLogEntry(itemType, amount, action, GetDisplayName(itemType));
+ recentLogs.Insert(0, entry);
+
+ while (recentLogs.Count > Mathf.Max(1, maxRecentLogCount))
+ recentLogs.RemoveAt(recentLogs.Count - 1);
+
+ LogAdded?.Invoke(entry);
+ }
+
+ public void SaveInventory()
+ {
+ InventorySaveData saveData = new InventorySaveData();
+
+ foreach (KeyValuePair pair in itemCounts)
+ {
+ saveData.items.Add(new InventorySavedItemCount
+ {
+ itemTypeName = pair.Key.ToString(),
+ count = Mathf.Max(0, pair.Value)
+ });
+ }
+
+ foreach (string key in consumedPersistentKeys)
+ saveData.consumedPersistentKeys.Add(key);
+
+ string json = JsonUtility.ToJson(saveData);
+ PlayerPrefs.SetString(saveKey, json);
+ PlayerPrefs.Save();
+
+ if (showDebugLog)
+ Debug.Log("[InventoryManager] 인벤토리 저장 완료");
+ }
+
+ public bool LoadInventory()
+ {
+ return LoadInventory(true);
+ }
+
+ private bool LoadInventory(bool notify)
+ {
+ if (!PlayerPrefs.HasKey(saveKey))
+ return false;
+
+ string json = PlayerPrefs.GetString(saveKey, string.Empty);
+ if (string.IsNullOrWhiteSpace(json))
+ return false;
+
+ InventorySaveData saveData;
+
+ try
+ {
+ saveData = JsonUtility.FromJson(json);
+ }
+ catch (Exception exception)
+ {
+ Debug.LogWarning($"[InventoryManager] 저장 데이터 읽기 실패: {exception.Message}");
+ return false;
+ }
+
+ InitializeFromInspector();
+ consumedPersistentKeys.Clear();
+
+ if (saveData != null)
+ {
+ for (int i = 0; i < saveData.items.Count; i++)
+ {
+ InventorySavedItemCount savedItem = saveData.items[i];
+ if (savedItem == null || string.IsNullOrWhiteSpace(savedItem.itemTypeName))
+ continue;
+
+ if (Enum.TryParse(savedItem.itemTypeName, out InventoryItemType itemType))
+ itemCounts[itemType] = ClampCount(itemType, savedItem.count);
+ }
+
+ for (int i = 0; i < saveData.consumedPersistentKeys.Count; i++)
+ {
+ string key = saveData.consumedPersistentKeys[i];
+ if (!string.IsNullOrWhiteSpace(key))
+ consumedPersistentKeys.Add(key);
+ }
+ }
+
+ if (notify)
+ {
+ NotifyAllItemsChanged();
+ NotifyMemoryPieceProgress();
+ }
+
+ if (showDebugLog)
+ Debug.Log("[InventoryManager] 인벤토리 불러오기 완료");
+
+ return true;
+ }
+
+ public void DeleteSavedInventory()
+ {
+ PlayerPrefs.DeleteKey(saveKey);
+ PlayerPrefs.Save();
+
+ consumedPersistentKeys.Clear();
+ recentLogs.Clear();
+ memoryPieceCompletedNotified = false;
+
+ // ResetToInitialItems()를 호출하면 saveOnChange가 true일 때 삭제 직후 다시 저장 파일을 만들 수 있으므로
+ // 여기서는 직접 초기화와 알림만 수행합니다.
+ InitializeFromInspector();
+ NotifyAllItemsChanged();
+ NotifyMemoryPieceProgress();
+
+ RequestMessage("인벤토리 저장 데이터를 삭제했습니다.");
+ }
+
+ private int ClampCount(InventoryItemType itemType, int count)
+ {
+ int safeCount = Mathf.Max(0, count);
+ int maxCount = GetMaxCount(itemType);
+
+ if (maxCount > 0)
+ safeCount = Mathf.Min(safeCount, maxCount);
+
+ return safeCount;
}
private void NotifyItemChanged(InventoryItemType itemType, int count)
@@ -155,6 +762,21 @@ private void NotifyItemChanged(InventoryItemType itemType, int count)
onItemCountChanged?.Invoke(itemType, count);
onInventoryChanged?.Invoke();
+
+ if (itemType == memoryPieceItemType)
+ NotifyMemoryPieceProgress();
+ }
+
+ private void NotifyItemAdded(InventoryItemType itemType, int addedAmount, int totalCount)
+ {
+ ItemAdded?.Invoke(itemType, addedAmount, totalCount);
+ onItemAdded?.Invoke(itemType, addedAmount, totalCount);
+ }
+
+ private void NotifyItemRemoved(InventoryItemType itemType, int removedAmount, int totalCount)
+ {
+ ItemRemoved?.Invoke(itemType, removedAmount, totalCount);
+ onItemRemoved?.Invoke(itemType, removedAmount, totalCount);
}
private void NotifyAllItemsChanged()
@@ -169,17 +791,83 @@ private void NotifyAllItemsChanged()
onInventoryChanged?.Invoke();
}
+ private void NotifyMemoryPieceProgress()
+ {
+ int current = GetItemCount(memoryPieceItemType);
+ int target = Mathf.Max(1, memoryPieceTargetCount);
+
+ MemoryPieceProgressChanged?.Invoke(current, target);
+ onMemoryPieceProgressChanged?.Invoke(current, target);
+
+ if (current < target)
+ {
+ memoryPieceCompletedNotified = false;
+ return;
+ }
+
+ if (!memoryPieceCompletedNotified)
+ {
+ memoryPieceCompletedNotified = true;
+ MemoryPieceCompleted?.Invoke();
+ onMemoryPieceCompleted?.Invoke();
+ }
+ }
+
+ // 개발용 버튼/UnityEvent 연결용 메서드입니다.
+ public void DebugAddFish() => AddItem(InventoryItemType.Fish, 1);
+ public void DebugAddCompass() => AddItem(InventoryItemType.OldCompass, 1);
+ public void DebugAddTrash() => AddItem(InventoryItemType.Trash, 1);
+ public void DebugAddBottle() => AddItem(InventoryItemType.Bottle, 1);
+ public void DebugAddMemoryPiece() => AddItem(InventoryItemType.MemoryPiece, 1);
+ public void DebugClearInventory() => ClearInventory();
+ public void DebugDeleteSave() => DeleteSavedInventory();
+
#if UNITY_EDITOR
private void OnValidate()
{
- if (initialItems == null)
- return;
+ if (defaultMaxCount < 0)
+ defaultMaxCount = 0;
- for (int i = 0; i < initialItems.Count; i++)
+ if (memoryPieceTargetCount < 1)
+ memoryPieceTargetCount = 1;
+
+ if (maxRecentLogCount < 1)
+ maxRecentLogCount = 1;
+
+ if (string.IsNullOrWhiteSpace(saveKey))
+ saveKey = "Inventory_SaveData";
+
+ if (initialItems != null)
{
- if (initialItems[i] != null)
- initialItems[i].count = Mathf.Max(0, initialItems[i].count);
+ for (int i = 0; i < initialItems.Count; i++)
+ {
+ if (initialItems[i] != null)
+ initialItems[i].count = Mathf.Max(0, initialItems[i].count);
+ }
+ }
+
+ if (itemDefinitions != null)
+ {
+ for (int i = 0; i < itemDefinitions.Count; i++)
+ {
+ if (itemDefinitions[i] != null)
+ itemDefinitions[i].maxCount = Mathf.Max(0, itemDefinitions[i].maxCount);
+ }
}
}
#endif
+
+ [Serializable]
+ private class InventorySaveData
+ {
+ public List items = new List();
+ public List consumedPersistentKeys = new List();
+ }
+
+ [Serializable]
+ private class InventorySavedItemCount
+ {
+ public string itemTypeName;
+ public int count;
+ }
}
diff --git a/Assets/My project/Inventory/Scripts/InventorySlotUI.cs b/Assets/My project/Inventory/Scripts/InventorySlotUI.cs
index fbbd8b76..a8644876 100644
--- a/Assets/My project/Inventory/Scripts/InventorySlotUI.cs
+++ b/Assets/My project/Inventory/Scripts/InventorySlotUI.cs
@@ -4,12 +4,19 @@
using UnityEngine.EventSystems;
using UnityEngine.UI;
+public enum InventorySlotClickMode
+{
+ None,
+ ShowDetail,
+ RequestUse
+}
+
///
/// 인벤토리 슬롯 하나를 담당하는 UI 스크립트입니다.
-/// FishSlot, CompassSlot, TrashSlot, BottleSlot 등에 각각 붙입니다.
+/// VR 포인터 Hover/Select, NEW 배지, 0개 흐림 표시, 중요 아이템 강조, 상세보기/사용 클릭을 지원합니다.
///
[DisallowMultipleComponent]
-public class InventorySlotUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, ISelectHandler, IDeselectHandler
+public class InventorySlotUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler, ISelectHandler, IDeselectHandler, ISubmitHandler
{
[Header("Item")]
[SerializeField] private InventoryItemType itemType;
@@ -20,30 +27,75 @@ public class InventorySlotUI : MonoBehaviour, IPointerEnterHandler, IPointerExit
[SerializeField] private TMP_Text countText;
[SerializeField] private GameObject newBadge;
[SerializeField] private CanvasGroup canvasGroup;
+ [SerializeField] private GameObject importantGlowObject;
+ [SerializeField] private GameObject filteredOutOverlay;
- [Header("Tooltip")]
+ [Header("Tooltip / Parent UI")]
[SerializeField] private InventoryTooltipUI tooltipUI;
+ [SerializeField] private InventoryUI inventoryUI;
+
+ [Header("Click")]
+ [SerializeField] private InventorySlotClickMode clickMode = InventorySlotClickMode.ShowDetail;
[Header("Settings")]
+ [Tooltip("VR에서는 false 권장. 슬롯이 사라지면 Ray 조작 위치가 바뀌어 불편할 수 있습니다.")]
[SerializeField] private bool hideWhenZero = false;
[SerializeField] private bool dimWhenZero = true;
[SerializeField] private float zeroAlpha = 0.35f;
[SerializeField] private float ownedAlpha = 1f;
+ [SerializeField] private float filteredAlpha = 0.15f;
+ [SerializeField] private bool showCountWhenZero = true;
+ [SerializeField] private bool showMaxCount = false;
[SerializeField] private float newBadgeShowTime = 1.2f;
+ [Header("Hover Effect")]
+ [SerializeField] private bool useHoverScale = true;
+ [SerializeField] private float hoverScale = 1.08f;
+ [SerializeField] private bool bringToFrontOnHover = true;
+
private int currentCount;
+ private int currentMaxCount;
+ private bool filteredOut;
private Coroutine newBadgeRoutine;
+ private Vector3 originalScale;
+ private InventoryItemDefinition definition;
public InventoryItemType ItemType => itemType;
public int CurrentCount => currentCount;
+ public InventoryItemCategory Category => definition != null ? definition.category : InventoryItemCategory.Other;
private void Awake()
{
+ originalScale = transform.localScale;
+
if (canvasGroup == null)
canvasGroup = GetComponent();
+ if (slotBackground == null)
+ slotBackground = GetComponent();
+
+ if (inventoryUI == null)
+ inventoryUI = GetComponentInParent();
+
+ if (tooltipUI == null)
+ tooltipUI = GetComponentInParent();
+
if (newBadge != null)
newBadge.SetActive(false);
+
+ UpdateVisualState();
+ }
+
+ private void OnDisable()
+ {
+ HideNewBadge();
+ HideTooltip();
+ ResetHoverVisual();
+ }
+
+ public void SetInventoryUI(InventoryUI ui)
+ {
+ inventoryUI = ui;
}
public void SetItemType(InventoryItemType newItemType)
@@ -51,10 +103,52 @@ public void SetItemType(InventoryItemType newItemType)
itemType = newItemType;
}
+ public void SetDefinition(InventoryItemDefinition newDefinition)
+ {
+ definition = newDefinition;
+
+ if (definition == null)
+ {
+ UpdateVisualState();
+ return;
+ }
+
+ if (itemIcon != null)
+ {
+ itemIcon.sprite = definition.icon;
+ itemIcon.enabled = definition.icon != null;
+ }
+
+ if (slotBackground != null && definition.slotBackground != null)
+ {
+ slotBackground.sprite = definition.slotBackground;
+ slotBackground.enabled = true;
+ }
+
+ if (definition.overrideSlotDisplaySettings)
+ {
+ hideWhenZero = definition.hideWhenZero;
+ dimWhenZero = definition.dimWhenZero;
+ zeroAlpha = definition.zeroAlpha;
+ ownedAlpha = definition.ownedAlpha;
+ }
+
+ if (importantGlowObject != null)
+ importantGlowObject.SetActive(definition.importantItem);
+
+ UpdateVisualState();
+ }
+
public void SetCount(int count, bool showNewBadge)
+ {
+ SetCount(count, showNewBadge, currentMaxCount);
+ }
+
+ public void SetCount(int count, bool showNewBadge, int maxCount)
{
int previousCount = currentCount;
currentCount = Mathf.Max(0, count);
+ currentMaxCount = Mathf.Max(0, maxCount);
UpdateVisualState();
@@ -80,6 +174,23 @@ public void SetSlotBackground(Sprite sprite)
slotBackground.enabled = sprite != null;
}
+ public void SetFilteredOut(bool value)
+ {
+ filteredOut = value;
+
+ if (filteredOutOverlay != null)
+ filteredOutOverlay.SetActive(value);
+
+ UpdateVisualState();
+ }
+
+ public void SetHoverSettings(bool useScale, float scale, bool bringToFront)
+ {
+ useHoverScale = useScale;
+ hoverScale = Mathf.Max(1f, scale);
+ bringToFrontOnHover = bringToFront;
+ }
+
public void ShowNewBadge()
{
if (newBadge == null)
@@ -106,7 +217,7 @@ public void HideNewBadge()
private IEnumerator NewBadgeRoutine()
{
newBadge.SetActive(true);
- yield return new WaitForSeconds(newBadgeShowTime);
+ yield return new WaitForSecondsRealtime(newBadgeShowTime);
newBadge.SetActive(false);
newBadgeRoutine = null;
}
@@ -116,39 +227,108 @@ private void UpdateVisualState()
bool hasItem = currentCount > 0;
if (countText != null)
- countText.text = $"x{currentCount}";
+ {
+ if (!showCountWhenZero && currentCount <= 0)
+ countText.text = string.Empty;
+ else if (showMaxCount && currentMaxCount > 0)
+ countText.text = $"x{currentCount}/{currentMaxCount}";
+ else
+ countText.text = $"x{currentCount}";
+ }
if (hideWhenZero)
gameObject.SetActive(hasItem);
if (canvasGroup != null && dimWhenZero)
- canvasGroup.alpha = hasItem ? ownedAlpha : zeroAlpha;
+ {
+ float targetAlpha = hasItem ? ownedAlpha : zeroAlpha;
+ if (filteredOut)
+ targetAlpha = Mathf.Min(targetAlpha, filteredAlpha);
+
+ canvasGroup.alpha = targetAlpha;
+ canvasGroup.interactable = !filteredOut;
+ canvasGroup.blocksRaycasts = !filteredOut;
+ }
}
public void OnPointerEnter(PointerEventData eventData)
{
+ ApplyHoverVisual();
ShowTooltip();
}
public void OnPointerExit(PointerEventData eventData)
{
+ ResetHoverVisual();
HideTooltip();
}
+ public void OnPointerClick(PointerEventData eventData)
+ {
+ ActivateSlot();
+ }
+
public void OnSelect(BaseEventData eventData)
{
+ ApplyHoverVisual();
ShowTooltip();
}
public void OnDeselect(BaseEventData eventData)
{
+ ResetHoverVisual();
HideTooltip();
}
+ public void OnSubmit(BaseEventData eventData)
+ {
+ ActivateSlot();
+ }
+
+ private void ActivateSlot()
+ {
+ if (filteredOut)
+ return;
+
+ if (inventoryUI == null)
+ inventoryUI = GetComponentInParent();
+
+ if (inventoryUI == null)
+ return;
+
+ switch (clickMode)
+ {
+ case InventorySlotClickMode.ShowDetail:
+ inventoryUI.ShowItemDetail(itemType);
+ break;
+ case InventorySlotClickMode.RequestUse:
+ inventoryUI.RequestUseItem(itemType, 1);
+ break;
+ }
+ }
+
+ private void ApplyHoverVisual()
+ {
+ if (bringToFrontOnHover)
+ transform.SetAsLastSibling();
+
+ if (useHoverScale)
+ transform.localScale = originalScale * hoverScale;
+ }
+
+ private void ResetHoverVisual()
+ {
+ if (useHoverScale)
+ transform.localScale = originalScale;
+ }
+
public void ShowTooltip()
{
+ if (tooltipUI == null)
+ tooltipUI = GetComponentInParent();
+
if (tooltipUI != null)
- tooltipUI.ShowTooltip(itemType, currentCount);
+ tooltipUI.ShowTooltip(itemType, currentCount, currentMaxCount);
}
public void HideTooltip()
diff --git a/Assets/My project/Inventory/Scripts/InventoryTooltipUI.cs b/Assets/My project/Inventory/Scripts/InventoryTooltipUI.cs
index ef8f7dce..79c749ba 100644
--- a/Assets/My project/Inventory/Scripts/InventoryTooltipUI.cs
+++ b/Assets/My project/Inventory/Scripts/InventoryTooltipUI.cs
@@ -1,19 +1,10 @@
-using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
-[Serializable]
-public class InventoryTooltipEntry
-{
- public InventoryItemType itemType;
- public string displayName;
- [TextArea(2, 4)] public string description;
-}
-
///
/// 아이템 슬롯에 마우스/VR 포인터가 올라갔을 때 간단한 설명을 표시합니다.
-/// InventorySlotUI에서 ShowTooltip, HideTooltip을 호출합니다.
+/// InventoryManager의 ItemDefinition 데이터를 우선 사용합니다.
///
[DisallowMultipleComponent]
public class InventoryTooltipUI : MonoBehaviour
@@ -23,39 +14,68 @@ public class InventoryTooltipUI : MonoBehaviour
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text descriptionText;
[SerializeField] private TMP_Text countText;
+ [SerializeField] private TMP_Text goalHintText;
+ [SerializeField] private TMP_Text useHintText;
- [Header("Entries")]
- [SerializeField] private List entries = new List()
- {
- new InventoryTooltipEntry { itemType = InventoryItemType.Fish, displayName = "생선", description = "고양이 합창단이 좋아합니다." },
- new InventoryTooltipEntry { itemType = InventoryItemType.OldCompass, displayName = "낡은 나침반", description = "미로에서 길을 찾는 데 도움이 됩니다." },
- new InventoryTooltipEntry { itemType = InventoryItemType.Trash, displayName = "쓰레기", description = "낚시터를 정화하는 데 필요합니다." },
- new InventoryTooltipEntry { itemType = InventoryItemType.Bottle, displayName = "마법병", description = "바다 속에서 발견한 수상한 병입니다." },
- new InventoryTooltipEntry { itemType = InventoryItemType.MemoryPiece, displayName = "기억의 조각", description = "제페토를 구출하기 위한 중요한 조각입니다." }
- };
+ [Header("Reference")]
+ [SerializeField] private InventoryManager inventoryManager;
+ [SerializeField] private bool autoFindManager = true;
private void Awake()
{
+ ResolveManager();
HideTooltip();
}
public void ShowTooltip(InventoryItemType itemType, int count)
{
- InventoryTooltipEntry entry = FindEntry(itemType);
+ int maxCount = ResolveManager() != null ? inventoryManager.GetMaxCount(itemType) : 0;
+ ShowTooltip(itemType, count, maxCount);
+ }
+
+ public void ShowTooltip(InventoryItemType itemType, int count, int maxCount)
+ {
+ ResolveManager();
+
+ string displayName = itemType.ToString();
+ string description = string.Empty;
+ string goalHint = string.Empty;
+ string useHint = string.Empty;
+
+ InventoryItemDefinition definition = inventoryManager != null ? inventoryManager.GetDefinition(itemType) : null;
+ if (definition != null)
+ {
+ displayName = definition.SafeDisplayName;
+ description = definition.description;
+ goalHint = definition.goalHint;
+
+ if (definition.usable)
+ useHint = string.IsNullOrWhiteSpace(definition.useLabel) ? "사용 가능" : definition.useLabel;
+ }
if (tooltipPanel != null)
tooltipPanel.SetActive(true);
if (titleText != null)
- titleText.text = entry != null && !string.IsNullOrWhiteSpace(entry.displayName)
- ? entry.displayName
- : itemType.ToString();
+ titleText.text = displayName;
if (descriptionText != null)
- descriptionText.text = entry != null ? entry.description : string.Empty;
+ descriptionText.text = description;
if (countText != null)
- countText.text = $"보유: x{Mathf.Max(0, count)}";
+ countText.text = maxCount > 0 ? $"보유: x{Mathf.Max(0, count)} / {maxCount}" : $"보유: x{Mathf.Max(0, count)}";
+
+ if (goalHintText != null)
+ {
+ goalHintText.text = goalHint;
+ goalHintText.gameObject.SetActive(!string.IsNullOrWhiteSpace(goalHint));
+ }
+
+ if (useHintText != null)
+ {
+ useHintText.text = useHint;
+ useHintText.gameObject.SetActive(!string.IsNullOrWhiteSpace(useHint));
+ }
}
public void HideTooltip()
@@ -64,14 +84,16 @@ public void HideTooltip()
tooltipPanel.SetActive(false);
}
- private InventoryTooltipEntry FindEntry(InventoryItemType itemType)
+ private InventoryManager ResolveManager()
{
- for (int i = 0; i < entries.Count; i++)
- {
- if (entries[i] != null && entries[i].itemType == itemType)
- return entries[i];
- }
+ if (inventoryManager != null)
+ return inventoryManager;
- return null;
+ if (InventoryManager.Instance != null)
+ inventoryManager = InventoryManager.Instance;
+ else if (autoFindManager)
+ inventoryManager = FindFirstObjectByType();
+
+ return inventoryManager;
}
}
diff --git a/Assets/My project/Inventory/Scripts/InventoryUI.cs b/Assets/My project/Inventory/Scripts/InventoryUI.cs
index 62767035..9855fc9a 100644
--- a/Assets/My project/Inventory/Scripts/InventoryUI.cs
+++ b/Assets/My project/Inventory/Scripts/InventoryUI.cs
@@ -1,31 +1,158 @@
+using System.Collections;
+using System.Collections.Generic;
+using TMPro;
using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
///
/// 전체 인벤토리 UI를 갱신하는 스크립트입니다.
-/// InventoryManager의 변경 이벤트를 받아 각 InventorySlotUI에 전달합니다.
+/// 기능: 패널 열기/닫기, 슬롯 갱신, 획득 팝업, 부족 안내 메시지, 확인창, 상세보기, 획득 로그, 기억의 조각 진행도, 간단한 손목/타겟 추적.
+/// InventoryUI가 붙은 오브젝트는 항상 켜두고, 실제 보이는 inventoryPanel만 켜고 끄는 구조를 권장합니다.
///
[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();
+
+ if (inventoryPanel == null && autoFindPanelChild)
+ {
+ Transform panelTransform = transform.Find(autoPanelChildName);
+ if (panelTransform != null)
+ inventoryPanel = panelTransform.gameObject;
+ }
+
+ if (uiAudioSource == null)
+ uiAudioSource = GetComponent();
+
+ 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 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