1. 项目概述与核心价值最近在做一个独立游戏项目里面有不少剧情对话和旁白如果直接把大段文字“啪”一下全显示在屏幕上玩家大概率会直接跳过沉浸感大打折扣。这时候经典的“打字机效果”就成了提升叙事体验的利器。它让文字像被逐字敲出来一样配合音效能有效引导玩家的阅读节奏营造出更强的代入感。这个需求在Unity里实现最直接的自然就是基于UGUI的Text或TextMeshPro组件来搞。但需求往往没那么简单。策划可能会要求某些关键词高亮比如角色名、重要物品或者某些句子需要变色、加粗这就涉及到富文本Rich Text的支持。更麻烦的是有时剧情文本是分段加载的比如网络对话或者长剧情分页我们需要在已有文字的基础上继续“打”出新内容而不是清屏重来这就是“追加文字”功能。网上能找到的很多简单打字机脚本要么不支持富文本一用标签就乱套要么不支持追加每次都要重新初始化用起来束手束脚。所以我决定自己动手封装一个功能完善、性能可靠且易于集成的UGUI打字机效果组件。这个组件不仅要能流畅地逐字显示包含colorred.../color、b.../b、i.../i等标签的富文本还要能随时中断当前输出、追加新的文本内容并且提供播放速度控制、暂停、跳过等游戏内常用的交互控制。下面我就把从设计思路到代码实现再到实际应用和踩坑经验的完整过程分享出来。2. 核心设计思路与方案选型在动手写代码之前先得把核心问题拆解清楚选择最合适的技术路径。UGUI打字机的本质是在一段时间内动态改变UI Text组件所显示的字符串长度。2.1 基础实现方案对比最朴素的想法是每一帧在原始字符串上截取更长的子串进行赋值。例如有一个字符串“Hello World”我们用Coroutine协程控制第一次显示“H”等待0.1秒后显示“He”以此类推。这个方法简单直接但一旦遇到富文本问题就来了。比如字符串是“colorredHello/color World”如果你简单地截取前5个字符得到的是“colo”这根本不是有效的富文本标签会导致显示乱码colorred这个标签被破坏后面的所有文字颜色都可能出错。因此核心难点在于如何正确地解析和剥离富文本标签确保在逐字显示的过程中标签的完整性和有效性。我们不能破坏任何标签的结构。2.2 富文本处理的核心策略经过调研和测试我确定了两种主流策略标签剥离与重建在打字开始前将原始字符串里的所有富文本标签如colorred、/color解析出来记录下它们的位置和内容。然后得到一个纯文本的“内容字符串”。在打字过程中我们只在这个纯文本字符串上做截取。每显示一个字符都需要根据之前记录的标签信息在这个字符的“当前位置”上重新插入所有在该位置之前就应该生效的标签构建出一个新的、有效的富文本字符串再赋值给Text组件。利用Text组件的文本生成信息Unity的UGUI Text组件在渲染前会先对字符串进行解析生成一系列的UICharInfo字符信息和UILineInfo行信息。其中UICharInfo数组的长度就等于最终可见的字符数量富文本标签是不计入内的。我们可以通过Text.cachedTextGenerator或Text.GetParsedText()对于TextMeshPro是TMP_Text.textInfo.characterCount来获取这个纯净的字符数量。打字进度可以基于这个“可见字符数”来推进。方案一控制精度高可以知道每个字符对应的确切标签但实现复杂要自己写标签解析器容易有边缘情况处理不全。方案二巧妙地利用了Unity自身的文本渲染管线我们无需关心标签的具体位置只需要知道“第N个可见字符”对应原始字符串的哪个位置即可。我最终选择了方案二因为它实现更优雅完全依赖Unity自身的正确性避免了重复造轮子可能带来的解析错误。2.3 追加功能的实现思路追加功能意味着打字机对象需要维护一个“已显示内容”的缓冲区。当调用追加方法时不是替换整个文本而是将新文本连接到缓冲区末尾然后从缓冲区的“已显示长度”处开始继续执行打字动画。这里的关键在于状态管理打字机需要区分“初始播放”、“中途暂停后继续播放”、“追加播放”等不同状态。同时要确保在追加时新文本的富文本标签能与之前的内容正确衔接不会因为前一个标签未闭合而导致格式错误。这要求我们在设计数据结构时将“最终要显示的全部文本”和“当前已显示到的文本位置”分开存储。2.4 性能与扩展性考量协程 vs Update逐字动画自然想到用协程配合WaitForSeconds或WaitForSecondsRealtime。协程更清晰易于控制速度通过WaitForSeconds的间隔和暂停通过Coroutine控制。在非极端性能要求下协程是首选。组件化设计我们将功能封装成一个MonoBehaviour组件TypewriterEffect挂载在拥有Text或TextMeshProUGUI组件的GameObject上。通过公开的方法Play()Pause()Stop()AppendText()和事件OnCharacterTypedOnTypingCompleted来控制行为这样策划或其他程序员可以通过Inspector面板或简单代码调用进行交互。支持TextMeshPro虽然标题是UGUI但TextMeshProTMP现在是Unity UI文字渲染的事实标准效果更好功能更强尤其是富文本。我们的组件应该同时支持传统的UnityEngine.UI.Text和TMPro.TextMeshProUGUI通过泛型或类型判断来适配。基于以上思路我们有了一个清晰的技术蓝图一个基于协程利用Unity文本生成信息来安全处理富文本并内置缓冲区管理以支持追加功能的通用打字机组件。3. 核心代码实现与解析接下来我们进入具体的代码实现环节。我会创建一个名为TypewriterEffect的类并详细解释关键部分。3.1 组件定义与基础属性首先定义组件的序列化字段和公共属性以便在Inspector中配置。using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; using TMPro; using System.Collections; using System.Text; [RequireComponent(typeof(Text), typeof(TextMeshProUGUI))] // 简化实际需要选择其一 public class TypewriterEffect : MonoBehaviour { [Header(基础设置)] [SerializeField] private float charactersPerSecond 30f; // 每秒打字数 [SerializeField] private bool playOnAwake false; // 唤醒时自动播放 [SerializeField] private bool ignoreTimeScale false; // 是否忽略Time.timeScale [Header(音频)] [SerializeField] private AudioClip typingSound; // 打字音效 [SerializeField] private AudioSource audioSource; // 音频源可空 [Header(事件)] public UnityEvent onCharacterTyped; // 每打一个字触发 public UnityEvent onTypingStarted; public UnityEvent onTypingCompleted; // 内部状态 private Text uiText; private TextMeshProUGUI tmpText; private bool isUsingTMP false; private Coroutine typingCoroutine; private StringBuilder fullTextBuilder new StringBuilder(); // 存储完整文本用于追加 private int currentVisibleCharacterIndex 0; // 当前已显示到的可见字符索引 private bool isTypingActive false; }注意[RequireComponent]这里做了简化。实际上一个GameObject上不会同时有Text和TextMeshProUGUI。更健壮的做法是在Awake()或Start()中动态检测并决定使用哪一个或者创建两个版本的组件。为了清晰我们先按支持一种来写后面再讲适配。3.2 初始化与类型适配在Awake中我们尝试获取文本组件并确定类型。private void Awake() { uiText GetComponentText(); tmpText GetComponentTextMeshProUGUI(); if (tmpText ! null) { isUsingTMP true; // 确保TMP文本在开始时是空的除非我们想保留初始文本作为fullText的一部分 fullTextBuilder.Append(tmpText.text); tmpText.maxVisibleCharacters 0; // TMP控制显示字符数的关键属性 } else if (uiText ! null) { isUsingTMP false; fullTextBuilder.Append(uiText.text); uiText.text ; // 清空初始显示由打字机控制 } else { Debug.LogError(TypewriterEffect 需要 Text 或 TextMeshProUGUI 组件, this); enabled false; return; } if (playOnAwake fullTextBuilder.Length 0) { StartTyping(); } }这里有个关键点对于传统的UI.Text我们通过直接修改text属性来显示部分文本。而对于TextMeshProUGUI它有一个非常方便的maxVisibleCharacters属性设置为N它就只渲染前N个字符忽略富文本标签这完美契合了我们的方案二无需自己计算截取位置。3.3 核心打字协程这是组件的心脏。我们以支持TMP的版本为例进行讲解它的逻辑更清晰。private IEnumerator TypingRoutine(string textToType) { isTypingActive true; onTypingStarted?.Invoke(); // 计算每个字符的等待时间 float delay 1f / charactersPerSecond; WaitForSeconds wait ignoreTimeScale ? new WaitForSecondsRealtime(delay) : new WaitForSeconds(delay); // 重置并设置完整文本 if (isUsingTMP) { tmpText.text textToType; tmpText.ForceMeshUpdate(); // 关键强制立即生成网格和字符信息 currentVisibleCharacterIndex 0; tmpText.maxVisibleCharacters 0; } else { uiText.text ; currentVisibleCharacterIndex 0; } // 获取总可见字符数对于TMP int totalVisibleCharacters isUsingTMP ? tmpText.textInfo.characterCount : CalculateVisibleCharacterCount(textToType); while (currentVisibleCharacterIndex totalVisibleCharacters) { if (!isTypingActive) // 允许外部中断 { yield break; } currentVisibleCharacterIndex; UpdateTextDisplay(); // 播放音效 if (typingSound ! null audioSource ! null) { audioSource.PlayOneShot(typingSound); } onCharacterTyped?.Invoke(); yield return wait; } // 打字完成 FinishTyping(); } private void UpdateTextDisplay() { if (isUsingTMP) { tmpText.maxVisibleCharacters currentVisibleCharacterIndex; } else { // 对于UI.Text需要手动计算并设置子字符串 // 这里需要实现CalculateVisibleCharacterCount和GetVisibleSubstring uiText.text GetVisibleSubstring(fullTextBuilder.ToString(), currentVisibleCharacterIndex); } } private void FinishTyping() { isTypingActive false; typingCoroutine null; // 确保完全显示 if (isUsingTMP) { tmpText.maxVisibleCharacters tmpText.textInfo.characterCount; } else { uiText.text fullTextBuilder.ToString(); } onTypingCompleted?.Invoke(); }关键解析tmpText.ForceMeshUpdate()这行代码至关重要。在设置TMP的text属性后字符信息的生成可能不是立即完成的。调用此方法能确保textInfo.characterCount立即得到更新我们才能拿到正确的总字符数。textInfo.characterCount这就是TMP解析后所有可见字符不包括富文本标签的总数。我们的循环就基于这个数。maxVisibleCharacters通过递增这个属性TMP会自动处理富文本的显示。如果第N个字符在一个colorred标签内那么当它变为可见时自然会带有红色效果。我们完全不用操心标签的解析和重组。3.4 实现追加文字功能追加功能的核心是维护fullTextBuilder存储所有文本的StringBuilder和currentVisibleCharacterIndex当前显示进度。public void AppendText(string additionalText, bool startTypingImmediately true) { if (!isTypingActive) { // 如果当前没有在打字直接添加到完整文本末尾 fullTextBuilder.Append(additionalText); if (isUsingTMP) { tmpText.text fullTextBuilder.ToString(); tmpText.ForceMeshUpdate(); // 注意这里不改变maxVisibleCharacters所以不会立即显示新文本 } else { // UI.Text版本需要更复杂的处理来保持当前显示状态 // 暂时先更新text但显示截取部分 uiText.text GetVisibleSubstring(fullTextBuilder.ToString(), currentVisibleCharacterIndex); } if (startTypingImmediately) { StartTyping(); // 从当前进度继续打新字 } } else { // 如果正在打字一种策略是立即完成当前文本然后追加 // 另一种是排队。这里采用立即完成当前段落的策略体验较好 SkipToEndOfCurrentText(); // 递归调用此时isTypingActive为false AppendText(additionalText, startTypingImmediately); } } public void StartTyping() { if (typingCoroutine ! null) { StopCoroutine(typingCoroutine); } // 从当前存储的完整文本和进度开始 typingCoroutine StartCoroutine(TypingRoutine(fullTextBuilder.ToString())); } public void SkipToEndOfCurrentText() { if (typingCoroutine ! null) { StopCoroutine(typingCoroutine); typingCoroutine null; } FinishTyping(); // 直接跳到完成状态 }AppendText方法首先将新文本添加到fullTextBuilder中并更新底层Text组件的完整内容。如果设置startTypingImmediately为true它会重新启动打字协程协程会基于更新后的完整文本和当前的currentVisibleCharacterIndex继续执行从而实现“接着打”的效果。3.5 支持UI.Text的富文本处理对于不支持maxVisibleCharacters的旧版UI.Text我们需要自己实现CalculateVisibleCharacterCount和GetVisibleSubstring。这是一个挑战因为要正确剥离标签。一个相对可靠的方法是使用正则表达式匹配标签并累加非标签字符。using System.Text.RegularExpressions; private int CalculateVisibleCharacterCount(string richText) { // 移除所有富文本标签计算纯文本长度 string plainText Regex.Replace(richText, .*?, ); return plainText.Length; } private string GetVisibleSubstring(string richText, int visibleCharCount) { if (visibleCharCount 0) return ; if (visibleCharCount CalculateVisibleCharacterCount(richText)) return richText; // 这是一个简化的算法可能无法处理所有嵌套标签 // 更健壮的实现需要遍历字符串维护一个标签栈 StringBuilder result new StringBuilder(); int visibleCount 0; bool insideTag false; Stackstring tagStack new Stackstring(); // 用于处理嵌套标签 for (int i 0; i richText.Length; i) { char c richText[i]; if (c ) { insideTag true; result.Append(c); } else if (c ) { insideTag false; result.Append(c); // 可以在这里解析是开始标签还是结束标签并操作tagStack } else if (!insideTag) { visibleCount; result.Append(c); if (visibleCount visibleCharCount) { // 在达到目标字符数后需要补全所有未闭合的标签 while (tagStack.Count 0) { result.Append(tagStack.Pop()); // 注意结束标签的顺序 } break; } } else { result.Append(c); } } return result.ToString(); }重要提示上述GetVisibleSubstring是一个概念性示例处理复杂嵌套标签如bitext/i/b非常复杂且容易出错。强烈建议在需要富文本支持的项目中直接使用TextMeshPro它的maxVisibleCharacters属性完美、高效地解决了所有问题避免了自己实现解析器带来的潜在Bug和性能开销。4. 使用示例与场景集成组件写好了怎么用在项目里呢这里提供几个典型的使用场景。4.1 基础对话系统集成假设你有一个DialogueManager单例类负责管理对话流程。public class DialogueManager : MonoBehaviour { public TypewriterEffect typewriter; public Button continueButton; // 继续按钮 private Queuestring sentenceQueue new Queuestring(); void Start() { continueButton.onClick.AddListener(DisplayNextSentence); continueButton.gameObject.SetActive(false); } public void StartDialogue(Dialogue dialogue) { sentenceQueue.Clear(); foreach (string sentence in dialogue.sentences) { sentenceQueue.Enqueue(sentence); } DisplayNextSentence(); } void DisplayNextSentence() { continueButton.gameObject.SetActive(false); if (sentenceQueue.Count 0) { EndDialogue(); return; } string sentence sentenceQueue.Dequeue(); // 直接播放新句子覆盖之前的 typewriter.PlayText(sentence); // 假设我们有一个PlayText(string)方法内部会重置并播放 // 或者如果你想实现“追加”效果比如旁白连续滚动 // typewriter.AppendText(\n sentence); } void EndDialogue() { /* ... */ } }在Inspector中将UI上的TypewriterEffect组件拖拽到DialogueManager的typewriter字段即可。4.2 配合事件实现交互反馈我们可以利用打字机组件提供的事件来实现更丰富的反馈。// 在Unity Editor中将方法拖到事件回调上 public void OnTypingStarted() { // 开始打字时隐藏继续按钮禁止跳过 continueButton.interactable false; } public void OnCharacterTyped() { // 每打一个字可以轻微震动对话框或播放更细腻的音效 // 或者用于实现“关键字符”的特殊效果需要知道打的是什么字 } public void OnTypingCompleted() { // 打字完毕显示继续按钮 continueButton.gameObject.SetActive(true); continueButton.interactable true; }4.3 实现“跳过”与“加速”功能这是游戏中的常见需求。public class PlayerInput : MonoBehaviour { public TypewriterEffect typewriter; void Update() { if (Input.GetKeyDown(KeyCode.Space) || Input.GetMouseButtonDown(0)) { if (typewriter.IsTyping) // 需要组件暴露一个IsTyping属性 { // 如果正在打字按下空格/鼠标左键加速或跳过 if (Input.GetKey(KeyCode.LeftControl)) // 按住Ctrl直接跳过整句 { typewriter.SkipToEndOfCurrentText(); } else // 轻点则加速到最快速度 { typewriter.SetSpeed(1000f); // 设置一个极大的速度 } } else { // 如果打字已结束则触发下一句 DialogueManager.Instance.DisplayNextSentence(); } } // 松开按键时恢复原速如果需要 if (Input.GetKeyUp(KeyCode.Space) || Input.GetMouseButtonUp(0)) { typewriter.ResetSpeed(); } } }在TypewriterEffect组件中我们需要暴露IsTyping属性和SetSpeed方法。public bool IsTyping isTypingActive; public void SetSpeed(float newCharsPerSecond) { charactersPerSecond newCharsPerSecond; // 注意更改速度不会立即影响正在运行的协程需要重启或动态修改wait时间 // 更复杂的实现需要能动态更新协程内的wait变量 } public void ResetSpeed() { SetSpeed(originalSpeed); // 需要保存原始速度 }动态修改协程速度稍微复杂点可以通过在协程内检查一个公共的currentDelay变量或者停止旧协程用新速度重启一个新协程来实现。5. 常见问题、优化与避坑指南在实际使用和项目迭代中我遇到了不少问题也总结了一些优化点。5.1 性能问题与优化频繁的字符串赋值对于UI.Text每帧修改text属性会引发网格重建Rebuild如果文本很长或UI复杂可能造成卡顿。优化对于长文本可以考虑分帧处理。不是每打一个字都更新而是积累几个字符比如每0.05秒更新一次虽然牺牲了一点流畅度但大幅降低了重建频率。对于TMP修改maxVisibleCharacters同样会触发重建但TMP的重建优化通常比旧版UI.Text好。协程开销每个打字机实例一个协程如果场景中同时存在几十个打字机比如列表项协程调度会有开销。优化可以考虑用一个统一的Update管理器来驱动所有活跃的打字机使用基于时间戳的更新逻辑减少协程数量。但对于大多数情况单个对话系统的打字机数量有限协程方案完全够用。音效播放每打一个字都播放一次音效如果速度很快会导致音效重叠、刺耳。优化限制音效播放频率例如设置一个最小播放间隔如0.05秒或者使用一个随机的音效播放概率。也可以为空格、标点符号设置不同的、更轻柔的音效或静音。5.2 富文本相关的坑标签嵌套与破坏这是自己实现UI.Text富文本处理时最大的坑。例如文本“AcolorredBbC/bD/colorE”在显示到第3个字符时正确的输出应该是“AcolorredBbC/b/color”。自己写解析器要正确处理标签的打开、关闭和嵌套顺序。强烈建议使用TextMeshPro。这是最根本的解决方案。TMP的maxVisibleCharacters与sprite如果文本中包含TMP的精灵标签如sprite index0它会被算作一个“字符”。这通常是符合预期的。文本溢出与布局打字过程中文本不断变长可能会影响Content Size Fitter、布局组Vertical/Horizontal Layout Group或Scroll Rect。它们可能会每一帧都重新计算布局导致性能问题或视觉抖动。解决对于动态变化的文本可以考虑禁用或优化布局组。对于Scroll Rect可以在打字完成后再更新滚动位置或者使用Canvas.ForceUpdateCanvases()来强制立即更新布局后再调整滚动。5.3 交互与状态管理跳过逻辑玩家在快速点击时可能会在SkipToEnd方法执行完毕前就触发了下一条对话导致状态混乱。解决在跳过和开始新对话的方法中加入状态锁或标志位确保同一时间只有一个操作在执行。追加与中断当调用AppendText时如果打字机正在播放是应该立即停止当前播放并开始播放新的完整文本还是将新文本加入队列等待当前播放完设计决策这取决于游戏需求。我提供的示例采用了“立即完成当前然后追加”的策略这比较符合多数对话场景的直觉。你也可以实现一个队列系统Queuestring让打字机按顺序播放。与Time.timeScale的兼容如果游戏处于暂停状态Time.timeScale 0使用WaitForSeconds的协程也会暂停。如果你希望UI动画如对话打字不受游戏暂停影响务必使用WaitForSecondsRealtime并将ignoreTimeScale设置为true。5.4 扩展功能点子字符回调提供一个事件或委托在打出每一个特定字符时触发并传递该字符信息。这可以用于实现更高级的效果比如某个关键词出现时屏幕震动、播放特殊音效。渐变效果不仅仅是逐字出现可以让字符有淡入、缩放等动画。这需要修改Shader或为每个字符创建独立的动画复杂度较高但效果更炫。多语言支持确保组件在处理不同语言尤其是从右向左书写的语言时能正确工作。TMP对此有更好的支持。与Timeline或Dotween集成将打字机作为一个可播放的轨道集成到Timeline中或者用Dotween来驱动maxVisibleCharacters的变化可以实现更复杂的时间线控制。最后封装好的组件应该做到“开箱即用”。在Inspector中配置好速度、音效挂载到Text/TMP对象上通过简单的API调用Play()Append()就能工作。同时暴露必要的事件和属性让其他系统能方便地与它交互。这样的组件才能真正提升项目的开发效率让策划和设计师也能轻松地打造出富有表现力的文字演出效果。