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();
}
}
}