尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity打字机效果实现:协程与DOTween两种方案详解

Unity打字机效果实现:协程与DOTween两种方案详解 1. 项目概述为什么我们需要一个“打字机”在Unity里做游戏尤其是那些带点叙事感的RPG、AVG或者视觉小说你肯定遇到过这样的需求一大段剧情文本不想让它“唰”地一下全显示在屏幕上显得生硬又廉价。你想要的是那种一个字一个字蹦出来的感觉就像老式打字机在敲打或者角色在娓娓道来能营造出悬念、节奏感和沉浸感。这就是我们常说的“打字机效果”Typewriter Effect。听起来很简单不就是把字符串拆开每隔一段时间显示一个字符吗新手可能会立刻想到用for循环加yield return new WaitForSeconds。但真这么干了你很快就会踩坑富文本标签比如colorred重要/color会被拆得支离破碎显示出一堆乱码你想让玩家点击鼠标就立刻显示完整文本逻辑怎么写才干净如果文本特别长每帧都操作string会不会有性能问题我做过不少需要大量对话的游戏从简单的Text组件到复杂的TextMeshPro从协程到DOTween各种方案都试过。今天我就结合自己的踩坑经验给你拆解两种最实用、也最值得掌握的Unity打字机实现方式。一种是基于协程Coroutine的经典方案稳定可控适合大多数情况另一种是基于DOTween插件或类似动画系统的“骚操作”代码更简洁且能轻松实现非线性动画效果。无论你是刚入门的新手还是想优化现有方案的老手这篇都能给你带来可以直接“抄作业”的干货。2. 核心思路拆解两种方案的哲学与选型在动手写代码之前我们得先想清楚不同的实现方式背后对应着不同的设计哲学和适用场景。这决定了你项目的代码结构和未来的可维护性。2.1 方案一协程驱动步步为营这是最经典、最教科书式的实现。它的核心思想是“过程模拟”我们把显示文本的过程看作一个随时间推进的离散事件序列。为什么选择协程Unity的协程本质上是一个迭代器IEnumerator它允许你将一个任务分散到多个帧中去执行而不用自己管理复杂的计时器和状态机。对于打字机这种“等待一段时间 - 执行一个操作显示一个字 - 再等待”的循环任务协程是天作之合。它让代码逻辑保持线性易于阅读和调试。核心流程拆解初始化获取目标UI文本组件如Text或TextMeshProUGUI并保存完整的原始字符串。逐字显示循环在一个while循环中每次迭代从原始字符串中取出下一个字符追加到当前显示的字符串末尾。富文本处理这是关键难点。你不能简单地按字符索引切割字符串因为像b、i、color#FF0000这样的标签是多个字符但它们在渲染时是一个整体。我们需要识别这些标签并确保它们不被拆散。等待与中断每次追加字符后使用yield return new WaitForSeconds(interval)来等待指定的间隔时间形成打字节奏。同时需要监听玩家输入如鼠标点击或按键一旦触发就跳出循环立即显示全部文本。完成回调当所有字符显示完毕触发一个完成事件或回调通知其他系统比如自动播放下一句对话。这种方案的优势在于绝对可控每一个字符的显示时机你都能精确控制。逻辑清晰代码流程就是业务逻辑一目了然。不依赖第三方纯Unity原生功能实现无需额外插件。2.2 方案二DOTween动画化繁为简如果你已经在项目中使用DOTween一个强大的补间动画插件或者追求极简的代码风格那么这种方案会让你眼前一亮。它的核心思想是“属性动画”我们把“已显示文本的长度”看作一个可以从0变化到文本总长度的数值然后利用动画系统来驱动这个变化。为什么可以这么做Text或TextMeshProUGUI组件的.text属性是字符串。虽然DOTween不能直接对字符串做补间但我们可以通过一个“代理”的方式。我们创建一个浮点数比如叫_progress让它从0动画到文本的总长度字符数。在动画的每一帧根据当前的_progress值去截取原始字符串的相应长度并赋值给.text。DOTween负责所有关于时间、缓动曲线Easing的逻辑。核心流程拆解设置动画目标定义一个浮点型变量作为动画的驱动值例如currentLength。创建补间动画使用DOTween.To()方法将currentLength从0变化到完整文本的字符长度并设置持续时间。更新回调在动画的OnUpdate回调中根据当前的currentLength值计算应该显示多少字符并更新UI文本。这里同样需要处理富文本标签控制与完成DOTween的Tween对象可以被随时Kill()停止或Complete()立即完成这完美对应了玩家“跳过”动画的需求。动画自然结束时也会触发OnComplete回调。这种方案的优势在于代码极简核心逻辑可能就十几行代码。效果丰富轻松利用DOTween强大的缓动曲线实现先快后慢、弹性效果等非线性的打字节奏这是协程方案需要额外编码才能实现的。易于组合可以很方便地将打字机动画与其他UI动画如淡入、移动串联或并联。选型建议新手入门、追求稳定、项目限制多不能乱加插件无脑选方案一协程。它能帮你彻底理解打字机效果的所有细节和坑点。项目已集成DOTween、追求开发效率、需要复杂动画效果强烈推荐方案二。它能大幅减少代码量并带来更好的效果上限。性能敏感场景如超长文本、移动端两者都需要注意但协程方案在超长字符串拼接时可能产生较多GC垃圾回收需要优化。DOTween方案则要关注OnUpdate每帧调用的开销。3. 方案一实战协程打字机的完整实现与深度优化理论说完了我们直接上代码。我会从一个最基础的版本开始然后一步步加入富文本支持、跳过逻辑和性能优化让你看到一个生产可用的打字机是如何迭代出来的。3.1 基础骨架一个能跑起来的原型首先我们创建一个TypewriterEffect脚本。这里以Unity传统的UI.Text为例TextMeshPro的思路完全一致只是组件类型换一下。using UnityEngine; using UnityEngine.UI; using System.Collections; public class TypewriterEffect : MonoBehaviour { [SerializeField] private Text targetText; // 目标文本组件 [SerializeField] private float charsPerSecond 30f; // 打字速度字/秒 private string fullText; // 完整的文本内容 private Coroutine typewriterCoroutine; // 保存协程引用便于停止 public void StartTypewriter(string text) { // 如果已经有打字在进行先停止它 if (typewriterCoroutine ! null) { StopCoroutine(typewriterCoroutine); } fullText text; targetText.text ; // 清空初始文本 // 开始新的打字协程 typewriterCoroutine StartCoroutine(TypewriterRoutine()); } private IEnumerator TypewriterRoutine() { int currentLength 0; float delay 1f / charsPerSecond; // 计算每个字符的间隔时间 while (currentLength fullText.Length) { // 追加下一个字符 targetText.text fullText[currentLength]; currentLength; // 等待间隔时间 yield return new WaitForSeconds(delay); } // 打字完成清理协程引用 typewriterCoroutine null; OnComplete?.Invoke(); // 可以在这里触发完成事件 } // 提供一个外部调用的“跳过”方法 public void Skip() { if (typewriterCoroutine ! null) { StopCoroutine(typewriterCoroutine); targetText.text fullText; // 直接显示完整文本 typewriterCoroutine null; OnComplete?.Invoke(); } } // 完成事件可选 public System.Action OnComplete; }这个版本已经能实现最基本的逐字打印了。在UI上挂载这个脚本把Text组件拖进去然后调用StartTypewriter(“你的文本”)就能看到效果。但问题马上来了富文本灾难如果fullText是“这是colorred重要/color内容”你会看到“这是”、“colorred”、“重”、“要/”、“color内容”这样被切碎的标签渲染自然就乱了。性能隐患targetText.text …这个操作在循环中会不断创建新的字符串对象产生GC Alloc文本长了会有卡顿。跳过逻辑粗糙Skip方法直接StopCoroutine如果协程正在yield return等待可能会有一帧的延迟。3.2 攻克核心难题富文本标签的智能识别富文本标签不能被拆散。我们的策略是在遍历字符时如果检测到我们正在一个标签内部即遇到了且尚未遇到对应的我们就应该一次性把这个标签的所有字符都吞掉作为一个整体追加到显示文本中。我们需要一个辅助方法来判断当前位置是否在标签内并获取完整标签。private IEnumerator TypewriterRoutine() { int currentIndex 0; // 改用索引更清晰 float delay 1f / charsPerSecond; // 使用StringBuilder来优化字符串拼接性能 System.Text.StringBuilder displayedText new System.Text.StringBuilder(); while (currentIndex fullText.Length) { char currentChar fullText[currentIndex]; // 情况1当前字符是标签的开始 if (currentChar ) { // 找到这个标签的结束位置 int tagEndIndex fullText.IndexOf(, currentIndex); if (tagEndIndex currentIndex) // 确保找到了有效的结束符 { // 把整个标签从到作为一个整体追加 string tag fullText.Substring(currentIndex, tagEndIndex - currentIndex 1); displayedText.Append(tag); // 索引直接跳到标签结束符之后 currentIndex tagEndIndex 1; } else { // 没找到结束符按普通字符处理理论上不应该发生 displayedText.Append(currentChar); currentIndex; } } // 情况2普通字符 else { displayedText.Append(currentChar); currentIndex; } // 更新UI文本 targetText.text displayedText.ToString(); // 等待。注意标签整体追加时没有等待这符合预期标签是瞬间应用的 yield return new WaitForSeconds(delay); } typewriterCoroutine null; OnComplete?.Invoke(); }这个改进版解决了富文本问题。它识别到后会寻找配对的并将中间的所有内容包括和一次性追加从而保证了标签的完整性。实操心得这里用StringBuilder代替直接的字符串拼接是至关重要的性能优化。在循环中频繁修改字符串StringBuilder比效率高几个数量级因为它避免了反复创建新字符串对象。对于长文本对话这个优化能明显提升流畅度。3.3 增强交互更流畅的跳过与音效支持一个商业级的打字机还需要考虑交互体验。1. 更即时的跳过上面的Skip方法用StopCoroutine如果恰好在yield return等待会等这一帧的等待结束才执行Stop感觉有延迟。更即时的方法是使用一个标志位。private bool isSkipping false; public void Skip() { if (typewriterCoroutine ! null !isSkipping) { isSkipping true; // 注意我们不再StopCoroutine而是让它自然执行完但速度无限快 // 可以通过修改一个速度倍率来实现或者直接在下文逻辑中判断 } } private IEnumerator TypewriterRoutine() { // ... 初始化 ... while (currentIndex fullText.Length) { if (isSkipping) { // 跳过模式下一次性显示剩余所有文本 // 这里也需要处理富文本标签所以不能简单赋值fullText // 我们可以直接跳出循环在循环外处理 break; } // ... 正常的字符处理逻辑 ... // 等待时间需要根据是否跳过调整 float currentDelay isSkipping ? 0f : delay; if (currentDelay 0) { yield return new WaitForSeconds(currentDelay); } } // 循环结束后确保文本完整显示 if (isSkipping) { // 当跳过时我们需要正确解析所有剩余的富文本 // 一个简单的方法是直接将完整文本赋值因为标签本身是完整的。 // 但为了和之前逻辑一致也可以运行一个不等待的快速循环。 targetText.text fullText; } typewriterCoroutine null; isSkipping false; OnComplete?.Invoke(); }更常见的做法是在Skip()被调用时直接StopCoroutine然后运行一个同步的、不等待的循环快速把剩余字符包括标签处理完并显示。这样既即时又能保证富文本正确。2. 添加打字音效让打字机“咔哒”作响能极大增强沉浸感。[SerializeField] private AudioClip typeSound; // 打字音效 [SerializeField] private AudioSource audioSource; // 音频源可以挂在同一物体上 private IEnumerator TypewriterRoutine() { // ... 初始化 ... while (currentIndex fullText.Length) { // ... 字符处理逻辑 ... // 播放音效注意遇到标签时不播放 if (currentChar ! typeSound ! null audioSource ! null) { // 避免每帧播放太密集可以加一个随机概率或音高变化 audioSource.pitch Random.Range(0.95f, 1.05f); // 轻微音高变化更自然 audioSource.PlayOneShot(typeSound); } // ... 等待逻辑 ... } // ... 结束逻辑 ... }4. 方案二实战用DOTween打造动画式打字机如果你厌倦了手动管理协程和状态那么DOTween方案会让你感到清爽。确保你的项目已经通过Package Manager或Asset Store安装了DOTween。4.1 基础实现用浮点数驱动文本更新思路是用一个浮点数_displayedLength来代表“已显示的字符数”让DOTween对它做动画。using UnityEngine; using UnityEngine.UI; using DG.Tweening; // 引入DOTween命名空间 public class TypewriterEffectTween : MonoBehaviour { [SerializeField] private Text targetText; [SerializeField] private float duration 2f; // 完整显示文本所需总时间 private string _fullText; private float _displayedLength; // 已显示字符长度浮点用于动画 private Tween _currentTween; public void StartTypewriter(string text) { // 清理之前的动画 if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Kill(); } _fullText text; targetText.text ; // 清空 _displayedLength 0f; // 创建动画将_displayedLength从0变化到文本长度 _currentTween DOTween.To( () _displayedLength, // 获取当前值 x { _displayedLength x; UpdateTextDisplay(); // 每当值变化就更新文本显示 }, _fullText.Length, // 目标值 duration // 持续时间 ) .SetEase(Ease.Linear) // 默认线性缓动匀速打字 .OnComplete(() { _currentTween null; OnComplete?.Invoke(); }); } // 关键根据_displayedLength更新显示的文本 private void UpdateTextDisplay() { int lengthToShow Mathf.FloorToInt(_displayedLength); // 向下取整 // 临时方案直接截取前lengthToShow个字符富文本会出问题 targetText.text _fullText.Substring(0, lengthToShow); } public void Skip() { if (_currentTween ! null _currentTween.IsActive()) { // 立即完成动画 _currentTween.Complete(); } } public System.Action OnComplete; }这个基础版本实现了动画驱动的打字但和协程最初版一样UpdateTextDisplay中的简单Substring会切碎富文本标签。4.2 适配富文本共享解析逻辑好消息是我们之前为协程方案写的“智能识别标签并整体追加”的逻辑可以抽象成一个独立的工具方法供两种方案复用。我们创建一个静态工具类。public static class TextUtility { /// summary /// 获取富文本字符串中指定长度内按可见字符计的实际子串。 /// 确保富文本标签不被截断。 /// /summary /// param namesource源字符串/param /// param namevisibleCharCount想要显示的可见字符数量/param /// returns处理后的子串/returns public static string GetSubstringWithRichText(string source, int visibleCharCount) { System.Text.StringBuilder result new System.Text.StringBuilder(); int currentIndex 0; int visibleCharsAdded 0; bool insideTag false; while (currentIndex source.Length visibleCharsAdded visibleCharCount) { char c source[currentIndex]; if (c ) { // 进入标签找到结束的 int tagEnd source.IndexOf(, currentIndex); if (tagEnd currentIndex) { // 将整个标签追加 string tag source.Substring(currentIndex, tagEnd - currentIndex 1); result.Append(tag); currentIndex tagEnd 1; // 跳到标签后 // 标签不计入可见字符 continue; } } // 普通字符 result.Append(c); currentIndex; visibleCharsAdded; } return result.ToString(); } }然后在DOTween方案的UpdateTextDisplay中调用它private void UpdateTextDisplay() { int lengthToShow Mathf.FloorToInt(_displayedLength); // 使用工具方法获取正确处理富文本的子串 targetText.text TextUtility.GetSubstringWithRichText(_fullText, lengthToShow); }这样DOTween方案也完美支持了富文本。4.3 发挥动画优势缓动曲线与效果组合这才是DOTween方案的精华所在。你可以通过改变SetEase来轻松调整打字的节奏感无需修改核心逻辑。// 在StartTypewriter的动画链中修改 _currentTween DOTween.To(...) //.SetEase(Ease.Linear) // 匀速像机械打字机 //.SetEase(Ease.OutQuad) // 先快后慢像逐渐停下来 .SetEase(Ease.OutElastic, 0.5f, 0.8f) // 带一点弹性效果更生动 .OnComplete(...);你还可以轻松组合其他动画// 打字的同时让文本框轻微缩放增强动感 _currentTween DOTween.To(...) .SetEase(Ease.OutBack) // 文字出现带一点回弹 .OnStart(() { // 动画开始时将文本缩放到0 targetText.rectTransform.localScale Vector3.zero; }) .OnUpdate(() { // 随着显示进度逐渐放大文本非线性关联 float progress _displayedLength / _fullText.Length; float scale Mathf.Lerp(0.5f, 1f, progress); // 从0.5放大到1 targetText.rectTransform.localScale new Vector3(scale, scale, 1); }) .OnComplete(...);这种将“显示进度”与“其他属性动画”绑定的能力是协程方案需要额外编码才能实现的而在DOTween中几乎可以免费获得。5. 性能优化与高级技巧无论哪种方案在大量使用或移动端环境下都需要考虑性能。1. 对象池与复用如果你的对话系统频繁创建/销毁带有打字机组件的GameObject可以考虑使用对象池来复用它们避免频繁的实例化和垃圾回收。2. 分帧处理超长文本对于极其长的文本比如一整页文档即使使用StringBuilder在一帧内更新UI也可能造成卡顿。可以在协程方案中每显示N个字符就yield return null一帧或者使用DOTween的SetUpdate(UpdateType.Late)等。3. 支持TextMeshPro现在很多项目都用TextMeshPro因为它效果更好。适配非常简单将字段类型从Text改为TextMeshProUGUI。引用命名空间TMPro。TextMeshPro也支持Unity标准的富文本标签所以我们的富文本解析逻辑完全通用。只需注意TextMeshPro有一些自己的特定标签如sprite我们的解析器需要能识别它们不被拆散当前简单解析器对自闭合标签如br/支持不好需要增强。4. 增强富文本解析器我们之前的解析器只处理了最简单的tag形式。一个健壮的解析器还需要处理自闭合标签br/,sprite index0。属性中包含的标签虽然罕见但理论上存在。嵌套标签bi文本/i/b。我们的方案是整体追加所以嵌套标签也能正确显示因为我们在遇到b时会一直找到对应的/b中间的内容被完整包含。但更复杂的解析需要状态机。一个快速增强的方法是使用正则表达式但要注意性能。对于游戏内打字机简单场景下我们之前的方案已经足够。6. 常见问题与排查实录在实际项目中你肯定会遇到一些奇怪的问题。这里记录几个我踩过的坑和解决方法。问题1打字机在时间缩放Time.timeScale为0时停止了。原因协程中的WaitForSeconds受Time.timeScale影响。DOTween默认也受时间缩放影响。解决对于协程使用WaitForSecondsRealtime代替WaitForSeconds。对于DOTween在动画链中加入.SetUpdate(true)这表示使用无视时间缩放的独立更新。问题2打字机效果在WebGL上速度异常快或慢。原因WaitForSeconds的精度和帧率相关在不同性能的设备或平台上可能有微小差异。WebGL的线程模型和时间处理可能与原生平台不同。解决使用基于真实时间的累积计时器而不是依赖yield return的固定等待。在协程的while循环中用Time.deltaTime累加达到间隔时间再显示下一个字符。这样更精确且与帧率解耦。private IEnumerator TypewriterRoutine() { // ... 初始化 ... float timer 0f; while (currentIndex fullText.Length) { timer Time.deltaTime; if (timer delay) { timer 0f; // ... 处理并显示一个字符 ... } yield return null; // 每帧都检查而不是等待固定秒数 } }问题3打字过程中如果文本组件被禁用或游戏对象失活协程会继续运行吗答案不会。但DOTween的动画可能会继续取决于设置导致状态不一致。解决在OnDisable或OnDestroy方法中主动停止协程或Kill掉Tween。private void OnDisable() { if (typewriterCoroutine ! null) { StopCoroutine(typewriterCoroutine); typewriterCoroutine null; } if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Kill(); _currentTween null; } }问题4如何实现“逐词”打印而不是“逐字”打印思路以空格、标点等作为单词的分隔符。在解析时不是每次推进一个字符而是推进一个单词直到遇到分隔符。实现预先将完整文本按单词分割成数组。在循环中每次追加一个单词包括它后面的空格或标点。等待时间可以基于单词长度来调整使打字节奏更自然。最后选择哪种方案取决于你的项目需求和个人偏好。协程方案给你最底层的控制力适合学习原理和定制复杂逻辑。DOTween方案则提供了更高的开发效率和更强大的视觉效果集成能力。我个人在中小型叙事项目中偏爱DOTween方案因为它让代码更干净且容易做出出彩的动画效果而在需要极精细控制或无法引入插件的环境下协程方案则是可靠的基石。希望这篇超详细的拆解能帮你做出体验完美的游戏打字机。
返回列表