using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using UnityEngine; using Yarn.Markup; namespace SLSUtilities.Narrative.UI { /// /// 关键词文本处理器。 /// 扫描台词文本,将已注册的关键词包裹为 TMP 标签, /// 使其可被悬停检测和高亮显示。 /// public static class KeywordProcessor { /// /// 关键词的 link ID 前缀,用于与其他 link 类型区分。 /// 例如: /// public const string LinkPrefix = "kw:"; // 已编译的关键词匹配正则(所有触发词按长度降序排列) private static Regex _keywordPattern; // 触发词 → KeywordData 的映射表(大小写不敏感) private static Dictionary _triggerLookup; // 主关键词 → KeywordData 的映射表(用于通过 link ID 反查) private static Dictionary _primaryLookup; // 用于识别 RichText 标签的正则,避免在标签内部匹配关键词 private static readonly Regex RichTextTagPattern = new Regex(@"<[^>]+>", RegexOptions.Compiled); /// /// 从数据库构建关键词缓存。 /// 应在对话开始时调用一次,或在关键词列表变更后重新调用。 /// public static void BuildCache(List keywords) { _triggerLookup = new Dictionary(System.StringComparer.OrdinalIgnoreCase); _primaryLookup = new Dictionary(System.StringComparer.OrdinalIgnoreCase); if (keywords == null || keywords.Count == 0) { _keywordPattern = null; return; } var allTriggers = new List(); 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); } /// /// 处理台词文本:扫描并将匹配的关键词包裹为带高亮样式的 TMP link 标签。 /// 会自动跳过已有的 RichText 标签内部,避免破坏现有排版。 /// /// 原始台词文本(可能已包含 RichText 标签) /// 关键词高亮颜色(十六进制,如 "#AADDFF") /// 处理后的文本 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(); } /// /// 基于 Yarn Markup 属性处理台词/选项文本: /// 将 [kw] 和 [kw id="主关键词"] 标签转换为 TMP link/style 标签。 /// /// Yarn 标签语法: /// [kw]迎雾森林[/kw] —— 关键词 = 标签内的文本 /// [kw id="迎雾森林"]林地[/kw] —— 关键词 = “迎雾森林”,显示文本 = “林地” /// /// 来自 Yarn 的 MarkupParseResult(LocalizedLine.TextWithoutCharacterName 等) /// 包含 TMP 忌工标签的处理后字符串 public static string ProcessWithMarkup(MarkupParseResult markup) { string plainText = markup.Text; if (string.IsNullOrEmpty(plainText)) return plainText; // 收集所有 "kw" 属性 var kwAttributes = new List(); 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, ""); sb.Insert(attr.Position, $""); } return sb.ToString(); } /// /// 处理关键词的解释文本(用于嵌套 Tooltip)。 /// 与 Process 相同,但会排除自身关键词,避免自引用循环。 /// 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(); } /// /// 通过 link ID(去掉 "kw:" 前缀后的主关键词)反查对应的 KeywordData。 /// public static KeywordData FindByPrimaryKeyword(string primaryKeyword) { if (_primaryLookup == null || string.IsNullOrEmpty(primaryKeyword)) return null; _primaryLookup.TryGetValue(primaryKeyword, out var result); return result; } /// /// 从 TMP link ID 字符串中提取主关键词。 /// 例如输入 "kw:灵能者",返回 "灵能者"。 /// 如果不是关键词类型的 link,返回 null。 /// 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 $"{match.Value}"; }); } } }