Unity零延遲動(dòng)畫結(jié)束監(jiān)聽:基于Animator State Machine Behaviour的精準(zhǔn)事件方案
1. 項(xiàng)目概述為什么我們需要“零延遲”的動(dòng)畫結(jié)束監(jiān)聽在Unity項(xiàng)目里尤其是那些對(duì)操作反饋和手感要求極高的動(dòng)作游戲、RPG或者需要精準(zhǔn)流程控制的UI動(dòng)畫中動(dòng)畫狀態(tài)的切換時(shí)機(jī)是命門。你肯定遇到過這種情況角色揮出一刀傷害判定需要在動(dòng)畫播放到某一幀時(shí)觸發(fā)或者在動(dòng)畫完全結(jié)束后立刻切換到待機(jī)狀態(tài)。如果監(jiān)聽不準(zhǔn)輕則手感“肉”得不行重則出現(xiàn)角色“鬼畜”、技能連招中斷、UI邏輯錯(cuò)亂等惡性Bug。傳統(tǒng)的做法比如在Update里輪詢Animator.GetCurrentAnimatorStateInfo(0).normalizedTime或者依賴Animation Event在實(shí)際項(xiàng)目中都暴露出了明顯短板。輪詢消耗性能且難以精確捕捉“結(jié)束”的瞬間而Animation Event雖然精確但需要手動(dòng)在動(dòng)畫文件里添加對(duì)于大量動(dòng)畫或需要?jiǎng)討B(tài)綁定的情況來說維護(hù)成本高靈活性差。更頭疼的是由于Unity動(dòng)畫系統(tǒng)內(nèi)部狀態(tài)機(jī)更新與腳本Update執(zhí)行的順序問題這些方法幾乎必然帶來1幀的延遲在60FPS下就是16.67ms對(duì)于追求“刀刀到肉”手感的核心玩法這是不可接受的。所以這個(gè)“零延遲的Animator結(jié)束監(jiān)聽”方案目標(biāo)直指痛點(diǎn)在不增加性能負(fù)擔(dān)的前提下以腳本驅(qū)動(dòng)的方式精準(zhǔn)、無延遲地捕獲任意動(dòng)畫剪輯Animation Clip的播放完成事件。它不只是一個(gè)API調(diào)用技巧更是一套理解Unity動(dòng)畫系統(tǒng)更新機(jī)制并在此基礎(chǔ)上進(jìn)行精準(zhǔn)攔截的設(shè)計(jì)思路。無論是獨(dú)立開發(fā)者還是團(tuán)隊(duì)中的TA技術(shù)美術(shù)或客戶端程序掌握這套方法都能讓你對(duì)動(dòng)畫流程的控制力提升一個(gè)檔次。2. 核心思路拆解從狀態(tài)機(jī)層面理解“結(jié)束”要實(shí)現(xiàn)零延遲首先要摒棄“在動(dòng)畫播放完畢后去檢查”的被動(dòng)思維轉(zhuǎn)向“在動(dòng)畫即將結(jié)束的同一幀主動(dòng)觸發(fā)”的預(yù)測(cè)與攔截思維。核心在于深入利用Unity Animator Controller的層級(jí)結(jié)構(gòu)與狀態(tài)機(jī)邏輯。2.1 Animator的更新時(shí)序陷阱很多人沒意識(shí)到動(dòng)畫卡頓或監(jiān)聽延遲的根源往往不在代碼邏輯本身而在執(zhí)行順序。Unity一幀內(nèi)的主要順序大致是FixedUpdate-物理模擬-OnAnimatorMove(如果啟用) -動(dòng)畫狀態(tài)機(jī)更新-OnAnimatorIK(如果啟用) -LateUpdate-渲染。如果你的結(jié)束監(jiān)聽代碼寫在Update或LateUpdate里那么當(dāng)動(dòng)畫在“動(dòng)畫狀態(tài)機(jī)更新”階段結(jié)束時(shí)你的腳本要等到下一幀的Update才能反應(yīng)過來這1幀的延遲就是這么來的。OnAnimatorMove和OnAnimatorIK雖然時(shí)機(jī)更靠前但它們并非為通用事件監(jiān)聽設(shè)計(jì)且與特定動(dòng)畫功能耦合。因此我們的主戰(zhàn)場(chǎng)應(yīng)該在動(dòng)畫狀態(tài)機(jī)內(nèi)部尋找時(shí)機(jī)。理想的情況是在動(dòng)畫狀態(tài)機(jī)計(jì)算完當(dāng)前幀的狀態(tài)確定某個(gè)動(dòng)畫剪輯已經(jīng)播放完畢即將退出或轉(zhuǎn)換的“那一刻”我們就能得到通知。2.2 利用Animator State Machine Behaviour進(jìn)行精準(zhǔn)攔截AnimatorStateMachineBehaviour(SMB) 是附著在Animator Controller中某個(gè)具體狀態(tài)State上的腳本。它提供了一系列與該狀態(tài)生命周期緊密相關(guān)的回調(diào)方法例如OnStateEnter,OnStateUpdate,OnStateExit。關(guān)鍵點(diǎn)在于這些方法的調(diào)用時(shí)機(jī)是在Unity內(nèi)部動(dòng)畫系統(tǒng)更新該狀態(tài)的過程中遠(yuǎn)早于腳本的Update。OnStateExit聽起來很像是我們想要的“結(jié)束監(jiān)聽”但它有一個(gè)問題它不僅在動(dòng)畫自然播放完畢時(shí)觸發(fā)在任何導(dǎo)致退出該狀態(tài)的條件成立時(shí)例如被Transition強(qiáng)制跳轉(zhuǎn)也會(huì)觸發(fā)。我們需要更精確的“自然播放完畢”事件。這時(shí)我們需要結(jié)合AnimatorStateInfo.normalizedTime。normalizedTime是動(dòng)畫的標(biāo)準(zhǔn)化播放時(shí)間范圍通常是[0,1]當(dāng)動(dòng)畫循環(huán)時(shí)可能大于1。在OnStateUpdate中我們可以持續(xù)監(jiān)測(cè)這個(gè)值。核心判斷邏輯在OnStateUpdate中判斷當(dāng)前normalizedTime是否大于等于1對(duì)于非循環(huán)動(dòng)畫或完成了一個(gè)完整周期對(duì)于循環(huán)動(dòng)畫我們可能只關(guān)心單個(gè)周期。但這里有個(gè)極其關(guān)鍵的細(xì)節(jié)由于浮點(diǎn)數(shù)精度和動(dòng)畫系統(tǒng)采樣問題normalizedTime可能永遠(yuǎn)不會(huì)精確等于1。更可靠的做法是判斷它是否“大于等于0.999f”或者“大于等于1.0f - 一個(gè)很小的閾值如1e-5”。同時(shí)我們必須引入一個(gè)“已觸發(fā)”標(biāo)志位防止在同一動(dòng)畫周期內(nèi)多次觸發(fā)結(jié)束事件。然而僅僅在SMB的OnStateUpdate里判斷還不夠“零延遲”。因?yàn)镺nStateUpdate在當(dāng)前幀內(nèi)動(dòng)畫系統(tǒng)可能還未完成最終的狀態(tài)評(píng)估。我們需要一個(gè)更底層的鉤子。2.3 終極方案結(jié)合Animator Override Controller與自定義事件系統(tǒng)為了達(dá)到通用、解耦和零延遲我推薦的2024實(shí)測(cè)穩(wěn)定方案是一個(gè)組合拳基石SMB 閾值判斷在需要監(jiān)聽的動(dòng)畫狀態(tài)上掛載自定義的SMB腳本。在OnStateUpdate中以高精度閾值判斷動(dòng)畫是否“即將在本幀結(jié)束”。零延遲觸發(fā)在判斷成立的同一幀直接調(diào)用業(yè)務(wù)邏輯。這是實(shí)現(xiàn)“零延遲”的關(guān)鍵。不要在SMB里發(fā)送一個(gè)需要等到Update才處理的UnityEvent或者C# Event而是直接調(diào)用預(yù)先注入的業(yè)務(wù)邏輯方法。這避免了任何消息傳遞帶來的幀延遲。解耦與通信使用可注入的委托Delegate或接口Interface。為了讓SMB能直接調(diào)用業(yè)務(wù)邏輯又不能與具體業(yè)務(wù)類緊耦合我們采用依賴注入的方式。在動(dòng)畫開始前例如在角色的主控制腳本里將需要執(zhí)行的方法如OnAttackAnimationComplete以委托Action或通過接口如IAnimationCompleteReceiver的形式動(dòng)態(tài)設(shè)置給SMB。動(dòng)態(tài)綁定Animator Override Controller的妙用。我們不可能為每一個(gè)動(dòng)畫剪輯都手動(dòng)配置一個(gè)Animator State并掛載SMB。這時(shí)AnimatorOverrideController就是神器。我們可以創(chuàng)建一個(gè)基礎(chǔ)的RuntimeAnimatorController里面包含一個(gè)通用的、掛載了我們自定義SMB的“模板狀態(tài)”。然后在運(yùn)行時(shí)通過AnimatorOverrideController動(dòng)態(tài)地將任何動(dòng)畫剪輯替換到這個(gè)“模板狀態(tài)”上。這樣任何動(dòng)畫都能自動(dòng)獲得零延遲結(jié)束監(jiān)聽的能力而無需修改原始的FBX或動(dòng)畫文件。這套方案聽起來有點(diǎn)繞但它是目前兼顧性能、精度、靈活性和維護(hù)性的最佳實(shí)踐。下面我們進(jìn)入實(shí)操環(huán)節(jié)一步步把它實(shí)現(xiàn)出來。3. 核心細(xì)節(jié)解析與實(shí)操要點(diǎn)3.1 創(chuàng)建通用的Animation Complete SMB首先我們創(chuàng)建一個(gè)核心的StateMachineBehaviour腳本我將其命名為AnimationCompleteListenerSMB。using UnityEngine; public class AnimationCompleteListenerSMB : StateMachineBehaviour { // 用于注入業(yè)務(wù)邏輯的委托 public System.Action OnAnimationComplete; // 防止同一動(dòng)畫周期內(nèi)重復(fù)觸發(fā) private bool _hasTriggered false; // 結(jié)束閾值用于處理浮點(diǎn)數(shù)精度問題 [SerializeField, Range(0.9f, 1.0f)] private float _completionThreshold 0.999f; // 是否只監(jiān)聽單次循環(huán)對(duì)于循環(huán)動(dòng)畫每次循環(huán)結(jié)束都觸發(fā) [SerializeField] private bool _triggerOnLoop false; // 當(dāng)進(jìn)入該狀態(tài)時(shí)重置觸發(fā)標(biāo)志 override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { _hasTriggered false; // 注意如果動(dòng)畫是從中間開始的normalizedTime 0 // 這里可能需要更復(fù)雜的邏輯來判斷是否立即觸發(fā)通常不需要。 } // 核心在狀態(tài)更新時(shí)判斷 override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { if (_hasTriggered) return; // 計(jì)算當(dāng)前動(dòng)畫周期的完成進(jìn)度 float normalizedTime stateInfo.normalizedTime % 1.0f; // 取小數(shù)部分應(yīng)對(duì)循環(huán)動(dòng)畫 // 判斷條件 // 1. 如果動(dòng)畫自然播放到了閾值點(diǎn) // 2. 或者動(dòng)畫被設(shè)置成單次播放且播放完畢normalizedTime 1.0f 但可能因?yàn)榫葲]到 bool isComplete normalizedTime _completionThreshold; // 如果是循環(huán)動(dòng)畫且需要每次循環(huán)都觸發(fā) bool isLoopPoint _triggerOnLoop Mathf.Approximately(normalizedTime, 0f); // 注意從1跳回0可能在一幀內(nèi)發(fā)生這里用Approximate判斷 if (isComplete || isLoopPoint) { _hasTriggered true; // 零延遲關(guān)鍵直接調(diào)用 OnAnimationComplete?.Invoke(); } } // 當(dāng)狀態(tài)被強(qiáng)制退出時(shí)如果還沒觸發(fā)完成事件可以根據(jù)需求決定是否觸發(fā)通常不觸發(fā) override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // 例如如果動(dòng)畫被打斷你可能想觸發(fā)一個(gè)“取消”事件。 // 這里為了純粹性我們只處理自然結(jié)束。打斷邏輯應(yīng)由其他機(jī)制處理。 // if (!_hasTriggered) { /* OnAnimationCancelled?.Invoke(); */ } } }要點(diǎn)與避坑指南閾值_completionThreshold的選擇0.999f是一個(gè)經(jīng)驗(yàn)值在60FPS下通常能穩(wěn)定捕獲最后一幀。如果你的動(dòng)畫幀率很高或很低可能需要調(diào)整。絕對(duì)不要設(shè)為1.0f因?yàn)楦↑c(diǎn)數(shù)精度問題可能導(dǎo)致永遠(yuǎn)無法滿足條件。循環(huán)動(dòng)畫的處理_triggerOnLoop選項(xiàng)給了你控制權(quán)。對(duì)于Idle這種循環(huán)動(dòng)畫你可能不需要監(jiān)聽但對(duì)于一個(gè)連續(xù)攻擊的循環(huán)每次攻擊動(dòng)作循環(huán)一次設(shè)置為true就很有用。注意Mathf.Approximately的使用用于判斷是否回到了循環(huán)起點(diǎn)。委托判空?.Invoke()這是C#的安全調(diào)用語(yǔ)法確保即使沒有業(yè)務(wù)方法注入也不會(huì)報(bào)空引用異常。狀態(tài)退出OnStateExit這里特意沒有在退出時(shí)補(bǔ)發(fā)完成事件。因?yàn)椤巴顺觥痹蚝芏啾淮驍唷⑻D(zhuǎn)與“自然完成”語(yǔ)義不同。兩者應(yīng)該分開處理使邏輯更清晰。3.2 構(gòu)建基礎(chǔ)Animator Controller與模板狀態(tài)在Project窗口右鍵 - Create - Animator Controller命名為BaseAnimatorController。雙擊打開Animator Controller窗口。創(chuàng)建一個(gè)空狀態(tài)右鍵 - Create State - Empty。將其命名為TemplateState并設(shè)為默認(rèn)狀態(tài)橙色。選中TemplateState在Inspector窗口中點(diǎn)擊Add Behaviour選擇我們剛創(chuàng)建的AnimationCompleteListenerSMB腳本。在這個(gè)SMB組件上你可以先不勾選任何東西這些參數(shù)我們可以在運(yùn)行時(shí)通過代碼動(dòng)態(tài)修改但也可以在這里設(shè)置默認(rèn)值如_completionThreshold。這個(gè)TemplateState就是一個(gè)“插座”任何插到這個(gè)插座上的“插頭”動(dòng)畫剪輯都能獲得監(jiān)聽能力。3.3 實(shí)現(xiàn)動(dòng)態(tài)綁定與委托注入接下來我們需要一個(gè)管理器腳本負(fù)責(zé)創(chuàng)建AnimatorOverrideController將想要的動(dòng)畫剪輯覆蓋到模板狀態(tài)上并把業(yè)務(wù)邏輯方法注入到SMB中。這個(gè)腳本通常掛在擁有Animator組件的角色或UI對(duì)象上。using System; using System.Collections.Generic; using UnityEngine; public class AdvancedAnimationEventManager : MonoBehaviour { private Animator _animator; private AnimatorOverrideController _overrideController; // 緩存已經(jīng)建立監(jiān)聽的動(dòng)畫剪輯與對(duì)應(yīng)委托的關(guān)系避免重復(fù)設(shè)置 private DictionaryAnimationClip, Action _clipCallbackMap new DictionaryAnimationClip, Action(); void Awake() { _animator GetComponentAnimator(); if (_animator null) { _animator GetComponentInChildrenAnimator(); } if (_animator.runtimeAnimatorController null) { Debug.LogError(Animator has no base controller assigned!); return; } // 創(chuàng)建Animator Override Controller _overrideController new AnimatorOverrideController(_animator.runtimeAnimatorController); _animator.runtimeAnimatorController _overrideController; } /// summary /// 為指定的動(dòng)畫剪輯注冊(cè)一個(gè)播放完成時(shí)的回調(diào)。 /// /summary /// param nameclip要監(jiān)聽的動(dòng)畫剪輯/param /// param nameonComplete動(dòng)畫自然播放完成時(shí)的回調(diào)/param /// param namecompletionThreshold觸發(fā)完成的閾值(0.9~1.0)/param /// param nametriggerOnLoop對(duì)于循環(huán)動(dòng)畫是否每次循環(huán)結(jié)束都觸發(fā)/param public void RegisterAnimationCompleteListener(AnimationClip clip, Action onComplete, float completionThreshold 0.999f, bool triggerOnLoop false) { if (clip null || onComplete null) { Debug.LogWarning(Invalid clip or callback provided.); return; } // 1. 將動(dòng)畫剪輯覆蓋到我們的模板狀態(tài)上 // 假設(shè)我們的模板狀態(tài)在基礎(chǔ)控制器中的名字是TemplateState // AnimatorOverrideController的[]操作符用于覆蓋指定名稱的原始剪輯 _overrideController[TemplateState] clip; // 2. 獲取當(dāng)前Animator中對(duì)應(yīng)層的狀態(tài)信息第0層 // 注意這里假設(shè)我們的模板狀態(tài)在Animator的默認(rèn)層第0層。 // 如果你的狀態(tài)機(jī)更復(fù)雜使用了層融合需要調(diào)整層索引或狀態(tài)名查找邏輯。 AnimatorStateInfo stateInfo _animator.GetCurrentAnimatorStateInfo(0); // 這里無法直接通過狀態(tài)名獲取SMB我們需要換一種方式。 // 更可靠的方法是我們提前獲取到附著在Animator Controller里那個(gè)模板狀態(tài)上的SMB實(shí)例。 // 但由于OverrideController是實(shí)例我們需要在Awake或Start后Animator初始化完畢時(shí)去獲取。 } // 我們需要一個(gè)方法來初始化并緩存SMB的引用 private AnimationCompleteListenerSMB _cachedSMB; private void CacheTemplateStateSMB() { if (_cachedSMB ! null) return; // 獲取Animator Controller的所有狀態(tài)機(jī)行為 // 注意這里需要遍歷當(dāng)前Animator Controller現(xiàn)在是OverrideController關(guān)聯(lián)的原始Controller的狀態(tài)。 // 我們可以通過_overrideController.runtimeAnimatorController獲取原始控制器。 var baseController _overrideController.runtimeAnimatorController as UnityEditor.Animations.AnimatorController; if (baseController ! null) { // 遍歷所有圖層Layer foreach (var layer in baseController.layers) { // 遍歷該圖層狀態(tài)機(jī)中的所有狀態(tài) var states layer.stateMachine.states; foreach (var state in states) { if (state.state.name TemplateState) { // 獲取該狀態(tài)上的所有StateMachineBehaviour var behaviours state.state.behaviours; foreach (var behaviour in behaviours) { if (behaviour is AnimationCompleteListenerSMB smb) { _cachedSMB smb; return; } } } } } } Debug.LogError(Failed to find AnimationCompleteListenerSMB on TemplateState. Please check your base Animator Controller setup.); } // 修改后的Register方法 public void RegisterAnimationCompleteListener(AnimationClip clip, Action onComplete, float completionThreshold 0.999f, bool triggerOnLoop false) { // ... 前面的參數(shù)檢查和剪輯覆蓋邏輯不變 ... _overrideController[TemplateState] clip; // 確保SMB引用已緩存 if (_cachedSMB null) { CacheTemplateStateSMB(); if (_cachedSMB null) return; // 獲取失敗直接返回 } // 3. 配置并注入回調(diào)到SMB實(shí)例 // 注意由于SMB是共享的附著在模板狀態(tài)上當(dāng)我們切換動(dòng)畫剪輯時(shí) // SMB的實(shí)例是同一個(gè)。所以我們需要一種機(jī)制讓SMB知道當(dāng)前播放的是哪個(gè)剪輯該調(diào)用哪個(gè)回調(diào)。 // 簡(jiǎn)單的做法是我們只允許同時(shí)監(jiān)聽一個(gè)動(dòng)畫因?yàn)槟0鍫顟B(tài)同一時(shí)刻只播放一個(gè)動(dòng)畫。 // 更復(fù)雜的做法是在SMB中使用字典根據(jù)動(dòng)畫剪輯的實(shí)例ID或哈希值來映射回調(diào)。 // 這里為了演示清晰采用簡(jiǎn)單方案直接替換SMB的委托。 _cachedSMB.OnAnimationComplete null; // 清空舊委托 _cachedSMB.OnAnimationComplete onComplete; _cachedSMB._completionThreshold completionThreshold; _cachedSMB._triggerOnLoop triggerOnLoop; // 4. 播放這個(gè)動(dòng)畫觸發(fā)模板狀態(tài) _animator.Play(TemplateState, 0, 0f); // 從第0層0秒開始播放 } /// summary /// 播放一個(gè)動(dòng)畫并自動(dòng)注冊(cè)結(jié)束監(jiān)聽。 /// /summary public void PlayAnimationWithCallback(AnimationClip clip, Action onComplete, float completionThreshold 0.999f, bool triggerOnLoop false) { RegisterAnimationCompleteListener(clip, onComplete, completionThreshold, triggerOnLoop); } void OnDestroy() { // 清理委托引用防止內(nèi)存泄漏 if (_cachedSMB ! null) { _cachedSMB.OnAnimationComplete null; } _clipCallbackMap.Clear(); } }代碼解析與注意事項(xiàng)AnimatorOverrideController的妙用_overrideController[TemplateState] clip;這行代碼是動(dòng)態(tài)綁定的核心。它將基礎(chǔ)控制器中名為“TemplateState”的原始動(dòng)畫剪輯在運(yùn)行時(shí)替換成我們傳入的clip。這個(gè)操作是即時(shí)生效的。SMB實(shí)例的獲取在編輯器模式下我們可以通過遍歷AnimatorController.layers和stateMachine.states來找到特定的SMB實(shí)例。但在運(yùn)行時(shí)構(gòu)建后的游戲UnityEditor命名空間是不可用的上面的CacheTemplateStateSMB方法包含了編輯器代碼無法在真機(jī)運(yùn)行。這是一個(gè)關(guān)鍵陷阱。運(yùn)行時(shí)獲取SMB的解決方案我們必須在編輯器期就建立好SMB的引用。修改AdvancedAnimationEventManager的Awake方法void Awake() { _animator GetComponentAnimator(); // ... 創(chuàng)建_overrideController ... // 方法一推薦通過Animator的GetBehaviour方法獲取。 // 前提需要先進(jìn)入一次該狀態(tài)讓Animator實(shí)例化SMB。 // 我們可以在Start時(shí)播放一個(gè)極短的空白動(dòng)畫來“激活”模板狀態(tài)。 StartCoroutine(CacheSMBAtStart()); } System.Collections.IEnumerator CacheSMBAtStart() { // 記錄當(dāng)前狀態(tài)以便恢復(fù) AnimatorStateInfo currentState _animator.GetCurrentAnimatorStateInfo(0); // 強(qiáng)制切換到模板狀態(tài)用一個(gè)null剪輯覆蓋或者一個(gè)極短的空白動(dòng)畫 AnimationClip emptyClip null; // 或者 Resources.LoadAnimationClip(EmptyClip); // 注意覆蓋一個(gè)null剪輯可能導(dǎo)致錯(cuò)誤最好準(zhǔn)備一個(gè)長(zhǎng)度為0的動(dòng)畫剪輯資源。 // 這里假設(shè)我們有一個(gè)名為“Empty”的0幀動(dòng)畫剪輯。 _overrideController[TemplateState] emptyClip; // 需要確保emptyClip不為null _animator.Play(TemplateState, 0, 0f); _animator.Update(0); // 立即強(qiáng)制更新一幀Animator確保狀態(tài)切換和SMB實(shí)例化 // 現(xiàn)在可以獲取SMB了 // 獲取當(dāng)前狀態(tài)應(yīng)該是TemplateState的所有SMB StateMachineBehaviour[] behaviours _animator.GetBehavioursAnimationCompleteListenerSMB(); if (behaviours ! null behaviours.Length 0) { _cachedSMB behaviours[0]; // 假設(shè)只有一個(gè) Debug.Log(Successfully cached SMB instance at runtime.); } else { Debug.LogError(Could not find AnimationCompleteListenerSMB at runtime.); } // 恢復(fù)之前的動(dòng)畫狀態(tài)如果需要 // _animator.Play(currentState.fullPathHash, 0, currentState.normalizedTime); // 更簡(jiǎn)單的做法如果初始狀態(tài)就是TemplateState且為空可以忽略恢復(fù)。 yield return null; }回調(diào)管理上面的簡(jiǎn)單方案直接替換了_cachedSMB.OnAnimationComplete委托。這意味著同一時(shí)間只能有一個(gè)動(dòng)畫監(jiān)聽生效。對(duì)于大多數(shù)角色動(dòng)作序列一個(gè)接一個(gè)播放這足夠了。如果你需要并行監(jiān)聽多個(gè)狀態(tài)例如在不同動(dòng)畫層則需要為每個(gè)需要監(jiān)聽的狀態(tài)創(chuàng)建獨(dú)立的模板狀態(tài)和SMB或者改造SMB內(nèi)部結(jié)構(gòu)使其支持一個(gè)委托字典以動(dòng)畫狀態(tài)哈希或剪輯名為Key。4. 實(shí)操過程與核心環(huán)節(jié)實(shí)現(xiàn)讓我們通過一個(gè)具體的角色攻擊案例串聯(lián)起整個(gè)流程。4.1 準(zhǔn)備階段資源與場(chǎng)景搭建模型與動(dòng)畫準(zhǔn)備一個(gè)帶有Animator組件的角色模型例如Player。準(zhǔn)備兩個(gè)動(dòng)畫剪輯Attack01.anim單次攻擊動(dòng)畫長(zhǎng)度約1秒和Idle.anim循環(huán)待機(jī)動(dòng)畫。創(chuàng)建基礎(chǔ)控制器按照3.2節(jié)步驟創(chuàng)建BaseAnimatorController包含TemplateState并掛載AnimationCompleteListenerSMB。配置角色將BaseAnimatorController拖拽到角色Player的Animator組件的Controller屬性上。添加管理器為Player游戲?qū)ο筇砑覣dvancedAnimationEventManager腳本。4.2 編寫業(yè)務(wù)邏輯腳本創(chuàng)建一個(gè)控制玩家攻擊的腳本PlayerCombat。using UnityEngine; public class PlayerCombat : MonoBehaviour { public AnimationClip attackAnimationClip; // 在Inspector中拖入Attack01.anim private AdvancedAnimationEventManager _animEventManager; private bool _isAttacking false; void Start() { _animEventManager GetComponentAdvancedAnimationEventManager(); if (_animEventManager null) { Debug.LogError(AdvancedAnimationEventManager not found!); } } void Update() { if (Input.GetKeyDown(KeyCode.Space) !_isAttacking) { PerformAttack(); } } void PerformAttack() { _isAttacking true; Debug.Log(Attack started at frame: Time.frameCount); // 注冊(cè)攻擊動(dòng)畫結(jié)束監(jiān)聽并直接傳入處理方法 _animEventManager.PlayAnimationWithCallback( clip: attackAnimationClip, onComplete: () { // 這個(gè)委托方法會(huì)在動(dòng)畫播放到閾值幀時(shí)由SMB在同一幀內(nèi)直接調(diào)用 OnAttackAnimationCompleted(); }, completionThreshold: 0.999f, triggerOnLoop: false // 攻擊動(dòng)畫是單次播放 ); // 這里可以同時(shí)觸發(fā)攻擊判定的開始例如在動(dòng)畫的某一幀用另一個(gè)機(jī)制 // 例如可以使用另一個(gè)SMB或者Animation Event來觸發(fā)攻擊判定的開始幀。 } void OnAttackAnimationCompleted() { Debug.Log(Attack animation COMPLETED (Zero Delay) at frame: Time.frameCount); _isAttacking false; // 這里可以處理攻擊結(jié)束后的邏輯如 // - 重置連擊計(jì)數(shù)器 // - 允許輸入下一個(gè)指令 // - 播放收刀特效或音效 // - 強(qiáng)制切換回Idle狀態(tài)如果Animator沒有自動(dòng)Transition } // 可選一個(gè)在攻擊動(dòng)畫中途如舉到最高點(diǎn)時(shí)觸發(fā)傷害判定的方法 // 這個(gè)方法可以通過在Attack動(dòng)畫剪輯上添加一個(gè)Animation Event并指向它。 public void OnAttackHitFrame() { Debug.Log(Hit frame triggered at frame: Time.frameCount); // 執(zhí)行傷害計(jì)算、生成碰撞體、播放命中特效等 } }4.3 運(yùn)行測(cè)試與幀精度驗(yàn)證運(yùn)行游戲按下空格鍵。觀察Console輸出Attack started at frame: 150 Attack animation COMPLETED (Zero Delay) at frame: 167計(jì)算幀差167 - 150 17幀。如果你的Attack01動(dòng)畫正好是1秒在60FPS下就是60幀。但這里只有17幀是因?yàn)槲覀兪菑膭?dòng)畫的第0幀開始監(jiān)聽的而PerformAttack方法里調(diào)用PlayAnimationWithCallback時(shí)動(dòng)畫可能還沒立刻開始播放Animator需要一幀來切換狀態(tài)。更精確的測(cè)試是在SMB的OnStateEnter中也打印日志計(jì)算從OnStateEnter到OnAnimationComplete的幀數(shù)。這個(gè)幀數(shù)應(yīng)該精確等于動(dòng)畫剪輯的幀數(shù)考慮閾值提前了一點(diǎn)可能少1幀。這才是真正的“零延遲”——在動(dòng)畫系統(tǒng)判定結(jié)束的同一幀你的業(yè)務(wù)邏輯就被執(zhí)行了沒有等待到下一個(gè)Update。對(duì)比傳統(tǒng)Update輪詢法你可能會(huì)看到“完成”日志在168幀才打印這就是那1幀的延遲被消除了。5. 常見問題與排查技巧實(shí)錄即使方案正確在實(shí)際集成中也可能遇到各種問題。下面是我踩過坑后總結(jié)的排查清單。5.1 監(jiān)聽完全不觸發(fā)檢查1動(dòng)畫剪輯是否成功覆蓋現(xiàn)象播放的動(dòng)畫不是你傳入的那個(gè)。排查在RegisterAnimationCompleteListener方法中覆蓋剪輯后打印_overrideController[TemplateState].name看是否是預(yù)期的剪輯名。確保傳入的AnimationClip引用不為空且是有效的動(dòng)畫剪輯不是模型文件。檢查2SMB實(shí)例是否成功獲取現(xiàn)象_cachedSMB始終為null。排查確保CacheSMBAtStart協(xié)程被執(zhí)行。檢查emptyClip是否有效。在CacheSMBAtStart中在調(diào)用_animator.GetBehavioursAnimationCompleteListenerSMB()后打印behaviours.Length。如果為0說明Animator沒有找到該類型的SMB。確認(rèn)基礎(chǔ)控制器中的狀態(tài)名是否是“TemplateState”大小寫敏感。AnimationCompleteListenerSMB腳本是否確實(shí)掛在了那個(gè)狀態(tài)上。運(yùn)行時(shí)Animator的當(dāng)前控制器是否已經(jīng)成功被替換為AnimatorOverrideController。檢查3閾值是否設(shè)置不當(dāng)現(xiàn)象動(dòng)畫播放完了但沒觸發(fā)。排查在SMB的OnStateUpdate中打印normalizedTime的值。觀察它最終能達(dá)到的最大值。對(duì)于某些非循環(huán)動(dòng)畫如果其Wrap Mode設(shè)置有問題normalizedTime可能無法達(dá)到1。嘗試將_completionThreshold暫時(shí)調(diào)低到0.99或0.95進(jìn)行測(cè)試。5.2 監(jiān)聽觸發(fā)過早或多次觸發(fā)檢查1閾值過低現(xiàn)象動(dòng)畫還沒播完就觸發(fā)了。排查_completionThreshold設(shè)置過低如0.9。對(duì)于1秒60幀的動(dòng)畫0.9意味著在0.9秒54幀就觸發(fā)了。根據(jù)動(dòng)畫長(zhǎng)度調(diào)整閾值通常0.999對(duì)于30-60FPS動(dòng)畫是安全的。檢查2循環(huán)動(dòng)畫與_triggerOnLoop邏輯現(xiàn)象循環(huán)動(dòng)畫觸發(fā)了無數(shù)次。排查如果你不想讓循環(huán)動(dòng)畫如Idle觸發(fā)結(jié)束事件請(qǐng)確保_triggerOnLoop為false。同時(shí)檢查OnStateUpdate中關(guān)于循環(huán)點(diǎn)的判斷邏輯Mathf.Approximately(normalizedTime, 0f)是否過于敏感。可以加入一個(gè)時(shí)間間隔限制比如上次觸發(fā)后至少過去0.1秒才允許再次觸發(fā)。檢查3_hasTriggered標(biāo)志位未正確重置現(xiàn)象同一個(gè)動(dòng)畫第一次播放正常觸發(fā)第二次播放不觸發(fā)。排查確保OnStateEnter中正確地將_hasTriggered重置為false。如果動(dòng)畫是被打斷后又重新進(jìn)入同個(gè)狀態(tài)OnStateEnter會(huì)被再次調(diào)用標(biāo)志位應(yīng)該重置。5.3 性能與設(shè)計(jì)考量問題每個(gè)需要監(jiān)聽的動(dòng)畫都需要一個(gè)模板狀態(tài)嗎解答不一定。一個(gè)模板狀態(tài)配合AnimatorOverrideController可以服務(wù)無數(shù)個(gè)動(dòng)畫剪輯。但如果你需要同時(shí)監(jiān)聽多個(gè)不同的動(dòng)畫狀態(tài)例如基礎(chǔ)攻擊和特殊攻擊在兩個(gè)不同的狀態(tài)節(jié)點(diǎn)且可能同時(shí)處于播放中那么你需要為每個(gè)需要獨(dú)立監(jiān)聽的狀態(tài)創(chuàng)建獨(dú)立的模板狀態(tài)和SMB實(shí)例。你可以復(fù)制TemplateState命名為TemplateState_Attack,TemplateState_Skill等并分別掛載SMB。然后在管理器中根據(jù)動(dòng)畫類型覆蓋到不同的狀態(tài)名上并管理不同的SMB引用。問題SMB是每個(gè)Animator實(shí)例一份還是共享的解答StateMachineBehaviour是每個(gè)Animator實(shí)例一份。即使多個(gè)游戲?qū)ο笫褂猛粋€(gè)AnimatorController資源它們各自的Animator組件都會(huì)實(shí)例化出一份獨(dú)立的SMB。這意味著你可以在每個(gè)角色實(shí)例上獨(dú)立配置SMB的參數(shù)如閾值而不會(huì)相互干擾。這也是我們能通過_animator.GetBehaviours獲取到當(dāng)前實(shí)例SMB的原因。問題這個(gè)方案對(duì)性能影響大嗎解答極小。OnStateUpdate是Unity動(dòng)畫系統(tǒng)固有的回調(diào)增加一個(gè)浮點(diǎn)數(shù)比較和布爾判斷的開銷可以忽略不計(jì)。相比在幾十個(gè)MonoBehaviour的Update里輪詢Animator.GetCurrentAnimatorStateInfo這種集中式的、僅在相關(guān)狀態(tài)激活時(shí)執(zhí)行的監(jiān)聽性能更優(yōu)。委托調(diào)用也是高效的。5.4 高級(jí)技巧與Timeline或動(dòng)畫混合樹配合Timeline如果你使用Timeline控制動(dòng)畫本方案依然有效。你可以在Timeline中控制Animator播放某個(gè)狀態(tài)只要那個(gè)狀態(tài)是我們?cè)O(shè)置好的模板狀態(tài)并且通過AnimatorOverrideController覆蓋了正確的剪輯監(jiān)聽就能工作。注意Timeline可能會(huì)控制動(dòng)畫的播放速度、權(quán)重這可能會(huì)影響normalizedTime的進(jìn)度閾值可能需要微調(diào)。Blend Tree混合樹如果動(dòng)畫是通過Blend Tree播放的情況變得復(fù)雜。因?yàn)锽lend Tree本身是一個(gè)狀態(tài)它混合多個(gè)子剪輯。OnStateUpdate中獲取到的stateInfo對(duì)應(yīng)的是Blend Tree這個(gè)狀態(tài)其normalizedTime可能沒有明確意義。此時(shí)監(jiān)聽Blend Tree的“結(jié)束”通常不是好主意。你應(yīng)該監(jiān)聽的是從Blend Tree狀態(tài)退出到其他狀態(tài)的TransitionOnStateExit或者為Blend Tree中的每個(gè)子剪輯單獨(dú)創(chuàng)建狀態(tài)機(jī)并配置監(jiān)聽如果邏輯需要。最后我個(gè)人在大型項(xiàng)目中使用這套方案的經(jīng)驗(yàn)是它為動(dòng)畫和游戲邏輯的同步提供了近乎完美的解決方案。最大的優(yōu)勢(shì)在于確定性你再也不用擔(dān)心因?yàn)槠脚_(tái)幀率波動(dòng)、設(shè)備性能差異導(dǎo)致動(dòng)畫結(jié)束事件晚觸發(fā)幾幀從而影響連招判定或QTE節(jié)奏。將這套系統(tǒng)封裝成工具箱后策劃和動(dòng)畫師也能更直觀地配置動(dòng)畫事件不再需要頻繁打開動(dòng)畫文件去拖拽Animation Event整個(gè)工作流都順暢了許多。記住好的手感往往就藏在這看似微不足道的“零延遲”之中。

