同步
This commit is contained in:
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>";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user