Files
ichni_Official/Assets/Scripts/Menu/MessagePage/MessageUIPage.cs
SoulliesOfficial a34461d31f 阶段性完成
2026-07-25 13:27:53 -04:00

428 lines
16 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.Collections.Generic;
using System.Threading.Tasks;
using Ichni.Localization;
using Ichni.Menu.UI;
using Ichni.UI;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace Ichni.Story.UI
{
/// <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;
}
}
/// <summary>
/// 剧情与菜单共用的弹窗页面。
/// MessageBox 与 SelectionBox 共用同一遮罩、容器和显示队列:两者不会重叠,
/// 后续系统只需向本页提交“消息”或“选项”请求,无需再创建平行的 UIPage。
/// </summary>
public class MessageUIPage : UIPageBase
{
public static MessageUIPage instance;
[Header("Prefabs & Containers")]
[Tooltip("默认使用的 MessageBox 预制体(请从 Project 中拖拽,而非场景中的实例)")]
public MessageBox defaultMessageBoxPrefab;
[Tooltip("生成的 MessageBox 挂载在哪?如果不填,默认挂载在自己身上")]
public Transform messageContainer;
[Tooltip("通用多选弹窗预制体。TutorialBlock、奖励选择等系统都通过本页动态生成它。")]
public SelectionBox defaultSelectionBoxPrefab;
private enum PopupType
{
Message,
Selection
}
// 用于统一排队的内部弹窗数据结构。
private struct PendingPopup
{
public PopupType type;
public string title;
public string content;
public MessageBox messagePrefab;
public List<SelectionBoxOption> selectionOptions;
public bool useLocalizedText;
public LocalizedPopupText localizedTitle;
public LocalizedPopupText localizedContent;
public List<LocalizedSelectionBoxOption> localizedSelectionOptions;
}
private readonly Queue<PendingPopup> _popupQueue = new();
private MessageBox _currentMessageBox;
private SelectionBox _currentSelectionBox;
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>
/// 将歌曲解锁提示加入弹窗队列。
/// <param name="songDisplayName">玩家可见的歌曲名称;调用方不得传入内部 Unlock Key。</param>
/// </summary>
public void ShowUnlockMessage(string songDisplayName)
{
ShowLocalizedMessage(
new LocalizedPopupText("Message", "system_unlock_song_title"),
new LocalizedPopupText("Message", "system_unlock_song_content", songDisplayName));
}
/// <summary>
/// 将自定义文本提示加入弹窗队列。
/// </summary>
public void ShowCustomMessage(string title, string content)
{
EnqueueMessage(title, content, defaultMessageBoxPrefab);
}
/// <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();
}
/// <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;
}
/// <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;
}
/// <summary>
/// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。
/// </summary>
private void EnqueueMessage(string title, string content, MessageBox prefab)
{
if (prefab == null)
{
Debug.LogError("[StoryMessageBoxUIPage] 没有可用的 MessageBox 预制体!");
return;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Message,
title = title,
content = content,
messagePrefab = prefab
});
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);
}
/// <summary>
/// 从队列中取出一个并实例化显示。
/// </summary>
private async void ShowNextInQueue()
{
if (_popupQueue.Count == 0)
{
// 队列处理完毕,大页面整体退场。
_currentMessageBox = null;
_currentSelectionBox = null;
_isPageActive = false;
FadeOut(0f);
return;
}
PendingPopup popup = _popupQueue.Dequeue();
if (popup.useLocalizedText)
{
popup = await ResolveLocalizedPopupAsync(popup);
}
if (popup.type == PopupType.Selection)
{
ShowSelectionPopup(popup);
return;
}
ShowMessagePopup(popup);
}
/// <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;
}
private void ShowMessagePopup(PendingPopup popup)
{
if (popup.messagePrefab == null)
{
Debug.LogError("[MessageUIPage] 队列中的 MessageBox Prefab 已丢失,跳过该消息。");
ShowNextInQueue();
return;
}
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();
}
}
}