Unity动画播放完成检测:Animation Event、State Info与State Machine Behaviour详解

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的动画系统在播放时间轴到达指定点时自动触发。这就像在录像带上贴了一个标签,播放器走到那里就会“滴”一声提醒你。

优点

  1. 精度极高:事件触发与动画帧完全同步,不受帧率波动、TimeScale缩放的影响。
  2. 配置直观:美术或动画师可以在Unity编辑器内可视化地添加和调整事件点,无需修改代码。
  3. 资源驱动:检测逻辑与动画资源本身绑定,适合动画逻辑相对固定、由资源主导的项目。

缺点

  1. 需要修改动画资源:必须打开每个需要检测的动画文件进行编辑,如果动画数量庞大或需要频繁调整,会带来一定的工作量。
  2. 灵活性较低:事件点一旦设定,在运行时难以动态改变。如果同一个动画片段在不同情境下需要不同的完成回调,处理起来会稍显麻烦。

最适合的场景:角色攻击、受击、死亡等动作游戏的技能动画;UI动画(如弹窗打开/关闭);过场动画(Cinematic)中特定时刻的镜头或逻辑触发。

2.2 详细操作步骤与编辑器配置

假设我们有一个名为Attack的动画片段,需要在播放结束时触发一个方法。

第一步:在动画窗口中添加事件

  1. 在Project窗口中找到你的动画片段(例如Attack.anim),双击在动画窗口(Animation Window)中打开它。
  2. 将时间轴滑块拖动到动画的最后一帧。你可以点击窗口左上角的“帧”显示模式,并输入准确的帧数来精确定位。
  3. 在事件轨道(通常是底部一条深色的轨道)上,右键点击,选择“Add Animation Event”。
  4. 这时会出现一个白色的事件标记。选中它,在检查器(Inspector)中会显示该事件的详情。
  5. Function字段中,输入你希望在此时被调用的方法名,例如OnAttackAnimationComplete
  6. (可选)你还可以在FloatIntStringObject字段中传递参数给该方法。

注意:确保时间点准确。一个常见的技巧是,在动画的倒数第二帧和最后一帧都添加事件,或者使用一个非常短暂的、不渲染的“结束帧”来放置事件,以避免因为动画循环或过渡导致的事件触发时机问题。

第二步:在脚本中编写接收方法这个脚本需要挂载在拥有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 代码示例与参数传递实战

上面的基础示例展示了无参数的情况。在实际项目中,我们经常需要传递信息。

场景示例:一个角色有多种攻击动画(轻击、重击、跳跃攻击),它们都调用同一个完成方法,但需要知道是哪种攻击结束了。

  1. 在动画事件中设置String参数:在动画窗口的事件检查器中,将String字段设为"LightAttack""HeavyAttack"等。
  2. 在脚本中接收参数
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结构解析与关键API

AnimatorStateInfoAnimator类提供的一个结构体,包含了当前图层(Layer)下所播放状态的大量信息。我们需要关注以下几个关键属性:

  • normalizedTime: 这是核心中的核心。它表示当前状态播放的标准化时间。范围通常在0到1之间(对于不循环的动画),表示从开头到结尾的进度。但请注意:对于循环动画,它会超过1(例如,播放第二次循环时,值会在1到2之间)。
  • length: 当前状态所使用动画片段的长度(以秒为单位)。
  • shortNameHash: 当前状态的哈希值(通过Animator.StringToHash计算得到),用于快速比较状态名,性能优于字符串比较。
  • IsName(string name): 判断当前状态是否是指定名称的状态。

检测动画完成的核心逻辑就是:在Update()中持续检查,如果当前状态是我们关心的那个状态,并且它的normalizedTime大于等于0.99f(或1.0f,考虑到浮点数精度),我们就认为动画播放完成了。

3.2 标准化时间(normalizedTime)的深度理解与陷阱规避

normalizedTime的计算方式是:当前播放时间 / 动画长度。但它有一些你必须清楚的“坑”:

  1. 循环动画(Loop Time):如果动画设置为循环,normalizedTime会无限增长。normalizedTime % 1可以得到当前循环周期内的进度。所以,判断循环动画的“一次”播放完成,需要额外逻辑。
  2. 动画过渡(CrossFade/Blend):在动画过渡期间,Animator可能同时混合两个状态。此时获取的stateInfo可能是不稳定的,normalizedTime的跳变可能导致误判。
  3. 时间缩放(Time.timeScale)normalizedTime的计算已经考虑了Time.timeScale,所以你不需要额外处理。
  4. 浮点数精度:不要使用== 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 = GetComponent<Animator>(); 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 == _targetStateHashstateInfo.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.GetComponent<PlayerController>()?.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中挂载

  1. 打开你的Animator Controller。
  2. 在状态机中,找到你想要检测的动画状态(例如“Attack”状态)。
  3. 在该状态的Inspector面板中,你会看到一个“Add Behaviour”按钮。
  4. 点击它,从列表中选择你刚刚创建的脚本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资源
