using System; using System.Collections.Generic; using Cielonos.MainGame; using Cielonos.MainGame.Inventory; using Cielonos.MainGame.Inventory.Collections; using Sirenix.OdinInspector; using SLSUtilities.General; using SLSUtilities.UI; using UnityEngine; namespace Cielonos.MainGame.UI { public enum NotificationType { ItemObtained, CurrencyChanged, BattleCompleted, Warning, System } [Serializable] public struct NotificationData { public NotificationType type; public string title; public string message; public Sprite icon; public Color accentColor; public float duration; public NotificationData( NotificationType type, string title, string message, Sprite icon, Color accentColor, float duration) { this.type = type; this.title = title; this.message = message; this.icon = icon; this.accentColor = accentColor; this.duration = duration; } } /// /// 右上角通用提示队列。该 UI 不阻塞输入,也不参与 UIPageManager 的页面栈。 /// public class NotificationUIArea : UIElementBase { public static NotificationUIArea Current { get; private set; } [SerializeField] private NotificationItem itemPrefab; [SerializeField] private RectTransform itemContainer; [SerializeField] private BattleCompletionBanner battleCompletionBanner; [SerializeField, Min(1)] private int maxVisible = 4; [SerializeField, Min(1f)] private float itemHeight = 100f; [SerializeField, Min(0f)] private float itemSpacing = 10f; [SerializeField, Min(0f)] private float defaultDuration = 3f; [SerializeField, Min(0f)] private float enterDuration = 0.25f; [SerializeField, Min(0f)] private float moveDuration = 0.2f; [SerializeField, Min(0f)] private float exitDuration = 0.2f; [SerializeField, Min(0f)] private float queueWaitTime = 0.12f; [SerializeField] private Color defaultAccentColor = new Color(0.7f, 0.85f, 1f, 1f); [SerializeField] private Color currencyAccentColor = new Color(1f, 0.82f, 0.35f, 1f); [SerializeField] private Color warningAccentColor = new Color(1f, 0.4f, 0.35f, 1f); [SerializeField] private string battleCompletedMessage = "战斗结束"; private readonly List activeItems = new(); private readonly Queue pendingItems = new(); private bool roomClearedSubscribed; private float nextSpawnTime; private float StackStep => itemHeight + itemSpacing; private void Awake() { Current = this; rectTransform ??= GetComponent(); canvasGroup ??= GetComponent(); itemContainer ??= rectTransform; battleCompletionBanner ??= GetComponentInChildren(true); if (canvasGroup != null) { canvasGroup.alpha = 1f; canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; } } private void Start() { gameObject.SetActive(true); TrySubscribeRoomCleared(); } private void OnEnable() { Current = this; TrySubscribeRoomCleared(); } private void OnDestroy() { if (Current == this) { Current = null; } UnsubscribeRoomCleared(); } private void Update() { if (!roomClearedSubscribed) { TrySubscribeRoomCleared(); } ProcessPendingItems(); } public void Push(NotificationData data) { if (itemPrefab == null || itemContainer == null) { Debug.LogWarning("[NotificationUIArea] itemPrefab 或 itemContainer 未配置。"); return; } EnsureAreaVisible(); pendingItems.Enqueue(PrepareData(data)); ProcessPendingItems(); } public void PushBattleCompleted() { if (battleCompletionBanner != null) { battleCompletionBanner.Show(battleCompletedMessage); return; } Push(new NotificationData( NotificationType.BattleCompleted, "战斗完成", battleCompletedMessage, null, defaultAccentColor, defaultDuration)); } [TitleGroup("Debug")] [Button("生成测试 Notification")] private void PushTestNotification() { Push(new NotificationData( NotificationType.System, "测试通知", $"Notification Test {DateTime.Now:HH:mm:ss}", null, defaultAccentColor, defaultDuration)); } public void PushItemObtained(ItemBase item, int amount = 1) { if (item == null) { return; } Push(CreateItemObtainedData(item, amount)); } public void PushRareMaterial(int amount, string title = "奖励结算") { string message = amount > 0 ? $"获得 RareMaterial x{amount}" : "未获得 RareMaterial"; Push(new NotificationData( NotificationType.CurrencyChanged, title, message, null, currencyAccentColor, defaultDuration)); } public void PushWarning(string title, string message) { Push(new NotificationData( NotificationType.Warning, title, message, null, warningAccentColor, defaultDuration)); } public static NotificationData CreateItemObtainedData(ItemBase item, int amount = 1) { ContentData data = item?.contentData; if (item is PrefabricatedComponent component && component.targetEquipment != null) { data = component.targetEquipment.contentData; } string itemName = data != null && !string.IsNullOrEmpty(data.displayNameKey) ? data.displayNameKey.Localize("Items") : item != null ? item.name : "Unknown Item"; bool isUpgrade = item is PrefabricatedComponent; string title = isUpgrade ? "装备升级" : "获得物品"; string message = isUpgrade ? $"升级:{itemName} x{Mathf.Max(1, amount)}" : $"{itemName} x{Mathf.Max(1, amount)}"; return new NotificationData( NotificationType.ItemObtained, title, message, data?.itemIcon, GetItemAccentColor(data), 3f); } private static Color GetItemAccentColor(ContentData data) { return data != null ? RewardUIFormatter.GetRarityColor(data.itemRarity) : new Color(0.7f, 0.85f, 1f, 1f); } private NotificationData PrepareData(NotificationData data) { if (data.accentColor.a <= 0f) { data.accentColor = defaultAccentColor; } if (data.duration <= 0f) { data.duration = defaultDuration; } return data; } private void ProcessPendingItems() { if (pendingItems.Count == 0 || itemPrefab == null || itemContainer == null || maxVisible < 1) { return; } if (Time.unscaledTime < nextSpawnTime) { return; } SpawnItem(pendingItems.Dequeue()); nextSpawnTime = Time.unscaledTime + queueWaitTime; } private void SpawnItem(NotificationData data) { CleanupDestroyedItems(); while (activeItems.Count >= maxVisible) { RemoveOldest(); } NotificationItem item = Instantiate(itemPrefab, itemContainer); if (item == null) { return; } activeItems.Insert(0, item); item.Setup(data, defaultDuration); ApplyLayout(true); item.PlayIn(GetTargetPosition(0), enterDuration, () => Remove(item)); } private void ApplyLayout(bool animate) { CleanupDestroyedItems(); for (int i = 0; i < activeItems.Count; i++) { NotificationItem item = activeItems[i]; if (item == null) { continue; } Vector2 target = GetTargetPosition(i); if (animate && i > 0) { item.MoveTo(target, moveDuration); } else { item.SetPosition(target); } } } private Vector2 GetTargetPosition(int index) { // itemContainer 应锚定在右上角。新提示位于堆栈底部,旧提示向上移动。 Rect rect = itemContainer != null ? itemContainer.rect : new Rect(0f, 0f, 0f, maxVisible * StackStep); float pivotY = itemContainer != null ? itemContainer.pivot.y : 0.5f; float bottom = -rect.height * pivotY; float baseline = bottom + itemHeight * 0.5f; return new Vector2(0f, baseline + index * StackStep); } private void RemoveOldest() { int lastIndex = activeItems.Count - 1; if (lastIndex < 0) { return; } NotificationItem oldest = activeItems[lastIndex]; activeItems.RemoveAt(lastIndex); if (oldest != null) { oldest.DismissImmediate(null); } } private void Remove(NotificationItem item) { if (item == null) { return; } if (!activeItems.Remove(item)) { return; } item.PlayOut(null, exitDuration); ApplyLayout(true); } private void CleanupDestroyedItems() { activeItems.RemoveAll(item => item == null); } private void EnsureAreaVisible() { if (!gameObject.activeSelf) { gameObject.SetActive(true); } if (canvasGroup == null) { return; } canvasGroup.alpha = 1f; canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; } private void TrySubscribeRoomCleared() { if (roomClearedSubscribed || RunManager.Instance == null) { return; } RunManager.Instance.OnCombatRoomCleared += HandleCombatRoomCleared; roomClearedSubscribed = true; } private void UnsubscribeRoomCleared() { if (!roomClearedSubscribed || RunManager.Instance == null) { roomClearedSubscribed = false; return; } RunManager.Instance.OnCombatRoomCleared -= HandleCombatRoomCleared; roomClearedSubscribed = false; } private void HandleCombatRoomCleared(RunMapNode node) { PushBattleCompleted(); } public static NotificationUIArea Resolve() { if (Current != null) { return Current; } Current = FindFirstObjectByType(FindObjectsInactive.Include); return Current; } } }