using System; using System.Collections.Generic; 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 { public InventoryItemType itemType; [Min(0)] public int count; } [Serializable] 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 { } /// /// 실제 아이템 개수를 관리하는 중심 스크립트입니다. /// 기능: 싱글톤, 저장/불러오기, 최대 소지 수량, 사용, 부족 안내, 획득 로그, 기억의 조각 진행도 이벤트. /// [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("UI")] [SerializeField] private InventoryUI _inventoryUI; [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() { itemCounts.Clear(); foreach (InventoryItemType itemType in Enum.GetValues(typeof(InventoryItemType))) itemCounts[itemType] = 0; for (int i = 0; i < initialItems.Count; i++) { InventoryItemStack stack = initialItems[i]; if (stack == null) continue; itemCounts[stack.itemType] = ClampCount(stack.itemType, stack.count); } } public void AddItem(InventoryItemType itemType) { AddItem(itemType, 1); } public void AddItem(InventoryItemType itemType, int amount) { AddItemAndGetAddedAmount(itemType, amount); } /// /// 실제로 추가된 개수를 반환합니다. 최대 소지 수량에 막히면 요청량보다 적을 수 있습니다. /// 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} +{addedAmount} => {newCount}"); return addedAmount; } public bool RemoveItem(InventoryItemType itemType) { return RemoveItem(itemType, 1); } public bool RemoveItem(InventoryItemType itemType, int amount) { if (amount <= 0) return false; int current = GetItemCount(itemType); if (current < amount) { RequestMessage(GetInsufficientMessage(itemType, amount)); if (showDebugLog) Debug.LogWarning($"[InventoryManager] {itemType} 부족: 현재 {current}, 필요 {amount}"); return false; } 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 = ClampCount(itemType, count); int previousCount = GetItemCount(itemType); if (previousCount == clampedCount) return; itemCounts[itemType] = clampedCount; NotifyItemChanged(itemType, clampedCount); if (saveOnChange) SaveInventory(); } public int GetItemCount(InventoryItemType itemType) { if (itemCounts.TryGetValue(itemType, out int count)) return count; return 0; } public bool HasItem(InventoryItemType itemType) { return GetItemCount(itemType) > 0; } 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 void ResetToInitialItems() { 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) { ItemCountChanged?.Invoke(itemType, count); InventoryChanged?.Invoke(); 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() { foreach (KeyValuePair pair in itemCounts) { ItemCountChanged?.Invoke(pair.Key, pair.Value); onItemCountChanged?.Invoke(pair.Key, pair.Value); } InventoryChanged?.Invoke(); 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(); public void InventoryUIToggle() { _inventoryUI.gameObject.SetActive(!_inventoryUI.gameObject.activeSelf); } #if UNITY_EDITOR private void OnValidate() { if (defaultMaxCount < 0) defaultMaxCount = 0; if (memoryPieceTargetCount < 1) memoryPieceTargetCount = 1; if (maxRecentLogCount < 1) maxRecentLogCount = 1; if (string.IsNullOrWhiteSpace(saveKey)) saveKey = "Inventory_SaveData"; if (initialItems != null) { 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; } }