421 lines
12 KiB
C#
421 lines
12 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 右上角通用提示队列。该 UI 不阻塞输入,也不参与 UIPageManager 的页面栈。
|
||
|
|
/// </summary>
|
||
|
|
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<NotificationItem> activeItems = new();
|
||
|
|
private readonly Queue<NotificationData> pendingItems = new();
|
||
|
|
private bool roomClearedSubscribed;
|
||
|
|
private float nextSpawnTime;
|
||
|
|
|
||
|
|
private float StackStep => itemHeight + itemSpacing;
|
||
|
|
|
||
|
|
private void Awake()
|
||
|
|
{
|
||
|
|
Current = this;
|
||
|
|
rectTransform ??= GetComponent<RectTransform>();
|
||
|
|
canvasGroup ??= GetComponent<CanvasGroup>();
|
||
|
|
itemContainer ??= rectTransform;
|
||
|
|
battleCompletionBanner ??= GetComponentInChildren<BattleCompletionBanner>(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<NotificationUIArea>(FindObjectsInactive.Include);
|
||
|
|
return Current;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|