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

337 lines
12 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;
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);
}
}
}