270 lines
11 KiB
C#
270 lines
11 KiB
C#
using Sirenix.OdinInspector;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using UnityEngine.Serialization;
|
||
using UnityEngine.UI;
|
||
|
||
namespace SLSUtilities.Narrative.UI
|
||
{
|
||
/// <summary>
|
||
/// 关键词浮动面板组件。
|
||
/// 挂载在 Tooltip Prefab 根节点上,负责管理单个面板的内容显示、
|
||
/// 固定状态及屏幕定位。
|
||
/// </summary>
|
||
public class TooltipPanel : MonoBehaviour
|
||
{
|
||
// ---------------------------------------------------------------
|
||
// Inspector 配置(Prefab 内拖拽赋值)
|
||
// ---------------------------------------------------------------
|
||
|
||
[TitleGroup("面板引用 (Panel References)", Alignment = TitleAlignments.Centered)]
|
||
|
||
[BoxGroup("面板引用 (Panel References)/标题行")]
|
||
[LabelText("标题栏容器")]
|
||
[SerializeField] private RectTransform titleBarContainer;
|
||
|
||
[BoxGroup("面板引用 (Panel References)/标题行")]
|
||
[LabelText("标题文本")]
|
||
[SerializeField] private TMP_Text titleText;
|
||
|
||
[BoxGroup("面板引用 (Panel References)/标题行")]
|
||
[LabelText("关键词图标")]
|
||
[SerializeField] private Image iconImage;
|
||
|
||
[FormerlySerializedAs("pinIndicator")]
|
||
[BoxGroup("面板引用 (Panel References)/标题行")]
|
||
[LabelText("固定指示器")]
|
||
[SerializeField] private GameObject titlePin;
|
||
|
||
[BoxGroup("面板引用 (Panel References)/描述行")]
|
||
[LabelText("描述栏容器")]
|
||
[SerializeField] private RectTransform descriptionContainer;
|
||
|
||
[BoxGroup("面板引用 (Panel References)/描述行")]
|
||
[LabelText("描述文本")]
|
||
[SerializeField] private TMP_Text descriptionText;
|
||
|
||
[BoxGroup("面板引用 (Panel References)/描述行")]
|
||
[LabelText("固定指示器")]
|
||
[SerializeField] private GameObject descriptionPin;
|
||
|
||
// ---------------------------------------------------------------
|
||
// 公开属性
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 此面板对应的主关键词。
|
||
/// </summary>
|
||
public string Keyword { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 该面板是否已被固定(右键固定后不再跟随鼠标,且不会因移开鼠标而关闭)。
|
||
/// </summary>
|
||
public bool IsPinned { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 面板的 RectTransform 引用,供外部定位和碰撞检测。
|
||
/// </summary>
|
||
public RectTransform Rect { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 面板内的描述文本组件引用,供外部检测嵌套链接。
|
||
/// </summary>
|
||
public TMP_Text DescriptionText => descriptionText;
|
||
|
||
// ---------------------------------------------------------------
|
||
// 生命周期
|
||
// ---------------------------------------------------------------
|
||
|
||
private void Awake()
|
||
{
|
||
Rect = GetComponent<RectTransform>();
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 初始化
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 初始化面板内容。由 KeywordTooltipUI 在实例化后调用。
|
||
/// </summary>
|
||
/// <param name="data">关键词数据</param>
|
||
/// <param name="pinned">是否初始即为固定状态</param>
|
||
public void Initialize(KeywordData data, bool pinned)
|
||
{
|
||
Keyword = data.keyword;
|
||
IsPinned = pinned;
|
||
|
||
// 1. 标题与标题栏显隐控制
|
||
bool hasTitle = !string.IsNullOrEmpty(data.keyword);
|
||
if (titleText != null)
|
||
titleText.text = data.keyword;
|
||
|
||
if (titleBarContainer != null)
|
||
titleBarContainer.gameObject.SetActive(hasTitle);
|
||
|
||
// 2. 描述(经过关键词处理,支持嵌套链接,排除自身防止自引用)
|
||
if (descriptionText != null)
|
||
{
|
||
string processed = KeywordProcessor.ProcessDescription(
|
||
data.description, data.keyword);
|
||
|
||
// 性能与排版双重防御:
|
||
// 获取或动态添加 LayoutElement 元素。在 Horizontal Layout Group 中,
|
||
// 如果不使用 LayoutElement.preferredWidth 限制,子节点的 TMP_Text 组件
|
||
// 会被 Layout 强制拉伸压缩至其 Minimum Width(即单个中文字符宽度,产生“过窄”Bug)。
|
||
var textLayout = descriptionText.GetComponent<LayoutElement>();
|
||
if (textLayout == null)
|
||
textLayout = descriptionText.gameObject.AddComponent<LayoutElement>();
|
||
|
||
var containerLayout = descriptionContainer != null ? descriptionContainer.GetComponent<LayoutElement>() : null;
|
||
if (descriptionContainer != null && containerLayout == null)
|
||
containerLayout = descriptionContainer.gameObject.AddComponent<LayoutElement>();
|
||
|
||
// 暂时关闭自动换行以计算其“自然无换行的 preferredWidth”
|
||
descriptionText.textWrappingMode = TextWrappingModes.NoWrap;
|
||
descriptionText.text = processed;
|
||
|
||
// 强制更新 TMPro 字形数据以获取精确的 preferredWidth
|
||
descriptionText.ForceMeshUpdate();
|
||
float preferredWidth = descriptionText.preferredWidth;
|
||
|
||
// 左右各缩减 10 像素,所以 padding 占用共 20 像素
|
||
float paddingWidth = 20f;
|
||
|
||
if (preferredWidth > 980f)
|
||
{
|
||
// 超过 980 像素,限制文本 preferredWidth 为 980,并启用自动折行
|
||
textLayout.preferredWidth = 980f;
|
||
descriptionText.textWrappingMode = TextWrappingModes.Normal;
|
||
|
||
if (containerLayout != null)
|
||
containerLayout.preferredWidth = 1000f; // 容器宽度 = 980px + 20px padding
|
||
}
|
||
else
|
||
{
|
||
// 在 980 像素内,紧贴真实内容宽度展示,不进行折行
|
||
textLayout.preferredWidth = preferredWidth;
|
||
descriptionText.textWrappingMode = TextWrappingModes.NoWrap;
|
||
|
||
if (containerLayout != null)
|
||
containerLayout.preferredWidth = preferredWidth + paddingWidth;
|
||
}
|
||
}
|
||
|
||
// 3. 固定指示器与固定状态
|
||
UpdatePinIndicator();
|
||
|
||
// 4. 性能优化:只在内容加载、文本大小发生改变时强制刷新一次 UI 布局,防止跟随鼠标时每帧高频刷新
|
||
LayoutRebuilder.ForceRebuildLayoutImmediate(Rect);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 固定 / 取消固定
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 将面板设为固定状态。固定后不再跟随鼠标,且不会因移开鼠标而自动关闭。
|
||
/// </summary>
|
||
public void Pin()
|
||
{
|
||
IsPinned = true;
|
||
UpdatePinIndicator();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 取消固定状态。
|
||
/// </summary>
|
||
public void Unpin()
|
||
{
|
||
IsPinned = false;
|
||
UpdatePinIndicator();
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 屏幕定位
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 将面板定位到指定的屏幕坐标。
|
||
/// 默认情况下,面板左下角与鼠标对齐;
|
||
/// 在靠近屏幕边缘时,会自动调整到合适位置。
|
||
/// </summary>
|
||
/// <param name="screenPos">鼠标屏幕坐标</param>
|
||
/// <param name="offset">基础偏移量</param>
|
||
public void PositionAtScreenPoint(Vector2 screenPos, Vector2 offset)
|
||
{
|
||
if (Rect == null) return;
|
||
|
||
float panelWidth = Rect.rect.width;
|
||
float panelHeight = Rect.rect.height;
|
||
|
||
// 视觉边缘细节:引入 16 像素的安全屏幕边缘 padding,防止边缘阴影或外发光被物理截边
|
||
float safeMargin = 16f;
|
||
|
||
// 基础定位:面板左下角对齐鼠标位置(鼠标在面板的左下角)
|
||
// screenPos 即面板的左下角坐标,再加一个小偏移
|
||
float posX = screenPos.x + offset.x;
|
||
float posY = screenPos.y + offset.y;
|
||
|
||
// 边缘自适应 ─ 右边界
|
||
if (panelWidth > 0 && posX + panelWidth > Screen.width - safeMargin)
|
||
{
|
||
// 面板会超出右侧 → 改为右下角对齐鼠标(面板在鼠标左侧)
|
||
posX = screenPos.x - panelWidth - Mathf.Abs(offset.x);
|
||
}
|
||
|
||
// 边缘自适应 ─ 左边界
|
||
if (posX < safeMargin)
|
||
{
|
||
posX = safeMargin;
|
||
}
|
||
|
||
// 边缘自适应 ─ 上边界
|
||
if (posY + panelHeight > Screen.height - safeMargin)
|
||
{
|
||
// 面板会超出上方 → 向下调整
|
||
posY = Screen.height - panelHeight - safeMargin;
|
||
}
|
||
|
||
// 边缘自适应 ─ 下边界
|
||
if (posY < safeMargin)
|
||
{
|
||
posY = safeMargin;
|
||
}
|
||
|
||
// 设置 Pivot 为左下角 (0, 0) 以匹配我们的定位逻辑
|
||
Rect.pivot = new Vector2(0f, 0f);
|
||
Rect.position = new Vector2(posX, posY);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 碰撞检测
|
||
// ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 检测指定的屏幕坐标是否在面板区域内。
|
||
/// </summary>
|
||
public bool ContainsScreenPoint(Vector2 screenPos, Camera uiCamera)
|
||
{
|
||
return Rect != null &&
|
||
RectTransformUtility.RectangleContainsScreenPoint(Rect, screenPos, uiCamera);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// 内部方法
|
||
// ---------------------------------------------------------------
|
||
|
||
private void UpdatePinIndicator()
|
||
{
|
||
bool hasTitle = !string.IsNullOrEmpty(Keyword);
|
||
|
||
if (titlePin != null)
|
||
titlePin.SetActive(IsPinned && hasTitle);
|
||
|
||
if (descriptionPin != null)
|
||
descriptionPin.SetActive(IsPinned && !hasTitle);
|
||
}
|
||
}
|
||
}
|