using UnityEngine; using System.Collections.Generic; using DG.Tweening; using Ashwild.Inventory; using Ashwild.Player; namespace Ashwild.UI { /// /// Drives the on-screen item/recipe notification stack. Incoming notifications are queued and /// released one at a time on a short delay, so a burst (e.g. one pickup unlocking several recipes) /// reveals gradually instead of popping all at once, capped by a max on-screen count. /// public class NotificationUI : MonoBehaviour { [Header("References")] [SerializeField] private RectTransform container; [SerializeField] private GameObject notificationPrefab; [Header("Layout")] [SerializeField] private float notificationHeight = 40f; [SerializeField] private float spacing = 6f; [SerializeField] private float startOffsetY = 20f; [Header("Timing")] [SerializeField] private float displayDuration = 4f; [Header("Animation")] [SerializeField] private float slideInDuration = 0.35f; [SerializeField] private Ease slideInEase = Ease.OutBack; [SerializeField] private float stackSlideDuration = 0.25f; [SerializeField] private Ease stackSlideEase = Ease.OutQuad; [Header("Colors")] [SerializeField] private Color gainColor = new Color(0.45f, 1f, 0.45f); // picked up / added [SerializeField] private Color lossColor = new Color(1f, 0.45f, 0.45f); // dropped / consumed [Header("Discovery")] [Tooltip("Badge shown in the number slot when a new item/recipe is discovered.")] [SerializeField] private string discoveryLabel = "NEW"; [SerializeField] private Color discoveryColor = new Color(1f, 0.85f, 0.3f); [Header("Queue")] [Tooltip("Minimum delay between two notifications appearing, so a burst reveals one at a time.")] [SerializeField] private float spawnDelay = 0.5f; [Tooltip("Maximum notifications visible on screen at once; the rest wait in the queue.")] [SerializeField] private int maxActiveNotifications = 5; [Tooltip("Maximum notifications waiting in the queue; beyond this the oldest pending one is dropped.")] [SerializeField] private int maxQueuedNotifications = 20; private readonly List activeNotifications = new List(); private readonly Queue pendingQueue = new Queue(); private float spawnCooldown; /// /// A notification waiting to be shown. Holds either an item gain/loss (item + signed quantity, /// still mergeable while queued) or a recipe discovery (icon + name). /// private class PendingNotification { public bool isDiscovery; public bool isMessage; public ItemData item; public int signedQuantity; public Sprite icon; public string label; } private void OnEnable() { PlayerEvents.ItemAdded += OnItemAdded; PlayerEvents.ItemDropped += OnItemDropped; PlayerEvents.ItemConsumed += OnItemConsumed; PlayerEvents.RecipeDiscovered += OnRecipeDiscovered; PlayerEvents.InteractionRefused += OnInteractionRefused; PlayerEvents.FoodPlacedToCook += OnItemSpent; PlayerEvents.FuelAdded += OnItemSpent; } private void OnDisable() { PlayerEvents.ItemAdded -= OnItemAdded; PlayerEvents.ItemDropped -= OnItemDropped; PlayerEvents.ItemConsumed -= OnItemConsumed; PlayerEvents.RecipeDiscovered -= OnRecipeDiscovered; PlayerEvents.InteractionRefused -= OnInteractionRefused; PlayerEvents.FoodPlacedToCook -= OnItemSpent; PlayerEvents.FuelAdded -= OnItemSpent; } /// /// Announces a gain — but never a transfer: pulling a stack out of a chest only shuffles items /// between the player's own containers, so popping a "+N" for it would be noise. /// private void OnItemAdded(ItemData item, int quantity, bool isTransfer) { if (isTransfer) return; Push(item, quantity); } private void OnItemDropped(ItemData item, int quantity) => Push(item, -quantity); private void OnItemConsumed(ItemData item) => Push(item, -1); private void OnRecipeDiscovered(Sprite icon, string recipeName) => PushDiscovery(icon, recipeName); private void OnInteractionRefused(string reason) => PushMessage(reason); // Food placed on a cooking station or fuel fed to it both leave the inventory by one. private void OnItemSpent(ItemData item) => Push(item, -1); /// /// Records an item gain/loss. Merges instantly into a matching active notification, then into a /// matching one still queued, otherwise enqueues a new one to be released on the spawn delay. /// private void Push(ItemData item, int signedQuantity) { if (item == null || signedQuantity == 0) return; activeNotifications.RemoveAll(n => n == null); bool gain = signedQuantity > 0; for (int i = 0; i < activeNotifications.Count; i++) { if (activeNotifications[i] != null && !activeNotifications[i].IsDiscovery && activeNotifications[i].ItemData == item && activeNotifications[i].IsGain == gain) { activeNotifications[i].AddQuantity(signedQuantity, displayDuration); return; } } foreach (PendingNotification pending in pendingQueue) { if (!pending.isDiscovery && pending.item == item && (pending.signedQuantity > 0) == gain) { pending.signedQuantity += signedQuantity; return; } } Enqueue(new PendingNotification { isDiscovery = false, item = item, signedQuantity = signedQuantity }); } /// /// Enqueues a one-time "NEW" notification for a newly unlocked recipe (its result icon and name /// with the discovery badge). Never merges; released on the spawn delay like the rest. /// private void PushDiscovery(Sprite icon, string recipeName) { Enqueue(new PendingNotification { isDiscovery = true, icon = icon, label = recipeName }); } /// /// Enqueues a plain message telling the player why an interaction gave them nothing ("Inventory /// full"). Collapses onto an identical message that is still on screen — spamming the interact key /// against a full inventory must refresh one line, not stack ten of them — and is tinted with the /// loss colour, since it always reports something the player did not get. /// private void PushMessage(string message) { if (string.IsNullOrEmpty(message)) return; activeNotifications.RemoveAll(n => n == null); for (int i = 0; i < activeNotifications.Count; i++) { if (activeNotifications[i] != null && activeNotifications[i].Message == message) { activeNotifications[i].RefreshMessage(displayDuration); return; } } foreach (PendingNotification pending in pendingQueue) if (pending.isMessage && pending.label == message) return; Enqueue(new PendingNotification { isMessage = true, label = message }); } /// /// Adds a pending notification to the queue, dropping the oldest waiting one when the queue is /// full so the most recent notifications are never lost. /// private void Enqueue(PendingNotification pending) { if (pendingQueue.Count >= maxQueuedNotifications) pendingQueue.Dequeue(); pendingQueue.Enqueue(pending); } /// /// Ticks the spawn cooldown and releases at most one queued notification per delay, while the /// on-screen count is under the cap. /// private void Update() { if (spawnCooldown > 0f) spawnCooldown -= Time.deltaTime; if (pendingQueue.Count == 0 || spawnCooldown > 0f) return; activeNotifications.RemoveAll(n => n == null); if (activeNotifications.Count >= maxActiveNotifications) return; Spawn(pendingQueue.Dequeue()); spawnCooldown = spawnDelay; } /// /// Instantiates and slides in a notification for a released pending entry, wiring it as either /// an item gain/loss or a recipe discovery. /// private void Spawn(PendingNotification pending) { GameObject go = Instantiate(notificationPrefab, container); NotificationItemUI notification = go.GetComponent(); float targetY = startOffsetY + activeNotifications.Count * (notificationHeight + spacing); RectTransform rt = notification.RectTransform; rt.anchoredPosition = new Vector2(0f, targetY - 30f); if (pending.isMessage) notification.InitializeMessage(pending.label, lossColor, displayDuration, slideInDuration); else if (pending.isDiscovery) notification.InitializeDiscovery(pending.icon, pending.label, discoveryLabel, discoveryColor, displayDuration, slideInDuration); else notification.Initialize(pending.item, pending.signedQuantity, pending.signedQuantity > 0 ? gainColor : lossColor, displayDuration, slideInDuration, slideInEase); rt.DOAnchorPosY(targetY, slideInDuration).SetEase(slideInEase); activeNotifications.Add(notification); } private void LateUpdate() { // Clean up destroyed notifications and reposition remaining ones int removed = activeNotifications.RemoveAll(n => n == null); if (removed > 0) RepositionAll(); } private void RepositionAll() { for (int i = 0; i < activeNotifications.Count; i++) { if (activeNotifications[i] == null) continue; float targetY = startOffsetY + i * (notificationHeight + spacing); activeNotifications[i].RectTransform .DOAnchorPosY(targetY, stackSlideDuration) .SetEase(stackSlideEase); } } } }