This commit is contained in:
SoulliesOfficial
2026-06-09 11:21:59 -04:00
parent 7c60c40d6b
commit 021e76efe7
493 changed files with 50500 additions and 2211 deletions

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c3b5f02f9e109140b36d9e3bad02271

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1762f73b835dbd24f934d49bcb0c3c8f

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3965a9528049b914baeeff5e76f39162

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7e64bee4ae3276841b0949789632d0b4

View 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 的 MarkupParseResultLocalizedLine.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>";
});
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 045da86a92e1064419ec0949cf6a3c51

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78e77e9842502d948928cf5cb7c814d7

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dab16e5156d7d044b83244748ea29ab7

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2aa296e25917c8b468ed9c19f3c90b38
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b9480658e91a4d548a18e05958b5605f