1. 项目概述与核心价值在Unity开发中动画系统是赋予角色和物体生命力的核心。无论是角色攻击后的硬直、UI界面的弹出收起还是机关触发后的状态切换我们常常需要精确地知道一个动画片段Animation Clip何时播放完毕以便无缝地衔接后续的逻辑。Animator组件作为Unity动画状态机的载体功能强大但它并没有直接提供一个像AnimationEvent那样直观的“OnAnimationComplete”回调。很多开发者尤其是刚接触状态机动画的朋友经常会卡在“如何检测动画播放完成”这个看似简单却至关重要的环节上。我自己在带项目和做技术分享时发现这是新手和老手都会反复遇到的问题。新手可能用Time.deltaTime累加去硬算结果因为动画速度、过渡混合等问题导致检测不准而有经验的开发者则可能过度设计写出一套复杂的消息派发系统反而让简单的逻辑变得臃肿。实际上Unity的Animator已经为我们提供了多种优雅且可靠的检测途径。掌握它们不仅能写出更健壮的代码还能深刻理解Animator状态机的工作机制。本文将深入拆解三种在Unity中检测Animator动画播放完成的实用方法并附上可直接“抄作业”的代码示例。这三种方法覆盖了从事件驱动到状态查询的不同场景你可以根据项目的具体需求如代码架构、性能要求、动画复杂度灵活选用。无论你是在制作一款动作游戏、一个交互式应用还是任何需要精细动画控制的场景这篇文章都能为你提供清晰的解决方案。2. 核心方法一利用Animation Event动画事件这是最直接、最精准也是与动画美术资源绑定最紧密的一种方法。它的原理是在动画片段Animation Clip的特定时间点通常是最后一帧插入一个自定义事件Event当动画播放到该时间点时就会触发绑定在游戏对象GameObject上的对应方法。2.1 方法原理与适用场景原理动画事件是存储在动画资源.anim文件内部的元数据。它不依赖于代码逻辑去计算时间而是由Unity的动画系统在播放时间轴到达指定点时自动触发。这就像在录像带上贴了一个标签播放器走到那里就会“滴”一声提醒你。优点精度极高事件触发与动画帧完全同步不受帧率波动、TimeScale缩放的影响。配置直观美术或动画师可以在Unity编辑器内可视化地添加和调整事件点无需修改代码。资源驱动检测逻辑与动画资源本身绑定适合动画逻辑相对固定、由资源主导的项目。缺点需要修改动画资源必须打开每个需要检测的动画文件进行编辑如果动画数量庞大或需要频繁调整会带来一定的工作量。灵活性较低事件点一旦设定在运行时难以动态改变。如果同一个动画片段在不同情境下需要不同的完成回调处理起来会稍显麻烦。最适合的场景角色攻击、受击、死亡等动作游戏的技能动画UI动画如弹窗打开/关闭过场动画Cinematic中特定时刻的镜头或逻辑触发。2.2 详细操作步骤与编辑器配置假设我们有一个名为Attack的动画片段需要在播放结束时触发一个方法。第一步在动画窗口中添加事件在Project窗口中找到你的动画片段例如Attack.anim双击在动画窗口Animation Window中打开它。将时间轴滑块拖动到动画的最后一帧。你可以点击窗口左上角的“帧”显示模式并输入准确的帧数来精确定位。在事件轨道通常是底部一条深色的轨道上右键点击选择“Add Animation Event”。这时会出现一个白色的事件标记。选中它在检查器Inspector中会显示该事件的详情。在Function字段中输入你希望在此时被调用的方法名例如OnAttackAnimationComplete。可选你还可以在Float、Int、String或Object字段中传递参数给该方法。注意确保时间点准确。一个常见的技巧是在动画的倒数第二帧和最后一帧都添加事件或者使用一个非常短暂的、不渲染的“结束帧”来放置事件以避免因为动画循环或过渡导致的事件触发时机问题。第二步在脚本中编写接收方法这个脚本需要挂载在拥有Animator组件的同一个游戏对象上或者其父/子物体上取决于消息传递的设置。using UnityEngine; public class AnimationEventHandler : MonoBehaviour { // 由动画事件调用的方法 public void OnAttackAnimationComplete() { Debug.Log(攻击动画播放完成); // 在这里执行后续逻辑例如 // - 允许玩家输入下一个指令 // - 切换回待机状态 // - 生成攻击判定的特效或碰撞体 // - 通知其他系统如技能冷却、连击计数 } // 如果需要传递参数可以这样定义方法 public void OnAnimationEventWithParam(string eventName, float damageMultiplier) { Debug.Log($事件 {eventName} 触发伤害倍率: {damageMultiplier}); // 根据参数执行不同逻辑 } }第三步关联动画控制器Animator Controller确保你的Animator组件使用的控制器Animator Controller包含了已添加事件的动画片段。通常你会在状态机State Machine的某个状态State中引用这个Attack动画片段。2.3 代码示例与参数传递实战上面的基础示例展示了无参数的情况。在实际项目中我们经常需要传递信息。场景示例一个角色有多种攻击动画轻击、重击、跳跃攻击它们都调用同一个完成方法但需要知道是哪种攻击结束了。在动画事件中设置String参数在动画窗口的事件检查器中将String字段设为LightAttack、HeavyAttack等。在脚本中接收参数public void OnAnyAttackComplete(string attackType) { switch (attackType) { case LightAttack: // 轻击结束逻辑如短硬直 break; case HeavyAttack: // 重击结束逻辑如长硬直或消耗耐力 break; case JumpAttack: // 跳跃攻击结束允许再次起跳或落地 break; } }更复杂的参数传递你甚至可以传递一个float参数来表示伤害倍率传递一个int参数来表示连击段数或者传递一个对场景中其他物体的引用Object。这为动画与游戏逻辑的深度交互提供了极大的灵活性。实操心得对于团队协作建议建立一套动画事件命名和参数传递的规范。例如规定所有完成事件的方法名以OnAnimComplete_开头或者所有伤害事件传递的float参数都代表基础伤害值。这能极大提升代码的可读性和维护性。3. 核心方法二通过Animator State Info进行状态与时间检测当你不希望或无法修改动画资源时通过代码查询Animator的当前状态信息AnimatorStateInfo就成了首选方案。这种方法完全在运行时通过逻辑判断不依赖资源配置。3.1 AnimatorStateInfo结构解析与关键APIAnimatorStateInfo是Animator类提供的一个结构体包含了当前图层Layer下所播放状态的大量信息。我们需要关注以下几个关键属性normalizedTime: 这是核心中的核心。它表示当前状态播放的标准化时间。范围通常在0到1之间对于不循环的动画表示从开头到结尾的进度。但请注意对于循环动画它会超过1例如播放第二次循环时值会在1到2之间。length: 当前状态所使用动画片段的长度以秒为单位。shortNameHash: 当前状态的哈希值通过Animator.StringToHash计算得到用于快速比较状态名性能优于字符串比较。IsName(string name): 判断当前状态是否是指定名称的状态。检测动画完成的核心逻辑就是在Update()中持续检查如果当前状态是我们关心的那个状态并且它的normalizedTime大于等于0.99f或1.0f考虑到浮点数精度我们就认为动画播放完成了。3.2 标准化时间normalizedTime的深度理解与陷阱规避normalizedTime的计算方式是当前播放时间 / 动画长度。但它有一些你必须清楚的“坑”循环动画Loop Time如果动画设置为循环normalizedTime会无限增长。normalizedTime % 1可以得到当前循环周期内的进度。所以判断循环动画的“一次”播放完成需要额外逻辑。动画过渡CrossFade/Blend在动画过渡期间Animator可能同时混合两个状态。此时获取的stateInfo可能是不稳定的normalizedTime的跳变可能导致误判。时间缩放Time.timeScalenormalizedTime的计算已经考虑了Time.timeScale所以你不需要额外处理。浮点数精度不要使用 1.0f来判断完成。因为浮点数计算可能有微小误差推荐使用 0.99f或 0.999f。更稳健的做法是结合状态是否即将切换。3.3 完整实现方案与性能优化考量下面是一个健壮的、考虑过渡和循环的检测组件示例using UnityEngine; public class AnimatorStateDetector : MonoBehaviour { private Animator _animator; private int _targetStateHash; private bool _isInTargetState false; private bool _hasCompleted false; [SerializeField] private string targetStateName YourAnimationStateName; [SerializeField] private bool ignoreLoop true; // 是否忽略循环只检测第一次播放完成 void Start() { _animator GetComponentAnimator(); if (_animator null) { Debug.LogError(AnimatorStateDetector 需要挂载在带有 Animator 组件的物体上。); return; } _targetStateHash Animator.StringToHash(targetStateName); } void Update() { if (_animator null) return; // 获取当前图层0的状态信息 AnimatorStateInfo stateInfo _animator.GetCurrentAnimatorStateInfo(0); // 检查是否进入了目标状态 bool currentlyInTargetState stateInfo.shortNameHash _targetStateHash; if (currentlyInTargetState !_isInTargetState) { // 刚进入目标状态重置完成标志 _hasCompleted false; _isInTargetState true; Debug.Log($进入状态: {targetStateName}); } else if (!currentlyInTargetState _isInTargetState) { // 离开了目标状态 _isInTargetState false; Debug.Log($离开状态: {targetStateName}); } // 如果在目标状态中且未标记完成则检查播放进度 if (_isInTargetState !_hasCompleted) { float normalizedTime stateInfo.normalizedTime; float completionThreshold 0.99f; // 处理循环动画如果忽略循环只判断第一个周期 if (ignoreLoop) { normalizedTime normalizedTime % 1.0f; } if (normalizedTime completionThreshold) { _hasCompleted true; OnTargetAnimationCompleted(); } } } private void OnTargetAnimationCompleted() { Debug.Log($动画状态 [{targetStateName}] 播放完成); // 触发你的业务逻辑 // 例如启用角色控制、触发下一个动画、发送事件等。 } // 提供一个外部方法用于动态设置要检测的状态可选 public void SetTargetState(string newStateName) { targetStateName newStateName; _targetStateHash Animator.StringToHash(newStateName); _isInTargetState false; _hasCompleted false; } }性能优化提示使用哈希值比较stateInfo.shortNameHash _targetStateHash比stateInfo.IsName(“stateName”)性能更高因为后者内部需要进行字符串哈希计算。在Update中每帧调用这点性能差异值得关注。避免每帧获取多个状态信息如果你需要检测多个状态尽量在一次Update中获取所有需要的AnimatorStateInfo而不是为每个状态单独调用GetCurrentAnimatorStateInfo。非必要不检测如果游戏对象不在屏幕上或处于非活动状态可以考虑禁用这个检测脚本。4. 核心方法三结合Animator Controller的State Machine Behaviours这是最面向设计、最符合Unity动画状态机哲学的一种高级方法。State Machine Behaviour(SMB) 是一种可以挂载在Animator Controller状态机中特定状态上的脚本。它允许你编写与该状态生命周期绑定的代码例如OnStateEnter,OnStateUpdate,OnStateExit。4.1 State Machine Behaviour 的工作机制与生命周期你可以把State Machine Behaviour理解为附着在动画状态上的一个“监听器”。它独立于场景中的任何MonoBehaviour脚本其生命周期完全由Animator的状态机驱动OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex): 当动画进入该状态时调用。OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex): 当动画处于该状态时每帧调用在Animator的更新之后。OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex): 当动画离开该状态时调用。检测动画完成的绝佳位置就是OnStateExit。当动画播放完毕状态机将要切换到下一个状态可能是任何状态包括退出、过渡到其他状态或回到自身时OnStateExit会被调用。4.2 在状态节点上挂载与配置检测脚本第一步创建State Machine Behaviour脚本在Project窗口中右键 - Create - C# Script命名为OnAttackCompleteBehaviour。using UnityEngine; public class OnAttackCompleteBehaviour : StateMachineBehaviour { // 当离开这个状态时调用 override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // 注意即使动画被中断例如受到攻击也会触发OnStateExit。 // 因此我们需要判断是否是“正常播放完成”而离开的。 // 一个常见的判断方法是检查标准化时间是否接近1。 // 但更可靠的做法是结合动画过渡信息或者设计状态机时确保只有完成才会触发特定过渡。 // 方法1简单判断可能不准确如果动画被快速切换 // if (stateInfo.normalizedTime 0.99f) // { // Debug.Log(攻击动画正常播放完成退出); // animator.GetComponentPlayerController()?.OnAttackAnimationFinished(); // } // 方法2推荐通过参数或发送消息通知 // 我们假设当攻击动画正常完成时会通过一个触发器Trigger参数“AttackFinished”来过渡。 // 在OnStateExit里我们可以发送一个消息给游戏对象。 animator.SendMessage(OnAnimationStateExited_Attack, SendMessageOptions.DontRequireReceiver); } // 你也可以在进入状态时做一些初始化 override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { Debug.Log(进入攻击状态); // 例如锁定玩家输入播放声音等 } // 在状态更新时可以做一些每帧检查 override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // 例如根据播放进度调整特效强度 // float progress stateInfo.normalizedTime % 1.0f; // UpdateEffectIntensity(progress); } }第二步在Animator Controller中挂载打开你的Animator Controller。在状态机中找到你想要检测的动画状态例如“Attack”状态。在该状态的Inspector面板中你会看到一个“Add Behaviour”按钮。点击它从列表中选择你刚刚创建的脚本OnAttackCompleteBehaviour。现在只要动画从“Attack”状态离开无论去往哪个状态OnStateExit方法都会被调用。4.3 实现细节区分“正常完成”与“被中断”这是使用OnStateExit时最大的挑战。因为动画被强制切换比如角色被击中立即切换到“Hit”状态时也会触发OnStateExit。我们需要区分这两种情况。解决方案1利用Animator参数和状态机设计这是最清晰的方法。设计你的状态机时让“正常完成”和“被中断”走向不同的过渡。在“Attack”状态上设置两个过渡Transition条件AttackFinishedTrigger 为 True - 过渡到“Idle”待机状态。这代表正常完成。条件IsHitBool 为 True - 过渡到“Hit”受击状态。这代表被中断。在你的攻击逻辑脚本中在攻击动画应该结束的时刻可以通过方法二的计时或一个预设的定时器设置Animator.SetTrigger(“AttackFinished”)。在OnStateExit中我们不直接判断是否完成而是由状态机的过渡逻辑来保证只有正常完成才会触发通往“Idle”的过渡。因此在OnStateExit里我们可以默认执行完成逻辑或者通过检查下一个状态是什么来决定。解决方案2在SMB中结合normalizedTime判断不够稳健如上文代码注释所示可以在OnStateExit中检查stateInfo.normalizedTime。如果接近1则认为是正常完成如果远小于1则可能是被中断。但这种方法在动画有较长过渡CrossFade或速度变化时可能不可靠。解决方案3使用自定义数据和消息在SMB的OnStateExit中向游戏对象发送一个包含退出原因的消息。public class ImprovedAttackCompleteBehaviour : StateMachineBehaviour { public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // 计算播放进度 float progress stateInfo.normalizedTime % 1.0f; bool isCompleted progress 0.95f; // 设定一个阈值 // 发送一个结构化的消息 AnimationExitData data new AnimationExitData { stateName Attack, normalizedTime progress, isInterrupted !isCompleted }; // 需要接收方脚本定义一个 ReceiveAnimationExitData 方法 animator.SendMessage(ReceiveAnimationExitData, data, SendMessageOptions.DontRequireReceiver); } } // 定义一个可序列化的类来传递数据 [System.Serializable] public class AnimationExitData { public string stateName; public float normalizedTime; public bool isInterrupted; }5. 三种方法对比与选型指南现在我们已经掌握了三种武器该如何选择下表从多个维度进行了对比特性维度方法一Animation Event方法二State Info 检测方法三State Machine Behaviour精度极高帧事件级同步高依赖每帧查询可能有1帧延迟高OnStateExit调用时机准确性能最优事件触发开销极小中每帧需执行查询和比较逻辑优仅状态切换时触发回调资源依赖需修改动画文件(.anim)无需修改资源纯代码驱动需修改Animator Controller资源灵活性较低事件点与动画绑定很高可动态检测任意状态高逻辑与状态节点绑定可复用可维护性中事件散落在各个动画文件中中逻辑集中在检测脚本中高行为与状态可视化关联一目了然团队协作需要动画/美术人员参与配置程序独立完成程序完成设计人员可视适用场景动作帧同步、音效触发、特效触发运行时动态状态监控、通用检测系统状态相关的复杂逻辑、行为树集成选型决策流追求极致精度和资源驱动比如格斗游戏每个攻击动作的命中帧、特效帧必须绝对精确选方法一Animation Event。需要运行时灵活控制不想动资源比如一个通用的人物动画控制器需要随时检测各种状态的完成情况选方法二State Info 检测。可以将其封装成一个AnimatorMonitor组件。构建复杂、清晰的状态机逻辑比如角色的AI行为树每个动画状态都对应一套进入、更新、退出的行为如播放跑步动画时开始消耗体力结束时停止消耗选方法三State Machine Behaviour。它能很好地实现状态与行为的解耦。混合使用在大型项目中这三种方法常常并存。例如用方法一处理攻击特效和受击框用方法三管理状态的生命周期逻辑如开始蓄力、结束蓄力再用方法二写一个调试工具来实时监控所有动画的播放进度。6. 高级应用与常见问题排查6.1 多层动画Layers与子状态机Sub-State Machines的处理现实项目中的Animator Controller往往非常复杂包含多层Layers和子状态机。多层动画例如第0层控制基础移动走跑跳第1层控制上半身动作射击、挥手。当你需要检测第1层某个动画的完成时务必在GetCurrentAnimatorStateInfo或SMB的回调参数中指定正确的layerIndex。// 检测第1层的状态 AnimatorStateInfo upperBodyState animator.GetCurrentAnimatorStateInfo(1); if (upperBodyState.shortNameHash shootStateHash upperBodyState.normalizedTime 0.99f) { // 上半身射击动画完成 }在SMB中OnStateExit等方法的layerIndex参数已经告诉你是哪一层了。子状态机子状态机内部的State Machine Behaviour只对该子状态机内的状态生效。如果你在子状态机入口Entry节点上挂载SMB它的OnStateEnter会在进入该子状态机时调用OnStateExit会在离开整个子状态机时调用。这对于管理一组相关动画的集体行为非常有用。6.2 动画过渡CrossFade与混合树Blend Tree的影响动画过渡和混合会导致状态检测变得复杂。过渡期间GetCurrentAnimatorStateInfo返回的可能是正在淡出的旧状态也可能是正在淡入的新状态或者是某种混合状态。normalizedTime可能不连续。因此在动画过渡很短的几帧内依赖normalizedTime的判断可能失效。更可靠的方法是依赖状态机本身的过渡条件如方法三的方案1或者使用Animator.IsInTransition(layerIndex)方法来检测是否处于过渡期并忽略过渡期内的完成判断。void Update() { if (_animator.IsInTransition(0)) { // 正在过渡跳过本帧的完成检测 return; } // ... 正常的检测逻辑 }混合树检测混合树中某个具体动画的完成几乎是不可能的也是没有意义的。因为混合树是多个动画的混合结果。你应该检测的是混合树所在的那个Animator状态的完成。或者如果你的逻辑依赖于混合树中某个特定动画片段那么你应该回到方法一Animation Event在那个具体的动画片段上添加事件。6.3 性能敏感场景下的优化策略在移动端或VR项目每一毫秒都很珍贵。减少查询频率对于方法二如果不是每帧都需要可以改为每2-3帧检测一次使用Time.frameCount % interval 0。使用哈希缓存将所有需要检测的状态名预先计算好哈希值并缓存起来避免在Update中频繁调用Animator.StringToHash。按需启用检测器为每个需要检测的动画状态挂载一个独立的检测脚本或使用一个管理器并只在角色进入相关逻辑阶段如战斗状态时才启用这些脚本的检测功能。优先选用SMB从性能角度看SMB方法三的回调是事件驱动的只在状态切换时发生通常比每帧查询方法二更高效。避免在SMB的OnStateUpdate中做繁重操作OnStateUpdate每帧调用其性能开销与Update类似。6.4 常见问题速查与调试技巧问题1动画事件没有触发。检查接收脚本是否挂载在正确的游戏对象上方法名是否完全匹配大小写敏感动画事件的时间点是否在动画长度范围内动画是否真的被播放检查Animator的状态和参数问题2使用State Info检测时完成事件被多次触发。原因很可能是因为normalizedTime在完成阈值附近持续了几帧导致每帧都满足 0.99f条件。解决引入一个“已触发”标志位bool _hasFired如上面的示例代码所示确保只触发一次。问题3OnStateExit被调用但动画看起来还没播完。原因动画被中断了例如触发了其他过渡条件。这是最常见的问题。解决按照4.3节的方法设计状态机或添加判断逻辑来区分“正常完成”和“被中断”。问题4在编辑器里运行正常打包后动画完成检测失灵。可能原因动画压缩设置。在Player Settings中如果动画被过度压缩可能会导致长度信息或事件时间点有微小偏差。解决检查动画的导入设置特别是“Anim. Compression”选项。对于精度要求高的动画可以尝试使用“Keyframe Reduction”或“Off”。同时将完成检测的阈值如0.99f适当放宽到0.98f或0.985f以提高鲁棒性。调试技巧在OnStateEnter和OnStateExit中打印日志并附上stateInfo.normalizedTime清晰跟踪状态流向。在Scene视图开启Animator窗口实时观察状态机的切换和参数变化。使用Debug.Log($State: {stateInfo.shortNameHash}, Time: {stateInfo.normalizedTime:F3})在Update中监控进度。