设置扩展

This commit is contained in:
SoulliesOfficial
2026-07-19 16:44:58 -04:00
parent dda354ebb9
commit 9fd5f098ab
110 changed files with 8386 additions and 2311 deletions

View File

@@ -1,158 +1,323 @@
using System;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using Sirenix.OdinInspector;
using SLSUtilities.WwiseAssistance;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace Ichni.Menu.UI
{
public partial class MessageBox : SerializedMonoBehaviour
/// <summary>
/// 单条文本提示框。
/// 多条弹窗的 FIFO 顺序由 MessageUIPage 统一管理;本类只负责当前一条消息的布局、
/// 入退场视觉和确认输入,避免与 UIPage 的队列职责重复。
/// </summary>
public sealed class MessageBox : MonoBehaviour
{
public CanvasGroup canvasGroup;
[Header("Base")]
[SerializeField] private CanvasGroup canvasGroup;
[SerializeField] private Button receiveButton;
[Tooltip("当该消息框结束所有消息并彻底淡出消失时触发。")]
public UnityEvent onAllMessagesClosed = new UnityEvent();
[Header("Text")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text contentText;
public int infoIndex;
public List<MessageBoxInfo> infos;
public Sprite defaultIcon;
public Image iconImage;
public TMP_Text titleText;
public TMP_Text contentText;
public Button receiveButton;
public Tweener fadeTweener;
[Header("Layout References")]
[Tooltip("可变高度的最终布局节点。动画期间不会修改它、Title 或 Content 的 RectTransform。")]
[SerializeField] private RectTransform messageBody;
[SerializeField] private RectTransform backgroundVisual;
[SerializeField] private RectMask2D titleTextMask;
[SerializeField] private RectTransform contentLayoutRoot;
[SerializeField] private RectMask2D contentTextMask;
[SerializeField] private RectTransform railRoot;
[SerializeField] private RectTransform leftRail;
[SerializeField] private RectTransform rightRail;
private void Start()
[Header("Content Height Limit")]
[SerializeField, Min(0f)] private float maximumBodyHeight = 600f;
[SerializeField, Min(1f)] private float minimumContentFontSize = 18f;
[Header("Animation")]
[Tooltip("背景与文本 Mask 从中心展开前的延迟Rail 的默认延迟为 0因此会先行动。")]
[SerializeField, Min(0f)] private float bodyOpenDelay = 0.1f;
[SerializeField, Min(0f)] private float bodyOpenDuration = 0.5f;
[SerializeField, Min(0f)] private float railOpenDelay;
[SerializeField, Min(0f)] private float railOpenDuration = 0.5f;
[SerializeField, Min(0f)] private float bodyCloseDelay;
[SerializeField, Min(0f)] private float bodyCloseDuration = 0.5f;
[SerializeField, Min(0f)] private float railCloseDelay = 0.1f;
[SerializeField, Min(0f)] private float railCloseDuration = 0.5f;
[SerializeField, Min(0f)] private float closedMaskHorizontalPadding = 520f;
[SerializeField] private Ease bodyEase = Ease.OutCubic;
[SerializeField] private Ease railEase = Ease.OutQuart;
/// <summary>
/// 退场动画结束后触发。MessageUIPage 订阅它以销毁当前实例并继续 FIFO 队列。
/// </summary>
public event Action<MessageBox> Closed;
private Sequence transitionSequence;
private CanvasGroup leftRailCanvasGroup;
private CanvasGroup rightRailCanvasGroup;
private Vector2 backgroundOpenSizeDelta;
private Vector2 leftRailOpenPosition;
private Vector2 rightRailOpenPosition;
private float contentMaximumFontSize;
private void Awake()
{
receiveButton.onClick.AddListener(()=>PlayNextMessage(true));
receiveButton.GetComponent<RectTransform>().sizeDelta = new Vector2(Screen.width, Screen.height);
backgroundOpenSizeDelta = backgroundVisual.sizeDelta;
leftRailOpenPosition = leftRail.anchoredPosition;
rightRailOpenPosition = rightRail.anchoredPosition;
leftRailCanvasGroup = leftRail.GetComponent<CanvasGroup>();
rightRailCanvasGroup = rightRail.GetComponent<CanvasGroup>();
contentMaximumFontSize = contentText.fontSize;
receiveButton.onClick.AddListener(HandleReceiveButtonClicked);
}
public void SetUp()
private void OnDestroy()
{
if (infos.Count == 0)
transitionSequence?.Kill();
receiveButton.onClick.RemoveListener(HandleReceiveButtonClicked);
}
/// <summary>
/// 写入唯一一条待显示文本,并播放入场动画。
/// 多条提示请分别提交给 MessageUIPage而不要在一个 MessageBox 中追加消息。
/// </summary>
public void SetUp(string title, string content)
{
titleText.text = title ?? string.Empty;
contentText.text = content ?? string.Empty;
RefreshLayoutAndFitContentText();
Open();
}
private void HandleReceiveButtonClicked()
{
AudioManager.Post(AK.EVENTS.CONFIRM);
Close();
}
/// <summary>
/// 使用最终宽度计算文字换行与内容高度。正文超过 maximumBodyHeight 时,
/// 通过二分查找缩小至可容纳的最大字号;不会引入滚动或截断内容。
/// </summary>
private void RefreshLayoutAndFitContentText()
{
SetOpenVisualState();
ForceRebuildLayout();
if (maximumBodyHeight <= 0f)
{
return;
}
infoIndex = 0;
PlayNextMessage();
FadeIn();
}
public void SetUpIcon(Sprite icon)
{
iconImage.sprite = icon;
}
public void AddInfo(string title, string content, UnityAction action = null, Sprite icon = null)
{
MessageBoxInfo info = new MessageBoxInfo(icon, title, content, action);
infos.Add(info);
}
contentText.enableAutoSizing = false;
contentText.fontSize = contentMaximumFontSize;
ForceRebuildLayout();
public void PlayNextMessage(bool playAudio = false)
{
if (playAudio)
if (messageBody.rect.height <= maximumBodyHeight)
{
AudioManager.Post(AK.EVENTS.CONFIRM);
}
if (infoIndex >= infos.Count)
{
FadeOut();
return;
}
infos[infoIndex].action?.Invoke();
if (titleText != null) titleText.text = infos[infoIndex].title;
if (contentText != null) contentText.text = infos[infoIndex].content;
this.iconImage.sprite = infos[infoIndex].icon != null ? infos[infoIndex].icon : defaultIcon;
infoIndex++;
}
public void Clear()
{
infos = new List<MessageBoxInfo>();
infoIndex = 0;
iconImage.sprite = defaultIcon;
titleText.text = string.Empty;
contentText.text = string.Empty;
}
}
public partial class MessageBox
{
// 废弃对 I2.Loc 参数化功能的支持
// public void SetParameter(string paramName, string paramValue, bool localize = false)
// {
// }
}
float minimumFontSize = Mathf.Min(minimumContentFontSize, contentMaximumFontSize);
float lowerBound = minimumFontSize;
float upperBound = contentMaximumFontSize;
float bestFontSize = minimumFontSize;
public partial class MessageBox
{
public void FadeIn(float duration = 0.5f, bool ignoreTimeScale = false)
// 字号与内容高度单调相关;六次二分已足够平滑且只在弹窗创建时执行。
for (int iteration = 0; iteration < 6; iteration++)
{
float candidate = (lowerBound + upperBound) * 0.5f;
contentText.fontSize = candidate;
ForceRebuildLayout();
if (messageBody.rect.height <= maximumBodyHeight)
{
bestFontSize = candidate;
lowerBound = candidate;
}
else
{
upperBound = candidate;
}
}
contentText.fontSize = bestFontSize;
ForceRebuildLayout();
if (messageBody.rect.height > maximumBodyHeight)
{
Debug.LogWarning(
$"[MessageBox] 正文在最小字号 {minimumFontSize:F1} 下仍超过最大高度 {maximumBodyHeight:F1}" +
"已保留完整内容并允许消息框继续增高。",
this);
}
}
private void ForceRebuildLayout()
{
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(contentLayoutRoot);
LayoutRebuilder.ForceRebuildLayoutImmediate(messageBody);
Canvas.ForceUpdateCanvases();
}
/// <summary>
/// 恢复最终视觉尺寸以完成布局。这里不会改变 MessageBox、Title 或 Content 的尺寸,
/// 因此 TMP 在展开和收拢全过程都不会重新换行。
/// </summary>
private void SetOpenVisualState()
{
backgroundVisual.sizeDelta = backgroundOpenSizeDelta;
titleTextMask.padding = Vector4.zero;
contentTextMask.padding = Vector4.zero;
leftRail.anchoredPosition = leftRailOpenPosition;
rightRail.anchoredPosition = rightRailOpenPosition;
leftRailCanvasGroup.alpha = 1f;
rightRailCanvasGroup.alpha = 1f;
}
private void SetClosedVisualState()
{
backgroundVisual.sizeDelta = GetClosedBackgroundSizeDelta();
titleTextMask.padding = ClosedMaskPadding;
contentTextMask.padding = ClosedMaskPadding;
float halfRootWidth = railRoot.rect.width * 0.5f;
leftRail.anchoredPosition = new Vector2(halfRootWidth, leftRailOpenPosition.y);
rightRail.anchoredPosition = new Vector2(-halfRootWidth, rightRailOpenPosition.y);
leftRailCanvasGroup.alpha = 0f;
rightRailCanvasGroup.alpha = 0f;
}
private Vector4 ClosedMaskPadding =>
new(closedMaskHorizontalPadding, 0f, closedMaskHorizontalPadding, 0f);
/// <summary>
/// Background 使用左右 Stretch 锚点。以最终实际宽度抵消它的 SizeDelta X
/// 可将图像视觉收拢到中心而不改变 MessageBox 的布局宽度。
/// </summary>
private Vector2 GetClosedBackgroundSizeDelta() =>
new(backgroundOpenSizeDelta.x - backgroundVisual.rect.width, backgroundOpenSizeDelta.y);
private static Tween CreateMaskPaddingTween(RectMask2D mask, Vector4 targetPadding, float duration, Ease ease) =>
DOTween.To(() => mask.padding, value => mask.padding = value, targetPadding, duration).SetEase(ease);
private static void AddRailTransition(
Sequence sequence,
RectTransform rail,
CanvasGroup railCanvasGroup,
Vector2 targetPosition,
float targetAlpha,
float delay,
float duration,
Ease positionEase)
{
sequence.Insert(delay, rail.DOAnchorPos(targetPosition, duration).SetEase(positionEase));
sequence.Insert(delay, railCanvasGroup.DOFade(targetAlpha, duration).SetEase(Ease.Linear));
}
/// <summary>
/// 只展开 Background 与两个文字 MaskMessageBox、标题、正文均已是最终布局尺寸。
/// 每个 Sequence 都先 Pause再在结尾显式调用 Play符合项目的 DOTween 使用约定。
/// </summary>
private void Open()
{
transitionSequence?.Kill();
canvasGroup.gameObject.SetActive(true);
fadeTweener = canvasGroup.DOFade(1f, duration).OnComplete(() =>
canvasGroup.alpha = 1f;
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
SetClosedVisualState();
transitionSequence = DOTween.Sequence().SetAutoKill(true).Pause();
transitionSequence.Insert(
bodyOpenDelay,
backgroundVisual.DOSizeDelta(backgroundOpenSizeDelta, bodyOpenDuration).SetEase(bodyEase));
transitionSequence.Insert(
bodyOpenDelay,
CreateMaskPaddingTween(titleTextMask, Vector4.zero, bodyOpenDuration, bodyEase));
transitionSequence.Insert(
bodyOpenDelay,
CreateMaskPaddingTween(contentTextMask, Vector4.zero, bodyOpenDuration, bodyEase));
AddRailTransition(
transitionSequence,
leftRail,
leftRailCanvasGroup,
leftRailOpenPosition,
1f,
railOpenDelay,
railOpenDuration,
railEase);
AddRailTransition(
transitionSequence,
rightRail,
rightRailCanvasGroup,
rightRailOpenPosition,
1f,
railOpenDelay,
railOpenDuration,
railEase);
transitionSequence.OnComplete(() =>
{
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
});
if (ignoreTimeScale)
{
fadeTweener.SetUpdate(true);
}
fadeTweener.Play();
transitionSequence.Play();
}
public void FadeOut(float duration = 0.5f, bool ignoreTimeScale = false)
/// <summary>
/// 反向收拢 Background、文字 Mask 和两侧 Rail完成后通知 MessageUIPage 显示下一项。
/// </summary>
private void Close()
{
transitionSequence?.Kill();
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
fadeTweener = canvasGroup.DOFade(0f, duration).OnComplete(() =>
float halfRootWidth = railRoot.rect.width * 0.5f;
transitionSequence = DOTween.Sequence().SetAutoKill(true).Pause();
transitionSequence.Insert(
bodyCloseDelay,
backgroundVisual.DOSizeDelta(GetClosedBackgroundSizeDelta(), bodyCloseDuration).SetEase(Ease.InCubic));
transitionSequence.Insert(
bodyCloseDelay,
CreateMaskPaddingTween(titleTextMask, ClosedMaskPadding, bodyCloseDuration, Ease.InCubic));
transitionSequence.Insert(
bodyCloseDelay,
CreateMaskPaddingTween(contentTextMask, ClosedMaskPadding, bodyCloseDuration, Ease.InCubic));
AddRailTransition(
transitionSequence,
leftRail,
leftRailCanvasGroup,
new Vector2(halfRootWidth, leftRailOpenPosition.y),
0f,
railCloseDelay,
railCloseDuration,
Ease.InQuart);
AddRailTransition(
transitionSequence,
rightRail,
rightRailCanvasGroup,
new Vector2(-halfRootWidth, rightRailOpenPosition.y),
0f,
railCloseDelay,
railCloseDuration,
Ease.InQuart);
transitionSequence.OnComplete(() =>
{
canvasGroup.gameObject.SetActive(false);
onAllMessagesClosed?.Invoke();
Closed?.Invoke(this);
});
if (ignoreTimeScale)
{
fadeTweener.SetUpdate(true);
}
fadeTweener.Play();
transitionSequence.Play();
}
}
public struct MessageBoxInfo
{
public Sprite icon;
public string title;
public string content;
public UnityAction action;
public MessageBoxInfo(Sprite icon, string title, string content, UnityAction action)
{
this.icon = icon;
this.title = title;
this.content = content;
this.action = action;
}
}
}
}