相關(guān)新聞

Windows時(shí)間管理神器Tai:終極指南教你輕松追蹤每一分鐘

Windows時(shí)間管理神器Tai:終極指南教你輕松追蹤每一分鐘

Windows時(shí)間管理神器Tai:終極指南教你輕松追蹤每一分鐘 【免費(fèi)下載鏈接】Tai 👻 在Windows上統(tǒng)計(jì)軟件使用時(shí)長(zhǎng)和網(wǎng)站瀏覽時(shí)長(zhǎng) 項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ta/Tai 你是否經(jīng)常感到一天結(jié)束時(shí),時(shí)間不知去向&#xff…

2026/8/2 20:27:04 閱讀更多
nginx的安裝與部署

nginx的安裝與部署

nginx的安裝與部署1、下載源碼包2、修改配置文件3、配置開機(jī)自啟動(dòng)3、驗(yàn)證1、下載源碼包 #下載最新穩(wěn)定版(替換版本號(hào)) wget https://nginx.org/download/nginx-1.28.3.tar.gz#解壓 tar -zxvf nginx-1.28.3.tar.gz cd nginx-1.28.3#安裝依賴 #Ubuntu …

2026/8/2 20:17:04 閱讀更多
線段與同級(jí)別分解:構(gòu)建市場(chǎng)走勢(shì)的標(biāo)準(zhǔn)化分析框架

