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);
}
}
}
}