设置扩展
This commit is contained in:
@@ -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 与两个文字 Mask;MessageBox、标题、正文均已是最终布局尺寸。
|
||||
/// 每个 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,96 +4,306 @@ using DG.Tweening;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Menu.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 可复用的多选弹窗元素。调用方提供标题、内容与任意数量的 <see cref="SelectionBoxOption"/>,
|
||||
/// 本类动态实例化对应按钮,并确保一次显示最多只会执行一个选项回调。
|
||||
/// 通用多选弹窗。
|
||||
/// 标题、正文与按钮均在播放前完成最终布局;入退场只改变 Background、两处文本 Mask 和 Rail,
|
||||
/// 因而不会在动画期间触发 TMP 重新换行或按钮布局抖动。
|
||||
/// </summary>
|
||||
public class SelectionBox : MonoBehaviour
|
||||
public sealed class SelectionBox : MonoBehaviour
|
||||
{
|
||||
[Header("Selection Box")]
|
||||
public CanvasGroup canvasGroup;
|
||||
public TMP_Text titleText;
|
||||
public TMP_Text contentText;
|
||||
public RectTransform optionContainer;
|
||||
public SelectionBoxButton optionButtonPrefab;
|
||||
[Header("Base")]
|
||||
[SerializeField] private CanvasGroup canvasGroup;
|
||||
[SerializeField] private CanvasGroup optionCanvasGroup;
|
||||
|
||||
private readonly List<SelectionBoxButton> _generatedButtons = new();
|
||||
private bool _isResolved;
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// 选项被选择并且自身淡出结束后触发。页面容器使用它销毁本次生成的 SelectionBox。
|
||||
/// 玩家选择任一选项、弹窗完成退场后触发。MessageUIPage 使用它继续共享 FIFO 队列。
|
||||
/// </summary>
|
||||
public event Action<SelectionBox> Closed;
|
||||
|
||||
private readonly List<SelectionBoxButton> 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()
|
||||
{
|
||||
canvasGroup ??= GetComponent<CanvasGroup>();
|
||||
backgroundOpenSizeDelta = backgroundVisual.sizeDelta;
|
||||
leftRailOpenPosition = leftRail.anchoredPosition;
|
||||
rightRailOpenPosition = rightRail.anchoredPosition;
|
||||
leftRailCanvasGroup = leftRail.GetComponent<CanvasGroup>();
|
||||
rightRailCanvasGroup = rightRail.GetComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
transitionSequence?.Kill();
|
||||
ClearGeneratedButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据运行时选项初始化弹窗。至少需要一个选项;失败时返回 false,且不会显示半配置的弹窗。
|
||||
/// 创建本次选择所需的按钮并播放入场动画。空选项没有可执行的玩家选择,因此返回 false。
|
||||
/// </summary>
|
||||
public bool SetUp(string title, string content, IReadOnlyList<SelectionBoxOption> options)
|
||||
{
|
||||
if (canvasGroup == null || optionContainer == null || optionButtonPrefab == null ||
|
||||
options == null || options.Count == 0)
|
||||
if (options == null || options.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBox] 缺少 CanvasGroup、选项容器、选项按钮预制体,或未提供任何选项。");
|
||||
Debug.LogWarning("[SelectionBox] 未提供任何 SelectionBoxOption,忽略空选项框。", this);
|
||||
return false;
|
||||
}
|
||||
|
||||
ClearGeneratedButtons();
|
||||
_isResolved = false;
|
||||
|
||||
if (titleText != null) titleText.text = title ?? string.Empty;
|
||||
if (contentText != null) contentText.text = content ?? string.Empty;
|
||||
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);
|
||||
generatedButtons.Add(button);
|
||||
button.SetUp(option.label, () => Select(option.action));
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
canvasGroup.alpha = 0f;
|
||||
canvasGroup.interactable = true;
|
||||
canvasGroup.blocksRaycasts = true;
|
||||
canvasGroup.DOFade(1f, 0.2f).Play();
|
||||
RefreshFinalLayout();
|
||||
Open();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Select(UnityAction action)
|
||||
{
|
||||
if (_isResolved) return;
|
||||
_isResolved = true;
|
||||
if (isResolved) return;
|
||||
isResolved = true;
|
||||
|
||||
// 先禁用所有输入,再执行回调。回调可以触发存档、场景切换或打开下一层 UI,
|
||||
// 因而不能让同一帧内的其它按钮再次触发。
|
||||
// 先阻断输入,再执行可能触发存档、跳转或新 UI 的业务回调。
|
||||
canvasGroup.interactable = false;
|
||||
canvasGroup.blocksRaycasts = false;
|
||||
foreach (SelectionBoxButton button in _generatedButtons)
|
||||
foreach (SelectionBoxButton button in generatedButtons)
|
||||
{
|
||||
if (button?.button != null)
|
||||
button.button.interactable = false;
|
||||
button.button.interactable = false;
|
||||
}
|
||||
|
||||
action?.Invoke();
|
||||
canvasGroup.DOFade(0f, 0.2f).OnComplete(() => Closed?.Invoke(this)).Play();
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在最终尺寸下重建正文、主体和按钮行的布局。后续动画不再改变这些布局节点的尺寸。
|
||||
/// </summary>
|
||||
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)
|
||||
foreach (SelectionBoxButton button in generatedButtons)
|
||||
{
|
||||
if (button != null)
|
||||
Destroy(button.gameObject);
|
||||
Destroy(button.gameObject);
|
||||
}
|
||||
|
||||
_generatedButtons.Clear();
|
||||
generatedButtons.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Menu.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用 SelectionBox 的页面宿主,职责等同于 MessageBox 的页面容器:
|
||||
/// 显示时动态生成一个 SelectionBox,选择完成后统一淡出并销毁本次实例。
|
||||
/// </summary>
|
||||
public class SelectionBoxUIPage : UIPageBase
|
||||
{
|
||||
[Header("Selection Box Page")]
|
||||
public SelectionBox selectionBoxPrefab;
|
||||
public RectTransform selectionBoxContainer;
|
||||
|
||||
private SelectionBox _currentSelectionBox;
|
||||
private bool _isShowing;
|
||||
|
||||
/// <summary>当前是否已有一个 SelectionBox 等待玩家选择。</summary>
|
||||
public bool IsShowing => _isShowing;
|
||||
|
||||
/// <summary>
|
||||
/// 生成并显示一组运行时选项。页面显示期间拒绝重复请求,避免两个弹窗争夺输入。
|
||||
/// </summary>
|
||||
public bool Show(string title, string content, IReadOnlyList<SelectionBoxOption> options)
|
||||
{
|
||||
if (_isShowing)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBoxUIPage] 已有 SelectionBox 显示中,忽略重复请求。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mainCanvasGroup == null || selectionBoxPrefab == null || selectionBoxContainer == null)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBoxUIPage] 缺少 CanvasGroup、SelectionBox Prefab 或容器,无法显示选项框。");
|
||||
return false;
|
||||
}
|
||||
|
||||
_currentSelectionBox = Instantiate(selectionBoxPrefab, selectionBoxContainer);
|
||||
_currentSelectionBox.Closed += HandleSelectionBoxClosed;
|
||||
if (!_currentSelectionBox.SetUp(title, content, options))
|
||||
{
|
||||
Destroy(_currentSelectionBox.gameObject);
|
||||
_currentSelectionBox = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_isShowing = true;
|
||||
|
||||
// UIPageBase 默认淡入完成后才开启射线;选择框从出现瞬间就必须阻断底层输入。
|
||||
mainCanvasGroup.gameObject.SetActive(true);
|
||||
mainCanvasGroup.interactable = true;
|
||||
mainCanvasGroup.blocksRaycasts = true;
|
||||
FadeIn();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void HandleSelectionBoxClosed(SelectionBox selectionBox)
|
||||
{
|
||||
if (selectionBox != _currentSelectionBox) return;
|
||||
|
||||
FadeOut(0.2f, false, () =>
|
||||
{
|
||||
if (_currentSelectionBox != null)
|
||||
Destroy(_currentSelectionBox.gameObject);
|
||||
|
||||
_currentSelectionBox = null;
|
||||
_isShowing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8b607f8c5654e1eb3bf1f7ca33fd3a4
|
||||
Reference in New Issue
Block a user