線段與同級(jí)別分解:構(gòu)建市場(chǎng)走勢(shì)的標(biāo)準(zhǔn)化分析框架

1. 項(xiàng)目概述:線段與同級(jí)別分解的核心邏輯 在技術(shù)分析的實(shí)戰(zhàn)中,我們常常會(huì)遇到一個(gè)難題:市場(chǎng)走勢(shì)紛繁復(fù)雜,如何從看似無序的波動(dòng)中,提煉出清晰、客觀、可操作的結(jié)構(gòu)?這正是“線段”與“同級(jí)別分解”這兩個(gè)概…

2026/8/2 20:17:04 閱讀更多
Unity中LaTeX數(shù)學(xué)公式渲染:TEXDraw插件原理與實(shí)戰(zhàn)應(yīng)用

Unity中LaTeX數(shù)學(xué)公式渲染:TEXDraw插件原理與實(shí)戰(zhàn)應(yīng)用

1. 項(xiàng)目概述:當(dāng)數(shù)學(xué)公式遇見Unity游戲世界在開發(fā)教育類游戲、科學(xué)仿真軟件或者任何需要展示復(fù)雜數(shù)學(xué)、物理公式的Unity項(xiàng)目時(shí),一個(gè)繞不開的難題就是:如何優(yōu)雅、準(zhǔn)確地將那些在學(xué)術(shù)論文中常見的LaTeX公式,搬到游戲畫面里&#xff1…

