Files
ichni_Official/Assets/Scripts/SLSUtilities/Narrative/UI/AdvancedLinePresenter.cs
SoulliesOfficial 021e76efe7 同步
2026-06-09 11:21:59 -04:00

305 lines
12 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;
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();
}
}