同步
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 高级输入控制器,继承自官方 LineAdvancer。
|
||||
/// 在保留所有原生输入处理逻辑的基础上,
|
||||
/// 当关键词 Tooltip 窗口处于打开状态时,阻断本帧的台词推进输入。
|
||||
/// </summary>
|
||||
public class AdvancedLineAdvancer : LineAdvancer
|
||||
{
|
||||
protected override void RequestLineHurryUpInternal()
|
||||
{
|
||||
if (KeywordTooltipUI.IsBlockingDialogueInput) return;
|
||||
base.RequestLineHurryUpInternal();
|
||||
}
|
||||
|
||||
public override void RequestNextLine()
|
||||
{
|
||||
if (KeywordTooltipUI.IsBlockingDialogueInput) return;
|
||||
base.RequestNextLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3b5f02f9e109140b36d9e3bad02271
|
||||
@@ -0,0 +1,305 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Markup;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 高级台词展现层,继承自官方 LinePresenter。
|
||||
/// 在保留所有原生功能(打字机、淡入淡出、LineAdvancer 状态机)的基础上,
|
||||
/// 扩展了角色立绘/头像切换、关键词高亮与悬停百科等功能。
|
||||
/// </summary>
|
||||
public class AdvancedLinePresenter : LinePresenter
|
||||
{
|
||||
[TitleGroup("立绘系统 (Portrait System)", Alignment = TitleAlignments.Centered)]
|
||||
|
||||
[BoxGroup("立绘系统 (Portrait System)/UI 引用")]
|
||||
[Tooltip("用于显示角色立绘/头像的 Image 组件")]
|
||||
[SerializeField] private Image portraitImage;
|
||||
|
||||
[BoxGroup("立绘系统 (Portrait System)/UI 引用")]
|
||||
[Tooltip("立绘的容器节点。当没有立绘时整体隐藏,避免空白区域占位")]
|
||||
[SerializeField] private GameObject portraitContainer;
|
||||
|
||||
[TitleGroup("关键词系统 (Keyword System)", Alignment = TitleAlignments.Centered)]
|
||||
|
||||
[BoxGroup("关键词系统 (Keyword System)/设置")]
|
||||
[LabelText("启用关键词高亮")]
|
||||
[Tooltip("是否在台词中自动识别并高亮关键词")]
|
||||
[SerializeField] private bool enableKeywordHighlight = true;
|
||||
|
||||
[BoxGroup("关键词系统 (Keyword System)/设置")]
|
||||
[ShowIf(nameof(enableKeywordHighlight))]
|
||||
[LabelText("高亮颜色 (Highlight Color)")]
|
||||
[Tooltip("关键词文本的高亮颜色")]
|
||||
[SerializeField] private Color keywordHighlightColor = new Color(0.67f, 0.87f, 1f); // #AADDFF
|
||||
|
||||
// 缓存是否已构建的标记,避免每句台词重复构建
|
||||
private bool _keywordCacheBuilt = false;
|
||||
|
||||
// 当前台词的 Markup 解析结果,保存到 PostProcessDisplayText 使用
|
||||
private MarkupParseResult _currentLineMarkup;
|
||||
|
||||
/// <summary>最后一次说话的角色名称(Yarn 脚本中的 CharacterName)</summary>
|
||||
public static string LastSpeakerName { get; set; }
|
||||
|
||||
/// <summary>当行内 Markup 标记请求播放动画时触发此事件</summary>
|
||||
public static event Action<string, string> OnPlayAnimationRequested;
|
||||
|
||||
/// <summary>当行内 Markup 标记请求停止动画时触发此事件</summary>
|
||||
public static event Action<string> OnStopAnimationRequested;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 生命周期
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public override YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
// 在对话开始时构建关键词缓存
|
||||
if (enableKeywordHighlight && StorySystem.Database != null)
|
||||
{
|
||||
KeywordProcessor.BuildCache(StorySystem.Database.keywords);
|
||||
_keywordCacheBuilt = true;
|
||||
}
|
||||
|
||||
return base.OnDialogueStartedAsync();
|
||||
}
|
||||
|
||||
public override YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
_keywordCacheBuilt = false;
|
||||
LastSpeakerName = null; // 对话结束时置空
|
||||
return base.OnDialogueCompleteAsync();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 台词处理
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line.CharacterName))
|
||||
{
|
||||
LastSpeakerName = line.CharacterName; // 记录当前说话角色
|
||||
}
|
||||
|
||||
// 确保打字机已通过 IAsyncTypewriter 装饰器包裹,防止其在开始打字时抹除关键词富文本标签。
|
||||
// 基类 Awake 必然已实例化 Typewriter,在此将其替换为我们的富文本打字机包装器。
|
||||
if (Typewriter != null && !(Typewriter is KeywordTypewriterWrapper))
|
||||
{
|
||||
Typewriter = new KeywordTypewriterWrapper(Typewriter, markup => KeywordProcessor.ProcessWithMarkup(markup));
|
||||
}
|
||||
|
||||
// 保存当前台词的 Markup,供 PostProcessDisplayText 使用
|
||||
_currentLineMarkup = line.TextWithoutCharacterName;
|
||||
|
||||
// 提取并解析 Yarn Markup 行内动作标记 (方案 B)
|
||||
if (line.TextWithoutCharacterName.Attributes != null)
|
||||
{
|
||||
foreach (var attribute in line.TextWithoutCharacterName.Attributes)
|
||||
{
|
||||
if (attribute.Name == "anim" || attribute.Name == "play_animation")
|
||||
{
|
||||
if (attribute.Properties.TryGetValue(attribute.Name, out var animValue))
|
||||
{
|
||||
string animName = animValue.StringValue;
|
||||
// 默认指向当前发言说话人,并解析为标准英文 ID
|
||||
string targetNpc = ResolveStandardCharacterId(line.CharacterName);
|
||||
|
||||
// 同时也支持显式指定其他 NPC,例如 [anim="PushButton" npc="SLS"/]
|
||||
if (attribute.Properties.TryGetValue("npc", out var npcValue))
|
||||
{
|
||||
targetNpc = ResolveStandardCharacterId(npcValue.StringValue);
|
||||
}
|
||||
|
||||
OnPlayAnimationRequested?.Invoke(animName, targetNpc);
|
||||
}
|
||||
}
|
||||
else if (attribute.Name == "stop_anim" || attribute.Name == "stop_animation")
|
||||
{
|
||||
string targetNpc = ResolveStandardCharacterId(line.CharacterName);
|
||||
if (attribute.Properties.TryGetValue("npc", out var npcValue))
|
||||
{
|
||||
targetNpc = ResolveStandardCharacterId(npcValue.StringValue);
|
||||
}
|
||||
|
||||
OnStopAnimationRequested?.Invoke(targetNpc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在台词展示之前更新立绘
|
||||
UpdatePortrait(line);
|
||||
|
||||
// 调用父类处理所有其余逻辑(文本设置、打字机、淡入淡出、等待输入)
|
||||
// 父类会在 Typewriter.PrepareForContent 之后调用 PostProcessDisplayText()
|
||||
await base.RunLineAsync(line, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由父类 LinePresenter 在 Typewriter.PrepareForContent 之后调用。
|
||||
/// 此时文本已完整设置到 TMP 组件上,但打字机尚未开始逐字展示。
|
||||
/// 我们在此注入关键词的 link 标签。
|
||||
/// </summary>
|
||||
protected override void PostProcessDisplayText()
|
||||
{
|
||||
if (!enableKeywordHighlight || !_keywordCacheBuilt) return;
|
||||
if (lineText == null) return;
|
||||
|
||||
// 使用基于 [kw] Markup 标签的处理,替代正则自动扫描
|
||||
lineText.text = KeywordProcessor.ProcessWithMarkup(_currentLineMarkup);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 立绘系统
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private void UpdatePortrait(LocalizedLine line)
|
||||
{
|
||||
if (portraitImage == null) return;
|
||||
|
||||
string characterName = line.CharacterName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(characterName) || StorySystem.Database == null)
|
||||
{
|
||||
SetPortraitVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
CharacterData charData = null;
|
||||
foreach (var c in StorySystem.Database.characters)
|
||||
{
|
||||
if (c != null && (string.Equals(c.nameKey, characterName, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.alias.Contains(characterName)))
|
||||
{
|
||||
charData = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (charData == null)
|
||||
{
|
||||
SetPortraitVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
YarnTagParser.Parse(line.Metadata, out var kvTags, out _);
|
||||
kvTags.TryGetValue("mood", out string mood);
|
||||
|
||||
Sprite targetSprite = ResolvePortraitSprite(charData, mood);
|
||||
|
||||
if (targetSprite == null)
|
||||
{
|
||||
SetPortraitVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
portraitImage.sprite = targetSprite;
|
||||
SetPortraitVisible(true);
|
||||
}
|
||||
|
||||
private static Sprite ResolvePortraitSprite(CharacterData charData, string mood)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mood) &&
|
||||
charData.expressions != null &&
|
||||
charData.expressions.TryGetValue(mood, out Sprite moodSprite) &&
|
||||
moodSprite != null)
|
||||
{
|
||||
return moodSprite;
|
||||
}
|
||||
|
||||
return charData.defaultPortrait;
|
||||
}
|
||||
|
||||
private void SetPortraitVisible(bool visible)
|
||||
{
|
||||
if (portraitContainer != null)
|
||||
portraitContainer.SetActive(visible);
|
||||
else if (portraitImage != null)
|
||||
portraitImage.enabled = visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 Yarn 传回的本地化说话人名字(如 "引导者")反向解析为系统内部注册的标准英文 ID(如 "Guide")。
|
||||
/// </summary>
|
||||
private string ResolveStandardCharacterId(string speakerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(speakerName) || StorySystem.Database == null)
|
||||
return speakerName;
|
||||
|
||||
foreach (var c in StorySystem.Database.characters)
|
||||
{
|
||||
if (c == null) continue;
|
||||
|
||||
// 1. 若已经是标准英文名 (nameKey),直接返回
|
||||
if (string.Equals(c.nameKey, speakerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return c.nameKey;
|
||||
}
|
||||
|
||||
// 2. 若匹配到显示名称 (displayName),则返回对应的标准英文 ID (nameKey)
|
||||
if (c.alias != null && c.alias.Contains(speakerName))
|
||||
{
|
||||
return c.nameKey;
|
||||
}
|
||||
}
|
||||
|
||||
return speakerName; // 未匹配到则保留原样
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关键词打字机包装器。
|
||||
/// 解决官方打字机在 PrepareForContent 和 RunTypewriter 时,
|
||||
/// 会强行用 plainText 覆盖 text 组件,导致我们注入的富文本高亮标签被抹除的问题。
|
||||
/// </summary>
|
||||
public class KeywordTypewriterWrapper : IAsyncTypewriter
|
||||
{
|
||||
private readonly IAsyncTypewriter _inner;
|
||||
private readonly Func<MarkupParseResult, string> _processMarkupFunc;
|
||||
|
||||
public KeywordTypewriterWrapper(IAsyncTypewriter inner, Func<MarkupParseResult, string> processMarkupFunc)
|
||||
{
|
||||
_inner = inner;
|
||||
_processMarkupFunc = processMarkupFunc;
|
||||
}
|
||||
|
||||
public TMPro.TMP_Text? TextElement
|
||||
{
|
||||
get => _inner.TextElement;
|
||||
set => _inner.TextElement = value;
|
||||
}
|
||||
|
||||
public List<IActionMarkupHandler> ActionMarkupHandlers => _inner.ActionMarkupHandlers;
|
||||
|
||||
public void PrepareForContent(MarkupParseResult line)
|
||||
{
|
||||
_inner.PrepareForContent(line);
|
||||
if (TextElement != null)
|
||||
{
|
||||
TextElement.text = _processMarkupFunc(line);
|
||||
}
|
||||
}
|
||||
|
||||
public async YarnTask RunTypewriter(MarkupParseResult line, System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
var task = _inner.RunTypewriter(line, cancellationToken);
|
||||
if (TextElement != null)
|
||||
{
|
||||
TextElement.text = _processMarkupFunc(line);
|
||||
}
|
||||
await task;
|
||||
if (TextElement != null)
|
||||
{
|
||||
TextElement.text = _processMarkupFunc(line);
|
||||
}
|
||||
}
|
||||
|
||||
public void ContentWillDismiss() => _inner.ContentWillDismiss();
|
||||
public void ContentDidDismiss() => _inner.ContentDidDismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1762f73b835dbd24f934d49bcb0c3c8f
|
||||
128
Assets/Scripts/SLSUtilities/Narrative/UI/AdvancedOptionItem.cs
Normal file
128
Assets/Scripts/SLSUtilities/Narrative/UI/AdvancedOptionItem.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Markup;
|
||||
using TMPro;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 高级选项项,继承自官方 OptionItem。
|
||||
/// 支持解析选项中的 #desc: 和 #fail: 元数据标签,
|
||||
/// 通过 OptionTooltipUI 显示选项提示,
|
||||
/// 以及使用 [kw] Yarn Markup 标签高亮关键词。
|
||||
/// </summary>
|
||||
public class AdvancedOptionItem : OptionItem
|
||||
{
|
||||
public string TooltipDesc { get; private set; }
|
||||
public string TooltipFail { get; private set; }
|
||||
|
||||
public TMP_Text GetTextComponent() => text;
|
||||
|
||||
// 追踪当前是否是由鼠标指针触发的选中状态
|
||||
// OnPointerEnter 在 OnSelect 之前同步设置此标记,OnDeselect 清除
|
||||
private bool _pointerEntered = false;
|
||||
|
||||
public override DialogueOption Option
|
||||
{
|
||||
get => base.Option;
|
||||
set
|
||||
{
|
||||
// 调用基类 setter:设置 _option、interactable 和 ApplyStyle
|
||||
// 注意:基类也会设置 text.text,我们稍后会覆盖它
|
||||
base.Option = value;
|
||||
|
||||
// 解析 Tooltip 元数据标签 (#desc: / #fail:)
|
||||
TooltipDesc = null;
|
||||
TooltipFail = null;
|
||||
|
||||
if (value.Line.Metadata != null)
|
||||
{
|
||||
YarnTagParser.Parse(value.Line.Metadata, out var kvTags, out _);
|
||||
|
||||
if (kvTags.TryGetValue("desc", out string desc))
|
||||
{
|
||||
TooltipDesc = desc.Replace("_", " ");
|
||||
}
|
||||
|
||||
if (kvTags.TryGetValue("fail", out string fail))
|
||||
{
|
||||
TooltipFail = fail.Replace("_", " ");
|
||||
}
|
||||
}
|
||||
|
||||
// 选项文本高亮处理:
|
||||
// 仅通过 ProcessWithMarkup 处理手动标记的 [kw] 或 [kw id="..."] 标签。
|
||||
// 不进行正则自动扫描(只高亮明确被 [kw] 标记的部分,与台词表现一致)。
|
||||
if (text != null)
|
||||
{
|
||||
string processed = KeywordProcessor.ProcessWithMarkup(value.Line.TextWithoutCharacterName);
|
||||
|
||||
if (disabledStrikeThrough && !value.IsAvailable)
|
||||
{
|
||||
processed = $"<s>{processed}</s>";
|
||||
}
|
||||
|
||||
text.text = processed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
// 在调用 base.OnPointerEnter(其中会同步调用 OnSelect)之前
|
||||
// 设置标记,使 OnSelect 能知道这是鼠标触发的
|
||||
_pointerEntered = true;
|
||||
base.OnPointerEnter(eventData);
|
||||
}
|
||||
|
||||
public override void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
base.OnPointerExit(eventData);
|
||||
// 鼠标离开后清除,但不关闭 Tooltip(由 OptionTooltipUI 的文本区域检测控制)
|
||||
_pointerEntered = false;
|
||||
}
|
||||
|
||||
public override void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
// 只有当点击的是鼠标左键时,才允许选择并推进此选项,阻断鼠标右键的选择触发
|
||||
if (eventData.button == PointerEventData.InputButton.Left)
|
||||
{
|
||||
base.OnPointerClick(eventData);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnSelect(BaseEventData eventData)
|
||||
{
|
||||
base.OnSelect(eventData);
|
||||
|
||||
// 将"是否为鼠标触发"传递给 OptionTooltipUI,用于决定定位方式
|
||||
OptionTooltipUI.Instance?.OnOptionSelected(this, _pointerEntered);
|
||||
}
|
||||
|
||||
public override void OnDeselect(BaseEventData eventData)
|
||||
{
|
||||
base.OnDeselect(eventData);
|
||||
_pointerEntered = false;
|
||||
OptionTooltipUI.Instance?.OnOptionDeselected(this);
|
||||
}
|
||||
|
||||
protected override void OnEnable()
|
||||
{
|
||||
base.OnEnable();
|
||||
if (KeywordTooltipUI.Instance != null && text != null)
|
||||
{
|
||||
KeywordTooltipUI.Instance.RegisterExternalText(text);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDisable()
|
||||
{
|
||||
base.OnDisable();
|
||||
if (KeywordTooltipUI.Instance != null && text != null)
|
||||
{
|
||||
KeywordTooltipUI.Instance.UnregisterExternalText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3965a9528049b914baeeff5e76f39162
|
||||
@@ -0,0 +1,74 @@
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
using Sirenix.OdinInspector;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 高级选项展现层,继承自官方 OptionsPresenter。
|
||||
/// </summary>
|
||||
public class AdvancedOptionsPresenter : OptionsPresenter
|
||||
{
|
||||
[TitleGroup("Advanced Settings", Alignment = TitleAlignments.Centered)]
|
||||
[BoxGroup("Advanced Settings/Prefabs")]
|
||||
[Required("需要指定 AdvancedOptionItem 预制体")]
|
||||
[SerializeField] private AdvancedOptionItem advancedOptionViewPrefab;
|
||||
|
||||
public override YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
// 建立关键词缓存,保证 ProcessWithMarkup 能正确查找关键词数据
|
||||
// (不依赖 AdvancedLinePresenter 是否已经运行)
|
||||
if (StorySystem.Database != null)
|
||||
KeywordProcessor.BuildCache(StorySystem.Database.keywords);
|
||||
|
||||
return base.OnDialogueStartedAsync();
|
||||
}
|
||||
|
||||
public override YarnTask<DialogueOption?> RunOptionsAsync(DialogueOption[] dialogueOptions, LineCancellationToken cancellationToken)
|
||||
{
|
||||
// 过滤掉不可用且带有 "hide" 或 "#hide" 标签的选项
|
||||
var filteredOptions = new System.Collections.Generic.List<DialogueOption>();
|
||||
foreach (var option in dialogueOptions)
|
||||
{
|
||||
bool shouldHide = false;
|
||||
if (!option.IsAvailable && option.Line != null && option.Line.Metadata != null)
|
||||
{
|
||||
foreach (var tag in option.Line.Metadata)
|
||||
{
|
||||
if (tag.Equals("hide", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
tag.Equals("#hide", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
shouldHide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldHide)
|
||||
{
|
||||
filteredOptions.Add(option);
|
||||
}
|
||||
}
|
||||
|
||||
return base.RunOptionsAsync(filteredOptions.ToArray(), cancellationToken);
|
||||
}
|
||||
|
||||
protected override OptionItem CreateNewOptionView()
|
||||
{
|
||||
var targetTransform = canvasGroup != null ? canvasGroup.transform : this.transform;
|
||||
|
||||
var optionView = Instantiate(advancedOptionViewPrefab, targetTransform, false);
|
||||
|
||||
if (optionView == null)
|
||||
{
|
||||
Debug.LogError("Failed to instantiate advancedOptionViewPrefab.");
|
||||
return null;
|
||||
}
|
||||
|
||||
optionView.transform.SetAsLastSibling();
|
||||
optionView.gameObject.SetActive(false);
|
||||
|
||||
return optionView;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e64bee4ae3276841b0949789632d0b4
|
||||
263
Assets/Scripts/SLSUtilities/Narrative/UI/KeywordProcessor.cs
Normal file
263
Assets/Scripts/SLSUtilities/Narrative/UI/KeywordProcessor.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
using Yarn.Markup;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键词文本处理器。
|
||||
/// 扫描台词文本,将已注册的关键词包裹为 TMP <link> 标签,
|
||||
/// 使其可被悬停检测和高亮显示。
|
||||
/// </summary>
|
||||
public static class KeywordProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键词的 link ID 前缀,用于与其他 link 类型区分。
|
||||
/// 例如:<link="kw:灵能者">
|
||||
/// </summary>
|
||||
public const string LinkPrefix = "kw:";
|
||||
|
||||
// 已编译的关键词匹配正则(所有触发词按长度降序排列)
|
||||
private static Regex _keywordPattern;
|
||||
|
||||
// 触发词 → KeywordData 的映射表(大小写不敏感)
|
||||
private static Dictionary<string, KeywordData> _triggerLookup;
|
||||
|
||||
// 主关键词 → KeywordData 的映射表(用于通过 link ID 反查)
|
||||
private static Dictionary<string, KeywordData> _primaryLookup;
|
||||
|
||||
// 用于识别 RichText 标签的正则,避免在标签内部匹配关键词
|
||||
private static readonly Regex RichTextTagPattern =
|
||||
new Regex(@"<[^>]+>", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库构建关键词缓存。
|
||||
/// 应在对话开始时调用一次,或在关键词列表变更后重新调用。
|
||||
/// </summary>
|
||||
public static void BuildCache(List<KeywordData> keywords)
|
||||
{
|
||||
_triggerLookup = new Dictionary<string, KeywordData>(System.StringComparer.OrdinalIgnoreCase);
|
||||
_primaryLookup = new Dictionary<string, KeywordData>(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (keywords == null || keywords.Count == 0)
|
||||
{
|
||||
_keywordPattern = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var allTriggers = new List<string>();
|
||||
|
||||
foreach (var kw in keywords)
|
||||
{
|
||||
if (kw == null || string.IsNullOrWhiteSpace(kw.keyword)) continue;
|
||||
|
||||
// 注册主关键词到 primaryLookup
|
||||
if (!_primaryLookup.ContainsKey(kw.keyword))
|
||||
_primaryLookup[kw.keyword] = kw;
|
||||
|
||||
// 注册所有触发词(主关键词 + 别名)到 triggerLookup
|
||||
foreach (var trigger in kw.GetAllTriggerWords())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trigger) && !_triggerLookup.ContainsKey(trigger))
|
||||
{
|
||||
_triggerLookup[trigger] = kw;
|
||||
allTriggers.Add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTriggers.Count == 0)
|
||||
{
|
||||
_keywordPattern = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 按长度降序排列,确保"灵能者协会"优先于"灵能者"匹配
|
||||
allTriggers.Sort((a, b) => b.Length.CompareTo(a.Length));
|
||||
|
||||
// 构建正则:将所有触发词用 | 连接
|
||||
var escapedTriggers = allTriggers.Select(Regex.Escape);
|
||||
string pattern = string.Join("|", escapedTriggers);
|
||||
|
||||
_keywordPattern = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理台词文本:扫描并将匹配的关键词包裹为带高亮样式的 TMP link 标签。
|
||||
/// 会自动跳过已有的 RichText 标签内部,避免破坏现有排版。
|
||||
/// </summary>
|
||||
/// <param name="rawText">原始台词文本(可能已包含 RichText 标签)</param>
|
||||
/// <param name="highlightColor">关键词高亮颜色(十六进制,如 "#AADDFF")</param>
|
||||
/// <returns>处理后的文本</returns>
|
||||
public static string Process(string rawText, string highlightColor = "#AADDFF")
|
||||
{
|
||||
if (_keywordPattern == null || string.IsNullOrEmpty(rawText))
|
||||
return rawText;
|
||||
|
||||
// 将文本按"RichText 标签"和"纯文本"交替切分,
|
||||
// 只对纯文本段进行关键词替换,标签段原样保留。
|
||||
var result = new StringBuilder(rawText.Length * 2);
|
||||
int lastIndex = 0;
|
||||
|
||||
foreach (Match tagMatch in RichTextTagPattern.Matches(rawText))
|
||||
{
|
||||
// 处理标签前的纯文本段
|
||||
if (tagMatch.Index > lastIndex)
|
||||
{
|
||||
string segment = rawText.Substring(lastIndex, tagMatch.Index - lastIndex);
|
||||
result.Append(ReplaceKeywordsInSegment(segment, highlightColor));
|
||||
}
|
||||
|
||||
// 标签本身原样追加
|
||||
result.Append(tagMatch.Value);
|
||||
lastIndex = tagMatch.Index + tagMatch.Length;
|
||||
}
|
||||
|
||||
// 处理最后一个标签之后的剩余纯文本
|
||||
if (lastIndex < rawText.Length)
|
||||
{
|
||||
string remaining = rawText.Substring(lastIndex);
|
||||
result.Append(ReplaceKeywordsInSegment(remaining, highlightColor));
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于 Yarn Markup 属性处理台词/选项文本:
|
||||
/// 将 [kw] 和 [kw id="主关键词"] 标签转换为 TMP link/style 标签。
|
||||
///
|
||||
/// Yarn 标签语法:
|
||||
/// [kw]迎雾森林[/kw] —— 关键词 = 标签内的文本
|
||||
/// [kw id="迎雾森林"]林地[/kw] —— 关键词 = “迎雾森林”,显示文本 = “林地”
|
||||
/// </summary>
|
||||
/// <param name="markup">来自 Yarn 的 MarkupParseResult(LocalizedLine.TextWithoutCharacterName 等)</param>
|
||||
/// <returns>包含 TMP 忌工标签的处理后字符串</returns>
|
||||
public static string ProcessWithMarkup(MarkupParseResult markup)
|
||||
{
|
||||
string plainText = markup.Text;
|
||||
if (string.IsNullOrEmpty(plainText)) return plainText;
|
||||
|
||||
// 收集所有 "kw" 属性
|
||||
var kwAttributes = new List<MarkupAttribute>();
|
||||
foreach (var attr in markup.Attributes)
|
||||
{
|
||||
if (attr.Name == "kw")
|
||||
kwAttributes.Add(attr);
|
||||
}
|
||||
|
||||
// 没有关键词标签,直接返回原始文本
|
||||
if (kwAttributes.Count == 0) return plainText;
|
||||
|
||||
// 按位置降序排列,从字符串末尾开始插入,避免早期插入造成索引偏移
|
||||
kwAttributes.Sort((a, b) => b.Position.CompareTo(a.Position));
|
||||
|
||||
var sb = new StringBuilder(plainText);
|
||||
|
||||
foreach (var attr in kwAttributes)
|
||||
{
|
||||
// 确定关键词:优先使用 id 属性,否则取标签覆盖的文本
|
||||
string keyword;
|
||||
if (attr.Properties.TryGetValue("id", out var idProp))
|
||||
{
|
||||
keyword = idProp.StringValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyword = plainText.Substring(attr.Position, attr.Length);
|
||||
}
|
||||
|
||||
// 在数据库中查找该关键词,找不到则跳过(不插入任何标签)
|
||||
if (FindByPrimaryKeyword(keyword) == null) continue;
|
||||
|
||||
// 先插入闭合标签(索引较大),再插入开放标签
|
||||
sb.Insert(attr.Position + attr.Length, "</link></style>");
|
||||
sb.Insert(attr.Position, $"<style=\"kw\"><link=\"{LinkPrefix}{keyword}\">");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理关键词的解释文本(用于嵌套 Tooltip)。
|
||||
/// 与 Process 相同,但会排除自身关键词,避免自引用循环。
|
||||
/// </summary>
|
||||
public static string ProcessDescription(string description, string excludeKeyword,
|
||||
string highlightColor = "#AADDFF")
|
||||
{
|
||||
if (_keywordPattern == null || string.IsNullOrEmpty(description))
|
||||
return description;
|
||||
|
||||
var result = new StringBuilder(description.Length * 2);
|
||||
int lastIndex = 0;
|
||||
|
||||
foreach (Match tagMatch in RichTextTagPattern.Matches(description))
|
||||
{
|
||||
if (tagMatch.Index > lastIndex)
|
||||
{
|
||||
string segment = description.Substring(lastIndex, tagMatch.Index - lastIndex);
|
||||
result.Append(ReplaceKeywordsInSegment(segment, highlightColor, excludeKeyword));
|
||||
}
|
||||
result.Append(tagMatch.Value);
|
||||
lastIndex = tagMatch.Index + tagMatch.Length;
|
||||
}
|
||||
|
||||
if (lastIndex < description.Length)
|
||||
{
|
||||
string remaining = description.Substring(lastIndex);
|
||||
result.Append(ReplaceKeywordsInSegment(remaining, highlightColor, excludeKeyword));
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 link ID(去掉 "kw:" 前缀后的主关键词)反查对应的 KeywordData。
|
||||
/// </summary>
|
||||
public static KeywordData FindByPrimaryKeyword(string primaryKeyword)
|
||||
{
|
||||
if (_primaryLookup == null || string.IsNullOrEmpty(primaryKeyword))
|
||||
return null;
|
||||
|
||||
_primaryLookup.TryGetValue(primaryKeyword, out var result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 TMP link ID 字符串中提取主关键词。
|
||||
/// 例如输入 "kw:灵能者",返回 "灵能者"。
|
||||
/// 如果不是关键词类型的 link,返回 null。
|
||||
/// </summary>
|
||||
public static string ExtractKeywordFromLinkId(string linkId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(linkId) || !linkId.StartsWith(LinkPrefix))
|
||||
return null;
|
||||
|
||||
return linkId.Substring(LinkPrefix.Length);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private static string ReplaceKeywordsInSegment(string segment, string color,
|
||||
string excludeKeyword = null)
|
||||
{
|
||||
return _keywordPattern.Replace(segment, match =>
|
||||
{
|
||||
if (!_triggerLookup.TryGetValue(match.Value, out var kwData))
|
||||
return match.Value;
|
||||
|
||||
// 排除自身关键词(用于嵌套 Tooltip 防止自引用)
|
||||
if (excludeKeyword != null &&
|
||||
string.Equals(kwData.keyword, excludeKeyword, System.StringComparison.OrdinalIgnoreCase))
|
||||
return match.Value;
|
||||
|
||||
// 包裹为 TMP link 标签
|
||||
// link ID 格式: "kw:主关键词"
|
||||
return $"<style=\"kw\"><link=\"{LinkPrefix}{kwData.keyword}\">{match.Value}</link></style>";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 045da86a92e1064419ec0949cf6a3c51
|
||||
337
Assets/Scripts/SLSUtilities/Narrative/UI/KeywordTooltipUI.cs
Normal file
337
Assets/Scripts/SLSUtilities/Narrative/UI/KeywordTooltipUI.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78e77e9842502d948928cf5cb7c814d7
|
||||
330
Assets/Scripts/SLSUtilities/Narrative/UI/OptionTooltipUI.cs
Normal file
330
Assets/Scripts/SLSUtilities/Narrative/UI/OptionTooltipUI.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Sirenix.OdinInspector;
|
||||
|
||||
namespace SLSUtilities.Narrative.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 选项悬停提示面板管理器。
|
||||
/// - 鼠标模式:仅在鼠标悬停于选项"文本区域"(非空白行区域)时显示,跟随鼠标移动。
|
||||
/// - 键盘模式:在选项文本的右上角处固定显示。
|
||||
/// - 支持右键固定和点击外部关闭。
|
||||
/// </summary>
|
||||
public class OptionTooltipUI : MonoBehaviour
|
||||
{
|
||||
public static OptionTooltipUI Instance { get; private set; }
|
||||
|
||||
[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("渲染 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);
|
||||
|
||||
[BoxGroup("行为设置 (Behavior Settings)/定位")]
|
||||
[Tooltip("鼠标检测的边缘容差像素数。\n较大值可避免中文全角标点符号边缘闪烁,较小值则更精确地限制在文字内。")]
|
||||
[Range(0f, 20f)]
|
||||
[SerializeField] private float textBoundsTolerance = 6f;
|
||||
|
||||
private TooltipPanel _hoverPanel;
|
||||
private AdvancedOptionItem _hoverItem;
|
||||
|
||||
// 当前悬停是否由鼠标触发(false = 键盘触发)
|
||||
private bool _isMouseSelection;
|
||||
|
||||
// 已固定的选项 Tooltip
|
||||
private readonly List<TooltipPanel> _pinnedPanels = new List<TooltipPanel>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
CloseHoverPanel();
|
||||
CloseAllPinnedPanels();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
HandleHoverPanelVisibility();
|
||||
HandleClickInput();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 公开接口(由 AdvancedOptionItem 调用)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public void OnOptionSelected(AdvancedOptionItem item, bool isMouseTriggered)
|
||||
{
|
||||
_hoverItem = item;
|
||||
_isMouseSelection = isMouseTriggered;
|
||||
|
||||
string textToShow = item.Option.IsAvailable ? item.TooltipDesc : item.TooltipFail;
|
||||
if (string.IsNullOrWhiteSpace(textToShow))
|
||||
{
|
||||
CloseHoverPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasPinnedPanelForOption(textToShow))
|
||||
{
|
||||
CloseHoverPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
CloseHoverPanel();
|
||||
_hoverPanel = SpawnPanel(textToShow);
|
||||
|
||||
if (_isMouseSelection)
|
||||
{
|
||||
// 鼠标模式:初始位置对齐鼠标,后续每帧跟随
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
_hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 键盘模式:定位在文本右上角处
|
||||
PositionPanelAtTextTopRight(_hoverPanel, item.GetTextComponent());
|
||||
}
|
||||
}
|
||||
|
||||
public void OnOptionDeselected(AdvancedOptionItem item)
|
||||
{
|
||||
if (_hoverItem == item)
|
||||
CloseHoverPanel();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 每帧更新(悬停面板可见性与定位)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private void HandleHoverPanelVisibility()
|
||||
{
|
||||
if (_hoverPanel == null) return;
|
||||
|
||||
// 当选项文本中出现了关键词且玩家正在选中关键词时,隐去未固定的选项 Tooltip
|
||||
bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel;
|
||||
if (isHoveringKeyword)
|
||||
{
|
||||
_hoverPanel.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isMouseSelection)
|
||||
{
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
|
||||
// 使用 textBounds 检测文本渲染边界,避免全角标点符号字形间隙造成闪烁
|
||||
var textComp = _hoverItem?.GetTextComponent();
|
||||
bool mouseOverText = IsMouseOverTextArea(textComp, mousePos);
|
||||
|
||||
if (mouseOverText)
|
||||
{
|
||||
_hoverPanel.gameObject.SetActive(true);
|
||||
// 每帧跟随鼠标
|
||||
_hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
_hoverPanel.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 键盘模式:始终显示,位置固定在文本右上角(无需每帧更新)
|
||||
_hoverPanel.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 点击输入处理
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private void HandleClickInput()
|
||||
{
|
||||
bool leftClick = Mouse.current.leftButton.wasPressedThisFrame;
|
||||
bool rightClick = Mouse.current.rightButton.wasPressedThisFrame;
|
||||
|
||||
if (!leftClick && !rightClick) return;
|
||||
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
|
||||
// 右键固定:当 hover panel 可见时,右键单击在选项区域内将其固定
|
||||
if (rightClick && _hoverPanel != null && _hoverPanel.gameObject.activeSelf)
|
||||
{
|
||||
bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel;
|
||||
if (!isHoveringKeyword)
|
||||
{
|
||||
// 使用与悬停检测相同的 textBounds 方式
|
||||
var textComp = _hoverItem?.GetTextComponent();
|
||||
bool clickOverText = IsMouseOverTextArea(textComp, mousePos);
|
||||
bool clickOverPanel = _hoverPanel.ContainsScreenPoint(mousePos, uiCamera);
|
||||
|
||||
if (clickOverText || clickOverPanel)
|
||||
{
|
||||
PinHoverPanel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭所有已固定的选项 Tooltip
|
||||
if (_pinnedPanels.Count > 0)
|
||||
{
|
||||
bool clickedInside = false;
|
||||
|
||||
foreach (var panel in _pinnedPanels)
|
||||
{
|
||||
if (panel.ContainsScreenPoint(mousePos, uiCamera))
|
||||
{
|
||||
clickedInside = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedInside && _hoverPanel != null && _hoverPanel.ContainsScreenPoint(mousePos, uiCamera))
|
||||
clickedInside = true;
|
||||
|
||||
if (!clickedInside)
|
||||
CloseAllPinnedPanels();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 面板生命周期
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private void PinHoverPanel()
|
||||
{
|
||||
if (_hoverPanel == null) return;
|
||||
|
||||
_hoverPanel.Pin();
|
||||
_pinnedPanels.Add(_hoverPanel);
|
||||
_hoverPanel = null;
|
||||
}
|
||||
|
||||
private TooltipPanel SpawnPanel(string description)
|
||||
{
|
||||
if (tooltipPanelPrefab == null || tooltipContainer == null) return null;
|
||||
|
||||
var panelGO = Instantiate(tooltipPanelPrefab, tooltipContainer);
|
||||
var panel = panelGO.GetComponent<TooltipPanel>();
|
||||
|
||||
// 创建临时 KeywordData,内容为选项说明(无标题)
|
||||
var data = ScriptableObject.CreateInstance<KeywordData>();
|
||||
data.keyword = string.Empty;
|
||||
// 选项说明中也支持嵌套关键词:由 Initialize 内的 ProcessDescription 自动处理
|
||||
data.description = description;
|
||||
|
||||
panel.Initialize(data, false);
|
||||
|
||||
Destroy(data);
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 定位工具
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 将面板定位在 TMP 文本组件的右上角处(用于键盘模式)。
|
||||
/// </summary>
|
||||
private void PositionPanelAtTextTopRight(TooltipPanel panel, TMP_Text textComp)
|
||||
{
|
||||
if (panel == null || textComp == null) return;
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
textComp.rectTransform.GetWorldCorners(corners);
|
||||
// corners 顺序:0=BL, 1=TL, 2=TR, 3=BR(屏幕坐标,Overlay模式)
|
||||
// 对于非 Overlay 模式,使用 WorldToScreenPoint 转换
|
||||
Vector2 screenPos = uiCamera != null
|
||||
? RectTransformUtility.WorldToScreenPoint(uiCamera, corners[2])
|
||||
: new Vector2(corners[2].x, corners[2].y);
|
||||
|
||||
panel.PositionAtScreenPoint(screenPos, tooltipOffset);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 文本区域检测工具
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 检测鼠标屏幕坐标是否处于 TMP 文本的实际渲染边界矩形内。
|
||||
/// 使用 textBounds(字形渲染包围盒)而非 FindIntersectingCharacter,
|
||||
/// 可避免中文全角标点符号字形内空白区域导致的闪烁问题。
|
||||
/// </summary>
|
||||
private bool IsMouseOverTextArea(TMP_Text textComp, Vector2 screenMousePos)
|
||||
{
|
||||
if (textComp == null) return false;
|
||||
|
||||
// 确保 TMPro 网格在当下完成同步刷新,以获得 100% 准确的渲染包围盒,彻底阻断首帧零包围盒渲染计算闪烁
|
||||
textComp.ForceMeshUpdate();
|
||||
|
||||
// 将屏幕坐标转换为 TMP RectTransform 的局部坐标
|
||||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
textComp.rectTransform, screenMousePos, uiCamera, out Vector2 localPoint))
|
||||
return false;
|
||||
|
||||
// textBounds 是 TMP 实际渲染内容的包围盒(局部坐标),
|
||||
// 比 RectTransform 本身更精确,且不受字符个体差异影响
|
||||
Bounds bounds = textComp.textBounds;
|
||||
|
||||
// 加入可配置容差,避免全角标点字形边缘闪烁
|
||||
return localPoint.x >= bounds.min.x - textBoundsTolerance
|
||||
&& localPoint.x <= bounds.max.x + textBoundsTolerance
|
||||
&& localPoint.y >= bounds.min.y - textBoundsTolerance
|
||||
&& localPoint.y <= bounds.max.y + textBoundsTolerance;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 查询工具
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private bool HasPinnedPanelForOption(string description)
|
||||
{
|
||||
// 简单比较原始描述文本(未处理),避免二次处理比较问题
|
||||
foreach (var panel in _pinnedPanels)
|
||||
{
|
||||
if (panel != null && panel.DescriptionText != null &&
|
||||
panel.DescriptionText.text.Contains(description.Substring(0, Mathf.Min(description.Length, 10))))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void CloseHoverPanel()
|
||||
{
|
||||
if (_hoverPanel != null && _hoverPanel.gameObject != null)
|
||||
Destroy(_hoverPanel.gameObject);
|
||||
_hoverPanel = null;
|
||||
}
|
||||
|
||||
private void CloseAllPinnedPanels()
|
||||
{
|
||||
foreach (var panel in _pinnedPanels)
|
||||
{
|
||||
if (panel != null && panel.gameObject != null)
|
||||
Destroy(panel.gameObject);
|
||||
}
|
||||
_pinnedPanels.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dab16e5156d7d044b83244748ea29ab7
|
||||
BIN
Assets/Scripts/SLSUtilities/Narrative/UI/Text StyleSheet.asset
LFS
Normal file
BIN
Assets/Scripts/SLSUtilities/Narrative/UI/Text StyleSheet.asset
LFS
Normal file
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2aa296e25917c8b468ed9c19f3c90b38
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
269
Assets/Scripts/SLSUtilities/Narrative/UI/TooltipPanel.cs
Normal file
269
Assets/Scripts/SLSUtilities/Narrative/UI/TooltipPanel.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b9480658e91a4d548a18e05958b5605f
|
||||
Reference in New Issue
Block a user