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