using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.InputSystem; using Sirenix.OdinInspector; namespace SLSUtilities.Narrative.UI { /// /// 选项悬停提示面板管理器。 /// - 鼠标模式:仅在鼠标悬停于选项"文本区域"(非空白行区域)时显示,跟随鼠标移动。 /// - 键盘模式:在选项文本的右上角处固定显示。 /// - 支持右键固定和点击外部关闭。 /// public class OptionTooltipUI : MonoBehaviour { public static OptionTooltipUI Instance { get; private set; } [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("渲染 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); [BoxGroup("行为设置 (Behavior Settings)/定位")] [Tooltip("鼠标检测的边缘容差像素数。\n较大值可避免中文全角标点符号边缘闪烁,较小值则更精确地限制在文字内。")] [Range(0f, 20f)] [SerializeField] private float textBoundsTolerance = 6f; private TooltipPanel _hoverPanel; private AdvancedOptionItem _hoverItem; // 当前悬停是否由鼠标触发(false = 键盘触发) private bool _isMouseSelection; // 已固定的选项 Tooltip private readonly List _pinnedPanels = new List(); private void Awake() { Instance = this; } private void OnDisable() { CloseHoverPanel(); CloseAllPinnedPanels(); } private void Update() { HandleHoverPanelVisibility(); HandleClickInput(); } // --------------------------------------------------------------- // 公开接口(由 AdvancedOptionItem 调用) // --------------------------------------------------------------- public void OnOptionSelected(AdvancedOptionItem item, bool isMouseTriggered) { _hoverItem = item; _isMouseSelection = isMouseTriggered; string textToShow = item.Option.IsAvailable ? item.TooltipDesc : item.TooltipFail; if (string.IsNullOrWhiteSpace(textToShow)) { CloseHoverPanel(); return; } if (HasPinnedPanelForOption(textToShow)) { CloseHoverPanel(); return; } CloseHoverPanel(); _hoverPanel = SpawnPanel(textToShow); if (_isMouseSelection) { // 鼠标模式:初始位置对齐鼠标,后续每帧跟随 Vector2 mousePos = Mouse.current.position.ReadValue(); _hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset); } else { // 键盘模式:定位在文本右上角处 PositionPanelAtTextTopRight(_hoverPanel, item.GetTextComponent()); } } public void OnOptionDeselected(AdvancedOptionItem item) { if (_hoverItem == item) CloseHoverPanel(); } // --------------------------------------------------------------- // 每帧更新(悬停面板可见性与定位) // --------------------------------------------------------------- private void HandleHoverPanelVisibility() { if (_hoverPanel == null) return; // 当选项文本中出现了关键词且玩家正在选中关键词时,隐去未固定的选项 Tooltip bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel; if (isHoveringKeyword) { _hoverPanel.gameObject.SetActive(false); return; } if (_isMouseSelection) { Vector2 mousePos = Mouse.current.position.ReadValue(); // 使用 textBounds 检测文本渲染边界,避免全角标点符号字形间隙造成闪烁 var textComp = _hoverItem?.GetTextComponent(); bool mouseOverText = IsMouseOverTextArea(textComp, mousePos); if (mouseOverText) { _hoverPanel.gameObject.SetActive(true); // 每帧跟随鼠标 _hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset); } else { _hoverPanel.gameObject.SetActive(false); } } else { // 键盘模式:始终显示,位置固定在文本右上角(无需每帧更新) _hoverPanel.gameObject.SetActive(true); } } // --------------------------------------------------------------- // 点击输入处理 // --------------------------------------------------------------- private void HandleClickInput() { bool leftClick = Mouse.current.leftButton.wasPressedThisFrame; bool rightClick = Mouse.current.rightButton.wasPressedThisFrame; if (!leftClick && !rightClick) return; Vector2 mousePos = Mouse.current.position.ReadValue(); // 右键固定:当 hover panel 可见时,右键单击在选项区域内将其固定 if (rightClick && _hoverPanel != null && _hoverPanel.gameObject.activeSelf) { bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel; if (!isHoveringKeyword) { // 使用与悬停检测相同的 textBounds 方式 var textComp = _hoverItem?.GetTextComponent(); bool clickOverText = IsMouseOverTextArea(textComp, mousePos); bool clickOverPanel = _hoverPanel.ContainsScreenPoint(mousePos, uiCamera); if (clickOverText || clickOverPanel) { PinHoverPanel(); return; } } } // 点击外部关闭所有已固定的选项 Tooltip if (_pinnedPanels.Count > 0) { bool clickedInside = false; foreach (var panel in _pinnedPanels) { if (panel.ContainsScreenPoint(mousePos, uiCamera)) { clickedInside = true; break; } } if (!clickedInside && _hoverPanel != null && _hoverPanel.ContainsScreenPoint(mousePos, uiCamera)) clickedInside = true; if (!clickedInside) CloseAllPinnedPanels(); } } // --------------------------------------------------------------- // 面板生命周期 // --------------------------------------------------------------- private void PinHoverPanel() { if (_hoverPanel == null) return; _hoverPanel.Pin(); _pinnedPanels.Add(_hoverPanel); _hoverPanel = null; } private TooltipPanel SpawnPanel(string description) { if (tooltipPanelPrefab == null || tooltipContainer == null) return null; var panelGO = Instantiate(tooltipPanelPrefab, tooltipContainer); var panel = panelGO.GetComponent(); // 创建临时 KeywordData,内容为选项说明(无标题) var data = ScriptableObject.CreateInstance(); data.keyword = string.Empty; // 选项说明中也支持嵌套关键词:由 Initialize 内的 ProcessDescription 自动处理 data.description = description; panel.Initialize(data, false); Destroy(data); return panel; } // --------------------------------------------------------------- // 定位工具 // --------------------------------------------------------------- /// /// 将面板定位在 TMP 文本组件的右上角处(用于键盘模式)。 /// private void PositionPanelAtTextTopRight(TooltipPanel panel, TMP_Text textComp) { if (panel == null || textComp == null) return; Vector3[] corners = new Vector3[4]; textComp.rectTransform.GetWorldCorners(corners); // corners 顺序:0=BL, 1=TL, 2=TR, 3=BR(屏幕坐标,Overlay模式) // 对于非 Overlay 模式,使用 WorldToScreenPoint 转换 Vector2 screenPos = uiCamera != null ? RectTransformUtility.WorldToScreenPoint(uiCamera, corners[2]) : new Vector2(corners[2].x, corners[2].y); panel.PositionAtScreenPoint(screenPos, tooltipOffset); } // --------------------------------------------------------------- // 文本区域检测工具 // --------------------------------------------------------------- /// /// 检测鼠标屏幕坐标是否处于 TMP 文本的实际渲染边界矩形内。 /// 使用 textBounds(字形渲染包围盒)而非 FindIntersectingCharacter, /// 可避免中文全角标点符号字形内空白区域导致的闪烁问题。 /// private bool IsMouseOverTextArea(TMP_Text textComp, Vector2 screenMousePos) { if (textComp == null) return false; // 确保 TMPro 网格在当下完成同步刷新,以获得 100% 准确的渲染包围盒,彻底阻断首帧零包围盒渲染计算闪烁 textComp.ForceMeshUpdate(); // 将屏幕坐标转换为 TMP RectTransform 的局部坐标 if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( textComp.rectTransform, screenMousePos, uiCamera, out Vector2 localPoint)) return false; // textBounds 是 TMP 实际渲染内容的包围盒(局部坐标), // 比 RectTransform 本身更精确,且不受字符个体差异影响 Bounds bounds = textComp.textBounds; // 加入可配置容差,避免全角标点字形边缘闪烁 return localPoint.x >= bounds.min.x - textBoundsTolerance && localPoint.x <= bounds.max.x + textBoundsTolerance && localPoint.y >= bounds.min.y - textBoundsTolerance && localPoint.y <= bounds.max.y + textBoundsTolerance; } // --------------------------------------------------------------- // 查询工具 // --------------------------------------------------------------- private bool HasPinnedPanelForOption(string description) { // 简单比较原始描述文本(未处理),避免二次处理比较问题 foreach (var panel in _pinnedPanels) { if (panel != null && panel.DescriptionText != null && panel.DescriptionText.text.Contains(description.Substring(0, Mathf.Min(description.Length, 10)))) { return true; } } return false; } private void CloseHoverPanel() { if (_hoverPanel != null && _hoverPanel.gameObject != null) Destroy(_hoverPanel.gameObject); _hoverPanel = null; } private void CloseAllPinnedPanels() { foreach (var panel in _pinnedPanels) { if (panel != null && panel.gameObject != null) Destroy(panel.gameObject); } _pinnedPanels.Clear(); } } }