Files
Cielonos/Assets/Scripts/SLSUtilities/Narrative/UI/OptionTooltipUI.cs
SoulliesOfficial 8186f54e90 新场景,剧情
2026-06-02 12:55:39 -04:00

331 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}