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 { /// /// 运行时弹窗的一段本地化文本请求。 /// 请求进入队列时不会立即翻译; 即将显示该弹窗时才读取当前 Locale, /// 因而不会因异步加载导致 FIFO 顺序错乱,也不会把语言逻辑散落到各业务调用方。 /// 动态参数统一使用 Smart String 的位置参数,例如 {0}{1} /// 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; } /// 异步解析当前 Locale 的文本;缺失时显示 Key,便于开发阶段定位错误配置。 public Task ResolveAsync() => LocalizationTextService.ResolveAsync(tableReference, entryKey, entryKey, arguments); } /// /// SelectionBox 的本地化按钮定义。每个按钮可以独立指定 String Table, /// 因此业务专用操作与 UI 通用操作(如“取消”)可复用各自的词条。 /// public readonly struct LocalizedSelectionBoxOption { public readonly LocalizedPopupText label; public readonly UnityAction action; public LocalizedSelectionBoxOption(LocalizedPopupText label, UnityAction action) { this.label = label; this.action = action; } } /// /// 剧情与菜单共用的弹窗页面。 /// MessageBox 与 SelectionBox 共用同一遮罩、容器和显示队列:两者不会重叠, /// 后续系统只需向本页提交“消息”或“选项”请求,无需再创建平行的 UIPage。 /// 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 selectionOptions; public bool useLocalizedText; public LocalizedPopupText localizedTitle; public LocalizedPopupText localizedContent; public List localizedSelectionOptions; } private readonly Queue _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; } } /// /// 将歌曲解锁提示加入弹窗队列。 /// 玩家可见的歌曲名称;调用方不得传入内部 Unlock Key。 /// public void ShowUnlockMessage(string songDisplayName) { ShowLocalizedMessage( new LocalizedPopupText("Message", "system_unlock_song_title"), new LocalizedPopupText("Message", "system_unlock_song_content", songDisplayName)); } /// /// 将自定义文本提示加入弹窗队列。 /// public void ShowCustomMessage(string title, string content) { EnqueueMessage(title, content, defaultMessageBoxPrefab); } /// /// 将本地化 MessageBox 加入队列。文本会在该弹窗真正显示前才按当前 Locale 解析; /// 对于含动态参数的词条,请使用 的位置参数构造函数。 /// 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(); } /// /// Inspector 专用的运行时调试入口。 /// 通过正常队列创建一条基础测试文本,因此可以同时验证新 Prefab 的布局、 /// 入退场动画、输入拦截与队列收尾,而不会绕过正式流程或污染剧情数据。 /// [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."); } /// /// Inspector 专用的运行时调试入口。 /// 通过正常队列创建一条基础测试文本,因此可以同时验证新 Prefab 的布局、 /// 入退场动画、输入拦截与队列收尾,而不会绕过正式流程或污染剧情数据。 /// [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 { 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")) }); } /// /// 将一组运行时选项加入通用弹窗队列。 /// 若 MessageBox 或其它 SelectionBox 正在显示,选项会按提交顺序等待,而不会叠在同一遮罩上。 /// public bool ShowSelectionBox(string title, string content, IReadOnlyList 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(options) }); BeginQueueIfNeeded(); return true; } /// /// 将本地化 SelectionBox 加入队列。每个选项的标签单独保存本地化引用, /// 因此“进入教程”“跳过教程”等业务按钮不会被误替换为通用的“确定/取消”。 /// public bool ShowLocalizedSelectionBox( LocalizedPopupText title, LocalizedPopupText content, IReadOnlyList 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(options) }); BeginQueueIfNeeded(); return true; } /// /// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。 /// 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(); } /// /// 若当前没有弹窗,激活共享遮罩后开始依次显示队列。遮罩从出现瞬间拦截输入, /// 避免 UIPageBase 默认淡入结束前的短暂底层点击窗口。 /// private void BeginQueueIfNeeded() { if (_isPageActive) return; _isPageActive = true; mainCanvasGroup.gameObject.SetActive(true); mainCanvasGroup.interactable = true; mainCanvasGroup.blocksRaycasts = true; FadeIn(0f, false, ShowNextInQueue); } /// /// 从队列中取出一个并实例化显示。 /// 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); } /// /// 按 Popup 自己的请求依次解析标题、正文与按钮。该方法只由队列消费端调用, /// 所以即使 String Table 仍在异步加载,也不会打乱此前入队的弹窗顺序。 /// private static async Task 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(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(); } } }