最近在做一个剧情驱动的小游戏里面有大段的对话文本需要逐句渐显。最初图省事直接用协程在Update里拖进度条写着写着发现状态一多代码就开始乱成一团——对话要渐显、选项要延迟出现、跳过要瞬间完成、切场景要立刻清空。各个逻辑之间互相穿插改一处就得牵动好几个地方。后来我把这套逻辑完整地重构成了状态机用状态驱动原生Text的透明度变化。重构完最大的感受是**逻辑清理得干干净净任何时刻Text处于什么状态一目了然调试直接用状态名定位问题不用再从头捋一遍时间线。**这篇就把这套方案完整拆开从状态划分、代码实现到常见坑位一次性讲清楚。1. 方案选型为什么是原生Text状态机而不是无脑上插件1.1 原生Text与TextMeshPro的取舍很多新项目一上来就全局换TextMeshPro这固然是主流方向但在某些场景下原生Text反而更合适——比如轻量级原型、包体敏感的小游戏、或者历史遗留的老项目还没做资源迁移。原生Text的好处非常直观没有额外资源依赖不需要为每个字体生成图集和材质变体一个默认字体就能跑起来。在老项目里强行引入TextMeshPro还得处理一堆动态字体和字符集的兼容问题反而拖慢进度。它最大的短板在于渲染效果——字体本身不带SDF有向距离场缩放后边缘容易发虚。但如果文字就是固定字号、固定分辨率这个短板根本不影响。我做的这个项目需要频繁更新文本内容一段话几十个字符TextMeshPro在动态字体回退上偶尔会有延迟生成字形的抖动原生Text反而稳定得多。在需要连续迅速切换文本的场景下原生Text更省心。1.2 为什么选中状态机而非协程或补间插件刚开始用协程实现渐显时代码长这样IEnumerator FadeIn() { float elapsed 0f; Color c text.color; while (elapsed duration) { elapsed Time.deltaTime; c.a Mathf.Lerp(0f, 1f, elapsed / duration); text.color c; yield return null; } }单看这一个平平无奇问题出在多个协程互相叠加时——渐显还没结束用户又按了跳过跳过协程和渐显协程同时改同一个text.color.a结果就是你也不知道最终透明度到底是谁算的。协程的本质是时间片内的顺序逻辑一旦多个时间片交叉管理成本瞬间上涨。补间插件如DOTween简洁但多一层第三方依赖而且它解决的也只是动画怎么做的问题没有解决什么时候该做和做到一半被打断怎么办的问题。状态机解决的是后两者。状态机最核心的价值在于把当前处于什么状态和状态如何迁移变成了显式逻辑。渐显中就是一个状态被跳过不是一个独立的动画而是一个迁移动作——直接把状态切到常显。一码归一码流程清晰Debug时候甚至能直接打出状态名。2. 状态机设计先划分状态再谈写代码2.1 状态划分与含义针对文字渐显这个需求我划分了4个核心状态状态含义进入条件Idle文字全透明不可见初始化、文本清空FadingIn正在渐显中调用了显示接口Visible文字完全显示渐显完成Skip跳过过渡立刻显示用户触发跳过有的人会加FadingOut状态但我在实际项目里没用它——对话场景中文字消失基本都是瞬切不需要淡出。如果你做的是演出系统、过场动画、或者标题Logo需要FadingOut也完全没问题加一个状态和对应的迁移逻辑就行成本很低。有一点值得强调状态不要划分得过细。有段时间我把正在等待打字音效也做成一个状态结果状态数量翻倍但每个状态里做的事情都很少状态机反而变得笨重。状态划分的原则是每个状态里要有实质性的持续逻辑如果某个状态进入后只是干等一个回调那干脆用普通字段在Update里判断别硬塞进状态机。2.2 状态迁移表状态机就是把状态间的合法迁移画成一张表Idle --(Show() 方法被调用)-- FadingIn FadingIn --(透明度达到1)-- Visible FadingIn --(Skip() 被调用)-- Visible Visible --(Reset()/Clear())-- Idle Visible --(Skip() 且开启Retrigger)-- FadingIn不管外部事件多复杂都能落到这四条迁移线上。文字要变内容先Reset回Idle再走Show保证任何时刻都是旧内容渐出、新内容渐入不会重叠。2.3 用枚举还是状态模式类满大街的教程都喜欢用状态模式类每个状态建一个class实现接口然后互相替换。这个模式在状态内部有大量私有数据时是合理的但文字渐显这个需求根本没那么复杂——每个状态只需要知道透明度改多少、什么时候迁移出去用一个枚举加一个switch就能Hold住。public enum TextDisplayState { Idle, FadingIn, Visible, Skip }如果你觉得以后状态会复杂化可以先用枚举版本跑通流程复杂了再重构。对大项目而言状态模式类更规范但绝大多数游戏UI的文本显示需求枚举版本已经足够清晰还少写一堆样板代码。3. 核心实现状态机控制Text渐变的完整代码3.1 基础框架状态字段驱动Update逻辑先把状态机的壳子写出来。这个类挂到包含Text组件的GameObject上using UnityEngine; using UnityEngine.UI; public class TextFadeStateMachine : MonoBehaviour { public Text text; [Header(渐显参数)] public float fadeDuration 0.8f; public AnimationCurve fadeCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); private TextDisplayState m_State TextDisplayState.Idle; private float m_Timer 0f; public TextDisplayState State m_State; private void Awake() { if (text null) text GetComponentText(); SetAlpha(0f); } private void Update() { switch (m_State) { case TextDisplayState.Idle: // 无操作等待显示指令 break; case TextDisplayState.FadingIn: UpdateFadingIn(); break; case TextDisplayState.Visible: // 无操作文字保持全透 break; case TextDisplayState.Skip: // 无操作 break; } } private void UpdateFadingIn() { m_Timer Time.deltaTime; float t Mathf.Clamp01(m_Timer / fadeDuration); float curveValue fadeCurve.Evaluate(t); SetAlpha(curveValue); if (t 1f) { m_State TextDisplayState.Visible; } } private void SetAlpha(float alpha) { Color c text.color; c.a alpha; text.color c; } public void Show() { if (m_State TextDisplayState.Idle) { m_State TextDisplayState.FadingIn; m_Timer 0f; } } public void Skip() { if (m_State TextDisplayState.FadingIn) { m_State TextDisplayState.Visible; SetAlpha(1f); } } public void ResetDisplay() { m_State TextDisplayState.Idle; m_Timer 0f; SetAlpha(0f); } public void SetText(string content) { text.text content; ResetDisplay(); } }这里有个细节值得注意ResetDisplay()里只重置了m_Timer和m_State没有清空text.text。这取决于你想让文字在下一句来之前先显示空白还是保留旧文字——我的场景里文本内容在Reset后马上被SetText覆盖所以不额外清空也没问题。如果你的需求是先清屏再渐显那就在ResetDisplay里加上text.text 。3.2 为什么用Color.a而不是CanvasGroup刚做的时候我踩了个坑一直想着用CanvasGroup.alpha控制透明度。后来发现同一个面板上还有其他UI元素如果直接改整个CanvasGroup所有子物体一起渐显没法单独控制某一行文字。虽然也可以单独挂CanvasGroup但会多一层组件。直接用text.color.a是更精准的做法——只影响当前这个Text组件不波及任何兄弟节点。而且原生Text的color属性是结构体改a值只需要重新构造一个Color或拷贝出来改再赋回去。有一种情况需要用CanvasGroup而不建议用Color.a当Text本身被频繁实例化或销毁时color的赋值会打断UGUI的重建批次。但这属于极端情况常规对话系统里一个Text常驻场景完全没问题。还有个细节修改text.color会在UGUI里标记Graphic为脏数据触发顶点重建。频繁改透明度会不会带来性能问题实测下来一帧改一次Color和每帧SetAllDirty完全是两个量级。一次Color改动的开销微乎其微40帧每秒连续改1秒也就40次顶点颜色更新完全不构成性能瓶颈。如果用100个Text同时做渐显才需要担心但那个场景本身就该考虑批量合批的问题了。3.3 增加Text切换时的打断逻辑实际使用中正在渐显时用户又触发了新文本很常见。这就要把Show方法做成可重入的public void Show(string newContent) { text.text newContent; m_State TextDisplayState.FadingIn; m_Timer 0f; }注意这里没有判断当前状态直接无条件进入FadingIn。为什么这样做因为用户的点击事件总是最新的意图不需要保留旧的渐显过程。如果旧文字正在渐显一半直接切到新文本并重新渐显视觉上比先等旧的渐显完再淡出更顺畅。但这里有一个视觉上的小问题文本内容瞬间切换时即使透明度很低也可能被眼尖的玩家瞥见。一个简单的处理方案是切换时先置零透明度再进入渐显public void Show(string newContent) { text.text newContent; SetAlpha(0f); m_State TextDisplayState.FadingIn; m_Timer 0f; }这样换文字这个瞬时变化被透明度置零遮住了观感上是新文字缓缓浮现而不是旧文字被猛得替换掉。3.4 用UnscaledDeltaTime应对游戏暂停对话场景经常会配暂停功能——点开设置菜单背景游戏暂停但对话文本不能暂停。这种情况下Time.deltaTime会变成0渐显就卡住了。把Time.deltaTime换成Time.unscaledDeltaTime即可private void UpdateFadingIn() { m_Timer Time.unscaledDeltaTime; // 其余逻辑不变 }这是很小的改动但不少项目是在上线测试时才发现的。如果明确不做暂停功能继续用Time.deltaTime完全没问题。但我在做这个项目时把暂停、慢动作都考虑进去了所以统一用unscaledDeltaTime省心。4. 进阶处理曲线、顺序渐显与代码组织4.1 缓动曲线怎么选直接线性渐变的效果其实挺生硬——文字从头到尾以恒定速度浮现视觉上没有层次感。我推荐用AnimationCurve来控制透明度曲线的形状这个组件在Inspector里可以直接拖拽调整曲线。常用的几类曲线EaseInOut开头慢、中间快、收尾慢适合显示正式内容像一句话缓缓浮现。EaseOut开头快、收尾慢适合表示重要内容马上出现。线性匀速适合打字机风格的滚动字幕。做对话系统我推荐默认用EaseInOut玩家能看到内容已经开始出现的暗示又不会觉得突兀。如果你想在代码里直接指定曲线而不依赖Inspector里的曲线资源也可以用AnimationCurve.EaseInOut(0,0,1,1)生成或者用Keyframe自己拼AnimationCurve curve new AnimationCurve( new Keyframe(0f, 0f), new Keyframe(1f, 1f) ); // 调整切线类型 curve.SmoothTangents(0, 0f); curve.SmoothTangents(1, 1f);4.2 多段文字的顺序渐显对话里经常有第一句渐显过2秒渐显第二句再过2秒渐显第三句这样的需求。同一个状态机类可以直接复用但排列起来容易写成一坨屎山。我习惯把它们放在一个简单的队列管理器里public class SequentialTextFader : MonoBehaviour { public TextFadeStateMachine[] textFaders; public float interval 2f; private int m_CurrentIndex 0; private float m_Countdown 0f; private void Update() { if (m_CurrentIndex textFaders.Length) return; m_Countdown - Time.unscaledDeltaTime; if (m_Countdown 0f) { textFaders[m_CurrentIndex].Show(); m_CurrentIndex; m_Countdown interval; } } public void PlayAll() { m_CurrentIndex 0; foreach (var fader in textFaders) fader.ResetDisplay(); m_Countdown 0f; } }注意这段代码里每一个fader就是一个独立的状态机它们各自管理自己的状态。顺序管理器只负责在合适的时间触发下一个不关心内部具体状态。这其实就是状态机组合的好例子——每个对象的状态是内聚的对象间的时序是编排的两边解耦。4.3 代码组织状态机脚本怎么放我习惯把状态机脚本挂在容纳Text的同一个GameObject上这样从Hierarchy面板里就能直观看到这个对象带了一个文本渐显控制器。如果Text在子物体也没问题直接拖拽引用即可。脚本内部的字段布局对使用体验影响很大。我把Inspector面板里需要调整的参数都标[Header]方便美术和策划同事直接调[Header(渐显参数)] public float fadeDuration 0.8f; public AnimationCurve fadeCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);fadeDuration暴露出来就可以在不改代码的情况下按段调整渐显快慢。策划有时候觉得这句台词更庄重需要更慢的渐显直接在Inspector里改数值。5. 踩坑实录常见问题与排查技巧5.1 文字渐显没效果一查是alpha被覆盖这是最常遇到的问题。场景里如果有其他脚本同时在改Text的透明度——例如UI淡入淡出管理器、Tween插件、或者某个通用组件每帧重置color——状态机改完的alpha就被覆盖了。排查思路在SetAlpha里加一下Debug.Log打印改后的alpha值。如果日志里alpha一直在变化但画面里没变说明有其他脚本在你设置之后又重置了。用Hierarchy面板搜索同类组件逐个禁用排查。这个问题在多人协作项目里尤其常见。我自己的做法是给脚本加一个[DisallowMultipleComponent]标记并且文档里注明本组件独占Text.color.a的控制权避免别人无意识再加一个冲突组件。5.2 渐显过程中文字内容闪了一下前面提过的Show(string newContent)里如果先赋值再置零透明度会有极小概率在那一帧让玩家看到旧文字闪烁。修复方案就是先置零透明度再赋内容public void Show(string newContent) { SetAlpha(0f); // 先隐去 text.text newContent; // 再改内容 m_State TextDisplayState.FadingIn; m_Timer 0f; }很多人会觉得就差一帧有什么关系但游戏里一帧的闪烁在快速对话中真的会被注意到尤其当新内容和旧内容反差很大时。养成先隐再改的习惯能避免这个问题。5.3 状态卡在FadingIn里不动排查m_Timer的增长来源。如果用了Time.deltaTime但场景处于暂停状态计时器不会增长状态永远不会达到终点。解决方式很简单——要么用unscaledDeltaTime要么在检测到暂停时强制切到Visible。另外注意如果fadeDuration配的是0或者负数m_Timer / fadeDuration会变成无穷大或负数Mathf.Clamp01会异常。加个保护if (fadeDuration 0f) { SetAlpha(1f); m_State TextDisplayState.Visible; return; }5.4 状态机还没有状态切换有时候外部调用者期待调用Show()之后State属性的值立刻变为FadingIn。但如果你在Show()里用Debug.Log验证却发现State还没变——原因是外部在Awake或Start阶段调用Show()而Awake里做了m_State Idle的初始化覆盖了它。解决办法把初始化逻辑从Awake挪到Start或者把Show()放在LateUpdate之后。建议是——状态机的初始化统一在Start里做避免Awake阶段的调用被覆盖。5.5 多Text渐显时不同步如果多个Text要同时渐显各挂一个状态机后每帧的Time.deltaTime不同步渐显曲线会错开。解决方式方案一共用一个驱动类在这个类里维护统一的时间进度给所有Text设置同一个alpha。方案二用同一帧的Time.time作为计时基准各状态机读取同一时刻的进度。我遇到这个坑是在做一行标题一行副标题同时浮现时两个文字各自渐显结果一个快一个慢观感很怪。后来换成统一驱动类解决。单Text场景不需要考虑这点但多Text场景务必统一时间源。5.6 Text组件被禁用/不可见时渐显不生效UGUI的Text如果挂在SetActive为false的物体上修改color.a不会显示任何视觉变化因为渲染管线直接跳过了这个物体。排查方法是检查GameObject的激活状态以及父级Canvas是否active。还有一个隐藏坑如果Text的RaycastTarget保持true渐显过程中即使它透明度很低也仍然会拦截鼠标射线。玩家可能看不见但点不到按钮就会发现UI点击无响应。建议在不需要交互时关掉RaycastTarget尤其在渐显过程中。6. 这个小方案还能怎么扩展6.1 加入打字机效果渐显只是文字出现的一种方式另一种常见需求是打字机——逐字打出。状态机照样能管把FadingIn改成TypingUpdate逻辑从计算整体alpha变成根据时间计算显示字符数。状态机框架不用变只改一个状态的具体行为即可。经典打字机效果还带字符闪烁和音效这些都可以塞进Typing状态的处理逻辑里。用状态机的代码组织方式比用协程逐字等待更好调试。6.2 对接配置表如果对话文本是配置表驱动的JSON、Excel、或者本地化文件Show(string content)接口天然适配——配置表读出文本后直接传入不需要关心状态机内部实现。这也是我把状态机的公共接口设计成Show / Skip / ResetDisplay的原因外部只关心行为语义不关心内部状态细节。6.3 复用同一套逻辑控制Sprite渐显状态机并不局限于Text组件它控制的本质是一个float类型的alpha值。把text.color换成Image.color代码同样成立。因为你只需要抽象出控制一个Canvass元素的透明度这个概念然后把这个概念落成一个接口。我后续在项目里就是用这套状态机逻辑同时控制了对话背景的渐显——同一个框架换一个目标对象仅此而已。写在最后的个人体会这个方案最让我满意的地方不是代码有多巧妙而是它把我从一团乱麻的协程里解放了出来。以前调试对话流程最痛苦的就是这一秒到底在跑哪个协程现在只需看一眼State属性立刻知道当前处于什么状态。状态机的本质就是强制你梳理逻辑把所有可能的流程画成一张可验证的图然后照着图实现。如果你手头的项目正在被渐显逻辑折磨别急着加插件先画一张状态迁移图。画完你会发现百分之九十的混乱都来源于不知道当前处于什么状态。把这个搞清楚代码反而简单了。