Unity进阶:基于TextMeshPro实现带淡入效果的打字机脚本

1. 项目概述与核心价值

在Unity里做对话系统,新手和老手之间往往就差那么几个“感觉”。一个最直观的“感觉”就是文字呈现方式。直接把一整段话“啪”地一下怼到屏幕上,和让文字像老式打字机一样,一个字符一个字符地、带着呼吸感地浮现出来,玩家的沉浸体验是天差地别的。这个“打字机效果”(Typewriter Effect)几乎是现代叙事驱动型游戏的标配。

但很多教程止步于用Unity自带的UI Text组件实现基础打字机,效果生硬,功能单一。今天要聊的,是它的“进阶版”——基于TextMeshPro(TMP)实现带淡入效果的打字机。为什么非得是TMP?因为Unity原生的UI Text在字体渲染、排版丰富度(尤其是混排图文、超链接)和性能上,已经被TMP全面超越。对于追求文本表现力的游戏,TMP是事实上的标准选择。

这个项目要解决的,不仅仅是“让字蹦出来”。它要解决的是:如何让每个字在“蹦出来”的瞬间,不是生硬地出现,而是像从背景中逐渐显现(淡入),并且整个过程是可控的(速度、暂停、跳过、事件回调)。最终,我会附上一个经过实战打磨的、功能完整的C#脚本,你可以直接拿去用在你的项目里,或者以此为骨架进行二次开发。

2. 核心思路与方案选型

实现一个打字机效果,最核心的思路就是分帧渲染。我们不能在一帧内把整段文本设置好,而是需要在一段连续的时间内,每帧增加一个或几个字符的显示。

2.1 基础方案对比:协程 vs. Update

最常见的两种实现载体是协程(Coroutine)Update方法

  • 协程方案:利用yield return new WaitForSeconds(interval)来等待每个字符的显示间隔。代码结构清晰,时序控制简单直观,非常适合这种“等待-执行”的循环任务。它天然地将逻辑与帧率解耦,你设定每0.05秒显示一个字,那无论帧率是30还是60,显示速度都是恒定的。
  • Update方案:在每帧中累积时间,根据累积时间计算当前应显示的字符数。这种方式更贴近引擎的底层循环,但需要自己处理时间计算,代码稍显繁琐,且在时间缩放(Time.timeScale)为0时也会停止。

我们的选择是协程。理由很充分:对于打字机这种强时序、离散事件驱动的功能,协程的写法更符合直觉,易于阅读和维护。我们可以轻松地在字符间插入等待,也可以方便地挂起和继续。

2.2 淡入效果的关键:逐字顶点动画

基础打字机只控制文本的“可见字符数”。而淡入效果需要控制每个字符的透明度(Alpha)。TMP的强大之处在于,它允许我们访问和修改文本网格中每个字符的顶点信息。

核心思路如下:

  1. 我们通过一个协程逐步增加TMP_Text.maxVisibleCharacters属性来控制显示多少个字。
  2. 在每次增加显示字符后,立即遍历当前所有可见字符
  3. 对于每个字符,获取它的顶点数据(一个包含4个顶点的数组,代表一个字符的四边形)。
  4. 修改这些顶点颜色(Color32)的Alpha值,根据该字符“已显示的时间”计算出一个从0到255的过渡值,从而实现淡入。
  5. 最后,调用TMP_Text.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32)来强制更新网格颜色数据,使修改生效。

这里的关键在于,淡入动画是逐字独立的。每个字符从完全透明(Alpha=0)开始,在它“出生”后的一小段时间内(例如0.2秒)过渡到完全不透明(Alpha=255)。这样,当一段文字在打印时,你会看到最前面的字已经清晰,中间的字半透明,而最新的字刚刚开始显现,形成一种平滑的波浪式推进效果,视觉上非常舒服。

2.3 功能完备性设计

