263 lines
11 KiB
C#
263 lines
11 KiB
C#
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>";
|
||
});
|
||
}
|
||
}
|
||
} |