using System; using System.Collections.Generic; using Sirenix.OdinInspector; using UnityEngine; using UnityEngine.UI; using Yarn.Unity; using Yarn.Markup; namespace SLSUtilities.Narrative.UI { /// /// 高级台词展现层,继承自官方 LinePresenter。 /// 在保留所有原生功能(打字机、淡入淡出、LineAdvancer 状态机)的基础上, /// 扩展了角色立绘/头像切换、关键词高亮与悬停百科等功能。 /// 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; /// 最后一次说话的角色名称(Yarn 脚本中的 CharacterName) public static string LastSpeakerName { get; set; } /// 当行内 Markup 标记请求播放动画时触发此事件 public static event Action OnPlayAnimationRequested; /// 当行内 Markup 标记请求停止动画时触发此事件 public static event Action 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); } /// /// 由父类 LinePresenter 在 Typewriter.PrepareForContent 之后调用。 /// 此时文本已完整设置到 TMP 组件上,但打字机尚未开始逐字展示。 /// 我们在此注入关键词的 link 标签。 /// 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; } /// /// 将 Yarn 传回的本地化说话人名字(如 "引导者")反向解析为系统内部注册的标准英文 ID(如 "Guide")。 /// 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; // 未匹配到则保留原样 } } /// /// 关键词打字机包装器。 /// 解决官方打字机在 PrepareForContent 和 RunTypewriter 时, /// 会强行用 plainText 覆盖 text 组件,导致我们注入的富文本高亮标签被抹除的问题。 /// public class KeywordTypewriterWrapper : IAsyncTypewriter { private readonly IAsyncTypewriter _inner; private readonly Func _processMarkupFunc; public KeywordTypewriterWrapper(IAsyncTypewriter inner, Func processMarkupFunc) { _inner = inner; _processMarkupFunc = processMarkupFunc; } public TMPro.TMP_Text? TextElement { get => _inner.TextElement; set => _inner.TextElement = value; } public List 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(); } }