一个工业级的打字机脚本不能只有显示功能。我们必须考虑实际游戏中的交互需求:

  • 速度控制:支持全局字符间隔时间设置。
  • 加速与跳过:玩家按住某个键(如空格键或鼠标左键)时,打字速度应倍增;玩家单击该键时,应瞬间完成当前段落的显示。
  • 音效支持:每个字符出现时,可以触发一个打字音效(对于不同角色或情绪,可能还需要不同的音效)。
  • 事件回调:当开始显示、每个字符显示、完成显示时,应能触发UnityEvent或C# Action,方便其他系统(如音频、任务日志、角色表情动画)进行联动。
  • 富文本兼容:必须正确处理TMP的富文本标签(如<color=#FF0000>红色</color><b>粗体</b>),确保标签本身不被当作字符显示出来,且不影响其包裹内容的样式。

我们的脚本将围绕这些需求进行构建。

3. 核心脚本拆解与实现

下面,我将逐步拆解这个名为AdvancedTypewriterEffect的核心脚本。我会先给出关键代码片段并解释,最后提供完整脚本。

3.1 脚本结构与属性定义

首先,我们创建一个继承自MonoBehaviour的类,并声明所需的公共变量和私有字段。

using UnityEngine; using UnityEngine.Events; using TMPro; using System.Collections; using System.Text.RegularExpressions; [RequireComponent(typeof(TMP_Text))] public class AdvancedTypewriterEffect : MonoBehaviour { [Header("基础设置")] public float charactersPerSecond = 30f; // 打字速度,字符/秒 [Tooltip("启用后,每个字符的淡入时间固定。禁用则淡入时间与字符间隔关联。")] public bool useFixedFadeTime = true; [SerializeField] private float fadeDuration = 0.15f; // 每个字符的淡入时长(秒) [Header("交互控制")] public KeyCode speedUpKey = KeyCode.Space; public float speedUpMultiplier = 3.0f; // 加速时的倍率 public bool canSkip = true; [Header("音效")] public AudioClip typeSound; public AudioSource audioSource; // 可指定独立AudioSource,为空则尝试获取组件上的 [Header("事件")] public UnityEvent onTypingStarted; public UnityEvent onCharacterPrinted; public UnityEvent onTypingCompleted; // 私有字段 private TMP_Text m_TextComponent; private string m_OriginalText; private Coroutine m_TypewriterCoroutine; private bool m_IsTyping = false; private float m_CurrentInterval; // 计算出的实际字符间隔 private int m_TotalVisibleCharacters; // 去除富文本标签后的实际字符数 // 用于跳过富文本标签的正则表达式(简化版) private static readonly Regex s_RichTextRegex = new Regex(@"<[^>]*>"); }

属性解析

  • charactersPerSecond: 这是更直观的速度设置。字符间隔m_CurrentInterval会根据它计算(间隔 = 1.0f / 速度)。
  • useFixedFadeTimefadeDuration: 这是淡入效果的控制核心。如果启用固定淡入时间,无论打字快慢,每个字的淡入动画时长都是fadeDuration。如果禁用,淡入时间会自动与字符间隔时间挂钩(例如设为间隔时间的80%),确保动画节奏与打字节奏同步。
  • 事件(UnityEvent): 提供了无需硬编码的脚本通信方式,在Inspector面板里就能拖拽关联其他游戏对象的方法,非常灵活。

3.2 初始化与启动打字

StartAwake中获取组件引用,并提供一个公共方法来启动打字过程。

void Awake() { m_TextComponent = GetComponent<TMP_Text>(); if (audioSource == null) audioSource = GetComponent<AudioSource>(); } /// <summary> /// 开始播放打字机效果。 /// </summary> /// <param name="textToType">要打印的文本。如果为null或空,则使用TMP组件当前的文本。</param> public void StartTyping(string textToType = null) { // 如果正在打字,先停止之前的协程 if (m_IsTyping && m_TypewriterCoroutine != null) { StopCoroutine(m_TypewriterCoroutine); } // 重置文本状态 m_TextComponent.maxVisibleCharacters = 0; m_TextComponent.ForceMeshUpdate(); // 强制更新网格以获取正确的文本信息 // 设置要打印的文本 if (!string.IsNullOrEmpty(textToType)) { m_TextComponent.text = textToType; m_TextComponent.ForceMeshUpdate(); } m_OriginalText = m_TextComponent.text; // 计算去除富文本标签后的实际字符数,用于控制循环 m_TotalVisibleCharacters = s_RichTextRegex.Replace(m_OriginalText, "").Length; // 计算基础间隔 m_CurrentInterval = 1.0f / charactersPerSecond; // 触发开始事件 onTypingStarted?.Invoke(); m_IsTyping = true; // 启动打字协程 m_TypewriterCoroutine = StartCoroutine(TypewriterCoroutine()); }

关键点

  1. ForceMeshUpdate(): 在修改TMP文本或开始处理前,调用此方法确保文本信息网格是最新的,这对于后续获取正确的字符信息至关重要。
  2. s_RichTextRegex.Replace: 这是一个简单的预处理。我们用一个正则表达式匹配所有<...>格式的标签并将其移除,得到纯文本的长度。这样在协程循环时,我们递增的“可见字符数”是针对TMP渲染的,而循环终止条件判断的是“实际显示的字符数”,避免了因标签计数导致提前结束或延迟结束的问题。这是一个常见的坑。

3.3 打字协程的核心逻辑

这是脚本的“心脏”。协程将逐步增加可见字符,并处理淡入效果。

private IEnumerator TypewriterCoroutine() { TMP_TextInfo textInfo = m_TextComponent.textInfo; int totalCharacters = textInfo.characterCount; // TMP解析出的字符总数(含不可见字符) int visibleCount = 0; int printedVisibleCount = 0; // 已打印的“实际字符”数 // 预先将所有字符设置为完全透明 SetAllCharactersAlpha(0); m_TextComponent.maxVisibleCharacters = 0; // 存储每个字符的“出生时间” float[] characterBirthTime = new float[totalCharacters]; for (int i = 0; i < totalCharacters; i++) characterBirthTime[i] = -1f; float currentTime = Time.time; while (printedVisibleCount < m_TotalVisibleCharacters) { // 处理加速与跳过输入 float interval = m_CurrentInterval; if (Input.GetKey(speedUpKey)) { interval /= speedUpMultiplier; } if (canSkip && Input.GetKeyDown(speedUpKey)) { // 立即显示所有文本 m_TextComponent.maxVisibleCharacters = totalCharacters; SetAllCharactersAlpha(255); m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); printedVisibleCount = m_TotalVisibleCharacters; break; } // 等待下一个字符的间隔时间 yield return new WaitForSeconds(interval); // 增加一个可见字符(从TMP的角度) visibleCount++; m_TextComponent.maxVisibleCharacters = visibleCount; // 记录新显示字符的“出生时间” int currentCharIndex = visibleCount - 1; if (currentCharIndex < totalCharacters) { // 只有非空白、非标签的字符才算作“打印”了一个实际字符 TMP_CharacterInfo charInfo = textInfo.characterInfo[currentCharIndex]; if (charInfo.character != 0 && !charInfo.isVisible) // 0是空字符,通常由标签产生 { // 如果是不可见字符(如富文本标签),则不计入打印计数,也不记录出生时间 // 但需要继续循环,尝试显示下一个字符 continue; } characterBirthTime[currentCharIndex] = Time.time; printedVisibleCount++; // 实际打印字符数+1 // 播放音效 PlayTypeSound(); // 触发字符打印事件 onCharacterPrinted?.Invoke(); } // 更新所有已出生字符的淡入状态 UpdateCharactersAlpha(characterBirthTime, visibleCount); currentTime = Time.time; } // 循环结束,打字完成 OnTypingFinished(); }

逻辑详解

  1. 双计数器visibleCount对应TMP的maxVisibleCharacters,每次循环+1。printedVisibleCount对应过滤掉富文本标签后的“实际字符”,只有遇到真正渲染的字符时才+1。这确保了即使文本中有很多样式标签,打字节奏也不会被打乱。
  2. 字符出生时间表characterBirthTime数组记录了每个TMP字符索引开始显示的时间点,初始为-1。当某个字符变为可见时,我们将其时间戳设为当前Time.time
  3. 输入处理: 在循环开始时检查加速键。GetKey用于持续加速,GetKeyDown用于触发跳过。跳过时,我们直接将可见字符设为最大,并将所有字符Alpha值设为255(完全不透明),然后跳出循环。
  4. 淡入更新: 每次显示一个新字符后,调用UpdateCharactersAlpha,根据每个字符的“已存活时间”(当前时间 - 出生时间)来更新其透明度。

3.4 淡入效果的具体实现

现在来看最关键的淡入动画部分。

/// <summary> /// 根据字符的“出生时间”更新其透明度。 /// </summary> private void UpdateCharactersAlpha(float[] birthTimes, int upToIndex) { float fadeTime = useFixedFadeTime ? fadeDuration : (m_CurrentInterval * 0.8f); float currentTime = Time.time; TMP_TextInfo textInfo = m_TextComponent.textInfo; Color32[] newVertexColors; Color32 c0; // 遍历当前所有可见的字符 for (int i = 0; i < upToIndex && i < textInfo.characterCount; i++) { TMP_CharacterInfo charInfo = textInfo.characterInfo[i]; if (!charInfo.isVisible || birthTimes[i] < 0) continue; // 跳过不可见或未出生的字符 int materialIndex = charInfo.materialReferenceIndex; int vertexIndex = charInfo.vertexIndex; newVertexColors = textInfo.meshInfo[materialIndex].colors32; // 计算该字符的淡入进度 (0 到 1) float timeSinceBirth = currentTime - birthTimes[i]; float alphaProgress = Mathf.Clamp01(timeSinceBirth / fadeTime); byte targetAlpha = (byte)(Mathf.Lerp(0, 255, alphaProgress)); // 更新该字符四个顶点的颜色Alpha值 c0 = newVertexColors[vertexIndex]; c0.a = targetAlpha; newVertexColors[vertexIndex] = c0; c0 = newVertexColors[vertexIndex + 1]; c0.a = targetAlpha; newVertexColors[vertexIndex + 1] = c0; c0 = newVertexColors[vertexIndex + 2]; c0.a = targetAlpha; newVertexColors[vertexIndex + 2] = c0; c0 = newVertexColors[vertexIndex + 3]; c0.a = targetAlpha; newVertexColors[vertexIndex + 3] = c0; } // 告诉TMP更新顶点颜色数据 m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); } /// <summary> /// 将所有字符的透明度设置为指定值。 /// </summary> private void SetAllCharactersAlpha(byte alpha) { TMP_TextInfo textInfo = m_TextComponent.textInfo; for (int i = 0; i < textInfo.characterCount; i++) { TMP_CharacterInfo charInfo = textInfo.characterInfo[i]; if (!charInfo.isVisible) continue; int materialIndex = charInfo.materialReferenceIndex; int vertexIndex = charInfo.vertexIndex; Color32[] newVertexColors = textInfo.meshInfo[materialIndex].colors32; Color32 c0 = new Color32(newVertexColors[vertexIndex].r, newVertexColors[vertexIndex].g, newVertexColors[vertexIndex].b, alpha); newVertexColors[vertexIndex] = c0; newVertexColors[vertexIndex + 1] = c0; newVertexColors[vertexIndex + 2] = c0; newVertexColors[vertexIndex + 3] = c0; } }

技术细节

  • 顶点操作: TMP中每个字符由4个顶点构成一个四边形。charInfo.vertexIndex指向这个四边形第一个顶点的索引。我们需要同时修改这4个顶点的颜色。
  • 材质引用索引: 如果文本使用了多个材质(比如自定义字体图集),materialReferenceIndex指示当前字符属于哪个材质/网格。我们必须从对应的meshInfo数组中获取颜色数据。
  • UpdateVertexData: 这是点睛之笔。修改了顶点颜色数组后,必须调用此方法并传入TMP_VertexDataUpdateFlags.Colors32标志,才能将更改同步到实际渲染的网格上。忘记调用这一步,屏幕上什么都不会改变。
  • 淡入计算Mathf.Clamp01确保进度在0到1之间。使用Mathf.Lerp在0和255之间进行线性插值,得到平滑的Alpha过渡。

3.5 收尾与音效

打字完成或跳过时,需要清理状态并触发完成事件。

private void OnTypingFinished() { m_IsTyping = false; m_TypewriterCoroutine = null; onTypingCompleted?.Invoke(); } private void PlayTypeSound() { if (typeSound != null && audioSource != null) { // 避免音效重叠太密集,可以加一些随机音调或音量微调增加真实感 // audioSource.pitch = Random.Range(0.95f, 1.05f); audioSource.PlayOneShot(typeSound); } } /// <summary> /// 立即停止打字并显示全部文本。 /// </summary> public void FinishTypingImmediately() { if (m_TypewriterCoroutine != null) { StopCoroutine(m_TypewriterCoroutine); } m_TextComponent.maxVisibleCharacters = m_TextComponent.textInfo.characterCount; SetAllCharactersAlpha(255); m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); OnTypingFinished(); } /// <summary> /// 判断当前是否正在打字。 /// </summary> public bool IsTyping() { return m_IsTyping; }

FinishTypingImmediately方法提供了另一种从外部强制完成打字的方式,比如在对话选择枝出现时。

4. 使用示例与场景配置

将脚本挂载到带有TMP_Text组件的GameObject上(RequireComponent属性会自动添加)。在Inspector中配置参数:

  1. 基础设置Characters Per Second设为30-50比较舒适。Fade Duration设为0.1-0.2秒,淡入效果会比较明显。
  2. 交互: 通常将Speed Up Key设为SpaceSpeed Up Multiplier设为3.0到5.0。
  3. 音效: 将打字音效剪辑拖入Type Sound。如果对象上没有AudioSource,脚本会尝试获取,你也可以手动指定一个。
  4. 事件: 点击事件下方的+号,可以将其他对象的方法拖入。例如,On Character Printed可以关联到一个随机播放多个打字音效的脚本;On Typing Completed可以激活一个“继续”提示图标。

在代码中调用:

// 获取引用 AdvancedTypewriterEffect typewriter = dialogueText.GetComponent<AdvancedTypewriterEffect>(); // 开始打印一段新对话 typewriter.StartTyping("你好,旅行者。<color=#FFAA00>这个世界</color>正在等待你的探索...");

5. 常见问题、优化与避坑指南

在实际使用中,你可能会遇到以下问题,这里给出解决方案和优化思路。

5.1 富文本标签导致计时不准

这是最常见的问题。我们的脚本通过正则表达式预处理文本,计算纯文本长度作为终止条件,并在协程中跳过不可见字符,基本解决了这个问题。但需要注意,非常复杂的标签嵌套(如<color=red><b>文本</b></color>)可能被TMP解析为多个字符索引,我们的charInfo.isVisible判断通常能正确处理。如果遇到极端情况,可以尝试使用TMP_Text.GetParsedText()来获取处理后的文本进行分析。

5.2 性能考量与顶点更新优化

在每一帧(协程的每次循环后)都遍历所有可见字符并更新顶点颜色,如果文本很长(超过200字),可能会有性能压力。优化方法:

  • 增量更新: 在UpdateCharactersAlpha中,只更新“出生”时间在最近一两秒内的字符,更早的字符透明度早已是255,无需重复计算和设置。
  • 分帧更新: 如果单次更新顶点数过多,可以考虑将顶点更新操作分散到多帧完成,但这会增加代码复杂度,可能影响视觉效果的一致性。对于大多数对话场景,一次更新的字符数有限,直接更新的开销是可以接受的。

5.3 与对话系统集成

这个打字机脚本是一个“表现层”组件。一个完整的对话系统通常有数据层(对话树、JSON配置)、逻辑层(对话流转、条件分支)和表现层(UI、打字机、角色立绘动画)。建议的集成方式:

  1. 创建一个DialogueManager单例或管理器类,负责加载对话数据、管理当前对话状态。
  2. 当需要显示一句话时,DialogueManager调用AdvancedTypewriterEffect.StartTyping(text)
  3. 监听打字机的onTypingCompleted事件。当一句话打完时,DialogueManager显示“继续”按钮,等待玩家输入。
  4. 玩家按下继续键后,DialogueManager处理下一句对话或弹出选项。

5.4 扩展功能思路

  • 情绪化打字: 可以为不同的角色或情绪预设不同的打字速度、淡入时间和音效。在StartTyping方法中增加参数或通过配置文件读取。
  • 震动效果: 在字符出现时,给其顶点位置添加微小的随机偏移,模拟老旧打字机的震动感。这需要在修改顶点颜色时,同步修改顶点位置 (meshInfo.vertices)。
  • 光标闪烁: 在打字过程中,在文本末尾显示一个闪烁的光标。可以在协程中,在每次更新文本后,动态地在文本末尾附加一个光标字符(如“_”或“|”),并单独控制其闪烁动画。打字完成后移除或停止闪烁。
  • 多语言与字体回退: TMP本身支持字体回退。确保你的TMP字体资源包含了所需语言的字符集。对于像中文、日文这样字符集庞大的语言,要特别注意字体文件的大小和内存占用。

5.5 一个关键的“坑”:TextMeshProUGUI 与 TextMeshPro

Unity的TMP有两个主要组件:TextMeshProUGUI(用于UI系统)和TextMeshPro(用于3D世界空间或2D SpriteRenderer)。我们的脚本通过TMP_Text这个基类来引用,两者都兼容。但要注意:

  • 如果你的文本是UI元素,请使用TextMeshProUGUI
  • 如果你需要在3D世界中显示漂浮文字,则使用TextMeshPro
  • 两者在顶点数据的获取和更新上API一致,所以我们的脚本无需修改即可通用。

最后,附上完整的脚本代码。你可以直接复制到一个新的C#文件中,命名为AdvancedTypewriterEffect.cs,然后拖到你的TMP文本对象上开始使用。记住,好的工具是打磨出来的,根据你的项目需求调整参数和扩展功能,让它成为你对话系统里最得力的那一环。

// AdvancedTypewriterEffect.cs 完整脚本 using UnityEngine; using UnityEngine.Events; using TMPro; using System.Collections; using System.Text.RegularExpressions; [RequireComponent(typeof(TMP_Text))] public class AdvancedTypewriterEffect : MonoBehaviour { [Header("基础设置")] public float charactersPerSecond = 30f; [Tooltip("启用后,每个字符的淡入时间固定。禁用则淡入时间与字符间隔关联。")] public bool useFixedFadeTime = true; [SerializeField] private float fadeDuration = 0.15f; [Header("交互控制")] public KeyCode speedUpKey = KeyCode.Space; public float speedUpMultiplier = 3.0f; public bool canSkip = true; [Header("音效")] public AudioClip typeSound; public AudioSource audioSource; [Header("事件")] public UnityEvent onTypingStarted; public UnityEvent onCharacterPrinted; public UnityEvent onTypingCompleted; private TMP_Text m_TextComponent; private string m_OriginalText; private Coroutine m_TypewriterCoroutine; private bool m_IsTyping = false; private float m_CurrentInterval; private int m_TotalVisibleCharacters; private static readonly Regex s_RichTextRegex = new Regex(@"<[^>]*>"); void Awake() { m_TextComponent = GetComponent<TMP_Text>(); if (audioSource == null) audioSource = GetComponent<AudioSource>(); } public void StartTyping(string textToType = null) { if (m_IsTyping && m_TypewriterCoroutine != null) { StopCoroutine(m_TypewriterCoroutine); } m_TextComponent.maxVisibleCharacters = 0; m_TextComponent.ForceMeshUpdate(); if (!string.IsNullOrEmpty(textToType)) { m_TextComponent.text = textToType; m_TextComponent.ForceMeshUpdate(); } m_OriginalText = m_TextComponent.text; m_TotalVisibleCharacters = s_RichTextRegex.Replace(m_OriginalText, "").Length; m_CurrentInterval = 1.0f / charactersPerSecond; onTypingStarted?.Invoke(); m_IsTyping = true; m_TypewriterCoroutine = StartCoroutine(TypewriterCoroutine()); } private IEnumerator TypewriterCoroutine() { TMP_TextInfo textInfo = m_TextComponent.textInfo; int totalCharacters = textInfo.characterCount; int visibleCount = 0; int printedVisibleCount = 0; SetAllCharactersAlpha(0); m_TextComponent.maxVisibleCharacters = 0; float[] characterBirthTime = new float[totalCharacters]; for (int i = 0; i < totalCharacters; i++) characterBirthTime[i] = -1f; float currentTime = Time.time; while (printedVisibleCount < m_TotalVisibleCharacters) { float interval = m_CurrentInterval; if (Input.GetKey(speedUpKey)) { interval /= speedUpMultiplier; } if (canSkip && Input.GetKeyDown(speedUpKey)) { m_TextComponent.maxVisibleCharacters = totalCharacters; SetAllCharactersAlpha(255); m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); printedVisibleCount = m_TotalVisibleCharacters; break; } yield return new WaitForSeconds(interval); visibleCount++; m_TextComponent.maxVisibleCharacters = visibleCount; int currentCharIndex = visibleCount - 1; if (currentCharIndex < totalCharacters) { TMP_CharacterInfo charInfo = textInfo.characterInfo[currentCharIndex]; if (charInfo.character == 0 || !charInfo.isVisible) { continue; } characterBirthTime[currentCharIndex] = Time.time; printedVisibleCount++; PlayTypeSound(); onCharacterPrinted?.Invoke(); } UpdateCharactersAlpha(characterBirthTime, visibleCount); currentTime = Time.time; } OnTypingFinished(); } private void UpdateCharactersAlpha(float[] birthTimes, int upToIndex) { float fadeTime = useFixedFadeTime ? fadeDuration : (m_CurrentInterval * 0.8f); float currentTime = Time.time; TMP_TextInfo textInfo = m_TextComponent.textInfo; Color32[] newVertexColors; Color32 c0; for (int i = 0; i < upToIndex && i < textInfo.characterCount; i++) { TMP_CharacterInfo charInfo = textInfo.characterInfo[i]; if (!charInfo.isVisible || birthTimes[i] < 0) continue; int materialIndex = charInfo.materialReferenceIndex; int vertexIndex = charInfo.vertexIndex; newVertexColors = textInfo.meshInfo[materialIndex].colors32; float timeSinceBirth = currentTime - birthTimes[i]; float alphaProgress = Mathf.Clamp01(timeSinceBirth / fadeTime); byte targetAlpha = (byte)(Mathf.Lerp(0, 255, alphaProgress)); c0 = newVertexColors[vertexIndex]; c0.a = targetAlpha; newVertexColors[vertexIndex] = c0; c0 = newVertexColors[vertexIndex + 1]; c0.a = targetAlpha; newVertexColors[vertexIndex + 1] = c0; c0 = newVertexColors[vertexIndex + 2]; c0.a = targetAlpha; newVertexColors[vertexIndex + 2] = c0; c0 = newVertexColors[vertexIndex + 3]; c0.a = targetAlpha; newVertexColors[vertexIndex + 3] = c0; } m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); } private void SetAllCharactersAlpha(byte alpha) { TMP_TextInfo textInfo = m_TextComponent.textInfo; for (int i = 0; i < textInfo.characterCount; i++) { TMP_CharacterInfo charInfo = textInfo.characterInfo[i]; if (!charInfo.isVisible) continue; int materialIndex = charInfo.materialReferenceIndex; int vertexIndex = charInfo.vertexIndex; Color32[] newVertexColors = textInfo.meshInfo[materialIndex].colors32; Color32 c0 = new Color32(newVertexColors[vertexIndex].r, newVertexColors[vertexIndex].g, newVertexColors[vertexIndex].b, alpha); newVertexColors[vertexIndex] = c0; newVertexColors[vertexIndex + 1] = c0; newVertexColors[vertexIndex + 2] = c0; newVertexColors[vertexIndex + 3] = c0; } } private void OnTypingFinished() { m_IsTyping = false; m_TypewriterCoroutine = null; onTypingCompleted?.Invoke(); } private void PlayTypeSound() { if (typeSound != null && audioSource != null) { audioSource.PlayOneShot(typeSound); } } public void FinishTypingImmediately() { if (m_TypewriterCoroutine != null) { StopCoroutine(m_TypewriterCoroutine); } m_TextComponent.maxVisibleCharacters = m_TextComponent.textInfo.characterCount; SetAllCharactersAlpha(255); m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); OnTypingFinished(); } public bool IsTyping() { return m_IsTyping; } }