2026/8/2 21:47:12 閱讀更多
Unity異步協(xié)程HTTP請(qǐng)求:避免主線程阻塞的實(shí)戰(zhàn)方案

Unity異步協(xié)程HTTP請(qǐng)求:避免主線程阻塞的實(shí)戰(zhàn)方案

1. 項(xiàng)目概述:為什么要在Unity里“馴服”HTTP請(qǐng)求? 在Unity里做網(wǎng)絡(luò)請(qǐng)求,尤其是HTTP請(qǐng)求,幾乎是每個(gè)項(xiàng)目都繞不開的坎。無論是從服務(wù)器拉取配置表、提交玩家分?jǐn)?shù),還是下載資源、與后端API交互,都離不開它。但…

2026/8/2 21:47:12 閱讀更多
【企業(yè)級(jí)AI寫作SOP】:基于237個(gè)真實(shí)項(xiàng)目驗(yàn)證的大綱顆粒度標(biāo)準(zhǔn)與文章一致性保障協(xié)議

【企業(yè)級(jí)AI寫作SOP】:基于237個(gè)真實(shí)項(xiàng)目驗(yàn)證的大綱顆粒度標(biāo)準(zhǔn)與文章一致性保障協(xié)議

更多請(qǐng)點(diǎn)擊: https://codechina.net 第一章:【企業(yè)級(jí)AI寫作SOP】:基于237個(gè)真實(shí)項(xiàng)目驗(yàn)證的大綱顆粒度標(biāo)準(zhǔn)與文章一致性保障協(xié)議 企業(yè)級(jí)AI寫作并非簡(jiǎn)單調(diào)用大模型生成文本,而是以可復(fù)現(xiàn)、可審計(jì)、可規(guī)?;桓稙榍疤岬南到y(tǒng)性工程?!?/p>

