using System; using DG.Tweening; using SLSUtilities.WwiseAssistance; using TMPro; using UnityEngine; using UnityEngine.UI; namespace Ichni.Menu.UI { /// /// 单条文本提示框。 /// 多条弹窗的 FIFO 顺序由 MessageUIPage 统一管理;本类只负责当前一条消息的布局、 /// 入退场视觉和确认输入,避免与 UIPage 的队列职责重复。 /// 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; /// /// 退场动画结束后触发。MessageUIPage 订阅它以销毁当前实例并继续 FIFO 队列。 /// public event Action 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(); rightRailCanvasGroup = rightRail.GetComponent(); contentMaximumFontSize = contentText.fontSize; receiveButton.onClick.AddListener(HandleReceiveButtonClicked); } private void OnDestroy() { transitionSequence?.Kill(); receiveButton.onClick.RemoveListener(HandleReceiveButtonClicked); } /// /// 写入唯一一条待显示文本,并播放入场动画。 /// 多条提示请分别提交给 MessageUIPage,而不要在一个 MessageBox 中追加消息。 /// 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(); } /// /// 使用最终宽度计算文字换行与内容高度。正文超过 maximumBodyHeight 时, /// 通过二分查找缩小至可容纳的最大字号;不会引入滚动或截断内容。 /// 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(); } /// /// 恢复最终视觉尺寸以完成布局。这里不会改变 MessageBox、Title 或 Content 的尺寸, /// 因此 TMP 在展开和收拢全过程都不会重新换行。 /// 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); /// /// Background 使用左右 Stretch 锚点。以最终实际宽度抵消它的 SizeDelta X, /// 可将图像视觉收拢到中心而不改变 MessageBox 的布局宽度。 /// 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)); } /// /// 只展开 Background 与两个文字 Mask;MessageBox、标题、正文均已是最终布局尺寸。 /// 每个 Sequence 都先 Pause,再在结尾显式调用 Play,符合项目的 DOTween 使用约定。 /// 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(); } /// /// 反向收拢 Background、文字 Mask 和两侧 Rail;完成后通知 MessageUIPage 显示下一项。 /// 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(); } } }