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

270 lines
11 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 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);
}
}
}