2026-07-18 16:51:18 -04:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
using DG.Tweening;
|
|
|
|
|
|
using TMPro;
|
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
using UnityEngine.Events;
|
2026-07-19 16:44:58 -04:00
|
|
|
|
using UnityEngine.UI;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
|
|
|
|
|
|
namespace Ichni.Menu.UI
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
2026-07-19 16:44:58 -04:00
|
|
|
|
/// 通用多选弹窗。
|
|
|
|
|
|
/// 标题、正文与按钮均在播放前完成最终布局;入退场只改变 Background、两处文本 Mask 和 Rail,
|
|
|
|
|
|
/// 因而不会在动画期间触发 TMP 重新换行或按钮布局抖动。
|
2026-07-18 16:51:18 -04:00
|
|
|
|
/// </summary>
|
2026-07-19 16:44:58 -04:00
|
|
|
|
public sealed class SelectionBox : MonoBehaviour
|
2026-07-18 16:51:18 -04:00
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
[Header("Base")]
|
|
|
|
|
|
[SerializeField] private CanvasGroup canvasGroup;
|
|
|
|
|
|
[SerializeField] private CanvasGroup optionCanvasGroup;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
|
2026-07-19 16:44:58 -04:00
|
|
|
|
[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;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-07-19 16:44:58 -04:00
|
|
|
|
/// 玩家选择任一选项、弹窗完成退场后触发。MessageUIPage 使用它继续共享 FIFO 队列。
|
2026-07-18 16:51:18 -04:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action<SelectionBox> Closed;
|
|
|
|
|
|
|
2026-07-19 16:44:58 -04:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-18 16:51:18 -04:00
|
|
|
|
private void Awake()
|
|
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
backgroundOpenSizeDelta = backgroundVisual.sizeDelta;
|
|
|
|
|
|
leftRailOpenPosition = leftRail.anchoredPosition;
|
|
|
|
|
|
rightRailOpenPosition = rightRail.anchoredPosition;
|
|
|
|
|
|
leftRailCanvasGroup = leftRail.GetComponent<CanvasGroup>();
|
|
|
|
|
|
rightRailCanvasGroup = rightRail.GetComponent<CanvasGroup>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnDestroy()
|
|
|
|
|
|
{
|
|
|
|
|
|
transitionSequence?.Kill();
|
|
|
|
|
|
ClearGeneratedButtons();
|
2026-07-18 16:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-07-19 16:44:58 -04:00
|
|
|
|
/// 创建本次选择所需的按钮并播放入场动画。空选项没有可执行的玩家选择,因此返回 false。
|
2026-07-18 16:51:18 -04:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public bool SetUp(string title, string content, IReadOnlyList<SelectionBoxOption> options)
|
|
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
if (options == null || options.Count == 0)
|
2026-07-18 16:51:18 -04:00
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
Debug.LogWarning("[SelectionBox] 未提供任何 SelectionBoxOption,忽略空选项框。", this);
|
2026-07-18 16:51:18 -04:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ClearGeneratedButtons();
|
2026-07-19 16:44:58 -04:00
|
|
|
|
isResolved = false;
|
|
|
|
|
|
titleText.text = title ?? string.Empty;
|
|
|
|
|
|
contentText.text = content ?? string.Empty;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
|
|
|
|
|
|
foreach (SelectionBoxOption option in options)
|
|
|
|
|
|
{
|
|
|
|
|
|
SelectionBoxButton button = Instantiate(optionButtonPrefab, optionContainer);
|
2026-07-19 16:44:58 -04:00
|
|
|
|
generatedButtons.Add(button);
|
2026-07-18 16:51:18 -04:00
|
|
|
|
button.SetUp(option.label, () => Select(option.action));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
gameObject.SetActive(true);
|
2026-07-19 16:44:58 -04:00
|
|
|
|
RefreshFinalLayout();
|
|
|
|
|
|
Open();
|
2026-07-18 16:51:18 -04:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void Select(UnityAction action)
|
|
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
if (isResolved) return;
|
|
|
|
|
|
isResolved = true;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
|
2026-07-19 16:44:58 -04:00
|
|
|
|
// 先阻断输入,再执行可能触发存档、跳转或新 UI 的业务回调。
|
2026-07-18 16:51:18 -04:00
|
|
|
|
canvasGroup.interactable = false;
|
|
|
|
|
|
canvasGroup.blocksRaycasts = false;
|
2026-07-19 16:44:58 -04:00
|
|
|
|
foreach (SelectionBoxButton button in generatedButtons)
|
2026-07-18 16:51:18 -04:00
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
button.button.interactable = false;
|
2026-07-18 16:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
action?.Invoke();
|
2026-07-19 16:44:58 -04:00
|
|
|
|
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();
|
2026-07-18 16:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ClearGeneratedButtons()
|
|
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
foreach (SelectionBoxButton button in generatedButtons)
|
2026-07-18 16:51:18 -04:00
|
|
|
|
{
|
2026-07-19 16:44:58 -04:00
|
|
|
|
Destroy(button.gameObject);
|
2026-07-18 16:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 16:44:58 -04:00
|
|
|
|
generatedButtons.Clear();
|
2026-07-18 16:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|