331 lines
13 KiB
C#
331 lines
13 KiB
C#
using System.Collections.Generic;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using UnityEngine.InputSystem;
|
||
using Sirenix.OdinInspector;
|
||
|
||
namespace SLSUtilities.Narrative.UI
|
||
{
|
||
/// <summary>
|
||
/// 选项悬停提示面板管理器。
|
||
/// - 鼠标模式:仅在鼠标悬停于选项"文本区域"(非空白行区域)时显示,跟随鼠标移动。
|
||
/// - 键盘模式:在选项文本的右上角处固定显示。
|
||
/// - 支持右键固定和点击外部关闭。
|
||
/// </summary>
|
||
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<TooltipPanel> _pinnedPanels = new List<TooltipPanel>();
|
||
|
||
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<TooltipPanel>();
|
||
|
||
// 创建临时 KeywordData,内容为选项说明(无标题)
|
||
var data = ScriptableObject.CreateInstance<KeywordData>();
|
||
data.keyword = string.Empty;
|
||
// 选项说明中也支持嵌套关键词:由 Initialize 内的 ProcessDescription 自动处理
|
||
data.description = description;
|
||
|
||
panel.Initialize(data, false);
|
||
|
||
Destroy(data);
|
||
return panel;
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 定位工具
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 将面板定位在 TMP 文本组件的右上角处(用于键盘模式)。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 文本区域检测工具
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 检测鼠标屏幕坐标是否处于 TMP 文本的实际渲染边界矩形内。
|
||
/// 使用 textBounds(字形渲染包围盒)而非 FindIntersectingCharacter,
|
||
/// 可避免中文全角标点符号字形内空白区域导致的闪烁问题。
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|
||
}
|