Unity功能模块一对话系统(4)实现个性文本标签
本期我们将了解如何在TMPro中自定义我们的标签样式,并实现两种有趣的效果。
一.需求描述
1.定义<float>格式的标签,实现标签处延迟打印功能
2.定义<r=" "></r>格式的标签,实现标签区间内文本片段的注释显示功能
3.补充文本多种打印样式功能。
二.自定义标签样式
首先我们需要借助预处理器来读取到我们的目标格式的标签,并记录标签内的相关信息
基本思路:通过ITextPreprocessor接口实现预处理器的预加工函数PreprocessText(),读取到文本中的自定义标签格式并记录标签内信息。
1.实现接口方法
public class AdvancedTextPreprocessor : ITextPreprocessor
{
public string PreprocessText(string text){}
}
2.提取标签
(1)使用正则表达式来匹配<>的类html格式。
public string PreprocessText(string text)
{
string processingText = text;
string pattern = "<.*?>";//定义一个正则表达式 "<.*>" 用于匹配所有以 < 开头、> 结尾的字符串。贪婪模式,即它会匹配最远的 >
Match match = Regex.Match(processingText, pattern);//首次匹配,查找所有富文本标签
while (match.Success) //尝试匹配富文本标签
{
match = Regex.Match(processingText, pattern);//继续匹配,查找下一个标签
}
processingText = text;
return processingText;
}
//正则表达式概念
// . 代表任意字符
//*代表前一个字符出现0次或多次
//+代表前一个字符出现1次或多次
//?代表前一个字符出现0次或1次
这里我们定义了<.*?>这种文本模式,下方代码将在用户文本中查找符合模式的文本片段match。
Match match = Regex.Match(processingText, pattern);//按照pattern模式来匹配用户文本中符合格式的文本(含<>)
3.记录标签内信息
下面我们需要记录下我们的自定义标签内的信息。
1.定义存储结构
/// <summary>
/// 打印延迟字典【读取标签<float>,遇到将延迟float秒后打印后面内容】
/// </summary>
public Dictionary<int, float> IntervalDictionary = new Dictionary<int, float>();//自定义打印标签字典
/// <summary>
/// 注释标签列表
/// </summary>
public List<RubyData> rubyList = new List<RubyData>();
这里我使用字典存储延迟标签信息,将延迟标签的位置偏移作为键,延迟长度作为值。
使用列表来存储注释数据结构。成员是Rubydata类的实例。
Rubydata类中定义了3种属性:注释自身在文本中的起止位置偏移startIndex和终止位置偏移endIndex,以及注释内容contnet。
RubyData类如下
/// <summary>
/// 注释数据
/// </summary>
public class RubyData
{
public RubyData(int _startIndex, string _content)
{
startIndex = _startIndex;
rubyContent = _content;
}
public int startIndex { get; }
public string rubyContent { get; }
public int endIndex { get; set; }
}
2.匹配<>标签内的文本信息
下方代码功能是去除match文本中包含的<>字符,得到标签内的信息label。
string label = match.Value.Substring(1, match.Length - 2);
3.标签内信息的存储逻辑
(1)读取<float>格式标签内容
//自定义延迟打印标签【规定<float>】并记录在打印间隔标签字典中
if (float.TryParse(label, out float result))
IntervalDictionary[match.Index - 1] = result;
此处由于标签内部就是一个float类型的数据,所以我们可以直接就爱那个标签记录在我们的打印间隔标签字典中
(2)读取<r=" "></r>格式标签内容
//自定义注释标签【规定<r="">】录入注释列表
//注释标签开启
if (Regex.IsMatch(label, "^r=.+")) //^:表示字符串的开头。这个符号确保匹配的字符串必须从开头开始
rubyList.Add(new RubyData(match.Index, label.Substring(2)));
//注释标签终止
else if (Regex.IsMatch(label, "/r"))
{
if (rubyList.Count > 0)
rubyList[rubyList.Count - 1].endIndex = match.Index - 1;
}
4.去除标签文本
由于我们在用户文本中含有<>的类html标签,但是最终是不需要显示的,所以我们还需要在用户文本的基础上去除我们的标签(替换为空字符串),才能得到最终的渲染文本。
pattern = @"(<(\d+)(\.\d+)?>)|(</r>)|(<r=.*?>)";//使用 @ 前缀可以避免在字符串中使用转义字符
//将处理文本中符合pattern规则的字符串 替换为空字符串""
processingText = Regex.Replace(processingText, pattern, "");
完整源码见后文。
三.实现自定义文本效果
1.延迟打印功能
现在我们已经可以存储文本中的自定义标签内的信息,我们现在来实现一下文本的自定义效果。
我们前面已经实现了打字机的效果,实际上的延时打印功能,就是在打字函数的携程循环中等待的迭代函数。
if (selfPreprocessor.IntervalDictionary.TryGetValue(typingIndex, out float result))
yield return new WaitForSecondsRealtime(result);
else
yield return new WaitForSecondsRealtime(defalutInterval);
上句代码用来判断文本中当前打印位置处会否是延迟标签所在位置。如果恰好是字典中延迟标签的位置,则返回字典中的的当前位置键对应的float值作为输入执行携程延迟函数。否则按照默认间隔执行打印延迟。
2.注释功能
我们具体会用到两个方法。
(1)生成并设置注释
void SetRubyText(RubyData data)
{
GameObject ruby = Instantiate(Resources.Load<GameObject>("RubyText"), transform);
ruby.GetComponent<TextMeshProUGUI>().SetText(data.rubyContent);
ruby.GetComponent<TextMeshProUGUI>().color = textInfo.characterInfo[data.startIndex].color;
ruby.transform.localPosition = (textInfo.characterInfo[data.startIndex].topLeft + textInfo.characterInfo[data.endIndex].topRight) / 2 - new Vector3(0, 10, 0);
}
这里我们传入一个Rubydata实例,设置预制件文本,颜色及位置。
(2)判断当前位置是否是注释的起始位
/// <summary>
/// 读取一个位置是否就是Ruby起始位
/// </summary>
/// <param name="index"></param>
/// <param name="data"></param>
/// <returns></returns>
public bool TryGetRubyText(int index, out RubyData data)
{
data = new RubyData(0, "");
foreach (RubyData item in rubyList)
{
if (item.startIndex == index)
{
data = item;
return true;
}
}
return false;
}
}
每打印到一个位置,我们都需要判断当前位置是否是注释标签的起始位。如果是,将调用生成注释预制件的函数。
四.改进文本显示逻辑
现在我们去AdvancedText类中改进一下前面实现的文本显示逻辑。
增添字段
Coroutine typingCor;//存储打字携程,易于中断
Action OnFinish; //文本打印结束的回调委托
这里的携程字段使用来记录我们的打字机携程的,实际情况中我们会遇到 打断正在打印中文本 的需求。存储到一个携程变量中将易于管理。
增添枚举
我们使用一个枚举来决定文本以什么形式显示。(见下方)
public enum E_DisplayType
{
Defalut,Fading,Typing
}
Defalut模式:文本采用 整体直接显示。
Fading模式:文本采用 整体淡入显示。
Typing模式:文本采用 打字机淡入效果显示。
我们现在实现一下各种打印样式的函数。
Defalut模式显示方法
/// <summary>
/// 文本整体直接显示
/// </summary>
void DefalutDisplay(Action action = null)
{
for (int i = 0; i < m_characterCount; i++)
SetSingleCharacterAlpha(i, 255);
action?.Invoke();
}
Fading模式显示方法
/// <summary>
/// 文本整体淡入显示
/// </summary>
void FadingDisplay(float fadeDuration,Action action=null)
{
for (int i = 0; i < m_characterCount; i++)
StartCoroutine(FadeInCharacter(i, fadeDuration));
action?.Invoke();
}
Typing模式显示方法
/// <summary>
/// 文本打字机显示
/// </summary>
/// <returns></returns>
IEnumerator TypingDisplay(float fadeDuration,Action action = null)
{
ForceMeshUpdate();
for (int i = 0; i < m_characterCount; i++)
{
SetSingleCharacterAlpha(i, 0);
}
typingIndex = 0;
while (typingIndex < m_characterCount)
{
//SetSingleCharacterAlpha(typingIndex,255); //无淡入打字机效果
StartCoroutine(FadeInCharacter(typingIndex, fadeDuration)); //淡入打字机效果
if (selfPreprocessor.IntervalDictionary.TryGetValue(typingIndex, out float result))
yield return new WaitForSecondsRealtime(result);
else
yield return new WaitForSecondsRealtime(defalutInterval);
typingIndex++;
}
action?.Invoke();
}
文本显示方法
我们还需要一个函数作为暴露给外部的应用接口,客户端可以选择不同的文本显示样式。
public IEnumerator ShowText(string content, E_DisplayType displayType, float duration)
{
if (typingCor != null)
{
StopCoroutine(typingCor);
}
typingCor = null;
SetText(content);
yield return null;
TextDisAppear();
switch (displayType)
{
case E_DisplayType.Defalut:
DefalutDisplay();
SetAllRubyTexts();
break;
case E_DisplayType.Fading:
FadingDisplay(duration);
SetAllRubyTexts();
break;
case E_DisplayType.Typing:
typingCor = StartCoroutine(TypingDisplay(duration));
break;
default:
break;
}
}
五.功能测试
在测试脚本中我们调用AdvancedText实例的携程来显示文本。
StartCoroutine(advancedText.ShowText(content,displayType,duration));
编辑器内选择打印样式。
运行效果
这里我是做了一个按键输入功能,通过按键显示并开始打印文本,但调用文本打印的逻辑是一致的。如果大家对UI动效有有兴趣的话我后面可以出一期关于UI的代码框架及动效功能的解决方案。
六.完整源码
AdvancedTextPreprocessor类
using System.Collections.Generic;
using System.Text.RegularExpressions;
using TMPro;
namespace DialogueDemo
{
public class AdvancedTextPreprocessor : ITextPreprocessor
{
/// <summary>
/// 打印延迟字典【读取标签<float>,遇到将延迟float秒后打印后面内容】
/// </summary>
public Dictionary<int, float> IntervalDictionary = new Dictionary<int, float>();//自定义打印标签字典
public List<RubyData> rubyList = new List<RubyData>();
public string PreprocessText(string text)
{
IntervalDictionary.Clear();
string processingText = text;
string pattern = "<.*?>";//定义一个正则表达式 "<.*>" 用于匹配所有以 < 开头、> 结尾的字符串。贪婪模式,即它会匹配最远的 >
Match match = Regex.Match(processingText, pattern);//首次匹配,查找所有富文本标签
while (match.Success) //尝试匹配富文本标签
{
string label = match.Value.Substring(1, match.Length - 2);
//自定义延迟打印标签【规定<float>】并记录在打印间隔标签字典中
if (float.TryParse(label, out float result))
IntervalDictionary[match.Index - 1] = result;
//自定义注释标签【规定<r="">】录入注释列表
//注释标签开启
else if (Regex.IsMatch(label, "^r=.+")) //^:表示字符串的开头。这个符号确保匹配的字符串必须从开头开始
rubyList.Add(new RubyData(match.Index, label.Substring(2)));
//注释标签终止
else if (Regex.IsMatch(label, "/r"))
{
if (rubyList.Count > 0)
rubyList[rubyList.Count - 1].endIndex = match.Index - 1;
}
processingText = processingText.Remove(match.Index, match.Length);//读取此打印间隔标签后,删除此标签
if (Regex.IsMatch(label, "^sprite.+")) //如果标签格式是精灵,需要一个占位符
processingText.Insert(match.Index, "*");
match = Regex.Match(processingText, pattern);//继续匹配,查找下一个标签
}
processingText = text;
//正则表达式概念
// . 代表任意字符
//*代表前一个字符出现0次或多次
//+代表前一个字符出现1次或多次
//?代表前一个字符出现0次或1次
pattern = @"(<(\d+)(\.\d+)?>)|(</r>)|(<r=.*?>)";//使用 @ 前缀可以避免在字符串中使用转义字符
//简单解释:本句代码实现了读取<>中的整数或小数的功能
/* (\d +):
\d + 是一个数字匹配模式,它匹配一个或多个数字字符。+表示前面的模式(数字)可以匹配一个或多个字符。
() 是捕获组的标记,这样 \d + 匹配到的数字部分就会被捕获到组中,可以在后续处理中使用。
(\.\d +)?:
\. 匹配一个字面上的点(.)。点号是一个元字符,在正则中表示任意字符,但在这里需要加 \ 进行转义,表示字面上的点号。
\d + 匹配一个或多个数字,表示小数点后面的部分。
() ? 表示这个捕获组是可选的,即小数部分不是必需的。如果没有小数部分,这一部分会被忽略。*/
//将处理文本中符合pattern规则的字符串 替换为 后面的字符串
processingText = Regex.Replace(processingText, pattern, "");
return processingText;
}
/// <summary>
/// 读取一个位置是否就是Ruby起始位
/// </summary>
/// <param name="index"></param>
/// <param name="data"></param>
/// <returns></returns>
public bool TryGetRubyText(int index, out RubyData data)
{
data = new RubyData(0, "");
foreach (RubyData item in rubyList)
{
if (item.startIndex == index)
{
data = item;
return true;
}
}
return false;
}
}
/// <summary>
/// 注释数据
/// </summary>
public class RubyData
{
public RubyData(int _startIndex, string _content)
{
startIndex = _startIndex;
rubyContent = _content;
}
public int startIndex { get; }
public string rubyContent { get; }
public int endIndex { get; set; }
}
}
AdvancedText类
using System;
using System.Collections;
using TMPro;
using UnityEngine;
namespace DialogueDemo
{
public class AdvancedText : TextMeshProUGUI
{
int typingIndex;
float defalutInterval = 0.08f;
Coroutine typingCor;//存储打字携程,易于中断
Action OnFinish;
AdvancedTextPreprocessor selfPreprocessor => (AdvancedTextPreprocessor)textPreprocessor;
private void Init()
{
SetText("");
ClearRubyText();
}
public AdvancedText()
{
textPreprocessor = new AdvancedTextPreprocessor();
}
public void TextDisAppear()
{
for (int i = 0; i < m_characterCount; i++)
SetSingleCharacterAlpha(i, 0);
}
public IEnumerator ShowText(string content, E_DisplayType displayType, float duration)
{
if (typingCor != null)
{
StopCoroutine(typingCor);
}
typingCor = null;
SetText(content);
yield return null;
TextDisAppear();
switch (displayType)
{
case E_DisplayType.Defalut:
DefalutDisplay();
SetAllRubyTexts();
break;
case E_DisplayType.Fading:
FadingDisplay(duration);
SetAllRubyTexts();
break;
case E_DisplayType.Typing:
typingCor = StartCoroutine(TypingDisplay(duration));
break;
default:
break;
}
}
/// <summary>
/// 直接显示
/// </summary>
void DefalutDisplay(Action action = null)
{
for (int i = 0; i < m_characterCount; i++)
SetSingleCharacterAlpha(i, 255);
action?.Invoke();
}
/// <summary>
/// 整体淡入
/// </summary>
void FadingDisplay(float fadeDuration,Action action=null)
{
for (int i = 0; i < m_characterCount; i++)
StartCoroutine(FadeInCharacter(i, fadeDuration));
action?.Invoke();
}
/// <summary>
/// 打字机显示
/// </summary>
/// <returns></returns>
IEnumerator TypingDisplay(float fadeDuration,Action action = null)
{
ForceMeshUpdate();
for (int i = 0; i < m_characterCount; i++)
{
SetSingleCharacterAlpha(i, 0);
}
typingIndex = 0;
while (typingIndex < m_characterCount)
{
//SetSingleCharacterAlpha(typingIndex,255); //无淡入打字机效果
StartCoroutine(FadeInCharacter(typingIndex, fadeDuration)); //淡入打字机效果
if (selfPreprocessor.IntervalDictionary.TryGetValue(typingIndex, out float result))
yield return new WaitForSecondsRealtime(result);
else
yield return new WaitForSecondsRealtime(defalutInterval);
typingIndex++;
}
action?.Invoke();
}
/// <summary>
/// 设置单字符的透明度(每个字符都是由网格(含4个顶点)渲染)
/// </summary>
/// <param name="index"></param>
/// <param name="newAlpha">newalpha范围为0~255</param>
void SetSingleCharacterAlpha(int index, byte newAlpha)
{
TMP_CharacterInfo character = textInfo.characterInfo[index];//获取文本内容索引下的单个字符
if (!character.isVisible)
return;
int matIndex = character.materialReferenceIndex;//获取字符材质索引
int vertexIndex = character.vertexIndex;//获取字符顶点索引
for (int i = 0; i < 4; i++)
{
textInfo.meshInfo[matIndex].colors32[vertexIndex + i].a = newAlpha;
}
UpdateVertexData();//更新顶点数据
}
/// <summary>
/// 单字符淡入
/// </summary>
/// <param name="index"></param>
/// <param name="duration"></param>
/// <returns></returns>
IEnumerator FadeInCharacter(int index, float duration)
{
//如果找到Ruby起始位,设置Ruby预制件
Debug.Log(selfPreprocessor.TryGetRubyText(index, out var data1));
if (selfPreprocessor.TryGetRubyText(index, out RubyData data))
SetRubyText(data);
if (duration <= 0)
SetSingleCharacterAlpha(index, 255);
else
{
float timer = 0;
while (timer < duration)
{
timer = Mathf.Min(duration, timer + Time.unscaledDeltaTime);
SetSingleCharacterAlpha(index, (byte)(255 * (timer / duration)));
yield return null;
}
}
}
void SetRubyText(RubyData data)
{
GameObject ruby = Instantiate(Resources.Load<GameObject>("RubyText"), transform);
ruby.GetComponent<TextMeshProUGUI>().SetText(data.rubyContent);
ruby.GetComponent<TextMeshProUGUI>().color = textInfo.characterInfo[data.startIndex].color;
ruby.transform.localPosition = (textInfo.characterInfo[data.startIndex].topLeft + textInfo.characterInfo[data.endIndex].topRight) / 2 - new Vector3(0, 10, 0);
}
/// <summary>
/// 清除当前的所有注释
/// </summary>
void ClearRubyText()
{
foreach (var item in GetComponentsInChildren<TextMeshProUGUI>())
{
if (item != this)
Destroy(item.gameObject);
}
}
/// <summary>
/// 用于跳过对话直接显示所有注释
/// </summary>
void SetAllRubyTexts()
{
foreach (var item in selfPreprocessor.rubyList)
{
SetRubyText(item);
}
}
}
}
七.尾声
在 Unity 的 TextMeshPro中,文本标签(如 <color=red>...</color>)的处理方式不同于我们在自定义 AdvancedTextPreprocessor 类中的实现。TextMeshPro 之所以能正确处理 <color=red> </color> 等标签,原因在于它内部已经预先实现了一个功能强大的解析器,用于识别和处理各种富文本标签。TMP处理这些标签的方法是通过其 TextParser 类内部的规则和机制。具体的实现位于 TextMeshPro 的源码中,这些规则和方法是在 TextMeshPro 的组件中预定义的。当你将含有富文本标签的字符串传递给 TMP_Text 组件时,TextMeshPro 会自动解析这些标签并应用对应的样式。这与 AdvancedTextPreprocessor 类不同,后者是我们自己实现的自定义处理器,它仅仅处理我们通过正则表达式解析并手动处理的标签(例如延迟打印标签、注释标签等),并不会干预 TextMeshPro 内部已经定义的标准标签处理。
本篇完