灵活性较低,事件点与动画绑定很高,可动态检测任意状态高,逻辑与状态节点绑定,可复用
可维护性中,事件散落在各个动画文件中中,逻辑集中在检测脚本中,行为与状态可视化关联,一目了然
团队协作需要动画/美术人员参与配置程序独立完成程序完成,设计人员可视
适用场景动作帧同步、音效触发、特效触发运行时动态状态监控、通用检测系统状态相关的复杂逻辑、行为树集成

选型决策流

  1. 追求极致精度和资源驱动:比如格斗游戏,每个攻击动作的命中帧、特效帧必须绝对精确,选方法一(Animation Event)
  2. 需要运行时灵活控制,不想动资源:比如一个通用的人物动画控制器,需要随时检测各种状态的完成情况,选方法二(State Info 检测)。可以将其封装成一个AnimatorMonitor组件。
  3. 构建复杂、清晰的状态机逻辑:比如角色的AI行为树,每个动画状态都对应一套进入、更新、退出的行为(如:播放跑步动画时开始消耗体力,结束时停止消耗),选方法三(State Machine Behaviour)。它能很好地实现状态与行为的解耦。
  4. 混合使用:在大型项目中,这三种方法常常并存。例如用方法一处理攻击特效和受击框,用方法三管理状态的生命周期逻辑(如开始蓄力、结束蓄力),再用方法二写一个调试工具来实时监控所有动画的播放进度。

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项目,每一毫秒都很珍贵。

  1. 减少查询频率:对于方法二,如果不是每帧都需要,可以改为每2-3帧检测一次(使用Time.frameCount % interval == 0)。
  2. 使用哈希缓存:将所有需要检测的状态名预先计算好哈希值并缓存起来,避免在Update中频繁调用Animator.StringToHash
  3. 按需启用检测器:为每个需要检测的动画状态挂载一个独立的检测脚本(或使用一个管理器),并只在角色进入相关逻辑阶段(如战斗状态)时才启用这些脚本的检测功能。
  4. 优先选用SMB:从性能角度看,SMB(方法三)的回调是事件驱动的,只在状态切换时发生,通常比每帧查询(方法二)更高效。
  5. 避免在SMB的OnStateUpdate中做繁重操作OnStateUpdate每帧调用,其性能开销与Update类似。

6.4 常见问题速查与调试技巧

问题1:动画事件没有触发。

  • 检查:接收脚本是否挂载在正确的游戏对象上?方法名是否完全匹配(大小写敏感)?动画事件的时间点是否在动画长度范围内?动画是否真的被播放(检查Animator的状态和参数)?

问题2:使用State Info检测时,完成事件被多次触发。

  • 原因:很可能是因为normalizedTime在完成阈值附近持续了几帧,导致每帧都满足>= 0.99f条件。
  • 解决:引入一个“已触发”标志位(bool _hasFired),如上面的示例代码所示,确保只触发一次。

问题3:OnStateExit被调用,但动画看起来还没播完。

  • 原因:动画被中断了(例如触发了其他过渡条件)。这是最常见的问题。
  • 解决:按照4.3节的方法,设计状态机或添加判断逻辑来区分“正常完成”和“被中断”。

问题4:在编辑器里运行正常,打包后动画完成检测失灵。

  • 可能原因:动画压缩设置。在Player Settings中,如果动画被过度压缩,可能会导致长度信息或事件时间点有微小偏差。
  • 解决:检查动画的导入设置,特别是“Anim. Compression”选项。对于精度要求高的动画,可以尝试使用“Keyframe Reduction”或“Off”。同时,将完成检测的阈值(如0.99f)适当放宽到0.98f或0.985f,以提高鲁棒性。

调试技巧

  • OnStateEnterOnStateExit中打印日志,并附上stateInfo.normalizedTime,清晰跟踪状态流向。
  • 在Scene视图开启Animator窗口,实时观察状态机的切换和参数变化。
  • 使用Debug.Log($"State: {stateInfo.shortNameHash}, Time: {stateInfo.normalizedTime:F3}")在Update中监控进度。