1. 项目概述不止于移动的MoveTowards在Unity开发中Vector3.MoveTowards()几乎是每个开发者入门时就会接触到的函数。它的官方描述很直白将当前点向目标点移动每次调用移动不超过maxDistanceDelta指定的距离。于是绝大多数教程和项目里它被简单地用在Update中驱动一个物体从A点“一格一格”地挪到B点代码看起来就像这样void Update() { transform.position Vector3.MoveTowards(transform.position, targetPosition, speed * Time.deltaTime); }这没错但如果你认为MoveTowards的能耐仅限于此那可能错过了它至少80%的实用价值。这个函数真正的威力在于其确定性和可预测性。它不像Lerp线性插值那样需要一个从0到1的t参数也不像SmoothDamp那样内部维护着速度状态。MoveTowards的每次调用只关心“从当前位置出发向目标最多能走多远”。这种纯粹性让它成为了一个绝佳的“进度驱动器”。当你把它从Update的桎梏中解放出来与协程Coroutine结合时一个全新的世界就打开了。协程允许我们将一段逻辑“拉长”到多个帧中执行并且可以精确地控制每帧的“进度”。MoveTowards则完美地充当了这个“进度”的量化执行者。这种组合能够以极简、高可控且性能友好的方式实现UI元素的淡入淡出、面板的平滑滑动、物体的复杂路径移动、数值的渐进变化等效果。今天要分享的就是这套被我称为“MoveTowards协程驱动模式”的完整配置流程与隐藏玩法它能让你的代码更清晰效果更丝滑。2. 核心原理为什么是MoveTowards协程在深入配置之前我们必须先理解为什么这个组合如此有效。这涉及到对几个核心概念的重新审视。2.1 MoveTowards的确定性优势Vector3.MoveTowards(current, target, maxDistanceDelta)的核心逻辑是计算从current指向target的方向向量然后沿着这个方向移动不超过maxDistanceDelta的距离。它保证了两点永不超调物体永远不会越过目标点。路径最短总是沿直线向目标移动。这种确定性意味着只要你控制了maxDistanceDelta你就完全控制了这一帧的移动“步长”。这个步长可以是一个固定速度乘以时间也可以是基于一个0到1的“进度值”计算出来的“剩余距离”。2.2 协程作为状态机与计时器协程的本质是一个迭代器它能在yield return语句处暂停并在下一帧或指定时间后恢复。这使得它天然适合处理随时间变化的状态。在一个协程里我们可以轻松地用一个while循环来持续执行逻辑直到满足某个条件如到达目标。在每帧开始时计算一个新的“进度”例如基于耗时占总时长的比例。将这个“进度”映射为MoveTowards的maxDistanceDelta。2.3 组合范式解耦与复用将移动/渐变的逻辑封装在一个协程里带来了巨大的好处解耦移动逻辑与Update生命周期解耦。你可以在任何时刻如点击按钮、触发事件时启动、停止、重启一个移动过程。可控你可以轻易地暂停yield return null只是等待一帧你可以等待任意条件、加速、减速或中途改变目标。复用一个写好的“平滑移动到目标”的协程既可以用来移动Transform也可以用来改变CanvasGroup.alphaUI透明度甚至可以用来插值Color、float等任何可以线性插值的类型通过Mathf.MoveTowards。这种范式将“做什么”移动到哪个目标和“怎么做”以多快、多平滑的方式移动清晰地分离开。3. 基础配置构建通用的平滑动画协程让我们从构建一个最基础、最通用的平滑动画协程开始。这个协程将作为我们所有效果的基石。3.1 核心协程SmoothMoveToTarget这个协程的目标是在指定的持续时间内将一个Vector3值从起始状态平滑地变化到目标状态。using System.Collections; using UnityEngine; public static class CoroutineAnimations { /// summary /// 通用平滑过渡协程用于Vector3位置、缩放等 /// /summary /// param namestartValue起始值/param /// param nameendValue目标值/param /// param nameduration持续时间秒/param /// param nameonUpdate每帧更新的回调传入当前插值/param /// param nameeasingFunc可选缓动函数用于改变进度曲线/param /// returns/returns public static IEnumerator SmoothMoveToTarget(Vector3 startValue, Vector3 endValue, float duration, System.ActionVector3 onUpdate, System.Funcfloat, float easingFunc null) { // 参数安全检查 if (duration 0) { onUpdate?.Invoke(endValue); // 立即跳到终点 yield break; } float elapsedTime 0f; while (elapsedTime duration) { elapsedTime Time.deltaTime; // 计算原始进度0到1 float rawProgress Mathf.Clamp01(elapsedTime / duration); // 应用缓动函数如果提供 float easedProgress easingFunc ! null ? easingFunc(rawProgress) : rawProgress; // 核心使用MoveTowards思想进行插值。 // 注意这里我们直接使用Vector3.Lerp因为我们已经有了进度值。 // 但“MoveTowards思想”体现在我们是通过控制每帧增加的进度elapsedTime来间接控制每帧的变化量。 // 一个更贴近MoveTowards本意的实现见下方“SmoothMoveToTargetWithSpeed”。 Vector3 currentValue Vector3.Lerp(startValue, endValue, easedProgress); onUpdate?.Invoke(currentValue); yield return null; // 等待下一帧 } // 确保最终状态精确 onUpdate?.Invoke(endValue); } /// summary /// 使用恒定速度单位/秒向目标移动的协程更贴近MoveTowards原意 /// /summary /// param namegetCurrent获取当前值的函数/param /// param nametargetValue目标值/param /// param namespeed移动速度单位/秒/param /// param nameonUpdate每帧更新的回调/param /// returns/returns public static IEnumerator SmoothMoveToTargetWithSpeed(System.FuncVector3 getCurrent, Vector3 targetValue, float speed, System.ActionVector3 onUpdate) { if (speed 0) yield break; float distance Vector3.Distance(getCurrent(), targetValue); while (distance 0.001f) // 设置一个很小的阈值避免无限循环 { Vector3 current getCurrent(); // 这才是直接使用MoveTowards每帧移动 speed * Time.deltaTime 的距离 Vector3 newPosition Vector3.MoveTowards(current, targetValue, speed * Time.deltaTime); onUpdate?.Invoke(newPosition); distance Vector3.Distance(newPosition, targetValue); yield return null; } onUpdate?.Invoke(targetValue); // 最终修正 } }关键点解析两种模式SmoothMoveToTarget是基于时间的确保动画在固定时长内完成与距离无关。SmoothMoveToTargetWithSpeed是基于速度的移动时间取决于起始点与目标的距离。UI渐变通常用基于时间的物体移动两者皆可。回调函数onUpdate这是解耦的关键。协程只负责计算每一帧“应该的值”至于这个值是用来设置transform.position还是localScale亦或是CanvasGroup.alpha由外部传入的回调函数决定。这使得协程完全通用。缓动函数EasingrawProgress是线性的从0到1。通过一个缓动函数如EaseInOutQuad对其进行变换可以得到加速、减速等效果。这是实现专业感动画的灵魂。循环条件基于时间的用elapsedTime duration判断基于速度的用剩余距离判断。注意基于速度的循环里需要一个很小的阈值如0.001f因为浮点数计算可能无法精确等于0。3.2 缓动函数库让动画富有生命力线性动画是生硬的。引入缓动函数能让动画拥有物理感或艺术感。这里提供一个常用的缓动函数集你可以直接复制使用。public static class EasingFunctions { // 线性 public static float Linear(float t) t; // 二次缓动Quad public static float EaseInQuad(float t) t * t; public static float EaseOutQuad(float t) 1 - (1 - t) * (1 - t); public static float EaseInOutQuad(float t) t 0.5f ? 2 * t * t : 1 - Mathf.Pow(-2 * t 2, 2) / 2; // 三次缓动Cubic public static float EaseInCubic(float t) t * t * t; public static float EaseOutCubic(float t) 1 - Mathf.Pow(1 - t, 3); public static float EaseInOutCubic(float t) t 0.5f ? 4 * t * t * t : 1 - Mathf.Pow(-2 * t 2, 3) / 2; // 正弦缓动Sine public static float EaseInSine(float t) 1 - Mathf.Cos((t * Mathf.PI) / 2); public static float EaseOutSine(float t) Mathf.Sin((t * Mathf.PI) / 2); public static float EaseInOutSine(float t) -(Mathf.Cos(Mathf.PI * t) - 1) / 2; // 弹性缓动Elastic - 提供overshoot效果 public static float EaseOutElastic(float t) { float c4 (2 * Mathf.PI) / 3; return t 0 ? 0 : (t 1 ? 1 : Mathf.Pow(2, -10 * t) * Mathf.Sin((t * 10 - 0.75f) * c4) 1); } }实操心得对于UI交互EaseOutQuad或EaseOutCubic是最常用、最安全的因为它模拟了物体启动很快、慢慢停下来的自然感觉。EaseInOutQuad适合循环或强调中间状态的动画。谨慎使用Elastic这类过冲效果虽然炫酷但在强调效率的界面中可能显得拖沓。4. 实战应用一UI元素的渐变与滑动现在我们将通用协程应用到具体的UI场景中。假设我们有一个CanvasGroup用于控制整体透明度和一个RectTransform用于控制面板位置。4.1 控制CanvasGroup淡入淡出淡入淡出本质上是将一个float值alpha从0变化到1或反之。我们可以使用Mathf.MoveTowards但更通用的是使用基于时间的Lerp。我们扩展之前的通用协程创建一个专门用于float的版本。// 在CoroutineAnimations类中添加 public static IEnumerator SmoothValueToTarget(float startValue, float endValue, float duration, System.Actionfloat onUpdate, System.Funcfloat, float easingFunc null) { if (duration 0) { onUpdate?.Invoke(endValue); yield break; } float elapsedTime 0f; while (elapsedTime duration) { elapsedTime Time.deltaTime; float rawProgress Mathf.Clamp01(elapsedTime / duration); float easedProgress easingFunc ! null ? easingFunc(rawProgress) : rawProgress; float currentValue Mathf.Lerp(startValue, endValue, easedProgress); onUpdate?.Invoke(currentValue); yield return null; } onUpdate?.Invoke(endValue); }使用示例using UnityEngine; using UnityEngine.UI; public class UIPanelFader : MonoBehaviour { public CanvasGroup panelCanvasGroup; public float fadeDuration 0.3f; private Coroutine _currentFadeCoroutine; // 淡入 public void FadeIn() { // 先停止可能正在进行的上一个淡入淡出动画防止冲突 if (_currentFadeCoroutine ! null) { StopCoroutine(_currentFadeCoroutine); } // 确保面板可交互性在动画开始时设置正确 panelCanvasGroup.blocksRaycasts true; _currentFadeCoroutine StartCoroutine(FadeRoutine(panelCanvasGroup.alpha, 1f)); } // 淡出 public void FadeOut() { if (_currentFadeCoroutine ! null) { StopCoroutine(_currentFadeCoroutine); } // 淡出开始时就可以禁止交互提升体验 panelCanvasGroup.blocksRaycasts false; _currentFadeCoroutine StartCoroutine(FadeRoutine(panelCanvasGroup.alpha, 0f)); } private IEnumerator FadeRoutine(float fromAlpha, float toAlpha) { yield return CoroutineAnimations.SmoothValueToTarget( startValue: fromAlpha, endValue: toAlpha, duration: fadeDuration, onUpdate: (currentAlpha) { panelCanvasGroup.alpha currentAlpha; // 如果需要可以根据alpha值微调interactable例如完全透明时才不可交互 // panelCanvasGroup.interactable currentAlpha 0.1f; }, easingFunc: EasingFunctions.EaseOutQuad // 使用缓出效果更自然 ); // 动画结束后可以在这里做一些清理或状态确认 // 例如淡出后彻底关闭GameObject // if (toAlpha 0) gameObject.SetActive(false); _currentFadeCoroutine null; } }注意事项管理协程引用用一个私有Coroutine变量_currentFadeCoroutine来保存当前运行的协程。在启动新动画前先停止旧的这是防止多个动画叠加导致逻辑混乱的关键技巧。交互性控制CanvasGroup的blocksRaycasts和interactable属性应与透明度动画同步管理。通常淡出开始时就可以禁用交互淡入完成后再启用。这能防止透明但可点击的“幽灵按钮”问题。性能与在Update中每帧判断并移动相比协程只在动画持续的帧数内运行动画结束即停止没有持续的性能开销。4.2 控制面板平滑滑入滑出面板滑动是改变RectTransform的anchoredPosition。我们可以直接使用处理Vector3的通用协程。public class UIPanelSlider : MonoBehaviour { public RectTransform panelRectTransform; public float slideDuration 0.4f; // 假设从屏幕右侧外滑入目标位置是屏幕中央 (0, 0) private Vector2 _offScreenRightPos new Vector2(1200, 0); // 根据你的Canvas分辨率调整 private Vector2 _onScreenCenterPos Vector2.zero; private Coroutine _currentSlideCoroutine; public void SlideIn() { if (_currentSlideCoroutine ! null) StopCoroutine(_currentSlideCoroutine); panelRectTransform.gameObject.SetActive(true); // 先激活 _currentSlideCoroutine StartCoroutine(SlideRoutine(_offScreenRightPos, _onScreenCenterPos)); } public void SlideOut() { if (_currentSlideCoroutine ! null) StopCoroutine(_currentSlideCoroutine); _currentSlideCoroutine StartCoroutine(SlideRoutine(panelRectTransform.anchoredPosition, _offScreenRightPos, () { // 滑动完成后的回调可以隐藏对象 panelRectTransform.gameObject.SetActive(false); })); } private IEnumerator SlideRoutine(Vector2 fromPos, Vector2 toPos, System.Action onComplete null) { yield return CoroutineAnimations.SmoothMoveToTarget( startValue: fromPos, endValue: toPos, duration: slideDuration, onUpdate: (currentPos) { panelRectTransform.anchoredPosition currentPos; }, easingFunc: EasingFunctions.EaseOutCubic // 滑动常用更强的缓动感觉更“弹” ); onComplete?.Invoke(); _currentSlideCoroutine null; } }踩坑记录RectTransform的位置操作要明确是anchoredPosition相对于锚点的位置还是localPosition。对于UI动画绝大多数情况使用anchoredPosition。在动画开始前设置gameObject.SetActive(true)至关重要否则RectTransform可能不在正确的层级里渲染导致动画不可见。5. 实战应用二游戏物体的复杂平滑移动对于3D/2D游戏物体平滑移动的需求更复杂。我们不仅要移动还要处理旋转、缩放甚至组合动画。5.1 基础点位移动与路径跟随使用基于速度的SmoothMoveToTargetWithSpeed可以方便地实现物体以恒定速度向目标点移动。public class ObjectMover : MonoBehaviour { public float moveSpeed 5.0f; private Coroutine _moveCoroutine; public void MoveToPoint(Vector3 targetWorldPos) { if (_moveCoroutine ! null) StopCoroutine(_moveCoroutine); _moveCoroutine StartCoroutine(MoveToPointRoutine(targetWorldPos)); } private IEnumerator MoveToPointRoutine(Vector3 targetPos) { yield return CoroutineAnimations.SmoothMoveToTargetWithSpeed( getCurrent: () transform.position, targetValue: targetPos, speed: moveSpeed, onUpdate: (newPos) { transform.position newPos; } ); Debug.Log(移动到达目标点); _moveCoroutine null; } }路径跟随如果需要按顺序经过多个点只需在一个协程中循环执行单点移动即可。public IEnumerator FollowPathRoutine(ListVector3 waypoints) { foreach (var point in waypoints) { yield return MoveToPointRoutine(point); // 等待移动到当前点 // 可以在这里添加到达点后的停顿 yield return new WaitForSeconds(0.5f); } }5.2 组合动画移动、旋转、缩放同步进行这是协程模式发光发热的地方。我们可以同时启动多个协程分别控制物体的不同属性它们会并发执行形成组合动画。public void PerformComplexAnimation(Vector3 targetPos, Vector3 targetScale, Quaternion targetRotation, float duration) { StartCoroutine(MoveRoutine(targetPos, duration)); StartCoroutine(ScaleRoutine(targetScale, duration)); StartCoroutine(RotateRoutine(targetRotation, duration)); } private IEnumerator MoveRoutine(Vector3 targetPos, float duration) { Vector3 startPos transform.position; yield return CoroutineAnimations.SmoothMoveToTarget(startPos, targetPos, duration, (pos) transform.position pos, EasingFunctions.EaseInOutSine); } private IEnumerator ScaleRoutine(Vector3 targetScale, float duration) { Vector3 startScale transform.localScale; yield return CoroutineAnimations.SmoothMoveToTarget(startScale, targetScale, duration, (scale) transform.localScale scale, EasingFunctions.EaseOutBack); // Back缓动有轻微过冲适合缩放 } private IEnumerator RotateRoutine(Quaternion targetRot, float duration) { Quaternion startRot transform.rotation; float elapsed 0; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); transform.rotation Quaternion.Slerp(startRot, targetRot, EasingFunctions.EaseOutQuad(t)); yield return null; } transform.rotation targetRot; }核心技巧对于旋转使用Quaternion.Slerp球面线性插值比Lerp效果更好能保证旋转路径是最短弧。同样可以将Slerp封装进一个类似SmoothMoveToTarget的通用协程中。6. 高级技巧与性能优化掌握了基础应用后我们来看看如何让这套系统更健壮、更高效。6.1 动画队列与中断处理在实际项目中动画请求可能频繁发生。一个健壮的系统需要处理动画的排队和中断。using System.Collections.Generic; public class AdvancedAnimationManager : MonoBehaviour { private QueueIEnumerator _animationQueue new QueueIEnumerator(); private Coroutine _currentProcess; private bool _isProcessingQueue false; public void EnqueueAnimation(IEnumerator animationCoroutine) { _animationQueue.Enqueue(animationCoroutine); if (!_isProcessingQueue) { StartCoroutine(ProcessQueue()); } } private IEnumerator ProcessQueue() { _isProcessingQueue true; while (_animationQueue.Count 0) { var nextAnimation _animationQueue.Dequeue(); // 执行当前动画并等待其完全结束 yield return StartCoroutine(nextAnimation); } _isProcessingQueue false; } // 立即中断当前所有动画并清空队列 public void StopAllAnimationsImmediately() { if (_currentProcess ! null) { StopCoroutine(_currentProcess); _currentProcess null; } _animationQueue.Clear(); _isProcessingQueue false; // 这里可能需要将物体重置到某个确定状态 } }使用方式animationManager.EnqueueAnimation(SlideInCoroutine()); animationManager.EnqueueAnimation(ScaleUpCoroutine()); // 两个动画会按顺序执行6.2 使用Unscaled DeltaTime应对Time.timeScale变化当游戏暂停Time.timeScale 0时所有依赖Time.deltaTime的动画都会停止。但UI动画如暂停菜单的弹出通常需要在游戏暂停时也能播放。这时需要使用Time.unscaledDeltaTime。修改我们的通用协程增加一个useUnscaledTime参数public static IEnumerator SmoothMoveToTarget(Vector3 startValue, Vector3 endValue, float duration, System.ActionVector3 onUpdate, System.Funcfloat, float easingFunc null, bool useUnscaledTime false) { if (duration 0) { onUpdate?.Invoke(endValue); yield break; } float elapsedTime 0f; while (elapsedTime duration) { // 关键在这里根据参数选择使用哪种deltaTime elapsedTime useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime; float rawProgress Mathf.Clamp01(elapsedTime / duration); float easedProgress easingFunc ! null ? easingFunc(rawProgress) : rawProgress; onUpdate?.Invoke(Vector3.Lerp(startValue, endValue, easedProgress)); yield return null; } onUpdate?.Invoke(endValue); }这样在弹出暂停菜单时就可以传入useUnscaledTime: true确保动画流畅播放。6.3 对象池动画管理在大量UI元素如列表项、战斗伤害数字需要频繁执行相同动画时为每个对象单独启动协程会产生大量开销。一个优化方案是使用一个中心化的动画管理器采用对象池思想进行更新。using System.Collections.Generic; using UnityEngine; public class CentralizedTweenManager : MonoBehaviour { private static CentralizedTweenManager _instance; public static CentralizedTweenManager Instance _instance; private ListITweenable _activeTweens new ListITweenable(); void Awake() { if (_instance null) _instance this; } void Update() { for (int i _activeTweens.Count - 1; i 0; i--) { if (!_activeTweens[i].OnUpdate()) { // 动画完成或失效从列表中移除 _activeTweens.RemoveAt(i); } } } public void AddTween(ITweenable tween) _activeTweens.Add(tween); } // 定义一个简单的补间接口 public interface ITweenable { bool OnUpdate(); // 返回false表示动画结束 } // 一个具体的数值补间实现 public class FloatTween : ITweenable { public float CurrentValue { get; private set; } private float _start, _end, _duration, _elapsed; private System.Actionfloat _onUpdate; private System.Funcfloat, float _easingFunc; private bool _useUnscaledTime; public FloatTween(float from, float to, float duration, System.Actionfloat onUpdate, System.Funcfloat, float easingFunc null, bool useUnscaled false) { _start from; _end to; _duration duration; _onUpdate onUpdate; _easingFunc easingFunc; _useUnscaledTime useUnscaled; CurrentValue _start; _elapsed 0; } public bool OnUpdate() { if (_elapsed _duration) { CurrentValue _end; _onUpdate?.Invoke(CurrentValue); return false; // 动画结束 } _elapsed _useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime; float t Mathf.Clamp01(_elapsed / _duration); float easedT _easingFunc ! null ? _easingFunc(t) : t; CurrentValue Mathf.Lerp(_start, _end, easedT); _onUpdate?.Invoke(CurrentValue); return true; // 动画继续 } }使用方式// 创建一个浮点数补间 var tween new FloatTween(0, 1, 1f, (val) { image.color new Color(1,1,1,val); }, EasingFunctions.EaseOutQuad); // 交给管理器统一更新 CentralizedTweenManager.Instance.AddTween(tween);这种方式将成百上千个独立协程的调度开销合并为一个管理器Update中的循环在需要处理大规模简单动画时如卡牌游戏、模拟经营游戏性能提升非常显著。当然对于复杂的、需要yield等待特定条件的动画协程仍是更灵活的选择。7. 常见问题与排查技巧实录即使掌握了原理和代码在实际开发中还是会遇到各种问题。这里记录了几个最常见的问题和解决方法。7.1 动画卡顿、不流畅问题表现动画看起来一跳一跳的或者在某些帧明显停顿。排查步骤检查Time.deltaTime确保在计算移动步长时乘以了Time.deltaTime基于速度的模式或将elapsedTime的累加基于Time.deltaTime基于时间的模式。这是实现帧率无关动画的关键。检查目标帧率在Unity Editor中Game视图的右上角可以查看当前帧率(FPS)。如果帧率波动巨大如从60掉到20动画自然会卡。使用性能分析器Profiler查找CPU或GPU瓶颈。检查协程yield确保协程循环内使用了yield return null;等待下一帧。如果错误地使用了yield return 0;或yield break;会导致逻辑错误。避免在动画过程中进行重型操作如果在onUpdate回调里执行了复杂的计算、实例化对象、加载资源等会阻塞主线程导致动画卡顿。应将重型操作移到动画开始前或结束后。7.2 动画结束后物体位置/状态有微小偏差问题表现动画逻辑上结束了但物体的位置或透明度不是精确的目标值比如alpha是0.999而不是1.0。原因与解决这是浮点数精度误差的典型体现。在基于时间的循环中elapsedTime可能因为Time.deltaTime的累加误差在最后一帧时略小于duration导致插值未达到100%。解决方案在循环结束后强制设置一次最终状态。我们的通用协程模板中已经包含了这一步onUpdate?.Invoke(endValue);。这是保证结果精确的必备操作。7.3 多个动画叠加导致逻辑错误问题表现快速连续点击按钮触发动画物体会鬼畜或者停在奇怪的位置。原因上一个动画协程还没结束下一个又启动了两者同时修改同一个属性。解决方案这就是为什么我们在每个动画控制器里都维护了一个_currentCoroutine引用。在启动新协程前务必先StopCoroutine(_currentCoroutine)。对于更复杂的队列需求参考第6.1节的动画队列管理器。7.4 UI动画在World Space Canvas下异常问题表现在World Space渲染模式的Canvas下UI的缩放、移动动画看起来速度不对或者方向奇怪。排查坐标系World Space下UI的RectTransform的position和localScale是基于世界坐标的。确保你计算起始值和目标值时使用的是正确的坐标系。对于位置动画使用Transform的position而非anchoredPosition可能更简单。缩放影响World Space Canvas的根节点缩放会影响所有子UI。如果你的动画也修改缩放要注意是局部缩放(localScale)还是受父节点影响的最终缩放。摄像机确保渲染此Canvas的摄像机参数尤其是视口大小设置正确。7.5 如何在动画中途取消并还原到初始状态这是一个进阶需求。我们的协程模式可以优雅地支持。private IEnumerator _activeAnimation; private Vector3 _originalPosition; private Vector3 _targetPosition; public void StartAnimation() { _originalPosition transform.position; _targetPosition ...; // 计算目标位置 if (_activeAnimation ! null) StopCoroutine(_activeAnimation); _activeAnimation StartCoroutine(AnimateRoutine()); } public void CancelAndRevert() { if (_activeAnimation ! null) { StopCoroutine(_activeAnimation); _activeAnimation null; // 启动一个“回退”动画从当前位置回到原始位置 StartCoroutine(CoroutineAnimations.SmoothMoveToTarget( transform.position, _originalPosition, 0.2f, // 快速回退 pos transform.position pos, EasingFunctions.EaseOutQuad )); } } private IEnumerator AnimateRoutine() { yield return CoroutineAnimations.SmoothMoveToTarget(_originalPosition, _targetPosition, 1f, pos transform.position pos); _activeAnimation null; }关键在于在取消时我们不是简单地停止协程并把物体瞬移回去而是启动一个新的、时长短的“回退”动画。这提供了更好的用户体验避免了界面的突兀跳变。这套基于MoveTowards思想与协程的动画系统其核心价值在于将复杂、易错的逐帧状态管理抽象为简洁、声明式的“从A到B用X时间以Y曲线”的描述。它可能不是Unity性能最高的动画方案对于极致性能可以考虑DOTween或Unity自带的Animator状态机但在开发效率、代码可读性和可控性上达到了极佳的平衡。从我个人的项目经验来看对于中小型项目、UI动画和游戏逻辑中的程序化动画这几乎是首选方案。它让你从Update的泥潭中解脱出来用更符合直觉的方式思考和时间打交道。