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