Files
ichni_Official/Assets/Scripts/Menu/MessagePage/MessageUIPage.cs

428 lines
16 KiB
C#
Raw Normal View History

2026-07-07 01:28:27 -04:00
using System.Collections.Generic;
2026-07-25 13:27:53 -04:00
using System.Threading.Tasks;
using Ichni.Localization;
2026-07-07 01:28:27 -04:00
using Ichni.Menu.UI;
using Ichni.UI;
2026-07-19 16:44:58 -04:00
using Sirenix.OdinInspector;
2026-07-07 01:28:27 -04:00
using UnityEngine;
2026-07-25 13:27:53 -04:00
using UnityEngine.Events;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
2026-07-07 01:28:27 -04:00
namespace Ichni.Story.UI
{
2026-07-25 13:27:53 -04:00
/// <summary>
/// 运行时弹窗的一段本地化文本请求。
/// <para>请求进入队列时不会立即翻译;<see cref="MessageUIPage"/> 即将显示该弹窗时才读取当前 Locale
/// 因而不会因异步加载导致 FIFO 顺序错乱,也不会把语言逻辑散落到各业务调用方。</para>
/// <para>动态参数统一使用 Smart String 的位置参数,例如 <c>{0}</c>、<c>{1}</c>。</para>
/// </summary>
public readonly struct LocalizedPopupText
{
public readonly TableReference tableReference;
public readonly string entryKey;
public readonly object[] arguments;
public LocalizedPopupText(TableReference tableReference, string entryKey, params object[] arguments)
{
this.tableReference = tableReference;
this.entryKey = entryKey;
this.arguments = arguments;
}
/// <summary>异步解析当前 Locale 的文本;缺失时显示 Key便于开发阶段定位错误配置。</summary>
public Task<string> ResolveAsync() =>
LocalizationTextService.ResolveAsync(tableReference, entryKey, entryKey, arguments);
}
/// <summary>
/// SelectionBox 的本地化按钮定义。每个按钮可以独立指定 String Table
/// 因此业务专用操作与 UI 通用操作(如“取消”)可复用各自的词条。
/// </summary>
public readonly struct LocalizedSelectionBoxOption
{
public readonly LocalizedPopupText label;
public readonly UnityAction action;
public LocalizedSelectionBoxOption(LocalizedPopupText label, UnityAction action)
{
this.label = label;
this.action = action;
}
}
2026-07-07 01:28:27 -04:00
/// <summary>
2026-07-19 16:44:58 -04:00
/// 剧情与菜单共用的弹窗页面。
/// MessageBox 与 SelectionBox 共用同一遮罩、容器和显示队列:两者不会重叠,
/// 后续系统只需向本页提交“消息”或“选项”请求,无需再创建平行的 UIPage。
2026-07-07 01:28:27 -04:00
/// </summary>
public class MessageUIPage : UIPageBase
2026-07-07 01:28:27 -04:00
{
public static MessageUIPage instance;
2026-07-07 01:28:27 -04:00
[Header("Prefabs & Containers")]
[Tooltip("默认使用的 MessageBox 预制体(请从 Project 中拖拽,而非场景中的实例)")]
public MessageBox defaultMessageBoxPrefab;
[Tooltip("生成的 MessageBox 挂载在哪?如果不填,默认挂载在自己身上")]
public Transform messageContainer;
2026-07-19 16:44:58 -04:00
[Tooltip("通用多选弹窗预制体。TutorialBlock、奖励选择等系统都通过本页动态生成它。")]
public SelectionBox defaultSelectionBoxPrefab;
private enum PopupType
2026-07-07 01:28:27 -04:00
{
2026-07-19 16:44:58 -04:00
Message,
Selection
}
// 用于统一排队的内部弹窗数据结构。
private struct PendingPopup
{
public PopupType type;
2026-07-07 01:28:27 -04:00
public string title;
public string content;
2026-07-19 16:44:58 -04:00
public MessageBox messagePrefab;
public List<SelectionBoxOption> selectionOptions;
2026-07-25 13:27:53 -04:00
public bool useLocalizedText;
public LocalizedPopupText localizedTitle;
public LocalizedPopupText localizedContent;
public List<LocalizedSelectionBoxOption> localizedSelectionOptions;
2026-07-07 01:28:27 -04:00
}
2026-07-19 16:44:58 -04:00
private readonly Queue<PendingPopup> _popupQueue = new();
private MessageBox _currentMessageBox;
private SelectionBox _currentSelectionBox;
2026-07-07 01:28:27 -04:00
private bool _isPageActive = false;
protected override void Awake()
{
base.Awake();
if (instance == null)
{
instance = this;
}
else
{
Debug.LogWarning("[StoryMessageBoxUIPage] 场景中存在多个实例,正在销毁多余的实例。");
Destroy(gameObject);
return;
}
if (messageContainer == null)
{
messageContainer = this.transform;
}
}
/// <summary>
/// 将歌曲解锁提示加入弹窗队列。
2026-07-25 13:27:53 -04:00
/// <param name="songDisplayName">玩家可见的歌曲名称;调用方不得传入内部 Unlock Key。</param>
2026-07-07 01:28:27 -04:00
/// </summary>
2026-07-25 13:27:53 -04:00
public void ShowUnlockMessage(string songDisplayName)
2026-07-07 01:28:27 -04:00
{
2026-07-25 13:27:53 -04:00
ShowLocalizedMessage(
new LocalizedPopupText("Message", "system_unlock_song_title"),
new LocalizedPopupText("Message", "system_unlock_song_content", songDisplayName));
2026-07-07 01:28:27 -04:00
}
/// <summary>
/// 将自定义文本提示加入弹窗队列。
/// </summary>
public void ShowCustomMessage(string title, string content)
{
EnqueueMessage(title, content, defaultMessageBoxPrefab);
}
2026-07-25 13:27:53 -04:00
/// <summary>
/// 将本地化 MessageBox 加入队列。文本会在该弹窗真正显示前才按当前 Locale 解析;
/// 对于含动态参数的词条,请使用 <see cref="LocalizedPopupText"/> 的位置参数构造函数。
/// </summary>
public void ShowLocalizedMessage(LocalizedPopupText title, LocalizedPopupText content,
MessageBox prefab = null)
{
MessageBox targetPrefab = prefab ?? defaultMessageBoxPrefab;
if (targetPrefab == null)
{
Debug.LogError("[MessageUIPage] 没有可用的 MessageBox Prefab无法显示本地化消息。");
return;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Message,
messagePrefab = targetPrefab,
useLocalizedText = true,
localizedTitle = title,
localizedContent = content
});
BeginQueueIfNeeded();
}
2026-07-19 16:44:58 -04:00
/// <summary>
/// Inspector 专用的运行时调试入口。
/// 通过正常队列创建一条基础测试文本,因此可以同时验证新 Prefab 的布局、
/// 入退场动画、输入拦截与队列收尾,而不会绕过正式流程或污染剧情数据。
/// </summary>
[Button("调试:创建空 MessageBox", ButtonSizes.Medium)]
private void DebugCreateEmptyMessageBox()
{
if (!Application.isPlaying)
{
Debug.LogWarning("[MessageUIPage] 空 MessageBox 调试按钮只能在 Play Mode 中使用。", this);
return;
}
ShowCustomMessage(
"MessageBox Debug",
"这是一条用于检查基础布局、文本换行与 Rail 动效的测试文本。\n\n" +
"This is a basic MessageBox test message.");
}
/// <summary>
/// Inspector 专用的运行时调试入口。
/// 通过正常队列创建一条基础测试文本,因此可以同时验证新 Prefab 的布局、
/// 入退场动画、输入拦截与队列收尾,而不会绕过正式流程或污染剧情数据。
/// </summary>
[Button("调试:创建空 SelectionBox", ButtonSizes.Medium)]
private void DebugCreateEmptySelectionBox()
{
if (!Application.isPlaying)
{
Debug.LogWarning("[MessageUIPage] 空 SelectionBox 调试按钮只能在 Play Mode 中使用。", this);
return;
}
ShowSelectionBox(
"SelectionBox Debug",
"这是一条用于检查基础布局、文本换行与 Rail 动效的测试文本。\n\n" +
"This is a basic SelectionBox test message.",
new List<SelectionBoxOption>
{
new SelectionBoxOption("Option 1", () => Debug.Log("Option 1 selected")),
new SelectionBoxOption("Option 2", () => Debug.Log("Option 2 selected")),
new SelectionBoxOption("Option 3", () => Debug.Log("Option 3 selected"))
});
}
/// <summary>
/// 将一组运行时选项加入通用弹窗队列。
/// 若 MessageBox 或其它 SelectionBox 正在显示,选项会按提交顺序等待,而不会叠在同一遮罩上。
/// </summary>
public bool ShowSelectionBox(string title, string content, IReadOnlyList<SelectionBoxOption> options)
{
if (defaultSelectionBoxPrefab == null)
{
Debug.LogError("[MessageUIPage] 未配置 defaultSelectionBoxPrefab无法显示选项框。");
return false;
}
if (options == null || options.Count == 0)
{
Debug.LogWarning("[MessageUIPage] 未提供任何 SelectionBoxOption忽略空选项框。");
return false;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Selection,
title = title,
content = content,
// 复制调用方传入的列表,防止调用方随后改动集合而改变已排队的玩家选择。
selectionOptions = new List<SelectionBoxOption>(options)
});
BeginQueueIfNeeded();
return true;
}
2026-07-25 13:27:53 -04:00
/// <summary>
/// 将本地化 SelectionBox 加入队列。每个选项的标签单独保存本地化引用,
/// 因此“进入教程”“跳过教程”等业务按钮不会被误替换为通用的“确定/取消”。
/// </summary>
public bool ShowLocalizedSelectionBox(
LocalizedPopupText title,
LocalizedPopupText content,
IReadOnlyList<LocalizedSelectionBoxOption> options)
{
if (defaultSelectionBoxPrefab == null)
{
Debug.LogError("[MessageUIPage] 未配置 defaultSelectionBoxPrefab无法显示本地化选项框。");
return false;
}
if (options == null || options.Count == 0)
{
Debug.LogWarning("[MessageUIPage] 未提供任何本地化 SelectionBoxOption忽略空选项框。");
return false;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Selection,
useLocalizedText = true,
localizedTitle = title,
localizedContent = content,
localizedSelectionOptions = new List<LocalizedSelectionBoxOption>(options)
});
BeginQueueIfNeeded();
return true;
}
2026-07-07 01:28:27 -04:00
/// <summary>
/// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。
/// </summary>
private void EnqueueMessage(string title, string content, MessageBox prefab)
{
if (prefab == null)
{
Debug.LogError("[StoryMessageBoxUIPage] 没有可用的 MessageBox 预制体!");
return;
}
2026-07-19 16:44:58 -04:00
_popupQueue.Enqueue(new PendingPopup
2026-07-07 01:28:27 -04:00
{
2026-07-19 16:44:58 -04:00
type = PopupType.Message,
2026-07-07 01:28:27 -04:00
title = title,
content = content,
2026-07-19 16:44:58 -04:00
messagePrefab = prefab
2026-07-07 01:28:27 -04:00
});
2026-07-19 16:44:58 -04:00
BeginQueueIfNeeded();
}
/// <summary>
/// 若当前没有弹窗,激活共享遮罩后开始依次显示队列。遮罩从出现瞬间拦截输入,
/// 避免 UIPageBase 默认淡入结束前的短暂底层点击窗口。
/// </summary>
private void BeginQueueIfNeeded()
{
if (_isPageActive) return;
_isPageActive = true;
mainCanvasGroup.gameObject.SetActive(true);
mainCanvasGroup.interactable = true;
mainCanvasGroup.blocksRaycasts = true;
FadeIn(0f, false, ShowNextInQueue);
2026-07-07 01:28:27 -04:00
}
/// <summary>
/// 从队列中取出一个并实例化显示。
/// </summary>
2026-07-25 13:27:53 -04:00
private async void ShowNextInQueue()
2026-07-07 01:28:27 -04:00
{
2026-07-19 16:44:58 -04:00
if (_popupQueue.Count == 0)
2026-07-07 01:28:27 -04:00
{
2026-07-19 16:44:58 -04:00
// 队列处理完毕,大页面整体退场。
_currentMessageBox = null;
_currentSelectionBox = null;
2026-07-07 01:28:27 -04:00
_isPageActive = false;
2026-07-19 16:44:58 -04:00
FadeOut(0f);
2026-07-07 01:28:27 -04:00
return;
}
2026-07-19 16:44:58 -04:00
PendingPopup popup = _popupQueue.Dequeue();
2026-07-25 13:27:53 -04:00
if (popup.useLocalizedText)
{
popup = await ResolveLocalizedPopupAsync(popup);
}
2026-07-19 16:44:58 -04:00
if (popup.type == PopupType.Selection)
{
ShowSelectionPopup(popup);
return;
}
2026-07-07 01:28:27 -04:00
2026-07-19 16:44:58 -04:00
ShowMessagePopup(popup);
}
2026-07-07 01:28:27 -04:00
2026-07-25 13:27:53 -04:00
/// <summary>
/// 按 Popup 自己的请求依次解析标题、正文与按钮。该方法只由队列消费端调用,
/// 所以即使 String Table 仍在异步加载,也不会打乱此前入队的弹窗顺序。
/// </summary>
private static async Task<PendingPopup> ResolveLocalizedPopupAsync(PendingPopup popup)
{
popup.title = await popup.localizedTitle.ResolveAsync();
popup.content = await popup.localizedContent.ResolveAsync();
if (popup.type != PopupType.Selection || popup.localizedSelectionOptions == null)
{
return popup;
}
popup.selectionOptions = new List<SelectionBoxOption>(popup.localizedSelectionOptions.Count);
foreach (LocalizedSelectionBoxOption localizedOption in popup.localizedSelectionOptions)
{
string label = await localizedOption.label.ResolveAsync();
popup.selectionOptions.Add(new SelectionBoxOption(label, localizedOption.action));
}
return popup;
}
2026-07-19 16:44:58 -04:00
private void ShowMessagePopup(PendingPopup popup)
{
if (popup.messagePrefab == null)
2026-07-07 01:28:27 -04:00
{
2026-07-19 16:44:58 -04:00
Debug.LogError("[MessageUIPage] 队列中的 MessageBox Prefab 已丢失,跳过该消息。");
2026-07-07 01:28:27 -04:00
ShowNextInQueue();
2026-07-19 16:44:58 -04:00
return;
}
2026-07-07 01:28:27 -04:00
2026-07-19 16:44:58 -04:00
MessageBox messageBox = Instantiate(popup.messagePrefab, messageContainer);
_currentMessageBox = messageBox;
// MessageBox 只显示一条消息;队列顺序始终由本页统一管理。
messageBox.Closed += HandleMessageBoxClosed;
messageBox.gameObject.SetActive(true);
messageBox.SetUp(popup.title, popup.content);
}
private void HandleMessageBoxClosed(MessageBox messageBox)
{
messageBox.Closed -= HandleMessageBoxClosed;
Destroy(messageBox.gameObject);
if (_currentMessageBox == messageBox)
_currentMessageBox = null;
ShowNextInQueue();
}
private void ShowSelectionPopup(PendingPopup popup)
{
if (defaultSelectionBoxPrefab == null)
{
Debug.LogError("[MessageUIPage] 队列中的 SelectionBox Prefab 已丢失,跳过该选项框。");
ShowNextInQueue();
return;
}
SelectionBox selectionBox = Instantiate(defaultSelectionBoxPrefab, messageContainer);
_currentSelectionBox = selectionBox;
selectionBox.Closed += HandleSelectionBoxClosed;
if (!selectionBox.SetUp(popup.title, popup.content, popup.selectionOptions))
{
selectionBox.Closed -= HandleSelectionBoxClosed;
Destroy(selectionBox.gameObject);
_currentSelectionBox = null;
ShowNextInQueue();
}
}
private void HandleSelectionBoxClosed(SelectionBox selectionBox)
{
if (selectionBox != _currentSelectionBox) return;
selectionBox.Closed -= HandleSelectionBoxClosed;
Destroy(selectionBox.gameObject);
_currentSelectionBox = null;
ShowNextInQueue();
2026-07-07 01:28:27 -04:00
}
}
}