using Sirenix.OdinInspector;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace SLSUtilities.Narrative.UI
{
///
/// 关键词浮动面板组件。
/// 挂载在 Tooltip Prefab 根节点上,负责管理单个面板的内容显示、
/// 固定状态及屏幕定位。
///
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;
// ---------------------------------------------------------------
// 公开属性
// ---------------------------------------------------------------
///
/// 此面板对应的主关键词。
///
public string Keyword { get; private set; }
///
/// 该面板是否已被固定(右键固定后不再跟随鼠标,且不会因移开鼠标而关闭)。
///
public bool IsPinned { get; private set; }
///
/// 面板的 RectTransform 引用,供外部定位和碰撞检测。
///
public RectTransform Rect { get; private set; }
///
/// 面板内的描述文本组件引用,供外部检测嵌套链接。
///
public TMP_Text DescriptionText => descriptionText;
// ---------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------
private void Awake()
{
Rect = GetComponent();
}
// ---------------------------------------------------------------
// 初始化
// ---------------------------------------------------------------
///
/// 初始化面板内容。由 KeywordTooltipUI 在实例化后调用。
///
/// 关键词数据
/// 是否初始即为固定状态
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();
if (textLayout == null)
textLayout = descriptionText.gameObject.AddComponent();
var containerLayout = descriptionContainer != null ? descriptionContainer.GetComponent() : null;
if (descriptionContainer != null && containerLayout == null)
containerLayout = descriptionContainer.gameObject.AddComponent();
// 暂时关闭自动换行以计算其“自然无换行的 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);
}
// ---------------------------------------------------------------
// 固定 / 取消固定
// ---------------------------------------------------------------
///
/// 将面板设为固定状态。固定后不再跟随鼠标,且不会因移开鼠标而自动关闭。
///
public void Pin()
{
IsPinned = true;
UpdatePinIndicator();
}
///
/// 取消固定状态。
///
public void Unpin()
{
IsPinned = false;
UpdatePinIndicator();
}
// ---------------------------------------------------------------
// 屏幕定位
// ---------------------------------------------------------------
///
/// 将面板定位到指定的屏幕坐标。
/// 默认情况下,面板左下角与鼠标对齐;
/// 在靠近屏幕边缘时,会自动调整到合适位置。
///
/// 鼠标屏幕坐标
/// 基础偏移量
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);
}
// ---------------------------------------------------------------
// 碰撞检测
// ---------------------------------------------------------------
///
/// 检测指定的屏幕坐标是否在面板区域内。
///
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);
}
}
}