很多第一次做锻造、开箱、抽卡这类“结果结算”系统的开发者,往往会有同样一个困惑:为什么别人做的锻造结算面板弹出时,能带出那种“瞬间上头的爽感”,而自己做的时候,最多就是让面板淡入、放个图标、显示几行文字?
这不是美术资源的问题。问题在于,结果结算型 UI 动效本质上不是一个“动画”,而是一个被完整编排的“流程状态机”。锻造结算尤其典型:材料飞入、炉火燃烧、等待判定、结果面板弹出、装备图标弹跳、属性飘字、按钮出现……这些环节如果靠一个超大 Animator Controller 硬拼,或者靠一堆散落的动画片段互相通信,最终一定会陷入“改一个时间参数到处爆炸”的困境。
本文会用一个可以跑通的最小项目,把锻造结算 UI 动效从流程拆解到代码实现完整讲一遍。你会看到为什么用协程 + AnimationCurve + 粒子配合,是最适合这类结果型 UI 动效的组合方式;也会看到真正容易踩坑的地方在 Canvas 层级、粒子显示、时间缩放和流程打断这几个点上。这篇文章面向的是 Unity 开发者,需要用 UGUI 做界面,并且希望把动效从“纯靠美术播放一个动画”升级为“程序可控制的动效流程”。如果这是你的现状,那这篇文章适合你现在读下去。
1. 这篇文章真正要解决的问题
先想清楚一个问题:锻造结算这类动效,到底难在哪?
如果只看表面,它无非是“弹窗 + 图标 + 文字 + 特效”,好像没什么技术含量。但真正做过的人会知道,它难在四个地方。
第一,动的对象是 UI,不是 3D 模型。3D 特效可以随便用粒子、Shader、物理模拟,但 UI 特殊效果跑在 Canvas 上,受 Canvas 重建、Mask、图集、Text 排版、CanvasGroup 交互状态影响。一个弹窗从 A 点移动到 B 点,在 3D 场景里只是移动 Transform,在 UI 里还可能触发 Layout 重排。
第二,流程节点多。锻造结算不是单一动画,而是多段动画的串联和并联。材料消失、炉子震动、闪光特效、面板弹出、装备展示、飘字、按钮出现,顺序错了视觉感受就完全不对。用 Animation Clip 做单段没问题,做整条时间线很容易失控。
第三,结果数据是动态的。每个玩家的锻造结果不同,装备图标、名称、品阶、属性加成、成功失败文案都不一样。纯动画做不到把动态数据塞进固定时间轴,必须由代码在合适的时机填充。
第四,必须能被打断。玩家不会老老实实等完 2 秒流程。可能点击“再锻造一次”,可能点击“领取”,可能直接切后台。如果流程控制没有统一的停止机制,就会出现“弹窗已经关了,特效还在播”“按钮已经能点了,遮罩还挡着”这种低级问题。
所以这篇文章要解决的不是“怎么做一段好看的动画”,而是“怎么用工程化的方式组织这段动画”。我会从流程拆解开始,最终给出一套原生的、不依赖第三方插件的 C# 实现。
2. 锻造结算动效的典型流程与设计拆解
2.1 一个完整的锻造结算动效包含哪些阶段
不同游戏锻造流程不完全一样,但结果结算的核心阶段高度相似。以常见的“材料放入 → 炉中锻造 → 结果揭示”三段式为例,可以拆成下面 6 个阶段。
| 阶段 | 画面内容 | 动效类型 | 控制者 |
|---|---|---|---|
| 1. 材料进炉 | 材料图标依次缩小飞向炉心 | 位移 + 缩放 | 代码补间 |
| 2. 熔炼过程 | 炉子燃烧、震动、光效 | 序列帧 + 粒子 | 粒子/帧动画 |
| 3. 判定等待 | 炉子闪烁,屏幕偶尔微震 | 序列帧 + 相机震动 | 粒子/帧动画 |
| 4. 揭示结果 | 白光闪过,结果面板弹出 | 闪光 + CanvasGroup 淡入 | 代码 + 特效 |
| 5. 装备展示 | 图标弹跳放大,品阶边框发光 | 缩放 + 弹性曲线 | 代码补间 |
| 6. 收尾操作 | 属性飘字,按钮出现 | 位移 + 淡入 | 代码补间 |
从这个表可以看出,真正需要“美术做一段完整动画”的部分只有炉子燃烧和闪光特效,其他部分用代码补间完全能控制。这也是本文的判断:结果型 UI 动效应该以代码流编排为主,以美术素材作为辅助原件。
2.2 为什么要用“代码 + Animation + 粒子”混合实现
一种常见的保守做法是:美术用 Animation Clip 把整个结算过程拍成一整条动画,程序在最后播放一下 Clip。
这个方案在简单原型阶段可行,但迭代一两次就会暴露问题:策划说“结果面板提前 0.2 秒出现”“装备图标想多弹一下”“飘字想从右侧出来”,美术需要重新反复调整动画曲线,还要担心跟动态数据结合的部分。因为数据是代码注入的,纯动画没法做到“根据数据改变出现顺序”。
改用“代码补间 + 粒子/序列帧”之后,收益很明显:
- 时间轴完全由代码控制,改时间参数就是改几个 float。
- 数据注入变得自然,比如图标变化、文案变化,只需要在阶段切换时赋值。
- 特效和 UI 弹动分离,序列帧播放器负责华丽,代码负责节奏。
- 打断和高频触发变得安全,直接 StopAllCoroutines 即可。
代价是需要写少量代码,但这份代码是可复用组件,而不是一次性的动画片段。
3. 环境准备与项目结构
3.1 版本与基础选型
本文的示例基于 UGUI 方案,也就是 UnityEngine.UI。推荐使用 Unity 2021.3 LTS 或更高版本。使用不同版本的 Unity 时,UGUI API 基本保持兼容,所以代码可以通用。
关于选型,有两点需要说明。
第一,这里不依赖 DOTween。虽然 DOTween 确实能简化补间,但本文想讲清楚原理,所以用 Unity 原生 API 自己实现轻量补间。理解了原理后,你完全可以换成 DOTween 简化代码。
第二,UI 使用 UGUI 而不是 UI Toolkit,因为游戏项目的 UI 特效、粒子、Mask 生态在 UGUI 上更成熟。UI Toolkit 正在发展,但目前做游戏内复杂的“结算爆点”效果,UGUI 仍然是最主流选择。
3.2 需要的素材与资源
要跑通本文的完整流程,需要准备以下素材:
- 一张装备图标 Sprite,用于显示锻造结果。
- 一张品阶边框 Sprite,可选,用于表现高级装备。
- 一组炉火序列帧,或一个简单的火焰粒子系统。
- 一道白光序列帧或粒子,用于结果揭示时的闪光。
- 一款中文字体,用于显示装备名称和属性。
如果暂时没有序列帧素材,可以用纯粒子代替,或者先用一张白色圆环 Sprite 模拟光效。重点是代码流程,素材可以后补。
3.3 场景层级结构建议
锻造结算面板的层级结构,直接决定你后面调 Sort Order 会不会想砸电脑。建议按“遮罩层 → 内容层 → 特效层 → 交互层”来组织。
ForgeResultCanvas (Screen Space - Camera 或 Overlay) ├── DimLayer // 全屏黑色半透明,用于压暗背景 ├── ResultPanel // 结果面板主体 │ ├── HeaderText // “锻造结果”标题 │ ├── EquipIcon // 装备图标 │ ├── EquipNameText // 装备名称 │ ├── AttributeText // 属性加成文本 │ └── BottomButtons // “再锻造一次”“领取”按钮 ├── EffectLayer // 特效层,粒子、序列帧放这里 └── TouchBlockLayer // 流程中阻止玩家点击的透明层这里有个细节值得注意:很多新手把所有 UI 都塞进同一个 Canvas,然后用层级顺序控制遮挡。在结算这种多特效场景里,更稳妥的做法是给结算面板单独开一个 Canvas,并设置独立的 SortingOrder。比如主 UI 的 SortingOrder 是 100,结算 Canvas 就是 200,特效 Canvas 可以是 300。这样即使主 UI 后续有改动,也不容易盖住结算特效。
4. 动效核心:用协程控制完整流程
4.1 流程控制的思路
锻造结算最容易写乱的地方,就是把所有段落的代码揉在一个方法里,用一堆嵌套的 if 和 Invoke 控制时间。这非常容易出错。
更合适的方式是把它当成一个“阶段序列”,每个阶段就是一个方法或一段迭代器逻辑,用协程按顺序执行。
这样做的好处有三点:
- 每段逻辑独立,便于单独调整时间参数。
- 可以随时 StopAllCoroutines 打断整个流程。
- 阶段之间的衔接一目了然,不会出现“一个动画播完忘了让另一个动画开始”的问题。
4.2 协程中的阶段推进
先看一个最小示例,理解阶段推进的骨架。
// 文件路径:Assets/Scripts/Forge/ForgeResultPanel.cs using System.Collections; using UnityEngine; using UnityEngine.UI; public class ForgeResultPanel : MonoBehaviour { private void Start() { StartCoroutine(PlayForgeResultSequence()); } private IEnumerator PlayForgeResultSequence() { // 阶段1:材料进炉 yield return StartCoroutine(Stage_CollectMaterial()); // 阶段2:炉火燃烧 yield return StartCoroutine(Stage_Burning()); // 阶段3:闪光 + 面板淡入 yield return StartCoroutine(Stage_ShowResult()); // 阶段4:图标弹跳 yield return StartCoroutine(Stage_IconBounce()); // 阶段5:飘字 + 开放按钮 yield return StartCoroutine(Stage_RewardInput()); } private IEnumerator Stage_CollectMaterial() { // 这里暂时用空逻辑占位,后面章节替换为真实补间 yield return new WaitForSeconds(0.3f); } private IEnumerator Stage_Burning() { // 播放粒子或序列帧 yield return new WaitForSeconds(1.0f); } // 其余阶段方法省略,会在第 7 节给出完整实现 }这个例子的价值在于“结构先行”。每一段逻辑被封装成一个协程方法,上一段结束、下一段开始,时间点都通过协程的 yield 天然控制。后面你调整某一阶段的时间,不会影响其他阶段。
一个小提醒:yield return StartCoroutine(...)表示“等这个协程完整跑完再继续下一行”。这是常用且安全的写法,比直接yield return new WaitForSeconds(...)更容易理解和维护。
5. 动效核心:弹性缩放与缓动曲线
5.1 为什么要自己写缓动
Unity 的 Animation Clip 确实支持曲线编辑,但如果是代码控制数据填充,用 AnimationCurve 更顺手。
AnimationCurve 的核心用法是:给你一个 0 到 1 的输入,经过曲线映射得到一个新的 0 到 1 输出。你只需要在 Update 循环里,把时间进度归一化传入曲线,再把这个输出应用到目标属性上。
以“装备图标弹跳”为例,希望的效果是:先快速放大超过 100%,再回弹到 100% 附近。这个效果最简单的实现方式,就是设计一条“先冲高再回落”的曲线。
5.2 弹性动画实现
// 文件路径:Assets/Scripts/Forge/UiTweenRunner.cs using System; using System.Collections; using UnityEngine; public static class UiTweenRunner { // 让 rect 从 startScale 过渡到 endScale,中间用 curve 控制节奏 public static IEnumerator ScaleRect(RectTransform rect, float startScale, float endScale, float duration, AnimationCurve curve) { float t = 0f; while (t < duration) { t += Time.deltaTime; float p = Mathf.Clamp01(t / duration); float curveValue = curve.Evaluate(p); float currentScale = Mathf.LerpUnclamped(startScale, endScale, curveValue); rect.localScale = Vector3.one * currentScale; yield return null; } rect.localScale = Vector3.one * endScale; } // 让 canvasGroup 从 fromAlpha 过渡到 toAlpha public static IEnumerator FadeCanvas(CanvasGroup group, float fromAlpha, float toAlpha, float duration, AnimationCurve curve) { float t = 0f; while (t < duration) { t += Time.deltaTime; float p = Mathf.Clamp01(t / duration); float curveValue = curve.Evaluate(p); group.alpha = Mathf.LerpUnclamped(fromAlpha, toAlpha, curveValue); yield return null; } group.alpha = toAlpha; } }这个UiTweenRunner看起来很简单,但它解决了动态数据 + 动效控制的核心问题:你想要哪个 UI 元素在什么时间段做哪种运动,就调用对应的静态方法。参数全部来自外部,不需要创建一大堆 Animation Clip。
调用方式也很直观:
// 文件路径:Assets/Scripts/Forge/ForgeResultPanel.cs 中的调用示例 private IEnumerator Stage_IconBounce() { // 先快速放大到 1.15,再回落到 1.0 yield return StartCoroutine(UiTweenRunner.ScaleRect( equipIconRect, 1f, 1.15f, 0.12f, bounceCurve )); yield return StartCoroutine(UiTweenRunner.ScaleRect( equipIconRect, 1.15f, 1f, 0.18f, backCurve )); }这里的bounceCurve可以是一条快速上升的曲线,backCurve是一条缓出曲线。在 Inspector 中编辑这两个曲线时,你就能直观感受到“调整曲线比调整动画片段快得多”。
有一点要注意:如果目标对象是 RectTransform,修改 localScale 时要注意它是否被 LayoutGroup 控制。如果弹窗处于 HorizontalLayoutGroup 或 VerticalLayoutGroup 中,localScale 的修改会被布局系统干扰。建议做动画的节点不要放在 LayoutGroup 内部。
6. 序列帧特效与粒子特效的接入
6.1 序列帧播放
炉火燃烧、闪光这类效果,美术最常给到的资源是一组序列帧。在 UI 中播放序列帧,最简单的方式是写一个帧动画组件,逐帧切换 Sprite。
// 文件路径:Assets/Scripts/Forge/UiSpriteFrameAnimator.cs using UnityEngine; using UnityEngine.UI; public class UiSpriteFrameAnimator : MonoBehaviour { public Image targetImage; public Sprite[] frames; public float frameRate = 24f; private float timer; private int frameIndex; private void OnEnable() { timer = 0f; frameIndex = 0; } private void Update() { if (frames == null || frames.Length == 0 || targetImage == null) { return; } timer += Time.deltaTime; float interval = 1f / frameRate; while (timer >= interval) { timer -= interval; frameIndex = (frameIndex + 1) % frames.Length; targetImage.sprite = frames[frameIndex]; } } }这个组件的使用方式很简单:把序列帧 Sprite 数组拖入frames,把目标 Image 拖入targetImage。播放到最后一帧后会循环。如果需要“播完一次后停止”,可以加一个playOnce开关,播放到最后一帧后直接禁用脚本。
序列帧的帧动画播放器放在EffectLayer下面,并且要保证它的父节点在 Canvas 中的 SortingOrder 高于结果面板。
6.2 粒子特效在 UI 里显示的注意事项
直接在一个 Screen Space - Overlay 的 Canvas 下放 ParticleSystem 是常见的坑。因为 Overlay 模式下 UI 没有世界坐标概念,粒子系统默认在世界空间中渲染,会出现“看不见粒子”或者“粒子出现在某个奇怪的世界坐标”的问题。
处理 UI 粒子显示,有几种常用方案:
- 将粒子系统的根节点放在一个使用世界坐标定位的子节点下,并把它放到 UI 相机可渲染的范围内。
- 将整个结算 Canvas 设置为 Screen Space - Camera,并给粒子节点一个合适的摄像机距离。
- 使用 RenderTexture 将粒子渲染到 RawImage 上。
- 引入 UIParticle 这类三方组件,它会通过位移补偿把粒子贴合到 UI 坐标。
从稳定性和性能角度看,如果项目里已经普遍使用 Screen Space - Camera,那么粒子直接放进 Canvas 下的子节点,并设置粒子系统为“按需要显示”,再调好 SortingOrder,是最省事的方案。如果整个项目都是 Overlay 模式,建议用 RenderTexture 或 UIParticle,而不是强行让默认粒子系统出现在 Overlay UI 中。
还有一个容易被忽略的细节:粒子系统的材质。UI 上的粒子通常需要半透明叠加效果,默认的 Default-Particle 材质在某些 UI 层级上可能显示不正常。更稳妥的是使用 UI 常用的 Additive Shader 材质,这样粒子会呈现出“发光”的叠加感,更符合锻造结算的华丽需求。
7. 完整示例代码:ForgeResultPanel
前面几节讲的是碎片化方案,这一节把它们整合成一个可以运行的面板控制器。这个控制器不依赖第三方库,你可以直接复制到一个新的 C# 脚本里测试。
7.1 数据类与主控制器
首先定义一个锻造结果数据类,用来模拟服务器返回的数据。
// 文件路径:Assets/Scripts/Forge/ForgeResult.cs using System; using UnityEngine; [Serializable] public class ForgeResult { public bool isSuccess; public Sprite itemIcon; public string itemName; [TextArea] public string attributeText; }然后实现主控制器。它会按照“遮罩淡入 → 材料进炉 → 燃烧等待 → 白光 → 面板弹出 → 图标弹跳 → 飘字 → 按钮开放”的完整流程推进。
// 文件路径:Assets/Scripts/Forge/ForgeResultPanel.cs using System.Collections; using UnityEngine; using UnityEngine.UI; public class ForgeResultPanel : MonoBehaviour { [Header("CanvasGroup 控制")] public CanvasGroup panelGroup; public CanvasGroup dimGroup; [Header("核心节点")] public RectTransform resultPanelRect; public CanvasGroup effectGroup; [Header("内容展示")] public Image equipIcon; public Text itemNameText; public Text attributeText; public GameObject rewardButtons; [Header("特效")] public GameObject whiteFlashEffect; // 白光特效,默认隐藏 public GameObject furnaceEffect; // 炉火特效,默认隐藏 [Header("曲线")] public AnimationCurve panelInCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); public AnimationCurve bounceUpCurve = AnimationCurve.EaseOut(0f, 0f, 1f, 1f); public AnimationCurve bounceDownCurve = AnimationCurve.EaseIn(0f, 0f, 1f, 1f); [Header("时间参数")] public float dimFadeTime = 0.25f; public float materialFlyTime = 0.4f; public float burningTime = 1.2f; public float panelInTime = 0.35f; public float iconBounceUpTime = 0.12f; public float iconBounceDownTime = 0.18f; public float rewardDelayTime = 0.3f; private WaitForSeconds burningWait; private WaitForSeconds rewardDelay; private void Awake() { panelGroup.alpha = 0f; dimGroup.alpha = 0f; panelGroup.interactable = false; panelGroup.blocksRaycasts = false; rewardButtons.SetActive(false); if (whiteFlashEffect != null) { whiteFlashEffect.SetActive(false); } if (furnaceEffect != null) { furnaceEffect.SetActive(false); } burningWait = new WaitForSeconds(burningTime); rewardDelay = new WaitForSeconds(rewardDelayTime); } public void PlayForgeResult(ForgeResult data) { if (data == null) { return; } equipIcon.sprite = data.itemIcon; itemNameText.text = data.itemName; attributeText.text = data.attributeText; StartCoroutine(PlaySequence(data)); } public void StopAllEffect() { StopAllCoroutines(); panelGroup.alpha = 1f; panelGroup.interactable = true; panelGroup.blocksRaycasts = true; resultPanelRect.localScale = Vector3.one; if (whiteFlashEffect != null) { whiteFlashEffect.SetActive(false); } if (furnaceEffect != null) { furnaceEffect.SetActive(false); } } private IEnumerator PlaySequence(ForgeResult data) { // 1. 遮罩淡入,并显示面板容器 yield return StartCoroutine(FadeGroup(dimGroup, 0f, 0.7f, dimFadeTime)); panelGroup.alpha = 1f; // 2. 材料进炉:这里用一个简单的缩放演示“飞入炉心”的感觉 yield return StartCoroutine(ScaleRect(resultPanelRect, 0.6f, 1f, materialFlyTime, panelInCurve)); // 3. 炉火燃烧 if (furnaceEffect != null) { furnaceEffect.SetActive(true); } yield return burningWait; if (furnaceEffect != null) { furnaceEffect.SetActive(false); } // 4. 揭示结果:白光闪过,同时面板真正淡入 if (whiteFlashEffect != null) { whiteFlashEffect.SetActive(true); } yield return StartCoroutine(FadeGroup(panelGroup, 0f, 1f, panelInTime)); if (whiteFlashEffect != null) { yield return new WaitForSeconds(0.1f); whiteFlashEffect.SetActive(false); } // 5. 装备图标弹跳 yield return StartCoroutine(ScaleRect(equipIcon.rectTransform, 1f, 1.2f, iconBounceUpTime, bounceUpCurve)); yield return StartCoroutine(ScaleRect(equipIcon.rectTransform, 1.2f, 1f, iconBounceDownTime, bounceDownCurve)); // 6. 飘字延迟展示,并开放操作按钮 attributeText.gameObject.SetActive(true); yield return rewardDelay; rewardButtons.SetActive(true); panelGroup.interactable = true; panelGroup.blocksRaycasts = true; effectGroup.alpha = 1f; } private IEnumerator FadeGroup(CanvasGroup group, float from, float to, float duration) { float t = 0f; while (t < duration) { t += Time.deltaTime; float p = Mathf.Clamp01(t / duration); group.alpha = Mathf.LerpUnclamped(from, to, p); yield return null; } group.alpha = to; } private IEnumerator ScaleRect(RectTransform rect, float from, float to, float duration, AnimationCurve curve) { float t = 0f; while (t < duration) { t += Time.deltaTime; float p = Mathf.Clamp01(t / duration); float curveValue = curve.Evaluate(p); float currentScale = Mathf.LerpUnclamped(from, to, curveValue); rect.localScale = Vector3.one * currentScale; yield return null; } rect.localScale = Vector3.one * to; } }这段代码有几个关键点要说明。
StopAllEffect方法本质上是流程安全阀。当玩家快速点击“再锻造一次”时,旧的流程必须被强行终止,否则会出现“两个流程同时跑,一个协程设置 alpha 为 1,另一个协程又把 alpha 拉回 0”的互踢问题。
WaitForSeconds在 Awake 里预创建,是减少 GC 的小优化。频繁创建WaitForSeconds会产生垃圾回收压力,在移动端尤其应该避免。
panelGroup.alpha = 1f在遮罩淡入阶段就被设置了,但面板是隐藏内容还是显示内容,由子节点的初始状态和 rewardButtons 控制。这样可以保证“面板容器先存在,但给玩家的感觉是结果还没揭晓”。
7.2 场景挂载与运行验证
在场景中创建一个空节点,取名为ForgeResultCanvas,挂上ForgeResultPanel脚本。随后创建子节点,把对应的 Image、Text、CanvasGroup 拖到脚本的 Inspector 字段上。
测试方式:给一个测试按钮绑定下面的事件,或者直接在某个协程里调用。
// 调用入口示例 ForgeResult fakeData = new ForgeResult { isSuccess = true, itemName = "烈焰战斧", attributeText = "攻击 +12\n暴击 +2%" }; GetComponent<ForgeResultPanel>().PlayForgeResult(fakeData);运行后,预期看到的效果顺序是:全屏变暗 → 结果面板缩放出现 → 炉火播放约 1.2 秒 → 白光一闪 → 面板淡入 → 装备图标先放大到 120% 再回落 → 属性文案展示 → 底部按钮出现。
如果某个阶段没有发生,按顺序检查:场景层级里那个节点有没有被其他节点遮挡;对应的 Image/Text 是否被赋值了错误的对象;canvasGroup.blocksRaycasts是否在测试时误设为 false。
8. 常见问题与排查思路
下面这些问题是做 UI 结算动效时高频出现的,按投入产出比来说,值得提前做成表格贴在脚本注释里。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 面板出现但按钮点不了 | panelGroup.interactable 或 blocksRaycasts 仍为 false | 在 OnGUI 或 Inspector 中查看 CanvasGroup 状态 | 流程最后把 interactable 和 blocksRaycasts 恢复为 true |
| 粒子看不见 | Canvas 是 Screen Space - Overlay,粒子没有对应世界坐标 | 检查 Canvas RenderMode 和粒子根节点位置 | 改成 Screen Space - Camera,或者用 RenderTexture / UIParticle |
| 粒子把整个 UI 盖住 | 粒子 Canvas SortingOrder 过高 | 检查 SortingOrder 数值 | 特效层高于结果面板即可,不要高于全局弹窗 |
| 动画突然停顿 | 协程被 StopAllCoroutines 打断 | 检查是否有其他脚本调用了 StopAllCoroutines | 尽量只在该面板脚本内部调用,或改用独立的协程持有者对象 |
| 真空版本下动画变快/变慢 | 使用了 Time.deltaTime,但全局 Time.timeScale 被修改 | 检查是否在暂停游戏时触发了结算动画 | 结算动画如果存在于暂停场景,改用 Time.unscaledDeltaTime |
| 图标弹跳被 LayoutGroup 拉回来 | RectTransform 所在节点受 LayoutGroup 控制 | 检查目标父节点是否有 Layout 组件 | 动画节点不要放在 LayoutGroup 内部,或放到独立节点 |
| 重新播放时特效残留 | 上次的粒子还没销毁,序列帧还停在最后一帧 | 检查 OnEnable 是否重置状态 | 在 PlayForgeResult 开头重置所有特效节点状态 |
这里重点说下 Time.deltaTime 的问题。很多游戏在进入锻造界面后会暂停战斗逻辑,但如果直接把 Time.timeScale 设为 0,UI 动效也会跟着停。如果希望结算动画在暂停状态下继续播放,需要把协程里的Time.deltaTime全部换成Time.unscaledDeltaTime,并且在方法注释里明确标注。反之,如果希望结算动画跟随暂停,就继续用 deltaTime。这个选择必须在项目早期定好,否则后面大量 UI 动画都会受到影响。
9. 最佳实践与工程建议
9.1 用配置驱动时间轴
现在这段代码把所有时间参数都暴露成了公共字段,这已经比写死在代码里好很多。但更进一步的项目,建议把所有参数收进一个 ScriptableObject 配置资产,比如ForgeEffectConfig,内容包括每个阶段的时长、目标数值、曲线。这样策划可以直接在资产里调整手感,程序不需要反复改代码。
配置化的核心收益不是“少写几行代码”,而是“让动效参数具备版本管理能力”。你可以在 Git 里看到“这次手感调整只是改了一个配置资产”,而不是翻遍代码找数字。
9.2 特效对象池化
序列帧和粒子在移动端的开销都不小。尤其粒子系统频繁实例化和销毁,会引起 GC 波动和渲染状态切换。建议对“炉火”“白光”这类高频出现的特效做对象池。
对象池的做法很简单:场景里准备多个特效节点,启动时隐藏,需要时依次激活,回收时按规则复用。
// 文件路径:Assets/Scripts/Forge/SimpleEffectPool.cs using System.Collections.Generic; using UnityEngine; public class SimpleEffectPool : MonoBehaviour { public GameObject effectPrefab; public int preloadCount = 4; private Queue<GameObject> pool = new Queue<GameObject>(); private void Awake() { for (int i = 0; i < preloadCount; i++) { GameObject go = Instantiate(effectPrefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Spawn(Vector3 position, Quaternion rotation) { GameObject go; if (pool.Count > 0) { go = pool.Dequeue(); } else { go = Instantiate(effectPrefab, transform); } go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } public void Despawn(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }实际项目中,对象池可以做得更精细,比如粒子播完自动回收入池。这里给出最小示例,是为了提醒你:结果结算动效在生产环境中是会被高频反复触发的,不做对象池,后面性能报表会很难看。
9.3 Canvas 层级与 SortingOrder 的统一约定
一个大项目通常有多个 UI Canvas。如果没有约定,就会出现“不同的弹窗谁盖谁全凭挂载顺序”的混乱局面。
建议在项目里建立统一约定:
- 主界面 UI:SortingOrder 100
- 普通弹窗:SortingOrder 200
- 全屏过场 / 大特效:SortingOrder 300
- 结算/抽奖等强反馈面板:SortingOrder 250
- 特效叠加层:SortingOrder 280
这个数字不是标准,重要的是团队内统一。如果你发现某个特效就是反复被其他 UI 遮挡,优先检查 SortingOrder,而不是去调节点顺序。
9.4 关注移动端渲染压力
锻造结算这类“爆点”场景很容易堆特效,尤其是在手机端。
需要注意几个点:
- 粒子系统的 Max Particles 要限制,单个结算流程不建议超过 300 个粒子。
- 序列帧的贴图要压缩并打入图集,逐帧切换大图是显存杀手。
- 尽量避免全屏实时模糊。如果背景模糊是刚需,优先用预烘焙模糊图,或做一次 RenderTexture 降采样模糊,而不是每一帧都在 UI 上做全屏后处理。
- 结算面板最好有自己的 Canvas,并禁用不必要的 Graphic Raycaster,减少 Canvas 的重建范围。
9.5 “再锻造一次”的高频触发边界
从交互上讲,玩家点击“再锻造一次”之后,服务端请求需要时间。这段时间内,按钮应该进入冷却状态,防止玩家连点导致多个并发的结算流程。
在 UI 层,可以这样控制:点击后用标志位isPlaying阻止重复触发;动画结束后再恢复。
private bool isPlaying; public void OnClickForgeAgain() { if (isPlaying) { return; } isPlaying = true; // 停止当前结算动画,重新开始 StopAllEffect(); StartCoroutine(WaitForRebuild()); } private IEnumerator WaitForRebuild() { yield return null; // 这里可以调用服务端请求的入口 // 等结果返回后再调用 PlayForgeResult isPlaying = false; }这个“互斥锁 + 停止旧流程 + 新流程重新排队”的思路,适用于所有结算类型界面。否则玩家连点 5 次,你的面板可能开出 5 个白光特效同时播放。
10. 总结与后续学习方向
做一个锻造结算 UI 动效,难点从来不在某一段动画怎么做,而在整个“结果展示流程”怎么编排。用协程串起阶段、用 AnimationCurve 控制弹性、用粒子或序列帧补充视觉冲击力,是目前结果型 UI 动效里工程成本最低、迭代成本也最低的组合方式。
写这篇文章时,我特意没有引入 DOTween,也没有引入复杂的 UI 特效插件。因为一旦理解了这个流程控制方式,替换第三方补间库只是语法层面的事情。核心不变:动效是流程,不是孤立动画。
下一步如果你想深入,可以沿着三个方向继续:
- 学习用 ScriptableObject 配置动效资产,把所有时间、曲线、目标纬度配置化,让策划自己调手感。
- 了解 UIParticle / RenderTexture 方案,解决 Overlay 模式下 UI 粒子渲染的更多细节。
- 研究如何把本文的流程写法封装成通用“结算播放器”,让抽卡、开箱、段位结算共用同一套动效框架。
建议你在自己的项目里先找一个最简单的弹窗,试着把流程拆成协程阶段,再逐步加入特效。你会发现,当动效变成可控制的流程之后,调手感这件事会变得顺畅很多。这套方法值得收藏备用,适合下次做任何“结果展示型”UI 时直接复用。