2026/8/2 21:47:12 閱讀更多
UC3844、UC3845、UC2844、UC2845操作說明

UC3844、UC3845、UC2844、UC2845操作說明

UC3844、UC3845、UC2844、UC2845操作說明 UC3844、UC3845系列是高性能、固定頻率、電流模式控制器。它們專為離線和直流-直流轉(zhuǎn)換器應(yīng)用而設(shè)計(jì),為設(shè)計(jì)人員提供了一種成本效益高且外部元件最少的解決方案。圖16展示了一個(gè)代表性的框圖。 振蕩器 振蕩器頻率由為定時(shí)元…

2026/8/2 21:47:12 閱讀更多
MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案

MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案

MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案 【免費(fèi)下載鏈接】MoneyPrinterPlus AI一鍵批量生成各類短視頻,自動(dòng)批量混剪短視頻,自動(dòng)把視頻發(fā)布到抖音,快手,小紅書,視頻號(hào)上,賺錢從來沒有這么容易過! 支持本地語(yǔ)音模型chatTTS,fasterwhisper,…

2026/8/2 0:04:00 閱讀更多
3分鐘搞定!QQ空間歷史說說完整備份終極指南

3分鐘搞定!QQ空間歷史說說完整備份終極指南

3分鐘搞定!QQ空間歷史說說完整備份終極指南 【免費(fèi)下載鏈接】GetQzonehistory 獲取QQ空間發(fā)布的歷史說說 項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 你是否曾想過,那些年發(fā)過的QQ空間說說,那些記錄青春的文字…

