设置扩展

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

@@ -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();
}
}