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