Files
Cielonos/Assets/Scripts/SLSUtilities/Narrative/UI/KeywordProcessor.cs
SoulliesOfficial 8186f54e90 新场景,剧情
2026-06-02 12:55:39 -04:00

263 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>";
});
}
}
}