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

325 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 System.Collections.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace Ichni.Menu.UI
{
/// <summary>
/// 通用多选弹窗。
/// 标题、正文与按钮均在播放前完成最终布局;入退场只改变 Background、两处文本 Mask 和 Rail
/// 因而不会在动画期间触发 TMP 重新换行或按钮布局抖动。
/// </summary>
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;
/// <summary>
/// 玩家选择任一选项、弹窗完成退场后触发。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()
{
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。
/// </summary>
public bool SetUp(string title, string content, IReadOnlyList<SelectionBoxOption> 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();
}
/// <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)
{
Destroy(button.gameObject);
}
generatedButtons.Clear();
}
}
/// <summary>
/// SelectionBox 的单个运行时选项。它不序列化到场景或存档;显示时由调用方临时创建。
/// </summary>
public readonly struct SelectionBoxOption
{
public readonly string label;
public readonly UnityAction action;
public SelectionBoxOption(string label, UnityAction action)
{
this.label = label;
this.action = action;
}
}
}