using System.Collections; using System.Collections.Generic; using Sirenix.OdinInspector; using TMPro; using UnityEngine; using UnityEngine.InputSystem; namespace SLSUtilities.Narrative.UI { /// /// 关键词浮动窗口管理器。 /// 检测鼠标在 TMP 文本上悬停的 link 标签,弹出关键词解释窗口。 /// 支持嵌套窗口、右键固定、点击外部关闭,以及左键关闭时阻断台词推进。 /// /// 面板的实际内容显示、定位和固定状态由 组件管理, /// 本类仅负责悬停检测、生命周期编排和输入分发。 /// public class KeywordTooltipUI : MonoBehaviour { // --------------------------------------------------------------- // 静态属性:供 LineAdvancer 查询是否需要阻断本帧输入 // --------------------------------------------------------------- /// /// 当任意 Tooltip 窗口处于打开状态时为 true。 /// LineAdvancer 应在处理台词推进前检查此值。 /// public static bool IsBlockingDialogueInput { get; private set; } public static KeywordTooltipUI Instance { get; private set; } // --------------------------------------------------------------- // Inspector 配置 // --------------------------------------------------------------- [TitleGroup("核心引用 (Core References)", Alignment = TitleAlignments.Centered)] [BoxGroup("核心引用 (Core References)/UI")] [Required("需要指定 Tooltip 面板的 Prefab(必须挂载 TooltipPanel 组件)")] [SerializeField] private GameObject tooltipPanelPrefab; [BoxGroup("核心引用 (Core References)/UI")] [Required("Tooltip 生成的父级容器(RectTransform)")] [SerializeField] private RectTransform tooltipContainer; [BoxGroup("核心引用 (Core References)/UI")] [Tooltip("主台词文本组件,用于检测鼠标悬停的关键词链接")] [SerializeField] private TMP_Text mainLineText; [BoxGroup("核心引用 (Core References)/UI")] [Tooltip("渲染 Canvas 的摄像机。Screen Space Overlay 模式下留空")] [SerializeField] private Camera uiCamera; [TitleGroup("行为设置 (Behavior Settings)", Alignment = TitleAlignments.Centered)] [BoxGroup("行为设置 (Behavior Settings)/定位")] [Tooltip("Tooltip 左下角相对于鼠标的屏幕像素偏移量")] [SerializeField] private Vector2 tooltipOffset = new Vector2(12f, 12f); // --------------------------------------------------------------- // 内部状态 // --------------------------------------------------------------- // 所有当前打开的 Tooltip 面板(包含固定的和悬停的) private readonly List _openPanels = new List(); // 当前唯一的悬停 Tooltip(未固定,跟随鼠标) private TooltipPanel _hoverPanel; // 上一帧检测到的悬停关键词 private string _lastHoveredKeyword; // 供外部(如 OptionTooltipUI)注册的额外检测文本 private readonly List _externalTexts = new List(); /// /// 当前是否有未固定的悬停面板 /// public bool HasHoverPanel => _hoverPanel != null; // --------------------------------------------------------------- // Unity 生命周期 // --------------------------------------------------------------- private void Awake() { Instance = this; } private void OnDisable() { CloseAllTooltips(); } private void Update() { HandleHoverDetection(); HandleClickInput(); } // --------------------------------------------------------------- // 悬停检测 // --------------------------------------------------------------- private void HandleHoverDetection() { Vector2 mousePos = Mouse.current.position.ReadValue(); // 检测当前鼠标命中的关键词链接 string hoveredKeyword = DetectHoveredKeyword(mousePos); // 如果鼠标没命中链接,但在当前 Hover 面板内,保持悬停状态不变 bool mouseInsideHoverPanel = _hoverPanel != null && _hoverPanel.ContainsScreenPoint(mousePos, uiCamera); if (mouseInsideHoverPanel && hoveredKeyword == null) { // 鼠标从链接移到了 Tooltip 面板 → 保持显示,不移动位置 return; } // 悬停目标变化 → 刷新 Hover Tooltip if (hoveredKeyword != _lastHoveredKeyword) { _lastHoveredKeyword = hoveredKeyword; CloseHoverTooltip(); if (!string.IsNullOrEmpty(hoveredKeyword)) { // 如果该关键词已经有固定窗口存在 → 不创建新的 Hover if (!HasPinnedPanelForKeyword(hoveredKeyword)) { var kwData = KeywordProcessor.FindByPrimaryKeyword(hoveredKeyword); if (kwData != null) _hoverPanel = SpawnPanel(kwData, mousePos, pinned: false); } } } // 跟随鼠标更新位置(仅对 Hover 面板) if (_hoverPanel != null && !_hoverPanel.IsPinned) _hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset); } private string DetectHoveredKeyword(Vector2 mousePos) { // 先检测主台词文本 string kw = DetectLinkAt(mainLineText, mousePos); if (kw != null) return kw; // 再检测所有已打开面板内的描述文本(支持嵌套) foreach (var panel in _openPanels) { kw = DetectLinkAt(panel.DescriptionText, mousePos); if (kw != null) return kw; } // 最后检测外部注册的文本(如选项文本) foreach (var extText in _externalTexts) { kw = DetectLinkAt(extText, mousePos); if (kw != null) return kw; } return null; } // --------------------------------------------------------------- // 点击输入处理 // --------------------------------------------------------------- private void HandleClickInput() { bool leftClick = Mouse.current.leftButton.wasPressedThisFrame; bool rightClick = Mouse.current.rightButton.wasPressedThisFrame; if (!leftClick && !rightClick) return; if (_openPanels.Count == 0) return; Vector2 mousePos = Mouse.current.position.ReadValue(); // 检测是否右键点击在关键词链接上 → 固定 Hover 面板 if (rightClick) { string clickedKeyword = DetectHoveredKeyword(mousePos); if (!string.IsNullOrEmpty(clickedKeyword) && _hoverPanel != null && _hoverPanel.Keyword == clickedKeyword) { PinHoverPanel(); return; } } // 检测是否点击在任意 Tooltip 面板内部 → 如果是则不处理 if (IsMouseInsideAnyPanel(mousePos)) return; // 点击在所有 Tooltip 外部 → 关闭所有 Tooltip CloseAllTooltips(); // 左键关闭时,本帧阻断台词推进(下一帧自动解除) if (leftClick) { IsBlockingDialogueInput = true; StartCoroutine(UnblockNextFrame()); } } // --------------------------------------------------------------- // 面板生命周期 // --------------------------------------------------------------- private TooltipPanel SpawnPanel(KeywordData data, Vector2 screenPos, bool pinned) { if (tooltipPanelPrefab == null || tooltipContainer == null) return null; var panelGO = Instantiate(tooltipPanelPrefab, tooltipContainer); var panel = panelGO.GetComponent(); if (panel == null) { Debug.LogError( $"[KeywordTooltipUI] Tooltip Prefab 上缺少 TooltipPanel 组件!" + $"请确保 Prefab '{tooltipPanelPrefab.name}' 挂载了 TooltipPanel 脚本。", tooltipPanelPrefab); Destroy(panelGO); return null; } panel.Initialize(data, pinned); panel.PositionAtScreenPoint(screenPos, tooltipOffset); _openPanels.Add(panel); IsBlockingDialogueInput = true; return panel; } private void PinHoverPanel() { if (_hoverPanel == null) return; _hoverPanel.Pin(); _hoverPanel = null; _lastHoveredKeyword = null; } private void CloseHoverTooltip() { if (_hoverPanel == null) return; _openPanels.Remove(_hoverPanel); if (_hoverPanel.gameObject != null) Destroy(_hoverPanel.gameObject); _hoverPanel = null; if (_openPanels.Count == 0) IsBlockingDialogueInput = false; } private void CloseAllTooltips() { foreach (var panel in _openPanels) { if (panel != null && panel.gameObject != null) Destroy(panel.gameObject); } _openPanels.Clear(); _hoverPanel = null; _lastHoveredKeyword = null; IsBlockingDialogueInput = false; } // --------------------------------------------------------------- // 查询方法 // --------------------------------------------------------------- /// /// 检查指定关键词是否已有固定的面板存在。 /// 用于避免为同一个关键词生成重复的 Hover 面板。 /// private bool HasPinnedPanelForKeyword(string keyword) { foreach (var panel in _openPanels) { if (panel.IsPinned && panel.Keyword == keyword) return true; } return false; } // --------------------------------------------------------------- // 工具方法 // --------------------------------------------------------------- private string DetectLinkAt(TMP_Text tmpText, Vector2 screenPos) { if (tmpText == null) return null; int linkIndex = TMP_TextUtilities.FindIntersectingLink(tmpText, screenPos, uiCamera); if (linkIndex < 0) return null; string linkId = tmpText.textInfo.linkInfo[linkIndex].GetLinkID(); return KeywordProcessor.ExtractKeywordFromLinkId(linkId); } private bool IsMouseInsideAnyPanel(Vector2 screenPos) { foreach (var panel in _openPanels) { if (panel != null && panel.ContainsScreenPoint(screenPos, uiCamera)) return true; } return false; } private IEnumerator UnblockNextFrame() { yield return null; IsBlockingDialogueInput = false; } public void RegisterExternalText(TMP_Text text) { if (text != null && !_externalTexts.Contains(text)) _externalTexts.Add(text); } public void UnregisterExternalText(TMP_Text text) { if (text != null) _externalTexts.Remove(text); } } }