2026/8/2 0:04:01 閱讀更多
MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案

MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案

MoneyPrinterPlus實(shí)戰(zhàn)指南:AI視頻批量生成與自動(dòng)化發(fā)布完整解決方案 【免費(fèi)下載鏈接】MoneyPrinterPlus AI一鍵批量生成各類短視頻,自動(dòng)批量混剪短視頻,自動(dòng)把視頻發(fā)布到抖音,快手,小紅書,視頻號(hào)上,賺錢從來沒有這么容易過! 支持本地語(yǔ)音模型chatTTS,fasterwhisper,…

2026/8/2 0:04:00 閱讀更多
3分鐘搞定!QQ空間歷史說說完整備份終極指南

3分鐘搞定!QQ空間歷史說說完整備份終極指南

3分鐘搞定!QQ空間歷史說說完整備份終極指南 【免費(fèi)下載鏈接】GetQzonehistory 獲取QQ空間發(fā)布的歷史說說 項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 你是否曾想過,那些年發(fā)過的QQ空間說說,那些記錄青春的文字…

2026/8/2 0:04:01 閱讀更多
AMAT 0100-02186 I/O 分配 PCB

AMAT 0100-02186 I/O 分配 PCB

AMAT 0100-02186 I/O分配PCB板是應(yīng)用材料(Applied Materials)公司生產(chǎn)的一款用于半導(dǎo)體設(shè)備的I/O信號(hào)分配電路板。該型號(hào)(0100-02186)的核心特點(diǎn)如下:專用于Endura等半導(dǎo)體工藝腔室。集成信號(hào)路由與分配功能。連接控制…

2026/8/2 2:51:21 閱讀更多
Nissei Corp FFMN-32L-10-T0 40AX 三相異步電動(dòng)機(jī)

Nissei Corp FFMN-32L-10-T0 40AX 三相異步電動(dòng)機(jī)

Nissei Corp FFMN-32L-10-T0 40AX 三相異步電動(dòng)機(jī)是日本日清(Nissei)品牌的一款工業(yè)用三相異步電機(jī),適用于自動(dòng)化設(shè)備及通用機(jī)械驅(qū)動(dòng)。該型號(hào)(FFMN-32L-10-T0 40AX)的核心特點(diǎn)如下:三相交流異步電動(dòng)機(jī)。額定…

2026/8/2 2:52:49 閱讀更多