337 lines
12 KiB
C#
337 lines
12 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using Sirenix.OdinInspector;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using UnityEngine.InputSystem;
|
||
|
||
namespace SLSUtilities.Narrative.UI
|
||
{
|
||
/// <summary>
|
||
/// 关键词浮动窗口管理器。
|
||
/// 检测鼠标在 TMP 文本上悬停的 link 标签,弹出关键词解释窗口。
|
||
/// 支持嵌套窗口、右键固定、点击外部关闭,以及左键关闭时阻断台词推进。
|
||
///
|
||
/// 面板的实际内容显示、定位和固定状态由 <see cref="TooltipPanel"/> 组件管理,
|
||
/// 本类仅负责悬停检测、生命周期编排和输入分发。
|
||
/// </summary>
|
||
public class KeywordTooltipUI : MonoBehaviour
|
||
{
|
||
// ---------------------------------------------------------------
|
||
// 静态属性:供 LineAdvancer 查询是否需要阻断本帧输入
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 当任意 Tooltip 窗口处于打开状态时为 true。
|
||
/// LineAdvancer 应在处理台词推进前检查此值。
|
||
/// </summary>
|
||
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<TooltipPanel> _openPanels = new List<TooltipPanel>();
|
||
|
||
// 当前唯一的悬停 Tooltip(未固定,跟随鼠标)
|
||
private TooltipPanel _hoverPanel;
|
||
|
||
// 上一帧检测到的悬停关键词
|
||
private string _lastHoveredKeyword;
|
||
|
||
// 供外部(如 OptionTooltipUI)注册的额外检测文本
|
||
private readonly List<TMP_Text> _externalTexts = new List<TMP_Text>();
|
||
|
||
/// <summary>
|
||
/// 当前是否有未固定的悬停面板
|
||
/// </summary>
|
||
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<TooltipPanel>();
|
||
|
||
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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 查询方法
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 检查指定关键词是否已有固定的面板存在。
|
||
/// 用于避免为同一个关键词生成重复的 Hover 面板。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
} |