Files
ichni_Official/Assets/Scripts/UI/Elements/MessageBox.cs
SoulliesOfficial 9fd5f098ab 设置扩展
2026-07-19 16:44:58 -04:00

324 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using DG.Tweening;
using SLSUtilities.WwiseAssistance;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Menu.UI
{
/// <summary>
/// 单条文本提示框。
/// 多条弹窗的 FIFO 顺序由 MessageUIPage 统一管理;本类只负责当前一条消息的布局、
/// 入退场视觉和确认输入,避免与 UIPage 的队列职责重复。
/// </summary>
public sealed class MessageBox : MonoBehaviour
{
[Header("Base")]
[SerializeField] private CanvasGroup canvasGroup;
[SerializeField] private Button receiveButton;
[Header("Text")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text contentText;
[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;
[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()
{
backgroundOpenSizeDelta = backgroundVisual.sizeDelta;
leftRailOpenPosition = leftRail.anchoredPosition;
rightRailOpenPosition = rightRail.anchoredPosition;
leftRailCanvasGroup = leftRail.GetComponent<CanvasGroup>();
rightRailCanvasGroup = rightRail.GetComponent<CanvasGroup>();
contentMaximumFontSize = contentText.fontSize;
receiveButton.onClick.AddListener(HandleReceiveButtonClicked);
}
private void OnDestroy()
{
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;
}
contentText.enableAutoSizing = false;
contentText.fontSize = contentMaximumFontSize;
ForceRebuildLayout();
if (messageBody.rect.height <= maximumBodyHeight)
{
return;
}
float minimumFontSize = Mathf.Min(minimumContentFontSize, contentMaximumFontSize);
float lowerBound = minimumFontSize;
float upperBound = contentMaximumFontSize;
float bestFontSize = minimumFontSize;
// 字号与内容高度单调相关;六次二分已足够平滑且只在弹窗创建时执行。
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);
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;
});
transitionSequence.Play();
}
/// <summary>
/// 反向收拢 Background、文字 Mask 和两侧 Rail完成后通知 MessageUIPage 显示下一项。
/// </summary>
private void Close()
{
transitionSequence?.Kill();
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
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);
Closed?.Invoke(this);
});
transitionSequence.Play();
}
}
}