using System; using System.Collections.Generic; using DG.Tweening; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; namespace Ichni.Menu.UI { /// /// 通用多选弹窗。 /// 标题、正文与按钮均在播放前完成最终布局;入退场只改变 Background、两处文本 Mask 和 Rail, /// 因而不会在动画期间触发 TMP 重新换行或按钮布局抖动。 /// public sealed class SelectionBox : MonoBehaviour { [Header("Base")] [SerializeField] private CanvasGroup canvasGroup; [SerializeField] private CanvasGroup optionCanvasGroup; [Header("Text & Options")] [SerializeField] private TMP_Text titleText; [SerializeField] private TMP_Text contentText; [SerializeField] private RectTransform optionContainer; [SerializeField] private SelectionBoxButton optionButtonPrefab; [Header("Layout References")] [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("Animation")] [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 = 600f; [SerializeField] private Ease bodyEase = Ease.OutCubic; [SerializeField] private Ease railEase = Ease.OutQuart; /// /// 玩家选择任一选项、弹窗完成退场后触发。MessageUIPage 使用它继续共享 FIFO 队列。 /// public event Action Closed; private readonly List generatedButtons = new(); private Sequence transitionSequence; private CanvasGroup leftRailCanvasGroup; private CanvasGroup rightRailCanvasGroup; private Vector2 backgroundOpenSizeDelta; private Vector2 leftRailOpenPosition; private Vector2 rightRailOpenPosition; private bool isResolved; private void Awake() { backgroundOpenSizeDelta = backgroundVisual.sizeDelta; leftRailOpenPosition = leftRail.anchoredPosition; rightRailOpenPosition = rightRail.anchoredPosition; leftRailCanvasGroup = leftRail.GetComponent(); rightRailCanvasGroup = rightRail.GetComponent(); } private void OnDestroy() { transitionSequence?.Kill(); ClearGeneratedButtons(); } /// /// 创建本次选择所需的按钮并播放入场动画。空选项没有可执行的玩家选择,因此返回 false。 /// public bool SetUp(string title, string content, IReadOnlyList options) { if (options == null || options.Count == 0) { Debug.LogWarning("[SelectionBox] 未提供任何 SelectionBoxOption,忽略空选项框。", this); return false; } ClearGeneratedButtons(); isResolved = false; titleText.text = title ?? string.Empty; contentText.text = content ?? string.Empty; foreach (SelectionBoxOption option in options) { SelectionBoxButton button = Instantiate(optionButtonPrefab, optionContainer); generatedButtons.Add(button); button.SetUp(option.label, () => Select(option.action)); } gameObject.SetActive(true); RefreshFinalLayout(); Open(); return true; } private void Select(UnityAction action) { if (isResolved) return; isResolved = true; // 先阻断输入,再执行可能触发存档、跳转或新 UI 的业务回调。 canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; foreach (SelectionBoxButton button in generatedButtons) { button.button.interactable = false; } action?.Invoke(); Close(); } /// /// 在最终尺寸下重建正文、主体和按钮行的布局。后续动画不再改变这些布局节点的尺寸。 /// private void RefreshFinalLayout() { SetOpenVisualState(); Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(contentLayoutRoot); LayoutRebuilder.ForceRebuildLayoutImmediate(messageBody); LayoutRebuilder.ForceRebuildLayoutImmediate(optionContainer); Canvas.ForceUpdateCanvases(); } private void SetOpenVisualState() { backgroundVisual.sizeDelta = backgroundOpenSizeDelta; titleTextMask.padding = Vector4.zero; contentTextMask.padding = Vector4.zero; optionContainer.localScale = new Vector3(1f, optionContainer.localScale.y, optionContainer.localScale.z); optionCanvasGroup.alpha = 1f; optionCanvasGroup.interactable = true; optionCanvasGroup.blocksRaycasts = true; leftRail.anchoredPosition = leftRailOpenPosition; rightRail.anchoredPosition = rightRailOpenPosition; leftRailCanvasGroup.alpha = 1f; rightRailCanvasGroup.alpha = 1f; } private void SetClosedVisualState() { backgroundVisual.sizeDelta = GetClosedBackgroundSizeDelta(); titleTextMask.padding = ClosedMaskPadding; contentTextMask.padding = ClosedMaskPadding; optionCanvasGroup.alpha = 0f; optionCanvasGroup.interactable = false; optionCanvasGroup.blocksRaycasts = false; 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); 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)); } 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); float optionRevealTime = bodyOpenDelay + bodyOpenDuration; transitionSequence.Insert( optionRevealTime, optionCanvasGroup.DOFade(1f, 0.4f).From(0f).SetEase(Ease.Linear)); transitionSequence.Insert( optionRevealTime, optionContainer.DOAnchorPos(Vector2.zero, 0.4f).From(new Vector2(0f, 100f)).SetEase(Ease.OutQuad)); transitionSequence.OnComplete(() => { canvasGroup.interactable = true; canvasGroup.blocksRaycasts = true; optionCanvasGroup.interactable = true; optionCanvasGroup.blocksRaycasts = true; }); // 项目约定:DOTween 动画必须由代码显式触发 Play。 transitionSequence.Play(); } private void Close() { transitionSequence?.Kill(); canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; optionCanvasGroup.interactable = false; optionCanvasGroup.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)); transitionSequence.Insert( bodyCloseDelay, optionContainer.DOScaleX(0f, bodyCloseDuration).SetEase(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); }); // 项目约定:DOTween 动画必须由代码显式触发 Play。 transitionSequence.Play(); } private void ClearGeneratedButtons() { foreach (SelectionBoxButton button in generatedButtons) { Destroy(button.gameObject); } generatedButtons.Clear(); } } /// /// SelectionBox 的单个运行时选项。它不序列化到场景或存档;显示时由调用方临时创建。 /// public readonly struct SelectionBoxOption { public readonly string label; public readonly UnityAction action; public SelectionBoxOption(string label, UnityAction action) { this.label = label; this.action = action; } } }